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
+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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user