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)
This commit is contained in:
WorkBuddy
2026-09-16 23:01:13 +08:00
parent 52e11a6c65
commit c607e87b45
15 changed files with 1686 additions and 46 deletions
@@ -214,6 +214,12 @@ public class HomeApplicationService {
list.add(serviceRec("counseling", "刚开始认识,不知道聊什么?",
"这里有一些第一次聊天的小建议。",
"看看沟通建议", "/pages/counseling-intro/counseling-intro"));
// Phase 12(计划项 2):getting_to_know 阶段补充婚前辅导入口引导。
// 历史缺陷:该分支此前只给 counseling,婚前辅导仅在 matched_successfully 才出现,
// 导致「正在了解」阶段用户完全看不到婚前辅导入口。
list.add(serviceRec("premarital", "想更认真地了解彼此?",
"善缘丹阳公益婚前辅导帮助你们在决定前更好地了解彼此。",
"了解婚前辅导", "/pages/premarital-intro/premarital-intro"));
} else if (Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY.equals(status)) {
list.add(serviceRec("premarital", "正在认真考虑未来?",
"可以了解善缘丹阳公益婚前辅导。",
@@ -55,10 +55,16 @@ public final class MatchService {
}
// 规则 2 & 3:新建
// Phase 12 复验修复:prior.episode 可能为 null(历史数据 / 早期由 RelationshipService
// 创建的 Match 未写该列),直接 `getEpisode() + 1` 会对 null 自动拆箱抛 NPE。
Optional<Match> latest = matchMapper.findLatestBetween(userA, userB);
int episode = latest.map(m ->
Constants.MATCH_STATUS_UNMATCHED.equals(m.getStatus()) ? m.getEpisode() + 1 : 1
).orElse(1);
int episode = latest.map(m -> {
if (!Constants.MATCH_STATUS_UNMATCHED.equals(m.getStatus())) {
return 1;
}
Integer priorEpisode = m.getEpisode();
return (priorEpisode == null ? 1 : priorEpisode) + 1;
}).orElse(1);
Match m = new Match();
m.setId(UUID.randomUUID().toString());
@@ -206,12 +206,27 @@ public class RelationshipService {
throw new RuntimeException("匹配已存在");
}
// Phase 12 复验修复(D5):本方法此前不设置 episode / lastActivityAt,
// 而另一条创建路径 MatchService.createOrReuseMatch 会设置 —— 两条路径字段填充不一致:
// · episode 为 null → 后续 MatchService.createOrReuseMatch 里
// `m.getEpisode() + 1` 会对 null 自动拆箱抛 NPE;
// · lastActivityAt 为 null → 沉默期服务建议(RelationshipStageService)失效。
// 统一按 AGENTS.md #20 推导 episode:prior Unmatch → prior.episode + 1,否则 1。
int episode = 1;
Match prior = matchMapper.findLatestMatch(userId, targetUserId).orElse(null);
if (prior != null && Constants.MATCH_STATUS_UNMATCHED.equals(prior.getStatus())) {
Integer priorEpisode = prior.getEpisode();
episode = (priorEpisode == null ? 1 : priorEpisode) + 1;
}
Match match = new Match();
match.setUserAId(userA);
match.setUserBId(userB);
match.setStatus(Constants.MATCH_STATUS_ACTIVE);
match.setSource(source);
match.setMatchedAt(Instant.now());
match.setLastActivityAt(Instant.now());
match.setEpisode(episode);
matchMapper.insert(match);
// V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理