diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..36ac36c --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/ProdEnvCheck.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/ProdEnvCheck.java new file mode 100644 index 0000000..7ffa399 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/ProdEnvCheck.java @@ -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 生产环境准备)。 + * + *

为什么必须是 {@code EnvironmentPostProcessor},而不是 ApplicationRunner

+ * + *

我先写成了 {@code ApplicationRunner}(跑在容器就绪之后),实测发现**它根本轮不到**: + * 缺 {@code XT_JWT_SECRET} 时,{@code JwtService} 在**构造 bean** 时就炸了, + * 用户看到的是一段 56 行的 Spring 栈,而我最该被看见的那句清单连影子都没有。 + * + *

所以挪到 {@code EnvironmentPostProcessor}:它跑在**任何 bean 被创建之前**, + * 只依赖 {@code Environment}(配置已经在里面了,只是还没绑定成对象)。 + * 代价是不能用 {@code FillProperties} 的访问器,得按 key 读 —— 但那点啰嗦换来的是 + * "报错一定是人话"。 + * + *

检查的都是"配错了也能启动、而且运行时完全不报错"的配置

+ * + * + * + * + * + * + * + * + * + * + * + * + * + *
配置不检查会怎样
{@code stub-enabled=true}任何人可以登录任何学生(它本来就是「跳过微信授权」的开关)
{@code storage.provider=local}多副本部署下必然丢文件(学生这次传到 A 实例、下次落到 B)。 + * 这不是"性能差一点",是正确性错误,而且丢的时候没有任何报错
JWT 密钥还是 dev 那把任何人都能拿仓库里那把密钥伪造任意用户的 token(含平台运营)
AES 密钥还是 dev 那把证件号是 AES 加密存的 —— 密钥泄露等于全校身份证可解
Swagger 还开着把全部接口与字段暴露给公网
+ * + *

为什么是"拒绝启动"而不是"打个 warn"

+ * + *

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 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 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("}"); + } + + /** + * **安全地**读一个配置值(这是唯一正确的读法,前两版都错过)。 + * + *

为什么要包一层 try/catch: + *

+ * + *

所以正确做法是**安全解析**:解析成功就用解析后的值,解析失败当没配。 + * + *

一般化的教训:**做配置检查的代码必须能区分"没配"与"配了"** —— + * 而且它必须比被检查的配置更皮实,否则会在配置最坏的时候自己先炸。 + */ + 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 sourcesOf(ConfigurableEnvironment env, String key) { + List out = new ArrayList<>(); + for (PropertySource ps : env.getPropertySources()) { + if (ps instanceof EnumerablePropertySource e && e.containsProperty(key)) { + out.add(ps.getName()); + } + } + return out; + } +} diff --git a/backend/xt-server/src/main/resources/META-INF/spring.factories b/backend/xt-server/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..18d47fa --- /dev/null +++ b/backend/xt-server/src/main/resources/META-INF/spring.factories @@ -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 diff --git a/backend/xt-server/src/main/resources/application-prod.yml b/backend/xt-server/src/main/resources/application-prod.yml new file mode 100644 index 0000000..eeeaee9 --- /dev/null +++ b/backend/xt-server/src/main/resources/application-prod.yml @@ -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 diff --git a/backend/xt-server/src/main/resources/application-test.yml b/backend/xt-server/src/main/resources/application-test.yml new file mode 100644 index 0000000..286519f --- /dev/null +++ b/backend/xt-server/src/main/resources/application-test.yml @@ -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 diff --git a/frontend-pc/.github/workflows/deploy.yml b/frontend-pc/.github/workflows/deploy.yml deleted file mode 100644 index afdb2d2..0000000 --- a/frontend-pc/.github/workflows/deploy.yml +++ /dev/null @@ -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