| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- [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 { '首次部署,无旧版本' })"
- }
|