补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
120 lines
7.4 KiB
PowerShell
120 lines
7.4 KiB
PowerShell
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 '任务运行账号或登录/提权方式与安装清单不一致,拒绝管理。'
|
||
}
|
||
}
|