Files
danyang-match/docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md
WorkBuddy c607e87b45 fix(relationship): Phase 12 复验 —— 修复实体↔生产迁移系统性漂移(生产故障族)/ 双路径 episode 与 NPE / 测试库缺表致首页零覆盖 / 计划项 2 未实现
KEEP-VERIFY 复验发现并修复 4 处真实缺陷:
- D1(Critical,生产故障·同一族系统性缺陷)实体与 db/migration 系统性漂移:23 表 50 列在生产
  迁移中不存在,而测试库 schema-h2.sql 手写补齐 → 全量绿=假绿。其中 11 表属"INSERT 必崩"级
  (缺 id/created_at/updated_at,被 AutoFillHandler 强制填充),含 matches.episode、user_preferences
  权重列、permissions/roles、consent_records、risk_events 等;真实 PG 上 Match 创建、择偶偏好保存、
  权限/角色写入、同意记录、风险事件全部失败。修复:新增 V037(幂等 ADD COLUMN IF NOT EXISTS
  + 存量 episode 回填)。
- D2(High)RelationshipService.createMatch 不写 episode/lastActivityAt,与 MatchService 双路径
  填充不一致;且 episode 为 null 时 m.getEpisode()+1 对 Integer 自动拆箱抛 NPE。修复:统一按
  AGENTS.md #20 推导 + null 兜底。
- D3(High)测试库缺 user_identities 等 7 表 → GET /api/v1/home 在测试环境 BadSqlGrammar,
  首页接口零回归覆盖。修复:schema-h2.sql 补齐 7 表。
- D4(Medium)Phase 12 计划项 2 实际未实现:getting_to_know 分支只产出 counseling。修复:
  HomeApplicationService 补 premarital 引导(与 counseling 并列)。

测试保真度:
- 新增永久守卫 EntityMigrationParityTest(5 用例):把"实体字段 ⊆ 生产迁移累积列"与"测试库覆盖
  关键业务表"固化为自动不变量,区分"必崩级/潜伏级";经两轮负向验证证明会变红(第一轮分级过窄
  已修正:episode 由"潜伏级"纠正为"必崩级")。
- 新增 RelationshipProgressPhase12Test(14 用例,含 8 线程并发确认)与
  HomePremaritalGuidancePhase12Test(5 用例)。
- 删除 2 个假测试类中的 assertTrue(true) 占位(AGENTS.md 明令禁止),改走真实服务方法验证。

Gate:单方确认不改变最终关系 ✅ / 双确认后才 successful ✅ / 历史 Episode 可追踪 ✅ /
新 Episode 不恢复旧聊天权限(conversations.match_id UNIQUE)✅ / 非成员不可代确认 ✅

全量回归:371 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(71 类,5m07s)
2026-09-16 23:01:13 +08:00

