[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "High")] param( [ValidatePattern("^[A-Za-z0-9.-]+$")] [string]$ServerAddress = "185.216.248.75", [ValidateRange(1, 65535)] [int]$SSHPort = 21638, [ValidatePattern("^[A-Za-z0-9_.-]+$")] [string]$SSHUser = "root", [string]$IdentityFile = "", [string]$Commit = "HEAD", [ValidatePattern("^https://[A-Za-z0-9.-]+(?::[0-9]{1,5})?$")] [string]$PublicBaseUrl = "https://buy.833729.com", [switch]$AllowSchemaMigration ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Assert-CommandExists { param([Parameter(Mandatory = $true)][string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "缺少命令 $Name,请先安装并加入 PATH。" } } function Invoke-CheckedNative { param( [Parameter(Mandatory = $true)][string]$FilePath, [Parameter(Mandatory = $true)][string[]]$Arguments, [string]$WorkingDirectory = "", [string]$FailureMessage = "命令执行失败" ) if ($WorkingDirectory) { Push-Location -LiteralPath $WorkingDirectory } try { & $FilePath @Arguments if ($LASTEXITCODE -ne 0) { throw "$FailureMessage(退出码 $LASTEXITCODE)" } } finally { if ($WorkingDirectory) { Pop-Location } } } function Get-CheckedNativeOutput { param( [Parameter(Mandatory = $true)][string]$FilePath, [Parameter(Mandatory = $true)][string[]]$Arguments, [string]$WorkingDirectory = "", [string]$FailureMessage = "命令执行失败" ) if ($WorkingDirectory) { Push-Location -LiteralPath $WorkingDirectory } try { $output = & $FilePath @Arguments 2>&1 if ($LASTEXITCODE -ne 0) { throw "$FailureMessage(退出码 $LASTEXITCODE):$($output | Out-String)" } return (($output | Out-String).Trim()) } finally { if ($WorkingDirectory) { Pop-Location } } } function Remove-SafeBuildDirectory { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return } $resolvedPath = [System.IO.Path]::GetFullPath($Path) $temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) $leafName = Split-Path -Leaf $resolvedPath if (-not $resolvedPath.StartsWith($temporaryRoot, [System.StringComparison]::OrdinalIgnoreCase) -or -not $leafName.StartsWith("cmautobuy-deploy-", [System.StringComparison]::OrdinalIgnoreCase)) { throw "拒绝清理不受信任的临时目录:$resolvedPath" } Remove-Item -LiteralPath $resolvedPath -Recurse -Force } foreach ($commandName in @("git", "go", "tar", "ssh", "scp")) { Assert-CommandExists -Name $commandName } $repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) $actualRoot = Get-CheckedNativeOutput -FilePath "git" -Arguments @("rev-parse", "--show-toplevel") ` -WorkingDirectory $repositoryRoot -FailureMessage "当前目录不是 Git 仓库" if ([System.IO.Path]::GetFullPath($actualRoot) -ne $repositoryRoot) { throw "脚本必须位于 cmautobuy 仓库的 admin/deploy 目录。" } $fullCommit = Get-CheckedNativeOutput -FilePath "git" -Arguments @("rev-parse", "$Commit`^{commit}") ` -WorkingDirectory $repositoryRoot -FailureMessage "无法解析提交 $Commit" if ($fullCommit -notmatch "^[0-9a-f]{40}$") { throw "Git 返回了无效提交哈希:$fullCommit" } $releaseID = $fullCommit.Substring(0, 7) $worktreeState = Get-CheckedNativeOutput -FilePath "git" -Arguments @("status", "--porcelain") ` -WorkingDirectory $repositoryRoot -FailureMessage "无法检查工作区状态" if ($worktreeState) { Write-Warning "工作区存在未提交文件;本次只从固定提交 $releaseID 构建,这些文件不会进入发布物。" } if ($IdentityFile) { if (-not (Test-Path -LiteralPath $IdentityFile -PathType Leaf)) { throw "SSH 私钥不存在:$IdentityFile" } $IdentityFile = (Resolve-Path -LiteralPath $IdentityFile).Path } else { $defaultIdentity = Join-Path $env:USERPROFILE ".ssh\vps_ai_deploy" if (Test-Path -LiteralPath $defaultIdentity -PathType Leaf) { $IdentityFile = (Resolve-Path -LiteralPath $defaultIdentity).Path } } $targetDescription = "$SSHUser@$ServerAddress`:$SSHPort" $migrationText = if ($AllowSchemaMigration) { "允许备份后执行 schema 升级" } else { "禁止 schema 升级" } if (-not $PSCmdlet.ShouldProcess( $targetDescription, "从固定提交 $releaseID 构建并部署 Admin($migrationText)" )) { return } $temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("cmautobuy-deploy-" + [guid]::NewGuid().ToString("N")) $remoteNonce = [guid]::NewGuid().ToString("N") $remoteBinary = "/tmp/cmautobuy-deploy-$remoteNonce.bin" $remoteRunner = "/tmp/cmautobuy-deploy-$remoteNonce.sh" $remoteCleanupNeeded = $false $sshArguments = @( "-p", $SSHPort.ToString(), "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=15" ) $scpArguments = @( "-P", $SSHPort.ToString(), "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=15" ) if ($IdentityFile) { $sshArguments += @("-i", $IdentityFile) $scpArguments += @("-i", $IdentityFile) } $remoteTarget = "$SSHUser@$ServerAddress" try { New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null $sourceArchive = Join-Path $temporaryDirectory "source.tar" $sourceDirectory = Join-Path $temporaryDirectory "source" $outputDirectory = Join-Path $temporaryDirectory "output" New-Item -ItemType Directory -Path $sourceDirectory, $outputDirectory | Out-Null Write-Host "[1/7] 从固定提交 $fullCommit 导出隔离源码..." Invoke-CheckedNative -FilePath "git" -Arguments @( "archive", "--format=tar", "--output=$sourceArchive", $fullCommit ) -WorkingDirectory $repositoryRoot -FailureMessage "导出固定提交失败" Invoke-CheckedNative -FilePath "tar" -Arguments @("-xf", $sourceArchive, "-C", $sourceDirectory) ` -FailureMessage "解压固定提交失败" $mysqlSource = Join-Path $sourceDirectory "admin\repository\mysql_db.go" $mysqlText = [System.IO.File]::ReadAllText($mysqlSource) $schemaMatch = [regex]::Match($mysqlText, "const\s+mysqlSchemaVersion\s*=\s*([0-9]+)") if (-not $schemaMatch.Success) { throw "无法从固定提交读取目标 MySQL schema 版本。" } $targetSchema = [int]$schemaMatch.Groups[1].Value Write-Host "[2/7] 使用 Go 1.23.0 执行全量 test、build、vet..." $savedEnvironment = @{} foreach ($name in @("GOTOOLCHAIN", "GOOS", "GOARCH", "CGO_ENABLED")) { $savedEnvironment[$name] = [System.Environment]::GetEnvironmentVariable($name, "Process") } try { $env:GOTOOLCHAIN = "go1.23.0" $env:GOOS = "" $env:GOARCH = "" $env:CGO_ENABLED = "" Invoke-CheckedNative -FilePath "go" -Arguments @("version") -WorkingDirectory (Join-Path $sourceDirectory "admin") ` -FailureMessage "Go 1.23.0 不可用" Invoke-CheckedNative -FilePath "go" -Arguments @("test", "./...", "-count=1") ` -WorkingDirectory (Join-Path $sourceDirectory "admin") -FailureMessage "Admin 全量测试失败" Invoke-CheckedNative -FilePath "go" -Arguments @("build", "./...") ` -WorkingDirectory (Join-Path $sourceDirectory "admin") -FailureMessage "Admin 全量编译失败" Invoke-CheckedNative -FilePath "go" -Arguments @("vet", "./...") ` -WorkingDirectory (Join-Path $sourceDirectory "admin") -FailureMessage "Admin go vet 失败" Write-Host "[3/7] 构建 Linux amd64 单文件发布物..." $env:GOOS = "linux" $env:GOARCH = "amd64" $env:CGO_ENABLED = "0" $localBinary = Join-Path $outputDirectory "cmautobuy-admin" Invoke-CheckedNative -FilePath "go" -Arguments @( "build", "-trimpath", "-ldflags=-s -w", "-o", $localBinary, "." ) -WorkingDirectory (Join-Path $sourceDirectory "admin") -FailureMessage "Linux amd64 发布物构建失败" } finally { foreach ($name in $savedEnvironment.Keys) { [System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process") } } $localHash = (Get-FileHash -LiteralPath $localBinary -Algorithm SHA256).Hash.ToLowerInvariant() $localSize = (Get-Item -LiteralPath $localBinary).Length Write-Host "发布物:commit=$releaseID schema=v$targetSchema size=$localSize sha256=$localHash" $remoteScriptSource = Join-Path $PSScriptRoot "deploy-admin-remote.sh" $remoteScriptUpload = Join-Path $temporaryDirectory "deploy-admin-remote.sh" $remoteScriptText = [System.IO.File]::ReadAllText($remoteScriptSource).Replace("`r`n", "`n").Replace("`r", "`n") [System.IO.File]::WriteAllText($remoteScriptUpload, $remoteScriptText, [System.Text.UTF8Encoding]::new($false)) Write-Host "[4/7] 上传发布物和远端执行器..." $remoteCleanupNeeded = $true Invoke-CheckedNative -FilePath "scp" -Arguments ($scpArguments + @( $localBinary, "${remoteTarget}:$remoteBinary" )) -FailureMessage "上传 Admin 发布物失败;请检查 SSH 密钥或 ssh-agent" Invoke-CheckedNative -FilePath "scp" -Arguments ($scpArguments + @( $remoteScriptUpload, "${remoteTarget}:$remoteRunner" )) -FailureMessage "上传远端部署执行器失败" Write-Host "[5/7] 由服务器执行备份、独立端口预检和原子切换..." $allowMigrationValue = if ($AllowSchemaMigration) { "1" } else { "0" } $remoteCommand = "chmod 0700 $remoteRunner && bash $remoteRunner $releaseID $localHash $targetSchema $allowMigrationValue $PublicBaseUrl $remoteBinary" Invoke-CheckedNative -FilePath "ssh" -Arguments ($sshArguments + @($remoteTarget, $remoteCommand)) ` -FailureMessage "服务器部署失败;远端执行器已在必要时尝试恢复上一版本" Write-Host "[6/7] 服务器部署和健康检查已通过。" Write-Host "[7/7] 完成:$PublicBaseUrl 已切换到提交 $releaseID。" } finally { if ($remoteCleanupNeeded) { & ssh @sshArguments $remoteTarget "rm -f -- $remoteBinary $remoteRunner" 2>$null if ($LASTEXITCODE -ne 0) { Write-Warning "未能清理远端临时上传文件;路径为 $remoteBinary 和 $remoteRunner。" } } Remove-SafeBuildDirectory -Path $temporaryDirectory }