diff --git a/.env.example b/.env.example index 1caa99b..cc31d5a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Base application configuration -NODE_ENV=development +NODE_ENV=production # Public URLs SERVER_IP=192.168.x.x diff --git a/README.md b/README.md index df94d5d..5f8aa8d 100644 --- a/README.md +++ b/README.md @@ -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. 文档入口 diff --git a/deploy-registry.ps1 b/deploy-registry.ps1 index 3ee29b7..efdf604 100644 --- a/deploy-registry.ps1 +++ b/deploy-registry.ps1 @@ -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" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3b7c6d7..2545aff 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,7 +1,7 @@ # ============================================ -# FLNXI素材库 - Pro版配置 -# 支持内网穿透,资源存储在MinIO存储桶 -# 从私有镜像仓库拉取镜像,无需源码 +# FLNXI素材库 - 私有化生产配置 +# 默认使用内置 PostgreSQL + MinIO,也可通过 .env 切换外部数据库或对象存储。 +# 主应用从版本化 Registry 镜像启动,客户现场无需源码。 # ============================================ # Registry 项目结构: # - flnxi/runtime:PostgreSQL、MinIO 等运行依赖镜像 diff --git a/docs/README.md b/docs/README.md index 45c129c..f86f5c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,8 @@ 本轮按商业化私有交付目标收口的 8 项阶段记录,只在回看本轮收口时查看,不作为默认入口。 - `客户首装升级坑位排查_20260530.md` 客户首装、升级、回滚、许可证服务和对象存储的历史排障记录,已被更新验证覆盖,默认看部署说明即可。 +- `商业化首装演练记录_20260601.md` + 飞牛 fnOS 虚拟机上的首装验收记录,用于追溯 Linux/NAS 脚本和真实测试环境结论。 - `许可证链路安全评估_20260312.md` 历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口,引用前必须按当前代码重新复核。 diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index 3fee0f3..aaecfde 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -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://:4320 +``` + +提供临时验收账号后,可以继续验登录、后台权限和模板接口: + +```bash +npm run smoke:customer -- \ + --base-url http://:4320 \ + --email \ + --password +``` + +需要真实验证数据库和 MinIO 写入时,加 `--write`。该动作会创建一条小素材验收记录,建议只在测试环境或客户允许的验收窗口执行: + +```bash +npm run smoke:customer -- \ + --base-url http://:4320 \ + --email \ + --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" ` diff --git a/docs/商业化首装演练记录_20260601.md b/docs/商业化首装演练记录_20260601.md new file mode 100644 index 0000000..3b9d6e7 --- /dev/null +++ b/docs/商业化首装演练记录_20260601.md @@ -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://: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 环境。 diff --git a/package.json b/package.json index 9f1ac0f..62e031a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/README.md b/scripts/README.md index 45169a6..7502eae 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 \ + --password +``` + +真实写入验收: + +```bash +npm run smoke:customer -- \ + --base-url http://127.0.0.1:4320 \ + --email \ + --password \ + --write +``` + +说明: + +- `--write` 会创建一条小素材记录,用来验证数据库和 MinIO/S3 确实可写。 +- 验收账号建议使用临时测试账号;脚本不会主动打印密码,但命令历史和 `.env.smoke` 也要按敏感信息处理。 +- 没有最高权限的账号可加 `--no-expect-admin`,避免把正常的后台 403 误判成失败。 + ### 镜像体积和分层基线 ```powershell diff --git a/scripts/backup-restore.sh b/scripts/backup-restore.sh new file mode 100644 index 0000000..bdcb478 --- /dev/null +++ b/scripts/backup-restore.sh @@ -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 部署目录,默认当前目录 + --compose-file Compose 文件名,默认 docker-compose.prod.yml + --env-file 环境文件名,默认 .env + --backup-dir 备份目录名,默认 backups + --backup-path 恢复时指定备份目录 + --helper-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 健康检查等待秒数,默认 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" <"$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 diff --git a/scripts/customer-acceptance-smoke.mjs b/scripts/customer-acceptance-smoke.mjs new file mode 100644 index 0000000..cf4857c --- /dev/null +++ b/scripts/customer-acceptance-smoke.mjs @@ -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 读取 .env.smoke,默认 .env.smoke + --email 验收账号邮箱,也可用 SMOKE_ADMIN_EMAIL + --password 验收账号密码,也可用 SMOKE_ADMIN_PASSWORD + --write 登录后执行一次真实小素材上传,验证数据库和 MinIO 写入 + --no-expect-admin 登录账号不是最高权限/管理员时使用 + --timeout-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) +}) diff --git a/scripts/customer-install.sh b/scripts/customer-install.sh new file mode 100644 index 0000000..edc657f --- /dev/null +++ b/scripts/customer-install.sh @@ -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 部署目录,默认当前目录 + --compose-file Compose 文件名,默认 docker-compose.prod.yml + --env-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 健康检查等待秒数,默认 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' diff --git a/scripts/smoke-install.sh b/scripts/smoke-install.sh new file mode 100644 index 0000000..2b0fbed --- /dev/null +++ b/scripts/smoke-install.sh @@ -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 部署目录,默认当前目录 + --compose-file Compose 文件名,默认 docker-compose.prod.yml + --env-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 健康检查等待秒数,默认 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 "首装基础验收完成"