Files
danyang-match/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md
T
WorkBuddy 08b9cb2468 fix(p21-2e): 红娘服务拦截接线(blocker 首次生效 + 竞态兜底);2d 确认 Onboarding 本地资格已实现;文档回写
P16 遗留 2e(红娘服务拦截):
- checkCanWithdraw 硬编码 0 → 接 MatchmakerServiceMapper.countActiveByUserId(非终态口径
  submitted/assigned/in_service/waiting_user,与 findActiveByOperatorAndUser 一致),blocker 首次真正生效
- notifyMatchmaker TODO 空转 → terminateActiveMatchmakerServices 竞态兜底(残留非终态置 cancelled);
  不发通知:Phase 18 D2 的 notifications.user_id→users(id) FK 教训
- 守卫 MatchmakerWithdrawBlockerGuardTest 4 用例全绿;首跑暴露测试基建坑:@Transactional Service
  是 CGLIB 代理,ReflectionTestUtils 直调私有方法拿到的是代理实例的 null 字段 → 须先
  AopTestUtils.getTargetObject 解包(已在测试内注释说明)
- 另发现历史遗留:countInProgressByOperator 使用白名单外的 'processing'(已登记)

P04-T10:复核确认 Onboarding 本地资格步骤早已实现(onboarding 消息/动作 + 路由 + /me/status 字段),
JIRA ⏳ → ✅;清除首页语义错位的死状态 isVerified(miniapp tsc EXIT=0)

文档:JIRA 收尾行/计划文档 Task 3.1/3.3 勾选
2026-09-19 19:30:38 +08:00

