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

159 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```sql
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 区分状态。
**测试库补约束**(否则修完仍是盲区):
```sql
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`:
```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)需用户确认后启动。按计划每阶段出报告后停止。