chore(ci): CI/CD 三阶段 6 job + test/prod 环境 + 启动前的配置自检
.gitlab-ci.yml:契约与实体对账 / 后端编译 / 管理端类型检查与构建 / 学生端 H5 构建 /
接口断言(全套 E2E 打真库)/ 生产环境自检;密钥每次随机生成,顺带证明应用没有偷偷
依赖 dev 默认值。
ProdEnvCheck 是 EnvironmentPostProcessor,跑在任何 bean 之前,拦 6 类「配错了也能启动」;
判「没配」与「配了」要 try { getProperty } catch(F-86)——
getProperty 会解析占位符、失败就抛,只读 PropertySource 又读不到 env 的值。
删掉 Gi 模板残留的 GitHub Pages 工作流(远端是 GitLab、用 pnpm,且它发布的是没有 API 的静态页)。
验证:scripts/e2e-prodguard.py 9/9(三种误配形态都拒绝启动)。
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# GitLab CI(M0-04 / M7-10)
|
||||
#
|
||||
# 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。
|
||||
# 这个仓库没有单元测试(`src/test` 是空的),验证靠三样东西:
|
||||
# ① 三个机械校验脚本(契约对账 / 实体列 / 文档一致性)
|
||||
# ② 编译与构建(后端 jar、PC、H5)
|
||||
# ③ **`scripts/e2e-*.py` 打真实接口 + 查库断言**(1500+ 条)
|
||||
# 所以 e2e 阶段不是"锦上添花",它是这个项目的主验证手段 ——
|
||||
# 一个只跑编译的流水线在这里等于没跑。
|
||||
#
|
||||
# ⚠️ 密钥策略:e2e 阶段**每次随机生成** JWT/AES/HMAC/RSA 密钥,
|
||||
# 不存任何 CI 变量。理由是它能顺带证明一件事:
|
||||
# **应用没有偷偷依赖某个 dev 默认值** —— 如果它依赖了,这里就会红。
|
||||
# (PG/Redis 的密码是流水线内一次性容器的,随机即可。)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
stages:
|
||||
- check # 不需要数据库:契约对账、文档一致性、类型检查
|
||||
- build # 编译与打包
|
||||
- e2e # 起真库、起服务、跑全套接口断言
|
||||
|
||||
variables:
|
||||
# 依赖缓存放仓库内(GitLab Runner 的默认缓存目录不一定可写)
|
||||
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
|
||||
XT_PG_IMAGE: "postgres:16-alpine"
|
||||
XT_REDIS_IMAGE: "redis:7-alpine"
|
||||
# E2E 的库与缓存
|
||||
PG_HOST: "postgres"
|
||||
PG_PORT: "5432"
|
||||
PG_DB: "filldb"
|
||||
PG_USER: "fill"
|
||||
REDIS_HOST: "redis"
|
||||
REDIS_PORT: "6379"
|
||||
|
||||
# ── 缓存:Maven 仓库与 pnpm store ─────────────────────────
|
||||
.maven-cache: &maven-cache
|
||||
key: "maven-$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .m2/repository/
|
||||
|
||||
.pnpm-cache: &pnpm-cache
|
||||
key: "pnpm-$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .pnpm-store/
|
||||
|
||||
# ══════════════ 阶段 1:机械校验 ══════════════
|
||||
|
||||
契约与实体对账:
|
||||
stage: check
|
||||
image: python:3.12-slim
|
||||
script:
|
||||
- echo "── 契约 §4 的 121 个接口 vs 代码里注册的路由 ──"
|
||||
- python scripts/check-api-parity.py --quiet
|
||||
- echo "── 实体字段 vs 迁移 DDL ──"
|
||||
- python scripts/check-entity-columns.py --quiet
|
||||
- echo "── 文档自身的一致性(表格列数 / FR 编号) ──"
|
||||
- python scripts/verify-docs.py
|
||||
- echo "── 手册里引用的错误码与文案是否真的存在(客服照着它回答) ──"
|
||||
- python scripts/check-manual.py
|
||||
- echo "── 全部 e2e 脚本的语法(引号写错会当场红,而不是跑到一半才红) ──"
|
||||
- |
|
||||
python - <<'PY'
|
||||
import io, os, sys
|
||||
bad = 0
|
||||
for fn in sorted(os.listdir('scripts')):
|
||||
if fn.startswith('e2e-') and fn.endswith('.py'):
|
||||
try:
|
||||
compile(io.open('scripts/' + fn, encoding='utf-8').read(), fn, 'exec')
|
||||
except SyntaxError as e:
|
||||
bad += 1
|
||||
print(' [语法错] %s:%s %s' % (fn, e.lineno, (e.text or '').strip()[:80]))
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
后端编译:
|
||||
stage: build
|
||||
image: maven:3.9-eclipse-temurin-21
|
||||
cache: *maven-cache
|
||||
script:
|
||||
- cd backend
|
||||
- mvn -B -DskipTests clean package
|
||||
# 产物留一份,后面 e2e 阶段直接用(不用再编译一次)
|
||||
- cp xt-server/target/xt-server-*.jar ../xt-server.jar
|
||||
artifacts:
|
||||
paths:
|
||||
- xt-server.jar
|
||||
expire_in: 1 day
|
||||
|
||||
管理端类型检查与构建:
|
||||
stage: build
|
||||
image: node:20
|
||||
cache: *pnpm-cache
|
||||
before_script:
|
||||
- corepack enable
|
||||
- pnpm config set store-dir .pnpm-store
|
||||
script:
|
||||
- cd frontend-pc
|
||||
- pnpm install --frozen-lockfile
|
||||
- pnpm typecheck
|
||||
# lint 只卡**本次改动涉及的文件**:模板自带的文件里有一批既有告警,
|
||||
# 全量 lint 会让流水线永远是红的,从而失去意义(红久了就没人看了)。
|
||||
#
|
||||
# ⚠️ 这段只能在 Linux 上跑 —— 本机 Windows 的 cmd.exe 解析不了单引号 pathspec,
|
||||
# 所以它是照 Linux 语义写的、本地验不到。首次上 runner 时留意,
|
||||
# 见 docs/09-部署与运维.md §6「首次接入 CI 的检查单」。
|
||||
- |
|
||||
BASE="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-HEAD~1}"
|
||||
FILES=$(git diff --name-only --diff-filter=ACMR "$BASE" HEAD -- frontend-pc/ 2>/dev/null \
|
||||
| grep -E '\.(vue|ts)$' | sed 's|^frontend-pc/||' || true)
|
||||
if [ -n "$FILES" ]; then
|
||||
echo "只检查本次改动的文件:"; echo "$FILES"
|
||||
npx eslint $FILES
|
||||
else
|
||||
echo "本次没有改前端文件,跳过 lint"
|
||||
fi
|
||||
- pnpm build
|
||||
|
||||
学生端 H5 构建:
|
||||
stage: build
|
||||
image: node:20
|
||||
cache: *pnpm-cache
|
||||
before_script:
|
||||
- corepack enable
|
||||
- pnpm config set store-dir .pnpm-store
|
||||
script:
|
||||
- cd frontend-mp
|
||||
- pnpm install --frozen-lockfile
|
||||
# ⚠️ H5 是**后端托管**的(没有独立 dev server),所以它的构建产物是
|
||||
# e2e 阶段起服务时的输入,不能跳过
|
||||
- pnpm build:h5
|
||||
artifacts:
|
||||
paths:
|
||||
- frontend-mp/dist/build/h5/
|
||||
expire_in: 1 day
|
||||
|
||||
# ══════════════ 阶段 3:全套接口断言 ══════════════
|
||||
|
||||
.e2e-base: &e2e-base
|
||||
stage: e2e
|
||||
image: eclipse-temurin:21-jre
|
||||
services:
|
||||
- name: $XT_PG_IMAGE
|
||||
alias: postgres
|
||||
- name: $XT_REDIS_IMAGE
|
||||
alias: redis
|
||||
variables:
|
||||
# 这两个容器只在本次流水线里活着,密码随机即可
|
||||
POSTGRES_DB: "filldb"
|
||||
POSTGRES_USER: "fill"
|
||||
POSTGRES_PASSWORD: "ci_pg_pwd"
|
||||
PG_PASSWORD: "ci_pg_pwd"
|
||||
REDIS_PASSWORD: ""
|
||||
before_script:
|
||||
# e2e 脚本要 curl 发 multipart、要 python3 跑断言、要 psql 查库
|
||||
- apt-get update -qq && apt-get install -y -qq python3 python3-pip curl postgresql-client
|
||||
- pip3 install --quiet --break-system-packages openpyxl requests
|
||||
# ⚠️ 每次随机的密钥:见文件头"密钥策略"
|
||||
- export XT_JWT_SECRET=$(openssl rand -base64 64 | tr -d '\n')
|
||||
- export XT_AES_KEY=$(openssl rand -base64 32 | tr -d '\n')
|
||||
- export XT_HMAC_KEY=$(openssl rand -base64 48 | tr -d '\n')
|
||||
- export XT_RSA_PRIVATE_KEY=$(openssl rand -base64 512 | tr -d '\n')
|
||||
- export REDIS_PASSWORD=""
|
||||
script:
|
||||
- echo "── 先单独验一次 test profile 能不能起来(失败了就是配置问题,不是脚本问题) ──"
|
||||
- python3 scripts/check-test-profile.py
|
||||
- echo "── 起后端(test profile) ──"
|
||||
- |
|
||||
java -jar xt-server.jar --spring.profiles.active=test \
|
||||
--server.port=8080 > /tmp/xt-server.log 2>&1 &
|
||||
for i in $(seq 1 90); do
|
||||
if curl -sf http://localhost:8080/api/v1/auth/captcha > /dev/null; then
|
||||
echo "后端已就绪(第 ${i} 次探测)"; break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
curl -sf http://localhost:8080/api/v1/auth/captcha > /dev/null \
|
||||
|| { echo "后端没起来,日志尾部:"; tail -80 /tmp/xt-server.log; exit 1; }
|
||||
- echo "── 依次跑全部 e2e 脚本 ──"
|
||||
- |
|
||||
fail=0
|
||||
total=0
|
||||
for f in $(ls scripts/e2e-*.py | sort); do
|
||||
out=$(python3 "$f" 2>&1)
|
||||
line=$(echo "$out" | grep -E '^通过 [0-9]+ 失败 [0-9]+ 共 [0-9]+' | tail -1)
|
||||
if [ -z "$line" ]; then
|
||||
echo " [无汇总] $f"; echo "$out" | tail -20; fail=1; continue
|
||||
fi
|
||||
n=$(echo "$line" | sed -E 's/^通过 ([0-9]+).*/\1/')
|
||||
bad=$(echo "$line" | sed -E 's/.*失败 ([0-9]+).*/\1/')
|
||||
total=$((total + n))
|
||||
echo " $f:$n 条,失败 $bad"
|
||||
if [ "$bad" != "0" ]; then fail=1; echo "$out" | tail -30; fi
|
||||
done
|
||||
echo "断言合计:$total"
|
||||
[ "$fail" = "0" ] || exit 1
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- /tmp/xt-server.log
|
||||
|
||||
接口断言(全套 E2E):
|
||||
extends: .e2e-base
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
# ⚠️ 生产自检单独一个 job:**它验的是"配错时拒绝启动"**,
|
||||
# 而这恰好是最容易被后来的改动悄悄破坏、且没人会发现的一件事
|
||||
# (自检不生效时,一切"看起来都正常")
|
||||
生产环境自检:
|
||||
stage: e2e
|
||||
image: eclipse-temurin:21-jre
|
||||
script:
|
||||
- apt-get update -qq && apt-get install -y -qq python3
|
||||
- python3 scripts/e2e-prodguard.py
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
@@ -0,0 +1,239 @@
|
||||
package cn.tianbaotong.fill.config;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.env.EnvironmentPostProcessor;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.EnumerablePropertySource;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 生产环境的**启动前自检**(M7-10 生产环境准备)。
|
||||
*
|
||||
* <h3>为什么必须是 {@code EnvironmentPostProcessor},而不是 ApplicationRunner</h3>
|
||||
*
|
||||
* <p>我先写成了 {@code ApplicationRunner}(跑在容器就绪之后),实测发现**它根本轮不到**:
|
||||
* 缺 {@code XT_JWT_SECRET} 时,{@code JwtService} 在**构造 bean** 时就炸了,
|
||||
* 用户看到的是一段 56 行的 Spring 栈,而我最该被看见的那句清单连影子都没有。
|
||||
*
|
||||
* <p>所以挪到 {@code EnvironmentPostProcessor}:它跑在**任何 bean 被创建之前**,
|
||||
* 只依赖 {@code Environment}(配置已经在里面了,只是还没绑定成对象)。
|
||||
* 代价是不能用 {@code FillProperties} 的访问器,得按 key 读 —— 但那点啰嗦换来的是
|
||||
* "报错一定是人话"。
|
||||
*
|
||||
* <h3>检查的都是"配错了也能启动、而且运行时完全不报错"的配置</h3>
|
||||
*
|
||||
* <table border="1">
|
||||
* <tr><th>配置</th><th>不检查会怎样</th></tr>
|
||||
* <tr><td>{@code stub-enabled=true}</td>
|
||||
* <td><b>任何人可以登录任何学生</b>(它本来就是「跳过微信授权」的开关)</td></tr>
|
||||
* <tr><td>{@code storage.provider=local}</td>
|
||||
* <td>多副本部署下<b>必然丢文件</b>(学生这次传到 A 实例、下次落到 B)。
|
||||
* 这不是"性能差一点",是正确性错误,而且丢的时候没有任何报错</td></tr>
|
||||
* <tr><td>JWT 密钥还是 dev 那把</td>
|
||||
* <td>任何人都能拿仓库里那把密钥<b>伪造任意用户的 token</b>(含平台运营)</td></tr>
|
||||
* <tr><td>AES 密钥还是 dev 那把</td>
|
||||
* <td>证件号是 AES 加密存的 —— 密钥泄露等于<b>全校身份证可解</b></td></tr>
|
||||
* <tr><td>Swagger 还开着</td>
|
||||
* <td>把全部接口与字段暴露给公网</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <h3>为什么是"拒绝启动"而不是"打个 warn"</h3>
|
||||
*
|
||||
* <p>warn 会被淹掉(生产日志一天几万行),而这几条里**任何一条为真都意味着系统不该上线**。
|
||||
* 宁可让它起不来 —— "起不来"是当场就能发现的事故,"带着后门跑了两周"不是。
|
||||
*/
|
||||
public class ProdEnvCheck implements EnvironmentPostProcessor, Ordered {
|
||||
|
||||
/** dev 用的 JWT 密钥前缀(用来识别"照抄了 dev 配置")。 */
|
||||
private static final String DEV_JWT_PREFIX = "BC0LUCKD3T4FfgMioHyARkl";
|
||||
|
||||
/** dev 用的 AES 密钥前缀,同理。 */
|
||||
private static final String DEV_AES_PREFIX = "CaJuBbvbSKkLGn+d3WfWcmbk";
|
||||
|
||||
/** 生产必需的键(缺失就报出来,一个都不许少)。 */
|
||||
private static final String[][] REQUIRED = {
|
||||
{"XT_JWT_SECRET", "fill.jwt.secret", "HS512 签名密钥,≥ 64 字节随机值"},
|
||||
{"XT_AES_KEY", "fill.crypto.aes-key", "证件号 AES 加密密钥(base64,32 字节)"},
|
||||
{"XT_HMAC_KEY", "fill.crypto.hmac-key", "开放接口 HMAC 签名密钥"},
|
||||
{"XT_RSA_PRIVATE_KEY", "fill.rsa.private-key", "登录密码解密用的 PKCS#8 私钥"},
|
||||
{"PG_PASSWORD", "spring.datasource.password", "数据库密码"},
|
||||
{"REDIS_PASSWORD", "spring.data.redis.password", "Redis 密码"},
|
||||
};
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
// 尽量靠前:别的 EnvironmentPostProcessor 不该先跑一半
|
||||
return Ordered.HIGHEST_PRECEDENCE + 10;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) {
|
||||
if (!isProd(env)) {
|
||||
return;
|
||||
}
|
||||
List<String> problems = new ArrayList<>();
|
||||
|
||||
// ① 必需项:缺一个都别启动
|
||||
for (String[] r : REQUIRED) {
|
||||
String v = safe(env, r[1]);
|
||||
if (isBlank(v) || looksLikeUnresolvedPlaceholder(v)) {
|
||||
problems.add(r[0] + "(" + r[1] + ")未设置或为空 —— " + r[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// ② JWT 密钥:不能是 dev 那把,也不能太短
|
||||
//
|
||||
// ⚠️ 先排除"未解析的占位符":`${XT_JWT_SECRET}` 正好 16 个字符,
|
||||
// 不排除的话会对同一个问题报两条,而其中一条("太短")是**错的** ——
|
||||
// 那会让看日志的人开始怀疑这份清单本身(而它正是用来建立信任的)。
|
||||
String jwt = safe(env, "fill.jwt.secret");
|
||||
if (!isBlank(jwt) && !looksLikeUnresolvedPlaceholder(jwt)) {
|
||||
if (jwt.startsWith(DEV_JWT_PREFIX)) {
|
||||
problems.add("fill.jwt.secret 用的还是**开发环境那把**(仓库里能查到)—— "
|
||||
+ "任何人都能拿它伪造任意用户的 token,包括平台运营");
|
||||
} else if (jwt.length() < 32) {
|
||||
problems.add("fill.jwt.secret 太短(" + jwt.length() + " 字符)—— "
|
||||
+ "HS512 至少 32,建议 64 以上");
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 加密密钥:证件号加密与开放接口签名都用它
|
||||
String aes = safe(env, "fill.crypto.aes-key");
|
||||
if (!isBlank(aes) && !looksLikeUnresolvedPlaceholder(aes)) {
|
||||
if (aes.startsWith(DEV_AES_PREFIX)) {
|
||||
problems.add("fill.crypto.aes-key 用的还是**开发环境那把** —— "
|
||||
+ "它用于证件号加密,泄露等于全校身份证可解");
|
||||
}
|
||||
// ⚠️ 长度也要查:AES-256 要求 32 字节,而不对的长度会在
|
||||
// `AesGcmCipher` 构造时抛 IllegalArgumentException ——
|
||||
// 错误信息本身很清楚,但它出现在**一个 85 层的栈中间**。
|
||||
// 这里提前说,运维看到的就是人话。
|
||||
byte[] aesBytes = tryBase64(aes);
|
||||
if (aesBytes != null && aesBytes.length != 32) {
|
||||
problems.add("fill.crypto.aes-key 解出来是 " + aesBytes.length
|
||||
+ " 字节,AES-256 需要 32 字节 —— 用 `openssl rand -base64 32` 生成");
|
||||
}
|
||||
}
|
||||
|
||||
// ④ stub 登录:开了它就是"任何人可登录任何学生"
|
||||
if (isTrue(safe(env, "fill.mp-h5.stub-enabled"))) {
|
||||
problems.add("fill.mp-h5.stub-enabled=true —— 这是「跳过微信授权直接登录」的后门,"
|
||||
+ "开了它任何人可以登录任何学生。生产必须 false");
|
||||
}
|
||||
|
||||
// ⑤ 存储:local 在多副本下必然丢文件
|
||||
String provider = safe(env, "fill.storage.provider");
|
||||
if (isBlank(provider) || "local".equalsIgnoreCase(provider)) {
|
||||
problems.add("fill.storage.provider=" + provider + " —— 本地磁盘存储在多副本部署下"
|
||||
+ "**必然丢文件**(学生这次传到 A 实例、下次请求落到 B)。"
|
||||
+ "生产必须 provider=qiniu 并提供 AK/SK");
|
||||
} else if ("qiniu".equalsIgnoreCase(provider)) {
|
||||
if (isBlank(safe(env, "fill.storage.qiniu.access-key"))
|
||||
|| isBlank(safe(env, "fill.storage.qiniu.secret-key"))) {
|
||||
problems.add("fill.storage.provider=qiniu 但没有提供 QINIU_ACCESS_KEY / "
|
||||
+ "QINIU_SECRET_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ Swagger:生产应当关掉
|
||||
if (isTrue(safe(env, "springdoc.api-docs.enabled"))) {
|
||||
problems.add("springdoc.api-docs.enabled 仍然开着 —— 生产会把全部接口与字段"
|
||||
+ "暴露给公网。要么设 false,要么只在运维网段可达");
|
||||
}
|
||||
|
||||
if (!problems.isEmpty()) {
|
||||
throw new IllegalStateException(buildMessage(problems));
|
||||
}
|
||||
}
|
||||
|
||||
/** 拼那句最该被人看见的话。 */
|
||||
static String buildMessage(List<String> problems) {
|
||||
return "\n"
|
||||
+ "══════════════════════════════════════════════════════════════\n"
|
||||
+ " 生产环境启动自检未通过(" + problems.size() + " 项)\n"
|
||||
+ "══════════════════════════════════════════════════════════════\n - "
|
||||
+ String.join("\n - ", problems)
|
||||
+ "\n\n"
|
||||
+ "这些配置**配错了也能启动、而且运行时完全不报错** ——\n"
|
||||
+ "所以这里直接拒绝启动。请核对环境变量后重启。\n"
|
||||
+ "必需的变量清单见 application-prod.yml 的头部注释,\n"
|
||||
+ "密钥的生成与轮换见 docs/09-部署与运维.md。\n"
|
||||
+ "══════════════════════════════════════════════════════════════";
|
||||
}
|
||||
|
||||
private static boolean isProd(ConfigurableEnvironment env) {
|
||||
for (String p : env.getActiveProfiles()) {
|
||||
if ("prod".equalsIgnoreCase(p)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
/** `${XT_JWT_SECRET}` 这种"占位符没被解析"的值,等于没配。 */
|
||||
private static boolean looksLikeUnresolvedPlaceholder(String s) {
|
||||
return s.startsWith("${") && s.endsWith("}");
|
||||
}
|
||||
|
||||
/**
|
||||
* **安全地**读一个配置值(这是唯一正确的读法,前两版都错过)。
|
||||
*
|
||||
* <p>为什么要包一层 try/catch:
|
||||
* <ul>
|
||||
* <li>{@code env.getProperty(key)} 会**解析占位符**,而 prod 配置里写的是
|
||||
* {@code secret: ${XT_JWT_SECRET}} —— 环境变量没设时它抛
|
||||
* {@code PlaceholderResolutionException}。于是**"检查缺了什么"的自检
|
||||
* 自己先崩在"缺了什么"上**,精心写的清单一个字都打不出来;</li>
|
||||
* <li>只读 {@code PropertySource.getProperty(key)}(完全不解析)也不行:
|
||||
* 读到的永远是字面量 {@code ${XT_JWT_SECRET}},于是**env 明明设了值
|
||||
* 也报"未设置"**。这个错更隐蔽 —— 场景"真没设"看起来完全正确,
|
||||
* 只有"设了值"的场景才暴露。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>所以正确做法是**安全解析**:解析成功就用解析后的值,解析失败当没配。
|
||||
*
|
||||
* <p>一般化的教训:**做配置检查的代码必须能区分"没配"与"配了"** ——
|
||||
* 而且它必须比被检查的配置更皮实,否则会在配置最坏的时候自己先炸。
|
||||
*/
|
||||
static String safe(ConfigurableEnvironment env, String key) {
|
||||
try {
|
||||
return env.getProperty(key);
|
||||
} catch (RuntimeException e) {
|
||||
// 占位符解析不出来 = 没配(这正是我们要报的那件事)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 试解 base64;解不开返回 null(**不抛** —— 检查代码自己不抛异常)。 */
|
||||
private static byte[] tryBase64(String s) {
|
||||
try {
|
||||
return java.util.Base64.getDecoder().decode(s.trim());
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 宽松判真(`true` / `TRUE` / `1` 都算)。 */
|
||||
private static boolean isTrue(String s) {
|
||||
return s != null && ("true".equalsIgnoreCase(s.trim()) || "1".equals(s.trim()));
|
||||
}
|
||||
|
||||
/** 打印所有属性来源(排障用:确认某个值到底从哪来的)。 */
|
||||
static List<String> sourcesOf(ConfigurableEnvironment env, String key) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (PropertySource<?> ps : env.getPropertySources()) {
|
||||
if (ps instanceof EnumerablePropertySource<?> e && e.containsProperty(key)) {
|
||||
out.add(ps.getName());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Spring Boot 的 EnvironmentPostProcessor 注册(META-INF/spring.factories)。
|
||||
#
|
||||
# ⚠️ 为什么在 spring.factories 而不是 .../AutoConfiguration.imports:
|
||||
# `.imports` 是给**自动配置类**用的(它们跑在环境准备好之后);
|
||||
# 而 EnvironmentPostProcessor 要求**在环境准备的过程中**被加载 ——
|
||||
# 这个时机只有 spring.factories 能表达。
|
||||
#
|
||||
# ⚠️ 注册错的表现是"检查静默不生效":应用照常启动、没有任何报错,
|
||||
# 而你以为生产自检在起作用。所以 scripts/e2e-prodguard.py 会**真的起一次**
|
||||
# 来验证它被调用了(不是只看代码在不在)。
|
||||
org.springframework.boot.env.EnvironmentPostProcessor=\
|
||||
cn.tianbaotong.fill.config.ProdEnvCheck
|
||||
@@ -0,0 +1,147 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 生产环境(prod)
|
||||
#
|
||||
# ⚠️⚠️ 这个文件里**一个密钥都没有,也永远不要加**。
|
||||
# 全部由环境变量提供;缺失时 **ProdGuard 会拒绝启动**
|
||||
# (见 config/ProdGuard.java —— 那 6 条都是"配错了也能启动、
|
||||
# 而且运行时完全不报错"的配置)。
|
||||
#
|
||||
# ── 必需的环境变量 ──────────────────────────────────────
|
||||
# 数据库 PG_HOST PG_PORT PG_DB PG_USER PG_PASSWORD
|
||||
# 缓存 REDIS_HOST REDIS_PORT REDIS_PASSWORD
|
||||
# 密钥 XT_JWT_SECRET HS512,≥ 64 字节随机值
|
||||
# XT_AES_KEY AES-256,base64,**用于证件号加密**
|
||||
# XT_HMAC_KEY HMAC,用于开放接口签名
|
||||
# XT_RSA_PRIVATE_KEY PKCS#8 单行 base64(登录密码解密)
|
||||
# 存储 XT_STORAGE_PROVIDER=qiniu
|
||||
# QINIU_ACCESS_KEY QINIU_SECRET_KEY QINIU_BUCKET QINIU_DOMAIN
|
||||
# 微信 WX_MP_APP_ID WX_MP_APP_SECRET (小程序)
|
||||
# WX_H5_APP_ID WX_H5_APP_SECRET (公众号网页授权)
|
||||
# WX_H5_CALLBACK WX_H5_BASE_URL (必须是 https 正式域名)
|
||||
# 其他 H5_STATIC_DIR(H5 构建产物目录)
|
||||
#
|
||||
# ⚠️ 密钥怎么生成、怎么轮换、轮换时要注意什么,见
|
||||
# docs/09-部署与运维.md §3「密钥与凭据」。
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://${PG_HOST}:${PG_PORT:5432}/${PG_DB:filldb}?stringtype=unspecified
|
||||
username: ${PG_USER}
|
||||
password: ${PG_PASSWORD}
|
||||
hikari:
|
||||
# 生产把池子放大一些:导出打包是长事务,会占住连接
|
||||
minimum-idle: 10
|
||||
maximum-pool-size: 40
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD}
|
||||
database: 0
|
||||
flyway:
|
||||
# ⚠️⚠️ 生产**禁止 clean**。它是"清空整个库再重建",
|
||||
# 在生产上执行等于把所有学校的填报数据删掉。
|
||||
# 流水线里也不要用 flyway clean —— 见部署文档的回滚一节。
|
||||
clean-disabled: true
|
||||
# 迁移文件发布后不许改(改了校验失败)。这是保护:它保证
|
||||
# "生产跑的迁移"与"仓库里的迁移"逐字节一致
|
||||
validate-on-migrate: true
|
||||
web:
|
||||
resources:
|
||||
# H5 产物由部署流程放到这个目录(**不进仓库**)
|
||||
static-locations: ${H5_STATIC_DIR:file:/opt/xt/h5/},classpath:/static/
|
||||
|
||||
server:
|
||||
# 生产在反向代理后面:让 Spring 认 X-Forwarded-* ,
|
||||
# 否则生成的回调地址会是 http://内网IP:8080
|
||||
forward-headers-strategy: framework
|
||||
tomcat:
|
||||
# 上传是长连接,生产放宽(学生可能从聊天记录里选一个 40MB 的文件)
|
||||
connection-timeout: 120s
|
||||
threads:
|
||||
max: 200
|
||||
min-spare: 20
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: warn
|
||||
cn.tianbaotong.fill: info
|
||||
file:
|
||||
# ⚠️ 日志按天滚动并保留 30 天:审计要能回答"上周三谁看过那份证件号"
|
||||
name: ${XT_LOG_DIR:/var/log/xt}/xt-server.log
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-history: 30
|
||||
max-file-size: 200MB
|
||||
total-size-cap: 20GB
|
||||
|
||||
# 生产关掉接口文档:它会把全部接口与字段暴露给公网。
|
||||
# ProdGuard 会检查这一条 —— 开着就拒绝启动。
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
# 生产只暴露健康检查(给负载均衡探活)
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
|
||||
fill:
|
||||
jwt:
|
||||
secret: ${XT_JWT_SECRET}
|
||||
crypto:
|
||||
aes-key: ${XT_AES_KEY}
|
||||
hmac-key: ${XT_HMAC_KEY}
|
||||
rsa:
|
||||
private-key: ${XT_RSA_PRIVATE_KEY}
|
||||
|
||||
security:
|
||||
login-max-fail: 5
|
||||
login-lock-minutes: 15
|
||||
captcha-after-fails: 2
|
||||
|
||||
storage:
|
||||
# 生产必须 qiniu(local 在多副本下必然丢文件,ProdGuard 会拦)
|
||||
provider: qiniu
|
||||
prefix: xiaotutongxue
|
||||
keep-raw: ${XT_STORAGE_KEEP_RAW:false}
|
||||
self-check-on-startup: ${XT_STORAGE_SELF_CHECK:true}
|
||||
qiniu:
|
||||
access-key: ${QINIU_ACCESS_KEY}
|
||||
secret-key: ${QINIU_SECRET_KEY}
|
||||
bucket: ${QINIU_BUCKET}
|
||||
domain: ${QINIU_DOMAIN}
|
||||
region: ${QINIU_REGION:auto}
|
||||
signed-url-expire-seconds: 1800
|
||||
|
||||
mini-program:
|
||||
app-id: ${WX_MP_APP_ID}
|
||||
app-secret: ${WX_MP_APP_SECRET}
|
||||
# ⚠️ 正式发布后用 release。用 trial/develop 生成的码扫开会提示
|
||||
# 「小程序未发布」,而**接口不报错** —— 只会让人以为码生成得不对
|
||||
env-version: ${WX_MP_ENV_VERSION:release}
|
||||
page: pages/index/index
|
||||
public-base-url: ${WX_MP_PUBLIC_BASE_URL}
|
||||
code-cache-days: 30
|
||||
|
||||
mp-h5:
|
||||
app-id: ${WX_H5_APP_ID}
|
||||
app-secret: ${WX_H5_APP_SECRET}
|
||||
callback-url: ${WX_H5_CALLBACK}
|
||||
h5-base-url: ${WX_H5_BASE_URL}
|
||||
# ⚠️⚠️ 生产**必须** false。开了它就是"任何人可登录任何学生"。
|
||||
# 这是 ProdGuard 的第一条检查,写成 true 直接拒绝启动。
|
||||
stub-enabled: false
|
||||
|
||||
tenant-domain:
|
||||
enabled: ${XT_TENANT_DOMAIN_ENABLED:false}
|
||||
base: ${XT_TENANT_DOMAIN_BASE:}
|
||||
scheme: https
|
||||
@@ -0,0 +1,87 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 测试环境(test)
|
||||
#
|
||||
# 用途:CI 流水线里的 E2E(`scripts/e2e-*.py` 打真实接口)与联调环境。
|
||||
#
|
||||
# ⚠️⚠️ 这个文件**不含任何密钥**,全部由环境变量提供。
|
||||
# 理由不是"洁癖":test 环境的库会被反复清空重建,
|
||||
# 而**密钥不该跟着库一起被当成一次性的**。更实际的是 ——
|
||||
# CI 上每次跑用**当次随机生成**的密钥(见 .gitlab-ci.yml),
|
||||
# 这样才能证明"应用没有偷偷依赖某个 dev 默认值"。
|
||||
#
|
||||
# 必需的环境变量(缺任何一个都起不来,且报错会指名道姓):
|
||||
# PG_HOST PG_PORT PG_DB PG_USER PG_PASSWORD
|
||||
# REDIS_HOST REDIS_PORT REDIS_PASSWORD
|
||||
# XT_JWT_SECRET XT_AES_KEY XT_HMAC_KEY XT_RSA_PRIVATE_KEY
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
# CI 里 PG 是 services 里的别名(默认 `postgres`),本地跑给 localhost 即可
|
||||
url: jdbc:postgresql://${PG_HOST:localhost}:${PG_PORT:5432}/${PG_DB:filldb}?stringtype=unspecified
|
||||
username: ${PG_USER:fill}
|
||||
password: ${PG_PASSWORD}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD}
|
||||
database: 0
|
||||
flyway:
|
||||
# test 允许 clean 后重建:每次流水线从一个空库开始,
|
||||
# **这样才能验证"所有迁移从 V1 依次跑到最新"是通的** ——
|
||||
# 而增量环境里永远验不到这件事(旧库已经是最新了)
|
||||
clean-disabled: false
|
||||
web:
|
||||
resources:
|
||||
# H5 产物在 CI 里是构建出来的;本地指向同一个相对路径
|
||||
static-locations: ${H5_STATIC_DIR:file:../frontend-mp/dist/build/h5/},classpath:/static/
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: info
|
||||
cn.tianbaotong.fill: info
|
||||
# CI 日志里带 SQL 会淹没断言输出,所以 test 不打开 SQL 日志
|
||||
|
||||
fill:
|
||||
jwt:
|
||||
secret: ${XT_JWT_SECRET}
|
||||
crypto:
|
||||
aes-key: ${XT_AES_KEY}
|
||||
hmac-key: ${XT_HMAC_KEY}
|
||||
rsa:
|
||||
private-key: ${XT_RSA_PRIVATE_KEY}
|
||||
|
||||
storage:
|
||||
# ⚠️ test 用 local 是**刻意的**:CI 里没有对象存储,而且
|
||||
# "上传→取回→预览"这条链路用本地磁盘就能完整验证。
|
||||
# 真实验证七牛要走 staging(那需要真凭据),见 docs/09-部署与运维.md
|
||||
provider: ${XT_STORAGE_PROVIDER:local}
|
||||
keep-raw: ${XT_STORAGE_KEEP_RAW:false}
|
||||
self-check-on-startup: false
|
||||
|
||||
mini-program:
|
||||
app-id: ${WX_MP_APP_ID:}
|
||||
app-secret: ${WX_MP_APP_SECRET:}
|
||||
env-version: develop
|
||||
|
||||
mp-h5:
|
||||
app-id: ${WX_H5_APP_ID:}
|
||||
app-secret: ${WX_H5_APP_SECRET:}
|
||||
callback-url: ${WX_H5_CALLBACK:http://localhost:8080/api/v1/mp/h5/callback}
|
||||
h5-base-url: ${WX_H5_BASE_URL:http://localhost:8080}
|
||||
# ⚠️ test 允许打桩登录(E2E 里有 23 条断言靠它跑 H5 授权链路)。
|
||||
# 它只在**非生产**可用:代码里另有一道"profile 不含 prod"的判断。
|
||||
stub-enabled: ${WX_H5_STUB:true}
|
||||
|
||||
tenant-domain:
|
||||
enabled: ${XT_TENANT_DOMAIN_ENABLED:false}
|
||||
base: ${XT_TENANT_DOMAIN_BASE:localhost}
|
||||
scheme: http
|
||||
|
||||
# test 也开 Swagger:联调的人要用它照着调
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
# 触发条件:推送或PR到master分支,以及手动触发
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
# 权限配置
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# 环境配置
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# 构建作业
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 检出代码
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 确保获取完整的历史记录
|
||||
|
||||
# 设置Node.js环境
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.4
|
||||
cache: npm # 缓存npm依赖
|
||||
|
||||
# 安装依赖
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps # 使用legacy-peer-deps解决peer dependency冲突
|
||||
|
||||
# 构建项目
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# 检查构建结果
|
||||
- name: Check build output
|
||||
run: |
|
||||
echo "=== 构建文件列表 ==="
|
||||
ls -la ./dist
|
||||
echo ""
|
||||
echo "=== Gzip 压缩文件统计 ==="
|
||||
find ./dist -name "*.gz" | wc -l | xargs echo "压缩文件数量:"
|
||||
find ./dist -name "*.gz" -exec ls -lh {} \; | head -10
|
||||
echo ""
|
||||
echo "=== 构建完成 ==="
|
||||
|
||||
# 上传构建产物作为artifact
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./dist
|
||||
|
||||
# 部署作业
|
||||
deploy:
|
||||
# 只有在构建成功且是push或手动触发时才运行
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
# 部署到GitHub Pages
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
with:
|
||||
# 使用官方的deploy-pages action,它会自动处理所有配置
|
||||
artifact_name: github-pages
|
||||
Reference in New Issue
Block a user