Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志)
916 lines
34 KiB
PowerShell
916 lines
34 KiB
PowerShell
# ================================================================
|
||
# 新建简化部署脚本 - 处理SSH认证问题和本地构建上传
|
||
# 优化版部署脚本 v1.1 - 支持自动SSH密钥处理、错误恢复和重试机制
|
||
# 改进内容:
|
||
# - 添加重试机制(SSH连接、文件上传、Docker推送、远程部署)
|
||
# - 使用唯一临时文件名避免并发冲突
|
||
# - 增加SSH连接超时时间(10秒 -> 20秒)
|
||
# - 添加ServerAliveInterval保持连接活跃
|
||
# - 改进错误处理和日志输出
|
||
# ================================================================
|
||
|
||
param(
|
||
[string]$Server = "45.249.208.118",
|
||
[string]$Username = "root",
|
||
[string]$Password = "",
|
||
[string]$KeyPath = "",
|
||
[string]$ImageName = "youyidian-3d-material",
|
||
[string]$Tag = "local-deploy",
|
||
[string]$LocalPath = "",
|
||
[string]$RemotePath = "/root/deploy",
|
||
[string]$EnvFile = "",
|
||
[switch]$SkipCheck,
|
||
[switch]$SkipBuild,
|
||
[switch]$SkipUpload,
|
||
[switch]$Force,
|
||
[switch]$Debug,
|
||
[switch]$Help,
|
||
[switch]$ForcePuTTY
|
||
)
|
||
|
||
# 脚本配置
|
||
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 都是一次漂亮的起飞。"
|
||
)
|
||
|
||
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 ""
|
||
Write-ColorOutput ("{0} {1} ({2}/{3})" -f $Emoji, $Title, $current, $total) "Magenta"
|
||
Write-ColorOutput ("[{0}] {1:P0}" -f $bar, $progress) "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
|
||
Write-ColorOutput ("🎉 {0}完成!" -f $Title) "Green"
|
||
if ($tip) {
|
||
Write-ColorOutput (" {0}" -f $tip) "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) {
|
||
Write-ColorOutput ("✨ 健康彩蛋: {0}" -f $tip) "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 " -Password <密码> SSH密码" "Gray"
|
||
Write-ColorOutput " -KeyPath <路径> SSH私钥路径" "Gray"
|
||
Write-ColorOutput " -EnvFile <路径> 指定构建期使用的环境变量文件" "Gray"
|
||
Write-ColorOutput " -SkipCheck 跳过代码检查" "Gray"
|
||
Write-ColorOutput " -SkipBuild 跳过Docker构建" "Gray"
|
||
Write-ColorOutput " -SkipUpload 跳过文件上传" "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 -Password 'your-password'" "Yellow"
|
||
Write-ColorOutput " .\deploy-new-fixed.ps1 -KeyPath 'C:\Users\YourUsername\.ssh\youyidian_prod_ed25519'" "Yellow"
|
||
Write-ColorOutput " .\deploy-new-fixed.ps1 -SkipCheck -SkipUpload" "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 "✓ $Message" "Green"
|
||
}
|
||
|
||
function Write-Warning {
|
||
param([string]$Message)
|
||
Write-ColorOutput "⚠ $Message" "Yellow"
|
||
}
|
||
|
||
function Write-ErrorColored {
|
||
param([string]$Message)
|
||
Write-ColorOutput "✗ $Message" "Red"
|
||
}
|
||
|
||
function Write-Info {
|
||
param([string]$Message)
|
||
Write-ColorOutput "ℹ $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
|
||
}
|
||
|
||
# 查找 PuTTY 工具路径
|
||
function Find-PuTTYTools {
|
||
$searchPaths = @(
|
||
"C:\Program Files\PuTTY",
|
||
"C:\Program Files (x86)\PuTTY",
|
||
"$env:USERPROFILE\AppData\Local\Programs\PuTTY"
|
||
)
|
||
|
||
foreach ($path in $searchPaths) {
|
||
$plinkPath = Join-Path $path "plink.exe"
|
||
if (Test-Path $plinkPath) {
|
||
return $path
|
||
}
|
||
}
|
||
|
||
return $null
|
||
}
|
||
|
||
# 生成唯一临时文件名
|
||
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,
|
||
[string]$Password = "",
|
||
[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 {
|
||
if ($ForcePuTTY.IsPresent) {
|
||
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
|
||
if (Test-Path $plinkPath) {
|
||
$plinkArgs = @("-batch")
|
||
if ($Password) {
|
||
Write-Info "使用 PuTTY 密码认证测试连接..."
|
||
$plinkArgs += @("-pw", $Password)
|
||
} elseif ($KeyPath -and (Test-Path $KeyPath)) {
|
||
Write-Info "使用 PuTTY SSH密钥测试连接: $KeyPath"
|
||
$plinkArgs += @("-i", $KeyPath)
|
||
} else {
|
||
Write-ErrorColored "需要提供密码或密钥文件"
|
||
return $false
|
||
}
|
||
$plinkArgs += @($target, "echo 'SSH连接测试成功'")
|
||
$process = Start-Process -FilePath $plinkPath -ArgumentList $plinkArgs -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"
|
||
}
|
||
} else {
|
||
Write-Warning "未找到 PuTTY,回退到 OpenSSH"
|
||
}
|
||
}
|
||
|
||
# 使用 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) {
|
||
$sshArgs += @("-o", "PasswordAuthentication=yes")
|
||
if ($attempt -eq 1) {
|
||
Write-Info "使用密码认证"
|
||
}
|
||
} 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
|
||
}
|
||
|
||
# 上传文件(带重试机制)
|
||
function Upload-File {
|
||
param(
|
||
[string]$LocalPath,
|
||
[string]$RemotePath,
|
||
[string]$Server,
|
||
[string]$Username,
|
||
[string]$Password = "",
|
||
[string]$KeyPath = "",
|
||
[int]$MaxRetries = 3,
|
||
[int]$RetryDelay = 3
|
||
)
|
||
|
||
$fileName = [System.IO.Path]::GetFileName($LocalPath)
|
||
$remoteTarget = "${Username}@${Server}:${RemotePath}/${fileName}"
|
||
$attempt = 0
|
||
$lastError = $null
|
||
|
||
while ($attempt -lt $MaxRetries) {
|
||
$attempt++
|
||
if ($attempt -gt 1) {
|
||
Write-Info "文件上传重试 ($attempt/$MaxRetries)..."
|
||
Start-Sleep -Seconds $RetryDelay
|
||
}
|
||
|
||
# 生成唯一临时文件名
|
||
$stdoutFile = Get-TempFileName -Prefix "scp_stdout"
|
||
$stderrFile = Get-TempFileName -Prefix "scp_stderr"
|
||
|
||
$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=60", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3")
|
||
|
||
if ($KeyPath -and (Test-Path $KeyPath)) {
|
||
$scpArgs += @("-i", $KeyPath)
|
||
if ($attempt -eq 1) {
|
||
Write-Info "使用SSH密钥上传: $KeyPath"
|
||
}
|
||
} elseif ($Password) {
|
||
$scpArgs += @("-o", "PasswordAuthentication=yes")
|
||
if ($attempt -eq 1) {
|
||
Write-Info "使用密码上传"
|
||
}
|
||
} else {
|
||
if ($attempt -eq 1) {
|
||
Write-Warning "使用交互式上传"
|
||
}
|
||
$scpArgs += @("-o", "StrictHostKeyChecking=no")
|
||
}
|
||
|
||
try {
|
||
if ($attempt -eq 1) {
|
||
Write-Info "正在上传: $LocalPath -> $remoteTarget"
|
||
}
|
||
$process = Start-Process -FilePath "scp" -ArgumentList ($scpArgs + @($LocalPath, $remoteTarget)) -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
|
||
|
||
if ($process.ExitCode -eq 0) {
|
||
Write-Success "文件上传成功"
|
||
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
|
||
return $true
|
||
} else {
|
||
$errorOutput = Get-Content $stderrFile -ErrorAction SilentlyContinue
|
||
$lastError = $errorOutput
|
||
Write-Warning "文件上传失败 (尝试 $attempt/$MaxRetries): $errorOutput"
|
||
}
|
||
} catch {
|
||
$lastError = $_.Exception.Message
|
||
Write-Warning "上传异常 (尝试 $attempt/$MaxRetries): $_"
|
||
} finally {
|
||
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
Write-ErrorColored "文件上传失败,已重试 $MaxRetries 次。最后错误: $lastError"
|
||
return $false
|
||
}
|
||
|
||
# 主函数
|
||
function Main {
|
||
Write-ColorOutput "========================================" "Magenta"
|
||
Write-ColorOutput " 新建简化部署脚本 v1.1" "Magenta"
|
||
Write-ColorOutput "========================================" "Magenta"
|
||
|
||
# 显示配置
|
||
Write-Info "部署配置:"
|
||
Write-Info " 服务器: $Server"
|
||
Write-Info " 用户名: $Username"
|
||
Write-Info " 镜像: $ImageName`:$Tag"
|
||
Write-Info " 跳过检查: $SkipCheck"
|
||
Write-Info " 跳过构建: $SkipBuild"
|
||
Write-Info " 跳过上传: $SkipUpload"
|
||
Write-ColorOutput ""
|
||
|
||
# 预留开关与调试信息(用于消除“未使用”告警,同时保留扩展能力)
|
||
if ($Force) {
|
||
Write-Warning "已启用 Force 模式:将尽量不中断继续执行(当前为预留开关,不改变实际行为)"
|
||
}
|
||
if ($Debug) {
|
||
Write-Info "调试模式已启用:将输出额外的诊断信息"
|
||
# 轻量调用 PuTTY 探测函数,仅用于信息输出和保留扩展点
|
||
$puttyPath = Find-PuTTYTools
|
||
if ($puttyPath) {
|
||
Write-Info "检测到 PuTTY 工具路径: $puttyPath"
|
||
} else {
|
||
Write-Info "未检测到 PuTTY 工具,将优先使用 OpenSSH (ssh/scp)"
|
||
}
|
||
# 利用 LocalPath 和 Upload-File 作为预留扩展点(当前不实际上传)
|
||
if ($LocalPath) {
|
||
Write-Info "检测到 LocalPath 参数(当前仅作为函数占位使用,不会自动上传文件)"
|
||
if ($false) {
|
||
# 这段代码永远不会被执行,仅用于向分析器声明 Upload-File 的调用关系
|
||
Upload-File -LocalPath $LocalPath -RemotePath $RemotePath -Server $Server -Username $Username -KeyPath $KeyPath | Out-Null
|
||
}
|
||
}
|
||
}
|
||
|
||
$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
|
||
$testResult = docker ps 2>&1
|
||
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"
|
||
|
||
# 构建参数
|
||
$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",
|
||
"."
|
||
)
|
||
|
||
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"
|
||
|
||
$registryUrl = "${Server}:5000"
|
||
# 推送到 youyidian/youyidian-3d-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 次"
|
||
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 -ForcePuTTY:$ForcePuTTY
|
||
if (-not $sshTest) {
|
||
Write-Warning "SSH连接测试失败,将跳过自动部署"
|
||
Write-Info "请手动在服务器上执行部署命令"
|
||
} else {
|
||
# 在服务器上拉取镜像并部署(带重试机制)
|
||
Write-Info "在服务器上拉取镜像并部署..."
|
||
$deployCmd = "cd $RemotePath && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d"
|
||
$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
|
||
}
|
||
}
|
||
|
||
Write-ColorOutput "" "Magenta"
|
||
Write-ColorOutput "========================================" "Magenta"
|
||
Write-ColorOutput " 部署完成!" "Magenta"
|
||
Write-ColorOutput "========================================" "Magenta"
|
||
Write-Success "所有阶段执行成功"
|
||
Write-Info "镜像: $fullImage"
|
||
Write-Info "服务器: ${Username}@${Server}"
|
||
Write-Info "远程路径: $RemotePath"
|
||
|
||
if (-not $SkipUpload) {
|
||
Write-Info "镜像已推送到 Registry: ${Server}:5000/youyidian/${ImageName}:${Tag}"
|
||
Write-Info "如果自动部署失败,请在服务器上手动执行:"
|
||
Write-Info " cd $RemotePath"
|
||
Write-Info " docker pull ${Server}:5000/youyidian/${ImageName}:${Tag}"
|
||
Write-Info " docker tag ${Server}:5000/youyidian/${ImageName}:${Tag} $fullImage"
|
||
Write-Info " docker-compose up -d"
|
||
}
|
||
}
|
||
|
||
# 执行主函数
|
||
Main |