Files
danyang-match/docs/Phase-11-V11-Recovery-Completion-Report-2026-09-16.md
T
WorkBuddy 52e11a6c65 fix(notification): Phase 11 复验 —— 修复通知类型违反生产 CHECK 约束(生产故障)/ 幂等无库兜底 / 婚前辅导通知未接线
复验模式:KEEP-VERIFY/EXTEND(不轻信历史 ✅,逐条核对运行时路径与生产约束)。

== D3(Critical · 生产故障)通知 type 全量违反生产 CHECK 约束 ==
NotificationService 的 10 个业务模板传入复合 type
(appointment_submitted / counseling_assigned / le_verified / referral_created 等),
而生产 V020__extend_notification_types.sql 的 chk_notification_type 白名单只允许 12 个短名。
真实 PostgreSQL 下这些通知全部落库失败;测试库 schema-h2.sql 未复刻该约束,故历史 334 测试全绿 → 假绿。
修复:
- type 归一为白名单短名,业务细分交由 businessType/title 承担:
  appointment_submitted|_confirmed|_changed -> appointment
  counseling_assigned|_scheduled|_completed  -> counseling
  premarital_scheduled                       -> premarital
  le_need_evidence|le_verified|le_rejected   -> local_eligibility
  referral_created                           -> matchmaker
- 重写 buildPrivacySafeCopy:原以复合 type 为 key 的 Map.ofEntries 归一后产生重复 key 会抛
  IllegalArgumentException(运行期崩溃),改为按短名 switch。
- schema-h2.sql 补齐生产 CHECK 约束(测试保真度下沉)。

== D1(High)通知幂等仅 SELECT-then-INSERT,无 DB 兜底 ==
NotificationMapper.findExisting 为查后插,并发下可重复落库。
修复:新增 V036__add_notification_idempotency_index.sql,建 partial unique index
uk_notification_business(user_id, type, business_type, business_id, content)
WHERE business_id IS NOT NULL(先按 created_at ASC 去重存量数据)。
schema-h2.sql 同步唯一索引(H2 无 partial index,标准 UNIQUE 对 NULL 多行共存语义等价)。

== D2(Medium)婚前辅导通知未接线 ==
premaritalScheduled 零调用;CounselingServiceApp.scheduleCounseling(P13A-T22 管理端路径)完全不发通知。
修复:CounselingService.schedule 与 CounselingServiceApp.scheduleCounseling 均按 service_type 分流,
premarital 走 premaritalScheduled,其余走 counselingScheduled;
管理端路径包 try/catch —— 通知失败不回滚排期事实。

== 测试(基线 334 → 346,+12) ==
- NotificationPhase11IntegrationTest(5):回滚不发通知(Gate 前半)、提交后可见、
  顺序重试幂等、绕过 findExisting 由唯一索引兜底抛异常且最终仅 1 条、business_id IS NULL 多行共存。
- NotificationPrivacyCopyTest(5):反射校验 4 类隐私文案,咨询类不含「咨询」且标题为「服务通知」,
  不泄露姓名/时间/证件类型;未知 type 安全回退;ALLOWED_TYPES 守卫。
- NotificationTemplateTypeGuardTest(2):关键 —— 调用模板方法本身(而非直接传字面量 send),
  断言 10 个模板全部以白名单短名落库。
- MigrationValidationTest:LATEST_MIGRATION 35 -> 36,DDL 校验补 CREATE UNIQUE INDEX 识别。

== 负向验证(两轮,含第一轮失效教训) ==
第一轮:注入复合 type 后 NotificationPhase11IntegrationTest 仍全绿 ——
  因该测试直接 send(..., "appointment", ...) 传字面量,不经过模板方法,属无效守卫。
第二轮:改为 NotificationTemplateTypeGuardTest 调用模板本身,注入后精准抛
  DataIntegrityViolation,守卫有效性得证。教训已写入报告:守卫必须经负向验证证明会变红。

== 证据 ==
全量回归 346 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(离线 Docker:danyang-match-backend-test:latest + 卷 danyang-m2-cache)
迁移校验 12/12;定向 19/19。

== Gate 判定 ==
回滚事务不发通知 ✅ | 通知失败不回滚核心事实 ✅ | 隐私安全文案 INV-010 ✅
已提交事务通知失败可重试且不产生重复业务记录 ✅(修复后成立)

