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 机制。
This commit is contained in:
WorkBuddy
2026-09-16 21:50:56 +08:00
parent fd7195ecb0
commit 52e11a6c65
13 changed files with 669 additions and 49 deletions
@@ -96,8 +96,15 @@ public class CounselingService {
svc.setScheduledStartAt(start);
svc.setScheduledEndAt(end);
mapper.updateById(svc);
notificationService.counselingScheduled(svc.getUserId(), serviceId,
start.toString() + " ~ " + end.toString());
// Phase 11:婚前辅导(premarital)与婚恋咨询共用排期流程,但通知类型分开,
// 便于后续按服务类型做差异化触达与统计(P11-V11-T07)。
if ("premarital".equals(svc.getServiceType())) {
notificationService.premaritalScheduled(svc.getUserId(), serviceId,
start.toString() + " ~ " + end.toString());
} else {
notificationService.counselingScheduled(svc.getUserId(), serviceId,
start.toString() + " ~ " + end.toString());
}
}
/** INV-012: 完成 — 只有 assigned Counselor 能调 */
@@ -160,6 +160,18 @@ public class CounselingServiceApp {
cs.setScheduledEndAt(endAt);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
// Phase 11:管理端排期同样需要触达用户;婚前辅导与婚恋咨询分开通知类型。
// 通知失败不回滚排期事实(send 内部对 sendExternal 已做隔离)。
try {
String time = startAt + " ~ " + endAt;
if ("premarital".equals(cs.getServiceType())) {
notificationService.premaritalScheduled(cs.getUserId(), counselingId, time);
} else {
notificationService.counselingScheduled(cs.getUserId(), counselingId, time);
}
} catch (Exception e) {
log.warn("排期通知发送失败: counselingId={}, err={}", counselingId, e.getMessage());
}
return toDTO(cs);
}
@@ -88,7 +88,7 @@ public class NotificationService {
/** P11-V11-T01 appointment submitted — 用户提交预约 */
public void appointmentSubmitted(String userId, String appointmentId, String serviceName) {
send(userId, "appointment_submitted",
send(userId, "appointment",
"预约已提交",
"您预约的" + serviceName + "已提交,我们将在 24 小时内确认。",
"appointment", appointmentId);
@@ -96,7 +96,7 @@ public class NotificationService {
/** P11-V11-T02 appointment confirmed — 红娘/运营确认 */
public void appointmentConfirmed(String userId, String appointmentId, String serviceName, String time) {
send(userId, "appointment_confirmed",
send(userId, "appointment",
"预约已确认",
"您预约的" + serviceName + "已确认,时间:" + time + "。",
"appointment", appointmentId);
@@ -104,7 +104,7 @@ public class NotificationService {
/** P11-V11-T03 appointment changed */
public void appointmentChanged(String userId, String appointmentId, String serviceName, String newTime) {
send(userId, "appointment_changed",
send(userId, "appointment",
"预约有变动",
"您预约的" + serviceName + "时间变更为:" + newTime + "。",
"appointment", appointmentId);
@@ -113,7 +113,7 @@ public class NotificationService {
/** P11-V11-T04 counseling assigned — 咨询人员分配 */
public void counselingAssigned(String userId, String counselingId) {
// P11-V11-T11: 外部文案不泄露敏感咨询类型 → 不说明是"婚恋咨询"还是"婚前辅导"
send(userId, "counseling_assigned",
send(userId, "counseling",
"服务已安排",
"您的服务已安排,工作人员将尽快与您联系。",
"counseling", counselingId);
@@ -121,7 +121,7 @@ public class NotificationService {
/** P11-V11-T05 counseling scheduled */
public void counselingScheduled(String userId, String counselingId, String time) {
send(userId, "counseling_scheduled",
send(userId, "counseling",
"服务已排期",
"您的服务已排期,时间:" + time + "。",
"counseling", counselingId);
@@ -129,7 +129,7 @@ public class NotificationService {
/** P11-V11-T06 counseling completed */
public void counselingCompleted(String userId, String counselingId) {
send(userId, "counseling_completed",
send(userId, "counseling",
"服务已完成",
"您的服务已完成,如有需要欢迎再次联系我们。",
"counseling", counselingId);
@@ -137,7 +137,7 @@ public class NotificationService {
/** P11-V11-T07 premarital scheduled — 婚前辅导 */
public void premaritalScheduled(String userId, String premaritalId, String time) {
send(userId, "premarital_scheduled",
send(userId, "premarital",
"婚前辅导已排期",
"您的婚前辅导已排期,时间:" + time + "。",
"premarital", premaritalId);
@@ -148,7 +148,7 @@ public class NotificationService {
String detail = missingItems != null && missingItems.length > 0
? "需补充:" + String.join("、", missingItems)
: "需补充相关证明材料。";
send(userId, "le_need_evidence",
send(userId, "local_eligibility",
"本地资格需补充材料",
detail,
"local_eligibility", leId);
@@ -156,7 +156,7 @@ public class NotificationService {
/** P11-V11-T09 local eligibility verified */
public void localEligibilityVerified(String userId, String leId) {
send(userId, "le_verified",
send(userId, "local_eligibility",
"本地资格已通过",
"恭喜!您的本地资格认证已通过。",
"local_eligibility", leId);
@@ -165,7 +165,7 @@ public class NotificationService {
/** P11-V11-T10 local eligibility rejected */
public void localEligibilityRejected(String userId, String leId, String reason) {
String msg = reason != null ? "原因:" + reason : "请检查材料后重新提交。";
send(userId, "le_rejected",
send(userId, "local_eligibility",
"本地资格未通过",
"您的本地资格认证未通过。" + msg,
"local_eligibility", leId);
@@ -208,7 +208,7 @@ public class NotificationService {
public void sendAppointmentSubmitted(String userId, String operatorId, String appointmentId) {
log.debug("Appointment submitted notification: userId={}, operatorId={}, appointmentId={}",
userId, operatorId, appointmentId);
send(userId, "appointment_submitted",
send(userId, "appointment",
"预约已提交",
"您的预约已提交,我们将在 24 小时内确认。",
"appointment", appointmentId);
@@ -218,7 +218,7 @@ public class NotificationService {
public void sendAppointmentConfirmed(String userId, String serviceLocationId, String appointmentId) {
log.debug("Appointment confirmed notification: userId={}, locationId={}, appointmentId={}",
userId, serviceLocationId, appointmentId);
send(userId, "appointment_confirmed",
send(userId, "appointment",
"预约已确认",
"您的预约已确认,请按时前往服务站点。",
"appointment", appointmentId);
@@ -236,7 +236,7 @@ public class NotificationService {
/** 本地资格需补充佐证 */
public void sendLocalEligibilityNeedEvidence(String userId) {
send(userId, "le_need_evidence",
send(userId, "local_eligibility",
"本地资格需补充材料",
"请补充相关证明材料后重新提交。",
"local_eligibility", null);
@@ -244,7 +244,7 @@ public class NotificationService {
/** 本地资格已通过 */
public void sendLocalEligibilityVerified(String userId) {
send(userId, "le_verified",
send(userId, "local_eligibility",
"本地资格已通过",
"恭喜!您的本地资格认证已通过。",
"local_eligibility", null);
@@ -252,7 +252,7 @@ public class NotificationService {
/** 本地资格未通过 */
public void sendLocalEligibilityRejected(String userId, String reason) {
send(userId, "le_rejected",
send(userId, "local_eligibility",
"本地资格未通过",
"您的本地资格认证未通过。" + (reason != null ? "原因:" + reason : "请检查材料后重新提交。"),
"local_eligibility", null);
@@ -265,7 +265,9 @@ public class NotificationService {
*/
public void sendMatchmakerReferralCreated(String userId, String operatorName,
String referralType, String businessId) {
send(userId, "referral_created",
// type 必须落在生产 chk_notification_type 白名单内;
// 转介来源(service_station/activity/counseling)交由 businessType 承载。
send(userId, "matchmaker",
"红娘为您推荐了服务",
(operatorName != null ? operatorName : "公益红娘") + "为您推荐了一项服务,请打开小程序查看。",
referralType, businessId);
@@ -298,33 +300,37 @@ public class NotificationService {
/**
* 隐私安全文案映射表 — INV-010 敏感数据默认拒绝
*
* 外部渠道(微信锁屏)绝对不能出现具体的咨询/辅导类型名称
* <p>外部渠道(微信锁屏)绝对不能出现具体的咨询/辅导类型名称。
*
* <p>注意:通知 {@code type} 已归一到 CHECK 约束允许的短名
* (appointment / counseling / premarital / local_eligibility),
* 因此这里<b>不能</b>再用 type 区分同一业务的多个状态(如"预约已提交/已确认/有变动"),
* 否则映射表出现重复 key 且信息坍缩。改为:隐私级别由 type 决定,
* 具体状态沿用站内 title 之中性表述(title 本身已是面向用户的安全措辞)。
*/
private Map<String, String> buildPrivacySafeCopy(String type, String title, String content) {
// 映射表:通知 type → 外部安全标题
Map<String, String> safeTitle = Map.ofEntries(
Map.entry("appointment_submitted", "预约已提交"),
Map.entry("appointment_confirmed", "预约已确认"),
Map.entry("appointment_changed", "预约有变动"),
Map.entry("counseling_assigned", "服务已安排"), // ❌ 不说"咨询"
Map.entry("counseling_scheduled", "服务已排期"), // ❌ 不说"咨询"
Map.entry("counseling_completed", "服务已完成"), // ❌ 不说"咨询"
Map.entry("premarital_scheduled", "婚前辅导已排期"), // 婚前期可公开展示
Map.entry("le_need_evidence", "资格需补充材料"),
Map.entry("le_verified", "资格已通过"),
Map.entry("le_rejected", "资格未通过")
);
String externalTitle = safeTitle.getOrDefault(type, "善缘丹阳通知");
// 外部消息正文永远不带敏感详情
// 外部(锁屏)正文 — 只表态"有新的服务通知",不含任何业务详情
String externalContent = switch (type) {
case "counseling_assigned", "counseling_scheduled", "counseling_completed" ->
"您有一条新的服务通知,请打开小程序查看。";
case "premarital_scheduled" ->
"请按时参加,祝您幸福。";
case "counseling" -> "您有一条新的服务通知,请打开小程序查看。";
case "premarital" -> "请按时参加,祝您幸福。";
case "local_eligibility" -> "请打开小程序查看您的资格核验进度。";
default -> "请打开小程序查看详情。";
};
// 外部标题:咨询类强制中性化为「服务」,其余沿用站内标题(站内标题本身不含敏感详情)
String externalTitle = switch (type) {
case "counseling" -> "服务通知";
case "premarital" -> "婚前辅导通知";
case "local_eligibility" -> "资格核验通知";
case "appointment" -> "预约通知";
default -> "善缘丹阳通知";
};
// 咨询类额外硬校验:即便传入的 title 含敏感词也不外泄
if ("counseling".equals(type) && externalTitle.contains("咨询")) {
externalTitle = "服务通知";
}
return Map.of("title", externalTitle, "content", externalContent);
}
@@ -0,0 +1,28 @@
-- V036: 补 notifications 幂等唯一索引
--
-- 背景(Phase 11 复验发现 D1):
-- NotificationService.send 的幂等仅依赖 SELECT-then-INSERT(mapper.findExisting),
-- 在并发 / 嵌套事务下两个调用方可能都查不到、再各自插入,产生重复业务通知,
-- 直接违反 Phase 11 门禁「已提交事务通知失败可重试且不产生重复业务记录」。
-- 与 Phase 09 messages 的 uk_message_client_id 属同类竞态,需由 DB 唯一索引兜底。
--
-- 索引语义:
-- 仅在 business_id 非空时约束(business_id IS NULL 表示无幂等键的通知,允许共存,
-- 例如同一用户多条系统提示)。partial index 使 NULL 行不参与唯一性判定。
-- content 参与比对,与 findExisting 的查询条件保持一致,避免过度拦截
-- (同一 businessId 下文案不同应视为不同通知)。
--
-- 前置清理:历史数据可能已存在重复行,先删除保留最早一条,避免迁移失败。
DELETE FROM notifications n
WHERE n.business_id IS NOT NULL
AND n.ctid NOT IN (
SELECT DISTINCT ON (user_id, type, business_type, business_id, content) ctid
FROM notifications
WHERE business_id IS NOT NULL
ORDER BY user_id, type, business_type, business_id, content, created_at ASC
);
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;