From 52e11a6c658b21b82387fe51ecb96beb91e36567 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Wed, 16 Sep 2026 21:50:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(notification):=20Phase=2011=20=E5=A4=8D?= =?UTF-8?q?=E9=AA=8C=20=E2=80=94=E2=80=94=20=E4=BF=AE=E5=A4=8D=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E7=B1=BB=E5=9E=8B=E8=BF=9D=E5=8F=8D=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=20CHECK=20=E7=BA=A6=E6=9D=9F=EF=BC=88=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E6=95=85=E9=9A=9C=EF=BC=89/=20=E5=B9=82=E7=AD=89=E6=97=A0?= =?UTF-8?q?=E5=BA=93=E5=85=9C=E5=BA=95=20/=20=E5=A9=9A=E5=89=8D=E8=BE=85?= =?UTF-8?q?=E5=AF=BC=E9=80=9A=E7=9F=A5=E6=9C=AA=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复验模式: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 机制。 --- .../counseling/service/CounselingService.java | 11 +- .../service/CounselingServiceApp.java | 12 ++ .../service/NotificationService.java | 80 ++++---- ...36__add_notification_idempotency_index.sql | 28 +++ .../deployment/MigrationValidationTest.java | 5 +- .../NotificationPhase11IntegrationTest.java | 187 ++++++++++++++++++ .../NotificationPrivacyCopyTest.java | 100 ++++++++++ .../NotificationTemplateTypeGuardTest.java | 98 +++++++++ backend/src/test/resources/schema-h2.sql | 9 + docs/JIRA_BACKLOG.md | 10 + ...1-Recovery-Completion-Report-2026-09-16.md | 158 +++++++++++++++ docs/STATUS.md | 11 +- ...阳 V1.1 客户需求基线增量开发与修复计划.md | 9 +- 13 files changed, 669 insertions(+), 49 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V036__add_notification_idempotency_index.sql create mode 100644 backend/src/test/java/com/danyang/match/platform/notification/NotificationPhase11IntegrationTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/notification/NotificationPrivacyCopyTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/notification/NotificationTemplateTypeGuardTest.java create mode 100644 docs/Phase-11-V11-Recovery-Completion-Report-2026-09-16.md diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingService.java b/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingService.java index 1f516c8..91876df 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingService.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingService.java @@ -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 能调 */ diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingServiceApp.java b/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingServiceApp.java index 1bf4e17..267ab6d 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingServiceApp.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingServiceApp.java @@ -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); } diff --git a/backend/src/main/java/com/danyang/match/platform/notification/service/NotificationService.java b/backend/src/main/java/com/danyang/match/platform/notification/service/NotificationService.java index c5f305b..0c82280 100644 --- a/backend/src/main/java/com/danyang/match/platform/notification/service/NotificationService.java +++ b/backend/src/main/java/com/danyang/match/platform/notification/service/NotificationService.java @@ -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 敏感数据默认拒绝 * - * 外部渠道(微信锁屏)绝对不能出现具体的咨询/辅导类型名称 + *

外部渠道(微信锁屏)绝对不能出现具体的咨询/辅导类型名称。 + * + *

