# ================================================================ # FLNXI素材库 - 部署脚本 v1.2 # 功能:本地构建 -> 推送到Registry -> 远程部署 # 特性:自动加载基础镜像、智能重试、镜像清理、趣味提示 # ================================================================ param( [string]$Server = "45.249.208.118", [string]$Username = "root", [SecureString]$Password = $null, [string]$KeyPath = "", [string]$ImageName = "flnxi-material", [string]$Tag = "pro-latest", [string]$RemotePath = "/root/deploy", [string]$EnvFile = "", [string]$Registry = "harbor", # 默认使用 Harbor [string]$HarborUrl = "harbor.flnxi.com", [string]$HarborProjectName = "flnxi", # Harbor 项目名称,例如: flnxi, sucai, demo, library [string]$HarborUsername = "admin", [string]$HarborPassword = "Harbor12345", [switch]$SkipCheck, [switch]$SkipBuild, [switch]$SkipUpload, [switch]$Debug, [switch]$Help, [switch]$CleanImages, [int]$KeepImageCount = 5, [switch]$CleanDockerDesktop = $true, # 默认启用清理 Docker Desktop 未使用资源 [switch]$NoCache # 禁用 Docker 构建缓存(默认使用缓存加速构建) ) # 脚本配置 Set-StrictMode -Version Latest $ErrorActionPreference = "Continue" try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 if ($PSVersionTable.PSVersion.Major -ge 6) { $PSDefaultParameterValues['*:Encoding'] = 'utf8' } } catch { # 控制台编码设置失败时忽略 } $script:StageTracker = @{ Total = 0 Current = 0 } $script:FunTips = @( "记得喝口咖啡,让部署的快乐多续一会", "今日小目标:让 CI/CD 成为最懂你的搭档", "猫咪提示:部署途中别忘了伸个懒腰", "数据正在轨道中穿梭,耐心是宇航员的必备素质", "小彩蛋:部署顺利指数 +1%,好运常伴你", "背景 BGM 同步播放中,部署节奏稳稳的", "如果日志有点长,记得深呼吸,冷静才是王牌技能", "镜像像快递一样打包上路,收件人:Production", "每一个 build 都是一次漂亮的起飞", "代码正在变身,从开发模式切换到生产模式", "FLNXI素材库正在打包,准备惊艳登场", "部署速度:光速模式已启动", "部署马戏团正在表演,请保持期待", "夜深了?没关系,部署脚本陪你到天亮", "部署完成后,记得给自己一个小奖励" ) function Initialize-StageTracker { param( [bool]$DoCheck, [bool]$DoBuild, [bool]$DoPush, [bool]$DoRemote ) $script:StageTracker.Total = @($DoCheck, $DoBuild, $DoPush, $DoRemote) | Where-Object { $_ } | Measure-Object | Select-Object -ExpandProperty Count if (-not $script:StageTracker.Total -or $script:StageTracker.Total -le 0) { $script:StageTracker.Total = 1 } $script:StageTracker.Current = 0 } function Enter-Stage { param( [string]$Title, [string]$Subtitle = "", [string]$Emoji = "[*]" ) $script:StageTracker.Current++ $current = $script:StageTracker.Current $total = $script:StageTracker.Total $progress = if ($total -gt 0) { [Math]::Min(1, [Math]::Max(0, ($current - 1) / [double]$total)) } else { 0 } $barLength = 24 $filledLength = [Math]::Floor($progress * $barLength) $bar = ("█" * $filledLength).PadRight($barLength, "░") Write-ColorOutput "" $stageMessage = $Emoji + " " + $Title + " (" + [string]$current + "/" + [string]$total + ")" Write-ColorOutput $stageMessage "Magenta" $progressPercent = [Math]::Round($progress * 100) $progressMessage = "[" + $bar + "] " + [string]$progressPercent + "%" Write-ColorOutput $progressMessage "Magenta" if ($Subtitle) { Write-Info $Subtitle } } function Get-FunTip { if (-not $script:FunTips -or $script:FunTips.Count -eq 0) { return $null } return Get-Random -InputObject $script:FunTips } # 内部函数:庆祝阶段完成(使用自定义动词以保持代码可读性) function Celebrate-Stage { param([string]$Title) $tip = Get-FunTip $completeMessage = $Title + "完成!" Write-ColorOutput $completeMessage "Green" if ($tip) { $tipMessage = " " + $tip Write-ColorOutput $tipMessage "Gray" } } function Test-RemoteHealth { param( [string]$Server, [int]$Port = 4320, [int]$MaxWaitSeconds = 45 ) $healthUrl = "http://{0}:{1}/api/health" -f $Server, $Port Write-Info "开始健康检查: $healthUrl" $elapsed = 0 $interval = 3 $ok = $false while ($elapsed -lt $MaxWaitSeconds -and -not $ok) { try { $resp = Invoke-WebRequest -Uri $healthUrl -Method GET -TimeoutSec 5 -ErrorAction Stop if ($resp.StatusCode -eq 200) { $ok = $true break } else { Write-Warning ("健康检查返回状态码 {0},稍后重试..." -f $resp.StatusCode) } } catch { Write-Info ("应用尚未完全就绪({0}s),继续等待..." -f $elapsed) } Start-Sleep -Seconds $interval $elapsed += $interval } if ($ok) { Write-Success ("健康检查通过,应用已就绪 ({0}s)" -f $elapsed) $tip = Get-FunTip if ($tip) { $healthTipMessage = "健康彩蛋: " + $tip Write-ColorOutput $healthTipMessage "Gray" } return $true } else { Write-Warning ("在 {0}s 内未能通过健康检查,请稍后手动访问 {1} 确认" -f $MaxWaitSeconds, $healthUrl) return $false } } # 检查是否需要显示帮助 if ($Help) { # 颜色输出函数 function Write-ColorOutput { param( [string]$Message, [ConsoleColor]$ForegroundColor = "White" ) Write-Host $Message -ForegroundColor $ForegroundColor } Write-ColorOutput "新建简化部署脚本" "Green" Write-ColorOutput "========================================" "Green" Write-ColorOutput "用法:" "White" Write-ColorOutput " .\deploy-new-fixed.ps1 [参数]" "Yellow" Write-ColorOutput "" "White" Write-ColorOutput "参数:" "White" Write-ColorOutput " -Server [地址] 服务器地址 (默认: 45.249.208.118)" "Gray" Write-ColorOutput " -Username [用户] SSH用户名 (默认: root)" "Gray" Write-ColorOutput " -KeyPath [路径] SSH私钥路径 (推荐)" "Gray" Write-ColorOutput " -EnvFile [路径] 指定构建期使用的环境变量文件" "Gray" Write-ColorOutput " -Registry [类型] Registry类型: default 或 harbor (默认: default)" "Gray" Write-ColorOutput " -HarborUrl [地址] Harbor地址 (默认: harbor.flnxi.com)" "Gray" Write-ColorOutput " -HarborProjectName [名称] Harbor项目名称 (默认: flnxi)" "Gray" Write-ColorOutput " -HarborUsername [用户] Harbor用户名 (默认: admin)" "Gray" Write-ColorOutput " -HarborPassword [密码] Harbor密码 (默认: Harbor12345)" "Gray" Write-ColorOutput " -SkipCheck 跳过代码检查" "Gray" Write-ColorOutput " -SkipBuild 跳过Docker构建" "Gray" Write-ColorOutput " -SkipUpload 跳过镜像推送和远程部署" "Gray" Write-ColorOutput " -CleanImages 清理旧版本镜像" "Gray" Write-ColorOutput " -KeepImageCount [N] 保留的镜像数量 (默认: 5)" "Gray" Write-ColorOutput " -CleanDockerDesktop 清理 Docker Desktop 未使用资源 (默认: 启用)" "Gray" Write-ColorOutput " -NoCache 禁用 Docker 构建缓存(默认使用缓存加速)" "Gray" Write-ColorOutput " -Debug 启用调试模式" "Gray" Write-ColorOutput " -Help 显示此帮助信息" "Gray" Write-ColorOutput "" "White" Write-ColorOutput "示例:" "White" Write-ColorOutput " .\deploy-new-fixed.ps1" "Yellow" Write-ColorOutput " .\deploy-new-fixed.ps1 -KeyPath 'C:\Users\YourUsername\.ssh\youyidian_prod_ed25519'" "Yellow" Write-ColorOutput " .\deploy-new-fixed.ps1 -Registry harbor" "Yellow" Write-ColorOutput " .\deploy-new-fixed.ps1 -SkipCheck -SkipUpload" "Yellow" Write-ColorOutput " .\deploy-new-fixed.ps1 -CleanImages -KeepImageCount 5" "Yellow" exit 0 } # 颜色输出函数 function Write-ColorOutput { param( [string]$Message, [ConsoleColor]$ForegroundColor = "White" ) Write-Host $Message -ForegroundColor $ForegroundColor } function Write-Success { param([string]$Message) Write-ColorOutput "[OK] $Message" "Green" } function Write-Warning { param([string]$Message) Write-ColorOutput "[WARN] $Message" "Yellow" } function Write-ErrorColored { param([string]$Message) Write-ColorOutput "[ERROR] $Message" "Red" } function Write-Info { param([string]$Message) Write-ColorOutput "[INFO] $Message" "Cyan" } # 环境变量文件处理 function Resolve-EnvFilePath { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $null } try { return (Resolve-Path $Path -ErrorAction Stop).Path } catch { return $null } } # 内部函数:准备构建环境 function Prepare-BuildEnvironment { param( [string]$ProjectRoot, [string]$EnvFileCandidate, [string]$Server ) $state = @{ TargetPath = Join-Path $ProjectRoot ".env.production" BackupPath = $null SourcePath = $null CreatedTemp = $false } $targetPath = $state.TargetPath $existingTarget = Test-Path $targetPath if ($existingTarget -and [string]::IsNullOrWhiteSpace($EnvFileCandidate)) { Write-Info "检测到现有 .env.production,直接使用" return $state } $candidatePaths = @() $resolvedCandidate = Resolve-EnvFilePath $EnvFileCandidate if ($resolvedCandidate) { $candidatePaths += $resolvedCandidate } $candidatePaths += @( (Join-Path $ProjectRoot ".env.production.local"), (Join-Path $ProjectRoot "config-bianliang.env"), (Join-Path $ProjectRoot ".env.local") ) foreach ($candidate in $candidatePaths) { if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path $candidate)) { $state.SourcePath = (Resolve-Path $candidate).Path Write-Info "使用环境变量文件: $($state.SourcePath)" break } } if ($state.SourcePath) { if ($existingTarget) { $state.BackupPath = "$targetPath.deploy-backup" Copy-Item -Path $targetPath -Destination $state.BackupPath -Force } if ($state.SourcePath -ne $targetPath) { Copy-Item -Path $state.SourcePath -Destination $targetPath -Force $state.CreatedTemp = $true } return $state } Write-Warning "未找到可用的环境变量文件,将生成临时占位配置。建议通过 -EnvFile 指定真实配置以避免构建期访问远程服务失败。" $placeholder = @" # 生产环境配置 (临时生成) NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 # 数据库配置 (占位符) DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder # 应用URL配置 NEXTAUTH_URL=http://$Server:4320 APP_PUBLIC_URL=http://$Server:4320 # 安全配置 NEXTAUTH_SECRET=$(New-Guid) # 存储配置 S3_ENDPOINT=https://s3.1080p.hk S3_BUCKET=jsxiangche S3_ACCESS_KEY_ID=AEvT1p0wSNjow3Asu9NV S3_SECRET_ACCESS_KEY=u8nzFl1S6dNoT5PQaQhkHz8Du6iX0hVnPswtvHhs S3_REGION=us-east-1 S3_PUBLIC_DOMAIN=https://s3.1080p.hk/jsxiangche S3_ENABLE_PATH_STYLE=1 S3_FILE_PATH=由壹点AI/ # 本地上传目录 UPLOAD_DIR=./uploads "@ $placeholder | Out-File -FilePath $targetPath -Encoding UTF8 -Force $state.CreatedTemp = -not $existingTarget return $state } function Restore-BuildEnvironment { param($State) if (-not $State) { return } $targetPath = $State.TargetPath if ($State.BackupPath -and (Test-Path $State.BackupPath)) { Move-Item -Path $State.BackupPath -Destination $targetPath -Force return } if ($State.CreatedTemp -and (Test-Path $targetPath)) { Remove-Item -Path $targetPath -Force -ErrorAction SilentlyContinue } } # 文件大小格式化 function Format-FileSize { param([long]$Size) if ($Size -ge 1GB) { return "{0:N2} GB" -f ($Size / 1GB) } elseif ($Size -ge 1MB) { return "{0:N2} MB" -f ($Size / 1MB) } elseif ($Size -ge 1KB) { return "{0:N2} KB" -f ($Size / 1KB) } else { return "$Size B" } } # 检查命令是否存在 function Test-Command { param([string]$Command) try { $null = Get-Command $Command -ErrorAction Stop return $true } catch { return $false } } # 查找SSH密钥文件 function Find-SSHKeys { $possibleKeys = @( # 优先使用项目专用的生产环境密钥(已验证可用) "$env:USERPROFILE\.ssh\youyidian_prod_ed25519", # 其他常见密钥位置 "$env:USERPROFILE\.ssh\id_rsa", "$env:USERPROFILE\.ssh\id_ed25519", "$env:USERPROFILE\.ssh\id_ecdsa", "$env:USERPROFILE\.ssh\id_dsa" ) $foundKeys = @() foreach ($key in $possibleKeys) { if (Test-Path $key) { $foundKeys += $key } } return $foundKeys } # 生成唯一临时文件名 function Get-TempFileName { param([string]$Prefix = "deploy") $timestamp = Get-Date -Format "yyyyMMddHHmmss" $processId = $PID $random = Get-Random -Minimum 1000 -Maximum 9999 return "${Prefix}_${timestamp}_${processId}_${random}.txt" } # 测试SSH连接(带重试机制) function Test-SSHConnection { param( [string]$Server, [string]$Username, [SecureString]$Password = $null, [string]$KeyPath = "", [int]$MaxRetries = 3, [int]$RetryDelay = 2 ) $target = "${Username}@${Server}" $attempt = 0 $lastError = $null while ($attempt -lt $MaxRetries) { $attempt++ if ($attempt -gt 1) { Write-Info "SSH连接重试 ($attempt/$MaxRetries)..." Start-Sleep -Seconds $RetryDelay } # 生成唯一临时文件名 $stdoutFile = Get-TempFileName -Prefix "ssh_stdout" $stderrFile = Get-TempFileName -Prefix "ssh_stderr" try { # 使用 OpenSSH(增加超时时间到20秒) $sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=20", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3") if ($KeyPath -and (Test-Path $KeyPath)) { $sshArgs += @("-i", $KeyPath) if ($attempt -eq 1) { Write-Info "使用SSH密钥: $KeyPath" } } elseif ($Password) { # 注意:SecureString 密码认证需要额外处理,当前主要使用 SSH 密钥 $sshArgs += @("-o", "PasswordAuthentication=yes") if ($attempt -eq 1) { Write-Warning "密码认证支持有限,建议使用 SSH 密钥" } } else { if ($attempt -eq 1) { Write-Warning "未提供密钥,使用交互式认证" } $sshArgs += @("-o", "StrictHostKeyChecking=no") } $process = Start-Process -FilePath "ssh" -ArgumentList ($sshArgs + @($target, "echo 'SSH连接测试成功'")) -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile if ($process.ExitCode -eq 0) { Write-Success "SSH连接测试成功" Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue return $true } else { $errorOutput = Get-Content $stderrFile -ErrorAction SilentlyContinue $lastError = $errorOutput Write-Warning "SSH连接失败 (尝试 $attempt/$MaxRetries): $errorOutput" } } catch { $lastError = $_.Exception.Message Write-Warning "SSH测试异常 (尝试 $attempt/$MaxRetries): $_" } finally { # 清理临时文件 Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue } } Write-ErrorColored "SSH连接失败,已重试 $MaxRetries 次。最后错误: $lastError" return $false } # 内部函数:清理本地 Docker 镜像 function Clean-LocalDockerImages { param( [string]$ImageName, [string]$KeepTag = "local-deploy", [int]$KeepCount = 3 ) Write-Info "清理本地 Docker 镜像..." try { # 清理悬空镜像 Write-Info " 清理悬空镜像..." docker image prune -f 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { Write-Success " 悬空镜像清理完成" } # 清理指定镜像的旧版本(保留最新的几个) Write-Info " 清理 $ImageName 的旧版本镜像(保留最新 $KeepCount 个)..." $images = docker images --format "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}" | Where-Object { $_ -match $ImageName } if ($images) { $sortedImages = $images | Sort-Object { [DateTime]::ParseExact(($_ -split "`t")[3], "yyyy-MM-dd HH:mm:ss zzz", $null) } -Descending $toRemove = $sortedImages | Select-Object -Skip $KeepCount foreach ($img in $toRemove) { $imgId = ($img -split "`t")[0] $imgRepo = ($img -split "`t")[1] $imgTag = ($img -split "`t")[2] Write-Info " 删除: $imgRepo`:$imgTag ($imgId)" docker rmi -f $imgId 2>&1 | Out-Null } if ($toRemove) { Write-Success " 已清理 $($toRemove.Count) 个旧镜像" } else { Write-Info " 没有需要清理的旧镜像" } } # 清理所有未使用的镜像(可选,谨慎使用) # docker image prune -a -f return $true } catch { Write-Warning "清理本地镜像时出错: $_" return $false } } # 内部函数:清理服务器上的 Docker 镜像 function Clean-RemoteDockerImages { param( [string]$Server, [string]$Username, [string]$KeyPath, [string]$ImageName, [int]$KeepCount = 3 ) Write-Info "清理服务器上的 Docker 镜像..." $cleanCmd = @" # 清理悬空镜像 docker image prune -f # 清理指定镜像的旧版本(保留最新的几个) docker images --format '{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}' | grep '$ImageName' | sort -k4 -r | tail -n +$($KeepCount + 1) | awk '{print \$1}' | xargs -r docker rmi -f 2>/dev/null || true # 显示清理后的镜像列表 echo '--- 清理后的镜像列表 ---' docker images | grep '$ImageName' | head -10 "@ $sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30") if ($KeyPath -and (Test-Path $KeyPath)) { $sshArgs += @("-i", $KeyPath) } try { $sshArgs += @("${Username}@${Server}", $cleanCmd) $process = Start-Process -FilePath "ssh" -ArgumentList $sshArgs -NoNewWindow -PassThru -Wait if ($process.ExitCode -eq 0) { Write-Success "服务器镜像清理完成" return $true } else { Write-Warning "服务器镜像清理可能未完全成功(退出码: $($process.ExitCode))" return $false } } catch { Write-Warning "清理服务器镜像时出错: $_" return $false } } # 内部函数:清理 Docker Registry 中的旧镜像 function Clean-RegistryImages { param( [string]$RegistryUrl, [string]$ImageName, [int]$KeepCount = 5 ) Write-Info "清理 Docker Registry 中的旧镜像..." try { # 获取镜像的所有标签 $tagsUrl = "$RegistryUrl/v2/$ImageName/tags/list" $tagsResponse = Invoke-RestMethod -Uri $tagsUrl -Method Get -ErrorAction SilentlyContinue if ($tagsResponse -and $tagsResponse.tags) { $tags = $tagsResponse.tags | Where-Object { $_ -ne "latest" -and $_ -ne "local-deploy" } | Sort-Object -Descending $toRemove = $tags | Select-Object -Skip $KeepCount if ($toRemove) { Write-Info " 将删除 $($toRemove.Count) 个旧标签: $($toRemove -join ', ')" foreach ($tag in $toRemove) { try { # 获取 manifest digest $manifestUrl = "$RegistryUrl/v2/$ImageName/manifests/$tag" $headers = @{ "Accept" = "application/vnd.docker.distribution.manifest.v2+json" } $manifestResponse = Invoke-WebRequest -Uri $manifestUrl -Method Head -Headers $headers -ErrorAction SilentlyContinue if ($manifestResponse -and $manifestResponse.Headers["Docker-Content-Digest"]) { $digest = $manifestResponse.Headers["Docker-Content-Digest"] $deleteUrl = "$RegistryUrl/v2/$ImageName/manifests/$digest" Invoke-RestMethod -Uri $deleteUrl -Method Delete -ErrorAction SilentlyContinue | Out-Null Write-Info " 已删除: $ImageName`:$tag" } } catch { Write-Warning " 删除 $ImageName`:$tag 失败: $_" } } Write-Success "Registry 镜像清理完成" } else { Write-Info " 没有需要清理的旧镜像标签" } } else { Write-Info " 未找到镜像 $ImageName 或无法访问 Registry" } return $true } catch { Write-Warning "清理 Registry 镜像时出错: $_" return $false } } # 内部函数:检查并准备基础镜像(优先级:本地已有 > Registry > 本地文件 > 网络下载) function Load-BaseImages { param( [string]$ProjectRoot, [string]$RegistryUrl = "", [string]$RegistryType = "harbor", [string]$RegistryProject = "" ) # 基础镜像列表 $baseImages = @( "node:18-alpine" ) $loadedCount = 0 $pulledCount = 0 $skippedCount = 0 Write-Info "检查基础镜像..." foreach ($baseImage in $baseImages) { # 1. 优先检查本地是否已有该镜像(最快,直接使用) $localCheck = docker images --format "{{.Repository}}:{{.Tag}}" | Select-String -Pattern "^${baseImage}$" if ($localCheck) { Write-Info " [✓] 本地已存在,直接使用: $baseImage" $skippedCount++ continue } # 2. 如果本地没有,尝试从Registry拉取(避免从Docker Hub下载) if ($RegistryUrl) { $imageName = $baseImage -replace ":", "-" if ($RegistryType -eq "harbor") { $registryBaseImage = "${RegistryUrl}/${RegistryProject}/base-${imageName}:latest" } else { $registryBaseImage = "${RegistryUrl}/youyidian/base-${imageName}:latest" } Write-Info " 尝试从Registry拉取: $registryBaseImage" $pullResult = docker pull $registryBaseImage 2>&1 if ($LASTEXITCODE -eq 0) { # 标记为原始名称 docker tag $registryBaseImage $baseImage 2>&1 | Out-Null Write-Info " [OK] 已从Registry拉取并标记: $baseImage" $pulledCount++ continue } else { Write-Info " [跳过] Registry中不存在" } } # 3. 如果Registry也没有,尝试从本地文件加载 $imagesDir = Join-Path $ProjectRoot "docker-images" if (Test-Path $imagesDir) { $foundInFiles = $false Get-ChildItem $imagesDir -Directory -ErrorAction SilentlyContinue | ForEach-Object { if ($foundInFiles) { return } $imageDir = $_.FullName Get-ChildItem $imageDir -Filter "*.tar" -ErrorAction SilentlyContinue | ForEach-Object { if ($foundInFiles) { return } $tarFile = $_.FullName Write-Info " 尝试从本地文件加载: $($_.Name)" $result = docker load -i $tarFile 2>&1 if ($LASTEXITCODE -eq 0) { $loadedImage = $result | Select-String -Pattern "Loaded image: (.+)" | ForEach-Object { $_.Matches[0].Groups[1].Value } if ($loadedImage -and $loadedImage -match $baseImage) { Write-Info " [OK] 已从本地文件加载: $loadedImage" $loadedCount++ $foundInFiles = $true } } } } if ($foundInFiles) { continue } } # 4. 如果以上都没有,构建时会从网络下载(Docker会自动处理) Write-Info " [提示] 未找到基础镜像,构建时将尝试从网络下载: $baseImage" } if ($skippedCount -gt 0) { Write-Success "本地已有 $skippedCount 个基础镜像,直接使用" } if ($pulledCount -gt 0) { Write-Success "从Registry拉取 $pulledCount 个基础镜像" } if ($loadedCount -gt 0) { Write-Success "从本地文件加载 $loadedCount 个基础镜像" } } # 主函数 function Main { Write-ColorOutput "========================================" "Magenta" Write-ColorOutput " 新建简化部署脚本 v1.1" "Magenta" Write-ColorOutput "========================================" "Magenta" # 加载基础镜像(在构建前) if (-not $SkipBuild) { $projectRoot = if ($PSScriptRoot) { Split-Path $PSScriptRoot -Parent } else { Get-Location } $baseRegistryUrl = if ($Registry -eq "harbor") { $HarborUrl } else { "${Server}:5000" } Load-BaseImages -ProjectRoot $projectRoot -RegistryUrl $baseRegistryUrl -RegistryType $Registry -RegistryProject $HarborProjectName Write-ColorOutput "" } # 显示配置 Write-Info "部署配置:" Write-Info " 服务器: $Server" Write-Info " 用户名: $Username" Write-Info " 镜像: $ImageName`:$Tag" Write-Info " Registry: $Registry" if ($Registry -eq "harbor") { Write-Info " Harbor地址: $HarborUrl" Write-Info " Harbor项目: $HarborProjectName" } Write-Info " 跳过检查: $SkipCheck" Write-Info " 跳过构建: $SkipBuild" Write-Info " 跳过上传: $SkipUpload" Write-ColorOutput "" # 调试模式 if ($Debug) { Write-Info "[DEBUG] 调试模式已启用:将输出额外的诊断信息" } $willCheck = -not $SkipCheck $willBuild = -not $SkipBuild $willPush = -not $SkipUpload $willRemote = $willPush Initialize-StageTracker -DoCheck:$willCheck -DoBuild:$willBuild -DoPush:$willPush -DoRemote:$willRemote # 检查必要工具 Write-Info "检查必要工具..." $requiredTools = @("docker") $missingTools = @() foreach ($tool in $requiredTools) { if (Test-Command $tool) { Write-Success "$tool 已安装" # 检查 Docker 是否可用 if ($tool -eq "docker") { $dockerCheck = docker ps 2>&1 if ($LASTEXITCODE -ne 0 -and $dockerCheck -match "cannot find the file specified|Is the docker daemon running") { Write-Warning "Docker 命令存在但 Docker Desktop 可能未运行" Write-Info "正在等待 Docker Desktop 启动(最多等待 30 秒)..." $maxWait = 30 $waited = 0 $dockerReady = $false while ($waited -lt $maxWait) { Start-Sleep -Seconds 2 $waited += 2 docker ps 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { $dockerReady = $true Write-Success "Docker 已就绪" break } } if (-not $dockerReady) { Write-ErrorColored "Docker Desktop 未运行,请手动启动 Docker Desktop 后重试" Write-Info "提示: 在 Windows 上,请启动 'Docker Desktop' 应用程序" exit 1 } } } } else { Write-ErrorColored "$tool 未安装" $missingTools += $tool } } if ($missingTools.Count -gt 0) { Write-ErrorColored "缺少必要工具: $($missingTools -join ', ')" exit 1 } # 项目根目录 $projectRoot = Get-Location Write-Info "项目目录: $projectRoot" # 1. 检查阶段 if (-not $SkipCheck) { Enter-Stage "检查阶段" "先给代码做个快速体检" Write-ColorOutput "===> 检查阶段" "Cyan" Write-Info "运行 npm run check..." try { $process = Start-Process -FilePath "npm" -ArgumentList @("run", "check") -NoNewWindow -PassThru -Wait if ($process.ExitCode -ne 0) { Write-Warning "npm run check 有警告,但继续执行" } else { Write-Success "代码检查通过" } } catch { Write-Warning "检查阶段异常: $_" } Celebrate-Stage "检查阶段" } # 2. 构建阶段 $fullImage = "${ImageName}:${Tag}" if (-not $SkipBuild) { Enter-Stage "构建阶段" "正在把代码揉成可部署的镜像" Write-ColorOutput "===> 构建阶段" "Cyan" $envState = Prepare-BuildEnvironment -ProjectRoot $projectRoot -EnvFileCandidate $EnvFile -Server $Server try { Write-Info "开始Docker构建: $fullImage" # 构建参数 # 默认使用缓存加速构建,只有指定 -NoCache 时才禁用缓存 $buildArgs = @( "build", "-t", $fullImage, "-f", "Dockerfile", "--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder", "--build-arg", "NEXTAUTH_URL=http://$Server:4320", "--build-arg", "APP_PUBLIC_URL=http://$Server:4320" ) if ($NoCache) { $buildArgs += "--no-cache" Write-Info "已禁用构建缓存 (--no-cache)" } $buildArgs += "." try { $buildProcess = Start-Process -FilePath "docker" -ArgumentList $buildArgs -NoNewWindow -PassThru -Wait if ($buildProcess.ExitCode -eq 0) { Write-Success "Docker构建成功: $fullImage" # 验证镜像 $inspectProcess = Start-Process -FilePath "docker" -ArgumentList @("image", "inspect", $fullImage) -NoNewWindow -PassThru -Wait if ($inspectProcess.ExitCode -eq 0) { Write-Success "镜像验证成功" } else { Write-ErrorColored "镜像验证失败" exit 1 } } else { Write-ErrorColored "Docker构建失败" exit 1 } } catch { Write-ErrorColored "构建过程异常: $_" exit 1 } } finally { Restore-BuildEnvironment -State $envState } Celebrate-Stage "构建阶段" } else { Write-Warning "跳过构建,使用现有镜像: $fullImage" } # 3. 推送镜像到 Docker Registry if (-not $SkipUpload) { Enter-Stage "镜像推送阶段" "把镜像打包寄到私有仓库" Write-ColorOutput "===> 推送镜像到 Docker Registry" "Cyan" # 根据 Registry 参数选择 Registry URL 和命名空间 if ($Registry -eq "harbor") { $registryUrl = $HarborUrl # Harbor 使用指定的项目名称,仓库名称为镜像名称 $registryImage = "${registryUrl}/${HarborProjectName}/${ImageName}:${Tag}" Write-Info "使用 Harbor Registry: $registryUrl" Write-Info "目标镜像: $registryImage" # 登录 Harbor Write-Info "登录 Harbor..." try { # 使用 PowerShell 的方式传递密码 $securePassword = ConvertTo-SecureString $HarborPassword -AsPlainText -Force $credential = New-Object System.Management.Automation.PSCredential($HarborUsername, $securePassword) $passwordPlain = $credential.GetNetworkCredential().Password $loginProcess = Start-Process -FilePath "docker" -ArgumentList @("login", $HarborUrl, "-u", $HarborUsername, "-p", $passwordPlain) -NoNewWindow -PassThru -Wait if ($loginProcess.ExitCode -eq 0) { Write-Success "Harbor 登录成功" } else { Write-Warning "Harbor 登录失败,尝试继续推送..." } } catch { Write-Warning "Harbor 登录过程异常: $_,尝试继续推送..." } } else { $registryUrl = "${Server}:5000" # 推送到 youyidian/flnxi-material 命名空间 $registryImage = "${registryUrl}/youyidian/${ImageName}:${Tag}" Write-Info "使用默认 Registry: $registryUrl" Write-Info "目标镜像: $registryImage" } # 标记镜像 Write-Info "标记镜像..." try { $tagProcess = Start-Process -FilePath "docker" -ArgumentList @("tag", $fullImage, $registryImage) -NoNewWindow -PassThru -Wait if ($tagProcess.ExitCode -eq 0) { Write-Success "镜像标记成功" } else { Write-ErrorColored "镜像标记失败" exit 1 } } catch { Write-ErrorColored "标记过程异常: $_" exit 1 } # 推送镜像(带重试机制) Write-Info "正在推送镜像到 Registry..." $pushMaxRetries = 3 $pushRetryDelay = 5 $pushAttempt = 0 $pushSuccess = $false while ($pushAttempt -lt $pushMaxRetries -and -not $pushSuccess) { $pushAttempt++ if ($pushAttempt -gt 1) { Write-Info "镜像推送重试 ($pushAttempt/$pushMaxRetries)..." Start-Sleep -Seconds $pushRetryDelay } try { $pushProcess = Start-Process -FilePath "docker" -ArgumentList @("push", $registryImage) -NoNewWindow -PassThru -Wait if ($pushProcess.ExitCode -eq 0) { Write-Success "镜像推送成功: $registryImage" $pushSuccess = $true } else { if ($pushAttempt -lt $pushMaxRetries) { Write-Warning "镜像推送失败 (尝试 $pushAttempt/$pushMaxRetries),将重试..." } else { Write-ErrorColored "镜像推送失败,已重试 $pushMaxRetries 次" if ($Registry -ne "harbor") { Write-Info "提示: 请确保 Docker 已配置允许不安全的 Registry ($registryUrl)" Write-Info " 在 Windows 上,编辑 Docker Desktop 设置 -> Docker Engine,添加:" Write-Info " { `"insecure-registries`": [`"$registryUrl`"] }" } exit 1 } } } catch { if ($pushAttempt -lt $pushMaxRetries) { Write-Warning "推送过程异常 (尝试 $pushAttempt/$pushMaxRetries): $_,将重试..." } else { Write-ErrorColored "推送过程异常,已重试 $pushMaxRetries 次: $_" exit 1 } } } Celebrate-Stage "镜像推送阶段" # 测试SSH连接(用于后续部署命令) Enter-Stage "远程部署阶段" "正在连线服务器,准备热乎乎上线" Write-Info "测试SSH连接..." if ([string]::IsNullOrWhiteSpace($KeyPath)) { $sshKeys = Find-SSHKeys if ($null -ne $sshKeys -and $sshKeys.Length -gt 0) { $KeyPath = $sshKeys[0] } } $sshTest = Test-SSHConnection -Server $Server -Username $Username -Password $Password -KeyPath $KeyPath if (-not $sshTest) { Write-Warning "SSH连接测试失败,将跳过自动部署" Write-Info "请手动在服务器上执行部署命令" } else { # 服务器端只需要拉取已构建好的镜像并部署,不需要基础镜像 # 因为镜像已经在本地构建完成,所有层(包括基础镜像层)都已包含在镜像中 # 在服务器上拉取镜像并部署(带重试机制) Write-Info "在服务器上拉取镜像并部署..." # 如果使用 Harbor,需要在服务器上先登录 if ($Registry -eq "harbor") { $harborLoginCmd = "docker login $HarborUrl -u $HarborUsername -p $HarborPassword" $deployCmd = "cd $RemotePath && $harborLoginCmd && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app" } else { # 先停止并删除旧容器,然后拉取新镜像,重新启动(使用 --no-deps --pull never 避免拉取依赖服务和重复拉取) $deployCmd = "cd $RemotePath && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app" } $sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3") if ($KeyPath -and (Test-Path $KeyPath)) { $sshArgs += @("-i", $KeyPath) Write-Info "使用 OpenSSH 执行远程部署命令" } elseif ($Password) { Write-Warning "未提供 SSH 密钥,无法在非交互模式下使用密码。请手动执行部署命令。" $sshArgs = $null } else { Write-Warning "缺少 SSH 凭据,请手动执行部署命令。" $sshArgs = $null } if ($sshArgs) { $deployMaxRetries = 2 $deployRetryDelay = 5 $deployAttempt = 0 $deploySuccess = $false while ($deployAttempt -lt $deployMaxRetries -and -not $deploySuccess) { $deployAttempt++ if ($deployAttempt -gt 1) { Write-Info "远程部署重试 ($deployAttempt/$deployMaxRetries)..." Start-Sleep -Seconds $deployRetryDelay } $currentSshArgs = $sshArgs + @("${Username}@${Server}", $deployCmd) try { $sshProcess = Start-Process -FilePath "ssh" -ArgumentList $currentSshArgs -NoNewWindow -PassThru -Wait if ($sshProcess.ExitCode -eq 0) { Write-Success "部署成功" $deploySuccess = $true Celebrate-Stage "远程部署阶段" } else { if ($deployAttempt -lt $deployMaxRetries) { Write-Warning "自动部署失败(退出码 $($sshProcess.ExitCode)),将重试..." } else { Write-Warning "自动部署失败(退出码 $($sshProcess.ExitCode)),已重试 $deployMaxRetries 次,请手动执行: $deployCmd" } } } catch { if ($deployAttempt -lt $deployMaxRetries) { Write-Warning "执行远程部署命令时出错: $_,将重试..." } else { Write-Warning "执行远程部署命令时出错,已重试 $deployMaxRetries 次: $_" Write-Info "请手动执行: $deployCmd" } } } } } if (-not $sshTest -or -not $sshArgs) { Write-Warning "远程部署阶段未完成,已提供手动命令指引" } elseif ($sshTest) { Write-Info "开始对线上应用做一次健康检查..." Test-RemoteHealth -Server $Server -Port 4320 -MaxWaitSeconds 60 | Out-Null } } # 清理旧镜像(如果启用) if ($CleanImages) { Write-ColorOutput "" "Magenta" Write-ColorOutput "========================================" "Magenta" Write-ColorOutput " 清理旧镜像阶段" "Magenta" Write-ColorOutput "========================================" "Magenta" # 清理本地镜像 Clean-LocalDockerImages -ImageName $ImageName -KeepTag $Tag -KeepCount $KeepImageCount # 清理服务器镜像 if ($sshTest -and $sshArgs) { Clean-RemoteDockerImages -Server $Server -Username $Username -KeyPath $KeyPath -ImageName $ImageName -KeepCount $KeepImageCount } # 清理 Registry 镜像(仅适用于旧 Registry) if ($Registry -eq "harbor") { Write-Info "Harbor 镜像请在 Harbor 控制台按需清理,这里暂不自动操作。" } else { $registryUrl = "http://${Server}:5000" Clean-RegistryImages -RegistryUrl $registryUrl -ImageName "youyidian/$ImageName" -KeepCount $KeepImageCount Clean-RegistryImages -RegistryUrl $registryUrl -ImageName "youyidian/flnxi-sucai" -KeepCount 0 } } Write-ColorOutput "" "Magenta" Write-ColorOutput "========================================" "Magenta" Write-ColorOutput " 部署完成!" "Magenta" Write-ColorOutput "========================================" "Magenta" Write-Success "所有阶段执行成功" Write-Info "镜像: $fullImage" Write-Info "服务器: ${Username}@${Server}" Write-Info "远程路径: $RemotePath" # 显示访问信息 Write-ColorOutput "" "Cyan" Write-ColorOutput "访问地址:" "Cyan" Write-ColorOutput " http://${Server}:4320" "Yellow" Write-ColorOutput " http://${Server}:4320/admin" "Yellow" if ($CleanImages) { Write-Info "已启用镜像清理,保留了最新 $KeepImageCount 个版本" } else { Write-Info "提示: 使用 -CleanImages 参数可以自动清理旧版本镜像" } if (-not $SkipUpload) { if ($Registry -eq "harbor") { Write-Info "镜像已推送到 Harbor Registry: $registryImage" Write-Info "如果自动部署失败,请在服务器上手动执行:" Write-Info " cd $RemotePath" Write-Info " docker login $HarborUrl -u $HarborUsername -p $HarborPassword" Write-Info " docker-compose stop app && docker-compose rm -f app" Write-Info " docker pull $registryImage" Write-Info " docker tag $registryImage $fullImage" Write-Info " docker-compose up -d --no-deps --pull never app" } else { Write-Info "镜像已推送到 Registry: $registryImage" Write-Info "如果自动部署失败,请在服务器上手动执行:" Write-Info " cd $RemotePath" Write-Info " docker-compose stop app && docker-compose rm -f app" Write-Info " docker pull $registryImage" Write-Info " docker tag $registryImage $fullImage" Write-Info " docker-compose up -d --no-deps --pull never app" } Write-Info " # 如需执行数据库迁移,请手动运行: docker exec flnxi-material npx prisma migrate deploy" } # 成功后的趣味提示 $successTips = @( "恭喜!FLNXI素材库已成功部署!", "现在可以去喝杯咖啡,享受部署成功的喜悦", "部署完成度:100% | 心情指数:100", "代码已安全抵达生产环境,准备接受用户检阅", "部署成功!FLNXI素材库已整装待发" ) $successTip = Get-Random -InputObject $successTips Write-ColorOutput "" "Green" Write-ColorOutput $successTip "Green" } # 执行主函数 Main