feat(deploy): D1 后端部署就绪(配置外置 + prod fail-fast + 自包含 Compose + 冒烟/回滚)
为什么:用户下午要部署,但全仓没有任何部署产物;且生产漏配密钥会静默使用代码里的 dev 默认值,
属上线阻塞项(04-部署架构 / 12-上线检查清单)。
- 配置外置(三件):DB_URL、JWT 双密钥(**发现 app-secret 此前没外置,一直用 dev 默认值**)、
MEDIA_SIGNATURE_SECRET、MEDIA_SIGNATURE_REQUIRED、CORS_ALLOWED_ORIGINS、MEDIA_ROOT_DIR
- 新增三份 application-prod.yml:签名强制开启、CORS 收紧、媒资指向挂载点、日志级别
- 新增 ProdSecretGuard(@Profile("prod")):密钥缺失/仍是 dev 默认值/不足 32 位、签名未开、
DB_URL 或 CORS 为空 → 一次列全并终止启动(exit 1)
- 新增 /healthz 轻量健康端点:查明 /actuator/health 的"超时"是旧 jar 未重启所致,项目本就
没有 actuator 依赖,故不引新依赖;同时把 /healthz 加进安全白名单
- Docker 化:多阶段 Dockerfile(非 root、tini、依赖层缓存、ffmpeg 只给 worker)、.dockerignore、
自包含 docker-compose.yml(含 PostgreSQL 18 + Redis + 命名卷 + 按序初始化 sql + healthcheck)
- tools/smoke.sh(失败非 0 退出)+ DEPLOY.md(Compose 主线 / 裸 jar 兜底 / 回滚 / 故障速查)
- 实测:mvn test 83 条全过;两入口 package 通过;prod 缺密钥退出码 1 且报错明确、配齐后正常启动;
冒烟脚本全 PASS;本机无 Docker,镜像与 compose 未经构建验证(已在回执如实标注)
This commit is contained in:
1 parent
9f76302e53
commit
b567365460
16 files changed
+814
-9
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 构建上下文里不需要的东西:产物、IDE、本地上传的媒资、日志。
|
||||
# 注意别把 pom.xml / src / sql 排掉,镜像构建要用。
|
||||
**/target/
|
||||
.git/
|
||||
.gitignore
|
||||
.idea/
|
||||
*.iml
|
||||
*.log
|
||||
data/
|
||||
docs/
|
||||
node_modules/
|
||||
@@ -0,0 +1,34 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 环境变量样例:复制成 .env 后填真实值(.env 不要提交,已在 .gitignore 里)。
|
||||
# 生产用 prod profile 时,下面标「必填」的项缺失/仍是 dev 默认值/长度不足 32 位 → 启动直接失败。
|
||||
# 详细说明见 DEPLOY.md。
|
||||
|
||||
# ── 数据库 ─────────────────────────────────────────────
|
||||
# Docker Compose 单机部署:db 是 compose 里的服务名,保持默认即可;
|
||||
# 若数据库在别的主机/用云托管,把 DB_URL 换成外部地址。
|
||||
DB_NAME=fruitshorts
|
||||
DB_URL=jdbc:postgresql://db:5432/fruitshorts
|
||||
DB_USER=fruitshorts
|
||||
DB_PASSWORD=
|
||||
|
||||
# ── 密钥(必填,各 32 位以上,且不能用代码里的 dev 默认值)──
|
||||
# 生成:openssl rand -base64 48
|
||||
JWT_ADMIN_SECRET=
|
||||
JWT_APP_SECRET=
|
||||
MEDIA_SIGNATURE_SECRET=
|
||||
|
||||
# ── 生产开关 ───────────────────────────────────────────
|
||||
# prod profile 会强制 true(缺了签名则启动失败)
|
||||
MEDIA_SIGNATURE_REQUIRED=true
|
||||
# 后台前端来源,逗号分隔;生产不许用 *
|
||||
CORS_ALLOWED_ORIGINS=https://admin.example.com
|
||||
# 媒资根目录:容器里是挂载点
|
||||
MEDIA_ROOT_DIR=/data/uploads
|
||||
# 是否开放 Swagger UI(生产默认 false,只留 /v3/api-docs)
|
||||
SWAGGER_ENABLED=false
|
||||
|
||||
# ── 可选:Google Play 商店票据校验(不配则保持 disabled)──
|
||||
STORE_VERIFIER=disabled
|
||||
GOOGLE_PLAY_PACKAGE_NAME=
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=
|
||||
@@ -16,3 +16,6 @@ logs/
|
||||
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
|
||||
data/uploads/*
|
||||
!data/uploads/.gitkeep
|
||||
|
||||
# 部署用的真实环境变量(含密钥),只提交 .env.example
|
||||
.env
|
||||
@@ -0,0 +1,185 @@
|
||||
# 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 | 生产是否开放 `/doc.html` 与 `/v3/api-docs` |
|
||||
| `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`,以及未授权路径是否按统一信封拒绝。
|
||||
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`(若 `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 # 本机一键冒烟
|
||||
```
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 多阶段构建:maven 构建 fat jar -> JRE 运行(非 root、tini 做 1 号进程)。
|
||||
# 三个可运行件共用一份 Dockerfile,用 MODULE 构建参数区分。
|
||||
#
|
||||
# 构建(在 FruitServer 目录下执行):
|
||||
# docker build -t fruitshorts/app-api:latest --build-arg MODULE=fruitshorts-app-api .
|
||||
# docker build -t fruitshorts/admin-api:latest --build-arg MODULE=fruitshorts-admin-api .
|
||||
# docker build -t fruitshorts/transcode-worker:latest --build-arg MODULE=fruitshorts-transcode-worker \
|
||||
# --build-arg INSTALL_FFMPEG=true .
|
||||
#
|
||||
# 注意:本机当前没有 Docker(见 DEPLOY.md),这份 Dockerfile 未经本机构建验证。
|
||||
|
||||
# ---------- 1. 构建阶段 ----------
|
||||
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /build
|
||||
|
||||
# 先只拷各模块 pom 再拉依赖:依赖层可复用,改业务代码不会重新下载依赖
|
||||
COPY pom.xml .
|
||||
COPY fruitshorts-common/pom.xml fruitshorts-common/
|
||||
COPY fruitshorts-framework/pom.xml fruitshorts-framework/
|
||||
COPY fruitshorts-module-system/pom.xml fruitshorts-module-system/
|
||||
COPY fruitshorts-module-content/pom.xml fruitshorts-module-content/
|
||||
COPY fruitshorts-module-media/pom.xml fruitshorts-module-media/
|
||||
COPY fruitshorts-module-user/pom.xml fruitshorts-module-user/
|
||||
COPY fruitshorts-module-playback/pom.xml fruitshorts-module-playback/
|
||||
COPY fruitshorts-module-interaction/pom.xml fruitshorts-module-interaction/
|
||||
COPY fruitshorts-module-commerce/pom.xml fruitshorts-module-commerce/
|
||||
COPY fruitshorts-module-creator/pom.xml fruitshorts-module-creator/
|
||||
COPY fruitshorts-admin-api/pom.xml fruitshorts-admin-api/
|
||||
COPY fruitshorts-app-api/pom.xml fruitshorts-app-api/
|
||||
COPY fruitshorts-transcode-worker/pom.xml fruitshorts-transcode-worker/
|
||||
RUN mvn -B -q -DskipTests dependency:go-offline || true
|
||||
|
||||
COPY . .
|
||||
ARG MODULE=fruitshorts-app-api
|
||||
RUN mvn -B -DskipTests -pl ${MODULE} -am package \
|
||||
&& cp ${MODULE}/target/*.jar /build/app.jar
|
||||
|
||||
# ---------- 2. 运行阶段 ----------
|
||||
FROM eclipse-temurin:21-jre
|
||||
|
||||
# ffmpeg 只有转码 worker 需要,用构建参数控制,避免把 80MB 装进两个 API 镜像
|
||||
ARG INSTALL_FFMPEG=false
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini curl \
|
||||
&& if [ "$INSTALL_FFMPEG" = "true" ]; then \
|
||||
apt-get install -y --no-install-recommends ffmpeg; \
|
||||
fi \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 非 root 运行;/data/uploads 预留给媒资挂载卷
|
||||
RUN groupadd -r fruitshorts \
|
||||
&& useradd -r -g fruitshorts -d /app -s /sbin/nologin fruitshorts \
|
||||
&& mkdir -p /app /data/uploads \
|
||||
&& chown -R fruitshorts:fruitshorts /app /data/uploads
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build --chown=fruitshorts:fruitshorts /build/app.jar /app/app.jar
|
||||
|
||||
USER fruitshorts
|
||||
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -Duser.timezone=Asia/Shanghai" \
|
||||
SPRING_PROFILES_ACTIVE=prod \
|
||||
MEDIA_ROOT_DIR=/data/uploads
|
||||
|
||||
# 健康检查打 /healthz(不依赖 actuator,见 HealthController)
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:${SERVER_PORT:-8080}/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
@@ -0,0 +1,147 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 办公室单机部署(docs/04-部署架构.md §三 精简方案 + §五 第一阶段用 Compose):
|
||||
# app-api(8081) + admin-api(8080,只绑本机) + transcode-worker + PostgreSQL 18 + Redis
|
||||
# 转码 worker 与在线服务**同机但独立进程/容器**(§8.5 隔离要求:不抢在线服务资源)。
|
||||
#
|
||||
# 用法(目标主机需要 Docker + Compose):
|
||||
# cp .env.example .env # 填密钥与数据库密码
|
||||
# docker compose up -d --build
|
||||
# tools/smoke.sh
|
||||
#
|
||||
# 本文件在本机**未验证**(本机没有 Docker),见 DEPLOY.md「未在本机验证的部分」。
|
||||
services:
|
||||
|
||||
# ─────────── 基础设施 ───────────
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
container_name: fruitshorts-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-fruitshorts}
|
||||
POSTGRES_USER: ${DB_USER:-fruitshorts}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?数据库密码必填,见 .env.example}
|
||||
# 中文与印尼语内容需要 UTF-8(镜像默认已是 UTF8,这里显式一次)
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
# 首次启动自动建表 + 造种子数据;顺序即文件名顺序(脚本本身幂等,可重复执行)
|
||||
- ./sql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||
- ./sql/schema-phase1.sql:/docker-entrypoint-initdb.d/02-schema-phase1.sql:ro
|
||||
- ./sql/partition-maintenance.sql:/docker-entrypoint-initdb.d/03-partition-maintenance.sql:ro
|
||||
- ./sql/data.sql:/docker-entrypoint-initdb.d/04-data.sql:ro
|
||||
- ./sql/data-phase1.sql:/docker-entrypoint-initdb.d/05-data-phase1.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fruitshorts} -d ${DB_NAME:-fruitshorts}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
# 不映射端口:只有同网络的容器需要访问;本机要连库时再放开 5432
|
||||
expose:
|
||||
- "5432"
|
||||
|
||||
redis:
|
||||
# 第一阶段还没有代码依赖 Redis(C2 才接缓存与限流),先把基础设施就位,
|
||||
# 免得上线时再改编排。要持久化就留 volume,纯缓存场景可去掉。
|
||||
image: redis:7-alpine
|
||||
container_name: fruitshorts-redis
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
expose:
|
||||
- "6379"
|
||||
|
||||
# ─────────── 应用 ───────────
|
||||
app-api:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
MODULE: fruitshorts-app-api
|
||||
image: fruitshorts/app-api:latest
|
||||
container_name: fruitshorts-app-api
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
SERVER_PORT: "8081"
|
||||
# 默认连编排里的 db;要连外部库就在 .env 里覆盖 DB_URL
|
||||
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
# 媒资落盘:三个服务共享同一个卷
|
||||
- ./data/uploads:/data/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8081/healthz || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 40s
|
||||
retries: 3
|
||||
|
||||
admin-api:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
MODULE: fruitshorts-admin-api
|
||||
image: fruitshorts/admin-api:latest
|
||||
container_name: fruitshorts-admin-api
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
SERVER_PORT: "8080"
|
||||
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
|
||||
ports:
|
||||
# docs/12-上线检查清单「管理端仅内网」:只绑本机,公网不直接暴露
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- ./data/uploads:/data/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 40s
|
||||
retries: 3
|
||||
|
||||
transcode-worker:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
MODULE: fruitshorts-transcode-worker
|
||||
# worker 镜像里装 ffmpeg,两个 API 镜像不装(省 80MB)
|
||||
INSTALL_FFMPEG: "true"
|
||||
image: fruitshorts/transcode-worker:latest
|
||||
container_name: fruitshorts-transcode-worker
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
|
||||
volumes:
|
||||
# 与两个入口同一个媒资根,否则找不到源文件
|
||||
- ./data/uploads:/data/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
# worker 不监听端口(server.port=0),用进程存活判断
|
||||
test: ["CMD-SHELL", "pgrep -f app.jar >/dev/null || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
start_period: 40s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
# 命名卷:跟着 compose 项目走,`docker compose down -v` 才会删
|
||||
db-data:
|
||||
redis-data:
|
||||
@@ -0,0 +1,29 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 生产 profile(--spring.profiles.active=prod):
|
||||
# 1) 集中生产开关,不在这里写任何密钥 —— 密钥一律环境变量注入;
|
||||
# 2) 密钥缺失/还是 dev 默认值/签名没开 → ProdSecretGuard 直接让启动失败(fail-fast)。
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
tomcat:
|
||||
# 生产前置反代,真实 IP 从 X-Forwarded-* 取
|
||||
remoteip:
|
||||
remote-ip-header: X-Forwarded-For
|
||||
protocol-header: X-Forwarded-Proto
|
||||
|
||||
spring:
|
||||
lifecycle:
|
||||
timeout-per-shutdown-phase: 30s
|
||||
|
||||
fruitshorts:
|
||||
media:
|
||||
# 生产必须开启播放地址签名(docs/12-上线检查清单)
|
||||
signature-required: true
|
||||
cors:
|
||||
# 后台只允许显式配置的来源(本地开发用 base 里的 localhost:3005)
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: info
|
||||
com.fruitshorts: info
|
||||
@@ -10,7 +10,8 @@ spring:
|
||||
exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts
|
||||
# 地址外置:生产用 DB_URL 注入(prod profile 会校验非空)
|
||||
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/fruitshorts}
|
||||
username: ${DB_USER:fruitshorts}
|
||||
password: ${DB_PASSWORD:fruitshorts}
|
||||
jackson:
|
||||
@@ -40,16 +41,23 @@ mybatis-plus:
|
||||
|
||||
fruitshorts:
|
||||
media:
|
||||
# 本地媒资目录(相对应用启动目录),通过 /files/** 访问
|
||||
root-dir: ./data/uploads
|
||||
# 媒资目录:容器里指向挂载卷;本地默认 ./data/uploads
|
||||
root-dir: ${MEDIA_ROOT_DIR:./data/uploads}
|
||||
public-prefix: /files
|
||||
chunk-size: 5242880
|
||||
max-file-size: 2147483648
|
||||
signature-secret: ${MEDIA_SIGNATURE_SECRET:fruitshorts-media-dev-secret-change-me-in-production-32b}
|
||||
signature-required: ${MEDIA_SIGNATURE_REQUIRED:false}
|
||||
signature-ttl-minutes: 15
|
||||
jwt:
|
||||
# 双密钥都要外置:只配 admin 而漏配 app,生产会静默用 dev 默认值(prod fail-fast 会拦住)
|
||||
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
|
||||
admin-ttl-hours: 12
|
||||
app-secret: ${JWT_APP_SECRET:fruitshorts-app-dev-secret-change-me-in-production-32bytes}
|
||||
app-access-ttl-minutes: 120
|
||||
app-refresh-ttl-days: 30
|
||||
cors:
|
||||
allowed-origins: http://localhost:3005,http://127.0.0.1:3005
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3005,http://127.0.0.1:3005}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 生产 profile(--spring.profiles.active=prod):
|
||||
# 1) 集中生产开关,不在这里写任何密钥 —— 密钥一律环境变量注入;
|
||||
# 2) 密钥缺失/还是 dev 默认值/签名没开 → ProdSecretGuard 直接让启动失败(fail-fast)。
|
||||
server:
|
||||
# 容器里由编排再映射;保持 8081 便于与 runbook 一致
|
||||
port: ${SERVER_PORT:8081}
|
||||
tomcat:
|
||||
# 生产前置反代,真实 IP 从 X-Forwarded-* 取
|
||||
remoteip:
|
||||
remote-ip-header: X-Forwarded-For
|
||||
protocol-header: X-Forwarded-Proto
|
||||
|
||||
spring:
|
||||
lifecycle:
|
||||
timeout-per-shutdown-phase: 30s
|
||||
|
||||
fruitshorts:
|
||||
media:
|
||||
# 生产必须开启播放地址签名(docs/12-上线检查清单)
|
||||
signature-required: true
|
||||
cors:
|
||||
# 生产只允许显式配置的来源;没配就等于空,浏览器侧会被拦住(比"*"安全)
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:}
|
||||
|
||||
springdoc:
|
||||
# 生产只留机器可读契约,关掉 UI(doc.html 不对外开放)
|
||||
swagger-ui:
|
||||
enabled: ${SWAGGER_ENABLED:false}
|
||||
api-docs:
|
||||
enabled: ${SWAGGER_ENABLED:false}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: info
|
||||
com.fruitshorts: info
|
||||
@@ -10,7 +10,9 @@ spring:
|
||||
exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts
|
||||
# 地址必须外置:不同环境(本机 / 测试 / 生产)连的库不一样,
|
||||
# 生产请用 DB_URL 注入(prod profile 会校验它非空)
|
||||
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/fruitshorts}
|
||||
username: ${DB_USER:fruitshorts}
|
||||
password: ${DB_PASSWORD:fruitshorts}
|
||||
jackson:
|
||||
@@ -64,15 +66,25 @@ fruitshorts:
|
||||
google-play-service-account-json: ${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON:}
|
||||
google-play-timeout-seconds: 10
|
||||
media:
|
||||
root-dir: ./data/uploads
|
||||
# 媒体根目录:容器里指向挂载卷;本地默认 ./data/uploads
|
||||
root-dir: ${MEDIA_ROOT_DIR:./data/uploads}
|
||||
public-prefix: /files
|
||||
chunk-size: 5242880
|
||||
max-file-size: 2147483648
|
||||
# 播放地址签名:生产必开(docs/12-上线检查清单),prod profile 里强制为 true
|
||||
signature-secret: ${MEDIA_SIGNATURE_SECRET:fruitshorts-media-dev-secret-change-me-in-production-32b}
|
||||
signature-required: ${MEDIA_SIGNATURE_REQUIRED:false}
|
||||
signature-ttl-minutes: 15
|
||||
jwt:
|
||||
# 双密钥:管理端与客户端物理隔离,token 不能互换(AGENTS.md 强制约束 2)
|
||||
# 注意 app-secret 以前没写在这里,一直用代码里的 dev 默认值 —— 生产必须注入 JWT_APP_SECRET
|
||||
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
|
||||
admin-ttl-hours: 12
|
||||
app-secret: ${JWT_APP_SECRET:fruitshorts-app-dev-secret-change-me-in-production-32bytes}
|
||||
app-access-ttl-minutes: 120
|
||||
app-refresh-ttl-days: 30
|
||||
cors:
|
||||
allowed-origins: "*"
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:*}
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.framework.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 生产环境密钥把关:`prod` profile 下,密钥缺失、仍等于代码里的 dev 默认值、
|
||||
* 或长度不足 32 位(以及"播放签名没开")→ **启动直接失败**。
|
||||
*
|
||||
* <p>为什么要有它:`docs/12-上线检查清单` 要求密钥一律走环境变量、播放签名生产必开,
|
||||
* 但属性类里为了本地开箱即用带了 dev 默认值 —— 一旦生产漏配,服务会**安静地**用 dev 密钥跑起来,
|
||||
* 后果是令牌/播放地址可被伪造。这里把"安静失败"改成"启动即报错"。
|
||||
*
|
||||
* <p>用 {@link Environment} 读**解析后**的属性(含 yml 里的默认值),而不是注入各模块的属性类:
|
||||
* framework 不该反向依赖 media 模块,这样三个可运行件都能复用同一份校验。
|
||||
* 只校验"配置是否可上线",不碰任何业务逻辑;非 prod profile 完全不受影响。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Profile("prod")
|
||||
@RequiredArgsConstructor
|
||||
public class ProdSecretGuard {
|
||||
|
||||
/** 代码里为本地开发准备的默认值,生产出现即视为未配置。 */
|
||||
private static final List<String> DEV_DEFAULTS = List.of(
|
||||
"fruitshorts-admin-dev-secret-change-me-in-production-32b",
|
||||
"fruitshorts-app-dev-secret-change-me-in-production-32bytes",
|
||||
"fruitshorts-media-dev-secret-change-me-in-production-32b");
|
||||
|
||||
private static final int MIN_SECRET_LENGTH = 32;
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
@PostConstruct
|
||||
public void verify() {
|
||||
List<String> problems = new ArrayList<>();
|
||||
check(problems, "fruitshorts.jwt.admin-secret(环境变量 JWT_ADMIN_SECRET)",
|
||||
environment.getProperty("fruitshorts.jwt.admin-secret"));
|
||||
check(problems, "fruitshorts.jwt.app-secret(环境变量 JWT_APP_SECRET)",
|
||||
environment.getProperty("fruitshorts.jwt.app-secret"));
|
||||
check(problems, "fruitshorts.media.signature-secret(环境变量 MEDIA_SIGNATURE_SECRET)",
|
||||
environment.getProperty("fruitshorts.media.signature-secret"));
|
||||
if (!environment.getProperty("fruitshorts.media.signature-required", Boolean.class, false)) {
|
||||
problems.add("fruitshorts.media.signature-required 必须为 true"
|
||||
+ "(docs/12-上线检查清单:生产必开播放地址签名)");
|
||||
}
|
||||
if (!StringUtils.hasText(environment.getProperty("spring.datasource.url"))) {
|
||||
problems.add("spring.datasource.url 为空(环境变量 DB_URL)");
|
||||
}
|
||||
if (environment.getProperty("fruitshorts.cors.allowed-origins", "").isBlank()) {
|
||||
problems.add("fruitshorts.cors.allowed-origins 为空(环境变量 CORS_ALLOWED_ORIGINS)"
|
||||
+ "—— 生产不允许用通配 *,必须显式列出来源");
|
||||
}
|
||||
if (!problems.isEmpty()) {
|
||||
String message = "生产配置校验未通过,拒绝启动(prod profile):\n - "
|
||||
+ String.join("\n - ", problems)
|
||||
+ "\n请按 FruitServer/DEPLOY.md 的环境变量清单补齐后重启。";
|
||||
log.error(message);
|
||||
throw new IllegalStateException(message);
|
||||
}
|
||||
log.info("生产配置校验通过:JWT 双密钥 + 媒体签名密钥均已注入,播放签名已强制开启");
|
||||
}
|
||||
|
||||
private void check(List<String> problems, String name, String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
problems.add(name + " 未配置(空)");
|
||||
return;
|
||||
}
|
||||
if (DEV_DEFAULTS.contains(value.trim())) {
|
||||
problems.add(name + " 仍是代码里的 dev 默认值,必须换成生产密钥");
|
||||
return;
|
||||
}
|
||||
if (value.trim().length() < MIN_SECRET_LENGTH) {
|
||||
problems.add(name + " 长度不足 " + MIN_SECRET_LENGTH + " 位(当前 "
|
||||
+ value.trim().length() + " 位)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -48,6 +48,8 @@ public class SecurityConfig {
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
"/v3/api-docs/**",
|
||||
/* 健康检查:给容器编排与冒烟脚本用(本项目没引 actuator,见 HealthController 注释) */
|
||||
"/healthz",
|
||||
"/actuator/health"
|
||||
};
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.framework.web;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 轻量健康检查端点:`GET /healthz`。
|
||||
*
|
||||
* <p>为什么不直接用 `/actuator/health`:本项目**没有引入 actuator 依赖**(jar 里 0 个 actuator 类),
|
||||
* 那个路径一直是"未映射"状态 —— 早期观察到的"超时"是另一回事(旧 jar 未重启,见 `docs/plans/INBOX.md` S1 条目)。
|
||||
* 部署需要的只是一个"进程活着、能响应 HTTP"的信号,这个端点足够,且不引新依赖。
|
||||
*
|
||||
* <p>要换成 actuator(带 DB/Redis 健康指示器)时:加依赖 + 把 actuator 路径加进
|
||||
* {@code SecurityConfig.PUBLIC_PATHS}(那里已经预留了 `/actuator/health`)。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class HealthController {
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
@Value("${spring.application.name:unknown}")
|
||||
private String applicationName;
|
||||
|
||||
/** 返回统一响应体,便于与其它接口共用同一套客户端解包逻辑。 */
|
||||
@GetMapping("/healthz")
|
||||
public Result<Map<String, Object>> health() {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("status", "UP");
|
||||
data.put("app", applicationName);
|
||||
data.put("profiles", String.join(",", environment.getActiveProfiles()));
|
||||
data.put("time", LocalDateTime.now().toString());
|
||||
return Result.ok(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 生产 profile:worker 只消费队列,不对外提供接口。
|
||||
# 密钥校验(JWT / 媒体签名)由 ProdSecretGuard 统一 fail-fast —— worker 也要求密钥齐全,
|
||||
# 这样"生产漏配密钥"不会表现为"worker 能起但入口起不来"这种半吊子状态。
|
||||
server:
|
||||
port: 0
|
||||
|
||||
spring:
|
||||
lifecycle:
|
||||
timeout-per-shutdown-phase: 30s
|
||||
|
||||
fruitshorts:
|
||||
media:
|
||||
transcode:
|
||||
# 生产此进程必须真的跑转码
|
||||
worker-enabled: true
|
||||
# 生产按小时级重试间隔,避免坏片源把队列打满
|
||||
poll-delay-ms: ${TRANSCODE_POLL_DELAY_MS:5000}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: info
|
||||
com.fruitshorts: info
|
||||
@@ -12,7 +12,8 @@ spring:
|
||||
exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts
|
||||
# 与两个入口同一个库(worker 消费 transcode_task)
|
||||
url: ${DB_URL:jdbc:postgresql://127.0.0.1:5432/fruitshorts}
|
||||
username: ${DB_USER:fruitshorts}
|
||||
password: ${DB_PASSWORD:fruitshorts}
|
||||
|
||||
@@ -35,7 +36,7 @@ mybatis-plus:
|
||||
fruitshorts:
|
||||
media:
|
||||
# 必须与 app-api/admin-api 指向同一个存储根,worker 才找得到上传后的源文件
|
||||
root-dir: ./data/uploads
|
||||
root-dir: ${MEDIA_ROOT_DIR:./data/uploads}
|
||||
public-prefix: /files
|
||||
transcode:
|
||||
# 只有本进程打开转码(§8.5 隔离要求)
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# 发布后冒烟:确认两个入口"活着 + 文档在线 + 未授权路径按统一信封拒绝"。
|
||||
# 任一检查失败 → 非 0 退出(可直接用在部署流水线里)。
|
||||
#
|
||||
# 用法:
|
||||
# tools/smoke.sh # 默认 127.0.0.1:8081 / 127.0.0.1:8080
|
||||
# APP_BASE=http://10.0.0.5:8081 ADMIN_BASE=http://10.0.0.5:8080 tools/smoke.sh
|
||||
|
||||
set -u -o pipefail
|
||||
|
||||
APP_BASE="${APP_BASE:-http://127.0.0.1:8081}"
|
||||
ADMIN_BASE="${ADMIN_BASE:-http://127.0.0.1:8080}"
|
||||
TIMEOUT="${SMOKE_TIMEOUT:-8}"
|
||||
FAILED=0
|
||||
|
||||
pass() { printf ' \033[32mPASS\033[0m %s\n' "$1"; }
|
||||
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAILED=1; }
|
||||
|
||||
# http_code <url> [跟随重定向]:只取状态码,连不上返回 000
|
||||
http_code() {
|
||||
local follow="${2:-}"
|
||||
if [ "$follow" = "follow" ]; then
|
||||
curl -sL -o /dev/null -m "$TIMEOUT" -w '%{http_code}' "$1" 2>/dev/null || echo 000
|
||||
else
|
||||
curl -s -o /dev/null -m "$TIMEOUT" -w '%{http_code}' "$1" 2>/dev/null || echo 000
|
||||
fi
|
||||
}
|
||||
|
||||
# body <url>:取响应体(用于断言统一信封)
|
||||
body() {
|
||||
curl -s -m "$TIMEOUT" "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# expect_code <期望> <url> <说明> [follow]
|
||||
expect_code() {
|
||||
local want="$1" url="$2" label="$3" follow="${4:-}" got
|
||||
got="$(http_code "$url" "$follow")"
|
||||
if [ "$got" = "$want" ]; then
|
||||
pass "${label}(${got})"
|
||||
else
|
||||
fail "${label}:期望 ${want},实际 ${got} —— ${url}"
|
||||
fi
|
||||
}
|
||||
|
||||
# expect_body <url> <必须包含的片段> <说明>
|
||||
# 只看 HTTP 200 是不够的:本项目未授权时也是 HTTP 200 + 信封 code=401,
|
||||
# 所以健康检查/文档必须断言响应体的关键字段。
|
||||
expect_body() {
|
||||
local url="$1" needle="$2" label="$3" out
|
||||
out="$(body "$url")"
|
||||
if printf '%s' "$out" | grep -q "$needle"; then
|
||||
pass "${label}"
|
||||
else
|
||||
fail "${label}:期望包含 ${needle},实际:$(printf '%s' "$out" | head -c 120)"
|
||||
fi
|
||||
}
|
||||
|
||||
# expect_envelope <url> <说明>:未授权路径必须返回统一响应体(code 字段),而不是挂住/空响应
|
||||
expect_envelope() {
|
||||
local url="$1" label="$2" out
|
||||
out="$(body "$url")"
|
||||
if printf '%s' "$out" | grep -q '"code"'; then
|
||||
pass "${label}($(printf '%s' "$out" | head -c 80))"
|
||||
else
|
||||
fail "$label:未返回统一响应体 —— $url,实际:$(printf '%s' "$out" | head -c 120)"
|
||||
fi
|
||||
}
|
||||
|
||||
printf '冒烟开始:APP=%s ADMIN=%s\n' "$APP_BASE" "$ADMIN_BASE"
|
||||
|
||||
printf '\n[1/5] 健康检查 /healthz\n'
|
||||
expect_code 200 "$APP_BASE/healthz" "app-api /healthz 可访问"
|
||||
expect_body "$APP_BASE/healthz" '"status":"UP"' "app-api /healthz 返回 status=UP"
|
||||
expect_code 200 "$ADMIN_BASE/healthz" "admin-api /healthz 可访问"
|
||||
expect_body "$ADMIN_BASE/healthz" '"status":"UP"' "admin-api /healthz 返回 status=UP"
|
||||
|
||||
printf '\n[2/5] 客户端目录接口\n'
|
||||
expect_code 200 "$APP_BASE/app/categories" "GET /app/categories"
|
||||
expect_code 200 "$APP_BASE/app/home/feed" "GET /app/home/feed"
|
||||
expect_body "$APP_BASE/app/categories" '"code":0' "GET /app/categories 返回 code=0"
|
||||
|
||||
printf '\n[3/5] 接口文档在线\n'
|
||||
expect_code 200 "$APP_BASE/v3/api-docs" "app-api /v3/api-docs"
|
||||
expect_code 200 "$ADMIN_BASE/v3/api-docs" "admin-api /v3/api-docs"
|
||||
expect_body "$APP_BASE/v3/api-docs" '"openapi"' "app-api /v3/api-docs 是 OpenAPI 文档"
|
||||
# /doc.html 是 springdoc 的 302 跳转,跟随重定向后应落到 Swagger UI(200)
|
||||
expect_code 200 "$APP_BASE/doc.html" "app-api /doc.html(跟随 302)" follow
|
||||
|
||||
printf '\n[4/5] 未授权路径按统一信封拒绝(不挂住)\n'
|
||||
expect_envelope "$APP_BASE/" "app-api /"
|
||||
expect_envelope "$ADMIN_BASE/admin/auth/profile" "admin-api 未带令牌访问受保护接口"
|
||||
|
||||
printf '\n[5/5] 响应体完整性(/app/categories 必须是 {code,data})\n'
|
||||
expect_body "$APP_BASE/app/categories" '"data"' "GET /app/categories 带 data 字段"
|
||||
# 用错误的 HTTP 方法访问 POST-only 接口:只要是"统一信封"就算合格(当前实现返回 code=500 的系统异常信封)
|
||||
expect_envelope "$ADMIN_BASE/admin/auth/login" "admin-api 用 GET 访问 POST-only 接口仍返回统一信封"
|
||||
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
printf '\n\033[32m冒烟通过\033[0m:全部检查通过\n'
|
||||
exit 0
|
||||
fi
|
||||
printf '\n\033[31m冒烟失败\033[0m:存在未通过项,请先看上面的 FAIL 行\n'
|
||||
exit 1
|
||||
Reference in new issue
Block a user