# ═══════════════════════════════════════════════════════════ # 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 # ⚠️ 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