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;
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
package com.danyang.match.platform.deployment;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
import java.util.stream.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 架构漂移守卫(Phase 12 复验新增,永久生效)
|
||||
*
|
||||
* 背景:
|
||||
* 测试库 schema-h2.sql 是**手写**的最小 schema。Phase 08~12 反复发现同一族缺陷 ——
|
||||
* 实体(@TableName + extends BaseEntity)声明了字段、Mapper 用 SELECT * / insert ,
|
||||
* 但**生产 db/migration 里没有对应列**。真实 PostgreSQL 上 insert/updateById 直接
|
||||
* `column "xxx" of relation "yyy" does not exist`;而 H2 测试库有列 → 全量测试绿 → 假绿。
|
||||
*
|
||||
* 已修复的实例:matches.episode / user_preferences.id / permissions.created_at /
|
||||
* roles.updated_at / consent_records.created_at 等(V037,共 23 表 50 列)。
|
||||
*
|
||||
* 本守卫的作用:
|
||||
* 把「实体字段 ⊆ 生产迁移累积列」变成**编译后自动可验证的不变量**。
|
||||
* 任何人新增实体字段而忘记写迁移,此测试立即变红 —— 不必再靠人工审计。
|
||||
*
|
||||
* 判定规则(只覆盖会真实触发运行时错误的部分):
|
||||
* 1. 实体字段若不在「生产迁移累积列」中 → 失败。
|
||||
* 2. 但 MyBatis-Plus 默认 FieldStrategy=NOT_NULL:null 字段不进 SQL。
|
||||
* 因此额外区分「必进 SQL 的字段」(id/createdAt/updatedAt,被 AutoFillHandler 强制填充)
|
||||
* 与「可能不进 SQL 的字段」。两者都报,但只有前者是硬失败。
|
||||
* 3. 实体可用 @TableField(exist = false) 显式声明非持久化字段 → 不计入。
|
||||
*
|
||||
* 注意:本测试是**文本级**解析(不依赖 Spring 上下文),秒级完成,适合作为门禁。
|
||||
*/
|
||||
class EntityMigrationParityTest {
|
||||
|
||||
private static final Path MAIN = Paths.get("backend/src/main/java");
|
||||
private static final Path MIGRATION_DIR = Paths.get("backend/src/main/resources/db/migration");
|
||||
private static final Path TEST_SCHEMA = Paths.get("backend/src/test/resources/schema-h2.sql");
|
||||
|
||||
/**
|
||||
* 测试库「允许暂缺」的生产表白名单。
|
||||
*
|
||||
* 背景:schema-h2.sql 是手写最小 schema,并非从迁移生成,因此必然少于生产的表数量。
|
||||
* 但**缺表 = 该表相关路径完全不可测**(Phase 12 就踩过:缺 user_identities 导致
|
||||
* GET /home 在测试环境下直接 BadSqlGrammar,首页零回归覆盖)。
|
||||
*
|
||||
* 因此本白名单只保留「确实与本轮测试无关、且补齐成本高」的表;每补齐一张就把它从这里删掉。
|
||||
* 任何人新增链路若依赖白名单外的表,必须先补 schema-h2.sql。
|
||||
*/
|
||||
private static final Set<String> TEST_SCHEMA_OPTIONAL_TABLES = Set.of(
|
||||
"activity_capacity_groups",
|
||||
"activity_interests",
|
||||
"activity_mutual_connection_consents",
|
||||
"activity_organizations",
|
||||
"appeals",
|
||||
"consent_records",
|
||||
"content_categories",
|
||||
"counseling_followups",
|
||||
"interest_dictionary",
|
||||
"match_rule_configs",
|
||||
"matchmaker_followups",
|
||||
"matchmaker_recommendations",
|
||||
"matchmaker_services",
|
||||
"operator_roles",
|
||||
"operators",
|
||||
"organization_relationships",
|
||||
"organizations",
|
||||
"permissions",
|
||||
"reports",
|
||||
"review_items",
|
||||
"review_reasons",
|
||||
"risk_actions",
|
||||
"risk_events",
|
||||
"role_permissions",
|
||||
"roles",
|
||||
"sensitive_access_logs",
|
||||
"service_location_operators",
|
||||
"service_programs",
|
||||
"system_configs",
|
||||
"user_interests",
|
||||
"user_photos",
|
||||
"user_preferences",
|
||||
"user_privacy_settings"
|
||||
);
|
||||
|
||||
/** 本测试自己直接断言的关键表:必须出现在测试库中。 */
|
||||
private static final Set<String> REQUIRED_IN_TEST_SCHEMA = Set.of(
|
||||
"users",
|
||||
"matches",
|
||||
"relationship_success_requests",
|
||||
"conversations",
|
||||
"messages",
|
||||
"notifications",
|
||||
"user_identities",
|
||||
"review_tasks",
|
||||
"home_banners",
|
||||
"service_locations",
|
||||
"content_articles",
|
||||
"activities",
|
||||
"user_local_eligibilities",
|
||||
"local_eligibility_evidences"
|
||||
);
|
||||
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
|
||||
private static final Set<String> BASE_ENTITY_COLUMNS =
|
||||
Set.of("id", "created_at", "updated_at", "deleted_at");
|
||||
|
||||
/**
|
||||
* 一定被写入的实体字段(MyBatis-Plus 默认 FieldStrategy=NOT_NULL:
|
||||
* 非 null 的字段必然出现在 INSERT/UPDATE 列清单里)。
|
||||
*
|
||||
* 分两类:
|
||||
* 1. AutoFillHandler.insertFill / updateFill 强制填充 → 每次 insert 都非 null;
|
||||
* 2. 业务代码在每个创建路径上都显式 set 的字段。
|
||||
*
|
||||
* 归类为「必崩级」意味着:只要该表有一次写操作,真实 PG 上就会报
|
||||
* column does not exist —— 即该功能在生产 100% 不可用。
|
||||
*/
|
||||
private static final Set<String> AUTO_FILLED_COLUMNS =
|
||||
Set.of("id", "created_at", "updated_at");
|
||||
|
||||
/**
|
||||
* 由业务代码在每个创建路径上显式赋非 null 值的字段(表名.列名)。
|
||||
* 这些列缺失时,一旦发生 insert 即崩。
|
||||
*
|
||||
* 例:MatchService.createOrReuseMatch 与 RelationshipService.createMatch
|
||||
* 都 setEpisode(n) / setLastActivityAt(now()),因此 matches.episode /
|
||||
* matches.last_activity_at 属必崩级,而非「潜伏级」。
|
||||
*/
|
||||
private static final Set<String> ALWAYS_WRITTEN_COLUMNS = Set.of(
|
||||
"matches.episode",
|
||||
"matches.last_activity_at",
|
||||
"user_preferences.deleted_at"
|
||||
);
|
||||
|
||||
private static final Pattern COL_NAME = Pattern.compile("[a-zA-Z_][a-zA-Z0-9_]*");
|
||||
|
||||
// ==================== 主断言 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("实体字段必须都能在生产迁移累积 DDL 中找到对应列")
|
||||
void allEntityFieldsExistInMigrations() throws IOException {
|
||||
Map<String, Set<String>> migrationColumns = parseMigrationColumns();
|
||||
Map<String, Set<String>> entityColumns = parseEntityColumns();
|
||||
|
||||
List<String> hardFailures = new ArrayList<>();
|
||||
List<String> softFailures = new ArrayList<>();
|
||||
|
||||
entityColumns.forEach((table, columns) -> {
|
||||
Set<String> prod = migrationColumns.get(table);
|
||||
if (prod == null) {
|
||||
hardFailures.add(String.format("%s: 实体存在但生产迁移未创建该表(列: %s)",
|
||||
table, new TreeSet<>(columns)));
|
||||
return;
|
||||
}
|
||||
Set<String> missing = new TreeSet<>(columns);
|
||||
missing.removeAll(prod);
|
||||
if (missing.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> hard = new TreeSet<>();
|
||||
Set<String> soft = new TreeSet<>();
|
||||
|
||||
for (String col : missing) {
|
||||
boolean autoFilled = AUTO_FILLED_COLUMNS.contains(col);
|
||||
boolean alwaysWritten = ALWAYS_WRITTEN_COLUMNS.contains(table + "." + col);
|
||||
if (autoFilled || alwaysWritten) {
|
||||
hard.add(col);
|
||||
} else {
|
||||
soft.add(col);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hard.isEmpty()) {
|
||||
hardFailures.add(String.format(
|
||||
"%s: 生产缺【必崩】列 %s(每次写入都会进入 SQL 列清单);"
|
||||
+ "其余缺列 %s",
|
||||
table, hard, soft));
|
||||
} else if (!soft.isEmpty()) {
|
||||
softFailures.add(String.format(
|
||||
"%s: 生产缺列 %s(null 时不进 SQL,但一旦写入非 null 值即崩;"
|
||||
+ "读路径该字段永远为 null,属静默功能失效)",
|
||||
table, soft));
|
||||
}
|
||||
});
|
||||
|
||||
String report = "";
|
||||
if (!hardFailures.isEmpty()) {
|
||||
report += "\n\n【必崩级】实体字段在生产迁移中不存在,真实 PG 上 insert/updateById 直接报错:\n - "
|
||||
+ String.join("\n - ", hardFailures);
|
||||
}
|
||||
if (!softFailures.isEmpty()) {
|
||||
report += "\n\n【潜伏级】实体字段在生产迁移中不存在,写非 null 值时崩溃:\n - "
|
||||
+ String.join("\n - ", softFailures);
|
||||
}
|
||||
if (!report.isEmpty()) {
|
||||
report += "\n\n修复方式:在 db/migration 末尾新增 V{n}__xxx.sql,用"
|
||||
+ " ALTER TABLE <表> ADD COLUMN IF NOT EXISTS <列> <类型>; 补齐。"
|
||||
+ "\n(不要为了让测试变绿而删除 test/resources/schema-h2.sql 里的列 ——"
|
||||
+ " 那会把生产故障藏回去。)";
|
||||
}
|
||||
|
||||
assertTrue(hardFailures.isEmpty() && softFailures.isEmpty(),
|
||||
"发现实体与生产迁移的架构漂移(schema drift):" + report);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试库必须覆盖关键业务表(否则相关路径完全不可测 → 假绿)")
|
||||
void testSchemaCoversCriticalTables() throws IOException {
|
||||
assertTrue(Files.exists(TEST_SCHEMA), "测试 schema 不存在: " + TEST_SCHEMA);
|
||||
|
||||
Set<String> testTables = new LinkedHashSet<>();
|
||||
Matcher m = Pattern.compile(
|
||||
"CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?([a-zA-Z_][a-zA-Z0-9_]*)",
|
||||
Pattern.CASE_INSENSITIVE).matcher(Files.readString(TEST_SCHEMA));
|
||||
while (m.find()) {
|
||||
testTables.add(m.group(1).toLowerCase());
|
||||
}
|
||||
|
||||
Set<String> missing = new TreeSet<>(REQUIRED_IN_TEST_SCHEMA);
|
||||
missing.removeAll(testTables);
|
||||
|
||||
assertTrue(missing.isEmpty(),
|
||||
"测试库 schema-h2.sql 缺失关键业务表,相关端到端路径无法验证:\n - "
|
||||
+ String.join("\n - ", missing)
|
||||
+ "\n\n补法:对照 db/migration 中该表的 CREATE TABLE,在 schema-h2.sql 追加"
|
||||
+ "同名列定义(类型可用 H2 等价类型)。\n"
|
||||
+ "若确认该表与本轮测试无关,请显式加入 TEST_SCHEMA_OPTIONAL_TABLES 白名单并说明理由。");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试库缺表不得超出白名单(防止新链路悄悄失去覆盖)")
|
||||
void testSchemaMissingTablesAreWhitelisted() throws IOException {
|
||||
Map<String, Set<String>> migrationColumns = parseMigrationColumns();
|
||||
|
||||
Set<String> testTables = new LinkedHashSet<>();
|
||||
Matcher m = Pattern.compile(
|
||||
"CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?([a-zA-Z_][a-zA-Z0-9_]*)",
|
||||
Pattern.CASE_INSENSITIVE).matcher(Files.readString(TEST_SCHEMA));
|
||||
while (m.find()) {
|
||||
testTables.add(m.group(1).toLowerCase());
|
||||
}
|
||||
|
||||
Set<String> unexpected = new TreeSet<>();
|
||||
for (String table : migrationColumns.keySet()) {
|
||||
if (table.startsWith("flyway")) {
|
||||
continue;
|
||||
}
|
||||
if (testTables.contains(table)) {
|
||||
continue;
|
||||
}
|
||||
if (TEST_SCHEMA_OPTIONAL_TABLES.contains(table)) {
|
||||
continue;
|
||||
}
|
||||
unexpected.add(table);
|
||||
}
|
||||
|
||||
assertTrue(unexpected.isEmpty(),
|
||||
"以下生产表在测试库中缺失,且未登记到 TEST_SCHEMA_OPTIONAL_TABLES 白名单:\n - "
|
||||
+ String.join("\n - ", unexpected)
|
||||
+ "\n\n请二选一:(1) 补进 schema-h2.sql;(2) 确认无关后加入白名单并写明理由。");
|
||||
}
|
||||
|
||||
// ==================== 负向验证锚点 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("解析器自检:能正确识别生产迁移中的列(防止守卫因解析失败而永远为真)")
|
||||
void parserMustActuallySeeColumns() throws IOException {
|
||||
Map<String, Set<String>> cols = parseMigrationColumns();
|
||||
|
||||
// 这些列确定存在,若解析器坏了这里会先红
|
||||
assertAll(
|
||||
() -> assertTrue(cols.containsKey("matches"), "解析器未识别 matches 表"),
|
||||
() -> assertTrue(cols.get("matches").contains("user_a_id"),
|
||||
"解析器未识别 matches.user_a_id"),
|
||||
() -> assertTrue(cols.get("matches").contains("episode"),
|
||||
"解析器未识别 matches.episode(V037 应已补齐)"),
|
||||
() -> assertTrue(cols.containsKey("user_preferences"),
|
||||
"解析器未识别 user_preferences 表"),
|
||||
() -> assertTrue(cols.get("user_preferences").contains("id"),
|
||||
"解析器未识别 user_preferences.id(V037 应已补齐)"),
|
||||
() -> assertTrue(cols.get("permissions").contains("created_at"),
|
||||
"解析器未识别 permissions.created_at(V037 应已补齐)"),
|
||||
() -> assertTrue(cols.size() > 40,
|
||||
"解析到的表数量异常偏少(" + cols.size() + "),解析器可能失效")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("解析器自检:能正确识别实体字段(含 BaseEntity 公共列)")
|
||||
void entityParserMustSeeBaseEntityColumns() throws IOException {
|
||||
Map<String, Set<String>> cols = parseEntityColumns();
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(cols.containsKey("matches"), "未识别 Match 实体"),
|
||||
() -> assertTrue(cols.get("matches").contains("episode"), "未识别 Match.episode"),
|
||||
() -> assertTrue(cols.get("matches").contains("created_at"),
|
||||
"未从 BaseEntity 继承 created_at"),
|
||||
() -> assertTrue(cols.get("matches").contains("deleted_at"),
|
||||
"未从 BaseEntity 继承 deleted_at"),
|
||||
() -> assertTrue(cols.containsKey("user_preferences"), "未识别 UserPreference 实体"),
|
||||
() -> assertTrue(cols.get("user_preferences").contains("age_weight"),
|
||||
"未识别 camelCase→snake_case 转换(ageWeight→age_weight)")
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/** 解析 db/migration/V*.sql 的 CREATE TABLE 列定义 + ALTER TABLE ADD COLUMN。 */
|
||||
private Map<String, Set<String>> parseMigrationColumns() throws IOException {
|
||||
assertTrue(Files.isDirectory(MIGRATION_DIR),
|
||||
"迁移目录不存在: " + MIGRATION_DIR.toAbsolutePath());
|
||||
|
||||
List<Path> files;
|
||||
try (Stream<Path> s = Files.list(MIGRATION_DIR)) {
|
||||
files = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql"))
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
assertFalse(files.isEmpty(), "未找到任何迁移文件");
|
||||
|
||||
StringBuilder merged = new StringBuilder();
|
||||
for (Path f : files) {
|
||||
// 去行注释,避免注释里的词干扰解析
|
||||
merged.append(Files.readString(f).replaceAll("--[^\\n]*", "")).append("\n;\n");
|
||||
}
|
||||
String ddl = merged.toString();
|
||||
|
||||
Map<String, Set<String>> tableColumns = new LinkedHashMap<>();
|
||||
|
||||
// CREATE TABLE ... ( ... );
|
||||
Matcher ct = Pattern.compile(
|
||||
"CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\((.*?)\\)\\s*;",
|
||||
Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(ddl);
|
||||
while (ct.find()) {
|
||||
String table = ct.group(1).toLowerCase();
|
||||
tableColumns.computeIfAbsent(table, k -> new LinkedHashSet<>())
|
||||
.addAll(parseColumnDefinitions(ct.group(2)));
|
||||
}
|
||||
|
||||
// ALTER TABLE ... ADD/DROP/RENAME COLUMN
|
||||
Matcher at = Pattern.compile(
|
||||
"ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\\s+(.*?);",
|
||||
Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(ddl);
|
||||
while (at.find()) {
|
||||
String table = at.group(1).toLowerCase();
|
||||
String body = at.group(2);
|
||||
Set<String> cols = tableColumns.computeIfAbsent(table, k -> new LinkedHashSet<>());
|
||||
Matcher add = Pattern.compile(
|
||||
"ADD\\s+COLUMN\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?([a-zA-Z_][a-zA-Z0-9_]*)",
|
||||
Pattern.CASE_INSENSITIVE).matcher(body);
|
||||
while (add.find()) {
|
||||
cols.add(add.group(1).toLowerCase());
|
||||
}
|
||||
Matcher drop = Pattern.compile(
|
||||
"DROP\\s+COLUMN\\s+(?:IF\\s+EXISTS\\s+)?([a-zA-Z_][a-zA-Z0-9_]*)",
|
||||
Pattern.CASE_INSENSITIVE).matcher(body);
|
||||
while (drop.find()) {
|
||||
cols.remove(drop.group(1).toLowerCase());
|
||||
}
|
||||
Matcher ren = Pattern.compile(
|
||||
"RENAME\\s+COLUMN\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+TO\\s+([a-zA-Z_][a-zA-Z0-9_]*)",
|
||||
Pattern.CASE_INSENSITIVE).matcher(body);
|
||||
while (ren.find()) {
|
||||
String from = ren.group(1).toLowerCase();
|
||||
if (cols.remove(from)) {
|
||||
cols.add(ren.group(2).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return tableColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 CREATE TABLE 的列定义体里抽取列名。
|
||||
* 需要按逗号切分,但要跳过函数调用 / 类型里的括号(如 NUMERIC(10,2)、CHECK (...))。
|
||||
*/
|
||||
private Set<String> parseColumnDefinitions(String body) {
|
||||
Set<String> columns = new LinkedHashSet<>();
|
||||
List<String> segments = new ArrayList<>();
|
||||
int depth = 0;
|
||||
StringBuilder cur = new StringBuilder();
|
||||
for (char ch : body.toCharArray()) {
|
||||
if (ch == '(') {
|
||||
depth++;
|
||||
} else if (ch == ')') {
|
||||
depth--;
|
||||
}
|
||||
if (ch == ',' && depth == 0) {
|
||||
segments.add(cur.toString());
|
||||
cur.setLength(0);
|
||||
} else {
|
||||
cur.append(ch);
|
||||
}
|
||||
}
|
||||
if (cur.toString().trim().length() > 0) {
|
||||
segments.add(cur.toString());
|
||||
}
|
||||
|
||||
for (String seg : segments) {
|
||||
String trimmed = seg.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Matcher m = Pattern.compile("^(" + COL_NAME + ")\\s+").matcher(trimmed);
|
||||
if (!m.find()) {
|
||||
continue;
|
||||
}
|
||||
String name = m.group(1).toLowerCase();
|
||||
// 表级约束关键字,不是列
|
||||
if (Set.of("constraint", "primary", "unique", "foreign", "check", "key")
|
||||
.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
columns.add(name);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/** 解析 main/java 下所有 @TableName 实体的字段(snake_case),含 BaseEntity 继承列。 */
|
||||
private Map<String, Set<String>> parseEntityColumns() throws IOException {
|
||||
assertTrue(Files.isDirectory(MAIN), "源码目录不存在: " + MAIN.toAbsolutePath());
|
||||
|
||||
Map<String, Set<String>> result = new LinkedHashMap<>();
|
||||
List<Path> entityFiles;
|
||||
try (Stream<Path> s = Files.walk(MAIN)) {
|
||||
entityFiles = s.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> p.getParent() != null
|
||||
&& "entity".equals(p.getParent().getFileName().toString()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
assertFalse(entityFiles.isEmpty(), "未找到任何 entity 类");
|
||||
|
||||
for (Path f : entityFiles) {
|
||||
String src = Files.readString(f);
|
||||
Matcher tm = Pattern.compile("@TableName\\(\\s*\"([^\"]+)\"").matcher(src);
|
||||
if (!tm.find()) {
|
||||
continue;
|
||||
}
|
||||
String table = tm.group(1).toLowerCase();
|
||||
|
||||
// 去注释,避免注释里的 "private xxx" 干扰
|
||||
String clean = src.replaceAll("//[^\\n]*", "")
|
||||
.replaceAll("/\\*.*?\\*/", "");
|
||||
|
||||
Set<String> columns = result.computeIfAbsent(table, k -> new LinkedHashSet<>());
|
||||
|
||||
// 所有 private 字段
|
||||
Set<String> excluded = new LinkedHashSet<>();
|
||||
Matcher noPersist = Pattern.compile(
|
||||
"@TableField\\([^)]*exist\\s*=\\s*false[^)]*\\)\\s*"
|
||||
+ "private\\s+[^;]*?\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*;",
|
||||
Pattern.DOTALL).matcher(clean);
|
||||
while (noPersist.find()) {
|
||||
excluded.add(camelToSnake(noPersist.group(1)));
|
||||
}
|
||||
|
||||
Matcher fm = Pattern.compile(
|
||||
"private\\s+(?:static\\s+final\\s+)?([\\w<>\\[\\],\\.\\s]+?)\\s+"
|
||||
+ "([a-zA-Z_][a-zA-Z0-9_]*)\\s*[;=]").matcher(clean);
|
||||
while (fm.find()) {
|
||||
String type = fm.group(1).trim();
|
||||
String name = fm.group(2);
|
||||
if ("serialVersionUID".equals(name) || name.equals(name.toUpperCase())) {
|
||||
continue;
|
||||
}
|
||||
if (type.contains("static") || type.endsWith("final")) {
|
||||
continue;
|
||||
}
|
||||
columns.add(camelToSnake(name));
|
||||
}
|
||||
columns.removeAll(excluded);
|
||||
|
||||
// 继承 BaseEntity → 补公共列
|
||||
if (Pattern.compile("extends\\s+\\w*BaseEntity").matcher(clean).find()) {
|
||||
columns.addAll(BASE_ENTITY_COLUMNS);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String camelToSnake(String s) {
|
||||
return s.replaceAll("([a-z0-9])([A-Z])", "$1_$2")
|
||||
.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* P25-T02: Migration 最终验证
|
||||
* 验证所有 V001-V036 迁移文件存在、命名规范、语法正确
|
||||
* 验证所有 V001-V037 迁移文件存在、命名规范、语法正确
|
||||
*/
|
||||
class MigrationValidationTest {
|
||||
|
||||
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
|
||||
private static final int LATEST_MIGRATION = 36;
|
||||
private static final int LATEST_MIGRATION = 37;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.danyang.match.platform.home;
|
||||
|
||||
import com.danyang.match.platform.home.dto.HomeDTO;
|
||||
import com.danyang.match.platform.home.service.HomeApplicationService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 12 计划项 2:getting_to_know 阶段的婚前辅导入口引导
|
||||
*
|
||||
* 历史缺陷:HomeApplicationService.buildServiceRecommendations 的 getting_to_know 分支
|
||||
* 只产出 counseling 一条,婚前辅导入口仅在 matched_successfully 才出现 ——
|
||||
* 「正在了解」阶段用户完全看不到婚前辅导入口,计划项 2 实际未实现。
|
||||
*
|
||||
* 本测试走真实 getHome(userId) 路径,断言:
|
||||
* · getting_to_know → serviceRecommendations 同时含 counseling 与 premarital;
|
||||
* · premarital 的 targetPage 指向 /pages/premarital-intro/premarital-intro;
|
||||
* · matched_successfully → premarital 仍在(回归保护);
|
||||
* · searching → 不出现 premarital(避免打扰)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class HomePremaritalGuidancePhase12Test {
|
||||
|
||||
@Autowired private HomeApplicationService homeApplicationService;
|
||||
@Autowired private UserMapper userMapper;
|
||||
|
||||
private String userId;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("p12_home_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
u.setRecommendationEnabled(true);
|
||||
userMapper.insert(u);
|
||||
userId = u.getId();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void teardown() {
|
||||
if (userId != null) userMapper.deleteById(userId);
|
||||
}
|
||||
|
||||
private void setStatus(String status) {
|
||||
User u = userMapper.findById(userId).orElseThrow();
|
||||
u.setRelationshipStatus(status);
|
||||
userMapper.updateById(u);
|
||||
}
|
||||
|
||||
private List<HomeDTO.ServiceRecommendation> recommendations() {
|
||||
HomeDTO dto = homeApplicationService.getHome(userId);
|
||||
assertNotNull(dto, "首页聚合响应不应为 null");
|
||||
List<HomeDTO.ServiceRecommendation> list = dto.getServiceRecommendations();
|
||||
assertNotNull(list, "serviceRecommendations 不应为 null");
|
||||
return list;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getting_to_know 阶段必须提供婚前辅导入口引导(计划项 2)")
|
||||
void gettingToKnowProvidesPremaritalEntry() {
|
||||
setStatus(Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW);
|
||||
|
||||
List<HomeDTO.ServiceRecommendation> recs = recommendations();
|
||||
|
||||
boolean hasPremarital = recs.stream()
|
||||
.anyMatch(r -> "premarital".equals(r.getType()));
|
||||
assertTrue(hasPremarital,
|
||||
"getting_to_know 阶段必须出现 premarital 服务建议,实际类型: "
|
||||
+ recs.stream().map(HomeDTO.ServiceRecommendation::getType).toList());
|
||||
|
||||
HomeDTO.ServiceRecommendation premarital = recs.stream()
|
||||
.filter(r -> "premarital".equals(r.getType()))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("/pages/premarital-intro/premarital-intro", premarital.getTargetPage(),
|
||||
"婚前辅导入口必须指向 premarital-intro 页面");
|
||||
assertNotNull(premarital.getTitle());
|
||||
assertNotNull(premarital.getCtaText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getting_to_know 阶段保留既有 counseling 引导(不得被 premarital 挤掉)")
|
||||
void gettingToKnowKeepsCounseling() {
|
||||
setStatus(Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW);
|
||||
List<HomeDTO.ServiceRecommendation> recs = recommendations();
|
||||
assertTrue(recs.stream().anyMatch(r -> "counseling".equals(r.getType())),
|
||||
"getting_to_know 阶段必须保留 counseling 建议");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("matched_successfully 阶段婚前辅导入口仍在(回归保护)")
|
||||
void successfulKeepsPremaritalEntry() {
|
||||
setStatus(Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY);
|
||||
List<HomeDTO.ServiceRecommendation> recs = recommendations();
|
||||
assertTrue(recs.stream().anyMatch(r -> "premarital".equals(r.getType())),
|
||||
"matched_successfully 阶段必须保留 premarital 建议");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("searching 阶段不显示婚前辅导(避免过早打扰)")
|
||||
void searchingHasNoPremarital() {
|
||||
setStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
List<HomeDTO.ServiceRecommendation> recs = recommendations();
|
||||
assertFalse(recs.stream().anyMatch(r -> "premarital".equals(r.getType())),
|
||||
"searching 阶段不应出现 premarital 建议");
|
||||
assertTrue(recs.stream().anyMatch(r -> "matchmaker".equals(r.getType())),
|
||||
"searching 阶段应给出 matchmaker 建议");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("所有服务建议的 targetPage 必须是站内路径(防外链/空值)")
|
||||
void allTargetsAreInternalPages() {
|
||||
for (String status : List.of(
|
||||
Constants.RELATIONSHIP_STATUS_SEARCHING,
|
||||
Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW,
|
||||
Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY)) {
|
||||
setStatus(status);
|
||||
for (HomeDTO.ServiceRecommendation r : recommendations()) {
|
||||
assertNotNull(r.getTargetPage(), "targetPage 不得为 null(status=" + status + ")");
|
||||
assertTrue(r.getTargetPage().startsWith("/pages/"),
|
||||
"targetPage 必须是站内路径,实际: " + r.getTargetPage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
package com.danyang.match.platform.relationship;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.chat.entity.Conversation;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.service.MatchService;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
|
||||
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
|
||||
import com.danyang.match.platform.relationship.service.RelationshipService;
|
||||
import com.danyang.match.platform.relationship.service.RelationshipStatusService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 12 复验:Relationship Progress 真实运行时路径测试
|
||||
*
|
||||
* 背景:Phase 12 原有测试 RelationshipStatusServiceTest /
|
||||
* RelationshipStatusServiceDoubleConfirmTest **全部是常量断言 + assertTrue(true) 占位**,
|
||||
* 从未调用过 RelationshipStatusService 的任何方法 —— 对「双确认」不变量零覆盖。
|
||||
* 本测试用真实方法路径替换之。
|
||||
*
|
||||
* 对应计划的三条:
|
||||
* [1] 验证成功关系双确认、恢复/退出、历史 Episode 和并发确认
|
||||
* [2] 补充 getting_to_know 阶段的婚前辅导入口引导
|
||||
* [3] 验证工作人员不能替用户确认成功关系
|
||||
*
|
||||
* Gate: 单方确认不改变最终关系;历史 Episode 可追踪但不会恢复旧聊天权限。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class RelationshipProgressPhase12Test {
|
||||
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private UserRelationMapper relationMapper;
|
||||
@Autowired private MatchMapper matchMapper;
|
||||
@Autowired private RelationshipSuccessRequestMapper successRequestMapper;
|
||||
@Autowired private ConversationMapper conversationMapper;
|
||||
@Autowired private RelationshipService relationshipService;
|
||||
@Autowired private RelationshipStatusService statusService;
|
||||
|
||||
private String userA;
|
||||
private String userB;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
userA = createUser("A");
|
||||
userB = createUser("B");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void teardown() {
|
||||
successRequestMapper.delete(null);
|
||||
conversationMapper.delete(null);
|
||||
matchMapper.delete(null);
|
||||
relationMapper.delete(null);
|
||||
userMapper.deleteById(userA);
|
||||
userMapper.deleteById(userB);
|
||||
}
|
||||
|
||||
private String createUser(String tag) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("p12_" + tag + "_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
u.setRecommendationEnabled(true);
|
||||
userMapper.insert(u);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
/** 走真实 Like 路径建立 Match(不用 SQL 直插) */
|
||||
private Match createMatchViaLike() {
|
||||
relationshipService.like(userA, userB);
|
||||
relationshipService.like(userB, userA);
|
||||
return matchMapper.findActiveMatch(userA, userB)
|
||||
.orElseThrow(() -> new AssertionError("Like 双方后应存在 active Match"));
|
||||
}
|
||||
|
||||
// ==================== Gate 前半:单方确认不改变最终关系 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 单方确认(发起方自确认)不使 Match 变为 successful")
|
||||
void singleConfirmDoesNotCompleteMatch() {
|
||||
Match match = createMatchViaLike();
|
||||
|
||||
statusService.requestSuccess(userA, userB);
|
||||
Map<String, Object> first = statusService.confirmSuccess(userA, userB);
|
||||
|
||||
assertEquals("waiting_for_other", first.get("status"),
|
||||
"单方确认后应返回 waiting_for_other,而不是 successful");
|
||||
assertEquals(Constants.MATCH_STATUS_ACTIVE,
|
||||
matchMapper.findById(match.getId()).orElseThrow().getStatus(),
|
||||
"单方确认后 Match 状态必须仍为 active");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 双方确认后 Match 变为 successful 且双方关系状态为 matched_successfully")
|
||||
void bothConfirmCompletesMatch() {
|
||||
Match match = createMatchViaLike();
|
||||
|
||||
statusService.requestSuccess(userA, userB);
|
||||
statusService.confirmSuccess(userA, userB);
|
||||
Map<String, Object> second = statusService.confirmSuccess(userB, userA);
|
||||
|
||||
assertEquals("successful", second.get("status"));
|
||||
|
||||
Match after = matchMapper.findById(match.getId()).orElseThrow();
|
||||
assertEquals(Constants.MATCH_STATUS_SUCCESSFUL, after.getStatus());
|
||||
assertNotNull(after.getSuccessfulAt(), "successfulAt 应被写入");
|
||||
|
||||
assertEquals(Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY,
|
||||
userMapper.findById(userA).orElseThrow().getRelationshipStatus());
|
||||
assertEquals(Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY,
|
||||
userMapper.findById(userB).orElseThrow().getRelationshipStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 成功牵手后双方推荐被停止(recommendationEnabled=false)")
|
||||
void successStopsRecommendations() {
|
||||
createMatchViaLike();
|
||||
statusService.requestSuccess(userA, userB);
|
||||
statusService.confirmSuccess(userA, userB);
|
||||
statusService.confirmSuccess(userB, userA);
|
||||
|
||||
assertFalse(userMapper.findById(userA).orElseThrow().getRecommendationEnabled());
|
||||
assertFalse(userMapper.findById(userB).orElseThrow().getRecommendationEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一人重复确认被拒绝,不能靠刷新绕过双确认")
|
||||
void samePersonCannotConfirmTwice() {
|
||||
createMatchViaLike();
|
||||
statusService.requestSuccess(userA, userB);
|
||||
statusService.confirmSuccess(userA, userB);
|
||||
|
||||
// A 再次确认(A 已是 confirmedBy)→ 必须被拒
|
||||
assertThrows(GlobalExceptionHandler.BusinessException.class,
|
||||
() -> statusService.confirmSuccess(userA, userB),
|
||||
"同一用户重复确认应抛业务异常");
|
||||
}
|
||||
|
||||
// ==================== 并发确认 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("并发确认:双方同时 confirm 只产生一次成功牵手,且不出现重复业务记录")
|
||||
void concurrentConfirmIsIdempotent() throws Exception {
|
||||
Match match = createMatchViaLike();
|
||||
statusService.requestSuccess(userA, userB);
|
||||
statusService.confirmSuccess(userA, userB); // 先让 A 确认
|
||||
|
||||
int threads = 8;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
AtomicInteger successCount = new AtomicInteger();
|
||||
AtomicInteger errorCount = new AtomicInteger();
|
||||
|
||||
List<Future<?>> futures = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < threads; i++) {
|
||||
final String uid = (i % 2 == 0) ? userB : userA;
|
||||
final String peer = (i % 2 == 0) ? userA : userB;
|
||||
futures.add(pool.submit(() -> {
|
||||
try {
|
||||
start.await();
|
||||
Map<String, Object> r = statusService.confirmSuccess(uid, peer);
|
||||
if ("successful".equals(r.get("status"))) {
|
||||
successCount.incrementAndGet();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errorCount.incrementAndGet();
|
||||
}
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for (Future<?> f : futures) {
|
||||
f.get(30, TimeUnit.SECONDS);
|
||||
}
|
||||
pool.shutdown();
|
||||
|
||||
// 最终事实:Match 必须恰好 successful 一次
|
||||
assertEquals(Constants.MATCH_STATUS_SUCCESSFUL,
|
||||
matchMapper.findById(match.getId()).orElseThrow().getStatus(),
|
||||
"并发确认后 Match 必须为 successful");
|
||||
|
||||
// 不产生重复业务记录:该 Match 的 success request 只有 1 条
|
||||
List<RelationshipSuccessRequest> reqs = successRequestMapper.selectList(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>()
|
||||
.eq(RelationshipSuccessRequest::getMatchId, match.getId()));
|
||||
assertEquals(1, reqs.size(),
|
||||
"成功牵手请求记录必须唯一(并发下不得重复插入),实际 " + reqs.size() + " 条");
|
||||
}
|
||||
|
||||
// ==================== 历史 Episode 与旧聊天权限 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: Unmatch 后重新 Match 生成 episode+1,历史 Episode 可追踪")
|
||||
void reMatchIncrementsEpisode() {
|
||||
Match first = createMatchViaLike();
|
||||
assertEquals(Integer.valueOf(1), first.getEpisode(),
|
||||
"首个 Match 的 episode 应为 1");
|
||||
|
||||
// Unmatch(走真实状态转换)
|
||||
Match m1 = matchMapper.findById(first.getId()).orElseThrow();
|
||||
MatchService.unmatch(m1, userA);
|
||||
matchMapper.updateById(m1);
|
||||
|
||||
// 重新形成 Match(MatchService.createOrReuseMatch 的 episode 推导)
|
||||
Match second = MatchService.createOrReuseMatch(
|
||||
matchMapper, relationMapper, userA, userB,
|
||||
Constants.MATCH_SOURCE_USER_LIKE, null);
|
||||
|
||||
assertNotNull(second, "Unmatch 后应能重新创建 Match");
|
||||
assertNotEquals(first.getId(), second.getId(), "重新 Match 必须是新的 Match 记录");
|
||||
assertEquals(Integer.valueOf(2), second.getEpisode(),
|
||||
"重新 Match 的 episode 应为 prior.episode + 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 重新 Match 不会恢复旧聊天权限(新 Match 无历史会话)")
|
||||
void reMatchDoesNotRestoreOldConversation() {
|
||||
Match first = createMatchViaLike();
|
||||
|
||||
// 旧 Match 有会话
|
||||
Long oldConvCount = conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId()));
|
||||
assertEquals(1L, oldConvCount, "首个 Match 应有 1 个会话");
|
||||
|
||||
// Unmatch
|
||||
Match m1 = matchMapper.findById(first.getId()).orElseThrow();
|
||||
MatchService.unmatch(m1, userA);
|
||||
matchMapper.updateById(m1);
|
||||
|
||||
// 重新 Match → 新 Match,新 Match 尚无会话
|
||||
Match second = MatchService.createOrReuseMatch(
|
||||
matchMapper, relationMapper, userA, userB,
|
||||
Constants.MATCH_SOURCE_USER_LIKE, null);
|
||||
assertNotNull(second);
|
||||
|
||||
// 关键:conversations 按 match_id 唯一 —— 新 Match 绝不能拿到旧会话
|
||||
assertEquals(0L, conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, second.getId())),
|
||||
"新 Match 不应自动继承旧会话(旧聊天权限不恢复)");
|
||||
|
||||
// 且旧会话仍挂在旧 Match 上,不会被新 Match 复用
|
||||
assertEquals(1L, conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())),
|
||||
"旧会话应仍属于旧 Match");
|
||||
}
|
||||
|
||||
// ==================== 拒绝 / 恢复 / 退出 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("拒绝后不可再发起成功牵手")
|
||||
void declinedCannotRestart() {
|
||||
createMatchViaLike();
|
||||
statusService.requestSuccess(userA, userB);
|
||||
statusService.declineSuccess(userB, userA);
|
||||
|
||||
assertThrows(GlobalExceptionHandler.BusinessException.class,
|
||||
() -> statusService.requestSuccess(userA, userB),
|
||||
"declined 后重新发起应被拒绝");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已成功牵手的 Match 不可重复发起")
|
||||
void successfulCannotRestart() {
|
||||
createMatchViaLike();
|
||||
statusService.requestSuccess(userA, userB);
|
||||
statusService.confirmSuccess(userA, userB);
|
||||
statusService.confirmSuccess(userB, userA);
|
||||
|
||||
assertThrows(GlobalExceptionHandler.BusinessException.class,
|
||||
() -> statusService.requestSuccess(userA, userB),
|
||||
"已成功牵手后重复发起应被拒绝");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("暂停寻找后已有 Match 保留(不解除配对)")
|
||||
void pauseKeepsExistingMatch() {
|
||||
Match match = createMatchViaLike();
|
||||
statusService.pauseSearching(userA);
|
||||
|
||||
assertEquals(Constants.MATCH_STATUS_ACTIVE,
|
||||
matchMapper.findById(match.getId()).orElseThrow().getStatus(),
|
||||
"暂停寻找不应改变已有 Match 状态");
|
||||
assertFalse(userMapper.findById(userA).orElseThrow().getRecommendationEnabled(),
|
||||
"暂停后推荐应关闭");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("恢复寻找:推荐重新开启,关系状态回到 searching")
|
||||
void resumeRestoresSearching() {
|
||||
statusService.pauseSearching(userA);
|
||||
statusService.resumeSearching(userA);
|
||||
|
||||
User u = userMapper.findById(userA).orElseThrow();
|
||||
assertEquals(Constants.RELATIONSHIP_STATUS_SEARCHING, u.getRelationshipStatus());
|
||||
assertTrue(u.getRecommendationEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("退出服务:关系状态为 service_exited 且推荐关闭")
|
||||
void exitServiceWorks() {
|
||||
statusService.exitService(userA);
|
||||
|
||||
User u = userMapper.findById(userA).orElseThrow();
|
||||
assertEquals(Constants.RELATIONSHIP_STATUS_SERVICE_EXITED, u.getRelationshipStatus());
|
||||
assertFalse(u.getRecommendationEnabled());
|
||||
}
|
||||
|
||||
// ==================== 工作人员不能替用户确认 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 非 Match 成员无法发起/确认成功牵手(工作人员代操作被拒)")
|
||||
void nonMemberCannotConfirm() {
|
||||
createMatchViaLike();
|
||||
String outsider = createUser("staff");
|
||||
try {
|
||||
// 第三方(如工作人员账号)尝试替用户确认 → 必须找不到 Match
|
||||
assertThrows(GlobalExceptionHandler.BusinessException.class,
|
||||
() -> statusService.requestSuccess(outsider, userA),
|
||||
"非成员不得发起成功牵手");
|
||||
assertThrows(GlobalExceptionHandler.BusinessException.class,
|
||||
() -> statusService.confirmSuccess(outsider, userA),
|
||||
"非成员不得确认成功牵手");
|
||||
} finally {
|
||||
userMapper.deleteById(outsider);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 单方确认后,其确认人字段只能是本人(不可伪造他人确认)")
|
||||
void confirmedByIsAlwaysTheActor() {
|
||||
createMatchViaLike();
|
||||
statusService.requestSuccess(userA, userB);
|
||||
statusService.confirmSuccess(userA, userB);
|
||||
|
||||
RelationshipSuccessRequest req = successRequestMapper.selectOne(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>()
|
||||
.orderByDesc(RelationshipSuccessRequest::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
assertNotNull(req);
|
||||
assertEquals(userA, req.getConfirmedBy(),
|
||||
"confirmedBy 必须是实际调用者,不能被他方写入");
|
||||
assertEquals(Constants.SUCCESS_REQUEST_STATUS_CONFIRMED, req.getStatus());
|
||||
}
|
||||
}
|
||||
+31
-22
@@ -3,12 +3,25 @@ package com.danyang.match.platform.relationship.service;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* P0-2 验证:成功牵手双确认逻辑
|
||||
* INV-005(成功牵手必须双方确认)口径守卫
|
||||
*
|
||||
* 不变量(INV-005):成功牵手必须双方确认,不能红娘/管理员/系统自动确认
|
||||
* ⚠️ 历史问题(Phase 12 复验修复):
|
||||
* 本类原先 6 个用例**全部**是常量断言或 `assertTrue(true); // 占位测试`,
|
||||
* 从未调用过 RelationshipStatusService —— 对一个声称验证「双确认」的测试类,
|
||||
* 实际覆盖率是 0。这正是 Phase 12 复验要根除的「假绿」模式。
|
||||
*
|
||||
* 现在本类只负责**不可变的口径**(常量集合与生产 CHECK 约束的一致性);
|
||||
* INV-005 的**行为**由 {@link com.danyang.match.platform.relationship.RelationshipProgressPhase12Test}
|
||||
* 通过真实调用 requestSuccess / confirmSuccess / declineSuccess 验证:
|
||||
* · 单方确认不改变最终关系;
|
||||
* · 同一人重复确认被拒;
|
||||
* · 并发确认只成功一次且不重复落库;
|
||||
* · 非 Match 成员(含工作人员账号)无法代为确认。
|
||||
*/
|
||||
class RelationshipStatusServiceDoubleConfirmTest {
|
||||
|
||||
@@ -21,7 +34,7 @@ class RelationshipStatusServiceDoubleConfirmTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: Match状态常量完整")
|
||||
@DisplayName("INV-005: Match 状态常量完整")
|
||||
void testMatchStatusConstants() {
|
||||
assertEquals("active", Constants.MATCH_STATUS_ACTIVE);
|
||||
assertEquals("getting_to_know", Constants.MATCH_STATUS_GETTING_TO_KNOW);
|
||||
@@ -32,7 +45,6 @@ class RelationshipStatusServiceDoubleConfirmTest {
|
||||
@Test
|
||||
@DisplayName("INV-005: 配对后关系状态转换链合法")
|
||||
void testRelationshipStatusTransitionChain() {
|
||||
// searching → getting_to_know → (active match) → matched_successfully / service_exited
|
||||
assertEquals("searching", Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
assertEquals("getting_to_know", Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW);
|
||||
assertEquals("paused", Constants.RELATIONSHIP_STATUS_PAUSED);
|
||||
@@ -41,26 +53,23 @@ class RelationshipStatusServiceDoubleConfirmTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 单方确认不应触发Match完成(双确认逻辑占位)")
|
||||
void testSingleConfirmDoesNotCompleteMatch() {
|
||||
// 实现说明:confirmSuccess() 内部先查询已存在的success_request,
|
||||
// 再单独查询对方的confirmed状态,只有双方都确认才更新match.status=successful
|
||||
// 此处验证关键中间状态常量存在
|
||||
assertEquals("pending", Constants.SUCCESS_REQUEST_STATUS_PENDING);
|
||||
assertEquals("confirmed", Constants.SUCCESS_REQUEST_STATUS_CONFIRMED);
|
||||
@DisplayName("INV-005: 成功牵手三态与生产 chk_success_status 约束一致(防漂移)")
|
||||
void successStatesMatchProductionConstraint() {
|
||||
Set<String> expected = Set.of("pending", "confirmed", "declined");
|
||||
Set<String> actual = Set.of(
|
||||
Constants.SUCCESS_REQUEST_STATUS_PENDING,
|
||||
Constants.SUCCESS_REQUEST_STATUS_CONFIRMED,
|
||||
Constants.SUCCESS_REQUEST_STATUS_DECLINED);
|
||||
assertEquals(expected, actual,
|
||||
"成功牵手状态取值集合必须与 V003 chk_success_status 完全一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 拒绝后状态为declined,不可再确认")
|
||||
void testDeclinedStatusExists() {
|
||||
assertEquals("declined", Constants.SUCCESS_REQUEST_STATUS_DECLINED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Paused状态保持已有聊天(INV-paused-chat)")
|
||||
void testPausedKeepsChat() {
|
||||
// Paused = 不再推荐新对象,但已有Match和Conversation不受影响
|
||||
// 由RelationshipService.pauseSearching()实现,不触发deleteMatch或closeConversation
|
||||
assertTrue(true);
|
||||
@DisplayName("INV-005: 「成功」状态必须由 successful 表示,不得复用其它状态名")
|
||||
void successfulIsTheOnlySuccessState() {
|
||||
assertEquals("successful", Constants.MATCH_STATUS_SUCCESSFUL);
|
||||
// 防止有人把 successful 写成 matched / success / done 等别名,导致前端与 DB 约束对不上
|
||||
assertNotEquals("success", Constants.MATCH_STATUS_SUCCESSFUL);
|
||||
assertNotEquals("matched", Constants.MATCH_STATUS_SUCCESSFUL);
|
||||
}
|
||||
}
|
||||
|
||||
+62
-8
@@ -1,10 +1,27 @@
|
||||
package com.danyang.match.platform.relationship.service;
|
||||
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 关系状态常量与状态机口径守卫
|
||||
*
|
||||
* ⚠️ 历史问题(Phase 12 复验修复):
|
||||
* 本类原先含两条 `assertTrue(true); // 占位测试`(testSuccessMustBeDualConfirmed /
|
||||
* testPausedKeepsChat),属 AGENTS.md 明令禁止的「假测试」——只虚增用例数、提供虚假信心。
|
||||
* 其中 testSuccessMustBeDualConfirmed 注释声称由 RelationshipStatusService.confirmSuccess
|
||||
* 保证 INV-005,却从未调用任何业务方法。
|
||||
*
|
||||
* 现已删除占位断言,改为:
|
||||
* · 常量口径由本类锁定(防止改名/改值导致前端、DB CHECK 约束三方不一致);
|
||||
* · INV-005 / paused 语义的**真实行为**验证移至
|
||||
* {@link com.danyang.match.platform.relationship.RelationshipProgressPhase12Test}
|
||||
* (走 RelationshipStatusService 真实方法路径)。
|
||||
*/
|
||||
class RelationshipStatusServiceTest {
|
||||
|
||||
@Test
|
||||
@@ -32,15 +49,52 @@ class RelationshipStatusServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessMustBeDualConfirmed() {
|
||||
// Core Invariant: 成功牵手必须双方确认,不能红娘/管理员/系统自动确认
|
||||
// 这由 RelationshipStatusService.confirmSuccess 方法保证
|
||||
assertTrue(true); // 占位测试
|
||||
@DisplayName("关系状态取值互不相同且非空")
|
||||
void relationshipStatusValuesAreDistinct() {
|
||||
Set<String> allowed = Set.of(
|
||||
Constants.RELATIONSHIP_STATUS_SEARCHING,
|
||||
Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW,
|
||||
Constants.RELATIONSHIP_STATUS_PAUSED,
|
||||
Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY,
|
||||
Constants.RELATIONSHIP_STATUS_SERVICE_EXITED);
|
||||
assertEquals(5, allowed.size(), "关系状态取值必须互不相同");
|
||||
allowed.forEach(v -> assertNotNull(v, "关系状态取值不得为 null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPausedKeepsChat() {
|
||||
// Core Invariant: Paused 不进入新推荐,但已有聊天保留
|
||||
assertTrue(true); // 占位测试
|
||||
@DisplayName("Match 状态常量必须与生产 V003 chk_match_status 白名单一致")
|
||||
void matchStatusMustMatchProductionCheckConstraint() {
|
||||
Set<String> expected = Set.of("active", "getting_to_know", "unmatched", "successful");
|
||||
Set<String> actual = Set.of(
|
||||
Constants.MATCH_STATUS_ACTIVE,
|
||||
Constants.MATCH_STATUS_GETTING_TO_KNOW,
|
||||
Constants.MATCH_STATUS_UNMATCHED,
|
||||
Constants.MATCH_STATUS_SUCCESSFUL);
|
||||
assertEquals(expected, actual,
|
||||
"Match 状态常量必须与生产 chk_match_status 约束完全一致,否则真实 PG 会拒绝写入");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Match source 常量必须与生产 V003 chk_match_source 白名单一致")
|
||||
void matchSourceMustMatchProductionCheckConstraint() {
|
||||
Set<String> expected = Set.of("user_like", "activity_mutual", "matchmaker");
|
||||
Set<String> actual = Set.of(
|
||||
Constants.MATCH_SOURCE_USER_LIKE,
|
||||
Constants.MATCH_SOURCE_ACTIVITY_MUTUAL,
|
||||
Constants.MATCH_SOURCE_MATCHMAKER);
|
||||
assertEquals(expected, actual,
|
||||
"Match source 常量必须与生产 chk_match_source 约束完全一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("成功牵手状态常量必须与生产 V003 chk_success_status 白名单一致")
|
||||
void successRequestStatusMustMatchProductionCheckConstraint() {
|
||||
Set<String> expected = Set.of("pending", "confirmed", "declined");
|
||||
Set<String> actual = Set.of(
|
||||
Constants.SUCCESS_REQUEST_STATUS_PENDING,
|
||||
Constants.SUCCESS_REQUEST_STATUS_CONFIRMED,
|
||||
Constants.SUCCESS_REQUEST_STATUS_DECLINED);
|
||||
assertEquals(expected, actual,
|
||||
"成功牵手状态常量必须与生产 chk_success_status 约束完全一致");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,3 +127,76 @@ CREATE TABLE user_local_eligibilities (
|
||||
verified_at TIMESTAMP WITH TIME ZONE, verified_by VARCHAR(36), created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 以下表为 Phase 12 复验补齐(测试保真度盲区):
|
||||
-- 生产迁移里存在、而测试库此前**完全没有**,导致相关端到端路径无法测试。
|
||||
-- 例:GET /api/v1/home 的 getHome() 会调用 UserIdentityMapper.findByUserId,
|
||||
-- 测试库缺 user_identities → BadSqlGrammar("Table USER_IDENTITIES not found")
|
||||
-- 整个首页接口在测试环境下不可用,任何首页回归都无法验证。
|
||||
-- 列定义对齐对应迁移,仅保留实体实际读写的列。
|
||||
-- ============================================================================
|
||||
|
||||
-- V002: user_identities 实名认证表
|
||||
CREATE TABLE user_identities (
|
||||
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), real_name_enc CLOB, id_number_enc CLOB,
|
||||
id_number_hash VARCHAR(128), gender VARCHAR(16), birth_date DATE,
|
||||
verification_status VARCHAR(32), provider VARCHAR(64), provider_request_id VARCHAR(128),
|
||||
verified_at TIMESTAMP WITH TIME ZONE, failed_reason_code VARCHAR(64),
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- V003: review_tasks 审核任务(首页 buildUserState 需查询审核状态)
|
||||
CREATE TABLE review_tasks (
|
||||
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), review_type VARCHAR(32), status VARCHAR(32),
|
||||
assigned_operator_id VARCHAR(36), submitted_at TIMESTAMP WITH TIME ZONE,
|
||||
started_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE,
|
||||
final_comment VARCHAR(1000), version INT,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- V017: local_eligibility_evidences 本地资格佐证材料
|
||||
CREATE TABLE local_eligibility_evidences (
|
||||
id VARCHAR(36) PRIMARY KEY, local_eligibility_id VARCHAR(36), file_key VARCHAR(256),
|
||||
file_id VARCHAR(36), evidence_type VARCHAR(64), status VARCHAR(32),
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- V019: home_banners 首页 Banner
|
||||
CREATE TABLE home_banners (
|
||||
id VARCHAR(36) PRIMARY KEY, title VARCHAR(200), subtitle VARCHAR(300), image_file_id VARCHAR(36),
|
||||
button_text VARCHAR(64), target_type VARCHAR(64), target_value VARCHAR(200),
|
||||
audience_scope VARCHAR(64), sort_order INT, start_at TIMESTAMP WITH TIME ZONE,
|
||||
end_at TIMESTAMP WITH TIME ZONE, status VARCHAR(32), created_by VARCHAR(36), updated_by VARCHAR(36),
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- V019: service_locations 善缘服务站
|
||||
CREATE TABLE service_locations (
|
||||
id VARCHAR(36) PRIMARY KEY, name VARCHAR(128), short_name VARCHAR(64), address CLOB,
|
||||
latitude DECIMAL(10, 7), longitude DECIMAL(10, 7), contact_phone_enc CLOB,
|
||||
contact_phone_display VARCHAR(32), opening_hours_text CLOB, service_description CLOB,
|
||||
cover_file_id VARCHAR(36), primary_organization_id VARCHAR(36), notice_text CLOB,
|
||||
status VARCHAR(32), created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- V026: content_articles 婚恋课堂文章
|
||||
CREATE TABLE content_articles (
|
||||
id VARCHAR(36) PRIMARY KEY, category_id VARCHAR(36), title VARCHAR(200), summary VARCHAR(500),
|
||||
content CLOB, cover_file_id VARCHAR(36), author_operator_id VARCHAR(36), status VARCHAR(32),
|
||||
publish_at TIMESTAMP WITH TIME ZONE, view_count INT, sort_order INT,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- V010: activities 公益活动(首页近期活动预览)
|
||||
CREATE TABLE activities (
|
||||
id VARCHAR(36) PRIMARY KEY, title VARCHAR(200), cover_file_id VARCHAR(36), description CLOB,
|
||||
organizer_org_id VARCHAR(36), coorganizer_text VARCHAR(500), start_at TIMESTAMP WITH TIME ZONE,
|
||||
end_at TIMESTAMP WITH TIME ZONE, location_name VARCHAR(200), location_address VARCHAR(500),
|
||||
male_limit INT, female_limit INT, total_limit INT, min_age SMALLINT, max_age SMALLINT,
|
||||
registration_start_at TIMESTAMP WITH TIME ZONE, registration_end_at TIMESTAMP WITH TIME ZONE,
|
||||
need_review BOOLEAN, status VARCHAR(32), created_by VARCHAR(36),
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user