diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f0dfe45 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Author: fruit.com.cn +# +# 构建上下文里不需要的东西:产物、IDE、本地上传的媒资、日志。 +# 注意别把 pom.xml / src / sql 排掉,镜像构建要用。 +**/target/ +.git/ +.gitignore +.idea/ +*.iml +*.log +data/ +docs/ +node_modules/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff469b7 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 77cf8a9..680bced 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ logs/ # 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录 data/uploads/* !data/uploads/.gitkeep + +# 部署用的真实环境变量(含密钥),只提交 .env.example +.env diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..b410eed --- /dev/null +++ b/DEPLOY.md @@ -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 # 本机一键冒烟 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..78e55d8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0e8fe30 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/fruitshorts-admin-api/src/main/resources/application-prod.yml b/fruitshorts-admin-api/src/main/resources/application-prod.yml new file mode 100644 index 0000000..2c66a81 --- /dev/null +++ b/fruitshorts-admin-api/src/main/resources/application-prod.yml @@ -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 diff --git a/fruitshorts-admin-api/src/main/resources/application.yml b/fruitshorts-admin-api/src/main/resources/application.yml index e72bd76..3202aef 100644 --- a/fruitshorts-admin-api/src/main/resources/application.yml +++ b/fruitshorts-admin-api/src/main/resources/application.yml @@ -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: diff --git a/fruitshorts-app-api/src/main/resources/application-prod.yml b/fruitshorts-app-api/src/main/resources/application-prod.yml new file mode 100644 index 0000000..39f53d0 --- /dev/null +++ b/fruitshorts-app-api/src/main/resources/application-prod.yml @@ -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 diff --git a/fruitshorts-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml index 354e44b..c2d1f4c 100644 --- a/fruitshorts-app-api/src/main/resources/application.yml +++ b/fruitshorts-app-api/src/main/resources/application.yml @@ -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: diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/ProdSecretGuard.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/ProdSecretGuard.java new file mode 100644 index 0000000..0d127c9 --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/ProdSecretGuard.java @@ -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 位(以及"播放签名没开")→ **启动直接失败**。 + * + *
为什么要有它:`docs/12-上线检查清单` 要求密钥一律走环境变量、播放签名生产必开, + * 但属性类里为了本地开箱即用带了 dev 默认值 —— 一旦生产漏配,服务会**安静地**用 dev 密钥跑起来, + * 后果是令牌/播放地址可被伪造。这里把"安静失败"改成"启动即报错"。 + * + *
用 {@link Environment} 读**解析后**的属性(含 yml 里的默认值),而不是注入各模块的属性类:
+ * framework 不该反向依赖 media 模块,这样三个可运行件都能复用同一份校验。
+ * 只校验"配置是否可上线",不碰任何业务逻辑;非 prod profile 完全不受影响。
+ */
+@Slf4j
+@Component
+@Profile("prod")
+@RequiredArgsConstructor
+public class ProdSecretGuard {
+
+ /** 代码里为本地开发准备的默认值,生产出现即视为未配置。 */
+ private static final List 为什么不直接用 `/actuator/health`:本项目**没有引入 actuator 依赖**(jar 里 0 个 actuator 类),
+ * 那个路径一直是"未映射"状态 —— 早期观察到的"超时"是另一回事(旧 jar 未重启,见 `docs/plans/INBOX.md` S1 条目)。
+ * 部署需要的只是一个"进程活着、能响应 HTTP"的信号,这个端点足够,且不引新依赖。
+ *
+ * 要换成 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