强化客户验收版本校验

This commit is contained in:
WuYongJie 2026-06-01 17:16:29 +08:00
parent b6246044b8
commit d359a411d2
11 changed files with 118 additions and 9 deletions

View File

@ -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)
})

View File

@ -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,

View File

@ -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://<SERVER_IP>:4320
--base-url http://<SERVER_IP>:4320 \
--expect-version 1.0.1
```
提供临时验收账号后,可以继续验登录、后台权限和模板接口:
@ -373,6 +374,7 @@ npm run smoke:customer -- \
```bash
npm run smoke:customer -- \
--base-url http://<SERVER_IP>:4320 \
--expect-version 1.0.1 \
--email <smoke-admin-email> \
--password <smoke-admin-password>
```
@ -382,6 +384,7 @@ npm run smoke:customer -- \
```bash
npm run smoke:customer -- \
--base-url http://<SERVER_IP>:4320 \
--expect-version 1.0.1 \
--email <smoke-admin-email> \
--password <smoke-admin-password> \
--write

View File

@ -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')
})
})

View File

@ -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('把客户验收拆成清楚的四步', () => {

View File

@ -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')
})

View File

@ -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 <path> 读取 .env.smoke,默认 .env.smoke
--email <email> 验收账号邮箱,也可用 SMOKE_ADMIN_EMAIL
--password <password> 验收账号密码,也可用 SMOKE_ADMIN_PASSWORD
--expect-version <ver> 期望 /api/health 返回的版本,也可用 SMOKE_EXPECT_VERSION
--write 登录后执行一次真实小素材上传,验证数据库和 MinIO 写入
--no-expect-admin 登录账号不是最高权限/管理员时使用
--timeout-ms <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)

View File

@ -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、镜像、目录权限或容器日志。"

View File

@ -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 <path> 部署目录,默认当前目录
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
--env-file <file> 环境文件名,默认 .env
--expect-version <ver> 期望镜像内 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)

View File

@ -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)) {

View File

@ -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 <path> 部署目录,默认当前目录
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
--env-file <file> 环境文件名,默认 .env
--expect-version <ver> 期望镜像内 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