Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Assert-TaskValue([string]$Value) { if ([string]::IsNullOrWhiteSpace($Value) -or $Value.IndexOfAny([char[]]@('"', "`r", "`n")) -ge 0) { throw '任务参数不能为空或包含双引号、换行。' } } function Quote-TaskArgument([string]$Value) { Assert-TaskValue $Value # Windows路径不能包含双引号;尾反斜线加倍,避免转义原生命令行的闭引号。 return '"' + ($Value -replace '(\\+)$', '$1$1') + '"' } function Get-PayrollTaskPlan { param([string]$ProjectRoot, [string]$PythonPath, [string]$RuntimeDirectory, [string]$ConnectionFile, [string]$TaskPath, [int]$ApiPort = 8000) foreach ($value in @($ProjectRoot, $PythonPath, $RuntimeDirectory, $ConnectionFile)) { Assert-TaskValue $value } if ($TaskPath -notmatch '^\\PayrollManagementSystem(?:-Test-[0-9a-f]{8})?\\$') { throw '任务目录必须为项目专用目录。' } if ($ApiPort -lt 1024 -or $ApiPort -gt 65535) { throw '应用端口必须在1024到65535之间。' } $root = (Resolve-Path -LiteralPath $ProjectRoot).Path $python = (Resolve-Path -LiteralPath $PythonPath).Path $connection = (Resolve-Path -LiteralPath $ConnectionFile).Path if (-not (Test-Path -LiteralPath (Join-Path $root 'backend/app/cli.py') -PathType Leaf)) { throw '项目根目录缺少后端CLI。' } if ((Get-Item -LiteralPath $connection).Length -gt 16384) { throw '只读连接文件异常。' } $runtime = [IO.Path]::GetFullPath($RuntimeDirectory) $launcher = Join-Path $PSScriptRoot 'Run-PayrollHost.ps1' $shell = Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe' $tasks = foreach ($role in @('backend', 'worker', 'maintenance')) { $arguments = '-NoProfile -NonInteractive -File ' + (Quote-TaskArgument $launcher) + ' -Role ' + $role + ' -ProjectRoot ' + (Quote-TaskArgument $root) + ' -PythonPath ' + (Quote-TaskArgument $python) + ' -RuntimeDirectory ' + (Quote-TaskArgument $runtime) + ' -ConnectionFile ' + (Quote-TaskArgument $connection) + ' -ApiPort ' + $ApiPort [pscustomobject]@{ Name = 'Payroll-' + $role; Role = $role; Execute = $shell; Arguments = $arguments; WorkingDirectory = (Join-Path $root 'backend'); Trigger = $(if ($role -eq 'maintenance') { 'StartupAndEveryMinute' } else { 'Startup' }) } } return [pscustomobject]@{ Version = 1; ProjectRoot = $root; PythonPath = $python; RuntimeDirectory = $runtime; ConnectionFile = $connection; TaskPath = $TaskPath; StopFile = (Join-Path $runtime 'stop.requested'); ApiPort = $ApiPort; Tasks = @($tasks); EnabledOnInstall = $false; MultipleInstances = 'IgnoreNew'; RestartCount = 3; RestartInterval = 'PT1M'; ExecutionTimeLimit = 'PT0S' } } function Get-PayrollTaskSettings { $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 3 ` -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable $settings.Enabled = $false return $settings } function Resolve-PayrollAccountSid([string]$Account) { if ($Account -match '^S-1-\d+(?:-\d+)+$') { return ([Security.Principal.SecurityIdentifier]$Account).Value } return ([Security.Principal.NTAccount]$Account).Translate([Security.Principal.SecurityIdentifier]).Value } function Assert-PayrollAccessRules { param($Rules, [string[]]$TrustedSids, [bool]$PrivateFile = $false, [switch]$Ancestor, [string]$Label) $writeMask = [Security.AccessControl.FileSystemRights]::Write -bor [Security.AccessControl.FileSystemRights]::Delete -bor [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [Security.AccessControl.FileSystemRights]::ChangePermissions -bor [Security.AccessControl.FileSystemRights]::TakeOwnership if ($Ancestor) { # 上层目录允许创建其他名称;已有项目可被删除/替换或改ACL才构成越界。 $writeMask = [Security.AccessControl.FileSystemRights]::Delete -bor [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [Security.AccessControl.FileSystemRights]::ChangePermissions -bor [Security.AccessControl.FileSystemRights]::TakeOwnership } foreach ($rule in $Rules) { if ($rule.AccessControlType -ne 'Allow' -or ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly)) { continue } $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value if ($TrustedSids -contains $sid) { continue } if (($rule.FileSystemRights -band $writeMask) -or ($PrivateFile -and ($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::ReadData))) { throw ('权限预检查不通过:' + $Label + '。存在非服务账号/管理员主体的写权限或连接文件读取权限,请管理员按部署说明核对ACL。') } } } function Assert-PayrollHostAccess { param($Plan, [string]$ServiceSid) $trusted = @('S-1-5-18', 'S-1-5-32-544', $ServiceSid) $identity = [Security.Principal.WindowsIdentity]::GetCurrent() # 独立的管理员账号可以部署;普通安装者不因运行本脚本自动成为可信主体。 if (@($identity.Groups | Where-Object { $_.Value -eq 'S-1-5-32-544' }).Count) { $trusted += $identity.User.Value } $targets = @($Plan.ProjectRoot, (Join-Path $Plan.ProjectRoot 'backend'), (Join-Path $Plan.ProjectRoot 'backend/app'), (Join-Path $Plan.ProjectRoot 'backend/app/cli.py'), $PSScriptRoot, (Join-Path $PSScriptRoot 'Run-PayrollHost.ps1'), (Join-Path $PSScriptRoot 'TaskHost.Common.ps1'), $Plan.PythonPath, (Split-Path -Parent $Plan.PythonPath), $Plan.RuntimeDirectory, $Plan.ConnectionFile) $environmentFile = Join-Path $Plan.ProjectRoot 'backend/.env' if (Test-Path -LiteralPath $environmentFile) { $targets += $environmentFile } foreach ($target in @($targets | Select-Object -Unique)) { if (-not (Test-Path -LiteralPath $target)) { throw ('权限预检查要求目录已由管理员准备:' + $target) } $acl = Get-Acl -LiteralPath $target Assert-PayrollAccessRules -Rules $acl.Access -TrustedSids $trusted ` -PrivateFile ($target -eq $Plan.ConnectionFile -or $target -eq $environmentFile) -Label $target $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value if ($trusted -notcontains $ownerSid) { throw ('权限预检查不通过,所有者不是可信部署主体:' + $target) } # 防止通过父目录的替换权限绕过文件本身ACL。逐级检查至磁盘根。 $parent = Split-Path -Parent $target while ($parent) { Assert-PayrollAccessRules -Rules (Get-Acl -LiteralPath $parent).Access -TrustedSids $trusted -Ancestor -Label $parent $next = Split-Path -Parent $parent if ($next -eq $parent) { break } $parent = $next } } } function Assert-PayrollTaskPrincipal { param($Actual, $Expected) if ((Resolve-PayrollAccountSid $Actual.UserId) -ne $Expected.ServiceSid -or [string]$Actual.LogonType -ne $Expected.LogonType -or [string]$Actual.RunLevel -ne 'Limited') { throw '任务运行账号或登录/提权方式与安装清单不一致,拒绝管理。' } }