# 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 AUTO_FILLED_COLUMNS = Set.of("id", "created_at", "updated_at"); private static final Set 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.**