PayrollManagementSystem/scripts/windows/TaskHost.Common.ps1
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

120 lines
7.4 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '任务运行账号或登录/提权方式与安装清单不一致,拒绝管理。'
}
}