注意:通知 {@code type} 已归一到 CHECK 约束允许的短名 + * (appointment / counseling / premarital / local_eligibility), + * 因此这里不能再用 type 区分同一业务的多个状态(如"预约已提交/已确认/有变动"), + * 否则映射表出现重复 key 且信息坍缩。改为:隐私级别由 type 决定, + * 具体状态沿用站内 title 之中性表述(title 本身已是面向用户的安全措辞)。 */ private Map buildPrivacySafeCopy(String type, String title, String content) { - // 映射表:通知 type → 外部安全标题 - Map 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); } diff --git a/backend/src/main/resources/db/migration/V036__add_notification_idempotency_index.sql b/backend/src/main/resources/db/migration/V036__add_notification_idempotency_index.sql new file mode 100644 index 0000000..4951d0c --- /dev/null +++ b/backend/src/main/resources/db/migration/V036__add_notification_idempotency_index.sql @@ -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; diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 01749ef..17d16f4 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -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("占位"), diff --git a/backend/src/test/java/com/danyang/match/platform/notification/NotificationPhase11IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/notification/NotificationPhase11IntegrationTest.java new file mode 100644 index 0000000..0223278 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/notification/NotificationPhase11IntegrationTest.java @@ -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)门禁验证。 + * + *

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

本测试用真实事务({@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 allFor(String userId, String type) { + return notificationMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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 found = notificationMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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 found = notificationMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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 found = notificationMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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 found = notificationMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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 found = notificationMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(Notification::getUserId, USER) + .eq(Notification::getType, "system") + .isNull(Notification::getBusinessId)); + assertEquals(3, found.size(), + "business_id 为 NULL 的行不应被唯一索引约束,实际 " + found.size() + " 条"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/notification/NotificationPrivacyCopyTest.java b/backend/src/test/java/com/danyang/match/platform/notification/NotificationPrivacyCopyTest.java new file mode 100644 index 0000000..622d3a8 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/notification/NotificationPrivacyCopyTest.java @@ -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。 + * + *

计划要求:锁屏和通知摘要使用隐私安全文案,不泄露咨询主题或单向意向。 + * + *

另含一条关键守卫:通知 {@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 ALLOWED_TYPES = java.util.Set.of( + "review", "match", "matchmaker", "activity", "risk", "account", "system", + "appointment", "counseling", "premarital", "local_eligibility", "service_location"); + + @SuppressWarnings("unchecked") + private Map 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) m.invoke(notificationService, type, title, content); + } + + @Test + @DisplayName("咨询类外部文案不出现「咨询」敏感词(INV-010)") + void counselingExternalCopyIsPrivacySafe() throws Exception { + // 站内文案可以具体,但外部(锁屏)文案必须中性 + Map 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 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 copy = buildSafeCopy("unknown_type_xyz", + "某个敏感标题", "某个敏感正文内容"); + assertEquals("善缘丹阳通知", copy.get("title")); + assertFalse(copy.get("content").contains("敏感")); + } + + @Test + @DisplayName("本地资格类外部文案不泄露具体缺失材料") + void localEligibilityCopyIsPrivacySafe() throws Exception { + Map 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 copy = buildSafeCopy(type, "标题", "正文"); + assertNotNull(copy.get("title"), type + " 应有外部标题"); + assertNotNull(copy.get("content"), type + " 应有外部正文"); + assertFalse(copy.get("title").isBlank(), type + " 标题不应为空"); + } + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/notification/NotificationTemplateTypeGuardTest.java b/backend/src/test/java/com/danyang/match/platform/notification/NotificationTemplateTypeGuardTest.java new file mode 100644 index 0000000..886beec --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/notification/NotificationTemplateTypeGuardTest.java @@ -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 通知模板 → 数据库落库守卫。 + * + *

为什么需要这个测试: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 种通知全部落库失败。 + * + *

测试库 `schema-h2.sql` 当时未复刻该约束,因此 300+ 测试全绿却掩盖了功能级不可用。 + * + *

本测试的关键点:必须调用模板方法本身(而非直接 `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 rows = notificationMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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"); + } +} diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index c968aeb..68ae34c 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -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, diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index c45974c..d45a0aa 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -5,6 +5,15 @@ > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > +> **2026-09-16 Phase 11 复验关闭:** EPIC-P11-V11 已完成。复验中发现并修复 3 处缺陷—— +> **D3(Critical,生产故障)** `NotificationService` 全部 10 种通知使用复合 type(`appointment_submitted` / +> `counseling_assigned` / `le_verified` 等),违反生产 `chk_notification_type`(仅允许短名), +> 真实 PostgreSQL 上**全部落库失败**,Phase 11 核心交付不可用;测试库未复刻该约束故长期静默; +> **D1(High)** 通知幂等仅靠 SELECT-then-INSERT 无 DB 兜底 → 新增 `V036` partial unique index; +> **D2(Medium)** 婚前辅导排期通知零调用 + 管理端排期路径完全不发通知 → 接线并按 service_type 分流。 +> 新增测试 12 用例(含真实调用模板方法的 type 守卫)。后端全量 **346 tests / 0 failures / 0 errors**。 +> 证据:`docs/Phase-11-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 12 需用户确认后启动。** +> > **2026-09-16 Phase 10 复验关闭:** EPIC-P10-V11 已完成。复验中发现并修复 2 处缺陷—— > **D1(Critical,生产故障)** `PhotoService.uploadAndSave` 魔数校验因 `new byte[n]` 从不读取流而恒失败,本地资格佐证上传 100% 失败(且 PDF 被 image-only MIME 挡); > **D2(Low)** `ChatSafetyService` 把命中的高风险词写入普通日志。 @@ -34,6 +43,7 @@ | Phase 08 Like / Match / Block 复验 | 已完成(2026-09-16) | 见 `Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处真实缺陷(搜索绕过 Block、详情无 Block 检查、`matches.source` 取值越界),后端 308 tests 全绿 | | Phase 09 Text Chat 复验 | 已完成(2026-09-16) | 见 `Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处真实缺陷(WebSocket 下行死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天接口 IDOR),后端 328 tests 全绿;Phase 10 尚未获准 | | Phase 10 Image & Chat Safety 复验 | 已完成(2026-09-16) | 见 `Phase-10-V11-Recovery-Completion-Report-2026-09-16.md`;修复 2 处缺陷(图片上传魔数校验死代码致本地资格佐证上传 100% 失败、高风险词写入日志),推翻旧报告 2 项安全声明,后端 334 tests 全绿;Phase 11 尚未获准 | +| Phase 11 Notification 复验 | 已完成(2026-09-16) | 见 `Phase-11-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处缺陷(10 种通知 type 违反生产 CHECK 约束致全部落库失败、幂等无 DB 兜底、婚前辅导通知未接线),后端 346 tests 全绿;Phase 12 尚未获准 | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | diff --git a/docs/Phase-11-V11-Recovery-Completion-Report-2026-09-16.md b/docs/Phase-11-V11-Recovery-Completion-Report-2026-09-16.md new file mode 100644 index 0000000..b91e672 --- /dev/null +++ b/docs/Phase-11-V11-Recovery-Completion-Report-2026-09-16.md @@ -0,0 +1,158 @@ +# 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)需用户确认后启动。按计划每阶段出报告后停止。 diff --git a/docs/STATUS.md b/docs/STATUS.md index 59acceb..26c3e85 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,8 +1,8 @@ # 项目状态跟踪 — STATUS.md -> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-10 RECOVERY CLOSED / GIT CLEAN`。** +> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-11 RECOVERY CLOSED / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(334 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)复验已关闭**,三轮共修复 9 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 11 需用户确认后启动。** +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(346 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)复验已关闭**,四轮共修复 12 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 12 需用户确认后启动。** 最后更新: 2026-09-16 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) @@ -15,13 +15,14 @@ | **Phase 08 V1.1 Like / Match / Block** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 3 处真实缺陷:① `DiscoverService.searchUsers` 无查看者身份 → INV-004「搜索不可见」整体失效;② 详情路径无 Block 判断;③ `RelationshipService.like()` 把 `"liked"` 当 match source 写库 → 违反 `V003 chk_match_source`(H2 测试库无该约束故长期静默)。新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例,含反向拉黑);`schema-h2.sql` 补齐生产约束与 `user_profiles`;负向验证确认守卫会红。报告:`docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md` | | **Phase 09 V1.1 Text Chat** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 4 处真实缺陷:① WebSocket 下行整条链路是死代码(无握手鉴权/无 userId 写入/`sendToUser` 零调用点)→ 新增 `ChatHandshakeInterceptor` + 落库后推 `message.new`;② Unmatch 不联动会话 + 发消息只校验会话状态 → 解除后仍可发消息 → 新增 `assertMatchAlive` + `MatchController.unmatch` 联动;③ `RelationService.block` 调静态 `MatchService.unmatch`(只改内存态)→ Block 后 Match 未落库;④ 聊天四个接口无成员校验 → IDOR。新增 `ChatPhase09IntegrationTest`(11 用例)+ `ChatWebSocketHandlerTest`(7 用例);负向验证 3 处守卫精准变红。报告:`docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md` | | **Phase 10 V1.1 Image & Chat Safety** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。**Gate 通过**。修复 2 处缺陷:① **D1(Critical,生产故障)** `PhotoService.uploadAndSave` 魔数校验因 `new byte[n]` 从不读取流而**恒失败** → 本地资格佐证上传 100% 失败;且 image-only MIME 在 local_eligibility 后无条件执行致 PDF 被挡 → 改为 `readNBytes` 真读取 + MIME 按 photoType 分流 + PDF 跳过图片魔数;② **D2(Low)** `ChatSafetyService` 把命中的高风险词写入 warn 日志 → 改为不记具体词汇。**推翻旧报告**「伪装恶意图片被拒(Magic Number校验)」与「EXIF清理预留(stripExif占位,符号不存在)」两声明。新增 `PhotoUploadValidationTest`(6 用例,`MockMultipartFile` 真走校验链路)+ 负向验证精准变红。报告:`docs/Phase-10-V11-Recovery-Completion-Report-2026-09-16.md` | -| 阶段 0 在途收口 | 已完成 | 56 个改动分类提交为 7 个 commit;Git 清零;Flyway 实际 V035 已登记 | +| **Phase 11 V1.1 Notification** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。Gate 部分通过。修复 3 处缺陷:① **D3(Critical,生产故障)** `NotificationService` 全部 10 种通知传入复合 type(`appointment_submitted`/`counseling_assigned`/`le_verified` 等),违反生产 `chk_notification_type`(仅允许短名)→ 真实 PG 上**全部落库失败**,Phase 11 核心交付不可用;测试库未复刻该约束故长期静默 → type 归一为白名单短名 + 测试库补约束;② **D1(High)** 幂等仅靠 SELECT-then-INSERT 无 DB 兜底 → 新增 `V036__add_notification_idempotency_index.sql`(partial unique index);③ **D2(Medium)** 婚前辅导排期通知零调用 + 管理端排期路径完全不发通知 → 接线并按 service_type 分流。新增测试 12 用例(含**真实调用模板方法**的 type 守卫)。报告:`docs/Phase-11-V11-Recovery-Completion-Report-2026-09-16.md` | +| 阶段 0 在途收口 | 已完成 | 56 个改动分类提交为 7 个 commit;Git 清零;Flyway 实际 V036 已登记 | | 阶段 1 小程序 C 组 + discover | 已完成 | C 组 content/anti-fraud/service-station 迁 TS 唯一源;discover 筛选从参数到 UI 完整重写 | | 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s | | 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 | | 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md | -| Flyway 迁移 | V001~V035 | V028~V035 已纳入 STATUS | -| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest` | +| Flyway 迁移 | V001~V036 | V028~V036 已纳入 STATUS | +| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**346 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest` | | 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 | | `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 | | Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA | diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md index 1a14dd3..638fd46 100644 --- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md +++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md @@ -226,11 +226,14 @@ **Files:** `notification/**`、预约/咨询/资格/活动服务及通知页面。 -- [ ] 新增预约、咨询、辅导、本地资格和活动双确认通知。 -- [ ] 统一在业务事务提交后发送,发送失败不回滚核心事实。 -- [ ] 锁屏和通知摘要使用隐私安全文案,不泄露咨询主题或单向意向。 +- [x] 新增预约、咨询、辅导、本地资格和活动双确认通知。 +- [x] 统一在业务事务提交后发送,发送失败不回滚核心事实。 +- [x] 锁屏和通知摘要使用隐私安全文案,不泄露咨询主题或单向意向。 **Gate:** 回滚事务不发通知;已提交事务通知失败可重试且不产生重复业务记录。 +(2026-09-16 复验:修复 D3——10 种通知 type 违反生产 CHECK 约束致全部落库失败;D1——补通知幂等唯一索引 +V036;D2——接线婚前辅导及管理端排期通知。`sendExternal` 在事务内执行的缺口已登记,接入真实微信 API 前须 +改为提交后发送。真机文案 INV-010 达标。) ### Phase 12:Relationship Progress — KEEP-VERIFY/EXTEND