param( [string]$ProjectDir = ".", [string]$ComposeFile = "docker-compose.prod.yml", [string]$EnvFile = ".env", [string]$AppService = "app", [string]$PostgresService = "postgres", [string]$MinioService = "minio", [string]$ExpectedVersion = "", [switch]$Start, [switch]$PreflightOnly, [switch]$PrepareDirectories, [switch]$SkipMigrationStatus, [switch]$SkipObjectStorageCheck, [int]$HealthTimeoutSeconds = 120 ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" 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 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 Test-PlaceholderValue { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $true } $trimmed = $Value.Trim() if ($trimmed -match '^(replace-with|your-|<|>)') { return $true } if ($trimmed -match 'example\.com' -or $trimmed -match '192\.168\.x\.x' -or $trimmed -match '\bx\.x\b') { return $true } return $false } function Add-Problem { param( [System.Collections.Generic.List[string]]$Problems, [string]$Message ) $Problems.Add($Message) | Out-Null } function Test-RequiredEnv { param( [hashtable]$EnvValues, [System.Collections.Generic.List[string]]$Problems, [string]$Name ) $value = Get-EnvValue $EnvValues $Name if (Test-PlaceholderValue $value) { Add-Problem $Problems "$Name 还不是客户现场真实值" } } function Test-AppImage { param( [hashtable]$EnvValues, [System.Collections.Generic.List[string]]$Problems ) $appImage = Get-EnvValue $EnvValues "APP_IMAGE" if (Test-PlaceholderValue $appImage) { Add-Problem $Problems "APP_IMAGE 还不是完整客户版本镜像" return } $imageName = ($appImage -split '/')[-1] $firstPart = ($appImage -split '/', 2)[0] if ($appImage -notlike "*/*" -or ($firstPart -ne "localhost" -and $firstPart -notmatch '[.:]')) { Add-Problem $Problems "APP_IMAGE 必须写完整镜像站地址,例如 registry.example.com:5000/flnxi/material/flnxi-material:1.0.1" } if ($imageName -match ':latest$') { Add-Problem $Problems "APP_IMAGE 不能使用 latest,必须固定到明确版本号" } if ($appImage -notmatch '@sha256:' -and $imageName -notmatch ':.+') { Add-Problem $Problems "APP_IMAGE 缺少版本标签,例如 :1.0.1" } if ($appImage -match '/material-dev/') { Write-Warn "APP_IMAGE 当前指向 material-dev 测试镜像;正式客户交付应使用 material 正式镜像。" } } 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 Test-DirectoryReady { param( [string]$ProjectDir, [System.Collections.Generic.List[string]]$Problems, [string]$Name, [switch]$Prepare ) $path = Join-Path $ProjectDir $Name if (-not (Test-Path -LiteralPath $path -PathType Container)) { if ($Prepare) { New-Item -ItemType Directory -Path $path -Force | Out-Null Write-Ok "已创建目录:$Name" } else { Add-Problem $Problems "缺少 $Name 目录;可先创建并确保应用容器用户可写" return } } $testFile = Join-Path $path ".flnxi-write-test" try { "ok" | Out-File -LiteralPath $testFile -Encoding utf8 -Force Remove-Item -LiteralPath $testFile -Force } catch { Add-Problem $Problems "$Name 目录当前不可写:$($_.Exception.Message)" } } function Invoke-InstallPreflight { param( [string]$ProjectDir, [string]$EnvPath, [hashtable]$EnvValues, [switch]$PrepareDirectories ) Write-Info "执行安装前检查" $problems = [System.Collections.Generic.List[string]]::new() if (-not (Test-Path -LiteralPath $EnvPath -PathType Leaf)) { Add-Problem $problems ".env 文件不存在,请先从 .env.example 复制并填写客户现场值" } foreach ($name in @( "APP_PUBLIC_URL", "NEXTAUTH_URL", "NEXTAUTH_SECRET", "LICENSE_SERVER_URL", "LICENSE_PROJECT_CODE", "LICENSE_REQUEST_SIGNING_SECRET", "LICENSE_PUBLIC_KEY", "LOCAL_ENCRYPTION_KEY", "POSTGRES_PASSWORD", "S3_PUBLIC_DOMAIN" )) { Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name } Test-AppImage -EnvValues $EnvValues -Problems $problems $databaseUrl = Get-EnvValue $EnvValues "DATABASE_URL" if (-not [string]::IsNullOrWhiteSpace($databaseUrl) -and (Test-PlaceholderValue $databaseUrl)) { Add-Problem $problems "DATABASE_URL 仍是示例地址;内置 PostgreSQL 请留空,外部 PostgreSQL 请填真实连接串" } $externalObjectStorage = Test-ExternalObjectStorage $EnvValues if ($externalObjectStorage) { foreach ($name in @("S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY")) { Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name } } else { foreach ($name in @("MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD", "S3_BUCKET")) { Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name } } $appPublicUrl = (Get-EnvValue $EnvValues "APP_PUBLIC_URL").TrimEnd("/") $nextAuthUrl = (Get-EnvValue $EnvValues "NEXTAUTH_URL").TrimEnd("/") if (-not [string]::IsNullOrWhiteSpace($appPublicUrl) -and -not [string]::IsNullOrWhiteSpace($nextAuthUrl) -and $appPublicUrl -ne $nextAuthUrl) { Write-Warn "APP_PUBLIC_URL 与 NEXTAUTH_URL 不一致,请确认最终浏览器访问入口。" } Test-DirectoryReady -ProjectDir $ProjectDir -Problems $problems -Name "uploads" -Prepare:$PrepareDirectories Test-DirectoryReady -ProjectDir $ProjectDir -Problems $problems -Name "data" -Prepare:$PrepareDirectories if ($problems.Count -gt 0) { $message = "安装前检查未通过:`n - " + ($problems -join "`n - ") throw $message } Write-Ok "安装前检查通过" } 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-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 Wait-Health { param([string]$HealthUrl, [int]$TimeoutSeconds, [string]$ExpectedVersion = "") $deadline = (Get-Date).AddSeconds($TimeoutSeconds) $lastVersion = "" do { try { $response = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 10 if ($response.StatusCode -eq 200) { if ([string]::IsNullOrWhiteSpace($ExpectedVersion)) { Write-Ok "/api/health 正常" return } try { $healthData = $response.Content | ConvertFrom-Json $lastVersion = "$($healthData.version)".Trim() } catch { $lastVersion = "" } if ($lastVersion -eq $ExpectedVersion) { Write-Ok "/api/health 正常,版本符合预期:$ExpectedVersion" return } } } catch { Start-Sleep -Seconds 5 } } while ((Get-Date) -lt $deadline) if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { $actual = if ([string]::IsNullOrWhiteSpace($lastVersion)) { "未返回" } else { $lastVersion } throw "/api/health 在 $TimeoutSeconds 秒内没有返回期望版本:实际 $actual,预期 $ExpectedVersion" } throw "/api/health 在 $TimeoutSeconds 秒内没有通过:$HealthUrl" } function Wait-ServiceRunning { param([string]$ContainerId, [string]$Service, [int]$TimeoutSeconds) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { $status = (& docker inspect $ContainerId --format '{{.State.Status}}').Trim() if ($LASTEXITCODE -eq 0 -and $status -eq "running") { Write-Ok "$Service 容器已运行" return } Start-Sleep -Seconds 5 } while ((Get-Date) -lt $deadline) throw "$Service 容器在 $TimeoutSeconds 秒内没有稳定运行。" } $resolvedProjectDir = (Resolve-Path -LiteralPath $ProjectDir).Path Push-Location $resolvedProjectDir try { if (-not (Test-Path -LiteralPath $ComposeFile -PathType Leaf)) { throw "Compose 文件不存在:$ComposeFile" } $composePath = (Resolve-Path -LiteralPath $ComposeFile).Path $envPath = Join-Path $resolvedProjectDir $EnvFile $envValues = Read-EnvFile $envPath $composeArgs = @("compose", "-f", $composePath) if (Test-Path -LiteralPath $envPath -PathType Leaf) { $composeArgs += @("--env-file", $envPath) } Invoke-InstallPreflight -ProjectDir $resolvedProjectDir -EnvPath $envPath -EnvValues $envValues -PrepareDirectories:$PrepareDirectories if ($PreflightOnly) { Write-Ok "安装前检查完成,未启动或检查容器。" return } Write-Info "检查 Compose 配置" Invoke-DockerCompose ($composeArgs + @("config", "--quiet")) if ($Start) { Write-Info "启动客户安装编排" Invoke-DockerCompose ($composeArgs + @("up", "-d")) } Write-Info "检查容器状态" Invoke-DockerCompose ($composeArgs + @("ps")) $appContainerId = Get-ServiceContainerId -ComposeArgs $composeArgs -Service $AppService Wait-ServiceRunning -ContainerId $appContainerId -Service $AppService -TimeoutSeconds $HealthTimeoutSeconds $runningImage = (& docker inspect $appContainerId --format '{{.Config.Image}}').Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runningImage)) { throw "无法读取应用运行镜像。" } Write-Ok "应用运行镜像:$runningImage" $versionCommand = "cat VERSION.txt 2>/dev/null || true" $versionOutput = & docker @composeArgs exec -T $AppService sh -c $versionCommand $version = "$versionOutput".Trim() if (-not [string]::IsNullOrWhiteSpace($version)) { Write-Ok "应用版本文件:$version" } else { Write-Host "[WARN] 镜像内未读到 VERSION.txt,请确认发布镜像是否包含版本文件。" } if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { if ([string]::IsNullOrWhiteSpace($version)) { throw "镜像内未读到 VERSION.txt,无法确认期望版本:$ExpectedVersion" } if ($version -ne $ExpectedVersion) { throw "应用版本不符合预期:实际 $version,预期 $ExpectedVersion" } Write-Ok "应用版本符合预期:$ExpectedVersion" } $publicUrl = Get-EnvValue $envValues "APP_PUBLIC_URL" if ([string]::IsNullOrWhiteSpace($publicUrl)) { $publicUrl = Get-EnvValue $envValues "NEXTAUTH_URL" } if ([string]::IsNullOrWhiteSpace($publicUrl)) { throw "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,无法验收健康接口。" } Wait-Health -HealthUrl ($publicUrl.TrimEnd("/") + "/api/health") -TimeoutSeconds $HealthTimeoutSeconds -ExpectedVersion $ExpectedVersion if (-not $SkipMigrationStatus) { Write-Info "检查数据库迁移状态" Invoke-DockerCompose ($composeArgs + @("exec", "-T", $AppService, "npx", "prisma", "migrate", "status", "--schema=prisma/schema.prisma")) } $externalObjectStorage = Test-ExternalObjectStorage $envValues if ($externalObjectStorage -and -not $SkipObjectStorageCheck) { Write-Warn "检测到外部对象存储,跳过内置 MinIO bucket 检查;请按客户现场对象存储方案单独验收。" } elseif (-not $SkipObjectStorageCheck) { $bucket = Get-EnvValue $envValues "S3_BUCKET" "flnxi-materials" Write-Info "检查 MinIO bucket:$bucket" Invoke-DockerCompose ($composeArgs + @("exec", "-T", $MinioService, "sh", "-c", "test -d /data/$bucket")) Write-Ok "对象存储 bucket 存在:$bucket" } Write-Info "最近应用日志" & docker @composeArgs logs --tail 80 $AppService if ($LASTEXITCODE -ne 0) { throw "读取应用日志失败。" } Write-Ok "首装基础验收完成" } finally { Pop-Location }