#!/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 // 第 14 项(2026-10-09):审核决策端点的「请求体契约」守卫 —— 前端统一发送 {comment, reasonCodes[]}, // 若 @RequestBody 被再度收窄为 Map,数组值反序列化失败即 400 // (现象:「小程序提交资料后,管理员点通过直接报错」)。 // ReviewControllerRequestBodyContractTest // 第 15~18 项(2026-10-11,ZC-MM-006): // MatchmakerAuditFieldsTest —— audit_logs 扩展审计字段(V055)写入/兼容/拒绝快照 // OperatorPermissionRevocationTest —— JWT 权限回收实时查库(禁用 401 / 撤权 403+审计) // MatchmakerSensitiveAccessAuditTest —— 服务详情敏感访问日志(success 行 / 双记录互斥) // MatchmakerWriteDenyUniformityTest —— 写接口 404 统一防探测(5 拒绝 ≡ 读详情 404) // 第 19~20 项(ZC-MM-003 B1 立项): // MatchmakerContactAuthorizationTest —— 受控联系授权生命周期/状态机/404 化拒绝/审计 // MatchmakerContactGuardTest —— 守卫翻转:联系回显收敛/端点前缀/到期语义/consent 独立性 // 新增/删减请同步修改本数组(下限随之变化)。 const CURATED = [ 'AuthServiceRefreshTtlTest', 'PhotoServiceConfirmUploadTest', 'RealIdentityVerificationProviderTest', 'JwtAuthFilterTest', 'CoreInvariantsTest', 'V11InvariantsTest', 'PermissionMatrixTest', 'MeRouteContractTest', 'CanonicalRouteContractTest', 'PhotoLimitGuardTest', 'PhotoLimitConcurrencyGuardTest', 'PhotoUploadValidationTest', 'OperatorAuthServiceTest', 'ReviewControllerRequestBodyContractTest', 'MatchmakerAuditFieldsTest', 'OperatorPermissionRevocationTest', 'MatchmakerSensitiveAccessAuditTest', 'MatchmakerWriteDenyUniformityTest', 'MatchmakerContactAuthorizationTest', 'MatchmakerContactGuardTest', ] 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) }