param( [ValidateSet("Backup", "Restore", "Verify")] [string]$Action = "Backup", [string]$ProjectDir = ".", [string]$ComposeFile = "docker-compose.prod.yml", [string]$EnvFile = ".env", [string]$BackupDir = "backups", [string]$BackupPath, [string]$AppService = "app", [string]$PostgresService = "postgres", [string]$MinioService = "minio", [string]$HelperImage = "busybox:1.36", [switch]$SkipDatabaseBackup, [switch]$SkipObjectStorageBackup, [switch]$SkipObjectStorageRestore, [switch]$RestoreEnv, [int]$HealthTimeoutSeconds = 120, [string]$ConfirmRestore ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $RestoreConfirmPhrase = "RESTORE_FLNXI_DATA" function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" } function Write-Ok { param([string]$Message) Write-Host "[OK] $Message" } function Write-Warn { param([string]$Message) Write-Warning $Message } function Resolve-ExistingFile { param([string]$Path, [string]$Label) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "$Label 不存在:$Path" } return (Resolve-Path -LiteralPath $Path).Path } function Resolve-ProjectPath { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Container)) { throw "项目目录不存在:$Path" } return (Resolve-Path -LiteralPath $Path).Path } function Read-EnvFile { param([string]$Path) $values = @{} if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $values } foreach ($line in Get-Content -LiteralPath $Path) { if ($line -match '^\s*#' -or $line -notmatch '=') { continue } $parts = $line -split '=', 2 $key = $parts[0].Trim() if ([string]::IsNullOrWhiteSpace($key)) { continue } $value = $parts[1].Trim() if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) { $value = $value.Substring(1, $value.Length - 2) } $values[$key] = $value } return $values } function Get-EnvValue { param( [hashtable]$EnvValues, [string]$Name, [string]$Default = "" ) if ($EnvValues.ContainsKey($Name)) { return $EnvValues[$Name] } return $Default } function Invoke-DockerCompose { param([string[]]$Arguments) & docker @Arguments if ($LASTEXITCODE -ne 0) { throw "docker 命令失败,退出码:$LASTEXITCODE" } } function Get-ComposeArgs { param([string]$ComposeFilePath, [string]$EnvFilePath) $args = @("compose", "-f", $ComposeFilePath) if (Test-Path -LiteralPath $EnvFilePath -PathType Leaf) { $args += @("--env-file", $EnvFilePath) } return $args } function Get-ServiceContainerId { param([string[]]$ComposeArgs, [string]$Service) $containerId = (& docker @ComposeArgs ps -q $Service).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($containerId)) { throw "未找到服务容器:$Service。请先确认服务已启动。" } return $containerId } function Test-ExternalDatabase { param([hashtable]$EnvValues) $databaseUrl = Get-EnvValue $EnvValues "DATABASE_URL" if ([string]::IsNullOrWhiteSpace($databaseUrl)) { return $false } return ($databaseUrl -notmatch '@postgres:5432' -and $databaseUrl -notmatch '@[^/]*postgres[^/]*:5432') } function Backup-Database { param([string[]]$ComposeArgs, [string]$Service, [string]$OutputPath) Write-Info "备份 Compose 内置 PostgreSQL" Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service)) $containerId = Get-ServiceContainerId -ComposeArgs $ComposeArgs -Service $Service $containerBackupPath = "/tmp/flnxi-backup.sql" Invoke-DockerCompose ($ComposeArgs + @( "exec", "-T", $Service, "sh", "-c", 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges -f /tmp/flnxi-backup.sql && test -s /tmp/flnxi-backup.sql' )) & docker cp "${containerId}:${containerBackupPath}" $OutputPath if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) { throw "数据库备份复制失败:$OutputPath" } Invoke-DockerCompose ($ComposeArgs + @("exec", "-T", $Service, "sh", "-c", "rm -f $containerBackupPath")) Write-Ok "数据库备份完成:$OutputPath" } function Restore-Database { param([string[]]$ComposeArgs, [string]$Service, [string]$InputPath) Write-Info "恢复 Compose 内置 PostgreSQL" Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service)) $containerId = Get-ServiceContainerId -ComposeArgs $ComposeArgs -Service $Service $containerRestorePath = "/tmp/flnxi-restore.sql" & docker cp $InputPath "${containerId}:${containerRestorePath}" if ($LASTEXITCODE -ne 0) { throw "数据库备份文件复制到容器失败" } Invoke-DockerCompose ($ComposeArgs + @( "exec", "-T", $Service, "sh", "-c", 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/flnxi-restore.sql' )) Invoke-DockerCompose ($ComposeArgs + @("exec", "-T", $Service, "sh", "-c", "rm -f $containerRestorePath")) Write-Ok "数据库恢复完成" } function Backup-ObjectStorage { param([string]$VolumeName, [string]$OutputPath, [string]$HelperImage) if ([string]::IsNullOrWhiteSpace($VolumeName)) { throw "MINIO_VOLUME_NAME 为空,无法备份内置对象存储卷。" } Write-Info "备份 Compose 内置 MinIO 数据卷:$VolumeName" $backupDir = Split-Path -Parent $OutputPath & docker volume inspect $VolumeName *> $null if ($LASTEXITCODE -ne 0) { throw "未找到 MinIO 数据卷:$VolumeName" } & docker run --rm -v "${VolumeName}:/source:ro" -v "${backupDir}:/backup" $HelperImage sh -c "cd /source && tar -czf /backup/minio-data.tar.gz ." if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) { throw "对象存储备份失败。请确认 helper 镜像可用:$HelperImage" } Write-Ok "对象存储备份完成:$OutputPath" } function Restore-ObjectStorage { param([string]$VolumeName, [string]$InputPath, [string]$HelperImage) if ([string]::IsNullOrWhiteSpace($VolumeName)) { throw "MINIO_VOLUME_NAME 为空,无法恢复内置对象存储卷。" } $backupDir = Split-Path -Parent $InputPath Write-Info "恢复 Compose 内置 MinIO 数据卷:$VolumeName" & docker volume inspect $VolumeName *> $null if ($LASTEXITCODE -ne 0) { & docker volume create $VolumeName | Out-Null if ($LASTEXITCODE -ne 0) { throw "创建 MinIO 数据卷失败:$VolumeName" } } & docker run --rm -v "${VolumeName}:/target" -v "${backupDir}:/backup:ro" $HelperImage sh -c "rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/minio-data.tar.gz" if ($LASTEXITCODE -ne 0) { throw "对象存储恢复失败。请确认 helper 镜像可用:$HelperImage" } Write-Ok "对象存储恢复完成" } function Save-CommandOutput { param([string]$Path, [scriptblock]$Command) try { & $Command *>&1 | Out-File -LiteralPath $Path -Encoding utf8 } catch { "命令执行失败:$($_.Exception.Message)" | Out-File -LiteralPath $Path -Encoding utf8 } } function Invoke-Verify { param([string[]]$ComposeArgs, [hashtable]$EnvValues, [int]$TimeoutSeconds) Write-Info "检查 Compose 配置" Invoke-DockerCompose ($ComposeArgs + @("config", "--quiet")) Write-Info "查看容器状态" Invoke-DockerCompose ($ComposeArgs + @("ps")) $publicUrl = Get-EnvValue $EnvValues "APP_PUBLIC_URL" if ([string]::IsNullOrWhiteSpace($publicUrl)) { $publicUrl = Get-EnvValue $EnvValues "NEXTAUTH_URL" } if ([string]::IsNullOrWhiteSpace($publicUrl)) { Write-Warn "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,跳过 /api/health HTTP 验收。" return } $healthUrl = $publicUrl.TrimEnd("/") + "/api/health" Write-Info "检查健康接口:$healthUrl" $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { try { $response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 10 if ($response.StatusCode -eq 200) { Write-Ok "/api/health 正常" return } } catch { Start-Sleep -Seconds 5 } } while ((Get-Date) -lt $deadline) throw "/api/health 在 $TimeoutSeconds 秒内没有通过:$healthUrl" } $resolvedProjectDir = Resolve-ProjectPath $ProjectDir Push-Location $resolvedProjectDir try { $composePath = Resolve-ExistingFile $ComposeFile "Compose 文件" $envPath = Join-Path $resolvedProjectDir $EnvFile $envValues = Read-EnvFile $envPath $composeArgs = Get-ComposeArgs -ComposeFilePath $composePath -EnvFilePath $envPath switch ($Action) { "Backup" { $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $backupRoot = Join-Path $resolvedProjectDir $BackupDir $targetDir = Join-Path $backupRoot "flnxi-backup-$timestamp" New-Item -ItemType Directory -Path $targetDir -Force | Out-Null Copy-Item -LiteralPath $composePath -Destination (Join-Path $targetDir "docker-compose.prod.yml") -Force if (Test-Path -LiteralPath $envPath -PathType Leaf) { Copy-Item -LiteralPath $envPath -Destination (Join-Path $targetDir ".env") -Force } if (Test-Path -LiteralPath "VERSION.txt" -PathType Leaf) { Copy-Item -LiteralPath "VERSION.txt" -Destination (Join-Path $targetDir "VERSION.txt") -Force } Save-CommandOutput -Path (Join-Path $targetDir "compose-config.txt") -Command { & docker @composeArgs config } Save-CommandOutput -Path (Join-Path $targetDir "compose-ps.txt") -Command { & docker @composeArgs ps } Save-CommandOutput -Path (Join-Path $targetDir "docker-images.txt") -Command { & docker images --digests } $externalDb = Test-ExternalDatabase $envValues if ($SkipDatabaseBackup) { "已按参数跳过数据库备份。请确认外部备份已存在。" | Out-File -LiteralPath (Join-Path $targetDir "DATABASE_BACKUP_SKIPPED.txt") -Encoding utf8 } elseif ($externalDb) { "检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。" | Out-File -LiteralPath (Join-Path $targetDir "EXTERNAL_DATABASE_BACKUP_REQUIRED.txt") -Encoding utf8 } else { Backup-Database -ComposeArgs $composeArgs -Service $PostgresService -OutputPath (Join-Path $targetDir "postgres.sql") } $minioVolumeName = Get-EnvValue $envValues "MINIO_VOLUME_NAME" "flnxi-minio-data" if ($SkipObjectStorageBackup) { "已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。" | Out-File -LiteralPath (Join-Path $targetDir "OBJECT_STORAGE_BACKUP_SKIPPED.txt") -Encoding utf8 } else { Backup-ObjectStorage -VolumeName $minioVolumeName -OutputPath (Join-Path $targetDir "minio-data.tar.gz") -HelperImage $HelperImage } $manifest = [ordered]@{ createdAt = (Get-Date).ToString("s") projectDir = $resolvedProjectDir composeFile = $composePath envFile = $envPath appImage = Get-EnvValue $envValues "APP_IMAGE" appPublicUrl = Get-EnvValue $envValues "APP_PUBLIC_URL" postgresService = $PostgresService minioVolumeName = $minioVolumeName externalDatabase = $externalDb restoreConfirmPhrase = $RestoreConfirmPhrase } $manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $targetDir "manifest.json") -Encoding utf8 Write-Ok "备份目录:$targetDir" } "Restore" { if ([string]::IsNullOrWhiteSpace($BackupPath)) { throw "恢复必须传入 -BackupPath。" } if ($ConfirmRestore -ne $RestoreConfirmPhrase) { throw "恢复会覆盖客户数据。请显式传入 -ConfirmRestore $RestoreConfirmPhrase。" } $sourceDir = Resolve-ProjectPath $BackupPath $dbBackup = Join-Path $sourceDir "postgres.sql" $objectBackup = Join-Path $sourceDir "minio-data.tar.gz" Write-Warn "即将恢复备份目录:$sourceDir" Invoke-DockerCompose ($composeArgs + @("stop", $AppService)) if ($RestoreEnv -and (Test-Path -LiteralPath (Join-Path $sourceDir ".env") -PathType Leaf)) { $envBackupPath = Join-Path $resolvedProjectDir (".env.before-restore-" + (Get-Date -Format "yyyyMMdd-HHmmss")) if (Test-Path -LiteralPath $envPath -PathType Leaf) { Copy-Item -LiteralPath $envPath -Destination $envBackupPath -Force } Copy-Item -LiteralPath (Join-Path $sourceDir ".env") -Destination $envPath -Force $envValues = Read-EnvFile $envPath Write-Ok ".env 已恢复,恢复前副本:$envBackupPath" } if (Test-Path -LiteralPath $dbBackup -PathType Leaf) { Restore-Database -ComposeArgs $composeArgs -Service $PostgresService -InputPath $dbBackup } else { Write-Warn "备份目录没有 postgres.sql,跳过数据库恢复。" } if (-not $SkipObjectStorageRestore -and (Test-Path -LiteralPath $objectBackup -PathType Leaf)) { Invoke-DockerCompose ($composeArgs + @("stop", $MinioService)) $minioVolumeName = Get-EnvValue $envValues "MINIO_VOLUME_NAME" "flnxi-minio-data" Restore-ObjectStorage -VolumeName $minioVolumeName -InputPath $objectBackup -HelperImage $HelperImage } elseif ($SkipObjectStorageRestore) { Write-Warn "已按参数跳过对象存储恢复。" } else { Write-Warn "备份目录没有 minio-data.tar.gz,跳过对象存储恢复。" } Invoke-DockerCompose ($composeArgs + @("up", "-d")) Invoke-Verify -ComposeArgs $composeArgs -EnvValues (Read-EnvFile $envPath) -TimeoutSeconds $HealthTimeoutSeconds Write-Ok "恢复和基础验收完成" } "Verify" { Invoke-Verify -ComposeArgs $composeArgs -EnvValues $envValues -TimeoutSeconds $HealthTimeoutSeconds } } } finally { Pop-Location }