2ff17cd0c9
用户决定:后端不再对外发静态文件,学生端 H5 交给网关。两者**仍然同源**
(微信只给一个授权回调域名;H5 的接口地址取自 location.origin),
所以前端业务代码、微信后台、application-dev.yml 的 h5-base-url 都不用改。
后端
- 删掉 dev/test/prod 三份 profile 里的 spring.web.resources.static-locations
- 主配置加 spring.web.resources.add-mappings=false —— 用开关而不是"指到空目录":
后者会连带启用 Spring Boot 的默认位置(classpath:/static 等),谁丢个文件进去就悄悄对外可见
- StaticCacheHeaderFilter → ApiNoStoreFilter,只剩 /api/** 的 no-store
(静态资源的缓存策略整体归 nginx,避免"改一处漏一处")
- 补 NoHandlerFoundException → 404:关掉静态映射后未匹配路径不再经过
ResourceHttpRequestHandler,抛的是 NoHandlerFoundException,只处理
NoResourceFoundException 时访问 / 会变成 500「系统繁忙」并刷 ERROR 日志
deploy
- 新增 xt-nginx(80):发 frontend-mp/dist/build/h5 + /api 反代 8080(host.docker.internal,
Linux 靠 extra_hosts host-gateway),端口与产物目录走 .env 的 H5_PORT/H5_DIR
- 三条"删了就会出事"的都已带上理由:client_max_body_size 50m(默认 1m,超了回 413
而后端日志一个字都没有)、/assets 的 immutable 与 *.html 的 no-cache(§一·十 那个
"发版后学生看到旧页面"的坑)、健康检查写 127.0.0.1(容器里 localhost 先解析到 IPv6,
nginx 只听 IPv4 → 永远 starting 而首页是好的)
CI 与文档
- CI:学生端 H5 构建从"e2e 的输入"改为编译检查,不再留 artifact(e2e 不消费它)
- 同步 AGENTS.md / README.md / docs/09(启动步骤、CI 表、发布注意、排障表)/ docs/11
- 交接摘要新增 §一·四十三(含理由、验证数字、要人工做的一步与环境限制)
验证
- mvn clean package 通过(必须 clean:删掉的旧类会留在 target/classes 被组件扫描)
- 后端直连:/ 与 /assets/* → 404({"code":40001,"msg":"接口不存在"})、
/api/.../captcha → 200 + no-store、/doc.html → 302→200、/v3/api-docs 与 /actuator/health → 200
- 经 nginx(80):首页 200 + no-cache、/assets/*.js 200 + immutable、/nope.js 404(无回退)、
/api 200 + no-store、gzip 生效;2MB multipart 上传 → 401(与直连一致 ⇒ 未踩 1m 的坑)、
55MB → 413
- e2e(XT_BASE=http://localhost,全程走 nginx):e2e-m0.py 37/37、e2e-m3-h5-auth.py 24/24、
e2e-m2-scope.py 38/38
- verify-all.py --quick 6/6;check-test-profile.py:test profile 仍能起来
224 lines
9.1 KiB
YAML
224 lines
9.1 KiB
YAML
# ═══════════════════════════════════════════════════════════
|
||
# GitLab CI(M0-04 / M7-10)
|
||
#
|
||
# 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。
|
||
# 这个仓库的单元测试很少(`src/test` 只有 12 条纯函数),验证靠三样东西:
|
||
# ① 五个机械校验脚本(契约对账 / 实体列 / 文档一致性 / 手册文案 / **接线**)
|
||
# ② 编译与构建(后端 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 "── 声明了但没接上的东西(权限码 / 错误码 / 通知模板 / 菜单键) ──"
|
||
- python scripts/check-wiring.py --quiet
|
||
- 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
|
||
# ⚠️ 2026-10 起 H5 **不再由后端托管**(改由 nginx/网关托管,见 deploy/nginx/xt.conf),
|
||
# 所以这个 job 的定位变了:它不再是 e2e 的输入,而是**编译检查** ——
|
||
# 学生端没有单独的 typecheck job(vue-tsc 有 2 条已知报错),
|
||
# "能不能 build 出 H5"就是它最实际的把关点。
|
||
# (产物也不留 artifact:e2e 不消费它,发版时由部署流程自己构建。)
|
||
- pnpm build:h5
|
||
|
||
# ══════════════ 阶段 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
|