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]$SkipStopAppForBackup, [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 Get-OptionalServiceContainerId { param([string[]]$ComposeArgs, [string]$Service) $containerId = (& docker @ComposeArgs ps -q $Service 2>$null).Trim() if ($LASTEXITCODE -ne 0) { return "" } return $containerId } function Test-ComposeServiceRunning { param([string[]]$ComposeArgs, [string]$Service) $containerId = Get-OptionalServiceContainerId -ComposeArgs $ComposeArgs -Service $Service if ([string]::IsNullOrWhiteSpace($containerId)) { return $false } $status = (& docker inspect $containerId --format '{{.State.Status}}' 2>$null).Trim() return ($LASTEXITCODE -eq 0 -and $status -eq "running") } function Stop-AppForConsistentBackup { param([string[]]$ComposeArgs, [string]$Service) if ($SkipStopAppForBackup) { Write-Warn "已跳过备份停写入步骤;请确认客户现场没有人正在上传、编辑或删除素材。" return $false } if (-not (Test-ComposeServiceRunning -ComposeArgs $ComposeArgs -Service $Service)) { Write-Info "$Service 服务未运行或不存在,跳过停写入步骤。" return $false } Write-Info "进入备份维护窗口:暂时停止 $Service 服务,避免备份期间继续写入数据库或素材文件。" Invoke-DockerCompose ($ComposeArgs + @("stop", $Service)) return $true } function Restart-AppAfterConsistentBackup { param([string[]]$ComposeArgs, [string]$Service) Write-Info "备份维护窗口结束:恢复启动 $Service 服务。" Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service)) } function Test-ExternalDatabase { param([hashtable]$EnvValues) $databaseUrl = Get-EnvValue $EnvValues "DATABASE_URL" if ([string]::IsNullOrWhiteSpace($databaseUrl)) { return $false } try { $uri = [Uri]$databaseUrl if (-not [string]::IsNullOrWhiteSpace($uri.Host)) { return ($uri.Host -ne "postgres") } } catch { # 继续走兼容解析,避免异常 URL 直接中断备份入口。 } if ($databaseUrl -match '@([^/:?]+)') { return ($Matches[1] -ne "postgres") } return $false } function Test-ExternalObjectStorage { param([hashtable]$EnvValues) $endpoint = Get-EnvValue $EnvValues "S3_ENDPOINT" if ([string]::IsNullOrWhiteSpace($endpoint)) { return $false } try { $uri = [Uri]$endpoint return ($uri.Host -ne "minio") } catch { return $false } } function Backup-LocalDirectory { param( [string]$ProjectDir, [string]$Name, [string]$OutputPath ) $sourcePath = Join-Path $ProjectDir $Name if (-not (Test-Path -LiteralPath $sourcePath -PathType Container)) { return $false } Compress-Archive -LiteralPath $sourcePath -DestinationPath $OutputPath -Force Write-Ok "本地目录备份完成:$Name -> $OutputPath" return $true } function Restore-LocalDirectory { param( [string]$ProjectDir, [string]$Name, [string]$InputPath ) if (-not (Test-Path -LiteralPath $InputPath -PathType Leaf)) { return } $targetPath = Join-Path $ProjectDir $Name if (Test-Path -LiteralPath $targetPath -PathType Container) { $beforeRestorePath = Join-Path $ProjectDir ("$Name.before-restore-" + (Get-Date -Format "yyyyMMdd-HHmmss")) Move-Item -LiteralPath $targetPath -Destination $beforeRestorePath -Force Write-Ok "$Name 恢复前副本:$beforeRestorePath" } Expand-Archive -LiteralPath $InputPath -DestinationPath $ProjectDir -Force Write-Ok "本地目录恢复完成:$Name" } 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" { $appWasRunningBeforeBackup = Stop-AppForConsistentBackup -ComposeArgs $composeArgs -Service $AppService try { $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" $externalObjectStorage = Test-ExternalObjectStorage $envValues if ($SkipObjectStorageBackup) { "已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。" | Out-File -LiteralPath (Join-Path $targetDir "OBJECT_STORAGE_BACKUP_SKIPPED.txt") -Encoding utf8 } elseif ($externalObjectStorage) { "检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。" | Out-File -LiteralPath (Join-Path $targetDir "EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt") -Encoding utf8 } else { Backup-ObjectStorage -VolumeName $minioVolumeName -OutputPath (Join-Path $targetDir "minio-data.tar.gz") -HelperImage $HelperImage } $localDataBackedUp = Backup-LocalDirectory -ProjectDir $resolvedProjectDir -Name "data" -OutputPath (Join-Path $targetDir "data.zip") $localUploadsBackedUp = Backup-LocalDirectory -ProjectDir $resolvedProjectDir -Name "uploads" -OutputPath (Join-Path $targetDir "uploads.zip") $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 externalObjectStorage = $externalObjectStorage localDirectories = [ordered]@{ data = $localDataBackedUp uploads = $localUploadsBackedUp } appStoppedForConsistentBackup = $appWasRunningBeforeBackup restoreConfirmPhrase = $RestoreConfirmPhrase } $manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $targetDir "manifest.json") -Encoding utf8 Write-Ok "备份目录:$targetDir" } finally { if ($appWasRunningBeforeBackup) { Restart-AppAfterConsistentBackup -ComposeArgs $composeArgs -Service $AppService } } } "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,跳过数据库恢复。" } $externalObjectStorage = Test-ExternalObjectStorage $envValues if ($externalObjectStorage) { Write-Warn "检测到当前 .env 使用外部对象存储,脚本不会恢复 Compose 内置 MinIO 卷;请按客户现场对象存储备份方案恢复。" } elseif (-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,跳过对象存储恢复。" } Restore-LocalDirectory -ProjectDir $resolvedProjectDir -Name "data" -InputPath (Join-Path $sourceDir "data.zip") Restore-LocalDirectory -ProjectDir $resolvedProjectDir -Name "uploads" -InputPath (Join-Path $sourceDir "uploads.zip") 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 }