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:
jianghaifeng committed 2026-10-16 13:01:08 +08:00
1 parent 9f76302e53
commit b567365460
16 files changed
+814 -9

No files matched your search

@@ -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() + " 位)");
}
}
}
@@ -48,6 +48,8 @@ public class SecurityConfig {
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
/* 健康检查:给容器编排与冒烟脚本用(本项目没引 actuator,见 HealthController 注释) */
"/healthz",
"/actuator/health"
};
@@ -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);
}
}