补齐 NAS 客户首装备份验收脚本

This commit is contained in:
WuYongJie 2026-06-01 15:26:33 +08:00
parent 80ad51e3dd
commit 3256524271
13 changed files with 1860 additions and 14 deletions

View File

@ -1,5 +1,5 @@
# Base application configuration
NODE_ENV=development
NODE_ENV=production
# Public URLs
SERVER_IP=192.168.x.x

View File

@ -94,7 +94,7 @@ gongxiangp2-source-reference-old-style-20260513-132942/
- `flnxi-agent/`:Node.js / Express / Socket.IO
- `flnxi-agent-go/`:Go / Gin / Docker SDK
Agent 是实施和运维编排工具,不是普通客户日常入口,也不是当前唯一正式安装器。正式客户首装仍以 `docker-compose.prod.yml + .env + scripts/smoke-install.ps1` 为主,Agent 只在交付方案明确采用时启用。
Agent 是实施和运维编排工具,不是普通客户日常入口,也不是当前唯一正式安装器。正式客户首装仍以 `docker-compose.prod.yml + .env` 为主;NAS / Linux 现场优先用 `scripts/customer-install.sh` / `scripts/smoke-install.sh` 做检查和验收,Windows 实施机可用对应 `.ps1` 脚本。Agent 只在交付方案明确采用时启用。
## 6. 本地开发
@ -207,6 +207,10 @@ docker compose -f docker-compose.prod.yml up -d
内部维护环境仍保留:
- `deploy-registry.ps1`
- `scripts/customer-install.sh`
- `scripts/smoke-install.sh`
- `scripts/backup-restore.sh`
- `scripts/customer-acceptance-smoke.mjs`
- `scripts/customer-install.ps1`
- `scripts/smoke-install.ps1`
- `scripts/backup-restore.ps1`
@ -215,7 +219,7 @@ docker compose -f docker-compose.prod.yml up -d
- `发布管理.ps1`
- `license-server/deploy.ps1`
其中 `scripts/customer-install.ps1` 是客户首装分步骤入口,内部调用 `scripts/smoke-install.ps1` 做预检、启动和验收;`scripts/backup-restore.ps1` 用于客户环境备份、恢复和恢复后验收;`scripts/measure-image-size.ps1` 用于发布前记录镜像体积和分层,也可以设置体积/层数阈值,超限时阻止发布继续。其它脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。
其中 `scripts/customer-install.sh` 是 NAS / Linux 客户首装分步骤入口,内部调用 `scripts/smoke-install.sh` 做预检、启动和验收;`scripts/backup-restore.sh` 用于 NAS / Linux 客户环境备份、恢复和恢复后验收;`scripts/customer-acceptance-smoke.mjs` 用于客户业务验收,可只读检查,也可用临时测试账号执行一次真实小素材上传;`.ps1` 版本用于 Windows 实施机或已安装 PowerShell 的现场;`scripts/measure-image-size.ps1` 用于发布前记录镜像体积和分层,也可以设置体积/层数阈值,超限时阻止发布继续。其它脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。
## 10. 文档入口

View File

