[CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)] [string]$PublishPath, [Parameter(Mandatory = $true)] [string]$ConfigurationPath, [Parameter(Mandatory = $true)] [string]$SiteName, [ValidatePattern('^[A-Za-z0-9_-]+$')] [string]$ApplicationName = 'xiaoshu-sync', [string]$ApplicationPoolName = 'XiaoshuLegacySync', [string]$PhysicalPath = 'C:\inetpub\xiaoshu-sync', [Parameter(Mandatory = $true)] [string]$HealthUrl, [switch]$PreflightOnly ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Assert-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw '请使用“以管理员身份运行”的 PowerShell 执行部署。' } } function Assert-Configuration([string]$Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "配置文件不存在:$Path" } $raw = Get-Content -LiteralPath $Path -Raw $config = $raw | ConvertFrom-Json $connection = [string]$config.ConnectionStrings.LegacySqlServer $keyId = [string]$config.XiaoshuSync.KeyId $secret = [string]$config.XiaoshuSync.Secret $allowedIps = @($config.XiaoshuSync.AllowedIps) if ([string]::IsNullOrWhiteSpace($connection) -or $connection -like 'REPLACE_*') { throw 'LegacySqlServer 连接串尚未配置。' } if ([string]::IsNullOrWhiteSpace($keyId) -or $keyId -like 'REPLACE_*') { throw '同步桥 KeyId 尚未配置。' } if ($secret.Length -lt 32 -or $secret -like 'REPLACE_*') { throw '同步桥 Secret 必须是至少 32 位的随机字符串。' } if ($allowedIps.Count -eq 0 -or $allowedIps | Where-Object { [string]::IsNullOrWhiteSpace([string]$_) -or [string]$_ -like 'REPLACE_*' }) { throw '必须配置 Parse/云函数服务器的出口 IP 白名单。' } return $raw } Assert-Administrator Import-Module WebAdministration $resolvedPublishPath = (Resolve-Path -LiteralPath $PublishPath).Path if (-not (Test-Path -LiteralPath (Join-Path $resolvedPublishPath 'Xiaoshu.LegacySyncBridge.dll'))) { throw '发布目录缺少 Xiaoshu.LegacySyncBridge.dll。' } if (-not (Test-Path -LiteralPath (Join-Path $resolvedPublishPath 'web.config'))) { throw '发布目录缺少 web.config。' } $productionConfiguration = Assert-Configuration $ConfigurationPath $configuration = $productionConfiguration | ConvertFrom-Json $configuredPathBase = [string]$configuration.XiaoshuSync.PathBase if ($configuredPathBase -ne "/$ApplicationName") { throw '配置中的 PathBase 必须与 IIS 应用路径一致。' } if (-not (Get-WebGlobalModule -Name 'AspNetCoreModuleV2' -ErrorAction SilentlyContinue)) { throw '服务器尚未安装 IIS AspNetCoreModuleV2。同步桥为 Windows x64 自包含发布,不要求服务器安装 .NET 8 Runtime,但仍需要该 IIS 模块。' } if (-not (Test-Path "IIS:\Sites\$SiteName")) { throw "IIS 站点不存在:$SiteName" } $healthUri = [Uri]$HealthUrl if (-not $healthUri.IsAbsoluteUri -or $healthUri.Scheme -notin @('http', 'https') -or $healthUri.AbsolutePath -ne "/$ApplicationName/v1/health" -or $healthUri.Query -or $healthUri.Fragment -or $healthUri.UserInfo) { throw 'HealthUrl 必须是目标站点的完整 HTTP(S) 健康地址,路径为 /应用名/v1/health。' } $matchingBindings = @(Get-WebBinding -Name $SiteName | Where-Object { $parts = ([string]$_.bindingInformation) -split ':' $address = $null $isIpAddress = [Net.IPAddress]::TryParse($healthUri.DnsSafeHost, [ref]$address) $hostMatches = $parts[-1] -eq $healthUri.DnsSafeHost -or ($isIpAddress -and [string]::IsNullOrEmpty($parts[-1]) -and ($parts[0] -eq '*' -or $parts[0] -eq $healthUri.DnsSafeHost)) $_.protocol -eq $healthUri.Scheme -and $parts[-2] -eq [string]$healthUri.Port -and $hostMatches }) if ($matchingBindings.Count -eq 0) { throw "健康地址的域名、端口和协议没有绑定到 IIS 站点 '$SiteName'。请指定实际承载旧站域名的站点,不能默认安装到 Default Web Site。" } $rootPool = [string](Get-Item "IIS:\Sites\$SiteName").applicationPool if ($ApplicationPoolName -eq $rootPool) { throw '同步桥必须使用独立应用池,不能使用旧站根应用的应用池。' } Write-Host '部署前检查通过:' Write-Host " IIS 站点:$SiteName" Write-Host " 应用路径:/$ApplicationName" Write-Host " 应用池:$ApplicationPoolName" Write-Host " 物理目录:$PhysicalPath" Write-Host " 健康地址:$HealthUrl" Write-Host ' 配置:连接串、HMAC KeyId/Secret 与 IP 白名单均已通过格式检查(未输出敏感值)' if ($PreflightOnly) { Write-Host 'PreflightOnly 已启用,未创建目录、应用池或 IIS 应用,也未复制任何文件。' return } $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $backupPath = "$PhysicalPath.backup-$timestamp" $applicationPath = "/$ApplicationName" if ($PSCmdlet.ShouldProcess("$SiteName$applicationPath", '部署小树旧系统同步桥')) { if (Test-Path "IIS:\AppPools\$ApplicationPoolName") { Stop-WebAppPool -Name $ApplicationPoolName -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $PhysicalPath) { Copy-Item -LiteralPath $PhysicalPath -Destination $backupPath -Recurse -Force Write-Host "已备份现有版本:$backupPath" } else { New-Item -ItemType Directory -Path $PhysicalPath -Force | Out-Null } Copy-Item -Path (Join-Path $resolvedPublishPath '*') -Destination $PhysicalPath -Recurse -Force $productionConfiguration | Set-Content -LiteralPath (Join-Path $PhysicalPath 'appsettings.Production.json') -Encoding UTF8 if (-not (Test-Path "IIS:\AppPools\$ApplicationPoolName")) { New-WebAppPool -Name $ApplicationPoolName | Out-Null } Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name managedRuntimeVersion -Value '' Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name enable32BitAppOnWin64 -Value $false Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name processModel.identityType -Value ApplicationPoolIdentity Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name startMode -Value AlwaysRunning $existingApplication = Get-WebApplication -Site $SiteName -Name $ApplicationName -ErrorAction SilentlyContinue if ($null -eq $existingApplication) { New-WebApplication -Site $SiteName -Name $ApplicationName -PhysicalPath $PhysicalPath -ApplicationPool $ApplicationPoolName | Out-Null } else { Set-ItemProperty "IIS:\Sites\$SiteName\$ApplicationName" -Name physicalPath -Value $PhysicalPath Set-ItemProperty "IIS:\Sites\$SiteName\$ApplicationName" -Name applicationPool -Value $ApplicationPoolName } & icacls.exe $PhysicalPath /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "IIS AppPool\${ApplicationPoolName}:(OI)(CI)RX" /T /C | Out-Null Start-WebAppPool -Name $ApplicationPoolName $healthy = $false foreach ($attempt in 1..12) { try { $health = Invoke-RestMethod -Uri $HealthUrl -Method Get -TimeoutSec 10 if ($health.status -eq 'ok' -and $health.service -eq 'xiaoshu-legacy-sync' -and $health.version -eq 'v1') { $healthy = $true; break } } catch { Start-Sleep -Seconds 2 } } if (-not $healthy) { throw "IIS 已部署,但健康检查没有通过:$HealthUrl。请检查事件日志与 IIS stdout 日志。" } Write-Host '同步桥部署完成,未输出任何连接串或签名密钥。' Write-Host "健康地址:$HealthUrl" Write-Host "回滚备份:$(if (Test-Path -LiteralPath $backupPath) { $backupPath } else { '首次部署,无旧版本' })" }