269 lines
14 KiB
Markdown
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.
# Phase 12 V1.1 Recovery Completion Report — Relationship Progress
> 日期:2026-09-16
> 阶段:Phase 12(Relationship Progress — KEEP-VERIFY/EXTEND)
> 分支:`main` | Tag:`phase12-recovery-2026-09-16`
> 结论:**CLOSED(Gate 全项通过)**,累计全量回归 **371 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**
---
## 一、计划目标与 Gate
计划出处:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 第 238-246 行。
| 计划项 | 要求 | 结论 |
|---|---|---|
| 计划项 1 | 验证成功关系双确认、恢复/退出、历史 Episode 和并发确认 | ✅ 通过(14 用例行为验证) |
| 计划项 2 | 补充 `getting_to_know` 阶段的婚前辅导入口引导 | ✅ **原未实现,本轮补实现** |
| 计划项 3 | 验证工作人员不能替用户确认成功关系(INV-005) | ✅ 通过(非成员路径断言) |
**Gate:** 单方确认不改变最终关系;历史 Episode 可追踪但不会恢复旧聊天权限。
| Gate 断言 | 证据 | 结果 |
|---|---|---|
| 单方确认不改变最终关系 | `singleConfirmDoesNotCompleteMatch`:A 单方确认后 Match 仍非 successful、success request 仍 pending | ✅ |
| 双确认后才 successful | `bothConfirmCompletesMatch`:A 确认 → B 确认 → Match = successful | ✅ |
| 历史 Episode 可追踪 | `reMatchIncrementsEpisode`:先 unmatch 后重建,episode 1 → 2 | ✅ |
| 新 Episode 不恢复旧聊天权限 | `reMatchDoesNotRestoreOldConversation`:新 Match 零会话;旧会话 `match_id` 仍指向旧 Match(`conversations.match_id` UNIQUE 约束) | ✅ |
| 工作人员不能代确认 | `nonMemberCannotConfirm`:非成员 userId 确认被业务异常拒绝 | ✅ |
---
## 二、复验方法论(KEEP-VERIFY / EXTEND)
沿用前四阶段已确立的复验纪律:**历史 ✅ 不可信**,「测试覆盖了函数」≠「测试覆盖了运行时路径」。逐条核对**运行时路径**与**生产约束**,发现真实缺陷则修复,并补齐测试保真度。
本轮新增第三层保真度要求并固化为守卫:
1. **生产 DDL 约束必须下沉测试库**(Phase 08~11 已建立);
2. **测试必须走真实方法路径**,不得传字面量绕过服务层;
3. **测试库 schema 必须与生产迁移对齐** ← **本轮新发现的最大盲区(见 D1)**。
---
## 三、缺陷清单(D1~D4)
### D1 — Critical(生产故障 · 同一族系统性缺陷):实体与生产迁移系统性漂移
**现象:** 23 张表、50 个列在 `backend/src/main/resources/db/migration/V*.sql` 中根本不存在,但实体(`@TableName`)声明了这些字段;同时测试库 `backend/src/test/resources/schema-h2.sql` 是**手写**最小 schema,长期比生产迁移**多出**这些列。
**后果:** H2 测试库有列 → 全量测试绿;真实 PostgreSQL 无列 → 运行时 `column "xxx" of relation "yyy" does not exist`。**全量测试绿 = 假绿。**
**分级(依据 MyBatis-Plus 字段策略):**
- MyBatis-Plus 默认 `FieldStrategy=NOT_NULL` → null 字段不进 SQL 列清单;
- `AutoFillHandler.insertFill` **强制填充** `id` / `createdAt` / `updatedAt` → 这些字段**必然出现在 INSERT**;
- 因此缺 `id`/`created_at`/`updated_at` 的表属**"INSERT 必崩"级**。
**"必崩级"(11 张表):**
| 表 | 缺失列 | 业务影响 |
|---|---|---|
| `matches` | `episode`、`source_id`、`last_activity_at`、`unmatched_by`、`deleted_at` | **Match 创建必崩**;episode 追踪不可用 |
| `user_preferences` | `id` + 11 个权重列 + `deleted_at` | **择偶偏好保存必崩** |
| `permissions`、`roles` | `created_at`/`updated_at`/`deleted_at` | 权限/角色写入必崩 |
| `consent_records` | `created_at`/`updated_at`/`deleted_at` | 同意记录必崩 |
| `risk_events`、`review_reasons` | `id`/`created_at`/`updated_at` | 风险事件/审核理由写入必崩 |
| `match_rule_configs`、`matchmaker_followups`、`matchmaker_recommendations` | `updated_at`/`deleted_at` | 红娘服务写入必崩 |
| `user_privacy_settings` | `id`/`deleted_at` | 隐私设置写入必崩 |
**"潜伏级"(其余 12 张表):** 缺 `deleted_at` 等,null 不进 SQL,但一旦写入非 null 值即崩。含 `local_eligibility_evidences`、`service_program_members`、`user_local_eligibilities`、`user_relations`、`user_identities`、`organization_relationships`、`activity_capacity_groups`、`activity_mutual_connection_consents`、`activity_organizations`、`matchmaker_services`、`review_items`、`review_tasks`。
**修复:** 新增 `backend/src/main/resources/db/migration/V037__align_entity_columns_with_migrations.sql`(165 行):
- 全部采用 `ADD COLUMN IF NOT EXISTS`(**幂等**,可重复执行);
- `review_reasons` / `user_preferences` / `user_privacy_settings` 等 PK 非 `id` 的表补 `id` 列 + 唯一索引;
- `matches.episode INT NOT NULL DEFAULT 1` + 存量数据回填 `UPDATE matches SET episode = 1 WHERE episode IS NULL`;
- 补两个部分索引:`idx_matches_episode`、`idx_matches_deleted`。
### D2 — High:双路径字段填充不一致 + Integer 自动拆箱 NPE
**现象:**
- `RelationshipService.createMatch` 创建 Match 时**不写** `episode` / `lastActivityAt`;
- `MatchService.createOrReuseMatch` 写了;
- 两条路径殊途同归到同一张表 → 字段填充不一致;
- 且 `MatchService` 中 `m.getEpisode() + 1` 在 `episode` 为 null 时对 `Integer` **自动拆箱抛 NPE**。
**修复:**
- 两条路径统一按 AGENTS.md #20 语义推导 episode:`findLatestMatch` → 若 prior 为 `unmatched` 则 `prior.episode + 1`,否则 1;
- 补 null 兜底:`(priorEpisode == null ? 1 : priorEpisode) + 1`;
- 两处均设 `lastActivityAt = Instant.now()`。
**涉及文件:** `relationship/service/RelationshipService.java`、`match/service/MatchService.java`
### D3 — High:测试库缺 7 张表,首页接口零回归覆盖
**现象:** 测试库 `schema-h2.sql` 缺 `user_identities` / `review_tasks` / `local_eligibility_evidences` / `home_banners` / `service_locations` / `content_articles` / `activities`。`GET /api/v1/home` 在测试环境直接抛 `BadSqlGrammar("Table USER_IDENTITIES not found")`。
**后果:** 首页接口在测试环境**根本无法执行** → 所谓"首页已通过"零回归覆盖。
**修复:** `backend/src/test/resources/schema-h2.sql` 补齐 7 张表(列对齐生产迁移)。
### D4 — Medium:Phase 12 计划项 2 实际未实现
**现象:** `HomeApplicationService.buildServiceRecommendations` 的 `getting_to_know` 分支只产出 `counseling`;`premarital`(婚前辅导)入口仅在 `matched_successfully` 才出现。
**修复:** `getting_to_know` 分支补 `premarital` 引导,与 `counseling` **并列**(不互相挤占):
```java
} else if (Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW.equals(status)) {
list.add(serviceRec("counseling", ...));
// Phase 12(计划项 2):getting_to_know 阶段补充婚前辅导入口引导。
list.add(serviceRec("premarital", "想更认真地了解彼此?",
"善缘丹阳公益婚前辅导帮助你们在决定前更好地了解彼此。",
"了解婚前辅导", "/pages/premarital-intro/premarital-intro"));
}
```
---
## 四、新增永久守卫:`EntityMigrationParityTest`
`backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java`(5 用例)
把「实体字段 ⊆ 生产迁移累积列」与「测试库覆盖关键业务表」固化为**编译后自动可验证的不变量**:
| 用例 | 作用 |
|---|---|
| `allEntityFieldsExistInMigrations()` | 文本级解析 `db/migration/V*.sql` 的 CREATE TABLE + ALTER ADD/DROP/RENAME COLUMN,对比 `entity/*.java` 的 `@TableName` 字段(含 camelToSnake、BaseEntity 继承列、`@TableField(exist=false)` 排除),区分**硬失败/软失败**,并标注**必崩级/潜伏级** |
| `parserMustActuallySeeColumns()` | 解析器自检(防止守卫因解析失效而永远为真) |
| `entityParserMustSeeBaseEntityColumns()` | 同上,验证 BaseEntity 继承列被识别 |
| `testSchemaCoversCriticalTables()` | 断言 `REQUIRED_IN_TEST_SCHEMA`(14 张关键表)都在 `schema-h2.sql` |
| `testSchemaMissingTablesAreWhitelisted()` | 生产表缺于测试库时必须登记在 `TEST_SCHEMA_OPTIONAL_TABLES`(33 项白名单) |
关键常量:
```java
private static final Set<String> AUTO_FILLED_COLUMNS = Set.of("id", "created_at", "updated_at");
private static final Set<String> ALWAYS_WRITTEN_COLUMNS = Set.of(
"matches.episode", "matches.last_activity_at", "user_preferences.deleted_at");
```
### 负向验证(守卫有效性证明)
**第一轮:** 注入缺陷(从 V037 移除 `matches.episode` 补齐)→ `allEntityFieldsExistInMigrations` + `parserMustActuallySeeColumns` **变红**,但分级误标为"潜伏级"。
**修正:** 新增 `ALWAYS_WRITTEN_COLUMNS`(业务代码每路径必 set 的列),`episode` 由"潜伏级"**纠正为"必崩级"**。
**第二轮:** 再次注入同一缺陷 → 守卫精准报出:
```
【必崩级】matches: 生产缺【必崩】列 [episode]
```
→ 守卫有效性得证。
---
## 五、新增行为测试
### `RelationshipProgressPhase12Test`(14 用例,`@SpringBootTest` + `@ActiveProfiles("test")`)
| 用例 | 验证点 |
|---|---|
| `singleConfirmDoesNotCompleteMatch` | Gate 前半:单方确认不改变最终关系 |
| `bothConfirmCompletesMatch` | 双确认 → successful |
| `successStopsRecommendations` | 成功后推荐停止 |
| `samePersonCannotConfirmTwice` | 同一人重复确认被拒 |
| `concurrentConfirmIsIdempotent` | 8 线程 CountDownLatch 并发确认 → Match 仅 successful 一次、success request 仅 1 条 |
| `reMatchIncrementsEpisode` | episode 1 → 2 |
| `reMatchDoesNotRestoreOldConversation` | 新 Match 零会话;旧会话仍属旧 Match |
| `declinedCannotRestart` | declined 不可重启 |
| `successfulCannotRestart` | successful 不可重启 |
| `pauseKeepsExistingMatch` | 暂停保留既有 Match |
| `resumeRestoresSearching` | 恢复回到 searching |
| `exitServiceWorks` | 退出服务可用 |
| `nonMemberCannotConfirm` | INV-005:工作人员代操作被拒 |
| `confirmedByIsAlwaysTheActor` | `confirmedBy` 恒为发起者 |
### `HomePremaritalGuidancePhase12Test`(5 用例)
| 用例 | 验证点 |
|---|---|
| `gettingToKnowProvidesPremaritalEntry` | 计划项 2:getting_to_know 提供婚前辅导入口 |
| `gettingToKnowKeepsCounseling` | 补 premarital 后 counseling 未被挤占 |
| `successfulKeepsPremaritalEntry` | successful 仍有婚前辅导入口 |
| `searchingHasNoPremarital` | searching 不应出现 |
| `allTargetsAreInternalPages` | 所有入口指向站内页 |
---
## 六、删除假测试
| 文件 | 问题 | 处理 |
|---|---|---|
| `relationship/RelationshipStatusServiceTest.java` | 含 2 条 `assertTrue(true); // 占位测试` | 删除占位;改为 4 个对抗生产 CHECK 约束漂移的断言(matchStatus/matchSource/successRequestStatus 与 V003 白名单一致、关系状态取值互异) |
| `relationship/RelationshipStatusServiceDoubleConfirmTest.java` | 含占位断言,对 INV-005 零覆盖 | 删除占位;改为口径守卫 + 注释指向 `RelationshipProgressPhase12Test` |
> AGENTS.md 明令禁止假测试。行为验证统一移至走真实服务方法的 `RelationshipProgressPhase12Test`。
---
## 七、测试结果
### 定向测试
| 测试类 | 结果 |
|---|---|
| `EntityMigrationParityTest` | 5/5 ✅(修白名单后单独复跑 5/5) |
| `MigrationValidationTest` | 12/12 ✅ |
| `HomePremaritalGuidancePhase12Test` | 5/5 ✅ |
| `RelationshipProgressPhase12Test` | 14/14 ✅ |
| `RelationshipStatusServiceDoubleConfirmTest` | 5/5 ✅ |
| `RelationshipStatusServiceTest` | 7/7 ✅ |
### 全量回归
```
371 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(71 个测试类,耗时 5m07s)
```
**计数对账:** Phase 11 基线 346 → 本轮 371,**净增 25**。
| 增量来源 | 用例数 |
|---|---|
| 新增 `RelationshipProgressPhase12Test` | +14 |
| 新增 `EntityMigrationParityTest` | +5 |
| 新增 `HomePremaritalGuidancePhase12Test` | +5 |
| 删除 `assertTrue(true)` 占位后为两个 relationship 测试类补齐的真实断言(原计数方式不同) | +1 |
| **合计** | **+25** |
> ⚠️ 规划阶段曾预估 358(按"仅新增 3 个测试类共 24 例"粗算),实测 **371**。以实测日志为准,`STATUS.md` / `JIRA_BACKLOG.md` 已同步修正。
---
## 八、变更文件清单
| 文件 | 类型 |
|---|---|
| `backend/src/main/resources/db/migration/V037__align_entity_columns_with_migrations.sql` | 新增(165 行) |
| `backend/src/test/java/.../deployment/EntityMigrationParityTest.java` | 新增 |
| `backend/src/test/java/.../relationship/RelationshipProgressPhase12Test.java` | 新增 |
| `backend/src/test/java/.../home/HomePremaritalGuidancePhase12Test.java` | 新增 |
| `backend/src/main/java/.../home/service/HomeApplicationService.java` | 修改(D4) |
| `backend/src/main/java/.../relationship/service/RelationshipService.java` | 修改(D2) |
| `backend/src/main/java/.../match/service/MatchService.java` | 修改(D2) |
| `backend/src/test/resources/schema-h2.sql` | 修改(D3,补 7 表) |
| `backend/src/test/java/.../deployment/MigrationValidationTest.java` | 修改(LATEST_MIGRATION 36→37) |
| `backend/src/test/java/.../relationship/RelationshipStatusServiceTest.java` | 重写(删假测试) |
| `backend/src/test/java/.../relationship/RelationshipStatusServiceDoubleConfirmTest.java` | 重写(删假测试) |
| `docs/STATUS.md`、`docs/JIRA_BACKLOG.md`、本报告 | 文档 |
---
## 九、遗留与后续
| 项 | 说明 |
|---|---|
| `schema-h2.sql` 手写维护风险 | 本轮已通过 `EntityMigrationParityTest` 守卫;长期建议改为由 Flyway 迁移生成测试 schema(或用 Testcontainers 直连生产迁移),消除手写漂移根源 |
| `TEST_SCHEMA_OPTIONAL_TABLES` 白名单 | 现有 33 项;后续新增表需同步登记,否则守卫变红(**这是设计意图**) |
| Phase 13(公益红娘转介 — EXTEND) | **未自动进入,等待用户确认** |
---
## 十、结论
Phase 12 三个计划项与 Gate 全项通过。**其中 D1 为跨阶段共性根因**——前四阶段(Phase 08~11)之所以能看到"测试绿而生产崩",正是因为测试库 schema 手写且与生产迁移漂移;本轮不仅修复了当前缺陷,还以 `EntityMigrationParityTest` 把这一族问题固化为自动守卫。
**PHASE 12 CLOSED.**