@ -9,13 +9,13 @@ param(
[string]$Action = "DeployApp",
[string]$Server = "",
[int]$SshPort = 18000,
[int]$SshPort = 22,
[string]$Username = "root",
[SecureString]$Password = $null,
[string]$KeyPath = "",
[string]$PlinkHostKey = "",
[string]$RemotePath = "/root/deploy",
[string]$RemotePath = "/opt/flnxi",
[string]$ComposeFile = "docker-compose.prod.yml",
[string]$ComposeService = "app",
[int]$AppPort = 4320,
@ -117,7 +117,7 @@ function Show-Help {
Write-Host ""
Write-Host "主要参数:" -ForegroundColor White
Write-Host " -Server 远程服务器IP或域名;远程部署时必须显式传入"
Write-Host " -SshPort SSH端口,默认 18000"
Write-Host " -SshPort SSH端口,默认 22;内部测试机或客户自定义端口必须显式传入"
Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入"
Write-Host " -RegistryUsername Registry账号,默认 flnxi"
Write-Host " -RegistryPassword Registry密码,建议用 Read-Host -AsSecureString 输入"
@ -128,7 +128,7 @@ function Show-Help {
Write-Host " -Version 正式版或 Agent 版本号"
Write-Host " -PushLatest 仅在需要兼容旧链路时,额外推送 latest(默认不推送)"
Write-Host " -AgentRepository Agent 镜像路径,默认 agent"
Write-Host " -RemotePath 远程部署目录,默认 /root/deploy"
Write-Host " -RemotePath 远程部署目录,默认 /opt/flnxi;已有客户环境必须传入真实部署目录"
Write-Host " -FullComposeUp 远程执行完整 docker compose up -d;默认只更新 app 服务"
Write-Host " -UpdateRemoteEnv 兼容旧参数;DeployApp 现在默认会把 APP_IMAGE 等写入远程 .env"
Write-Host " -NoUpdateRemoteEnv 只让本次 compose 命令临时生效,不持久写入远程 .env"

View File

@ -1,7 +1,7 @@
# ============================================
# FLNXI素材库 - Pro版配置
# 支持内网穿透,资源存储在MinIO存储桶
# 从私有镜像仓库拉取镜像,无需源码
# FLNXI素材库 - 私有化生产配置
# 默认使用内置 PostgreSQL + MinIO,也可通过 .env 切换外部数据库或对象存储。
# 主应用从版本化 Registry 镜像启动,客户现场无需源码。
# ============================================
# Registry 项目结构:
# - flnxi/runtime:PostgreSQL、MinIO 等运行依赖镜像

View File

@ -48,6 +48,8 @@
本轮按商业化私有交付目标收口的 8 项阶段记录,只在回看本轮收口时查看,不作为默认入口。
- `客户首装升级坑位排查_20260530.md`
客户首装、升级、回滚、许可证服务和对象存储的历史排障记录,已被更新验证覆盖,默认看部署说明即可。
- `商业化首装演练记录_20260601.md`
飞牛 fnOS 虚拟机上的首装验收记录,用于追溯 Linux/NAS 脚本和真实测试环境结论。
- `许可证链路安全评估_20260312.md`
历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口,引用前必须按当前代码重新复核。

View File

@ -242,6 +242,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
- 客户升级和内部发布优先按版本 tag 走,不要把 `latest` 当成客户部署依据。
- 客户现场部署使用 `-DeployChannel release -Version x.x.x`;测试通道只用于内部验证,必须显式传 `-DeployChannel test`。
- 客户 SSH 端口、登录用户和部署目录必须按现场显式确认;脚本默认端口是常见的 `22`,默认目录是 `/opt/flnxi`,内部测试机或自定义环境要显式传 `-SshPort`、`-Username`、`-RemotePath`。
- `DeployApp` 远程部署时默认会更新远端 `.env` 的 `APP_IMAGE`、访问地址和运行时镜像;只有临时验证时才使用 `-NoUpdateRemoteEnv`。
- `DeployApp` 默认会做远端维护窗口停写入、`.env` 备份、Compose PostgreSQL 备份、Compose MinIO 备份、恢复分级说明、失败自动回滚、运行镜像校验、版本文件校验和 Prisma 迁移状态校验。
- 旧的 `deploy-new-fixed.ps1` 仍可作为内部维护参考,但不再作为当前主线部署入口。
@ -282,8 +283,30 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
## 6. 部署后冒烟测试
NAS / Linux 客户现场优先使用 `.sh` 脚本。如果当前账号没有 Docker 权限,就用 `sudo bash ...` 执行。
启动前可以先做一轮不启动容器的安装前检查:
```bash
bash scripts/smoke-install.sh --project-dir /opt/flnxi --preflight-only
```
如果希望给实施人员一个更像向导的入口,可以先只看步骤:
```bash
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
```
`customer-install.sh` 只是对 `smoke-install.sh` 的分步骤包装,不替代 `docker-compose.prod.yml`,也不把 Agent 当成唯一安装入口。`--prepare-directories` 在 root / sudo 运行时会把 `uploads/` 和 `data/` 调整为应用容器 UID `1001` 可写,能提前拦截飞牛 / NAS 常见目录权限问题。
Windows 实施机或已安装 PowerShell 的现场,可以使用对应 `.ps1` 脚本:
```powershell
.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -PreflightOnly
```
@ -322,7 +345,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi"
```
该脚本会先做安装前检查,再检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。如果客户使用外部对象存储,加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。
这些脚本会先做安装前检查,再检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。如果客户使用外部对象存储,`.sh` 脚本加 `--skip-object-storage-check`,`.ps1` 脚本加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。
人工验收命令仍保留如下:
@ -336,6 +359,34 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
- [ ] 许可证状态显示正确
- [ ] 容器日志没有持续错误
如果需要一条命令做客户业务验收,可在实施机或部署机上运行:
```bash
npm run smoke:customer -- \
--base-url http://<SERVER_IP>:4320
```
提供临时验收账号后,可以继续验登录、后台权限和模板接口:
```bash
npm run smoke:customer -- \
--base-url http://<SERVER_IP>:4320 \
--email <smoke-admin-email> \
--password <smoke-admin-password>
```
需要真实验证数据库和 MinIO 写入时,加 `--write`。该动作会创建一条小素材验收记录,建议只在测试环境或客户允许的验收窗口执行:
```bash
npm run smoke:customer -- \
--base-url http://<SERVER_IP>:4320 \
--email <smoke-admin-email> \
--password <smoke-admin-password> \
--write
```
不要把验收账号密码写入文档、Git 或聊天记录;如果使用 `.env.smoke`,该文件也必须留在本机,不进入仓库。
## 7. 数据备份与回滚
`deploy-registry.ps1 -Action DeployApp` 会默认做一轮升级前备份和失败回滚。下面命令主要用于人工兜底、外部数据库场景或单独恢复演练。
@ -344,6 +395,14 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
首选脚本:
NAS / Linux:
```bash
sudo bash scripts/backup-restore.sh --project-dir /opt/flnxi --action backup
```
Windows / PowerShell:
```powershell
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup
```
@ -361,12 +420,18 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
备份脚本默认也会在备份期间短暂停止 app,并在备份结束后恢复启动,目的是让数据库、MinIO、`data/`、`uploads/` 尽量处在同一个时间点。只有客户现场已经有其它停写入机制时,才考虑加 `-SkipStopAppForBackup`。
备份包里会包含 `.env`、数据库内容和客户素材文件,通常也就包含访问密钥、许可证缓存和企业内部资料。备份目录必须放在受控位置,按客户现场要求加密或限权保存,不能提交到 Git,也不能随意发到聊天工具。
`DeployApp` 升级时生成的 `backups/deploy-时间戳/RESTORE_PLAN.txt` 是升级恢复指引;`backup-restore.ps1 -Action Backup` 生成的 `flnxi-backup-时间戳/manifest.json` 是完整备份包记录。两者用途不同,客户升级失败时优先看当次升级目录里的 `RESTORE_PLAN.txt`。
如果 `DATABASE_URL` 指向外部 PostgreSQL,脚本不会假装自动备份,会在备份目录写入 `EXTERNAL_DATABASE_BACKUP_REQUIRED.txt`,客户现场必须按外部数据库方案单独备份。
如果对象存储是外部 S3 / 外部 MinIO,或现场已有独立对象存储快照,可加:
```bash
sudo bash scripts/backup-restore.sh --project-dir /opt/flnxi --action backup --skip-object-storage-backup
```
```powershell
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup -SkipObjectStorageBackup
```
@ -375,6 +440,14 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
### 7.2 一键验收
NAS / Linux:
```bash
bash scripts/backup-restore.sh --project-dir /opt/flnxi --action verify
```
Windows / PowerShell:
```powershell
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Verify
```
@ -385,6 +458,18 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
恢复会覆盖客户数据,必须停写入并确认备份来源正确。脚本要求输入固定确认短语,避免误操作:
NAS / Linux:
```bash
sudo bash scripts/backup-restore.sh \
--project-dir /opt/flnxi \
--action restore \
--backup-path /opt/flnxi/backups/flnxi-backup-YYYYMMDD-HHMMSS \
--confirm-restore RESTORE_FLNXI_DATA
```
Windows / PowerShell:
```powershell
.\scripts\backup-restore.ps1 `
-ProjectDir "D:\deploy\flnxi" `

View File

@ -0,0 +1,234 @@
# 商业化首装演练记录 2026-06-01
本文记录本轮在本机飞牛 fnOS 虚拟机上的客户首装验收情况。只沉淀环境事实、验证结果和后续坑位,不记录 SSH 密码、许可证密钥、对象存储密钥或其它敏感信息。
## 1. 演练结论
- 飞牛 fnOS 虚拟机可以作为当前商业化首装/验收测试环境。
- 当前已有一套 `flnxi-fnos-smoke` 环境运行正常,未覆盖、未删除。
- 新增的 NAS / Linux 首装验收脚本 `scripts/customer-install.sh` 和 `scripts/smoke-install.sh` 已在飞牛 VM 上通过只读验收。
- 客户首装的真实主入口仍是 `docker-compose.prod.yml + .env`;脚本只是预检、启动和验收包装。
## 2. 环境现场
- 虚拟机地址:`192.168.116.128`
- 系统:Linux / fnOS,内核 `6.18.18-trim`
- Docker Server:`28.5.2`
- 可用磁盘:`/vol1` 约 `35G`
- 飞牛 Web 管理端:`5666`
- 当前测试部署目录:`/vol1/flnxi-customer-smoke`
当前运行中的 FLNXI 容器:
- `flnxi-fnos-smoke-app`
- `flnxi-fnos-smoke-postgres`
- `flnxi-fnos-smoke-minio`
当前测试端口:
- 应用:`4320 -> 3000`
- MinIO API:`39200 -> 9000`
- MinIO Console:`39201 -> 9001`
## 3. 已验证内容
### 3.1 远端健康检查
远端本机访问:
```bash
curl http://127.0.0.1:4320/api/health
```
结果:返回 `status: ok`,启动检查执行成功。
登录页:
```bash
curl -I http://127.0.0.1:4320/login
```
结果:返回 `HTTP/1.1 200 OK`。
### 3.2 NAS / Linux 首装向导
已把本轮新增脚本复制到测试部署目录:
```bash
/vol1/flnxi-customer-smoke/scripts/customer-install.sh
/vol1/flnxi-customer-smoke/scripts/smoke-install.sh
/vol1/flnxi-customer-smoke/scripts/backup-restore.sh
/vol1/flnxi-customer-smoke/scripts/customer-acceptance-smoke.mjs
```
只看步骤:
```bash
bash /vol1/flnxi-customer-smoke/scripts/customer-install.sh \
--project-dir /vol1/flnxi-customer-smoke \
--plan-only
```
结果:通过,能按 4 步展示预检、启动方式、验收和结果说明。
安装前检查:
```bash
sudo bash /vol1/flnxi-customer-smoke/scripts/smoke-install.sh \
--project-dir /vol1/flnxi-customer-smoke \
--preflight-only
```
结果:通过。
只读验收:
```bash
sudo bash /vol1/flnxi-customer-smoke/scripts/smoke-install.sh \
--project-dir /vol1/flnxi-customer-smoke \
--health-timeout-seconds 60
```
结果:通过。
脚本已验证:
- Compose 配置有效
- 三个容器运行正常
- 应用镜像为 `flnxi-material:customer-smoke-20260530`
- 镜像内 `VERSION.txt` 为 `1.0.0`
- `/api/health` 正常
- Prisma 迁移状态为最新
- MinIO bucket 存在
### 3.3 NAS / Linux 备份验收
备份前健康验收:
```bash
sudo bash /vol1/flnxi-customer-smoke/scripts/backup-restore.sh \
--project-dir /vol1/flnxi-customer-smoke \
--action verify \
--health-timeout-seconds 60
```
结果:通过。
实际备份:
```bash
sudo bash /vol1/flnxi-customer-smoke/scripts/backup-restore.sh \
--project-dir /vol1/flnxi-customer-smoke \
--action backup \
--health-timeout-seconds 60
```
结果:备份成功,生成目录:
```text
/vol1/flnxi-customer-smoke/backups/flnxi-backup-20260601-150351
```
备份内容包括:
- `.env`
- `docker-compose.prod.yml`
- `compose-config.txt`
- `compose-ps.txt`
- `docker-images.txt`
- `postgres.sql`
- `minio-data.tar.gz`
- `data.tar.gz`
- `uploads.tar.gz`
- `manifest.json`
- `README-SENSITIVE.txt`
补充修正脚本后再次备份,生成目录:
```text
/vol1/flnxi-customer-smoke/backups/flnxi-backup-20260601-150543
```
这次脚本在备份结束后自动等待健康检查,`/api/health` 再次返回 `status: ok`。
### 3.4 客户业务 smoke
本轮新增:
```bash
npm run smoke:customer -- --base-url http://<SERVER_IP>:4320
```
该脚本用于补足“容器活着但业务不一定可用”的空白:
- 只读模式检查 `/api/health`、`/api/system/init`、`/login` 和未登录后台拦截。
- 提供临时验收账号后,检查登录、上传模板、内置模板、默认密码检查和后台用户接口。
- 加 `--write` 后,执行一次真实小素材上传,验证数据库和 MinIO/S3 写入。
本轮在飞牛 VM 上已验证:
- 只读模式通过。
- 使用临时 smoke 管理员登录后,模板接口、默认密码检查和后台用户接口通过。
- `--write` 上传被许可证拦截,返回:`当前许可证最多支持 0 个素材,当前已有 0 个素材。请联系管理员处理授权。` 这说明当前 VM 的许可证状态不允许素材写入;脚本能正确把这类业务验收失败暴露出来。后续要验证真实上传,需要先给测试环境配置允许上传的许可证或换到已授权环境。
- 临时 smoke 管理员已在验证结束后删除,未作为长期账号保留。
## 4. 已发现和处理的坑
### 4.1 不能只提供 PowerShell 首装脚本
目标客户环境是 NAS / Linux,不能默认现场有 PowerShell。已新增:
- `scripts/customer-install.sh`
- `scripts/smoke-install.sh`
- `scripts/backup-restore.sh`
- `scripts/customer-acceptance-smoke.mjs`
文档已同步说明:NAS / Linux 优先 `.sh`,Windows 实施机或已安装 PowerShell 的现场可用 `.ps1`。
### 4.2 只换目录不够,必须换容器名/卷名/端口
飞牛 VM 上已经有一套 `flnxi-fnos-smoke`。后续如果做全新目录演练,必须在 `.env` 里同时修改:
- `COMPOSE_PROJECT_NAME`
- `APP_CONTAINER_NAME`
- `POSTGRES_CONTAINER_NAME`
- `MINIO_CONTAINER_NAME`
- `MINIO_INIT_CONTAINER_NAME`
- `POSTGRES_VOLUME_NAME`
- `MINIO_VOLUME_NAME`
- `APP_NETWORK_NAME`
- `APP_PORT`
- `MINIO_API_PORT`
- `MINIO_CONSOLE_PORT`
否则可能撞旧容器、旧卷、旧网络或端口。
### 4.3 fnOS 上 Docker 需要 sudo
当前 `admin` 用户不能直接访问 Docker daemon,脚本需要用 `sudo bash ...` 执行,或把实施账号加入合适的 Docker 权限组。
### 4.4 admin 家目录提示不阻断
SSH 登录会提示:
```text
Could not chdir to home directory /home/admin: No such file or directory
```
这不影响命令执行,但如果后续做长期自动化,建议在飞牛系统里补齐该用户家目录或换成更标准的实施账号。
### 4.5 备份后必须等待应用健康
第一次跑 `backup-restore.sh --action backup` 时,备份本身成功,脚本也重新启动了 app,但外部立刻请求 `/api/health` 会遇到应用刚启动的短暂窗口期。已修正脚本:如果备份前停止过 app,备份结束后会自动执行健康验收,避免实施人员误以为恢复启动失败。
### 4.6 业务 smoke 能暴露许可证限制
当前飞牛 VM 的许可证允许素材数为 `0`,所以真实上传 smoke 会失败。这不是脚本失败,而是业务前置条件没有满足。第一版商业化验收时,必须把“许可证是否允许上传”放进验收清单,否则系统容器正常也可能无法交付使用。
## 5. 下一步建议
1. 新建独立目录做一次真正“空目录首装”,不要覆盖 `/vol1/flnxi-customer-smoke`。
2. 目录建议:`/vol1/flnxi-commercial-rehearsal-20260601`。
3. 新 `.env` 必须使用独立容器名、卷名、网络名和端口,例如应用端口可用 `4322`。
4. 首装通过后,再做上传、模板、备份、升级和回滚演练。
5. 当前 Linux 版已覆盖首装、容器验收、备份和客户业务 smoke;真实上传验收还需要一份允许上传的测试许可证;恢复演练需要在单独演练环境执行,不能覆盖已有 smoke 环境。

View File

@ -13,6 +13,7 @@
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"smoke:customer": "node scripts/customer-acceptance-smoke.mjs",
"check": "npm run type-check && npm run lint && npm run prisma:validate",
"type-check": "tsc --noEmit",
"prisma:generate": "prisma generate",

View File

@ -39,10 +39,18 @@ npm run verify:template-data
生产构建后的服务端混淆处理。客户交付镜像通过 `Dockerfile` 执行 `npm run build:prod` 时会自动调用;混淆依赖或配置缺失会直接失败,不能静默跳过。
- `docker-entrypoint.sh`
主应用容器启动入口
- `customer-install.sh`
NAS / Linux 客户首装向导入口,按步骤调用 `smoke-install.sh` 做预检、启动和验收;不替代生产 Compose,也不依赖 Agent
- `smoke-install.sh`
NAS / Linux 客户首装后的基础验收脚本,可检查 Compose 配置、容器状态、运行镜像、版本文件、迁移状态、MinIO bucket 和 `/api/health`
- `backup-restore.sh`
NAS / Linux 客户环境备份、恢复和健康验收脚本;恢复必须显式输入确认短语,避免误覆盖客户数据
- `customer-acceptance-smoke.mjs`
客户业务验收脚本,可只读检查健康、初始化、登录页和权限拦截,也可登录后验模板、后台接口和真实小素材上传
- `customer-install.ps1`
客户首装向导入口,按步骤调用 `smoke-install.ps1` 做预检、启动和验收;不替代生产 Compose,也不依赖 Agent
Windows / PowerShell 版客户首装向导入口,逻辑对应 `customer-install.sh`
- `smoke-install.ps1`
客户首装后的基础验收脚本,可检查 Compose 配置、容器状态、运行镜像、版本文件、迁移状态、MinIO bucket 和 `/api/health`
Windows / PowerShell 版客户首装后的基础验收脚本,逻辑对应 `smoke-install.sh`
- `backup-restore.ps1`
客户环境备份、恢复和健康验收脚本;恢复必须显式输入确认短语,避免误覆盖客户数据
- `measure-image-size.ps1`
@ -106,7 +114,26 @@ npx tsx scripts/fix-missing-templates.ts
### 客户首装基础验收
更推荐给实施人员的向导入口:
NAS / Linux 客户现场优先用 `.sh` 入口;当前账号没有 Docker 权限时,用 `sudo bash ...`:
```bash
bash scripts/customer-install.sh --project-dir /opt/flnxi --plan-only
sudo bash scripts/customer-install.sh --project-dir /opt/flnxi --prepare-directories --start
```
说明:
- `--plan-only` 只打印四步流程,不启动容器。
- `--prepare-directories` 会创建并测试 `uploads/` 和 `data/`;sudo/root 运行时会调整目录为应用容器 UID `1001` 可写。
- `--start` 会执行 `docker compose up -d`;如果已经由 NAS 图形界面启动,可以不传。
- 这个脚本内部仍调用 `smoke-install.sh`,不引入第二套安装逻辑。
```bash
bash scripts/smoke-install.sh --project-dir /opt/flnxi --preflight-only
bash scripts/smoke-install.sh --project-dir /opt/flnxi
```
Windows 实施机或已安装 PowerShell 的现场,可以用 `.ps1` 入口:
```powershell
.\scripts\customer-install.ps1 -ProjectDir "D:\deploy\flnxi" -PlanOnly
@ -135,6 +162,20 @@ npx tsx scripts/fix-missing-templates.ts
### 客户备份、恢复和验收
NAS / Linux:
```bash
sudo bash scripts/backup-restore.sh --project-dir /opt/flnxi --action backup
bash scripts/backup-restore.sh --project-dir /opt/flnxi --action verify
sudo bash scripts/backup-restore.sh \
--project-dir /opt/flnxi \
--action restore \
--backup-path /opt/flnxi/backups/flnxi-backup-YYYYMMDD-HHMMSS \
--confirm-restore RESTORE_FLNXI_DATA
```
Windows / PowerShell:
```powershell
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Verify
@ -148,9 +189,43 @@ npx tsx scripts/fix-missing-templates.ts
说明:
- 默认备份 Compose 管理的 PostgreSQL、内置 MinIO 数据卷、`.env`、Compose 文件、版本文件、镜像和容器状态。
- 备份包会包含客户素材、数据库和 `.env` 密钥,必须限权或加密保存,不能提交到 Git。
- 外部数据库、外部对象存储由客户现场方案单独备份;脚本会写提示文件,不会假装自动覆盖。
- 对象存储备份/恢复使用 `busybox:1.36` 作为 helper 镜像;离线客户现场需要提前放入该镜像或通过 `-HelperImage` 指定现场已有镜像。
### 客户业务验收
只读检查:
```bash
npm run smoke:customer -- --base-url http://127.0.0.1:4320
```
登录后检查模板、后台和用户状态接口:
```bash
npm run smoke:customer -- \
--base-url http://127.0.0.1:4320 \
--email <smoke-admin-email> \
--password <smoke-admin-password>
```
真实写入验收:
```bash
npm run smoke:customer -- \
--base-url http://127.0.0.1:4320 \
--email <smoke-admin-email> \
--password <smoke-admin-password> \
--write
```
说明:
- `--write` 会创建一条小素材记录,用来验证数据库和 MinIO/S3 确实可写。
- 验收账号建议使用临时测试账号;脚本不会主动打印密码,但命令历史和 `.env.smoke` 也要按敏感信息处理。
- 没有最高权限的账号可加 `--no-expect-admin`,避免把正常的后台 403 误判成失败。
### 镜像体积和分层基线
```powershell

545
scripts/backup-restore.sh Normal file
View File

@ -0,0 +1,545 @@
#!/usr/bin/env bash
# FLNXI customer backup, restore, and verify helper for NAS/Linux hosts.
set -euo pipefail
ACTION="backup"
PROJECT_DIR="."
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env"
BACKUP_DIR="backups"
BACKUP_PATH=""
APP_SERVICE="app"
POSTGRES_SERVICE="postgres"
MINIO_SERVICE="minio"
HELPER_IMAGE="busybox:1.36"
SKIP_STOP_APP_FOR_BACKUP=0
SKIP_DATABASE_BACKUP=0
SKIP_OBJECT_STORAGE_BACKUP=0
SKIP_OBJECT_STORAGE_RESTORE=0
RESTORE_ENV=0
HEALTH_TIMEOUT_SECONDS=120
CONFIRM_RESTORE=""
RESTORE_CONFIRM_PHRASE="RESTORE_FLNXI_DATA"
info() {
printf '[INFO] %s\n' "$1"
}
ok() {
printf '[OK] %s\n' "$1"
}
warn() {
printf '[WARN] %s\n' "$1" >&2
}
fail() {
printf '[ERROR] %s\n' "$1" >&2
exit 1
}
usage() {
cat <<'EOF'
FLNXI NAS/Linux 备份、恢复和验收脚本
用法:
bash scripts/backup-restore.sh --action backup|restore|verify [选项]
常用选项:
--project-dir <path> 部署目录,默认当前目录
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
--env-file <file> 环境文件名,默认 .env
--backup-dir <dir> 备份目录名,默认 backups
--backup-path <path> 恢复时指定备份目录
--helper-image <image> 备份/恢复 MinIO 卷用的小型镜像,默认 busybox:1.36
--skip-stop-app-for-backup 备份时不暂停 app 写入
--skip-database-backup 跳过数据库备份
--skip-object-storage-backup 跳过对象存储备份
--skip-object-storage-restore 恢复时跳过对象存储
--restore-env 恢复备份中的 .env
--confirm-restore RESTORE_FLNXI_DATA 恢复确认短语
--health-timeout-seconds <sec> 健康检查等待秒数,默认 120
-h, --help 显示帮助
说明:
备份包包含 .env、数据库和客户素材,必须限权或加密保存,不能提交到 Git。
如果当前账号没有 Docker 权限,请用 sudo 运行本脚本。
EOF
}
normalize_action() {
case "$1" in
backup|Backup) printf 'backup' ;;
restore|Restore) printf 'restore' ;;
verify|Verify) printf 'verify' ;;
*) fail "未知动作:$1" ;;
esac
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="$(normalize_action "${2:?--action requires a value}")"
shift 2
;;
--project-dir)
PROJECT_DIR="${2:?--project-dir requires a value}"
shift 2
;;
--compose-file)
COMPOSE_FILE="${2:?--compose-file requires a value}"
shift 2
;;
--env-file)
ENV_FILE="${2:?--env-file requires a value}"
shift 2
;;
--backup-dir)
BACKUP_DIR="${2:?--backup-dir requires a value}"
shift 2
;;
--backup-path)
BACKUP_PATH="${2:?--backup-path requires a value}"
shift 2
;;
--app-service)
APP_SERVICE="${2:?--app-service requires a value}"
shift 2
;;
--postgres-service)
POSTGRES_SERVICE="${2:?--postgres-service requires a value}"
shift 2
;;
--minio-service)
MINIO_SERVICE="${2:?--minio-service requires a value}"
shift 2
;;
--helper-image)
HELPER_IMAGE="${2:?--helper-image requires a value}"
shift 2
;;
--skip-stop-app-for-backup)
SKIP_STOP_APP_FOR_BACKUP=1
shift
;;
--skip-database-backup)
SKIP_DATABASE_BACKUP=1
shift
;;
--skip-object-storage-backup)
SKIP_OBJECT_STORAGE_BACKUP=1
shift
;;
--skip-object-storage-restore)
SKIP_OBJECT_STORAGE_RESTORE=1
shift
;;
--restore-env)
RESTORE_ENV=1
shift
;;
--confirm-restore)
CONFIRM_RESTORE="${2:?--confirm-restore requires a value}"
shift 2
;;
--health-timeout-seconds)
HEALTH_TIMEOUT_SECONDS="${2:?--health-timeout-seconds requires a value}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
fail "未知参数:$1"
;;
esac
done
read_env_value() {
local key="$1"
local path="$2"
local line
if [[ ! -f "$path" ]]; then
return 0
fi
line="$(grep -E "^[[:space:]]*${key}=" "$path" | tail -n 1 || true)"
[[ -z "$line" ]] && return 0
printf '%s' "$line" | sed -E "s/^[^=]*=//;s/\r$//;s/^['\"]//;s/['\"]$//"
}
json_escape() {
printf '%s' "${1:-}" | sed -E 's/\\/\\\\/g;s/"/\\"/g'
}
is_external_database() {
local database_url host
database_url="$(read_env_value "DATABASE_URL" "$ENV_PATH")"
[[ -z "$database_url" ]] && return 1
host="$(printf '%s' "$database_url" | sed -E 's#^[a-zA-Z0-9+.-]+://[^@]*@([^/:?]+).*#\1#')"
[[ "$host" != "postgres" && "$host" != "$database_url" ]]
}
is_external_object_storage() {
local endpoint host
endpoint="$(read_env_value "S3_ENDPOINT" "$ENV_PATH")"
[[ -z "$endpoint" ]] && return 1
host="$(printf '%s' "$endpoint" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')"
[[ "$host" != "minio" ]]
}
run_docker() {
docker "$@"
}
run_compose() {
run_docker "${COMPOSE_ARGS[@]}" "$@"
}
service_container_id() {
local service="$1"
local container_id
container_id="$(run_compose ps -q "$service" | tr -d '\r')"
[[ -n "$container_id" ]] || fail "未找到服务容器:$service。请先确认服务已启动。"
printf '%s' "$container_id"
}
optional_service_container_id() {
local service="$1"
run_compose ps -q "$service" 2>/dev/null | tr -d '\r' || true
}
service_running() {
local service="$1"
local container_id status
container_id="$(optional_service_container_id "$service")"
[[ -n "$container_id" ]] || return 1
status="$(run_docker inspect "$container_id" --format '{{.State.Status}}' 2>/dev/null || true)"
[[ "$status" == "running" ]]
}
stop_app_for_backup() {
if [[ "$SKIP_STOP_APP_FOR_BACKUP" -eq 1 ]]; then
warn "已跳过备份停写入步骤;请确认客户现场没有人正在上传、编辑或删除素材。"
return 1
fi
if ! service_running "$APP_SERVICE"; then
info "$APP_SERVICE 服务未运行或不存在,跳过停写入步骤。"
return 1
fi
info "进入备份维护窗口:暂时停止 $APP_SERVICE 服务,避免备份期间继续写入数据库或素材文件。"
run_compose stop "$APP_SERVICE"
return 0
}
restart_app_after_backup() {
info "备份维护窗口结束:恢复启动 $APP_SERVICE 服务。"
run_compose up -d "$APP_SERVICE"
}
save_command_output() {
local path="$1"
shift
set +e
"$@" >"$path" 2>&1
local status=$?
set -e
if [[ "$status" -ne 0 ]]; then
printf '命令执行失败,退出码:%s\n' "$status" >>"$path"
fi
}
backup_database() {
local output_path="$1"
info "备份 Compose 内置 PostgreSQL"
run_compose up -d "$POSTGRES_SERVICE"
local container_id
container_id="$(service_container_id "$POSTGRES_SERVICE")"
run_compose exec -T "$POSTGRES_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'
run_docker cp "${container_id}:/tmp/flnxi-backup.sql" "$output_path"
[[ -s "$output_path" ]] || fail "数据库备份复制失败:$output_path"
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'rm -f /tmp/flnxi-backup.sql'
ok "数据库备份完成:$output_path"
}
restore_database() {
local input_path="$1"
info "恢复 Compose 内置 PostgreSQL"
run_compose up -d "$POSTGRES_SERVICE"
local container_id
container_id="$(service_container_id "$POSTGRES_SERVICE")"
run_docker cp "$input_path" "${container_id}:/tmp/flnxi-restore.sql"
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/flnxi-restore.sql'
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'rm -f /tmp/flnxi-restore.sql'
ok "数据库恢复完成"
}
backup_object_storage() {
local volume_name="$1"
local output_path="$2"
local backup_dir
[[ -n "$volume_name" ]] || fail "MINIO_VOLUME_NAME 为空,无法备份内置对象存储卷。"
backup_dir="$(dirname "$output_path")"
info "备份 Compose 内置 MinIO 数据卷:$volume_name"
run_docker volume inspect "$volume_name" >/dev/null
run_docker run --rm -v "${volume_name}:/source:ro" -v "${backup_dir}:/backup" "$HELPER_IMAGE" sh -c "cd /source && tar -czf /backup/minio-data.tar.gz ."
[[ -s "$output_path" ]] || fail "对象存储备份失败。请确认 helper 镜像可用:$HELPER_IMAGE"
ok "对象存储备份完成:$output_path"
}
restore_object_storage() {
local volume_name="$1"
local input_path="$2"
local backup_dir
[[ -n "$volume_name" ]] || fail "MINIO_VOLUME_NAME 为空,无法恢复内置对象存储卷。"
backup_dir="$(dirname "$input_path")"
info "恢复 Compose 内置 MinIO 数据卷:$volume_name"
if ! run_docker volume inspect "$volume_name" >/dev/null 2>&1; then
run_docker volume create "$volume_name" >/dev/null
fi
run_docker run --rm -v "${volume_name}:/target" -v "${backup_dir}:/backup:ro" "$HELPER_IMAGE" sh -c "rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/minio-data.tar.gz"
ok "对象存储恢复完成"
}
backup_local_directory() {
local name="$1"
local output_path="$2"
[[ -d "$PROJECT_DIR_ABS/$name" ]] || return 1
tar -czf "$output_path" -C "$PROJECT_DIR_ABS" "$name"
ok "本地目录备份完成:$name -> $output_path"
return 0
}
restore_local_directory() {
local name="$1"
local input_path="$2"
[[ -f "$input_path" ]] || return 0
local target_path before_path
target_path="$PROJECT_DIR_ABS/$name"
if [[ -d "$target_path" ]]; then
before_path="$PROJECT_DIR_ABS/$name.before-restore-$(date +%Y%m%d-%H%M%S)"
mv "$target_path" "$before_path"
ok "$name 恢复前副本:$before_path"
fi
tar -xzf "$input_path" -C "$PROJECT_DIR_ABS"
ok "本地目录恢复完成:$name"
}
wait_health() {
local health_url="$1"
local timeout="$2"
local deadline
deadline=$(( $(date +%s) + timeout ))
while [[ "$(date +%s)" -le "$deadline" ]]; do
if curl -fsS "$health_url" >/dev/null 2>&1; then
ok "/api/health 正常"
return 0
fi
sleep 5
done
fail "/api/health 在 $timeout 秒内没有通过:$health_url"
}
verify_install() {
info "检查 Compose 配置"
run_compose config --quiet
info "查看容器状态"
run_compose ps
local public_url
public_url="$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")"
if [[ -z "$public_url" ]]; then
public_url="$(read_env_value "NEXTAUTH_URL" "$ENV_PATH")"
fi
if [[ -z "$public_url" ]]; then
warn "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,跳过 /api/health HTTP 验收。"
return 0
fi
info "检查健康接口:${public_url%/}/api/health"
wait_health "${public_url%/}/api/health" "$HEALTH_TIMEOUT_SECONDS"
}
write_manifest() {
local target_dir="$1"
local minio_volume_name="$2"
local external_db="$3"
local external_object_storage="$4"
local data_backed="$5"
local uploads_backed="$6"
local app_stopped="$7"
cat >"$target_dir/manifest.json" <<EOF
{
"createdAt": "$(date +%Y-%m-%dT%H:%M:%S)",
"projectDir": "$(json_escape "$PROJECT_DIR_ABS")",
"composeFile": "$(json_escape "$COMPOSE_PATH")",
"envFile": "$(json_escape "$ENV_PATH")",
"appImage": "$(json_escape "$(read_env_value "APP_IMAGE" "$ENV_PATH")")",
"appPublicUrl": "$(json_escape "$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")")",
"postgresService": "$(json_escape "$POSTGRES_SERVICE")",
"minioVolumeName": "$(json_escape "$minio_volume_name")",
"externalDatabase": $external_db,
"externalObjectStorage": $external_object_storage,
"localDirectories": {
"data": $data_backed,
"uploads": $uploads_backed
},
"appStoppedForConsistentBackup": $app_stopped,
"restoreConfirmPhrase": "$RESTORE_CONFIRM_PHRASE"
}
EOF
}
backup_action_body() {
local timestamp backup_root target_dir minio_volume_name external_db external_object_storage
timestamp="$(date +%Y%m%d-%H%M%S)"
backup_root="$PROJECT_DIR_ABS/$BACKUP_DIR"
target_dir="$backup_root/flnxi-backup-$timestamp"
mkdir -p "$target_dir"
cp "$COMPOSE_PATH" "$target_dir/docker-compose.prod.yml"
[[ -f "$ENV_PATH" ]] && cp "$ENV_PATH" "$target_dir/.env"
[[ -f "$PROJECT_DIR_ABS/VERSION.txt" ]] && cp "$PROJECT_DIR_ABS/VERSION.txt" "$target_dir/VERSION.txt"
cat >"$target_dir/README-SENSITIVE.txt" <<'EOF'
This backup may contain .env secrets, database data, license cache, and customer materials.
Store it in a controlled location. Do not commit it to Git.
EOF
save_command_output "$target_dir/compose-config.txt" docker "${COMPOSE_ARGS[@]}" config
save_command_output "$target_dir/compose-ps.txt" docker "${COMPOSE_ARGS[@]}" ps
save_command_output "$target_dir/docker-images.txt" docker images --digests
if is_external_database; then external_db=true; else external_db=false; fi
if [[ "$SKIP_DATABASE_BACKUP" -eq 1 ]]; then
printf '已按参数跳过数据库备份。请确认外部备份已存在。\n' >"$target_dir/DATABASE_BACKUP_SKIPPED.txt"
elif [[ "$external_db" == true ]]; then
printf '检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。\n' >"$target_dir/EXTERNAL_DATABASE_BACKUP_REQUIRED.txt"
else
backup_database "$target_dir/postgres.sql"
fi
minio_volume_name="$(read_env_value "MINIO_VOLUME_NAME" "$ENV_PATH")"
minio_volume_name="${minio_volume_name:-flnxi-minio-data}"
if is_external_object_storage; then external_object_storage=true; else external_object_storage=false; fi
if [[ "$SKIP_OBJECT_STORAGE_BACKUP" -eq 1 ]]; then
printf '已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。\n' >"$target_dir/OBJECT_STORAGE_BACKUP_SKIPPED.txt"
elif [[ "$external_object_storage" == true ]]; then
printf '检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。\n' >"$target_dir/EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt"
else
backup_object_storage "$minio_volume_name" "$target_dir/minio-data.tar.gz"
fi
local data_backed uploads_backed
data_backed=false
uploads_backed=false
if backup_local_directory "data" "$target_dir/data.tar.gz"; then data_backed=true; fi
if backup_local_directory "uploads" "$target_dir/uploads.tar.gz"; then uploads_backed=true; fi
write_manifest "$target_dir" "$minio_volume_name" "$external_db" "$external_object_storage" "$data_backed" "$uploads_backed" "$APP_STOPPED_FOR_BACKUP"
ok "备份目录:$target_dir"
}
backup_action() {
APP_STOPPED_FOR_BACKUP=false
if stop_app_for_backup; then
APP_STOPPED_FOR_BACKUP=true
fi
set +e
backup_action_body
local status=$?
set -e
if [[ "$APP_STOPPED_FOR_BACKUP" == true ]]; then
restart_app_after_backup
if [[ "$status" -eq 0 ]]; then
verify_install
status=$?
fi
fi
return "$status"
}
restore_action() {
[[ -n "$BACKUP_PATH" ]] || fail "恢复必须传入 --backup-path。"
[[ "$CONFIRM_RESTORE" == "$RESTORE_CONFIRM_PHRASE" ]] || fail "恢复会覆盖客户数据。请显式传入 --confirm-restore $RESTORE_CONFIRM_PHRASE。"
local source_dir db_backup object_backup env_backup_path minio_volume_name
source_dir="$(cd "$BACKUP_PATH" && pwd)"
db_backup="$source_dir/postgres.sql"
object_backup="$source_dir/minio-data.tar.gz"
warn "即将恢复备份目录:$source_dir"
run_compose stop "$APP_SERVICE" || true
if [[ "$RESTORE_ENV" -eq 1 && -f "$source_dir/.env" ]]; then
env_backup_path="$PROJECT_DIR_ABS/.env.before-restore-$(date +%Y%m%d-%H%M%S)"
[[ -f "$ENV_PATH" ]] && cp "$ENV_PATH" "$env_backup_path"
cp "$source_dir/.env" "$ENV_PATH"
ok ".env 已恢复,恢复前副本:$env_backup_path"
fi
if [[ -f "$db_backup" ]]; then
restore_database "$db_backup"
else
warn "备份目录没有 postgres.sql,跳过数据库恢复。"
fi
if is_external_object_storage; then
warn "检测到当前 .env 使用外部对象存储,脚本不会恢复 Compose 内置 MinIO 卷;请按客户现场对象存储备份方案恢复。"
elif [[ "$SKIP_OBJECT_STORAGE_RESTORE" -eq 0 && -f "$object_backup" ]]; then
run_compose stop "$MINIO_SERVICE" || true
minio_volume_name="$(read_env_value "MINIO_VOLUME_NAME" "$ENV_PATH")"
minio_volume_name="${minio_volume_name:-flnxi-minio-data}"
restore_object_storage "$minio_volume_name" "$object_backup"
elif [[ "$SKIP_OBJECT_STORAGE_RESTORE" -eq 1 ]]; then
warn "已按参数跳过对象存储恢复。"
else
warn "备份目录没有 minio-data.tar.gz,跳过对象存储恢复。"
fi
restore_local_directory "data" "$source_dir/data.tar.gz"
restore_local_directory "uploads" "$source_dir/uploads.tar.gz"
run_compose up -d
verify_install
ok "恢复和基础验收完成"
}
command -v docker >/dev/null 2>&1 || fail "未找到 docker 命令"
docker compose version >/dev/null 2>&1 || fail "当前 Docker 不支持 compose v2,请先安装 Docker Compose V2"
command -v curl >/dev/null 2>&1 || fail "未找到 curl 命令"
command -v tar >/dev/null 2>&1 || fail "未找到 tar 命令"
PROJECT_DIR_ABS="$(cd "$PROJECT_DIR" && pwd)"
COMPOSE_PATH="$PROJECT_DIR_ABS/$COMPOSE_FILE"
ENV_PATH="$PROJECT_DIR_ABS/$ENV_FILE"
[[ -f "$COMPOSE_PATH" ]] || fail "Compose 文件不存在:$COMPOSE_PATH"
cd "$PROJECT_DIR_ABS"
COMPOSE_ARGS=(compose -f "$COMPOSE_PATH")
if [[ -f "$ENV_PATH" ]]; then
COMPOSE_ARGS+=(--env-file "$ENV_PATH")
fi
case "$ACTION" in
backup)
backup_action
;;
restore)
restore_action
;;
verify)
verify_install
;;
esac

View File

@ -0,0 +1,331 @@
#!/usr/bin/env node
// FLNXI customer acceptance smoke for a deployed app.
import { readFile } from 'node:fs/promises'
const DEFAULT_TIMEOUT_MS = 15000
function parseArgs(argv) {
const args = {
baseUrl: '',
envFile: '.env.smoke',
email: '',
password: '',
write: false,
expectAdmin: true,
timeoutMs: DEFAULT_TIMEOUT_MS,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
switch (arg) {
case '--base-url':
args.baseUrl = argv[++index] || ''
break
case '--env-file':
args.envFile = argv[++index] || ''
break
case '--email':
args.email = argv[++index] || ''
break
case '--password':
args.password = argv[++index] || ''
break
case '--write':
args.write = true
break
case '--no-expect-admin':
args.expectAdmin = false
break
case '--timeout-ms':
args.timeoutMs = Number(argv[++index] || DEFAULT_TIMEOUT_MS)
break
case '-h':
case '--help':
printHelp()
process.exit(0)
break
default:
throw new Error(`未知参数:${arg}`)
}
}
return args
}
function printHelp() {
console.log(`FLNXI 客户业务验收 smoke
用法:
node scripts/customer-acceptance-smoke.mjs --base-url http://127.0.0.1:4320 [选项]
常用选项:
--env-file <path> 读取 .env.smoke,默认 .env.smoke
--email <email> 验收账号邮箱,也可用 SMOKE_ADMIN_EMAIL
--password <password> 验收账号密码,也可用 SMOKE_ADMIN_PASSWORD
--write 登录后执行一次真实小素材上传,验证数据库和 MinIO 写入
--no-expect-admin 登录账号不是最高权限/管理员时使用
--timeout-ms <ms> 单个请求超时,默认 15000
.env.smoke 支持:
SMOKE_BASE_URL=http://127.0.0.1:4320
SMOKE_ADMIN_EMAIL=admin@example.com
SMOKE_ADMIN_PASSWORD=replace-with-smoke-password
`)
}
async function loadEnvFile(path) {
if (!path) return {}
try {
const content = await readFile(path, 'utf8')
const values = {}
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#') || !line.includes('=')) continue
const [key, ...rest] = line.split('=')
let value = rest.join('=').trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
values[key.trim()] = value
}
return values
} catch (error) {
if (error?.code === 'ENOENT') return {}
throw error
}
}
function normalizeBaseUrl(baseUrl) {
const normalized = (baseUrl || '').trim().replace(/\/+$/, '')
if (!normalized) {
throw new Error('缺少 --base-url 或 SMOKE_BASE_URL')
}
return normalized
}
function splitSetCookie(headerValue) {
if (!headerValue) return []
return headerValue.split(/,(?=\s*[^;,=\s]+=[^;]+)/g).map((value) => value.trim()).filter(Boolean)
}
class CookieJar {
constructor() {
this.cookies = new Map()
}
apply(response) {
const headers = response.headers
const setCookies = typeof headers.getSetCookie === 'function'
? headers.getSetCookie()
: splitSetCookie(headers.get('set-cookie'))
for (const setCookie of setCookies) {
const pair = setCookie.split(';')[0]
const separator = pair.indexOf('=')
if (separator <= 0) continue
this.cookies.set(pair.slice(0, separator), pair.slice(separator + 1))
}
}
header() {
return Array.from(this.cookies.entries()).map(([key, value]) => `${key}=${value}`).join('; ')
}
}
function withTimeout(timeoutMs) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
return { controller, timeout }
}
async function request(baseUrl, path, options, jar, timeoutMs) {
const { controller, timeout } = withTimeout(timeoutMs)
try {
const headers = new Headers(options?.headers || {})
if (jar && jar.header()) {
headers.set('cookie', jar.header())
}
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers,
redirect: options?.redirect || 'manual',
signal: controller.signal,
})
if (jar) jar.apply(response)
return response
} finally {
clearTimeout(timeout)
}
}
async function readJsonSafe(response) {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return { raw: text.slice(0, 500) }
}
}
function assertStatus(label, response, expected) {
const ok = Array.isArray(expected) ? expected.includes(response.status) : response.status === expected
if (!ok) {
throw new Error(`${label} 状态码不符合预期:实际 ${response.status},预期 ${Array.isArray(expected) ? expected.join('/') : expected}`)
}
}
function logOk(message) {
console.log(`[OK] ${message}`)
}
async function checkPublic(baseUrl, timeoutMs) {
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 init = await request(baseUrl, '/api/system/init', { method: 'GET' }, null, timeoutMs)
assertStatus('/api/system/init', init, 200)
await readJsonSafe(init)
logOk('/api/system/init 可读取')
const login = await request(baseUrl, '/login', { method: 'GET' }, null, timeoutMs)
assertStatus('/login', login, 200)
logOk('/login 可打开')
const blockedAdmin = await request(baseUrl, '/api/admin/users', { method: 'GET' }, null, timeoutMs)
assertStatus('未登录访问 /api/admin/users', blockedAdmin, [401, 403])
logOk('未登录访问后台用户接口已被拦截')
}
async function login(baseUrl, email, password, timeoutMs) {
if (!email || !password) {
throw new Error('需要 --email/--password 或 SMOKE_ADMIN_EMAIL/SMOKE_ADMIN_PASSWORD 才能执行登录验收')
}
const jar = new CookieJar()
const csrfResponse = await request(baseUrl, '/api/auth/csrf', { method: 'GET' }, jar, timeoutMs)
assertStatus('/api/auth/csrf', csrfResponse, 200)
const csrf = await readJsonSafe(csrfResponse)
if (!csrf?.csrfToken) {
throw new Error('未取得 NextAuth CSRF token')
}
const body = new URLSearchParams({
csrfToken: csrf.csrfToken,
email,
password,
redirect: 'false',
json: 'true',
})
const callback = await request(
baseUrl,
'/api/auth/callback/credentials?json=true',
{
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
},
jar,
timeoutMs
)
assertStatus('账号登录', callback, [200, 302])
const session = await request(baseUrl, '/api/auth/session', { method: 'GET' }, jar, timeoutMs)
assertStatus('/api/auth/session', session, 200)
const sessionJson = await readJsonSafe(session)
if (!sessionJson?.user?.email) {
throw new Error('登录后没有拿到有效 session')
}
logOk('账号登录成功')
return jar
}
async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) {
const templates = await request(baseUrl, '/api/upload-templates', { method: 'GET' }, jar, timeoutMs)
assertStatus('/api/upload-templates', templates, 200)
logOk('普通上传模板接口正常')
const builtinTemplates = await request(baseUrl, '/api/templates', { method: 'GET' }, jar, timeoutMs)
assertStatus('/api/templates', builtinTemplates, 200)
logOk('内置模板接口正常')
const defaultPassword = await request(baseUrl, '/api/user/check-default-password', { method: 'GET' }, jar, timeoutMs)
assertStatus('/api/user/check-default-password', defaultPassword, 200)
logOk('默认密码检查接口正常')
const adminUsers = await request(baseUrl, '/api/admin/users', { method: 'GET' }, jar, timeoutMs)
if (expectAdmin) {
assertStatus('/api/admin/users', adminUsers, 200)
logOk('后台用户接口正常')
} else {
assertStatus('/api/admin/users', adminUsers, [200, 403])
logOk('后台用户接口权限表现符合当前账号类型')
}
}
async function writeUploadSmoke(baseUrl, jar, timeoutMs) {
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
const png1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64'
)
const form = new FormData()
form.set('productModel', `FLNXI客户验收-${stamp}`)
form.set('categoryName', '客户验收')
form.set('sceneName', '客户验收')
form.set('materialType', '3DMAX')
form.set('renderer', 'CR')
form.set('previewImage', new Blob([png1x1], { type: 'image/png' }), `flnxi-smoke-${stamp}.png`)
form.set('zipFile', new Blob([Buffer.from('FLNXI customer smoke file\n')], { type: 'application/zip' }), `flnxi-smoke-${stamp}.zip`)
const response = await request(
baseUrl,
'/api/materials/upload',
{ method: 'POST', body: form },
jar,
timeoutMs
)
const data = await readJsonSafe(response)
if (response.status !== 201) {
const reason = data?.error || data?.message || data?.raw || '无错误详情'
throw new Error(`真实小素材上传失败:HTTP ${response.status},${reason}`)
}
const materialId = data?.material?.id || data?.material?.materialId || data?.material?.code || '已创建'
logOk(`真实小素材上传成功:${materialId}`)
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const env = await loadEnvFile(args.envFile)
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
console.log(`[INFO] 验收地址:${baseUrl}`)
await checkPublic(baseUrl, args.timeoutMs)
if (email || password || args.write) {
const jar = await login(baseUrl, email, password, args.timeoutMs)
await checkAuthenticated(baseUrl, jar, args.expectAdmin, args.timeoutMs)
if (args.write) {
await writeUploadSmoke(baseUrl, jar, args.timeoutMs)
}
} else {
console.log('[INFO] 未提供验收账号,已跳过登录、后台和上传验收。')
}
console.log('[OK] 客户业务验收 smoke 完成')
}
main().catch((error) => {
console.error(`[ERROR] ${error?.message || error}`)
process.exit(1)
})

160
scripts/customer-install.sh Normal file
View File

@ -0,0 +1,160 @@
#!/usr/bin/env bash
# FLNXI customer first-install guide for NAS/Linux hosts.
set -euo pipefail
PROJECT_DIR="."
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env"
START=0
PREPARE_DIRECTORIES=0
PLAN_ONLY=0
SKIP_MIGRATION_STATUS=0
SKIP_OBJECT_STORAGE_CHECK=0
HEALTH_TIMEOUT_SECONDS=120
usage() {
cat <<'EOF'
FLNXI NAS/Linux 客户首装向导
用法:
bash scripts/customer-install.sh [选项]
常用选项:
--project-dir <path> 部署目录,默认当前目录
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
--env-file <file> 环境文件名,默认 .env
--start 执行 docker compose up -d
--prepare-directories 缺少 uploads/data 时创建并检查权限
--plan-only 只打印步骤,不启动容器
--skip-migration-status 跳过 Prisma 迁移状态检查
--skip-object-storage-check 跳过 MinIO bucket 检查
--health-timeout-seconds <sec> 健康检查等待秒数,默认 120
-h, --help 显示帮助
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--project-dir)
PROJECT_DIR="${2:?--project-dir requires a value}"
shift 2
;;
--compose-file)
COMPOSE_FILE="${2:?--compose-file requires a value}"
shift 2
;;
--env-file)
ENV_FILE="${2:?--env-file requires a value}"
shift 2
;;
--start)
START=1
shift
;;
--prepare-directories)
PREPARE_DIRECTORIES=1
shift
;;
--plan-only)
PLAN_ONLY=1
shift
;;
--skip-migration-status)
SKIP_MIGRATION_STATUS=1
shift
;;
--skip-object-storage-check)
SKIP_OBJECT_STORAGE_CHECK=1
shift
;;
--health-timeout-seconds)
HEALTH_TIMEOUT_SECONDS="${2:?--health-timeout-seconds requires a value}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf '[ERROR] 未知参数:%s\n' "$1" >&2
exit 1
;;
esac
done
step() {
printf '\n[%s/4] %s\n' "$1" "$2"
}
printf '========================================\n'
printf ' FLNXI NAS/Linux 客户首装向导\n'
printf '========================================\n'
printf '部署目录:%s\n' "$PROJECT_DIR"
printf 'Compose:%s\n' "$COMPOSE_FILE"
printf '环境文件:%s\n' "$ENV_FILE"
step 1 "检查部署文件和客户现场配置"
printf '会检查 .env、APP_IMAGE、许可证参数、数据目录和常见占位值。\n'
if [[ "$PREPARE_DIRECTORIES" -eq 1 ]]; then
printf '缺少 uploads/data 目录时会自动创建,并在 root/sudo 运行时调整为 UID 1001 可写。\n'
fi
if [[ "$PLAN_ONLY" -eq 1 ]]; then
step 2 "准备启动方式"
printf '如果希望脚本启动容器,请重新执行并加 --start。\n'
printf '如果已经通过 NAS 图形界面启动,则不加 --start,脚本只做验收。\n'
step 3 "运行首装验收"
printf '验收内容包括 Compose 配置、容器状态、运行镜像、VERSION.txt、/api/health、Prisma 迁移状态和 MinIO bucket。\n'
step 4 "查看结果"
printf '所有步骤通过后,浏览器打开 APP_PUBLIC_URL / NEXTAUTH_URL。\n'
printf '如失败,根据脚本输出先修 .env、镜像、目录权限或容器日志。\n'
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SMOKE_SCRIPT="$SCRIPT_DIR/smoke-install.sh"
[[ -f "$SMOKE_SCRIPT" ]] || {
printf '[ERROR] 找不到 smoke-install.sh:%s\n' "$SMOKE_SCRIPT" >&2
exit 1
}
COMMON_ARGS=(
--project-dir "$PROJECT_DIR"
--compose-file "$COMPOSE_FILE"
--env-file "$ENV_FILE"
--health-timeout-seconds "$HEALTH_TIMEOUT_SECONDS"
)
if [[ "$SKIP_MIGRATION_STATUS" -eq 1 ]]; then
COMMON_ARGS+=(--skip-migration-status)
fi
if [[ "$SKIP_OBJECT_STORAGE_CHECK" -eq 1 ]]; then
COMMON_ARGS+=(--skip-object-storage-check)
fi
PREFLIGHT_ARGS=(--preflight-only)
if [[ "$PREPARE_DIRECTORIES" -eq 1 ]]; then
PREFLIGHT_ARGS+=(--prepare-directories)
fi
bash "$SMOKE_SCRIPT" "${COMMON_ARGS[@]}" "${PREFLIGHT_ARGS[@]}"
step 2 "确认容器启动方式"
if [[ "$START" -eq 1 ]]; then
printf '将由脚本执行 docker compose up -d。\n'
else
printf '未传 --start,默认认为容器已由 NAS 图形界面或其它编排入口启动。\n'
fi
step 3 "执行首装启动和验收"
VERIFY_ARGS=()
if [[ "$START" -eq 1 ]]; then
VERIFY_ARGS+=(--start)
fi
if [[ "$PREPARE_DIRECTORIES" -eq 1 ]]; then
VERIFY_ARGS+=(--prepare-directories)
fi
bash "$SMOKE_SCRIPT" "${COMMON_ARGS[@]}" "${VERIFY_ARGS[@]}"
step 4 "完成"
printf '首装向导完成。下一步请用浏览器打开 APP_PUBLIC_URL / NEXTAUTH_URL,并完成管理员初始化、登录、上传和下载验收。\n'

409
scripts/smoke-install.sh Normal file
View File

@ -0,0 +1,409 @@
#!/usr/bin/env bash
# FLNXI customer install smoke check for NAS/Linux hosts.
set -euo pipefail
PROJECT_DIR="."
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env"
APP_SERVICE="app"
POSTGRES_SERVICE="postgres"
MINIO_SERVICE="minio"
START=0
PREFLIGHT_ONLY=0
PREPARE_DIRECTORIES=0
SKIP_MIGRATION_STATUS=0
SKIP_OBJECT_STORAGE_CHECK=0
HEALTH_TIMEOUT_SECONDS=120
PROBLEMS=()
info() {
printf '[INFO] %s\n' "$1"
}
ok() {
printf '[OK] %s\n' "$1"
}
warn() {
printf '[WARN] %s\n' "$1" >&2
}
fail() {
printf '[ERROR] %s\n' "$1" >&2
exit 1
}
usage() {
cat <<'EOF'
FLNXI NAS/Linux 首装验收脚本
用法:
bash scripts/smoke-install.sh [选项]
常用选项:
--project-dir <path> 部署目录,默认当前目录
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
--env-file <file> 环境文件名,默认 .env
--start 执行 docker compose up -d
--preflight-only 只做安装前检查,不检查容器
--prepare-directories 缺少 uploads/data 时创建并检查权限
--skip-migration-status 跳过 Prisma 迁移状态检查
--skip-object-storage-check 跳过 MinIO bucket 检查
--health-timeout-seconds <sec> 健康检查等待秒数,默认 120
-h, --help 显示帮助
说明:
如果当前账号没有 Docker 权限,请用 sudo 运行本脚本。
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--project-dir)
PROJECT_DIR="${2:?--project-dir requires a value}"
shift 2
;;
--compose-file)
COMPOSE_FILE="${2:?--compose-file requires a value}"
shift 2
;;
--env-file)
ENV_FILE="${2:?--env-file requires a value}"
shift 2
;;
--app-service)
APP_SERVICE="${2:?--app-service requires a value}"
shift 2
;;
--postgres-service)
POSTGRES_SERVICE="${2:?--postgres-service requires a value}"
shift 2
;;
--minio-service)
MINIO_SERVICE="${2:?--minio-service requires a value}"
shift 2
;;
--start)
START=1
shift
;;
--preflight-only)
PREFLIGHT_ONLY=1
shift
;;
--prepare-directories)
PREPARE_DIRECTORIES=1
shift
;;
--skip-migration-status)
SKIP_MIGRATION_STATUS=1
shift
;;
--skip-object-storage-check)
SKIP_OBJECT_STORAGE_CHECK=1
shift
;;
--health-timeout-seconds)
HEALTH_TIMEOUT_SECONDS="${2:?--health-timeout-seconds requires a value}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
fail "未知参数:$1"
;;
esac
done
add_problem() {
PROBLEMS+=("$1")
}
read_env_value() {
local key="$1"
local path="$2"
local line
if [[ ! -f "$path" ]]; then
return 0
fi
line="$(grep -E "^[[:space:]]*${key}=" "$path" | tail -n 1 || true)"
[[ -z "$line" ]] && return 0
printf '%s' "$line" | sed -E "s/^[^=]*=//;s/\r$//;s/^['\"]//;s/['\"]$//"
}
is_placeholder_value() {
local value="${1:-}"
[[ -z "${value//[[:space:]]/}" ]] && return 0
[[ "$value" =~ ^(replace-with|your-|<|>) ]] && return 0
[[ "$value" == *"example.com"* ]] && return 0
[[ "$value" == *"192.168.x.x"* ]] && return 0
[[ "$value" =~ (^|[^0-9])x\.x([^0-9]|$) ]] && return 0
return 1
}
required_env() {
local name="$1"
local value
value="$(read_env_value "$name" "$ENV_PATH")"
if is_placeholder_value "$value"; then
add_problem "$name 还不是客户现场真实值"
fi
}
test_app_image() {
local app_image image_name
app_image="$(read_env_value "APP_IMAGE" "$ENV_PATH")"
if is_placeholder_value "$app_image"; then
add_problem "APP_IMAGE 还不是完整客户版本镜像"
return
fi
image_name="${app_image##*/}"
if [[ "$image_name" == *":latest" ]]; then
add_problem "APP_IMAGE 不能使用 latest,必须固定到明确版本号"
fi
if [[ "$app_image" != *"@sha256:"* && "$image_name" != *":"* ]]; then
add_problem "APP_IMAGE 缺少版本标签,例如 :1.0.1"
fi
if [[ "$app_image" == *"/material-dev/"* ]]; then
warn "APP_IMAGE 当前指向 material-dev 测试镜像;正式客户交付应使用 material 正式镜像。"
fi
}
is_external_object_storage() {
local endpoint host
endpoint="$(read_env_value "S3_ENDPOINT" "$ENV_PATH")"
[[ -z "$endpoint" ]] && return 1
host="$(printf '%s' "$endpoint" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')"
[[ "$host" != "minio" ]]
}
test_directory_ready() {
local name="$1"
local path="$PROJECT_DIR_ABS/$name"
if [[ ! -d "$path" ]]; then
if [[ "$PREPARE_DIRECTORIES" -eq 1 ]]; then
mkdir -p "$path"
ok "已创建目录:$name"
else
add_problem "缺少 $name 目录;可先创建并确保应用容器用户可写"
return
fi
fi
if [[ "$PREPARE_DIRECTORIES" -eq 1 && "$(id -u)" == "0" ]]; then
chown 1001:1001 "$path" || warn "无法调整 $name 目录属主,请人工确认 UID 1001 可写。"
chmod 755 "$path" || warn "无法调整 $name 目录权限,请人工确认 UID 1001 可写。"
fi
if ! touch "$path/.flnxi-write-test" 2>/dev/null; then
add_problem "$name 目录当前账号不可写;请检查部署目录权限"
else
rm -f "$path/.flnxi-write-test"
fi
local owner_uid owner_gid mode other_write
owner_uid="$(stat -c '%u' "$path" 2>/dev/null || echo unknown)"
owner_gid="$(stat -c '%g' "$path" 2>/dev/null || echo unknown)"
mode="$(stat -c '%a' "$path" 2>/dev/null || echo 0)"
other_write="${mode: -1}"
if [[ "$owner_uid" != "1001" && "$owner_gid" != "1001" && "$other_write" != "2" && "$other_write" != "3" && "$other_write" != "6" && "$other_write" != "7" ]]; then
add_problem "$name 目录可能不是应用容器用户 UID 1001 可写;建议执行 sudo chown 1001:1001 '$path'"
fi
}
install_preflight() {
info "执行安装前检查"
if [[ ! -f "$ENV_PATH" ]]; then
add_problem ".env 文件不存在,请先从 .env.example 复制并填写客户现场值"
fi
local required_names=(
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
)
local name
for name in "${required_names[@]}"; do
required_env "$name"
done
test_app_image
local database_url
database_url="$(read_env_value "DATABASE_URL" "$ENV_PATH")"
if [[ -n "$database_url" ]] && is_placeholder_value "$database_url"; then
add_problem "DATABASE_URL 仍是示例地址;内置 PostgreSQL 请留空,外部 PostgreSQL 请填真实连接串"
fi
if is_external_object_storage; then
for name in S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
required_env "$name"
done
else
for name in MINIO_ROOT_USER MINIO_ROOT_PASSWORD S3_BUCKET; do
required_env "$name"
done
fi
local app_public_url nextauth_url
app_public_url="$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")"
nextauth_url="$(read_env_value "NEXTAUTH_URL" "$ENV_PATH")"
app_public_url="${app_public_url%/}"
nextauth_url="${nextauth_url%/}"
if [[ -n "$app_public_url" && -n "$nextauth_url" && "$app_public_url" != "$nextauth_url" ]]; then
warn "APP_PUBLIC_URL 与 NEXTAUTH_URL 不一致,请确认最终浏览器访问入口。"
fi
test_directory_ready "uploads"
test_directory_ready "data"
if [[ "${#PROBLEMS[@]}" -gt 0 ]]; then
printf '[ERROR] 安装前检查未通过:\n' >&2
printf ' - %s\n' "${PROBLEMS[@]}" >&2
exit 1
fi
ok "安装前检查通过"
}
run_docker() {
docker "$@"
}
run_compose() {
run_docker "${COMPOSE_ARGS[@]}" "$@"
}
get_service_container_id() {
local service="$1"
local container_id
container_id="$(run_compose ps -q "$service" | tr -d '\r')"
if [[ -z "$container_id" ]]; then
fail "未找到服务容器:$service。"
fi
printf '%s' "$container_id"
}
wait_service_running() {
local container_id="$1"
local service="$2"
local timeout="$3"
local deadline status
deadline=$(( $(date +%s) + timeout ))
while [[ "$(date +%s)" -le "$deadline" ]]; do
status="$(run_docker inspect "$container_id" --format '{{.State.Status}}' 2>/dev/null || true)"
if [[ "$status" == "running" ]]; then
ok "$service 容器已运行"
return 0
fi
sleep 5
done
fail "$service 容器在 $timeout 秒内没有稳定运行。"
}
wait_health() {
local health_url="$1"
local timeout="$2"
local deadline
deadline=$(( $(date +%s) + timeout ))
while [[ "$(date +%s)" -le "$deadline" ]]; do
if curl -fsS "$health_url" >/dev/null 2>&1; then
ok "/api/health 正常"
return 0
fi
sleep 5
done
fail "/api/health 在 $timeout 秒内没有通过:$health_url"
}
command -v docker >/dev/null 2>&1 || fail "未找到 docker 命令"
docker compose version >/dev/null 2>&1 || fail "当前 Docker 不支持 compose v2,请先安装 Docker Compose V2"
command -v curl >/dev/null 2>&1 || fail "未找到 curl 命令"
PROJECT_DIR_ABS="$(cd "$PROJECT_DIR" && pwd)"
COMPOSE_PATH="$PROJECT_DIR_ABS/$COMPOSE_FILE"
ENV_PATH="$PROJECT_DIR_ABS/$ENV_FILE"
cd "$PROJECT_DIR_ABS"
[[ -f "$COMPOSE_PATH" ]] || fail "Compose 文件不存在:$COMPOSE_PATH"
COMPOSE_ARGS=(compose -f "$COMPOSE_PATH")
if [[ -f "$ENV_PATH" ]]; then
COMPOSE_ARGS+=(--env-file "$ENV_PATH")
fi
install_preflight
if [[ "$PREFLIGHT_ONLY" -eq 1 ]]; then
ok "安装前检查完成,未启动或检查容器。"
exit 0
fi
info "检查 Compose 配置"
run_compose config --quiet
if [[ "$START" -eq 1 ]]; then
info "启动客户安装编排"
run_compose up -d
fi
info "检查容器状态"
run_compose ps
APP_CONTAINER_ID="$(get_service_container_id "$APP_SERVICE")"
wait_service_running "$APP_CONTAINER_ID" "$APP_SERVICE" "$HEALTH_TIMEOUT_SECONDS"
RUNNING_IMAGE="$(run_docker inspect "$APP_CONTAINER_ID" --format '{{.Config.Image}}' | tr -d '\r')"
[[ -n "$RUNNING_IMAGE" ]] || fail "无法读取应用运行镜像。"
ok "应用运行镜像:$RUNNING_IMAGE"
VERSION_OUTPUT="$(run_compose exec -T "$APP_SERVICE" sh -c 'cat VERSION.txt 2>/dev/null || true' | tr -d '\r')"
if [[ -n "$VERSION_OUTPUT" ]]; then
ok "应用版本文件:$VERSION_OUTPUT"
else
warn "镜像内未读到 VERSION.txt,请确认发布镜像是否包含版本文件。"
fi
PUBLIC_URL="$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")"
if [[ -z "$PUBLIC_URL" ]]; then
PUBLIC_URL="$(read_env_value "NEXTAUTH_URL" "$ENV_PATH")"
fi
[[ -n "$PUBLIC_URL" ]] || fail "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,无法验收健康接口。"
wait_health "${PUBLIC_URL%/}/api/health" "$HEALTH_TIMEOUT_SECONDS"
if [[ "$SKIP_MIGRATION_STATUS" -eq 0 ]]; then
info "检查数据库迁移状态"
run_compose exec -T "$APP_SERVICE" npx prisma migrate status --schema=prisma/schema.prisma
fi
if [[ "$SKIP_OBJECT_STORAGE_CHECK" -eq 0 ]]; then
if is_external_object_storage; then
warn "检测到外部对象存储,跳过内置 MinIO bucket 检查;请按客户现场对象存储方案单独验收。"
else
BUCKET="$(read_env_value "S3_BUCKET" "$ENV_PATH")"
BUCKET="${BUCKET:-flnxi-materials}"
info "检查 MinIO bucket:$BUCKET"
run_compose exec -T "$MINIO_SERVICE" sh -c "test -d '/data/$BUCKET'"
ok "对象存储 bucket 存在:$BUCKET"
fi
fi
info "最近应用日志"
run_compose logs --tail 80 "$APP_SERVICE"
ok "首装基础验收完成"