Files
danyang-match/scripts/run-backend-contracts.mjs
WorkBuddy d3735e3afa feat(matchmaker,contactauth): 受控联系授权机制立项落地(OQ-MATCHMAKER-003 / DEC-MATCHMAKER-003 B1 例外通道)
后端:
- V056 matchmaker_contact_authorizations 表:channel CHECK(phone/offline_meeting/full_contact)、六态状态机(pending_approval→pending_consent→active→expired/revoked, →rejected)、部分唯一索引 uq_mm_contact_auth_active 防同红娘-同用户-同渠道多 ACTIVE、双态惰性收敛(active 过期 + pending_consent 超同意窗口)
- ContactAuthorizationConfig 五 canonical key(matchmaker.contact.*):enabled 代码缺省 false fail-safe(yml 显式 true)/approver-authorities 双岗并列/consent-deadline-hours 72/default-validity-days 7/retention-days 365,全部 DEFAULT+CONFIG 待产品+法务终审
- ContactAuthorizationPolicy.assertContactAllowed(404 化 deny 先审计后 404) + canContact 徽标查询
- ContactAuthorizationService:request(须有效分配+purpose 必填)/approve(权限码动态判定∈配置+组长组织范围)/consent(用户本人,仅 pending_consent)/decline/revoke(reason 必填)/disclose(唯一披露出口:enabled fail-safe→assertActiveAssignment→assertContactAllowed→仅返回 phone/disclosedAt/authorizationId 三键,每次落 SensitiveAccessLog user_phone_disclosure,phone 不落应用日志)
- 双控制器:运营端 /operator/matchmaker/contact-authorizations + C 端 /contact-authorizations(UserPrincipal 归属校验,独立端点无预同意参数),不碰 SecurityConfig

守卫翻转(机械锁强制复审机制按设计生效):
- MatchmakerContactGuardTest 由「锁不存在」翻转为「锁新契约」五条:回显白名单类收敛/端点前缀收敛(受治理三包)/到期语义/consent 独立性三端扫描(后端+miniapp+admin-web,修复原只扫后端的预同意参数逃逸缺口)/ReferralService 白名单与无 @Scheduled 保留
- run-backend-contracts CURATED 18→20

前端:
- miniapp 新页 pages/contact-consent(目的/渠道/窗口明示+独立同意拒绝按钮无预勾选,app.json 第 64 页) + 红娘工作台申请/列表/撤销
- admin-web「联系授权审批」tab(审批岗队列+本人授权撤销,枚举码中文映射)

验证:mvn verify 895/895(3 skip)、UI 契约 24/24(64 页)、后端契约门禁 228/CURATED 20、小程序真编译 87 WXML 0 失败;负向注入 5(工程师)+2(QA 不同落点)判红→还原复绿;QA 抓 disclose 键数收敛断言缺口已补并回归;QA fresh eyes PASS 0 P0/P1
2026-10-11 13:08:02 +08:00

241 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<String,String>,数组值反序列化失败即 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)
}