Files
danyang-match/docs/Phase-11-V11-Recovery-Completion-Report-2026-09-16.md
T

159 lines
8.4 KiB
Markdown
Raw Normal View 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`):
```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)需用户确认后启动。按计划每阶段出报告后停止。