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:
+6
@@ -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());
|
||||
|
||||
+15
@@ -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处理
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
-- V037__align_entity_columns_with_migrations.sql
|
||||
--
|
||||
-- 用途:消除「实体字段 vs 生产 DDL」的架构漂移(schema drift)。
|
||||
--
|
||||
-- 背景(Phase 12 复验发现,属与 Phase 08/09/10/11 同族的潜伏缺陷):
|
||||
-- 测试库 backend/src/test/resources/schema-h2.sql 是**手写**的最小 schema,长期比生产
|
||||
-- migrations 多出一批列。于是:
|
||||
-- · 实体(@TableName + extends BaseEntity)声明了这些列;
|
||||
-- · AutoFillHandler.insertFill 对 id / createdAt / updatedAt 做**强制填充**,
|
||||
-- MyBatis-Plus 默认 FieldStrategy=NOT_NULL → 这些字段**必然出现在 INSERT 语句**;
|
||||
-- · 真实 PostgreSQL 上列不存在 → 任何 insert/updateById 直接
|
||||
-- `ERROR: column "xxx" of relation "yyy" does not exist`;
|
||||
-- · H2 测试库有列 → 全量测试绿 → **假绿**。
|
||||
--
|
||||
-- 本迁移把生产 DDL 补齐到与实体一致,是「测试保真度三层」第一层(生产约束下沉)的
|
||||
-- 反向修复:**生产补齐而非测试删列** —— 因为业务代码确实在写这些列,删列会丢功能。
|
||||
--
|
||||
-- 分级(V037 全量覆盖):
|
||||
-- A 级(缺 id / created_at / updated_at → INSERT 必崩,生产功能 100% 不可用)
|
||||
-- consent_records · local_eligibility_evidences · match_rule_configs ·
|
||||
-- matchmaker_followups · matchmaker_recommendations · permissions ·
|
||||
-- review_reasons · risk_events · roles · user_preferences · user_privacy_settings
|
||||
-- B 级(缺 deleted_at / 业务列 → 读为 null、软删失效、Episode 永远为 null)
|
||||
-- activity_capacity_groups · activity_mutual_connection_consents ·
|
||||
-- activity_organizations · matches · matchmaker_services ·
|
||||
-- organization_relationships · review_items · review_tasks ·
|
||||
-- service_program_members · user_identities · user_local_eligibilities · user_relations
|
||||
--
|
||||
-- 幂等:全部使用 ADD COLUMN IF NOT EXISTS,可安全重复执行。
|
||||
|
||||
-- ============================================================================
|
||||
-- A 级:补齐 BaseEntity 公共列(id / created_at / updated_at / deleted_at)
|
||||
-- ============================================================================
|
||||
|
||||
-- roles:缺 updated_at / deleted_at(已有 created_at)
|
||||
ALTER TABLE roles
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- permissions:缺 created_at / updated_at / deleted_at(仅 id/code/name/module/description)
|
||||
ALTER TABLE permissions
|
||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- consent_records:缺 created_at / updated_at(业务时间列是 agreed_at)
|
||||
ALTER TABLE consent_records
|
||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- risk_events:缺 updated_at(已有 created_at / deleted_at)
|
||||
ALTER TABLE risk_events
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
|
||||
-- review_reasons:主键是 code(非 id),且缺 created_at / updated_at / deleted_at。
|
||||
-- 实体 ReviewReason extends BaseEntity → AutoFillHandler 强塞 id → 必须补 id 列以承接,
|
||||
-- 同时保留 code 为主键(业务以 code 引用)。id 上加唯一索引,保证与实体语义一致。
|
||||
ALTER TABLE review_reasons
|
||||
ADD COLUMN IF NOT EXISTS id UUID,
|
||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
UPDATE review_reasons SET id = gen_random_uuid() WHERE id IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_review_reasons_id ON review_reasons(id);
|
||||
|
||||
-- match_rule_configs:缺 updated_at / deleted_at(已有 created_at)
|
||||
ALTER TABLE match_rule_configs
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- matchmaker_followups:缺 updated_at / deleted_at
|
||||
ALTER TABLE matchmaker_followups
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- matchmaker_recommendations:缺 updated_at / deleted_at
|
||||
ALTER TABLE matchmaker_recommendations
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- ============================================================================
|
||||
-- A 级特例:主键为 user_id(无 id 列)的表
|
||||
-- 实体继承 BaseEntity → AutoFillHandler.insertFill 会 setValue("id", UUID),
|
||||
-- MyBatis-Plus 随即把 id 写进 INSERT 列清单 → 生产必崩。
|
||||
-- 补 id 列(可空 + 唯一索引)以承接实体语义,原 user_id 主键保持不变。
|
||||
-- ============================================================================
|
||||
|
||||
-- user_preferences:缺 id + 11 个匹配权重列 + deleted_at
|
||||
ALTER TABLE user_preferences
|
||||
ADD COLUMN IF NOT EXISTS id UUID,
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS age_weight DOUBLE PRECISION DEFAULT 0.15,
|
||||
ADD COLUMN IF NOT EXISTS region_weight DOUBLE PRECISION DEFAULT 0.20,
|
||||
ADD COLUMN IF NOT EXISTS marital_weight DOUBLE PRECISION DEFAULT 0.10,
|
||||
ADD COLUMN IF NOT EXISTS children_weight DOUBLE PRECISION DEFAULT 0.05,
|
||||
ADD COLUMN IF NOT EXISTS education_weight DOUBLE PRECISION DEFAULT 0.10,
|
||||
ADD COLUMN IF NOT EXISTS height_weight DOUBLE PRECISION DEFAULT 0.05,
|
||||
ADD COLUMN IF NOT EXISTS marriage_plan_weight DOUBLE PRECISION DEFAULT 0.15,
|
||||
ADD COLUMN IF NOT EXISTS lifestyle_weight DOUBLE PRECISION DEFAULT 0.10,
|
||||
ADD COLUMN IF NOT EXISTS settlement_weight DOUBLE PRECISION DEFAULT 0.05,
|
||||
ADD COLUMN IF NOT EXISTS interest_weight DOUBLE PRECISION DEFAULT 0.10;
|
||||
UPDATE user_preferences SET id = gen_random_uuid() WHERE id IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_preferences_id ON user_preferences(id);
|
||||
|
||||
-- user_privacy_settings:缺 id
|
||||
ALTER TABLE user_privacy_settings
|
||||
ADD COLUMN IF NOT EXISTS id UUID,
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
UPDATE user_privacy_settings SET id = gen_random_uuid() WHERE id IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_privacy_settings_id ON user_privacy_settings(id);
|
||||
|
||||
-- ============================================================================
|
||||
-- B 级:补齐 deleted_at(软删语义)与业务列
|
||||
-- ============================================================================
|
||||
|
||||
-- matches:缺 episode / source_id / last_activity_at / unmatched_by / deleted_at
|
||||
-- episode —— AGENTS.md #20:Prior Unmatch 后重新 Match 时 +1,历史 Episode 可追踪
|
||||
-- source_id —— 来源关联 ID(activity_mutual 时为 activity_id + pair_id)
|
||||
-- last_activity_at / unmatched_by —— 最近互动时间 / unmatch 发起方
|
||||
ALTER TABLE matches
|
||||
ADD COLUMN IF NOT EXISTS source_id VARCHAR(64),
|
||||
ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS unmatched_by UUID,
|
||||
ADD COLUMN IF NOT EXISTS episode INT NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
-- 存量 Match 的 episode 补齐为 1
|
||||
UPDATE matches SET episode = 1 WHERE episode IS NULL;
|
||||
|
||||
-- local_eligibility_evidences:缺 file_id / updated_at / deleted_at
|
||||
ALTER TABLE local_eligibility_evidences
|
||||
ADD COLUMN IF NOT EXISTS file_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- service_program_members / user_local_eligibilities / user_relations:缺 deleted_at
|
||||
ALTER TABLE service_program_members ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE user_local_eligibilities ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE user_relations ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- user_identities:缺 deleted_at
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- organization_relationships:缺 deleted_at + 实体声明的 created_by
|
||||
ALTER TABLE organization_relationships
|
||||
ADD COLUMN IF NOT EXISTS created_by UUID,
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- 活动域:缺 deleted_at
|
||||
ALTER TABLE activity_capacity_groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE activity_mutual_connection_consents ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE activity_organizations ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- matchmaker_services:缺 deleted_at
|
||||
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- review_items / review_tasks:缺 deleted_at
|
||||
ALTER TABLE review_items ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE review_tasks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- ============================================================================
|
||||
-- 索引:软删过滤常用 deleted_at IS NULL,为高频表补索引
|
||||
-- ============================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_episode ON matches(user_a_id, user_b_id, episode);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_deleted ON matches(deleted_at) WHERE deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user