From d359a411d2e4e19a72e0549e478f6fa8783dbc9e Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 1 Jun 2026 17:16:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=BA=E5=8C=96=E5=AE=A2=E6=88=B7=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E7=89=88=E6=9C=AC=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/health/__tests__/route.test.ts | 3 +++ app/api/health/route.ts | 21 +++++++++++++++++++ docs/deployment/部署说明.md | 15 +++++++------ .../customer-acceptance-smoke-script.test.ts | 16 ++++++++++++++ .../__tests__/customer-install-script.test.ts | 2 ++ .../__tests__/smoke-install-script.test.ts | 3 +++ scripts/customer-acceptance-smoke.mjs | 17 ++++++++++++--- scripts/customer-install.ps1 | 7 +++++++ scripts/customer-install.sh | 12 +++++++++++ scripts/smoke-install.ps1 | 15 +++++++++++++ scripts/smoke-install.sh | 16 ++++++++++++++ 11 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 scripts/__tests__/customer-acceptance-smoke-script.test.ts diff --git a/app/api/health/__tests__/route.test.ts b/app/api/health/__tests__/route.test.ts index c3cf0bd..a4131a5 100644 --- a/app/api/health/__tests__/route.test.ts +++ b/app/api/health/__tests__/route.test.ts @@ -1,4 +1,6 @@ import { GET, HEAD } from '../route' +import fs from 'fs' +import path from 'path' jest.mock('@/lib/startup-check', () => ({ runStartupCheck: jest.fn(), @@ -35,6 +37,7 @@ describe('/api/health', () => { expect(runStartupCheck).toHaveBeenCalledTimes(1) expect(response.status).toBe(200) expect(data.status).toBe('ok') + expect(data.version).toBe(fs.readFileSync(path.join(process.cwd(), 'VERSION.txt'), 'utf8').trim()) expect(data.systemCheck.success).toBe(true) expect(data.systemCheck.errorCount).toBe(0) }) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index eebe96b..20f592c 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,6 +1,25 @@ import { NextResponse } from 'next/server' +import { readFile } from 'node:fs/promises' +import path from 'node:path' import { runStartupCheck, getStartupCheckStatus } from '@/lib/startup-check' +let cachedAppVersion: string | null = null + +async function getAppVersion() { + if (cachedAppVersion) { + return cachedAppVersion + } + + try { + const content = await readFile(path.join(process.cwd(), 'VERSION.txt'), 'utf8') + cachedAppVersion = content.trim() || 'unknown' + } catch { + cachedAppVersion = 'unknown' + } + + return cachedAppVersion +} + async function resolveHealthStatus() { const status = getStartupCheckStatus() @@ -23,9 +42,11 @@ async function resolveHealthStatus() { // 首次调用时会自动执行系统初始化(创建内置模板等) export async function GET() { const { finalStatus, httpStatus, statusLabel } = await resolveHealthStatus() + const version = await getAppVersion() return NextResponse.json({ status: statusLabel, + version, timestamp: new Date().toISOString(), systemCheck: { executed: finalStatus.executed, diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index d6a68ed..28bff88 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -84,7 +84,7 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 - `SERVER_IP` 必须是用户浏览器能访问到的 NAS / 服务器地址。 - `APP_PUBLIC_URL` 必须和最终访问入口一致,用于生成公开访问、分享和回调相关地址。 - `NEXTAUTH_URL` 必须和最终访问入口一致。 -- `APP_IMAGE` 必须写完整镜像地址和版本号,不要只写 `latest`。 +- `APP_IMAGE` 必须写完整镜像站地址和版本号,例如 `registry.example.com:5000/flnxi/material/flnxi-material:1.0.1`;不要只写 `flnxi-material:1.0.1` 或 `latest`。 - `POSTGRES_IMAGE`、`MINIO_IMAGE`、`MINIO_MC_IMAGE` 建议同样固定版本;离线或受控网络交付时,应写成私有 Registry 里的固定版本镜像。 - `LICENSE_PROJECT_CODE` 必须和 `license-server` 中项目表的 `projects.code` 一致。 - `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。 @@ -302,7 +302,7 @@ bash scripts/customer-install.sh --project-dir /opt/flnxi --plan-only 确认 `.env`、镜像和数据目录都准备好后,让脚本按步骤预检、启动并验收: ```bash -sudo bash scripts/customer-install.sh --project-dir /opt/flnxi --prepare-directories --start +sudo bash scripts/customer-install.sh --project-dir /opt/flnxi --prepare-directories --start --expect-version 1.0.1 ``` `customer-install.sh` 只是对 `smoke-install.sh` 的分步骤包装,不替代 `docker-compose.prod.yml`,也不把 Agent 当成唯一安装入口。`--prepare-directories` 在 root / sudo 运行时会把 `uploads/` 和 `data/` 调整为应用容器 UID `1001` 可写,能提前拦截飞牛 / NAS 常见目录权限问题。 @@ -322,7 +322,7 @@ Windows 实施机或已安装 PowerShell 的现场,可以使用对应 `.ps1` 确认 `.env`、镜像和数据目录都准备好后,让脚本按步骤预检、启动并验收: ```powershell -.\scripts\customer-install.ps1 -ProjectDir "D:\deploy\flnxi" -PrepareDirectories -Start +.\scripts\customer-install.ps1 -ProjectDir "D:\deploy\flnxi" -PrepareDirectories -Start -ExpectedVersion 1.0.1 ``` `customer-install.ps1` 只是对 `smoke-install.ps1` 的分步骤包装,不替代 `docker-compose.prod.yml`,也不把 Agent 当成唯一安装入口。 @@ -338,7 +338,7 @@ Windows 实施机或已安装 PowerShell 的现场,可以使用对应 `.ps1` 首装后优先使用脚本做基础验收: ```powershell -.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -Start +.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -Start -ExpectedVersion 1.0.1 ``` 如果服务已经由 NAS 图形界面或其他编排入口启动,可以去掉 `-Start`,只做检查: @@ -347,7 +347,7 @@ Windows 实施机或已安装 PowerShell 的现场,可以使用对应 `.ps1` .\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" ``` -这些脚本会先做安装前检查,再检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。如果客户使用外部对象存储,`.sh` 脚本加 `--skip-object-storage-check`,`.ps1` 脚本加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。 +这些脚本会先做安装前检查,再检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。建议每次首装或升级验收都带上期望版本,`.sh` 使用 `--expect-version x.x.x`,`.ps1` 使用 `-ExpectedVersion x.x.x`,这样现场可以直接确认运行的就是本次交付版本。如果客户使用外部对象存储,`.sh` 脚本加 `--skip-object-storage-check`,`.ps1` 脚本加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。 人工验收命令仍保留如下: @@ -365,7 +365,8 @@ Windows 实施机或已安装 PowerShell 的现场,可以使用对应 `.ps1` ```bash npm run smoke:customer -- \ - --base-url http://:4320 + --base-url http://:4320 \ + --expect-version 1.0.1 ``` 提供临时验收账号后,可以继续验登录、后台权限和模板接口: @@ -373,6 +374,7 @@ npm run smoke:customer -- \ ```bash npm run smoke:customer -- \ --base-url http://:4320 \ + --expect-version 1.0.1 \ --email \ --password ``` @@ -382,6 +384,7 @@ npm run smoke:customer -- \ ```bash npm run smoke:customer -- \ --base-url http://:4320 \ + --expect-version 1.0.1 \ --email \ --password \ --write diff --git a/scripts/__tests__/customer-acceptance-smoke-script.test.ts b/scripts/__tests__/customer-acceptance-smoke-script.test.ts new file mode 100644 index 0000000..7d1245c --- /dev/null +++ b/scripts/__tests__/customer-acceptance-smoke-script.test.ts @@ -0,0 +1,16 @@ +import fs from 'fs' +import path from 'path' + +describe('scripts/customer-acceptance-smoke.mjs', () => { + const script = fs.readFileSync( + path.join(process.cwd(), 'scripts/customer-acceptance-smoke.mjs'), + 'utf8' + ) + + it('客户业务验收支持校验健康接口返回的版本', () => { + expect(script).toContain('--expect-version') + expect(script).toContain('SMOKE_EXPECT_VERSION') + expect(script).toContain('/api/health 版本不符合预期') + expect(script).toContain('healthJson?.version') + }) +}) diff --git a/scripts/__tests__/customer-install-script.test.ts b/scripts/__tests__/customer-install-script.test.ts index 97829b5..0c7a50f 100644 --- a/scripts/__tests__/customer-install-script.test.ts +++ b/scripts/__tests__/customer-install-script.test.ts @@ -17,8 +17,10 @@ describe('scripts/customer-install.ps1', () => { expect(script).toContain('[switch]$PlanOnly') expect(script).toContain('[switch]$PrepareDirectories') expect(script).toContain('[switch]$Start') + expect(script).toContain('[string]$ExpectedVersion = ""') expect(script).toContain('-PreflightOnly') expect(script).toContain('-Start') + expect(script).toContain('-ExpectedVersion') }) it('把客户验收拆成清楚的四步', () => { diff --git a/scripts/__tests__/smoke-install-script.test.ts b/scripts/__tests__/smoke-install-script.test.ts index a06926f..f20713d 100644 --- a/scripts/__tests__/smoke-install-script.test.ts +++ b/scripts/__tests__/smoke-install-script.test.ts @@ -20,6 +20,7 @@ describe('scripts/smoke-install.ps1', () => { expect(script).toContain('Invoke-InstallPreflight') expect(script).toContain('Test-PlaceholderValue') expect(script).toContain('APP_IMAGE 不能使用 latest') + expect(script).toContain('APP_IMAGE 必须写完整镜像站地址') expect(script).toContain('DATABASE_URL 仍是示例地址') expect(script).toContain('缺少 $Name 目录') expect(script).toContain('安装前检查完成,未启动或检查容器。') @@ -29,6 +30,8 @@ describe('scripts/smoke-install.ps1', () => { expect(script).toContain('Wait-ServiceRunning') expect(script).toContain('docker inspect $appContainerId --format') expect(script).toContain('cat VERSION.txt') + expect(script).toContain('[string]$ExpectedVersion = ""') + expect(script).toContain('应用版本符合预期') expect(script).toContain('npx", "prisma", "migrate", "status"') expect(script).toContain('/api/health') }) diff --git a/scripts/customer-acceptance-smoke.mjs b/scripts/customer-acceptance-smoke.mjs index 27ea3af..7d52d20 100644 --- a/scripts/customer-acceptance-smoke.mjs +++ b/scripts/customer-acceptance-smoke.mjs @@ -11,6 +11,7 @@ function parseArgs(argv) { envFile: '.env.smoke', email: '', password: '', + expectVersion: '', write: false, expectAdmin: true, timeoutMs: DEFAULT_TIMEOUT_MS, @@ -31,6 +32,9 @@ function parseArgs(argv) { case '--password': args.password = argv[++index] || '' break + case '--expect-version': + args.expectVersion = argv[++index] || '' + break case '--write': args.write = true break @@ -63,6 +67,7 @@ function printHelp() { --env-file 读取 .env.smoke,默认 .env.smoke --email 验收账号邮箱,也可用 SMOKE_ADMIN_EMAIL --password 验收账号密码,也可用 SMOKE_ADMIN_PASSWORD + --expect-version 期望 /api/health 返回的版本,也可用 SMOKE_EXPECT_VERSION --write 登录后执行一次真实小素材上传,验证数据库和 MinIO 写入 --no-expect-admin 登录账号不是最高权限/管理员时使用 --timeout-ms 单个请求超时,默认 15000 @@ -71,6 +76,7 @@ function printHelp() { SMOKE_BASE_URL=http://127.0.0.1:4320 SMOKE_ADMIN_EMAIL=admin@example.com SMOKE_ADMIN_PASSWORD=replace-with-smoke-password + SMOKE_EXPECT_VERSION=1.0.1 `) } @@ -180,14 +186,18 @@ function logOk(message) { console.log(`[OK] ${message}`) } -async function checkPublic(baseUrl, timeoutMs) { +async function checkPublic(baseUrl, timeoutMs, expectVersion) { const health = await request(baseUrl, '/api/health', { method: 'GET' }, null, timeoutMs) assertStatus('/api/health', health, 200) const healthJson = await readJsonSafe(health) if (healthJson?.status !== 'ok') { throw new Error('/api/health 没有返回 status=ok') } - logOk('/api/health 正常') + const actualVersion = String(healthJson?.version || '').trim() + if (expectVersion && actualVersion !== expectVersion) { + throw new Error(`/api/health 版本不符合预期:实际 ${actualVersion || '未返回'},预期 ${expectVersion}`) + } + logOk(actualVersion ? `/api/health 正常,版本 ${actualVersion}` : '/api/health 正常') const init = await request(baseUrl, '/api/system/init', { method: 'GET' }, null, timeoutMs) assertStatus('/api/system/init', init, 200) @@ -322,9 +332,10 @@ async function main() { const baseUrl = normalizeBaseUrl(args.baseUrl || process.env.SMOKE_BASE_URL || env.SMOKE_BASE_URL || env.APP_PUBLIC_URL || env.NEXTAUTH_URL) const email = args.email || process.env.SMOKE_ADMIN_EMAIL || env.SMOKE_ADMIN_EMAIL const password = args.password || process.env.SMOKE_ADMIN_PASSWORD || env.SMOKE_ADMIN_PASSWORD + const expectVersion = args.expectVersion || process.env.SMOKE_EXPECT_VERSION || env.SMOKE_EXPECT_VERSION || '' console.log(`[INFO] 验收地址:${baseUrl}`) - await checkPublic(baseUrl, args.timeoutMs) + await checkPublic(baseUrl, args.timeoutMs, expectVersion) if (email || password || args.write) { const jar = await login(baseUrl, email, password, args.timeoutMs) diff --git a/scripts/customer-install.ps1 b/scripts/customer-install.ps1 index 8b55375..f0bf3b8 100644 --- a/scripts/customer-install.ps1 +++ b/scripts/customer-install.ps1 @@ -5,6 +5,7 @@ param( [string]$ProjectDir = ".", [string]$ComposeFile = "docker-compose.prod.yml", [string]$EnvFile = ".env", + [string]$ExpectedVersion = "", [switch]$Start, [switch]$PrepareDirectories, [switch]$PlanOnly, @@ -43,6 +44,9 @@ function Invoke-SmokeInstall { if ($SkipObjectStorageCheck) { $smokeArgs += "-SkipObjectStorageCheck" } + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + $smokeArgs += @("-ExpectedVersion", $ExpectedVersion) + } & powershell -ExecutionPolicy Bypass -File $scriptPath @smokeArgs @ExtraArgs if ($LASTEXITCODE -ne 0) { @@ -69,6 +73,9 @@ if ($PlanOnly) { Write-Host "如果已经通过 NAS 图形界面启动,则不加 -Start,脚本只做验收。" Write-Step 3 "运行首装验收" Write-Host "验收内容包括 Compose 配置、容器状态、运行镜像、VERSION.txt、/api/health、Prisma 迁移状态和 MinIO bucket。" + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + Write-Host "会额外确认当前运行版本等于:$ExpectedVersion。" + } Write-Step 4 "查看结果" Write-Host "所有步骤通过后,浏览器打开 APP_PUBLIC_URL / NEXTAUTH_URL。" Write-Host "如失败,根据脚本输出先修 .env、镜像、目录权限或容器日志。" diff --git a/scripts/customer-install.sh b/scripts/customer-install.sh index edc657f..4c76702 100644 --- a/scripts/customer-install.sh +++ b/scripts/customer-install.sh @@ -6,6 +6,7 @@ set -euo pipefail PROJECT_DIR="." COMPOSE_FILE="docker-compose.prod.yml" ENV_FILE=".env" +EXPECTED_VERSION="" START=0 PREPARE_DIRECTORIES=0 PLAN_ONLY=0 @@ -24,6 +25,7 @@ FLNXI NAS/Linux 客户首装向导 --project-dir 部署目录,默认当前目录 --compose-file Compose 文件名,默认 docker-compose.prod.yml --env-file 环境文件名,默认 .env + --expect-version 期望镜像内 VERSION.txt 版本 --start 执行 docker compose up -d --prepare-directories 缺少 uploads/data 时创建并检查权限 --plan-only 只打印步骤,不启动容器 @@ -48,6 +50,10 @@ while [[ $# -gt 0 ]]; do ENV_FILE="${2:?--env-file requires a value}" shift 2 ;; + --expect-version) + EXPECTED_VERSION="${2:?--expect-version requires a value}" + shift 2 + ;; --start) START=1 shift @@ -106,6 +112,9 @@ if [[ "$PLAN_ONLY" -eq 1 ]]; then printf '如果已经通过 NAS 图形界面启动,则不加 --start,脚本只做验收。\n' step 3 "运行首装验收" printf '验收内容包括 Compose 配置、容器状态、运行镜像、VERSION.txt、/api/health、Prisma 迁移状态和 MinIO bucket。\n' + if [[ -n "$EXPECTED_VERSION" ]]; then + printf '会额外确认当前运行版本等于:%s。\n' "$EXPECTED_VERSION" + fi step 4 "查看结果" printf '所有步骤通过后,浏览器打开 APP_PUBLIC_URL / NEXTAUTH_URL。\n' printf '如失败,根据脚本输出先修 .env、镜像、目录权限或容器日志。\n' @@ -125,6 +134,9 @@ COMMON_ARGS=( --env-file "$ENV_FILE" --health-timeout-seconds "$HEALTH_TIMEOUT_SECONDS" ) +if [[ -n "$EXPECTED_VERSION" ]]; then + COMMON_ARGS+=(--expect-version "$EXPECTED_VERSION") +fi if [[ "$SKIP_MIGRATION_STATUS" -eq 1 ]]; then COMMON_ARGS+=(--skip-migration-status) diff --git a/scripts/smoke-install.ps1 b/scripts/smoke-install.ps1 index 9f6f9e5..b6c2f9d 100644 --- a/scripts/smoke-install.ps1 +++ b/scripts/smoke-install.ps1 @@ -5,6 +5,7 @@ [string]$AppService = "app", [string]$PostgresService = "postgres", [string]$MinioService = "minio", + [string]$ExpectedVersion = "", [switch]$Start, [switch]$PreflightOnly, [switch]$PrepareDirectories, @@ -114,6 +115,11 @@ function Test-AppImage { } $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,必须固定到明确版本号" } @@ -350,6 +356,15 @@ try { } 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)) { diff --git a/scripts/smoke-install.sh b/scripts/smoke-install.sh index 2b0fbed..917800c 100644 --- a/scripts/smoke-install.sh +++ b/scripts/smoke-install.sh @@ -9,6 +9,7 @@ ENV_FILE=".env" APP_SERVICE="app" POSTGRES_SERVICE="postgres" MINIO_SERVICE="minio" +EXPECTED_VERSION="" START=0 PREFLIGHT_ONLY=0 PREPARE_DIRECTORIES=0 @@ -46,6 +47,7 @@ FLNXI NAS/Linux 首装验收脚本 --project-dir 部署目录,默认当前目录 --compose-file Compose 文件名,默认 docker-compose.prod.yml --env-file 环境文件名,默认 .env + --expect-version 期望镜像内 VERSION.txt 版本 --start 执行 docker compose up -d --preflight-only 只做安装前检查,不检查容器 --prepare-directories 缺少 uploads/data 时创建并检查权限 @@ -85,6 +87,10 @@ while [[ $# -gt 0 ]]; do MINIO_SERVICE="${2:?--minio-service requires a value}" shift 2 ;; + --expect-version) + EXPECTED_VERSION="${2:?--expect-version requires a value}" + shift 2 + ;; --start) START=1 shift @@ -164,6 +170,11 @@ test_app_image() { fi image_name="${app_image##*/}" + local first_part="${app_image%%/*}" + if [[ "$app_image" != */* || ( "$first_part" != "localhost" && "$first_part" != *.* && "$first_part" != *:* ) ]]; then + add_problem "APP_IMAGE 必须写完整镜像站地址,例如 registry.example.com:5000/flnxi/material/flnxi-material:1.0.1" + fi + if [[ "$image_name" == *":latest" ]]; then add_problem "APP_IMAGE 不能使用 latest,必须固定到明确版本号" fi @@ -378,6 +389,11 @@ if [[ -n "$VERSION_OUTPUT" ]]; then else warn "镜像内未读到 VERSION.txt,请确认发布镜像是否包含版本文件。" fi +if [[ -n "$EXPECTED_VERSION" ]]; then + [[ -n "$VERSION_OUTPUT" ]] || fail "镜像内未读到 VERSION.txt,无法确认期望版本:$EXPECTED_VERSION" + [[ "$VERSION_OUTPUT" == "$EXPECTED_VERSION" ]] || fail "应用版本不符合预期:实际 $VERSION_OUTPUT,预期 $EXPECTED_VERSION" + ok "应用版本符合预期:$EXPECTED_VERSION" +fi PUBLIC_URL="$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")" if [[ -z "$PUBLIC_URL" ]]; then