== 遗留 gap(登记不自动实现) ==
sendExternal 在事务内同步执行外部副作用,当前为占位实现;接入真实微信推送前必须改为
@TransactionalEventListener(AFTER_COMMIT) / REQUIRES_NEW —— 全仓当前无任何 afterCommit 机制。
2026-09-16 21:50:56 +08:00

8.4 KiB
Raw Permalink Blame History

Phase 11(Notification — EXTEND)复验报告

日期:2026-09-16 方法:KEEP-VERIFY —— 不采信历史 ✅,逐条核对运行时路径与生产约束 结论:Gate 部分通过;发现并修复 3 处缺陷,其中 1 处为整个 Phase 功能级不可用的生产故障 基线:fd7195e(Phase 10 关闭) 全量回归:见文末「证据」


1. Gate 判定

计划 Gate:回滚事务不发通知;已提交事务通知失败可重试且不产生重复业务记录。

Gate 断言 判定 证据
回滚事务不发通知 ✅ 成立 NotificationService.send 为默认传播(REQUIRED),随调用方事务一起回滚。NotificationPhase11IntegrationTest#notificationRolledBackWithBusinessTransaction 用真实 TransactionTemplate 制造业务失败 → 通知零残留
已提交事务通知落库可见 ✅ 成立 #notificationPersistedWhenTransactionCommits
通知失败不回滚核心事实 ✅ 成立 sendExternal 包在 try/catch 内(NotificationService:78-82);多调用点(ReviewService、ReferralService、CounselingServiceApp)亦各自 try/catch
可重试且不产生重复业务记录 ⚠️ 修复后成立 修复前仅靠 SELECT-then-INSERT,无 DB 兜底 → 修复前测试记录到重复行(D1)
通知模板本身可用 ⚠️ 修复后成立 修复前 10 种通知在生产 PG 上全部落库失败(D3)

2. 发现与修复

D3(Critical,生产故障)10 种新通知的 type 全部违反生产 CHECK 约束

位置:NotificationService(Phase 11 全部业务模板)

现象:模板传入的 type 是复合名 —— appointment_submitted / appointment_confirmed / appointment_changed / counseling_assigned / counseling_scheduled / counseling_completed / premarital_scheduled / le_need_evidence / le_verified / le_rejected(另有 referral_created)。

生产约束(V004:96 + V020:9):

CHECK (type IN ('review','match','matchmaker','activity','risk','account','system',
                'appointment','counseling','premarital','local_eligibility','service_location'))

只允许短名。真实 PostgreSQL 上,上述 10 种通知的每一次 insert 都会抛 CheckViolation → Phase 11 的核心交付(10 种业务通知)全部不可用。

为何长期未被发现:测试库 backend/src/test/resources/schema-h2.sql 的 notifications 表未复刻 chk_notification_type,因此 300+ 测试全绿。后端既有测试也从未覆盖模板方法的 真实落库路径。

修复:把 type 归一到约束白名单内的短名,业务细分语义交由既有的 business_type / title 承载:

原 type 归一后 businessType
appointment_submitted / _confirmed / _changed appointment appointment
counseling_assigned / _scheduled / _completed counseling counseling
premarital_scheduled premarital premarital
le_need_evidence / le_verified / le_rejected local_eligibility local_eligibility
referral_created matchmaker service_station / activity / ...

同时重写 buildPrivacySafeCopy:原文案表以复合 type 为 key,归一后出现重复 key (Map.ofEntries 会直接抛 IllegalArgumentException)。改为按短 type 决定隐私级别与 中性标题,不再依赖 type 区分状态。

测试库补约束(否则修完仍是盲区):

ALTER TABLE notifications ADD CONSTRAINT chk_notification_type CHECK (
  type IN ('review','match','matchmaker','activity','risk','account','system',
           'appointment','counseling','premarital','local_eligibility','service_location')
);

D1(High)通知幂等无 DB 兜底 —— 并发重试会产生重复记录

位置:NotificationMapper.findExisting + notifications 表

幂等完全依赖 SELECT-then-INSERT(NotificationService:61-64)。两个并发/嵌套事务都可能 查不到、再各自插入 → 重复通知,直接违反 Gate「可重试且不产生重复业务记录」。 既有 NotificationServiceTest#testNotificationNotDuplication 实为 assertTrue(true); // 占位测试,该不变量从未被真正验证。

