flnxi-Wu/deploy-new-fixed.ps1
WuYongJie 542c7bcc49 feat: 优化自定义上传模板系统
- 修复内置模板验证问题(builtin-video-material等)
- 添加模板验证工具 lib/template-validator.ts
- 创建模板系统优化需求文档(30个详细需求)
- 优化上传API的模板验证逻辑
- 改进错误提示信息的清晰度
- 添加字段池和分类管理相关需求
- 完善自定义字段的创建、验证、存储逻辑
- 清理过期的 .specstory 和 .trae 文件
2025-12-13 14:26:43 +08:00

1165 lines
45 KiB
PowerShell
Raw 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.

# ================================================================
# 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 = "local-deploy",
[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 未使用资源
)
# 脚本配置
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 " -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"
# 构建参数
# 为了避免沿用旧的 .next 构建产物,这里强制禁用 Docker Build 缓存
# 确保每次部署到服务器的镜像都包含最新的 API 逻辑和调试日志
$buildArgs = @(
"build",
"--no-cache",
"-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",
"."
)
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