698 lines
61 KiB
Markdown
Raw 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.
# 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:** 分五批推进 —— 批 1~3 **不需要用户决策**(可立即执行),批 4 需**用户协作**(目视复验 + 13 项决策),批 5 仅登记。批 1~3 每批完成后必须跑全部门禁;**批 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`(新增 job `ui-contracts`)
- Add: `scripts/run-ui-contracts.mjs`(统一执行器:逐个跑 → 汇总 → 任一失败即非零退出)
- Test: `ui-tests/*.mjs`(**实测 14 个**守卫文件;原文此处误写「15 个」,本轮更正)
**背景**:14 个 UI 契约门禁此前**只在本地手工执行**,CI 上无人跑 —— 文档改了、端口漂移了、`/admin/` 又退回 alias 了,CI 不会变红。这是「门禁存在但不生效」的同族问题(本项目已复发多次)。
- [x] **Step 1:** 确认 CI 现有 job 清单(`backend` / `admin-web` / `miniapp` / `migration` / `test-shell-guard` / `e2e`)与触发条件。
- [x] **Step 2:** 新增 `ui-contracts` job(`node-version: 20`,与既有 job 一致;跑 `node scripts/run-ui-contracts.mjs`),**任一守卫非 0 退出即 job 失败**。
- [x] **Step 3:** 负向验证:① 注入一个**必然失败的守卫**(放在 `.workbuddy/tmp/`、显式传参运行,不污染正式门禁)→ 执行器报 **FAIL、退出码 1** 并打印该守卫原始输出;② **守卫数量下限**分支以 `UI_GUARDS_MIN=99` 自检 → 正确拒绝报绿。
- [x] **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 已完成)
- [x] `docs/database/TABLE_INVENTORY.md` 由 **40 表**重写为实测 **59 表**(含来源迁移列)。
- [x] `docs/database/MIGRATION_GUIDE.md` 由 **V001~V011** 重写为实测 **V001~V046(46 个)**。
- [x] 两处均标注「**以 `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` |
- [x] **Step 1:** 逐文件实现场景(**先删除空 `pass`**,禁止保留空壳)。两文件共 16 个真实用例,`pass` 空壳为 0。
- [x] **Step 2:** 本地活栈跑通(需后端 + PostgreSQL + 种子数据 + `E2E_PASSWORD`)。
本机实测 `pytest tests/e2e` → **65 passed**(49 基线 + 16 新增)。新增两个幂等 seed 文件承载
「活动互选」与「服务站/站点-运营关联」的前置数据,本机与 CI 通用。
- [x] **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`。
- [x] **Step 1 / Step 2:** 写负向测试 + 真实落库断言 ✅(2026-09-19)
- [x] **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.io` Bad Gateway)且本地无 temurin 基础镜像,
> 故**未复现 `--build` 环节**;改用本地既有镜像挂载当日 16:24 构建的 jar,使**被验证的代码版本与 CI 一致**
> (否则是拿 09-09 的旧代码验 09-18 的用例,结论无效)。CI 上 `--build` 由 runner 完成。
- [x] **Step 1:** 确定活栈供给方式 → **Compose in CI**(理由见上)。
- [x] **Step 2:** 按决策实现 job:`compose config` 秒级快速失败 → `down -v` 保证全新库 → `build` → `up`
(显式含 minio)→ 就绪等待(90×5s)→ seed → `pytest tests/e2e -v` → teardown;并加 `timeout-minutes: 30`。
- [ ] **Step 3:** 实测一次完整的 CI 绿 —— **需在 GitHub Actions 上执行,本机无法进行**(本机无 Docker Hub 出口)。
**Acceptance:** CI 串起 迁移 → backend test → admin type-check/build → miniapp type-check → E2E,任一失败阻断。
**本轮顺带修掉的 6 个 CI 真实缺陷**:
1. `minio/minio:latest` 是**滚动 tag**(MinIO 已多次改动镜像内容,如移除内嵌 console)→ **pin** `RELEASE.2025-09-07T16-13-09Z`。否则 CI 拉到的镜像与本地验证时不同 → 不可复现的偶发失败。
2. `up` **未显式列出 minio**(靠 `depends_on` 隐式拉起),而 minio 的 healthcheck 正是 backend 能否启动的前置条件 → 显式列出,消除隐性依赖。
3. `up --build` 把「构建镜像」与「起栈」耦合,失败时日志混杂 → 拆为 `build` / `up` 两步。
4. 就绪窗口 5 分钟**偏紧**(首次构建后仍需 Spring 启动 + Flyway 46 个迁移)→ 放宽到 7.5 分钟。
5. **缺 `down -v` 前置清卷**:seed 用 `ON CONFLICT (username) DO NOTHING`,若卷为上一轮残留,账号密码会停留在上一轮口令 → 本轮登录 401,并**伪装成业务失败**。(本机复用旧库时已实际踩到:必须手工 `UPDATE` 密码才能跑。)CI 每次全新库不触发,但 job 被取消/超时残留时会触发 → 起栈前强制清卷。
6. 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 — 代码级遗留清偿
### Task 3.1: Phase 21 遗留
- [x] **隐私设置接口**:`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 个正确未红。已还原。
- 至此小程序隐私设置**真正可保存**,① ② 两层均已关闭。
- [x] **注销红娘服务拦截硬编码 `0`** → **2026-09-19 已接线清偿**(守卫 `MatchmakerWithdrawBlockerGuardTest` 4 用例):
原文断言「数据模型**没有 `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 首次真正生效);原 `notifyMatchmaker` TODO 空转改为
`terminateActiveMatchmakerServices`(竞态兜底:残留非终态服务置 cancelled;**不发通知**——
Phase 18 D2 的 `notifications.user_id→users(id)` FK 教训)。
另发现历史遗留:`countInProgressByOperator` 用了白名单外的 `'processing'`(已登记,另行处置)。
(原文「全仓搜索为空」很可能是只在 `db/migration/` 内搜了表名的另一种写法 —— 该表在
`demo-seed-*.sql` 与 `V009` 中都有;凡「全仓无 X」这类断言必须给出**搜索命令与范围**,
否则无法复核,本项目已多次因此产生失实结论。)
- [ ] **数据保留流程**:按 `docs/legal/privacy-policy.md` 与 `docs/security/PIA.md` 落实保留期限(**不得擅自永久保存或删除**)。
### Task 3.2: Phase 22 遗留(死配置)
- [x] `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`。
- [x] `counselingAttachmentsPrivate` / `Bucket` 私有桶死配置(存储实走 `object-storage.bucket-private`)→ **同上,已移除**(除测试外零引用)。
- [x] **`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 部署,未引入配置中心)。
- **守卫**:新增 `FeatureFlagPersistenceGuardTest` 11 例,核心不变量是
「另起一个配置实例重新加载后改动仍在」(= 重启等价路径);
另有自检用例断言 `@PostConstruct` 未被摘掉 —— 否则钩子丢失时其余用例仍全绿。
- **负向验证**:注入 ①控制器不落库 ②摘掉启动钩子 → **恰好 5 例变红**
(`updateIsPersistedAndSurvivesReload` / `stringAndIntFlagsPersist` /
`repeatedUpdateKeepsSingleRow` / `typeMismatchIsRejectedWithoutChangingPersistedValue` /
`startupLoadHookIsDeclared`),`FeatureFlagControllerTest` 7 例不受影响;
还原后 md5 逐字节一致、`NEG-VAL` 残留 0。
### Task 3.3: 任务级 ⏳ 清偿 ✅(2026-09-19 全部关闭)
- [x] **P16-T13**:互选超时接线 → **缺陷本体**:`expires_at` 全仓无人写入(48h 配置在、confirm 过滤在,唯独创建不赋值 → 过滤恒真)。修复:创建时写 `expires_at` = 活动结束时间 + `ActivityConfig.mutualDeadlineHours`(已确认口径「截止后48小时」),缺失回退创建时刻。守卫 2 用例,负向验证(摘掉写入)恰 2 红。
- [x] **P13A-T31**:随访审计 → `getDetailForCounselor` 接线 `recordSensitiveAccess(counseling_followup)`;本人/manager 摘要视图不审计。守卫含两个不审计分支断言。
- [x] **P13A-T33**:`RiskEvent source=counseling` 来源扩展 → `detectCounselingRisk` 此前只扫申请摘要;`addFollowup` 正文接入同一检测(INV-013 加固:不记正文只记级别)。守卫含正常内容不误报断言。
守卫:`CounselingFollowupAuditAndRiskGuardTest` 4 用例(真实落库回查),全绿。
- [x] **P04-T10**:Onboarding 本地资格步骤 → **复核确认早已实现**(首页 onboarding 消息/动作覆盖 `not_declared`/`needs_evidence` 并路由本地资格页,`eligStatus` 取自 `/me/status.localEligibilityStatus`);JIRA 行滞后已回写 ✅;同轮清除语义错位的死状态 `isVerified`(`miniapp tsc` EXIT=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 用例编写
- [ ] 按 **9 个角色**(用户 / Reviewer / Matchmaker / Counselor / Counseling Manager / Service Station Operator / Content Editor / Activity Manager / Risk Operator)编写用例,**替代 V1.0 旧用例**(`tests/uat/UAT-Test-Cases.md` 为旧版参考)。
- [ ] 覆盖线上主路径、线下扫码、咨询、辅导、服务站、活动二阶段确认、反诈举报七条路径。
- [ ] 与 `tests/uat/run-uat.sh`(管理端可达性实测 + 备份检查)串联。
### 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. 批 1~3 每批完成后:`ui-tests/*.mjs` **18/18 绿**(数量以执行器 `MIN_GUARDS` 为准)+ `ui-document-baseline-contracts.mjs` 通过 + 后端 `mvn -o test` 0 failures + `pytest tests/e2e` 0 failed。
2. 批 2 的**端到端前置已满足**(2026-09-19 本机 `pytest tests/e2e` → **65 passed**);仍缺的是 **E2E 进 CI 的首次实测绿**(Task 2.3 Step 3)—— 不阻塞 UAT 启动,属回归防线缺口。
3. 每批独立 commit;`git tag` 用**单段名**;`git bundle create` 覆盖最新提交并回读验证。
4. `docs/STATUS.md` / `docs/JIRA_BACKLOG.md` / `docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 随批同步。
5. **不做**:不修改 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. 新发现(不在本计划内,需另行处置)
1. **`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_id` NOT NULL)。
2. **互选记录永不过期**(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 的超时用例因此只能靠回拨库内值触发。
3. **用户侧取消预约不校验状态**(Medium,需产品裁定)
`ServiceAppointmentService.cancelAppointment` 只校验归属,**已确认/已完成**的预约
都能被本人置为 `cancelled`;而运营侧 `cancelAppointmentByOperator` 明确拒绝
`completed` / `cancelled`。两侧口径不一致。原空壳用例名
`test_cancel_after_confirmation_denied` 无任何规格支撑(需求基线只写了归属隔离),
故未被写成断言 —— 待产品确认后再定。
4. **E2E 环境缺前置数据**(Low,已由本轮 seed 解决)
全新库 `organizations` / `activities` / `service_locations` **均为空表**——
无任何迁移种子。已补 `tests/e2e/sql/seed_e2e_activity.sql`、
`tests/e2e/sql/seed_e2e_service_station.sql`(幂等,本机与 CI 通用)。
5. **运营确认受数据范围约束,super_admin 不豁免**(设计事实,易被误判为缺陷)
`assertOperatorScope` → `canAccessByOperator`:
`sa.assigned_operator_id = :op OR EXISTS(service_location_operators 关联该站点 × :op)`。
实测 `e2e_admin`(super_admin)确认他站预约返回 **403** —— 这是**预期行为**,
不是权限配置错误。E2E 因此必须用站点关联的运营账号。
6. **本机 E2E 复跑会被登录锁定拦住**(Low,环境事项)
Redis 键 `auth:operator_login_lock:<operatorId>`(TTL 15 分钟)由失败登录累积触发,
表现为 `429 账号已锁定,请 N 分钟后重试`。清键即可。CI 每轮全新 Redis 不受影响。
7. **`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 是同一根因的两面。)
8. **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 系列「表/约束漂移盲区」同族。
9. **本机跑 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 分钟):
```bash
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,无碍)。
10. **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 接口的声明方法 —— 无绑定即失败。这样一类缺陷可被一次性、持续地挡住。
11. **隐私设置的「三方契约冲突」+ 表/实体主键错配**(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 之后的最终态)。
「缺表 + 语句未绑定」叠加,使相关代码路径在测试期**双重不可达** —— 本轮已补齐表。
12. **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` —— 反射遍历 MyBatis `MapperRegistry` 中所有
Mapper 的声明方法,`configuration.hasStatement(...)` 为假即失败;另含
**探测方式自检**(虚构 statementId 必须为假,防断言静默永真)与
**可执行性验证**(插真实数据后真查一遍,证明"绑定存在"≠"能跑")。
- **守卫豁免规则**:复合主键实体(无 `@TableId`,如 `ConversationMember`、
`ActivityStaffAssignment`)本就不会被 MP 注入 `selectById`/`updateById`/`deleteById`
等依赖主键的方法 —— 这是 MP 的**正确行为**,守卫按"实体有无主键"这一**语义**豁免
(不是按类名白名单,故新增复合主键表同样适用)。实测这两个 Mapper 在生产**零调用点**。
- **负向注入已验证**:临时移除 `ReviewItemMapper` 的 `@Select` →
绑定守卫报**恰 1 项缺失**,可执行性用例抛真实 `Invalid bound statement`;还原后复跑全绿。
- ⚠️ **并发写者两次覆盖编辑**(本轮实测):写入的 import 与用例修改在编辑完成后被回滚,
导致"改动看起来在、实际跑的是旧版"。**教训:编辑后到跑测试之间必须重新核对文件内容**,
且跑通后应尽快提交以缩短暴露窗口。
13. **隐私设置有两套实现,前端走的是桩那一套**(High —— **决定了"修了有没有用"**)
| 路径 | Service | 状态 | 前端是否调用 |
|---|---|---|---|
| `/account/privacy-settings`(GET/PUT) | `AccountLifecycleService` | **TODO 桩**:返回假数据 / update 只回 `updated:true`,**不落库** | ✅ **小程序在用** |
| `/me/privacy`、`/me/privacy-settings`、`/profile/privacy` | `PrivacyService` | 真实实现 | ❌ 无人调用(管理端对隐私端点**零调用**) |
- 实测依据:`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 个用例直接解析小程序源文件得出权威键集,防止再次漂移。
14. **新增迁移必须同步更新 `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 绿*