修复:新增生产迁移 V036__add_notification_idempotency_index.sql:

CREATE UNIQUE INDEX IF NOT EXISTS uk_notification_business
    ON notifications(user_id, type, business_type, business_id, content)
    WHERE business_id IS NOT NULL;
  • 采用 partial index:business_id IS NULL 的"无幂等键"通知(如系统提示)不参与约束, 可多行共存。
  • content 参与比对,与 findExisting 的查询条件一致,避免过度拦截。
  • 迁移前先清理历史重复行(保留最早一条),避免迁移失败。

测试库 schema-h2.sql 同步补该唯一索引(H2 无 partial index,但标准 UNIQUE 对 NULL 的语义——多行 NULL 允许共存——与生产等价)。

D2(Medium)婚前辅导排期通知从未接线

计划要求「新增辅导通知」,premaritalScheduled 模板已实现但全仓库零调用点。 另发现第二条排期路径 CounselingServiceApp.scheduleCounseling(P13A 管理端流程) 完全不发任何通知。

修复:

  • CounselingService.schedule:按 service_type 分流(premarital → premaritalScheduled, 其余 → counselingScheduled)。
  • CounselingServiceApp.scheduleCounseling:补发排期通知,同样按类型分流,并 try/catch 隔离 (通知失败不回滚排期事实)。

3. 未修复项(登记 gap)

项 说明
sendExternal 在事务内执行 外部推送(微信模板消息)目前是事务内同步副作用,一旦后续业务回滚,已发出的外部推送无法收回。当前为占位实现(TODO: 真实调用),暂无实际影响;接入真实微信 API 前必须改为事务提交后发送(@TransactionalEventListener(AFTER_COMMIT) 或 REQUIRES_NEW + 补偿)。全仓库当前无任何 afterCommit / REQUIRES_NEW 机制。
直传凭证路径无服务端校验 Phase 10 已登记,getUploadToken 不落 DB 记录,与通知无关但同属契约层缺口。

4. 测试保真度

新增测试 用例数 覆盖
NotificationPhase11IntegrationTest 5 真实事务回滚不落通知、提交可见、顺序重试幂等、唯一索引阻止重复插入、business_id=NULL 可多行共存
NotificationPrivacyCopyTest 5 INV-010 咨询类外部文案中性化、不泄露姓名/时间、未知 type 回退、本地资格不泄露证件类型、全部白名单 type 均可构造
NotificationTemplateTypeGuardTest 2 真实调用全部 10 个模板方法并断言落库成功(D3 的守卫)

负向验证(两轮,第二轮修正了第一轮的失效守卫):

  1. 第一轮失败:把 appointmentSubmitted 的 type 改回复合名,重跑 NotificationPhase11IntegrationTest → 测试仍全绿。原因:该测试直接调 send(..., "appointment", ...) 传字面量短名,不经过模板方法,模板的硬编码 type 不在被测路径上。这本身是一个重要教训。
  2. 第二轮成功:新增 NotificationTemplateTypeGuardTest(调用模板方法本身), 重新注入复合 type → 精准抛 DataIntegrityViolation(CHECK 约束拦截)。 还原后 grep 确认无复合 type 残留。

迁移基线守卫:MigrationValidationTest 的 LATEST_MIGRATION 由 35 递增为 36, 并让 DDL 校验识别 CREATE UNIQUE INDEX(合法 DDL,此前未覆盖)。12/12 通过。


5. 证据

  • 变更:NotificationService.java、CounselingService.java、CounselingServiceApp.java、 schema-h2.sql、MigrationValidationTest.java;新增 V036__add_notification_idempotency_index.sql
  • 新增测试:NotificationPhase11IntegrationTest(5)、NotificationPrivacyCopyTest(5)、 NotificationTemplateTypeGuardTest(2)
  • 定向验证:19/19(Notification* + PhotoUploadValidationTest)全绿
  • 全量回归:346 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 334,+12)
  • 离线容器:danyang-match-backend-test:latest,卷 danyang-m2-cache

6. Next

Phase 12(Relationship Progress — KEEP-VERIFY/EXTEND)需用户确认后启动。按计划每阶段出报告后停止。