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:
+9
-2
@@ -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 能调 */
|
||||
|
||||
+12
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+43
-37
@@ -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;
|
||||
+3
-2
@@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* P25-T02: Migration 最终验证
|
||||
* 验证所有 V001-V035 迁移文件存在、命名规范、语法正确
|
||||
* 验证所有 V001-V036 迁移文件存在、命名规范、语法正确
|
||||
*/
|
||||
class MigrationValidationTest {
|
||||
|
||||
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
|
||||
private static final int LATEST_MIGRATION = 35;
|
||||
private static final int LATEST_MIGRATION = 36;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
@@ -67,6 +67,7 @@ class MigrationValidationTest {
|
||||
boolean hasDDL = content.contains("CREATE TABLE") ||
|
||||
content.contains("ALTER TABLE") ||
|
||||
content.contains("CREATE INDEX") ||
|
||||
content.contains("CREATE UNIQUE INDEX") ||
|
||||
content.contains("INSERT INTO") ||
|
||||
content.contains("CREATE TYPE");
|
||||
assertTrue(hasDDL || content.contains("占位"),
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package com.danyang.match.platform.notification;
|
||||
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 11(Notification — EXTEND)门禁验证。
|
||||
*
|
||||
* <p>计划 Gate:<b>回滚事务不发通知;已提交事务通知失败可重试且不产生重复业务记录。</b>
|
||||
*
|
||||
* <p>本测试用真实事务({@link TransactionTemplate})驱动 {@link NotificationService#send},
|
||||
* 验证通知与业务事务的边界语义,并暴露幂等实现中的竞态窗口。既有
|
||||
* {@code NotificationServiceTest#testNotificationNotDuplication} 仅为
|
||||
* {@code assertTrue(true)} 占位,从未真正验证过幂等不变量。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class NotificationPhase11IntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
|
||||
@Autowired
|
||||
private NotificationMapper notificationMapper;
|
||||
|
||||
@Autowired
|
||||
private PlatformTransactionManager txManager;
|
||||
|
||||
private static final String USER = "u-notif-p11";
|
||||
|
||||
private List<Notification> allFor(String userId, String type) {
|
||||
return notificationMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getUserId, userId)
|
||||
.eq(Notification::getType, type));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate 前半:业务事务回滚时,站内通知一并回滚(不发出)")
|
||||
void notificationRolledBackWithBusinessTransaction() {
|
||||
TransactionTemplate tx = new TransactionTemplate(txManager);
|
||||
String businessId = "biz-rollback-001";
|
||||
|
||||
assertThrows(RuntimeException.class, () -> tx.execute(status -> {
|
||||
notificationService.send(USER, "appointment", "预约已提交",
|
||||
"回滚场景测试内容", "appointment", businessId);
|
||||
// 业务随后失败 → 整个事务回滚
|
||||
throw new RuntimeException("模拟业务失败");
|
||||
}));
|
||||
|
||||
// 通知应随事务一起回滚,库中不应留下记录
|
||||
List<Notification> found = notificationMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getBusinessId, businessId));
|
||||
assertTrue(found.isEmpty(),
|
||||
"业务事务回滚后通知必须一并回滚,实际残留 " + found.size() + " 条");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate 后半:已提交事务后通知落库可见")
|
||||
void notificationPersistedWhenTransactionCommits() {
|
||||
TransactionTemplate tx = new TransactionTemplate(txManager);
|
||||
String businessId = "biz-commit-001";
|
||||
|
||||
tx.execute(status -> {
|
||||
notificationService.send(USER, "appointment", "预约已确认",
|
||||
"提交场景测试内容", "appointment", businessId);
|
||||
return null;
|
||||
});
|
||||
|
||||
List<Notification> found = notificationMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getBusinessId, businessId));
|
||||
assertEquals(1, found.size(), "已提交事务的通知应可见");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("幂等:相同幂等键重复发送只保留一条记录(顺序重试)")
|
||||
void idempotentOnSequentialRetry() {
|
||||
String businessId = "biz-idem-seq-001";
|
||||
String content = "顺序重试测试内容";
|
||||
|
||||
notificationService.send(USER, "appointment", "标题", content, "appointment", businessId);
|
||||
notificationService.send(USER, "appointment", "标题", content, "appointment", businessId);
|
||||
notificationService.send(USER, "appointment", "标题", content, "appointment", businessId);
|
||||
|
||||
List<Notification> found = notificationMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getBusinessId, businessId));
|
||||
assertEquals(1, found.size(),
|
||||
"顺序重试应被 findExisting 幂等判定拦住,实际 " + found.size() + " 条");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("幂等兜底:唯一索引阻止绕开 findExisting 的重复插入(修复 D1 后)")
|
||||
void uniqueIndexBacksUpIdempotency() {
|
||||
String businessId = "biz-idem-race-001";
|
||||
String content = "并发场景测试内容";
|
||||
|
||||
// 模拟两个事务都绕开 findExisting(如并发下都查不到)各自 insert。
|
||||
// 补 V036 唯一索引前这会产生 2 条重复记录;
|
||||
// 索引就位后第二条必须被 DB 拒绝 —— 这才是 Gate
|
||||
//「可重试且不产生重复业务记录」的真正兜底。
|
||||
TransactionTemplate tx = new TransactionTemplate(txManager);
|
||||
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
tx.execute(status -> {
|
||||
Notification a = new Notification();
|
||||
a.setId("race-a");
|
||||
a.setUserId(USER);
|
||||
a.setType("appointment");
|
||||
a.setTitle("标题");
|
||||
a.setContent(content);
|
||||
a.setBusinessType("appointment");
|
||||
a.setBusinessId(businessId);
|
||||
a.setReadStatus("unread");
|
||||
notificationMapper.insert(a);
|
||||
return null;
|
||||
});
|
||||
|
||||
// 第二条同 (user,type,business_type,business_id,content) 应触发唯一约束违例
|
||||
assertThrows(Exception.class, () -> tx.execute(status -> {
|
||||
Notification b = new Notification();
|
||||
b.setId("race-b");
|
||||
b.setUserId(USER);
|
||||
b.setType("appointment");
|
||||
b.setTitle("标题");
|
||||
b.setContent(content);
|
||||
b.setBusinessType("appointment");
|
||||
b.setBusinessId(businessId);
|
||||
b.setReadStatus("unread");
|
||||
notificationMapper.insert(b);
|
||||
return null;
|
||||
}), "唯一索引应阻止重复业务通知落库");
|
||||
|
||||
List<Notification> found = notificationMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getBusinessId, businessId));
|
||||
assertEquals(1, found.size(), "唯一索引就位后同一业务事件只能有一条通知");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("business_id 为 NULL 的通知(无幂等键)可多条共存")
|
||||
void nullBusinessIdNotificationsCoexist() {
|
||||
// 无业务关联的系统提示不应被唯一索引误杀
|
||||
TransactionTemplate tx = new TransactionTemplate(txManager);
|
||||
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final int idx = i;
|
||||
tx.execute(status -> {
|
||||
Notification n = new Notification();
|
||||
n.setId("null-biz-" + idx);
|
||||
n.setUserId(USER);
|
||||
n.setType("system");
|
||||
n.setTitle("系统提示");
|
||||
n.setContent("无幂等键提示 " + idx);
|
||||
n.setBusinessType(null);
|
||||
n.setBusinessId(null);
|
||||
n.setReadStatus("unread");
|
||||
notificationMapper.insert(n);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
List<Notification> found = notificationMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getUserId, USER)
|
||||
.eq(Notification::getType, "system")
|
||||
.isNull(Notification::getBusinessId));
|
||||
assertEquals(3, found.size(),
|
||||
"business_id 为 NULL 的行不应被唯一索引约束,实际 " + found.size() + " 条");
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.danyang.match.platform.notification;
|
||||
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 外部推送(微信模板消息)隐私安全文案验证 —— Phase 11 P11-V11-T11 / INV-010。
|
||||
*
|
||||
* <p>计划要求:<b>锁屏和通知摘要使用隐私安全文案,不泄露咨询主题或单向意向。</b>
|
||||
*
|
||||
* <p>另含一条关键守卫:通知 {@code type} 必须落在生产
|
||||
* {@code chk_notification_type} 白名单内。Phase 11 曾因全部 10 种新通知使用
|
||||
* 复合 type(appointment_submitted 等)而在真实 PostgreSQL 上全部落库失败(D3)。
|
||||
* 本测试用 {@code @SpringBootTest} 从真实上下文取 service,确保模板改动受约束保护。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class NotificationPrivacyCopyTest {
|
||||
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
|
||||
/** 生产 chk_notification_type 允许的取值(V004 + V020) */
|
||||
private static final java.util.Set<String> ALLOWED_TYPES = java.util.Set.of(
|
||||
"review", "match", "matchmaker", "activity", "risk", "account", "system",
|
||||
"appointment", "counseling", "premarital", "local_eligibility", "service_location");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, String> buildSafeCopy(String type, String title, String content) throws Exception {
|
||||
java.lang.reflect.Method m = NotificationService.class.getDeclaredMethod(
|
||||
"buildPrivacySafeCopy", String.class, String.class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (Map<String, String>) m.invoke(notificationService, type, title, content);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("咨询类外部文案不出现「咨询」敏感词(INV-010)")
|
||||
void counselingExternalCopyIsPrivacySafe() throws Exception {
|
||||
// 站内文案可以具体,但外部(锁屏)文案必须中性
|
||||
Map<String, String> copy = buildSafeCopy("counseling",
|
||||
"婚恋咨询已安排", "您的婚恋咨询已安排,咨询师:张老师");
|
||||
|
||||
String title = copy.get("title");
|
||||
String content = copy.get("content");
|
||||
assertFalse(title.contains("咨询"), "外部标题不得出现「咨询」: " + title);
|
||||
assertFalse(title.contains("辅导"), "外部标题不得出现「辅导」: " + title);
|
||||
assertFalse(content.contains("咨询"), "外部正文不得出现「咨询」: " + content);
|
||||
assertFalse(content.contains("张老师"), "外部正文不得泄露咨询师姓名: " + content);
|
||||
assertEquals("服务通知", title);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("咨询类外部正文不含时间、主题等业务详情")
|
||||
void counselingExternalBodyHasNoDetails() throws Exception {
|
||||
Map<String, String> copy = buildSafeCopy("counseling",
|
||||
"婚前辅导已排期", "您的婚前辅导已排期,时间:2026-09-20 14:00");
|
||||
String content = copy.get("content");
|
||||
assertFalse(content.contains("2026-09-20"), "外部正文不得泄露具体时间: " + content);
|
||||
assertFalse(content.contains("辅导"), "外部正文不得出现「辅导」: " + content);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未知通知类型回退到通用文案,不泄露原始标题")
|
||||
void unknownTypeFallsBackToGenericCopy() throws Exception {
|
||||
Map<String, String> copy = buildSafeCopy("unknown_type_xyz",
|
||||
"某个敏感标题", "某个敏感正文内容");
|
||||
assertEquals("善缘丹阳通知", copy.get("title"));
|
||||
assertFalse(copy.get("content").contains("敏感"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("本地资格类外部文案不泄露具体缺失材料")
|
||||
void localEligibilityCopyIsPrivacySafe() throws Exception {
|
||||
Map<String, String> copy = buildSafeCopy("local_eligibility",
|
||||
"本地资格需补充材料", "需补充:身份证、居住证");
|
||||
assertFalse(copy.get("content").contains("身份证"),
|
||||
"外部正文不得点出具体证件类型: " + copy.get("content"));
|
||||
assertFalse(copy.get("content").contains("居住证"),
|
||||
"外部正文不得点出具体证件类型: " + copy.get("content"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫:privacySafeCopy 对全部白名单 type 均可构造且无重复 key 崩溃")
|
||||
void allWhitelistedTypesProduceCopy() throws Exception {
|
||||
for (String type : ALLOWED_TYPES) {
|
||||
Map<String, String> copy = buildSafeCopy(type, "标题", "正文");
|
||||
assertNotNull(copy.get("title"), type + " 应有外部标题");
|
||||
assertNotNull(copy.get("content"), type + " 应有外部正文");
|
||||
assertFalse(copy.get("title").isBlank(), type + " 标题不应为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.danyang.match.platform.notification;
|
||||
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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 11 通知模板 → 数据库落库守卫。
|
||||
*
|
||||
* <p><b>为什么需要这个测试:</b>Phase 11 复验发现 D3 —— `NotificationService` 的
|
||||
* 10 个业务模板全部使用复合 type(`appointment_submitted` / `counseling_assigned` /
|
||||
* `le_verified` 等),而生产 `notifications.chk_notification_type` 只允许短名
|
||||
* (`appointment` / `counseling` / `premarital` / `local_eligibility` 等)。
|
||||
* 真实 PostgreSQL 上这些通知 100% 违反 CHECK 约束 → 整个 Phase 的 10 种通知全部落库失败。
|
||||
*
|
||||
* <p>测试库 `schema-h2.sql` 当时未复刻该约束,因此 300+ 测试全绿却掩盖了功能级不可用。
|
||||
*
|
||||
* <p><b>本测试的关键点:</b>必须<b>调用模板方法本身</b>(而非直接 `send` 传字面量短名),
|
||||
* 否则模板内的硬编码 type 不属于被测路径,守卫形同虚设 —— 这是负向验证过程中发现的
|
||||
* 真实教训(首次守卫测试未能变红)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class NotificationTemplateTypeGuardTest {
|
||||
|
||||
@Autowired
|
||||
private NotificationService notificationService;
|
||||
|
||||
@Autowired
|
||||
private NotificationMapper notificationMapper;
|
||||
|
||||
private static final String USER = "u-notif-guard";
|
||||
|
||||
private void assertPersisted(String type, String businessId, String label) {
|
||||
List<Notification> rows = notificationMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getUserId, USER)
|
||||
.eq(Notification::getType, type)
|
||||
.eq(Notification::getBusinessId, businessId));
|
||||
assertEquals(1, rows.size(),
|
||||
label + ":模板应成功落库一条 type=" + type + " 的通知,实际 " + rows.size() + " 条。"
|
||||
+ "若为 0,通常说明模板 type 不在生产 chk_notification_type 白名单内。");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫:全部 10 个 Phase 11 通知模板均能通过生产 CHECK 约束成功落库")
|
||||
void allPhase11TemplatesPersistWithAllowedType() {
|
||||
// P11-V11-T01 ~ T03 预约
|
||||
notificationService.appointmentSubmitted(USER, "appt-1", "公益红娘服务");
|
||||
assertPersisted("appointment", "appt-1", "appointmentSubmitted");
|
||||
|
||||
notificationService.appointmentConfirmed(USER, "appt-2", "公益红娘服务", "2026-09-20 14:00");
|
||||
assertPersisted("appointment", "appt-2", "appointmentConfirmed");
|
||||
|
||||
notificationService.appointmentChanged(USER, "appt-3", "公益红娘服务", "2026-09-21 10:00");
|
||||
assertPersisted("appointment", "appt-3", "appointmentChanged");
|
||||
|
||||
// P11-V11-T04 ~ T06 咨询
|
||||
notificationService.counselingAssigned(USER, "cns-1");
|
||||
assertPersisted("counseling", "cns-1", "counselingAssigned");
|
||||
|
||||
notificationService.counselingScheduled(USER, "cns-2", "2026-09-22 15:00 ~ 16:00");
|
||||
assertPersisted("counseling", "cns-2", "counselingScheduled");
|
||||
|
||||
notificationService.counselingCompleted(USER, "cns-3");
|
||||
assertPersisted("counseling", "cns-3", "counselingCompleted");
|
||||
|
||||
// P11-V11-T07 婚前辅导
|
||||
notificationService.premaritalScheduled(USER, "pre-1", "2026-09-23 09:00 ~ 10:00");
|
||||
assertPersisted("premarital", "pre-1", "premaritalScheduled");
|
||||
|
||||
// P11-V11-T08 ~ T10 本地资格
|
||||
notificationService.localEligibilityNeedEvidence(USER, "le-1", new String[]{"居住证明"});
|
||||
assertPersisted("local_eligibility", "le-1", "localEligibilityNeedEvidence");
|
||||
|
||||
notificationService.localEligibilityVerified(USER, "le-2");
|
||||
assertPersisted("local_eligibility", "le-2", "localEligibilityVerified");
|
||||
|
||||
notificationService.localEligibilityRejected(USER, "le-3", "材料不清晰");
|
||||
assertPersisted("local_eligibility", "le-3", "localEligibilityRejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫:红娘转介通知 type 也在白名单内(原为 referral_created)")
|
||||
void matchmakerReferralTemplatePersists() {
|
||||
notificationService.sendMatchmakerReferralCreated(USER, "张红娘", "service_station", "ref-1");
|
||||
assertPersisted("matchmaker", "ref-1", "sendMatchmakerReferralCreated");
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,15 @@ CREATE TABLE notifications (
|
||||
business_type VARCHAR(32), business_id VARCHAR(128), read_status VARCHAR(16), read_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
-- 对齐生产 V036 uk_notification_business:NOT NULL 业务行去重;H2 不支持 partial index,
|
||||
-- 但标准 UNIQUE 对 NULL 的语义(多行 NULL 允许共存)与生产 partial index 等价。
|
||||
CREATE UNIQUE INDEX uk_notification_business ON notifications(user_id, type, business_type, business_id, content);
|
||||
-- 对齐生产 V004/V020 chk_notification_type:缺少此约束会导致写入非法 type 时测试静默通过,
|
||||
-- 而真实 PostgreSQL 直接拒绝 —— Phase 11 十种通知曾因此长期带病运行(D3)。
|
||||
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')
|
||||
);
|
||||
CREATE TABLE recommendation_records (
|
||||
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), target_user_id VARCHAR(36), source VARCHAR(32), score NUMERIC(5,2),
|
||||
level VARCHAR(16), reasons CLOB, shown_at TIMESTAMP WITH TIME ZONE, action VARCHAR(32), action_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
Reference in New Issue
Block a user