diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 267e913..c0a114b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,13 @@ jobs: working-directory: backend run: mvn verify -B + # 后端契约门禁(与 scripts/run-ui-contracts.mjs 同级): + # 固化关键后端回归/契约测试(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底 + # + 核心不变量),先校验这些测试源文件存在性下限(防改名/删除后门禁假绿), + # 再用 `mvn test -Dtest=<列表>` 真跑。复用本 job 已编译产物与依赖缓存,开销极小。 + - name: 后端契约门禁(curated + 下限校验) + run: node scripts/run-backend-contracts.mjs + - name: Upload Test Results if: always() uses: actions/upload-artifact@v4 diff --git a/docs/governance/CODE_REVIEW_STANDARDS.md b/docs/governance/CODE_REVIEW_STANDARDS.md index 20c3fde..ed52512 100644 --- a/docs/governance/CODE_REVIEW_STANDARDS.md +++ b/docs/governance/CODE_REVIEW_STANDARDS.md @@ -122,7 +122,7 @@ ## 6. 合并准入(Definition of Done / 合并门槛) ### 6.1 硬性门槛(全部满足才可合并) -1. CI 7 道门禁全绿(backend / admin-web / miniapp / migration / test-shell-guard / ui-contracts / e2e)。 +1. CI 7 道门禁全绿(backend / admin-web / miniapp / migration / test-shell-guard / ui-contracts / e2e)。其中 backend 门禁在 `mvn verify` 之外,额外由 `scripts/run-backend-contracts.mjs`(与 `run-ui-contracts.mjs` 同级)固化一组关键后端契约/回归测试,并校验这些测试源文件的存在性下限(防静默删除/改名后门禁假绿)。 2. PR 模板所有 **T 级对应清单项**已逐项打勾并附验证说明。 3. 无未闭环的 Blocker/Major(或已书面转移到 `known-risks.md` 并取得 owner 确认)。 4. 新增/修改的测试已做负向注入。 @@ -131,7 +131,7 @@ ### 6.2 与现有 CI 门禁的分工(人审补 CI 之不能) | CI job | 它验证了什么 | 它验证不了的(必须由人审) | |--------|--------------|----------------------------| -| Backend Build & Test | `mvn verify` 编译+单测 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 | +| Backend Build & Test | `mvn verify` 编译+单测;`run-backend-contracts.mjs` 固化关键回归/契约测试并校验源文件下限 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 | | Admin Web Build | `pnpm build` 通过 | 运行时渲染正确性、token 是否补全色阶、响应式是否真重渲染 | | Mini Program Compile Check | 仅 `tsc --noEmit` | **真编译** WXML/WXSS、资源是否被 `ignoreDevUnusedFiles` 吞、胶囊安全区 | | Migration Validation | 序列/命名/非空 | CHECK 与枚举语义一致、向后兼容、可逆性 | @@ -164,7 +164,7 @@ 合并(保护分支)──▶ 合并后回读 + 轮询 CI ``` -- **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`),自认 T 级。 +- **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`、`scripts/run-backend-contracts.mjs`),自认 T 级。 - **提 PR**:必须填 PR 模板,含"改了什么 / 怎么验证的(命令+输出)/ 风险级别 / 相关决策或已知风险"。 - **Reviewer**:先判 T 级,再走清单;对 Blocker/Major 给**可执行**的修改建议(具体到文件:行),不只写"这里有问题"。 - **CI 回退**:CI 红必须修复或证明是环境问题(如 KR-07 的 `docker` PATH、KR-08 的镜像源),不得"重试蒙过"。 diff --git a/scripts/run-backend-contracts.mjs b/scripts/run-backend-contracts.mjs new file mode 100644 index 0000000..e90609f --- /dev/null +++ b/scripts/run-backend-contracts.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +// 后端契约门禁统一执行器(本地 + CI 共用入口) +// +// 背景:run-ui-contracts.mjs 固化了一组「源码结构与关键行为断言」守卫(ui-tests/*.mjs,纯 node、零依赖), +// 但后端回归/契约测试是 Java/Maven,无法用 node 直接跑。此前它们只随 CI 的 `mvn verify` 全量执行, +// 没有「curated + 下限硬校验」这层保护 —— 即:有人把关键回归测试类**改名/删除**后, +// `mvn verify` 只会少跑几个测试、照样 SUCCESS,门禁假绿。 +// +// 本脚本把「固化关键后端契约测试 + 文件存在性下限 + 真跑并解析 surefire 汇总 + 非零退出」固化下来, +// 作为 run-ui-contracts.mjs 的**同级后端门禁**: +// +// node scripts/run-backend-contracts.mjs # 跑 CURATED 列表(默认) +// node scripts/run-backend-contracts.mjs --list # 只打印 curated 列表,不执行 +// +// 三条约定(对应 run-ui-contracts.mjs 的同名纪律): +// 1) **文件存在性下限硬校验**:CURATED 中的每个测试类源文件必须存在于 backend/src/test/java, +// 缺一个即拒绝报绿(防止「测试被静默删除/改名」后门禁仍然全绿)。 +// 新增契约测试请上调 CURATED 列表;有意删减则下调并说明原因。 +// 可用环境变量 BACKEND_GUARDS_MIN 覆盖(仅用于让「数量下限」这条分支可被自检)。 +// 2) 真跑:用 `mvn test -Dtest=<列表>` 执行(CI 的 backend job 已装好 JDK/Maven 且刚跑过 verify, +// 复用编译产物与依赖缓存,仅重跑指定测试,开销很小)。任一测试失败/报错 → 非零退出。 +// 3) **本地无 JDK/Maven 时绝不谎报通过**:探测 mvn/mvnw → Docker 测试镜像 → 都不行则明确退出码 2 +// (区别于 0 / 1),提示走 CI 或 Docker,绝不假装绿。 + +import { spawnSync } from 'node:child_process' +import { existsSync, readdirSync, statSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const backendTestRoot = resolve(root, 'backend', 'src', 'test', 'java') + +// ── curated 后端契约/回归测试 ──────────────────────────────────────────────── +// 第 1~4 项:本轮(2026-09-30)为 v1.1.0 修复补的回归测试 + 扩展断言 +// AuthServiceRefreshTtlTest —— refresh token TTL 须用 refresh 过期(14天) 而非 access(4h) +// PhotoServiceConfirmUploadTest —— 照片直传 confirm 落库护栏(归属前缀 + 对象存在性) +// RealIdentityVerificationProviderTest —— 生产实名核验走真实校验位 + 可配置第三方 + fail-closed +// JwtAuthFilterTest —— Authorization 头优先、缺失回退 dy_op_at Cookie +// 第 5~13 项:核心不变量/契约守卫(持续盯防,改坏会漏) +// CoreInvariantsTest / V11InvariantsTest / PermissionMatrixTest +// MeRouteContractTest / CanonicalRouteContractTest +// PhotoLimitGuardTest / PhotoLimitConcurrencyGuardTest / PhotoUploadValidationTest +// OperatorAuthServiceTest +// 新增/删减请同步修改本数组(下限随之变化)。 +const CURATED = [ + 'AuthServiceRefreshTtlTest', + 'PhotoServiceConfirmUploadTest', + 'RealIdentityVerificationProviderTest', + 'JwtAuthFilterTest', + 'CoreInvariantsTest', + 'V11InvariantsTest', + 'PermissionMatrixTest', + 'MeRouteContractTest', + 'CanonicalRouteContractTest', + 'PhotoLimitGuardTest', + 'PhotoLimitConcurrencyGuardTest', + 'PhotoUploadValidationTest', + 'OperatorAuthServiceTest', +] + +const MIN_GUARDS = Number(process.env.BACKEND_GUARDS_MIN ?? CURATED.length) + +// ── 步骤 1:文件存在性下限校验 ────────────────────────────────────────────── +function findClassFile(name) { + // 在 backend/src/test/java 下递归找 `${name}.java` + const stack = [backendTestRoot] + while (stack.length) { + const dir = stack.pop() + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + continue + } + for (const e of entries) { + const full = resolve(dir, e.name) + if (e.isDirectory()) stack.push(full) + else if (e.name === `${name}.java`) return full + } + } + return null +} + +// --list 模式:只打印,不执行 +if (process.argv.includes('--list')) { + console.log(`后端契约门禁 curated 列表(${CURATED.length} 项,下限 ${MIN_GUARDS}):`) + for (const n of CURATED) console.log(` - ${n}`) + process.exit(0) +} + +console.log(`后端契约门禁 — 校验 ${CURATED.length} 个契约测试源文件存在性(下限 ${MIN_GUARDS})\n`) + +const missing = [] +for (const name of CURATED) { + const f = findClassFile(name) + if (!f) { + missing.push(name) + console.log(` MISSING ${name}`) + } else { + console.log(` OK ${name}`) + } +} + +if (missing.length > 0) { + console.error( + `\nERROR: ${missing.length} 个契约测试源文件缺失,少于下限 ${MIN_GUARDS} —— 测试可能被删除或改名,拒绝报绿。`, + ) + console.error(' 新增契约测试请上调 CURATED 列表;确为有意删减请下调并说明原因。') + process.exit(1) +} +if (CURATED.length < MIN_GUARDS) { + console.error( + `ERROR: 仅 ${CURATED.length} 个契约测试,少于下限 ${MIN_GUARDS} —— 拒绝报绿。`, + ) + process.exit(1) +} +console.log(`\n✅ 全部 ${CURATED.length} 个契约测试源文件存在,下限校验通过。\n`) + +// ── 步骤 2:真跑(mvn test -Dtest=<列表>) ───────────────────────────────── +function detectMvn() { + for (const cand of ['mvnw', 'mvn']) { + const r = spawnSync(cand, ['-v'], { cwd: root, encoding: 'utf8' }) + if (r.status === 0) return cand + } + // Windows 常见:backend 下有 mvnw.cmd + const winMvnw = resolve(root, 'backend', 'mvnw.cmd') + if (existsSync(winMvnw)) return winMvnw + return null +} + +const testList = CURATED.join(',') +const t0 = Date.now() + +let mvn = detectMvn() +if (mvn) { + console.log(`▶ 使用 Maven 执行:${mvn} test -Dtest=<${CURATED.length} 个契约测试>\n`) + const r = spawnSync(mvn, ['-B', 'test', `-Dtest=${testList}`, '-DfailIfNoTests=true'], { + cwd: resolve(root, 'backend'), + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1' }, + }) + const out = [r.stdout, r.stderr].filter(Boolean).join('\n') + analyze(out, r.status) +} else { + // 退路:Docker 测试镜像(与 .workbuddy/run-regression.sh 同三件套) + // 关键:docker daemon 在本机可能无响应,`spawnSync` 不设 timeout 会**挂死整个脚本** + // (2026-09-30 实测:docker CLI 在 PATH 但 daemon 未起,`image inspect` 直接卡住)。 + // 因此所有 docker 探测都带 timeout,超时即视为不可用 → 走 cannotRun,绝不假绿也不挂死。 + const docker = spawnSync('docker', ['--version'], { encoding: 'utf8', timeout: 10000 }) + const IMG = 'danyang-match-backend-test:latest' + if (docker.status === 0) { + const imgCheck = spawnSync('docker', ['image', 'inspect', IMG], { + encoding: 'utf8', + timeout: 15000, + }) + if (imgCheck.status === 0) { + console.log(`▶ 本地无 Maven,回退 Docker 测试镜像 ${IMG}\n`) + const dockerExe = + process.env.DOCKER_EXE || 'D:/Data/Docker/DockerDesktop/resources/bin/docker.exe' + const settings = resolve(root, '.workbuddy', 'maven-settings-aliyun.xml') + const r = spawnSync( + dockerExe, + [ + 'run', '--rm', + '-v', `${root}:/proj`, + '-v', 'danyang-m2-cache:/root/.m2', + '-v', `${settings}:/root/aliyun-settings.xml`, + '-w', '/proj/backend', '--memory=3g', '-e', 'MAVEN_OPTS=-Xmx256m', + IMG, 'mvn', '-B', '-s', '/root/aliyun-settings.xml', 'test', + `-Dtest=${testList}`, '-DfailIfNoTests=true', + ], + { encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' }, timeout: 600000 }, + ) + const out = [r.stdout, r.stderr].filter(Boolean).join('\n') + analyze(out, r.status) + } else { + cannotRun() + } + } else { + cannotRun() + } +} + +function cannotRun() { + console.error( + '\n⚠️ 本机无 JDK/Maven 且无可用的后端测试 Docker 镜像,无法本地执行后端契约门禁。', + ) + console.error(' 该门禁由 CI(backend job)强制执行,请在 CI 中验证;或安装 Maven / 构建') + console.error(' danyang-match-backend-test 镜像后本地复跑。') + console.error(' (以退出码 2 表示「本环境无法执行」,而非谎报通过。)') + process.exit(2) +} + +function analyze(out, status) { + // 解析 surefire 汇总:Tests run: X, Failures: Y, Errors: Z, Skipped: S + const re = /Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+)/g + let m + let total = 0 + let failures = 0 + let errors = 0 + while ((m = re.exec(out)) !== null) { + total += Number(m[1]) + failures += Number(m[2]) + errors += Number(m[3]) + } + const secs = ((Date.now() - t0) / 1000).toFixed(1) + console.log('') + if (status === 0 && failures === 0 && errors === 0) { + console.log(`✅ PASS 后端契约门禁:${total} 测试全部通过,用时 ${secs}s`) + process.exit(0) + } + console.error(`❌ 后端契约门禁失败(exit=${status ?? 'null'}):`) + console.error(` 已执行测试 ${total},Failures=${failures},Errors=${errors}`) + // 把 surefire 关键片段打印出来便于定位 + const tail = out.split('\n').filter((l) => /Tests run:|BUILD FAILURE|<<< FAILURE|<<< ERROR|No tests were executed/.test(l)) + if (tail.length) { + console.error(' --- 关键输出 ---') + for (const l of tail.slice(-25)) console.error(' ' + l.trim()) + } + process.exit(1) +}