From c607e87b45db43ed7ea11be6868ca84bdadea0ef Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Wed, 16 Sep 2026 23:01:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(relationship):=20Phase=2012=20=E5=A4=8D?= =?UTF-8?q?=E9=AA=8C=20=E2=80=94=E2=80=94=20=E4=BF=AE=E5=A4=8D=E5=AE=9E?= =?UTF-8?q?=E4=BD=93=E2=86=94=E7=94=9F=E4=BA=A7=E8=BF=81=E7=A7=BB=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=80=A7=E6=BC=82=E7=A7=BB=EF=BC=88=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E6=95=85=E9=9A=9C=E6=97=8F=EF=BC=89/=20=E5=8F=8C=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=20episode=20=E4=B8=8E=20NPE=20/=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=BA=93=E7=BC=BA=E8=A1=A8=E8=87=B4=E9=A6=96=E9=A1=B5=E9=9B=B6?= =?UTF-8?q?=E8=A6=86=E7=9B=96=20/=20=E8=AE=A1=E5=88=92=E9=A1=B9=202=20?= =?UTF-8?q?=E6=9C=AA=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../home/service/HomeApplicationService.java | 6 + .../platform/match/service/MatchService.java | 12 +- .../service/RelationshipService.java | 15 + ...__align_entity_columns_with_migrations.sql | 165 ++++++ .../deployment/EntityMigrationParityTest.java | 490 ++++++++++++++++++ .../deployment/MigrationValidationTest.java | 4 +- .../HomePremaritalGuidancePhase12Test.java | 135 +++++ .../RelationshipProgressPhase12Test.java | 361 +++++++++++++ ...ionshipStatusServiceDoubleConfirmTest.java | 53 +- .../RelationshipStatusServiceTest.java | 70 ++- backend/src/test/resources/schema-h2.sql | 73 +++ docs/JIRA_BACKLOG.md | 19 +- ...1-Recovery-Completion-Report-2026-09-16.md | 268 ++++++++++ docs/STATUS.md | 47 +- ...阳 V1.1 客户需求基线增量开发与修复计划.md | 14 +- 15 files changed, 1686 insertions(+), 46 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V037__align_entity_columns_with_migrations.sql create mode 100644 backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/home/HomePremaritalGuidancePhase12Test.java create mode 100644 backend/src/test/java/com/danyang/match/platform/relationship/RelationshipProgressPhase12Test.java create mode 100644 docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md diff --git a/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java b/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java index db2d376..f28f342 100644 --- a/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java +++ b/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java @@ -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", "正在认真考虑未来?", "可以了解善缘丹阳公益婚前辅导。", diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/MatchService.java b/backend/src/main/java/com/danyang/match/platform/match/service/MatchService.java index a57294b..e172d0e 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/MatchService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/MatchService.java @@ -55,10 +55,16 @@ public final class MatchService { } // 规则 2 & 3:新建 + // Phase 12 复验修复:prior.episode 可能为 null(历史数据 / 早期由 RelationshipService + // 创建的 Match 未写该列),直接 `getEpisode() + 1` 会对 null 自动拆箱抛 NPE。 Optional 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()); diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java index 198c7af..b702602 100644 --- a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java +++ b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java @@ -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处理 diff --git a/backend/src/main/resources/db/migration/V037__align_entity_columns_with_migrations.sql b/backend/src/main/resources/db/migration/V037__align_entity_columns_with_migrations.sql new file mode 100644 index 0000000..29d86e5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V037__align_entity_columns_with_migrations.sql @@ -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; diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java new file mode 100644 index 0000000..cfa6b95 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java @@ -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 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 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 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 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 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> migrationColumns = parseMigrationColumns(); + Map> entityColumns = parseEntityColumns(); + + List hardFailures = new ArrayList<>(); + List softFailures = new ArrayList<>(); + + entityColumns.forEach((table, columns) -> { + Set prod = migrationColumns.get(table); + if (prod == null) { + hardFailures.add(String.format("%s: 实体存在但生产迁移未创建该表(列: %s)", + table, new TreeSet<>(columns))); + return; + } + Set missing = new TreeSet<>(columns); + missing.removeAll(prod); + if (missing.isEmpty()) { + return; + } + Set hard = new TreeSet<>(); + Set 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 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 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> migrationColumns = parseMigrationColumns(); + + Set 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 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> 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> 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> parseMigrationColumns() throws IOException { + assertTrue(Files.isDirectory(MIGRATION_DIR), + "迁移目录不存在: " + MIGRATION_DIR.toAbsolutePath()); + + List files; + try (Stream 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> 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 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 parseColumnDefinitions(String body) { + Set columns = new LinkedHashSet<>(); + List 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> parseEntityColumns() throws IOException { + assertTrue(Files.isDirectory(MAIN), "源码目录不存在: " + MAIN.toAbsolutePath()); + + Map> result = new LinkedHashMap<>(); + List entityFiles; + try (Stream 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 columns = result.computeIfAbsent(table, k -> new LinkedHashSet<>()); + + // 所有 private 字段 + Set 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(); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 17d16f4..14a07ca 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -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/"; diff --git a/backend/src/test/java/com/danyang/match/platform/home/HomePremaritalGuidancePhase12Test.java b/backend/src/test/java/com/danyang/match/platform/home/HomePremaritalGuidancePhase12Test.java new file mode 100644 index 0000000..a5b9a60 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/home/HomePremaritalGuidancePhase12Test.java @@ -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 recommendations() { + HomeDTO dto = homeApplicationService.getHome(userId); + assertNotNull(dto, "首页聚合响应不应为 null"); + List 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 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 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 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 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()); + } + } + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipProgressPhase12Test.java b/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipProgressPhase12Test.java new file mode 100644 index 0000000..24e4c35 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipProgressPhase12Test.java @@ -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 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 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> 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 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 reqs = successRequestMapper.selectList( + new LambdaQueryWrapper() + .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().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().eq(Conversation::getMatchId, second.getId())), + "新 Match 不应自动继承旧会话(旧聊天权限不恢复)"); + + // 且旧会话仍挂在旧 Match 上,不会被新 Match 复用 + assertEquals(1L, conversationMapper.selectCount( + new LambdaQueryWrapper().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() + .orderByDesc(RelationshipSuccessRequest::getCreatedAt) + .last("LIMIT 1")); + assertNotNull(req); + assertEquals(userA, req.getConfirmedBy(), + "confirmedBy 必须是实际调用者,不能被他方写入"); + assertEquals(Constants.SUCCESS_REQUEST_STATUS_CONFIRMED, req.getStatus()); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipStatusServiceDoubleConfirmTest.java b/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipStatusServiceDoubleConfirmTest.java index 2a590e5..86b7c7b 100644 --- a/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipStatusServiceDoubleConfirmTest.java +++ b/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipStatusServiceDoubleConfirmTest.java @@ -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 expected = Set.of("pending", "confirmed", "declined"); + Set 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); } } diff --git a/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipStatusServiceTest.java b/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipStatusServiceTest.java index 8a8252b..0480db5 100644 --- a/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipStatusServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/relationship/RelationshipStatusServiceTest.java @@ -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 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 expected = Set.of("active", "getting_to_know", "unmatched", "successful"); + Set 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 expected = Set.of("user_like", "activity_mutual", "matchmaker"); + Set 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 expected = Set.of("pending", "confirmed", "declined"); + Set actual = Set.of( + Constants.SUCCESS_REQUEST_STATUS_PENDING, + Constants.SUCCESS_REQUEST_STATUS_CONFIRMED, + Constants.SUCCESS_REQUEST_STATUS_DECLINED); + assertEquals(expected, actual, + "成功牵手状态常量必须与生产 chk_success_status 约束完全一致"); } } diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 68ae34c..9106259 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -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 +); + diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index d45a0aa..92719a5 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -5,6 +5,22 @@ > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > +> **2026-09-16 Phase 12 复验关闭:** EPIC-P12-V11 已完成。复验中发现并修复 4 处缺陷—— +> **D1(Critical,生产故障 · 同一族系统性缺陷)** 实体与生产迁移**系统性漂移**:23 张表、50 个列在 `db/migration` 中 +> 根本不存在,而测试库 `schema-h2.sql` 手写了它们 → 全量测试绿 = **假绿**。其中 11 张表属"INSERT 必崩"级 +> (缺 `id`/`created_at`/`updated_at`,被 `AutoFillHandler` 强制填充),含 `matches`(缺 `episode`/`source_id`/ +> `last_activity_at`/`unmatched_by`/`deleted_at`)、`user_preferences`、`permissions`、`roles`、`consent_records`、 +> `risk_events` 等;**真实 PG 上 Match 创建、择偶偏好保存、权限/角色写入、同意记录、风险事件全部失败**。 +> 修复:新增 `V037__align_entity_columns_with_migrations.sql`(幂等 `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`,**首页接口零回归覆盖**。 +> **D4(Medium)** Phase 12 计划项 2 实际**未实现**:`getting_to_know` 分支只产出 counseling。修复:补 premarital 引导。 +> 新增永久守卫 `EntityMigrationParityTest`(5 用例,区分"必崩级/潜伏级",经**两轮负向验证**证明会变红); +> **删除 2 个假测试类**(`RelationshipStatusServiceTest`/`RelationshipStatusServiceDoubleConfirmTest` 的 `assertTrue(true)` 占位)。 +> 后端全量 **371 tests / 0 failures / 0 errors**。 +> 证据:`docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 13 需用户确认后启动。** +> > **2026-09-16 Phase 11 复验关闭:** EPIC-P11-V11 已完成。复验中发现并修复 3 处缺陷—— > **D3(Critical,生产故障)** `NotificationService` 全部 10 种通知使用复合 type(`appointment_submitted` / > `counseling_assigned` / `le_verified` 等),违反生产 `chk_notification_type`(仅允许短名), @@ -43,7 +59,8 @@ | Phase 08 Like / Match / Block 复验 | 已完成(2026-09-16) | 见 `Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处真实缺陷(搜索绕过 Block、详情无 Block 检查、`matches.source` 取值越界),后端 308 tests 全绿 | | Phase 09 Text Chat 复验 | 已完成(2026-09-16) | 见 `Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处真实缺陷(WebSocket 下行死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天接口 IDOR),后端 328 tests 全绿;Phase 10 尚未获准 | | Phase 10 Image & Chat Safety 复验 | 已完成(2026-09-16) | 见 `Phase-10-V11-Recovery-Completion-Report-2026-09-16.md`;修复 2 处缺陷(图片上传魔数校验死代码致本地资格佐证上传 100% 失败、高风险词写入日志),推翻旧报告 2 项安全声明,后端 334 tests 全绿;Phase 11 尚未获准 | -| Phase 11 Notification 复验 | 已完成(2026-09-16) | 见 `Phase-11-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处缺陷(10 种通知 type 违反生产 CHECK 约束致全部落库失败、幂等无 DB 兜底、婚前辅导通知未接线),后端 346 tests 全绿;Phase 12 尚未获准 | +| Phase 11 Notification 复验 | 已完成(2026-09-16) | 见 `Phase-11-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处缺陷(10 种通知 type 违反生产 CHECK 约束致全部落库失败、幂等无 DB 兜底、婚前辅导通知未接线),后端 346 tests 全绿 | +| Phase 12 Relationship Progress 复验 | 已完成(2026-09-16) | 见 `Phase-12-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处缺陷(**实体↔生产迁移系统性漂移 23 表 50 列**、双路径 episode/NPE、测试库缺 7 表致首页零覆盖、计划项 2 未实现),新增永久守卫 `EntityMigrationParityTest`,删除 2 个假测试类,后端 371 tests 全绿;Phase 13 尚未获准 | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | diff --git a/docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md b/docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md new file mode 100644 index 0000000..f9b0811 --- /dev/null +++ b/docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md @@ -0,0 +1,268 @@ +# 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.** diff --git a/docs/STATUS.md b/docs/STATUS.md index 26c3e85..d1c16f4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,8 +1,8 @@ # 项目状态跟踪 — STATUS.md -> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-11 RECOVERY CLOSED / GIT CLEAN`。** +> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-12 RECOVERY CLOSED / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(346 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)复验已关闭**,四轮共修复 12 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 12 需用户确认后启动。** +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(371 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)复验已关闭**,五轮共修复 16 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 13 需用户确认后启动。** 最后更新: 2026-09-16 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) @@ -21,16 +21,16 @@ | 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s | | 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 | | 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md | -| Flyway 迁移 | V001~V036 | V028~V036 已纳入 STATUS | -| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**346 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest` | +| Flyway 迁移 | V001~V037 | V028~V037 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列) | +| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**371 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test` | | 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 | | `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 | | Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA | | Miniapp | **验证通过** | 2026-09-16:tsc ✅;**11 核心页全部 TS-only(11/11)**;首批 11 页于 2026-09-16 通过用户验收;discover 筛选重设计已通过 DevTools 验证 | | UI 契约测试 | **13/13 通过** | 2026-09-16 运行:全部退出码 0;含 capability 独立化适配与文档基线口径 | | Git 工作区 | **已清零** | 阶段 0~4 共 24+ commit 可追溯;`git status --porcelain` 0 行 | -| 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(299 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16) | -| UAT / RC / Production | **UI 验收通过,前后端契约已收敛,Phase 08-09 已关闭** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛;Phase 08/09 复验通过并关闭,可推进 Phase 10(**需用户确认后启动**) | +| 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(371 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16)、实体↔生产迁移漂移修复(V037,2026-09-16) | +| UAT / RC / Production | **UI 验收通过,前后端契约已收敛,Phase 08-12 已关闭** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛;Phase 08/09/10/11/12 复验通过并关闭,累计修复 16 处真实缺陷,可推进 Phase 13(**需用户确认后启动**) | --- @@ -40,6 +40,7 @@ |------|------|------| | 1.0.0-RC.2 | 2026-09-01 | V1.0 核心功能冻结 | | 1.1.0-dev | 2026-09-08 | V1.1 基线搭建启动(Phase 01 进行中) | +| 1.1.0-rc.3 | 2026-09-16 | Phase 08~12 复验关闭;新增 V037 对齐实体↔生产迁移;全量回归 371 tests 全绿 | --- @@ -211,12 +212,44 @@ | Constants 新增转介来源常量 | ✅ | | INV-005 红娘不可代用户操作验证 | ✅ | -### Phase 12 — 关系状态轻量扩展 ✅ 已完成 +### Phase 12 — 关系状态轻量扩展 ✅ 已完成(2026-09-16 KEEP-VERIFY 复验通过) | 任务 | 状态 | |------|------| | Successful首页服务建议(婚前辅导引导) | ✅ | | GettingToKnow服务建议规则 | ✅ | | Counseling recommendation rules | ✅ | +| 成功关系双确认 / 恢复 / 退出 / 历史 Episode / 并发确认 | ✅ 行为测试 14 项 | +| getting_to_know 阶段婚前辅导入口引导(计划项 2) | ✅ 已接线 `HomeApplicationService.buildServiceRecommendations` | +| 工作人员不能替用户确认成功关系(INV-005) | ✅ 非成员路径断言抛业务异常 | + +> **2026-09-16 复验结论(KEEP-VERIFY/EXTEND)**:历史 ✅ 不可信,逐条核对运行时路径后修复 4 处缺陷: +> - **D1(Critical,生产故障 · 同一族系统性缺陷)** 实体与生产迁移**系统性漂移**:23 张表、50 个列在 +> `db/migration` 中根本不存在,而测试库 `schema-h2.sql` 手写了它们 → 全量测试绿 = 假绿。其中 +> **11 张表属"INSERT 必崩"级**(缺 `id`/`created_at`/`updated_at`,被 `AutoFillHandler` 强制填充, +> 每次写入必进 SQL):`matches`(缺 `episode`/`source_id`/`last_activity_at`/`unmatched_by`/`deleted_at`)、 +> `user_preferences`(缺 `id` + 11 个权重列)、`permissions`、`roles`、`consent_records`、`risk_events`、 +> `review_reasons`、`match_rule_configs`、`matchmaker_followups`、`matchmaker_recommendations`、 +> `user_privacy_settings`。**真实 PG 上 Match 创建、择偶偏好保存、权限/角色写入、同意记录、风险事件 +> 全部失败**。修复:新增 `V037__align_entity_columns_with_migrations.sql`(幂等 `ADD COLUMN IF NOT +> EXISTS`,含存量数据 `episode` 回填与 `id` 补发)。 +> - **D2(High)** `RelationshipService.createMatch` 不写 `episode`/`lastActivityAt`,与 +> `MatchService.createOrReuseMatch` 形成**双路径字段填充不一致**;且 `episode` 为 null 时 +> `MatchService` 的 `m.getEpisode() + 1` 对 `Integer` **自动拆箱抛 NPE**。修复:两条路径统一按 +> AGENTS.md #20 推导 episode,并做 null 兜底。 +> - **D3(High)** 测试库缺 `user_identities` 等 7 张表 → `GET /api/v1/home` 在测试环境直接 +> `BadSqlGrammar("Table USER_IDENTITIES not found")`,**首页接口零回归覆盖**。修复:补齐 +> `user_identities`/`review_tasks`/`local_eligibility_evidences`/`home_banners`/`service_locations`/ +> `content_articles`/`activities` 七张表(列对齐生产迁移)。 +> - **D4(Medium)** Phase 12 计划项 2 实际**未实现**:`getting_to_know` 分支只产出 `counseling`, +> 婚前辅导入口仅在 `matched_successfully` 才出现。修复:`HomeApplicationService.buildServiceRecommendations` +> 的 `getting_to_know` 分支补 `premarital` 引导(与 counseling 并列,不互相挤占)。 +> - **新增永久守卫** `EntityMigrationParityTest`(5 用例):把「实体字段 ⊆ 生产迁移累积列」与 +> 「测试库覆盖关键业务表」固化为**编译后自动可验证的不变量**,并区分「必崩级 / 潜伏级」。经 +> **两轮负向验证**证明会变红(第一轮分级过窄已修正:`episode` 由"潜伏级"纠正为"必崩级")。 +> - **删除假测试**:`RelationshipStatusServiceTest` 与 `RelationshipStatusServiceDoubleConfirmTest` +> 原含 `assertTrue(true); // 占位测试`,对 INV-005 零覆盖(AGENTS.md 明令禁止)→ 已删除占位断言, +> 行为验证移至走真实服务方法的 `RelationshipProgressPhase12Test`(14 用例,含并发确认)。 +> - 报告:`docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md` ### V1.1 待实现模块(按 Phase 顺序) diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md index 638fd46..0cbe6e7 100644 --- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md +++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md @@ -239,11 +239,19 @@ V036;D2——接线婚前辅导及管理端排期通知。`sendExternal` 在 **Files:** relationship 状态服务、Match Episode、relationship-status 页面。 -- [ ] 验证成功关系双确认、恢复/退出、历史 Episode 和并发确认。 -- [ ] 补充 getting_to_know 阶段的婚前辅导入口引导。 -- [ ] 验证工作人员不能替用户确认成功关系。 +- [x] 验证成功关系双确认、恢复/退出、历史 Episode 和并发确认。 +- [x] 补充 getting_to_know 阶段的婚前辅导入口引导。 +- [x] 验证工作人员不能替用户确认成功关系。 **Gate:** 单方确认不改变最终关系;历史 Episode 可追踪但不会恢复旧聊天权限。 +(2026-09-16 复验:修复 D1——实体与生产迁移系统性漂移,23 表 50 列缺失,其中 `matches.episode` +等 11 张表属"INSERT 必崩"级生产故障,真实 PG 上 Match 创建 / 偏好保存 / 权限写入等全部失败; +D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `MatchService` 双路径字段不一致, +且 `episode` 为 null 时 `getEpisode() + 1` 自动拆箱 NPE;D3——测试库缺 `user_identities` 等 7 张表, +致 `GET /home` 在测试环境直接 BadSqlGrammar,首页零回归覆盖;D4——Phase 12 计划项 2 实际未实现 +(getting_to_know 只给 counseling,无婚前辅导入口)。新增 `EntityMigrationParityTest` 把 +「实体字段 ⊆ 生产迁移列」「测试库覆盖关键表」固化为自动守卫,并经负向验证证明会变红。 +真机行为:双确认/并发确认/历史 Episode 不恢复旧聊天/非成员不可代确认 —— 14 项全绿。) ### Phase 13:公益红娘转介 — EXTEND