一、机构审计归属缺陷(本轮活栈验收中新发现) - 现象:audit_logs 中 CREATE_ORGANIZATION 的 operator_id 7/7 为 NULL, 而 DISABLE/ENABLE_ORGANIZATION 均正确记录操作人。审计不可归属 = 无效审计。 - 根因:OrganizationController.create 持有 @AuthenticationPrincipal OperatorPrincipal, 却只把 params 传给 Service,未透传 principal.id() → saveAuditLog(null, ...)。 - 修复:createOrganization(params, operatorId) 签名透传;新增测试断言 createOrganizationRecordsOperatorInAuditLog 长期守卫。 - 复验:新写入的 7 条 CREATE 审计全部带 operator_id,旧 14 条仍为 NULL(前后对照清晰)。 二、补全 rc.22 的验证缺口(此前记为「已修复待验证」) - 全量回归 757 / 0 / 0 / 3,BUILD SUCCESS,108 个测试类,6:44(基线 741 + 新增 16)。 - 负向注入:ORG_STATUS_ACTIVE 改回 "normal" → 16 例变 3 失败 + 7 错误 (守卫三条断言各自命中 + 服务侧 7 例被 H2 CHK_ORG_STATUS 拦下)。 注入采用「仓库只读挂载 + 容器内副本改写」,工作区哈希前后一致,源码零污染。 - 活栈实测(Docker 全栈):创建/停用/启用均 2xx、非法输入 400、不存在 404、全程零 5xx(15/15)。 - 对已部署栈 pytest tests/e2e → 65 passed。 三、Docker 测试环境部署与工具链固化 - 测试镜像 danyang-match-backend-test 与命名卷 danyang-m2-cache 因 Docker Desktop 重装丢失, 构建定义此前是临时产物 → 落盘为仓库资产 infrastructure/test-toolchain/Dockerfile。 - 本地栈统一使用 docker compose -p danyang(原项目名 infra 与另一项目撞名, 其 --remove-orphans 会误删本项目容器;已确认邻项目数据无丢失,见 known-risks KR-09)。 四、UI 门禁脆弱性修复 - ui-document-baseline-contracts.mjs 解析 docker-compose.staging.yml 时报假红 (「守卫自身失效」);真因是该文件为 CRLF 而守卫正则要求 \n 紧随 container_name 之后 (prod.yml 是 LF 所以此前能过)。解析改为行尾无关,真实断言未削弱。门禁 18/18。 五、文档 - STATUS.md 新增 rc.23 并将 rc.22 由「待验证」订正为「已验证关闭」。 - 实施计划附 B.1、PROGRESS-ASSESSMENT-2026-09-21.md 同步订正。 - known-risks.md:KR-07 解除(Docker 已恢复)、KR-01/KR-06 按复测订正, 新增 KR-08(Docker Hub 域名 DNS 被投毒,改用国内镜像源绕行)与 KR-09(compose 项目名撞名)。 验证状态:全量回归 757/0/0/3 全绿;UI 契约门禁 18/18;对已部署栈 E2E 65/65; 负向注入可变红;工作区哈希未被注入过程污染。 未验证:GitHub Actions 首次真绿(仍未推送,见 KR-06)。
66 KiB
Phase 23 收尾与 UAT 就绪实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 把「Phase 01~22 已关闭、Phase 23 部分关闭、后端 669 tests 全绿」这一停点推进到 Phase 24(UAT)可启动:清偿 Phase 23 剩余与代码级遗留,完成 UAT 前置(决策、用例、目视复验),并登记 Phase 25/26 前置项。
Architecture: 分五批推进 —— 批 13 不需要用户决策(可立即执行),批 4 需用户协作(目视复验 + 13 项决策),批 5 仅登记。批 13 每批完成后必须跑全部门禁;批 2 的「活栈供给方式」基础设施决策已于 2026-09-18 落地为「Compose in CI」(见 Task 2.3),本机同一序列实测 E2E 49 passed;仅余 Task 2.3 Step 3(GitHub Actions 上实测一次绿)需在有 Docker Hub 出口的环境完成。
Tech Stack: Java 21 / Spring Boot 3 / PostgreSQL / Flyway · Vue 3 + TypeScript + Vite · 微信小程序原生 TypeScript · Node.js(契约门禁)· Docker Compose · GitHub Actions
Spec: docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md(Phase 23~26);
基线: docs/superpowers/specs/2026-09-13-reference-driven-dual-client-ui-redesign-design.md(UI 唯一视觉基线)
当前停点(2026-09-18)
| 项 | 状态 |
|---|---|
| Phase 01~22 | ✅ 复验关闭 |
| Phase 23 | ⚠️ 部分关闭 —— 44 个 pass 空壳已清、4 个真实 INV 行为守卫已补;E2E 场景实现已完成(2026-09-19,见 Task 2.1);INV-011 已补真实行为守卫(2026-09-19,见 Task 2.2,9 用例 + 两次注入验证)。仅余 E2E 在 CI 上的首次实测绿 |
| Phase 23B | ✅ E2E 测试基建重建(conftest + seed + CI e2e job,本机活栈 49 → 65 passed) |
| 横向加固 | ✅ HTTP 语义(105 处真实状态码)+ 身份收敛 + 幂等键(30 端点,V045/V046)+ 端口/入口守卫 |
| 后端回归 | 700 / 0 failures / 0 errors / 3 skipped(2026-09-19 全量实测;含 9 例 INV-011 + 3 例 Mapper 绑定 + 8 例隐私契约/upsert + 11 例 flag 持久化守卫) |
| UI 契约门禁 | 18/18 绿(原文 14/14 已过期,见 Global Constraints) |
| 停点 | Phase 24(UAT)启动门前 —— 需用户确认 |
✅ 批 2 的 E2E 项已在本机真实执行(2026-09-18:
pytest tests/e2e→ 49 passed,真栈 + 真库 + 当日代码), 「失败为零可进入 UAT」的前置条件已满足。仍缺的是 E2E 在 CI 上跑过一次(Task 2.3 Step 3)—— 那是回归防线缺口,不阻塞 UAT 启动,但 Phase 23 因此仍记「部分关闭」。
Global Constraints
- 禁止用
rm/git rm删除文件 —— 本机有 safe-delete 钩子会把「删文件」放大为「删父目录」(2026-09-18 实测:git rm单个文件导致整个docs/被移除)。需要移除内容时一律原地重写(Write/Edit)。 - 不修改业务规则、不扩大 V1 范围;新增能力必须走 Phase 顺序。
- 每批完成后跑
node scripts/run-ui-contracts.mjs(守卫数量以执行器MIN_GUARDS为准;2026-09-19 实为 18 个,须 18/18 绿 —— 原文「14 个 / 14/14」已过期:2026-09-19 起相继新增wxml-structure-contracts、miniapp-self-method-contracts、wxss-component-selector-contracts、miniapp-api-client-behavior)+ui-document-baseline-contracts.mjs(含端口与/admin/子路径断言)。 - 后端改动跑离线容器
mvn -o test,必须 0 failures / 0 errors。 ⚠️ 2026-09-19 补注:必须挂载命名卷-v danyang-m2-cache:/root/.m2。 镜像内置的/root/.m2(132MB)不含surefire-junit-platform;漏挂该卷时-o必报BUILD FAILURE ... could not be resolved ... in offline mode—— 看起来像「离线不可用」,实为「m2 卷没挂」(本人本轮即据此误判过一次,见附 B-9 的更正)。 命名卷实为 251MB 且含该 provider(surefire-junit-platform/3.1.2实测存在)。 ⚠️ 第二处必挂:把「仓库根」挂进来,CWD 指向backend——-v <repo>:/proj -w /proj/backend。 若干测试用相对仓库根的路径读文件(如backend/src/main/resources/db/migration、admin-web/src/modules/.../ServiceStationView.vue);若只挂backend/并-w /app, 它们会集体报NoSuchFile(本轮实测:35 failures + 54 errors 全部是这一类, 是挂载方式错,不是代码错 —— 别误当成回归)。 全量另加-Dsurefire.forkCount=1 -DreuseForks=true。完整命令见附 B-9。 - E2E 改动跑
pytest tests/e2e(需活栈;本机E2E_BASE_URL=http://localhost:8085/api/v1、E2E_OPERATOR_PASSWORD见 CI 变量),必须 0 failed。 本机复跑前若出现429 账号已锁定,是 Redis 里的登录失败锁定键(auth:operator_login_lock:<operatorId>,TTL 15 分钟), 由前一轮失败登录累积触发 —— 删掉该键即可,不是业务缺陷。 - 提交纪律:每批独立 commit +
git tag(单段名,带/会被本机常驻进程清理)+ 回读验证;git bundle create覆盖最新提交。 - 涉及密码/密钥:不写入文档,只用环境变量或安全 Seed。
批 1 — 快速收尾(无需决策)✅ 已完成(Task 1.1 / 1.2 均于 2026-09-18 关闭)
Task 1.1: ui-tests 全量契约门禁接入 CI ✅(2026-09-18 已完成)
Files:
- Modify:
.github/workflows/ci.yml(新增 jobui-contracts) - Add:
scripts/run-ui-contracts.mjs(统一执行器:逐个跑 → 汇总 → 任一失败即非零退出) - Test:
ui-tests/*.mjs(实测 14 个守卫文件;原文此处误写「15 个」,本轮更正)
背景:14 个 UI 契约门禁此前只在本地手工执行,CI 上无人跑 —— 文档改了、端口漂移了、/admin/ 又退回 alias 了,CI 不会变红。这是「门禁存在但不生效」的同族问题(本项目已复发多次)。
- Step 1: 确认 CI 现有 job 清单(
backend/admin-web/miniapp/migration/test-shell-guard/e2e)与触发条件。 - Step 2: 新增
ui-contractsjob(node-version: 20,与既有 job 一致;跑node scripts/run-ui-contracts.mjs),任一守卫非 0 退出即 job 失败。 - Step 3: 负向验证:① 注入一个必然失败的守卫(放在
.workbuddy/tmp/、显式传参运行,不污染正式门禁)→ 执行器报 FAIL、退出码 1 并打印该守卫原始输出;② 守卫数量下限分支以UI_GUARDS_MIN=99自检 → 正确拒绝报绿。 - Step 4: 确认基线变绿:
PASS 14/14、退出码 0;ci.yml经yaml.safe_load解析通过、jobs 含ui-contracts。
说明:Step 3 用的是「合成失败守卫」而非「改坏 STATUS 端口」—— 后者验证的是单个守卫的判别力,而本 Task 要验证的是执行器与 CI 接线能否把失败传递出来(合成守卫足以隔离这一点)。端口/入口类守卫自身的判别力已在 2026-09-18「管理端入口收敛」轮以真实负向注入证明(6 条断言)。
Acceptance: CI 上 14 个契约全绿(本地已实测);注入缺陷后执行器非零退出、CI 变红。✅
Task 1.2: database 文档对齐 ✅(2026-09-18 已完成)
docs/database/TABLE_INVENTORY.md由 40 表重写为实测 59 表(含来源迁移列)。docs/database/MIGRATION_GUIDE.md由 V001~V011 重写为实测 V001~V046(46 个)。- 两处均标注「以
backend/src/main/resources/db/migration/为准」。
Acceptance: 文档表数与迁移文件数一致(59 / 46)。✅
批 2 — Phase 23 剩余(核心阻塞)
Task 2.1: E2E 场景真实实现 ✅(2026-09-19 完成)
Files:
- Modify:
tests/e2e/activity_mutual_consent_test.py、tests/e2e/service_appointment_test.py - Add:
tests/e2e/sql/seed_e2e_activity.sql、tests/e2e/sql/seed_e2e_service_station.sql - Test: 本地活栈
pytest tests/e2e
前置状态修正(2026-09-19 实测):原文「现为占位文件」只对上述两个文件成立 —— 它们在 2026-09-18 的 KEEP-VERIFY 轮已从「44 个
pass空壳」改为诚实占位(仅 docstring、 0 个用例)。其余文件(home_v11_test.py/local_eligibility_test.py/counseling_*/http_semantics_test.py/idempotency_test.py等)在 Phase 23B 已是真实用例,不是占位。
场景清单(占位文件头部已列,不得缩减):
| 文件 | 场景数 | 覆盖 | 实现状态 |
|---|---|---|---|
| 首页聚合 | 11 | 首页 8 入口顺序、Banner 仅内部跳转、今日推荐、公益服务、安全提醒 | 分散在 home_v11_test.py 等(场景编号与用例数非一一对应,未逐条对账) |
| 本地资格 | 11 | 声明 → 佐证上传 → 审核 → 状态流转;INV-011 未授权不可见 | 分散在 local_eligibility_test.py 等(同上) |
| 互选 | 8 | 二阶段同意:互选结果 → 双方分别确认 → 创建 Match;已 Match 复用 / 已 Unmatch 新建 | ✅ activity_mutual_consent_test.py 8/8 通过 |
| 权限 | 10 | 各角色数据范围(Counselor Assigned / ContentEditor 零用户数据 / 站点点范围) | 分散在 counseling_permission_test.py / service_station_permission_test.py / organization_isolation_test.py |
| 预约 | 7 | 用户创建/详情/列表/取消仅本人;代登记走独立 operator 接口 | ✅ service_appointment_test.py 8/8 通过(原「已确认不可自行取消」一条见下方说明,改为有规格支撑的归属隔离 + 运营侧状态闸门两条) |
| 内容 | — | 婚恋课堂 / 反诈列表与详情 | content_e2e_test.py |
| 组织隔离 | — | 跨组织默认拒绝 | organization_isolation_test.py |
- Step 1: 逐文件实现场景(先删除空
pass,禁止保留空壳)。两文件共 16 个真实用例,pass空壳为 0。 - Step 2: 本地活栈跑通(需后端 + PostgreSQL + 种子数据 +
E2E_PASSWORD)。 本机实测pytest tests/e2e→ 65 passed(49 基线 + 16 新增)。新增两个幂等 seed 文件承载 「活动互选」与「服务站/站点-运营关联」的前置数据,本机与 CI 通用。 - Step 3: 负向验证:注入 2 处缺陷,确认对应 E2E 变红(详见下方「负向注入记录」)。
Acceptance: 场景全部真实执行且通过;无空 pass 残留。✅
负向注入记录(Step 3,2026-09-19 实测)
| # | 注入 | 期望 | 实测 |
|---|---|---|---|
| ③ | 撤销 service_station_operator 的 service_appointment.confirm |
运营确认类用例变红 | 恰 2 个红(test_operator_can_confirm / test_operator_cannot_cancel_completed,HTTP 403),其余 6 绿 |
| ④ | 触发器强制新建互选记录的对方侧为 accepted |
依赖「对方未确认」的用例变红 | 3 个红(test_both_confirm_creates_match_episode / test_timeout_handled / test_single_interest_not_leaked),其余 5 绿 |
两处注入均已还原并复跑全绿(65 passed),且确认无残留(无 e2e* 触发器、权限计数回到 1)。
两次失败注入(记录在案,避免后人重复踩)
- 注入①「删除站点→运营关联」无效:测试的 seed fixture 会在用例前幂等重放 seed, 把关联补回来 —— 即 fixture 会自愈它自己的前置条件。要注入必须选 fixture 不会修复的东西。
- 注入②「把
user_b_status列默认值改成accepted」无效:createMutualConsent在 Java 侧显式写入pending,DB 默认值根本不参与。这条侧面证明了写入路径的权威性。
Task 2.1 补充:场景对账表(2026-09-19)
原始场景清单取自 Phase 23 占位文件头(commit
ae1bd2b,此后文件已实现为真实用例)。 对账口径:场景有真实断言即算覆盖,层次分 E2E(HTTP 活栈)与后端集成(真实落库)。
首页 11 项
| # | 场景 | 覆盖 | 位置 |
|---|---|---|---|
| 1 | GET /home 返回 userState |
✅ E2E | run_v11_tests::test_02_home、home_v11_test::test_home_returns_fixed_sections |
| 2 | Banner 按时间过滤(未开始/已过期不显示) | ✅ 后端 | BannerOperatorPhase19IntegrationTest |
| 3 | Banner 按 audience_scope 过滤 |
✅ 后端 | 同上 |
| 4 | 首页 8 入口顺序固定(INV-017) | ✅ 双层 | 后端 V11InvariantsTest#inv017(真实 getHome)+ E2E run_v11_tests::test_03、home_v11_test::test_quick_entries_fixed_order |
| 5 | 未审核用户不返回真实推荐 | ✅ 后端 | RecommendationEligibilityPhase06Test(资格矩阵) |
| 6 | Paused 用户不返回新推荐 | ✅ 后端 | 同上 |
| 7 | Successful 用户不返回找对象推荐 | ✅ 后端 | 同上 + RelationshipProgressPhase12Test |
| 8 | 服务站可展示 | ✅ E2E | run_v11_tests::test_06 + service_appointment_test(seed 站点)+ Phase 19 公告守卫 |
| 9 | Banner 不能外链(INV-016) | ✅ 双层 | 后端 V11InvariantsTest#inv016(反射读生产白名单)+ E2E home_v11_test::test_banner_target_types_are_internal_only |
| 10 | counselingEnabled flag 控制 canApplyCounseling |
⚠️ 场景过时(被取代) | 该 flag 经产品裁定于 2026-09-19 移除(死配置,见 Task 3.2);入口可见性由 CapabilityService 决定(AGENTS.md #26)。原命题不再成立 |
| 11 | canReceiveRecommendations 随账号状态变化 |
✅ 间接 | 资格层 RecommendationEligibilityPhase06Test + E2E home 走真实 /home(Capability 参与);CapabilityService 无独立单测(接受,低风险) |
本地资格 11 项
| # | 场景 | 覆盖 | 位置 |
|---|---|---|---|
| 1 | 用户可声明丹阳工作/户籍/居住 | ✅ E2E | local_eligibility_test::test_declaration_is_persisted、::test_new_user_is_not_declared |
| 2 | 声明不默认要求上传佐证 | ✅ 后端 | LocalEligibilityServiceTest(declare 与 evidence 分离) |
| 3 | 佐证上传需要鉴权 | ✅ 后端 | LocalEligibilityServiceTest(userId 自限定)+ PhotoUploadValidationTest |
| 4 | 审核员可访问佐证(INV-011) | ✅ 后端 | Inv011EvidenceAccessGuardTest(真实落库 + 审计回查) |
| 5 | 红娘不可访问佐证(INV-011) | ✅ 后端 | 同上 |
| 6 | /me/status 返回 localEligibilityStatus |
✅ 本轮补缺(2026-09-19) | 对账发现实现存在但零断言 → 新增 MeStatusLocalEligibilityGuardTest(2 用例:not_declared / 真实回读) |
| 7 | 未核验影响匹配资格 | ✅ 后端 | RecommendationEligibilityPhase06Test(localEligibilityRequired 接入) |
| 8 | 审核进入 local_eligibility 队列 |
✅ 后端 | ReviewServicePhase05Test |
| 9 | 审核通过更新状态 | ✅ 后端 | 同上 |
| 10 | 需补充佐证触发用户上传 | ✅ 后端+页面 | ReviewService 置 needs_evidence、LocalEligibilityService 上传后回 pending_review(LocalEligibilityServiceTest 覆盖分支);用户端承接 local-eligibility-supplement 页 + getStatus.evidenceRequired |
| 11 | 查看佐证须记录审计 | ✅ 后端 | Inv011EvidenceAccessGuardTest(success/denied 双向审计落库回查) |
权限 10 项
| # | 场景 | 覆盖 | 位置 |
|---|---|---|---|
| 1 | Counselor 只看 assigned(INV-012) | ✅ 双层 | 后端 CounselingPhase13ATest + E2E counseling_v11_test::test_counselor_assigned_list_is_scoped |
| 2 | Counselor 不可看他人服务 | ✅ E2E | counseling_permission_test::test_counselor_cannot_read_unassigned_service |
| 3 | 红娘不可读咨询正文(INV-013) | ✅ 双层 | 后端 V11InvariantsTest#inv013(真实 getDetail 脱敏)+ E2E counseling_permission_test::test_matchmaker_has_no_counseling_view |
| 4 | 普通管理员不可读敏感备注 | ✅ 后端 | CounselingPhase13ATest(脱敏分层:manager 摘要 / 非 assigned 脱敏) |
| 5 | 咨询管理员可分配 | ✅ 双层 | 后端 assignIfPending 守卫 + E2E run_v11_tests::test_08 |
| 6 | 咨询可转交风控 | ✅ 后端 | RiskReferralService + ReportAndRiskPhase17IntegrationTest(detectCounselingRisk 来源接入) |
| 7 | 服务站人员不能浏览全用户(INV-018) | ✅ 双层 | E2E organization_isolation_test + service_station_permission_test + 后端 ServiceAppointmentDataScopeTest |
| 8 | 服务站人员可处理本站预约 | ✅ E2E | service_appointment_test::test_operator_can_confirm(seed 站点范围) |
| 9 | 内容编辑员不能访问用户档案(INV-019) | ✅ 双层 | 后端 V11InvariantsTest#inv019 + E2E content_e2e_test::test_content_editor_cannot_read_user_matching_data |
| 10 | 内容编辑员可管理文章 | ✅ E2E | content_e2e_test::test_article_lifecycle_draft_publish_offline |
对账结论:32 项场景中 31 项有真实断言覆盖(E2E 或后端集成),1 项(首页 #10)被产品裁定取代并记录取代关系;发现并补齐 1 处零断言字段(/me/status.localEligibilityStatus,场景 #6)。无「假覆盖」残留。
Task 2.2: INV-011 显式守卫
Files:
- Create: 测试类(
backend/src/test/java/.../invariants/) - Read:
LocalEligibilityService、ReviewController
背景:LocalEligibilityService 全部方法以 userId 自限定、零 role 分支,佐证无独立服务端读取入口(仅经受权限控制的审核队列)—— 结构性满足,但无测试固化。
2026-09-19 实测落点(照此实现即可,不必重新勘察)
-
当前唯一服务端读取入口:
POST /api/v1/operator/reviews/{taskId}/evidences/{evidenceId}/access(ReviewController:165),两级 gate:类级hasAuthority('review.view')- 方法级
hasAuthority('local_eligibility.evidence.view')。
- 方法级
-
业务侧
ReviewService.accessLocalEligibilityEvidence(taskId, evidenceId, operatorId, purpose)在 权限注解之外再校验四件事:purpose非空 → 任务是local_eligibility类型 →requireClaimOwner(领取人本人)→ 佐证归属该任务用户的资格记录。 成功与拒绝都会写sensitive_access_logs(logEvidenceAccess,result = success|denied)—— 这正是「可核查」的落库路径,也是负向断言应该咬住的地方。 -
用户侧入口
/local-eligibility/{declaration,evidences,submit}全部以principal.id()自限定,无 userId 入参。 -
佐证上传前置:
featureFlags.isLocalEvidenceUploadEnabled()、evidenceType白名单、fileId必须属于该用户自己的user_photos(userPhotoMapper.findByUserIdOrderBySort)。 -
测试基建:
@SpringBootTest @ActiveProfiles("test")(H2,schema-h2.sql),参照V11InvariantsTest。 -
Step 1 / Step 2: 写负向测试 + 真实落库断言 ✅(2026-09-19)
-
Step 3: 负向注入验证守卫变红 ✅(2026-09-19,两次注入,均精确命中)
产出:backend/src/test/java/.../invariants/Inv011EvidenceAccessGuardTest.java(9 个用例,全绿)
前置修复(必须先做,否则 Step 2 无从落地)
backend/src/test/resources/schema-h2.sql原本没有sensitive_access_logs表 → 「审计真的落库了吗」这条断言在测试期根本无法编写。已按 V001 + V034 + V035 补齐 (不建 FK,因测试库无operators表;与review_tasks同风格)。详见附 B-8。
9 个用例的分工(两条独立命题 + 一个反证 + 两个边界 + 三个结构性守卫)
| 用例 | 咬住的命题 |
|---|---|
inv011NonClaimantDeniedAndAudited |
非领取人 → 拒绝 + denied 落库 |
inv011CrossUserEvidenceDeniedAndAudited |
领取人拿自己 taskId 读他人佐证 → 拒绝 + denied |
inv011NonLocalEligibilityTaskDeniedAndAudited |
非 local_eligibility 任务 → 拒绝 + denied |
inv011ClaimOwnerSucceedsAndAudited |
反证:领取人读本任务佐证 → 放行 + success(排除「全局拒绝」假阳性) |
inv011BlankPurposeRejectedWithoutAudit |
边界:purpose 空 → 拒绝且不留痕(早退于触及用户数据之前) |
inv011UnknownTaskRejectedWithoutAudit |
边界:taskId 不存在 → 拒绝且不留痕 |
inv011UserSideEndpointsTakeNoUserControllableUserId |
结构性:用户侧 4 个端点无用户可控 userId 入参,身份只来自 @AuthenticationPrincipal |
inv011EvidenceReadEndpointRequiresDedicatedAuthority |
结构性:佐证读取入口类级 review.view + 方法级 local_eligibility.evidence.view 双重约束,且不暴露伪造用 operatorId 入参 |
inv011GuardSelfCheckParameterNamesAreReadable |
守卫自检:反射能读到参数名(否则上一条会静默永真) |
关于「403」的口径:两级
@PreAuthorize在 HTTP 层给 403;业务层给RuntimeException。 本测试两层分别覆盖 —— 业务层走真实落库断言,HTTP 层走注解反射断言(见后两个结构性用例)。
Step 3 负向注入记录(两次,均精确命中,无一误伤)
| 注入 | 手段 | 预期 | 实测 |
|---|---|---|---|
| ① | 停用 requireClaimOwner(task, operatorId) |
归属校验失效 | 恰 1 红:inv011NonClaimantDeniedAndAudited("Expected RuntimeException to be thrown, but nothing was thrown"),其余 8 绿 ✓ |
| ② | 停用 catch 块内的 logEvidenceAccess(..., "denied") |
拒绝路径不留痕 | 恰 2 红:inv011NonClaimantDeniedAndAudited、inv011CrossUserEvidenceDeniedAndAudited("应恰好落库 1 条审计(实得 0 条)"),其余 7 绿 ✓ |
注入②中
inv011NonLocalEligibilityTaskDeniedAndAudited正确地未变红 —— 它走的是 try 块之外的那条logEvidenceAccess(任务类型不符的早退分支), 不受注入影响。这从侧面证明用例确实咬住了两条不同的代码路径,而不是重复断言同一处。两次注入后均已还原并复跑全绿(9/9),
git diff中不存在ReviewService.java→ 无残留。
与原文的一处校正:Step 3 原注「改 Java 代码需重建镜像,成本极高」不再成立 ——
镜像 danyang-match-backend-test 内含 JDK+Maven,挂载源码即可直接跑 surefire,
注入-验证-还原一轮约 3.5 分钟。详见附 B-9。
Acceptance: ✅ INV-011 已从「常量口径 + 指针」升级为真实行为守卫(9 用例 + 两次注入验证)。
Task 2.3: E2E 进入 CI(已决策并落地,2026-09-18)
Files:
- Modify:
.github/workflows/ci.yml(e2e job) - Modify:
infrastructure/docker-compose.yml(minio 镜像 pin)
决策(2026-09-18):在 runner 内用 Compose 起全栈。
备选是 service containers + 在 runner 上 mvn package 后 java -jar;但后者需为后端另写一套 host 网络配置,
等于制造第二条与本地不一致的路径,且增加一份与生产无关的分歧。Compose 全栈与本地验证路径完全相同,
且不需要 registry 凭据、不需要预构建镜像分发。
本机实测(同一序列,2026-09-18):中间件三项 healthcheck 全 Healthy(pg_isready /
redis-cli -a … ping / mc ready local)→ backend /api/v1/health/live 返回 200 → seed 幂等可执行
(INSERT 0 0)→ pytest tests/e2e → 49 passed。
本机限制(如实记录):Docker Hub 不可达(
auth.docker.ioBad Gateway)且本地无 temurin 基础镜像, 故未复现--build环节;改用本地既有镜像挂载当日 16:24 构建的 jar,使被验证的代码版本与 CI 一致 (否则是拿 09-09 的旧代码验 09-18 的用例,结论无效)。CI 上--build由 runner 完成。
- Step 1: 确定活栈供给方式 → Compose in CI(理由见上)。
- Step 2: 按决策实现 job:
compose config秒级快速失败 →down -v保证全新库 →build→up(显式含 minio)→ 就绪等待(90×5s)→ seed →pytest tests/e2e -v→ teardown;并加timeout-minutes: 30。 - [~] Step 3: 实测一次完整的 CI 绿 —— 2026-09-20 本机已模拟 CI 序列实测:compose 全新栈(
down -v清卷)→ backend 就绪(Flyway 应用至 V049)→ seed →pytest tests/e2e65/65 全绿(宿主机 Python 3.11 +docker exec psql断言,与 CI runner 执行环境等价)。GitHub Actions 上的流水线实测仍待一次推送(需有 GitHub 出口的环境);另登记一处基础设施风险:minio/minio:RELEASE.2025-09-07T16-13-09Zpin tag 在本机拉取被拒(疑似上游清理 RELEASE 日期 tag,需在有完整 Hub 出口处验证;E2E 用例不触 S3,不影响本次 65/65 结论)。
Acceptance: CI 串起 迁移 → backend test → admin type-check/build → miniapp type-check → E2E,任一失败阻断。
本轮顺带修掉的 6 个 CI 真实缺陷:
minio/minio:latest是滚动 tag(MinIO 已多次改动镜像内容,如移除内嵌 console)→ pinRELEASE.2025-09-07T16-13-09Z。否则 CI 拉到的镜像与本地验证时不同 → 不可复现的偶发失败。up未显式列出 minio(靠depends_on隐式拉起),而 minio 的 healthcheck 正是 backend 能否启动的前置条件 → 显式列出,消除隐性依赖。up --build把「构建镜像」与「起栈」耦合,失败时日志混杂 → 拆为build/up两步。- 就绪窗口 5 分钟偏紧(首次构建后仍需 Spring 启动 + Flyway 46 个迁移)→ 放宽到 7.5 分钟。
- 缺
down -v前置清卷:seed 用ON CONFLICT (username) DO NOTHING,若卷为上一轮残留,账号密码会停留在上一轮口令 → 本轮登录 401,并伪装成业务失败。(本机复用旧库时已实际踩到:必须手工UPDATE密码才能跑。)CI 每次全新库不触发,但 job 被取消/超时残留时会触发 → 起栈前强制清卷。 - e2e job 无 timeout(GH Actions 默认 360 分钟)→ 起栈失败会把流水线拖住数小时 →
timeout-minutes: 30。
未采纳的(避免过度设计):pytest -v 曾被认为会从仓库根误收集 tests/*_test.py(那些文件在 import 期即依赖 psycopg2 与 E2E_DB_PASSWORD)——实测否定:pytest.ini 的 testpaths = tests/e2e 已把范围限定,从根收集正是 49 个用例且无 error。仍显式写成 pytest tests/e2e -v,作为「该配置被改动」的兜底。
Task 2.4: Phase 22(Deployment / Ops)口径复验
⚠️ 口径澄清:此为计划的 Phase 22(部署运维基础设施),与
STATUS.md所述「Phase 22 部署运维配置扩展(Feature Flag)」是两份不同工作。后者已于 2026-09-18 复验关闭。
- 生产关闭 Mock WeChat / 默认密码 / Debug / Swagger / 演示 Seed;Secret 全部由环境变量或 Secret 文件注入。
- 备份加密 + 独立存放,执行一次 PostgreSQL + 对象存储的实际恢复演练(要有命令、日志、结果证据)。
- Rollback 明确回到哪个稳定镜像/版本;Flyway 只向前修复,不破坏 V001~V027。
- 删除 Backend 镜像「跳过测试」的发布路径;CI 先测试再打包。
Acceptance: staging 部署、健康检查、恢复演练、版本回滚均有可复现证据。
批 3 — 代码级遗留清偿 ✅(2026-09-19/20 全部关闭)
Task 3.1: Phase 21 遗留
-
隐私设置接口:
getPrivacySettings/updatePrivacySettings原为 TODO 桩(返回假数据 / 不更新)→ 已接真实user_privacy_settings(2026-09-19,① ② 两层均已关闭,见下方进展记录)。 2026-09-19 进展 —— 本项拆成两层,下层已修,上层仍待裁定:**① 下层(真实实现 `PrivacyService`)已修:upsert 缺陷** —— 无条件 `insert` 改为 「存在则按 `user_id` 更新,不存在才插入」。 - 缺陷:该表主键自 V002 起就是 `user_id`,旧实现无论记录是否存在都 `insert` → 同一用户**第二次**更新必然 `DuplicateKeyException` → 500,隐私设置从此再也改不动。 - 更新条件刻意用 `user_id` 而非实体 `id`:`id` 是 V037 为「实体↔表对齐」后补的独立列, 不作为寻址依据(否则历史行 id 缺失时更新会静默失效)。 - 守卫:`PrivacySettingsUpsertGuardTest`(4 用例,全走真实落库)。 负向注入已验证:退回无条件 `insert` → **恰 2 红**且失败原因即 `DuplicateKeyException`; 另 2 个只更新一次的用例**正确未红**(走不到该分支)。已还原并复跑全绿。 - 已知边界(**未擅自处理**):`findByUserId` 带 `deleted_at IS NULL`,若将来引入软删路径, 已软删的行会让本方法走插入分支并撞主键。当前**不存在任何软删隐私设置的路径**,故不可达; 若将来新增软删,需同步改为 PG `ON CONFLICT (user_id) DO UPDATE`。 **② 上层(桩)已于 2026-09-19 接通** ✅ —— 产品裁定:**键集以小程序为准,用迁移补齐**。 - 新增迁移 **V047**:补 `allow_activity_invitation`(默认 TRUE)与 `show_last_online`(默认 **FALSE**)。 默认值**不对称**,与小程序 `data` 初值严格一致 —— `showLastOnline` 写成 TRUE 等于默认公开在线状态。 - 既有 6 列**保留不动**:`UserProfileService` 的资料详情 DTO 仍在全量返回这 6 键(第 74-79 行), 那是仍在生效的独立读取出口。本迁移只做加法。 - `AccountLifecycleService` 两个桩改为委托 `PrivacyService` 真实读写。 - 入参校验设计:**已知键类型错误必须显式报错**(`IllegalArgumentException`), 未知键宽容忽略 —— 若静默丢弃,用户会看到「保存成功」而设置从未生效,这类缺陷最难排查。 另容错 Boolean 的字符串形式("true"/"false")。 - 守卫:`PrivacySettingsUpsertGuardTest` 扩至 **8 用例**。其中 2 个是**契约守卫**, **直接解析小程序源文件**的 PUT payload 与 `data` 块得出权威键集/默认值, 而非抄一份契约副本(沿用 `EntityMigrationParityTest` 的纪律)。 - 负向注入:把桩退回「不落库」→ **恰 2 红**(落库用例 + 类型校验用例), 其余 6 个正确未红。已还原。 - 至此小程序隐私设置**真正可保存**,① ② 两层均已关闭。 -
注销红娘服务拦截硬编码
0→ 2026-09-19 已接线清偿(守卫MatchmakerWithdrawBlockerGuardTest4 用例): 原文断言「数据模型没有matchmaker_services表(全仓搜索为空),建表属扩权,需产品先确认」—— 三条均与实况不符,2026-09-19 实测推翻: ① 表存在,由V009__add_matchmaker_tables.sql创建(chk_matchmaker_status白名单:submitted / assigned / in_service / waiting_user / completed / cancelled); ②MatchmakerServiceMapper、MatchmakerServiceRecord、MatchmakerServiceService均已存在; ③ 因此不需要扩权、不需要产品裁定,属可直接实现的接线缺陷。 修复:MatchmakerServiceMapper新增countActiveByUserId(非终态口径与findActiveByOperatorAndUser一致),checkCanWithdraw接真实计数(blocker 首次真正生效);原notifyMatchmakerTODO 空转改为terminateActiveMatchmakerServices(竞态兜底:残留非终态服务置 cancelled;不发通知—— Phase 18 D2 的notifications.user_id→users(id)FK 教训)。 另发现历史遗留:countInProgressByOperator用了白名单外的'processing'(已登记,另行处置)。 (原文「全仓搜索为空」很可能是只在db/migration/内搜了表名的另一种写法 —— 该表在demo-seed-*.sql与V009中都有;凡「全仓无 X」这类断言必须给出搜索命令与范围, 否则无法复核,本项目已多次因此产生失实结论。) -
数据保留流程 → 2026-09-19 已落地(产品同日确认:注销后数据保留不得低于 36 个月): -
V049__add_account_retention.sql:users.closed_at列(此前注销时间只存在于日志,保留期无数据基础)+account.retention.purge权限(仅授 super_admin,处置不可逆不批量授权)。 -AccountConfig.dataRetentionMonths(默认 36,yml 可覆盖)—— 配置化纪律(AGENTS.md #12), 读取点为DataRetentionService,不留死配置。 -DataRetentionService(「配置 + 人工审核」模式,不引入自动定时删除,符合 Phase 21 既定要求):listDueAccounts()列保留期满的注销账号;purgeExpired()人工审核后执行匿名化 (openid 置purged-<userId>断连微信身份、phone 清空、资料匿名化、照片/佐证软删、审计留痕、幂等)。 保留期内的账号服务端拒绝处置(下限不可提前)。 -AccountRetentionOperatorController(/operator/account/retention/due+/{userId}/purge)。 - 政策同步:docs/legal/privacy-policy.md§3.3 与docs/security/PIA.md§8 原文「30 天内删除」 与本决策冲突,已按用户最新明确指令修订为「保留 36 个月(下限),到期人工审核匿名化」。 - 如实登记的边界:佐证/照片的对象存储文件本体不在此处删除(需存储客户端逐文件凭证), 由对象存储生命周期规则兜底。
Task 3.2: Phase 22 遗留(死配置)
counselingEnabled/premaritalEnabled/contentEnabled/serviceLocationEnabled/localEligibilityEnabled五个开关从未被业务读取 → 2026-09-19 已移除(产品裁定:确认无影响即可移除)。 - 移除前的无影响论证(不是"看着没人用就删"):全仓搜索 5 个 getter,命中的只有 ① 本类定义处、②getAllFlags()、③FeatureFlagControllerTest一处断言 —— 业务代码零调用点; 前端admin-web/src与miniapp对feature-flag零调用(仅一个未使用的 storageKey 常量)。 - ⚠️ 重要边界:FeatureFlagConfig整体不能删 —— 同类的isAppealEnabled(AppealService 4 处)、isLocalEvidenceUploadEnabled(LocalEligibilityService)、getLocalEvidenceMaxFiles/getLocalEvidenceMaxFileSizeMB/getLocalEvidenceAllowedTypes(PhotoService)都是真实生效的 gate。本次只删那 5 个死的。 - 同步清理:application-dev.yml/application-docker.yml各 7 项配置 (含 counselingAttachments 2 项),否则会留下"配了但不生效"的新死配置。 -getAllFlags()键数 21 → 14;FeatureFlagControllerTest相应更新, 布尔样例由counselingEnabled换成referralEnabled。counselingAttachmentsPrivate/Bucket私有桶死配置(存储实走object-storage.bucket-private)→ 同上,已移除(除测试外零引用)。PUT /feature-flags不持久化 → 2026-09-19 已做成真持久化(用户裁定:做成真持久化)。 - 缺陷性质:updateFlag只改FeatureFlagConfig这个内存单例的字段 —— 重启即回默认值、多实例互不一致。表现为「后台显示已关闭,一次发布后功能又回来了」。 - 改动: -V048__persist_feature_flag_overrides.sql新建feature_flag_overrides(flag_key主键;值一律存字符串,回读按字段声明类型解析;updated_by记审计)。 ⚠️updated_by必须建 uuid(不是 varchar):全局UUIDStringTypeHandler以 uuid 类型发参数,建成 varchar 会在 PG 上抛operator does not exist: character varying = uuid(同 V046 修的actor_id)。 -FeatureFlagOverride/Mapper/Service(upsert 走「先 UPDATE、0 行再 INSERT」, 不用 PG 的ON CONFLICT—— H2 测试库不认,同IdempotencyMapper的取舍)。 -FeatureFlagConfig.reloadFromPersistence()挂@PostConstruct:该时机晚于@ConfigurationProperties绑定,所以 DB 覆盖压得住 yml 默认值(反过来就会被静默盖掉)。 -PUT顺序刻意是「先改内存、再落库」:内存改动失败直接 400,不会写进任何一行。 - 新增DELETE /{flagName}恢复默认 —— 没有它,一次写错的覆盖只能手工改库撤销。 - 取值优先级:数据库覆盖 > yml/环境变量 > Java 字段默认值。 - 已知边界(如实记录,未扩权):① 数据库里写坏的覆盖值启动即 fail-fast (IllegalStateException),不静默回退默认 —— 否则表现为「配了不生效」; 修法是删掉那一行。② 多实例:其他实例的内存值不会自动刷新, 需各自重启(本项目为模块化单体、单 backend 部署,未引入配置中心)。 - 守卫:新增FeatureFlagPersistenceGuardTest11 例,核心不变量是 「另起一个配置实例重新加载后改动仍在」(= 重启等价路径); 另有自检用例断言@PostConstruct未被摘掉 —— 否则钩子丢失时其余用例仍全绿。 - 负向验证:注入 ①控制器不落库 ②摘掉启动钩子 → 恰好 5 例变红 (updateIsPersistedAndSurvivesReload/stringAndIntFlagsPersist/repeatedUpdateKeepsSingleRow/typeMismatchIsRejectedWithoutChangingPersistedValue/startupLoadHookIsDeclared),FeatureFlagControllerTest7 例不受影响; 还原后 md5 逐字节一致、NEG-VAL残留 0。
Task 3.3: 任务级 ⏳ 清偿 ✅(2026-09-19 全部关闭)
- P16-T13:互选超时接线 → 缺陷本体:
expires_at全仓无人写入(48h 配置在、confirm 过滤在,唯独创建不赋值 → 过滤恒真)。修复:创建时写expires_at= 活动结束时间 +ActivityConfig.mutualDeadlineHours(已确认口径「截止后48小时」),缺失回退创建时刻。守卫 2 用例,负向验证(摘掉写入)恰 2 红。 - P13A-T31:随访审计 →
getDetailForCounselor接线recordSensitiveAccess(counseling_followup);本人/manager 摘要视图不审计。守卫含两个不审计分支断言。 - P13A-T33:
RiskEvent source=counseling来源扩展 →detectCounselingRisk此前只扫申请摘要;addFollowup正文接入同一检测(INV-013 加固:不记正文只记级别)。守卫含正常内容不误报断言。 守卫:CounselingFollowupAuditAndRiskGuardTest4 用例(真实落库回查),全绿。 - P04-T10:Onboarding 本地资格步骤 → 复核确认早已实现(首页 onboarding 消息/动作覆盖
not_declared/needs_evidence并路由本地资格页,eligStatus取自/me/status.localEligibilityStatus);JIRA 行滞后已回写 ✅;同轮清除语义错位的死状态isVerified(miniapp tscEXIT=0)。
批 4 — UAT 就绪(需用户协作)
Task 4.1: 管理端桌面端目视复验
- 用户按 1440px 基准检查管理端(
/admin/子路径部署)各页面;覆盖 Loading / Skeleton / Empty / Error / Permission / Disabled / Responsive 状态。 - 小程序按 375px 基准同上(首批 11 页已于 2026-09-16 验收,此处为增补页面)。
Task 4.2: PENDING_DECISIONS 13 项决策
- 产品确认
docs/PENDING_DECISIONS.md中 13 项待确认(人脸核验、本地资格保留期限、咨询资质、服务站开放时间、Banner 最大数量、推荐阈值等)。 - 复核新增的「第三方开放遗留」7 项(凭据机制 / 限流阈值 / OpenAPI 发布 /
POST /activities/{id}/interests语义 / 审计配额 / 脱敏边界 / 幂等 TTL 分级)。
Task 4.3: V1.1 分角色 UAT 用例编写 ✅(2026-09-20 完成)
- 按 9 个角色(用户 / Reviewer / Matchmaker / Counselor / Counseling Manager / Service Station Operator / Content Editor / Activity Manager / Risk Operator)编写用例,替代 V1.0 旧用例 →
tests/uat/UAT-Test-Cases-V1.1.md(46 用例,旧文件已加取代头注)。 - 覆盖线上主路径、线下扫码、咨询、辅导、服务站、活动二阶段确认、反诈举报七条路径。
- 与
tests/uat/run-uat.sh(管理端可达性实测 + 备份检查)串联 —— 脚本内引用的用例清单需指向 V1.1 版,留待 UAT 启动前对齐(不阻塞)。
Task 4.4: Phase 24 启动确认
- 用户确认 Phase 24 启动;确认前不得自动进入。
批 5 — Phase 25 / 26 前置登记
- 运营素材清单(Phase 25):真实 Banner、服务站信息、咨询、课堂、反诈、关于我们、组织信息 —— 不 Seed 假用户 / 假 Match。
- 文案审核:隐私政策、用户协议、敏感文案、「非官方婚姻状态核验」提示。
- 上线前 13 项 smoke(Phase 26):Home / Quick Entries / Service Station / Counseling / Content / Activity Mutual / Chat / Block 等。
- RC 冻结:镜像、数据库版本、配置清单、第三方依赖、Rollback 目标。
验收与提交纪律
- 批 1~3 每批完成后:
ui-tests/*.mjs18/18 绿(数量以执行器MIN_GUARDS为准)+ui-document-baseline-contracts.mjs通过 + 后端mvn -o test0 failures +pytest tests/e2e0 failed。 - 批 2 的端到端前置已满足(2026-09-19 本机
pytest tests/e2e→ 65 passed);仍缺的是 E2E 进 CI 的首次实测绿(Task 2.3 Step 3)—— 不阻塞 UAT 启动,属回归防线缺口。 - 每批独立 commit;
git tag用单段名;git bundle create覆盖最新提交并回读验证。 docs/STATUS.md/docs/JIRA_BACKLOG.md/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md随批同步。- 不做:不修改 Recovery 报告与
releases/冻结快照;不触碰 bundle 自保产物。
附:2026-09-19 审阅校正与新发现
A. 校正(本文件中已被实测推翻的断言)
| 位置 | 原文 | 实测 |
|---|---|---|
| Task 3.1 第 2 项 | matchmaker_services 表不存在、建表属扩权、需产品确认 |
表由 V009 创建;mapper/entity/service 齐备;无需扩权,可直接接线 |
| Global Constraints / 验收纪律 / 当前停点 | UI 契约门禁 14 个、14/14 绿 | 18 个(2026-09-19 相继新增 4 项守卫),基线 18/18 |
| Task 2.1 | tests/e2e/** 现为占位文件 |
仅 2 个文件是占位;其余在 Phase 23B 已是真实用例 |
| 当前停点 | 本机活栈 E2E 49 passed |
基线 49,Task 2.1 完成后 65 passed |
| Global Constraints | 后端改动跑离线容器 mvn -o test |
原文无误;但「离线容器」的完整命令有两处必挂,否则会导出错误结论:-v danyang-m2-cache:/root/.m2 + 挂仓库根并 -w <repo>/backend(附 B-9) |
| 本文档自身(本轮中途两次误判) | 先写「-o 不可用」,后写「35 failures + 54 errors 是回归」 |
两条均误:前者是漏挂 m2 卷(内置仓库 132MB 缺 surefire-junit-platform),后者是只挂 backend/ 致相对路径失效。正确命令下全量 678 / 0 / 0 / 3 全绿(附 B-9) |
| Task 2.2 Step 3 | 「改 Java 代码需重建镜像,注入成本极高」 | 镜像 danyang-match-backend-test 内含 JDK 21 + Maven + 依赖缓存,挂载源码即可跑 surefire,一轮注入≈3.5 分钟(附 B-9) |
| 附 B-11(上一轮自身更正) | 「表无 id 列 → insert 必崩」 |
更正方向正确且本轮已实测确认:V037 已补齐 id/deleted_at/权重列,真实缺陷是「对已存在记录也走 insert」→ 第二次更新撞 user_id 主键。负向注入复现的正是 DuplicateKeyException(附 B-13) |
B. 新发现(不在本计划内,需另行处置)
-
POST /operator/organizations必然 500 —— 根因是 status 取值,不是 id(High)OrganizationService.createOrganization写org.setStatus(Constants.ACCOUNT_STATUS_NORMAL)即"normal",而organizations.type/status的 CHECK 白名单(V001)是chk_org_status: active / inactive→ CHECK 违约。2026-09-19 实测复核(本机活栈,
e2e_admin打POST /api/v1/operator/organizations,type取合法值charity_center):HTTP 500 {"success":false,"code":"SERVER_ERROR","message":"服务端内部错误,请联系管理员",...} 后端日志: Caused by: org.postgresql.util.PSQLException: ERROR: new row for relation "organizations" violates check constraint "chk_org_status"归因更正(本条初稿有误,如实留痕):初稿写作「从不给
id赋值 →invalid input syntax for type uuid: ""」。该归因错误:OrganizationMapper是纯BaseMapper(无自定义 insert),AutoFillHandler.insertFill会在id == null时补 UUID, 故 id 不会为空。真实根因是 status 取值不在白名单。教训同附 A —— 代码级推断必须实测复核, 不能凭「读了实现」就下结论。仍成立的次要问题:该接口未校验
type,非白名单值会撞chk_org_type(women_federation / trade_union / charity_center / volunteer_team / civil_affairs_partner / other)。 影响:运营端无法新建机构 → 无机构则无法新建活动(activities.organizer_org_idNOT NULL)。✅ 2026-09-21 已修复(处置留痕)
- 状态取值:
Constants新增ORG_STATUS_ACTIVE/ORG_STATUS_INACTIVE(独立于users.account_status的ACCOUNT_STATUS_*);createOrganization/disableOrganization/enableOrganization三处改为active/inactive。 - type 校验:新增
OrganizationService.ALLOWED_TYPES前置校验,非法值返回VALIDATION_ERROR(400),不再撞chk_org_type变 500。 - 附带修正:「机构不存在」由裸
RuntimeException(→500 SERVER_ERROR)改为NOT_FOUND(404);异常类型统一为项目实际处理的GlobalExceptionHandler.BusinessException(独立类shared.exception.BusinessException无 handler,抛它仍会 500)。 - 测试库盲区已补(缺陷长期隐身的第三重原因):
schema-h2.sql的organizations此前没有复刻chk_org_type/chk_org_status—— 已补齐两条 CHECK 及 NOT NULL / DEFAULT,H2 上写'normal'现在会真的失败。 - 新增守卫:
OrganizationConstraintParityTest(生产 DDL ↔ 测试库 ↔ 后端白名单 ↔ Constants 四方比对 + 解析器自检)、OrganizationServiceTest(走真实 DB, 9 个用例覆盖创建/停用/启用/往返/type 合法与非法/名称空白/404 语义)。 - ✅ 2026-09-22 补跑验证完成(此前记为「待验证」,现关闭):
① 全量回归 757 / 0 / 0 / 3,BUILD SUCCESS(108 个测试类,6:44;基线 741 + 新增 16 = 757);
② 负向注入可红:把
ORG_STATUS_ACTIVE改回"normal"→ 16 例变 3 失败 + 7 错误, 守卫三条断言(后端白名单 vs DDL / Constants vs 白名单 / 机构状态 vs 账号状态不相交)各自命中, 服务侧 7 例由 H2CHK_ORG_STATUS拦下。注入用仓库只读挂载 + 容器内副本改写, 工作区哈希前后一致,源码零污染(脚本.workbuddy/negative-injection.sh); ③ 活栈实测创建/停用/启用全部 2xx、非法 type 与缺字段 400、不存在 404、零 5xx(15/15); ④ 对已部署 Docker 栈pytest tests/e2e→ 65 passed。 - 2026-09-22 附带清偿同族缺陷:创建机构审计缺操作人。活栈验收复核
audit_logs发现CREATE_ORGANIZATION的operator_id7/7 为 NULL(而DISABLE/ENABLE_ORGANIZATION都正确记录了操作人)—— 控制器持有@AuthenticationPrincipal却未向 Service 透传, 审计不可归属等于无效审计。已改为createOrganization(params, principal.id()), 并新增断言createOrganizationRecordsOperatorInAuditLog长期守卫。 修后复验:新写入的 7 条 CREATE 审计全部带operator_id,旧 14 条仍为 NULL(前后对照清晰)。 - ⚠️ 仍未处置(不在本轮范围):
AnalyticsService一处误用独立类shared.exception.BusinessException(该类无@ExceptionHandler,抛出会退化为 500)。
- 状态取值:
-
互选记录永不过期(Medium,即批 3 Task 3.3 的 P16-T13)
createMutualConsent从不写expires_at(列无默认值),而confirmSideIfPending的过期闸门expires_at IS NULL OR expires_at > NOW()对 NULL 恒为真 → 配置项default-mutual-deadline-hours: 48与 V041 的部分索引idx_mutual_consents_expiry均为空转。E2E 的超时用例因此只能靠回拨库内值触发。 -
用户侧取消预约不校验状态(Medium,需产品裁定)
ServiceAppointmentService.cancelAppointment只校验归属,已确认/已完成的预约 都能被本人置为cancelled;而运营侧cancelAppointmentByOperator明确拒绝completed/cancelled。两侧口径不一致。原空壳用例名test_cancel_after_confirmation_denied无任何规格支撑(需求基线只写了归属隔离), 故未被写成断言 —— 待产品确认后再定。 -
E2E 环境缺前置数据(Low,已由本轮 seed 解决) 全新库
organizations/activities/service_locations均为空表—— 无任何迁移种子。已补tests/e2e/sql/seed_e2e_activity.sql、tests/e2e/sql/seed_e2e_service_station.sql(幂等,本机与 CI 通用)。 -
运营确认受数据范围约束,super_admin 不豁免(设计事实,易被误判为缺陷)
assertOperatorScope→canAccessByOperator:sa.assigned_operator_id = :op OR EXISTS(service_location_operators 关联该站点 × :op)。 实测e2e_admin(super_admin)确认他站预约返回 403 —— 这是预期行为, 不是权限配置错误。E2E 因此必须用站点关联的运营账号。 -
本机 E2E 复跑会被登录锁定拦住(Low,环境事项) Redis 键
auth:operator_login_lock:<operatorId>(TTL 15 分钟)由失败登录累积触发, 表现为429 账号已锁定,请 N 分钟后重试。清键即可。CI 每轮全新 Redis 不受影响。 -
e2e_*账号口令与 CI 不一致(Low,环境事项)seed_e2e_operators.sql用ON CONFLICT (username) DO NOTHING,重跑 seed 不覆盖旧口令; 本机活栈若复用旧卷,口令会停在上一轮的值 → 大量 401。 本机已用UPDATE operators SET password_hash = crypt('<CI 口令>', gen_salt('bf',8)) WHERE username LIKE 'e2e\_%'对齐到 CI 口令。(这与 Task 2.3 顺带修的 CI 缺陷 #5 是同一根因的两面。) -
H2 测试库缺
sensitive_access_logs表(Medium,Task 2.2 顺手修复) 生产有该表(V001 建表 + V034 加access_target/access_result+ V035 补updated_at/deleted_at), 而backend/src/test/resources/schema-h2.sql完全没有它。而ReviewService.logEvidenceAccess在成功与拒绝两条路径上都必然写它 → 后果是:INV-011 的「访问留痕」命题在测试期根本无法断言, 只能退化成常量断言(这正是 Task 2.2 之前的状态)。已按三份迁移补齐列定义(不建 FK, 因测试库无operators表;与review_tasks/local_eligibility_evidences同风格)。 与 schema-h2.sql 中已记录的 Phase 12/15/19 系列「表/约束漂移盲区」同族。 -
本机跑 Java 测试的通道 —— 含两处「必挂」,漏掉任一处都会导出完全错误的结论(工具性发现,可复用) 本机无 JDK / 无 Maven / 无
~/.m2(mvn报Could not find Maven installation), 测试镜像danyang-match-backend-test内含 Temurin JDK 21 + Maven 3.9.16 + 完整源码。 正确命令(2026-09-19 实测BUILD SUCCESS,全量 700 / 0 failures / 0 errors / 3 skipped,约 6 分钟):MSYS_NO_PATHCONV=1 docker run --rm \ -v "E:/develop/公益婚恋服务平台":/proj \ -v danyang-m2-cache:/root/.m2 \ -w /proj/backend --memory=3g -e MAVEN_OPTS=-Xmx256m \ danyang-match-backend-test:latest \ mvn -B -o test -Dsurefire.forkCount=1 -DreuseForks=true单类跑法:把
test换成test -Dtest=<类名> -DfailIfNoTests=false,约 3.5 分钟。漏挂项 症状 会被误判成 不挂 -v danyang-m2-cache:/root/.m2-o报could not be resolved ... in offline mode(镜像内置/root/.m2仅 132MB,缺surefire-junit-platform;命名卷 251MB 含 3.1.2)「本机不能离线跑测试」 只挂 backend/而非仓库根MigrationValidationTest/AnalyticsPermissionGrantTest/SecurityPathCoverageTest/PrincipalInjectionContractTest等集体NoSuchFile(它们用相对仓库根的路径读文件,如backend/src/main/resources/db/migration)「35 failures + 54 errors 的真实回归」 本人在本轮两条都踩了:先据第一次失败把「
-o不可用」写进 Global Constraints, 又据第二次失败把「35 failures 全绿不了」当成回归;两次都被「换个挂载方式重跑」推翻。 教训:测试失败时先怀疑命令与挂载,再怀疑代码 —— 尤其当失败清一色是NoSuchFile/could not be resolved这类基础设施型错误时。 (同理作废的还有一条中途结论:「全量跑 1 小时卡住」——那是漏挂 m2 卷 + 未加-Dsurefire.forkCount=1 -DreuseForks=true的错误命令所致,正确命令 6 分钟跑完。)- 重要:这使 Task 3.x 一类「改 Java 代码」的验证不再需要重建后端镜像 —— 原 Task 2.2 Step 3 标注的「改 Java 需重建镜像、成本极高」不再成立,负向注入可直接做。
backend/target/会被容器写回宿主机(该目录已 gitignore,无碍)。
-
5 个 Mapper 方法「声明了却无实现」(High,未实测触发,但判定依据可复核)
Backend的 mapper 统一用注解式 SQL(如ActivityCapacityGroupMapper的@Select/@Update)。 以下方法既无 SQL 注解、也无 XML:
| Mapper 方法 | 调用点 | 后果 |
|---|---|---|
ReviewReasonMapper.findAllEnabled |
ReviewService:92,143 |
审核原因选项接口必崩 |
UserPreferenceMapper.findByUserId |
MatchEligibilityService:59,107;RecommendationEngineService:140,141,216,219 |
推荐打分 / 匹配资格判定必崩 |
UserPrivacySettingsMapper.findByUserId |
RecommendationEngineService:334;PrivacyService |
推荐 + 隐私设置读写必崩 |
ReviewItemMapper.findByReviewTaskId |
无 | 死方法 |
ReviewReasonMapper.findByCategory |
无 | 死方法 |
判定依据(三条均已复核):① 全仓无任何 mapper XML —— Glob backend/src/main/resources/**/*.xml 返回空;
② pom.xml 无自定义 ISqlInjector / MapperAnnotationBuilder —— MyBatis-Plus 只会为 BaseMapper
的 CRUD 注入语句;③ 对照类 ActivityCapacityGroupMapper 的同位置方法均带注解。
→ 上述方法在 MyBatis 解析期抛 BindingException: Invalid bound statement (not found)。
为什么此前没人发现:本机无候选用户、无审核数据,这些路径的前置条件为空
(例:getDailyRecommendations 先 isParticipantEligible 再查候选池,候选池为空就不会走到
打分环节)→ 测试环境用「数据为空」掩盖了「代码必崩」。生产有数据即触发。
未实测的原因:需要构造「有候选用户 / 有审核任务」的数据集才能从 HTTP 层触发。
建议的机械化验证(比人工审查强):新增一个守卫测试,用
sqlSessionFactory.getConfiguration().hasStatement(<MapperFqn>.<methodName>) 遍历全部
Mapper 接口的声明方法 —— 无绑定即失败。这样一类缺陷可被一次性、持续地挡住。
- 隐私设置的「三方契约冲突」+ 表/实体主键错配(High —— 直接影响 Task 3.1 第 1 项的工作量评估)
原文把 Task 3.1 第 1 项描述为「接真实
user_privacy_settings」的接线工作。 实测发现它不是接线,而是需要契约裁定:
| 来源 | 字段集 |
|---|---|
| 生产表(V002) | visible_in_recommendations / visible_in_search / show_occupation / show_lifestyle_photos / receive_activity_recommendations / accept_matchmaker_recommendations |
小程序页 pages/privacy-settings |
visibleInRecommendations / allowActivityInvitation / showLastOnline |
AccountLifecycleService(桩) |
visibleInRecommendations / allowActivityInvitation / showLastOnline —— 与前端一致 |
PrivacyService(看似「真实实现」) |
与生产表一致的六个 —— 与前端仅 1 个交集 |
- 小程序的
allowActivityInvitation/showLastOnline在表里没有对应列; 表里的 5 列在小程序里没有 UI。三方只有visibleInRecommendations同时存在。 - 也就是说:桩是「照前端契约写的假数据」,而「真实实现」是「照表结构写的」 —— 两者根本没在同一个契约上。直接「接线」会立刻打断小程序页面。
- 另有表/实体错配(⚠️ 2026-09-19 二轮更正:初稿这条判断部分错误,已实测推翻):
初稿称「
user_privacy_settings无id/deleted_at列 → insert 会尝试写id→ 必然失败」。 该结论只看V002的初始建表,漏看了后续迁移。V037__align_entity_columns_with_migrations恰恰是为这类错配而写的,它已补齐:user_privacy_settings(+id,+deleted_at)、user_preferences(+id,+deleted_at,+11 个权重列)、review_reasons(+id,+created_at/updated_at/deleted_at)、review_items(+deleted_at), 且其注释明确点出根因是「AutoFillHandler.insertFill会 setValue("id", UUID),MP 随即把 id 写进 INSERT」。 故「id 列不存在」不成立,insert 不会因此崩。 - 但真实缺陷仍在:
user_privacy_settings.user_id是主键,而PrivacyService.updatePrivacySettings对已存在记录同样走insert(不是 upsert)→ 第二次更新必撞主键冲突。 这才是 Task 3.1 第 1 项真正要修的点。 - 方法论教训(可复用):判断「某表到底有哪些列」必须遍历全部迁移文件,
尤其是
V037这类专门"对齐实体与迁移"的迁移 —— 只看CREATE TABLE那一条会得出 与生产相反的结论。测试库 H2 schema 同理,应按最终态(V002 + V037)建模, 本轮已按此补建 4 张表。 - 附带:
user_privacy_settings/user_preferences/review_reasons/review_items四张表均不在 H2 测试库(同附 B-8 的表漂移盲区)—— 本轮已补齐(按 V037 之后的最终态)。 「缺表 + 语句未绑定」叠加,使相关代码路径在测试期双重不可达 —— 本轮已补齐表。
-
5 个 Mapper 方法「声明了却没有任何 SQL」(High,本轮已修复 + 已建持续守卫) 全仓无 mapper XML(
mapper-locations未配置)、无自定义ISqlInjector→ 自定义方法只能靠@Select/@Update/@Insert/@Delete提供 SQL。实测这 5 个方法只有声明:Mapper 方法 影响面 UserPrivacySettingsMapper.findByUserId隐私设置读取 UserPreferenceMapper.findByUserId推荐引擎 / 匹配资格共 6 处调用 ReviewReasonMapper.findAllEnabled审核原因字典下拉选项 ReviewReasonMapper.findByCategory同上 ReviewItemMapper.findByReviewTaskId审核项列表 调用必抛
Invalid bound statement,编译期完全无感知。 长期未被发现的原因:候选池为空(分支走不到)+ 测试库根本没有这 4 张表 → 双重不可达。- 已修:补
@Select,且均带AND deleted_at IS NULL(V037 之后这些表都有软删列, 只看 V002 建表会漏掉软删过滤,制造"读到已删数据"的新缺陷)。 - 已建持续守卫:
MapperBindingGuardTest—— 反射遍历 MyBatisMapperRegistry中所有 Mapper 的声明方法,configuration.hasStatement(...)为假即失败;另含 探测方式自检(虚构 statementId 必须为假,防断言静默永真)与 可执行性验证(插真实数据后真查一遍,证明"绑定存在"≠"能跑")。 - 守卫豁免规则:复合主键实体(无
@TableId,如ConversationMember、ActivityStaffAssignment)本就不会被 MP 注入selectById/updateById/deleteById等依赖主键的方法 —— 这是 MP 的正确行为,守卫按"实体有无主键"这一语义豁免 (不是按类名白名单,故新增复合主键表同样适用)。实测这两个 Mapper 在生产零调用点。 - 负向注入已验证:临时移除
ReviewItemMapper的@Select→ 绑定守卫报恰 1 项缺失,可执行性用例抛真实Invalid bound statement;还原后复跑全绿。 - ⚠️ 并发写者两次覆盖编辑(本轮实测):写入的 import 与用例修改在编辑完成后被回滚, 导致"改动看起来在、实际跑的是旧版"。教训:编辑后到跑测试之间必须重新核对文件内容, 且跑通后应尽快提交以缩短暴露窗口。
- 已修:补
-
隐私设置有两套实现,前端走的是桩那一套(High —— 决定了"修了有没有用")
路径 Service 状态 前端是否调用 /account/privacy-settings(GET/PUT)AccountLifecycleServiceTODO 桩:返回假数据 / update 只回 updated:true,不落库✅ 小程序在用 /me/privacy、/me/privacy-settings、/profile/privacyPrivacyService真实实现 ❌ 无人调用(管理端对隐私端点零调用) - 实测依据:
miniapp/pages/privacy-settings/privacy-settings.js:21,48调/account/privacy-settings;admin-web/src全仓搜privacy零命中。 - 因此用户当前遇到的真实故障是「改隐私设置根本不保存」(桩),而非「第二次更新 500」。 本轮修的 upsert 缺陷位于尚未被前端触达的路径 —— 必要但当前不可感知。
- 顺序关系:upsert 修复是 Task 3.1 ① 的必要前置 —— 桩一旦接到
PrivacyService, 立刻会撞上该缺陷。故先修它是对的。 - 推论:评估"某修复的价值"时,必须先确认前端实际调用哪条路径。
本项目同一业务存在多套并存端点(
/me/*与/account/*的历史兼容写法), 只看后端有真实实现 ≠ 用户能走到。 - 2026-09-19 已关闭:产品裁定「以小程序为准加迁移」→ V047 补 2 列、
AccountLifecycleService桩改为委托PrivacyService。小程序隐私设置真正可保存。 守卫里 2 个用例直接解析小程序源文件得出权威键集,防止再次漂移。
- 实测依据:
-
新增迁移必须同步更新
MigrationValidationTest.LATEST_MIGRATION(工具性发现,本轮实测踩到)本轮新增
V047后,全量回归立刻报红 1 项(不是我的测试,是既有守卫):MigrationValidationTest.testMigrationNamingConvention:58 版本号超出当前基线: V047__align_privacy_settings_with_miniapp.sql- 该守卫断言两件事:① 每个迁移版本号 ∈
[1, LATEST_MIGRATION]; ② 迁移文件总数 == LATEST_MIGRATION(即不允许缺号)。 - 常量在
MigrationValidationTest.java:25,当前已由 46 更新为 47。 - 这是有效守卫,不是障碍:它防止"新增迁移但忘记登记基线"或"文件多了没被发现"。 正确做法是更新常量,不要为了让测试变绿而改断言方式。
- 命名正则:
V\d{3}__[^_]+(?:_[^_]+)*\.sql—— 描述部分用下划线分词,不能有连续下划线。
- 该守卫断言两件事:① 每个迁移版本号 ∈
- 需要用户/产品三选一:以表为准改造前端 UI / 以小程序为准补迁移加列 / 加一层映射。 在此之前 Task 3.1 第 1 项不应动手。
- 附带:
user_privacy_settings/user_preferences/review_reasons/review_items四张表均不在 H2 测试库(同附 B-8 的表漂移盲区),即上表所有方法在测试期都无法被触达验证。
创建: 2026-09-18 — 承接 docs/文档治理报告-2026-09-18.md 与 STATUS.md 的两处悬空引用(D1)
审阅校正: 2026-09-19 — 按实测推翻 9 处失实断言(见附 A;其中 2 处是对本轮自查中自身结论的更正 ——
「-o 不可用」「35 failures 是回归」两条均系命令/挂载所致,已如实留痕),登记 11 项新发现(见附 B)
执行进展: 2026-09-19 — Task 2.1(E2E 16 场景)、Task 2.2(INV-011 守卫)均已完成并验证;
后端全量回归 700 / 0 failures / 0 errors / 3 skipped;UI 契约门禁 18/18 绿