# FruitShorts 后端部署与回滚(DEPLOY) > 依据:`docs/04-部署架构.md`(第一阶段 **Docker Compose 单机**、镜像即交付物;生产才上 K8s)、 > `docs/11-发布与回滚方案.md`、`docs/12-上线检查清单.md`(**播放签名生产必开**、密钥走环境变量)。 > 目标机形态:**办公室一台主机**(§三 精简方案:app/api/DB/Redis 合并 1 台 8C32G,转码 worker 独立进程)。 ## 一、两条部署路径(先选一条) | 路径 | 适用 | 命令主线 | | --- | --- | --- | | **A. Docker Compose(推荐,文档口径)** | 目标主机装了 Docker + Compose;一条命令起全栈(含 PG 18 与 Redis) | 见 §三 | | **B. 裸 jar(兜底)** | 主机没有 Docker,或只想起单个进程调试 | 见 §四 | > 两条路径的**配置完全相同**(同一批环境变量),只是"谁来跑进程"不同。 ## 二、环境变量清单 复制 `.env.example` 为 `.env` 填值(`.env` 已在 `.gitignore`,**不要提交**)。 使用 `--spring.profiles.active=prod`(Compose 里已默认)时,下表标 🔴 的项**缺失/仍是 dev 默认值/长度不足 32 位 → 启动直接失败**(fail-fast,见 §五)。 | 变量 | 必填 | 缺失后果 | 说明 | | --- | --- | --- | --- | | `DB_URL` | ✅ | 用默认 `jdbc:postgresql://127.0.0.1:5432/fruitshorts`,连不上就启动失败 | Compose 里默认指向 `db` 服务 | | `DB_USER` / `DB_PASSWORD` | ✅ | 连库失败 | Compose 的 db 容器用同一组值创建库 | | `DB_NAME` | Compose 必填 | 默认 `fruitshorts` | 只在 compose 里用到 | | `JWT_ADMIN_SECRET` | 🔴 | **prod 拒绝启动** | 管理端令牌密钥,≥32 位,生成:`openssl rand -base64 48` | | `JWT_APP_SECRET` | 🔴 | **prod 拒绝启动** | 客户端令牌密钥。**过去这个键没外置,一直用代码里的 dev 默认值**,务必注入 | | `MEDIA_SIGNATURE_SECRET` | 🔴 | **prod 拒绝启动** | 播放地址签名密钥(`docs/12-上线检查清单`) | | `MEDIA_SIGNATURE_REQUIRED` | 🔴 | prod 下不是 `true` 就拒绝启动 | 生产必须开;本地调试保持 false | | `CORS_ALLOWED_ORIGINS` | 🔴 | prod 下为空则拒绝启动 | 逗号分隔的正式来源,**生产不许用 `*`** | | `MEDIA_ROOT_DIR` | 建议 | 默认 `./data/uploads`,容器里必须为 `/data/uploads` | 媒资根目录(挂载卷) | | `SERVER_PORT` | Compose 已设 | 默认 8081/8080 | app-api 8081 / admin-api 8080 | | `SWAGGER_ENABLED` | 可选 | 默认 false(**测试/联调请在 `.env` 里设 `true`**,前端要看 `/doc.html`) | **一次开关两个**:`true` = `/doc.html` + `/v3/api-docs` 都开;`false` = 两个都关(生产不想暴露 UI 就设 false,机器可读契约也一并关闭) | | `STORE_VERIFIER` / `GOOGLE_PLAY_*` | 可选 | 默认 `disabled` | 商店票据校验,没凭据就保持 disabled(对外返回"尚未配置") | **密钥怎么生成**:`openssl rand -base64 48`(每个密钥单独生成,别复用)。 ## 三、路径 A:办公室单机 + Docker Compose(主线) 目标主机前置:**Docker + Docker Compose**(`docker compose version` 有输出即可),建议 8C32G。 ```bash # 0) 取代码(或拿打包好的目录)并进入 FruitServer cd FruitServer # 1) 配置环境变量 cp .env.example .env openssl rand -base64 48 # 生成三个密钥,分别填进 JWT_ADMIN_SECRET / JWT_APP_SECRET / MEDIA_SIGNATURE_SECRET vi .env # 还要填 DB_PASSWORD、CORS_ALLOWED_ORIGINS # 2) 构建并启动(首次会自动建表 + 造种子数据) docker compose up -d --build # 3) 看状态与日志 docker compose ps # 预期:db/redis healthy,app-api/admin-api healthy,transcode-worker running docker compose logs -f app-api # 预期:出现 "生产配置校验通过" 与 "Started AppApiApplication" # 4) 冒烟 tools/smoke.sh # 预期:全部 PASS,退出码 0 ``` **端口与访问**:app-api `8081`(对客户端开放)、admin-api **只绑 `127.0.0.1:8080`**(管理端仅内网,公网通过 SSH 隧道或反代访问)、PG/Redis 不对宿主暴露。 **数据库初始化**:`sql/*.sql` 已挂进 `docker-entrypoint-initdb.d`(schema → schema-phase1 → partition → data → data-phase1), **只在数据卷为空时执行**。改 SQL 后要重来:`docker compose down -v`(**会删数据**)再 `up`。 **日常操作** ```bash docker compose ps # 状态 docker compose logs -f --tail=200 # 全部日志 docker compose restart app-api # 重启单个服务 docker compose up -d --build app-api # 只重建单个镜像 docker compose down # 停(保留数据卷) ``` ## 四、路径 B:裸 jar(兜底) 目标主机前置:**JDK 21**;转码进程额外需要 **ffmpeg/ffprobe**(`brew install ffmpeg` 或 `apt install ffmpeg`)。 数据库:自己装 PostgreSQL 18 并导入 `sql/*.sql`(顺序同上)。 ```bash cd FruitServer mvn -pl fruitshorts-app-api,fruitshorts-admin-api,fruitshorts-transcode-worker -am package -DskipTests # 导出环境变量(示例;密钥务必换真实值) export DB_URL=jdbc:postgresql://127.0.0.1:5432/fruitshorts DB_USER=fruitshorts DB_PASSWORD='***' export JWT_ADMIN_SECRET="$(openssl rand -base64 48)" # 生产请固定保存,不要每次重新生成 export JWT_APP_SECRET="$(openssl rand -base64 48)" export MEDIA_SIGNATURE_SECRET="$(openssl rand -base64 48)" export MEDIA_SIGNATURE_REQUIRED=true CORS_ALLOWED_ORIGINS='https://admin.example.com' export MEDIA_ROOT_DIR="$PWD/data/uploads" # 起三个进程(生产用 prod profile;本地调试去掉 --spring.profiles.active=prod 即可) nohup java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar \ --spring.profiles.active=prod > /tmp/admin.log 2>&1 & nohup java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar \ --spring.profiles.active=prod > /tmp/app.log 2>&1 & nohup java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar \ --spring.profiles.active=prod > /tmp/worker.log 2>&1 & ``` > ⚠️ **重新打包必须重启进程**:`spring-boot:repackage` 会原地替换 jar,运行中的进程再去加载"没加载过的类" > 会抛 `NoClassDefFoundError`,表现为**请求挂住**(S1 记录过这个坑)。顺序永远是"改完 → 打包 → 重启 → 验证"。 ## 五、prod fail-fast 是什么、长什么样 `prod` profile 下 `ProdSecretGuard` 会校验:JWT 双密钥 + 媒体签名密钥(非空、不是 dev 默认值、≥32 位)、 `signature-required=true`、`DB_URL` 非空、CORS 非空。任一不满足 → **打印全部问题并终止启动**: ``` ERROR c.f.framework.config.ProdSecretGuard : 生产配置校验未通过,拒绝启动(prod profile): - fruitshorts.jwt.admin-secret(环境变量 JWT_ADMIN_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥 - fruitshorts.jwt.app-secret(环境变量 JWT_APP_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥 - fruitshorts.media.signature-secret(环境变量 MEDIA_SIGNATURE_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥 - fruitshorts.cors.allowed-origins 为空(环境变量 CORS_ALLOWED_ORIGINS)—— 生产不允许用通配 *,必须显式列出来源 请按 FruitServer/DEPLOY.md 的环境变量清单补齐后重启。 ``` exit code = **1**(部署脚本可以直接判失败)。**没有 prod profile 时这套校验不生效**,本地开发照旧开箱即用。 ## 六、发布后验证(每次发版都做) 1. `docker compose ps`:三个应用容器 `Up (healthy)`(worker 是 `Up`)。 2. `tools/smoke.sh`:全 PASS、退出码 0。它检查 `/healthz`、`/app/categories`、`/app/home/feed`、 两个 `/v3/api-docs`、`/doc.html`,以及未授权路径是否按统一信封拒绝。 **关于在线文档那三项**:脚本会按 `SWAGGER_ENABLED` 自适应 —— `true` 时断言 200; `false` 时打印 **SKIP**(不算失败),因为 `/doc.html` 与 `/v3/api-docs` 都被配置关掉了。 没设这个变量时脚本自动探测("200 且含 openapi"算开,"统一信封 code=404"算关,其它响应→FAIL)。 联调环境想强制要求文档在线:`SWAGGER_REQUIRED=true tools/smoke.sh`(不满足直接失败)。 3. `curl -s http://127.0.0.1:8081/healthz`:(从容器里或宿主机)应返回 `{"code":0,...,"status":"UP"}`。 4. 业务抽查:`curl -s http://127.0.0.1:8081/app/home/feed | head -c 200`(有内容); 后台 `http://127.0.0.1:8080/doc.html`(**需要 `.env` 里 `SWAGGER_ENABLED=true`**,联调环境请打开)。 5. 登录一个后台账号,确认能进后台首页(连通数据库与权限体系)。 ## 七、回滚 **镜像/代码回滚** ```bash # 用上一个版本的 tag(每次发版都打 tag:fruitshorts/app-api:2026-10-16-1) docker compose stop app-api admin-api transcode-worker docker tag fruitshorts/app-api:2026-10-15-2 fruitshorts/app-api:latest docker compose up -d app-api admin-api transcode-worker tools/smoke.sh ``` 或用 git 回到上一个 tag 重新 `up -d --build`: ```bash git fetch --tags && git checkout <上一个 release tag> docker compose up -d --build ``` **数据库回滚(谨慎)**:schema 脚本是幂等增量(`ADD COLUMN IF NOT EXISTS` 等),**一般不需要回滚表结构**; 真要回滚数据用备份:`docker compose exec -T db pg_dump -U fruitshorts fruitshorts > backup.sql`。 **发布前先备份**是硬要求: ```bash docker compose exec -T db pg_dump -U "${DB_USER:-fruitshorts}" "${DB_NAME:-fruitshorts}" > backup-$(date +%F-%H%M).sql ``` **回滚后必须重跑** `tools/smoke.sh`,并确认转码队列状态正常(后台「数据看板 → 转码看板」)。 ## 八、未在本机验证的部分(如实说明) 本机**没有 Docker / colima / podman**(`docker` 命令不存在),所以: - `Dockerfile`、`.dockerignore`、`docker-compose.yml` **未经本机构建与运行验证**(只做了语法与配置项静态检查)。 - 本机真实验证过的是:**裸 jar 路径**(两个入口 + worker 起停、`/healthz`、冒烟脚本)、 **prod fail-fast**(缺密钥时退出码 1 + 明确报错)、配置外置(`DB_URL` 等改动后仍能起)。 - 目标主机上有 Docker 之后,请**先跑 §三 的 1)–4) 步**确认;如报错把 `docker compose logs` 贴回来。 ## 九、故障速查 | 现象 | 先看 | 处理 | | --- | --- | --- | | 容器起不来,日志有"生产配置校验未通过" | §五 | 补 `.env` 里对应变量;密钥用 `openssl rand -base64 48` | | `/healthz` 超时(不是快速 404/200) | 是不是刚重新打包没重启? | 重启进程(§四 的注意事项) | | `app-api` healthy 但 `/app/*` 报连库失败 | `docker compose logs db` | 确认 `DB_URL` 指向 `db:5432`,`DB_PASSWORD` 与 db 容器一致 | | 转码任务一直 `QUEUED` | worker 日志 | 确认 `transcode-worker` 在跑、`MEDIA_ROOT_DIR` 与 API 相同、镜像里装了 ffmpeg | ## 十、本地开发(不上 Docker) ```bash mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar # 不带 prod profile,开箱即用 tools/smoke.sh # 本机一键冒烟 ```