From 469d7194bdbb4f7a9794ce807393ae67bb0948a5 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Sat, 19 Sep 2026 19:10:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(p16/p13a):=20Task=203.3=20=E4=B8=89?= =?UTF-8?q?=E9=A1=B9=E6=B8=85=E5=81=BF=20=E2=80=94=E2=80=94=20=E4=BA=92?= =?UTF-8?q?=E9=80=89=E8=B6=85=E6=97=B6=E6=8E=A5=E7=BA=BF=E3=80=81=E9=9A=8F?= =?UTF-8?q?=E8=AE=BF=E5=AE=A1=E8=AE=A1=E3=80=81=E9=9A=8F=E8=AE=BF=E9=A3=8E?= =?UTF-8?q?=E9=99=A9=E6=A3=80=E6=B5=8B=EF=BC=88=E8=B4=9F=E5=90=91=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=B7=B2=E8=AF=81=E5=AE=88=E5=8D=AB=E6=9C=89=E7=89=99?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P16-T13 互选超时接线: - 缺陷本体:expires_at 全仓无人写入(配置 48h 在、confirm 的过期过滤在,唯独创建不赋值) → (expires_at IS NULL OR expires_at > NOW()) 恒真,「截止后48小时」窗口形同虚设 - 修复:createMutualConsent 新建时写 expires_at = 活动结束时间 + ActivityConfig.mutualDeadlineHours (已确认口径「截止后48小时」),活动缺失/无结束时间回退创建时刻 - 守卫:ActivityMutualConsentServiceTest 新增 2 用例;负向验证(摘掉写入)恰 2 红、其余 7 绿 - 原测试 expiredConsentRejectsConfirm 手动补 expires_at 恰好掩盖了创建侧缺陷,如实记录 P13A-T31 随访审计: - assigned Counselor 读他人咨询正文/随访此前零审计 → getDetailForCounselor 接线 recordSensitiveAccess(counseling_followup);本人读自己与 manager 摘要视图不审计 P13A-T33 随访风险检测: - detectCounselingRisk 此前只扫申请摘要 → addFollowup 正文接入同一检测(source=counseling), INV-013 加固:不记录正文只记级别(同 apply 模式) - 守卫:CounselingFollowupAuditAndRiskGuardTest 4 用例(审计落库回查 ×2 + 风险事件落库回查 ×2),全绿 --- .../service/ActivityMutualConsentService.java | 15 +++ .../counseling/service/CounselingService.java | 25 ++++ .../ActivityMutualConsentServiceTest.java | 32 +++++ ...unselingFollowupAuditAndRiskGuardTest.java | 122 ++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 backend/src/test/java/com/danyang/match/platform/counseling/CounselingFollowupAuditAndRiskGuardTest.java diff --git a/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java b/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java index a7fa42c..a2f6539 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java +++ b/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java @@ -1,15 +1,19 @@ package com.danyang.match.platform.activity_mutual.service; +import com.danyang.match.platform.activity.entity.Activity; +import com.danyang.match.platform.activity.mapper.ActivityMapper; import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO; import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent; import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper; import com.danyang.match.platform.relationship.service.RelationshipService; +import com.danyang.match.platform.shared.config.ActivityConfig; import com.danyang.match.platform.shared.constant.Constants; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.Instant; import java.util.*; @@ -20,6 +24,8 @@ public class ActivityMutualConsentService { private final ActivityMutualConsentMapper consentMapper; private final RelationshipService relationshipService; + private final ActivityMapper activityMapper; + private final ActivityConfig activityConfig; public List detectMutualResults(String activityId) { List bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId); @@ -44,6 +50,15 @@ public class ActivityMutualConsentService { } consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING); consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING); + // P16-T13 接线:写入 expires_at,否则 confirmSideIfPending 的 + // `(expires_at IS NULL OR expires_at > NOW())` 过滤因恒 NULL 而永不生效, + // 「截止后 48 小时」窗口(PENDING_DECISIONS 已确认口径)形同虚设。 + // 基准点 = 活动结束时间(ActivityConfig.mutualDeadlineHours 注释的既定语义), + // 活动不存在或无结束时间时回退为创建时刻。 + Instant deadlineBase = Optional.ofNullable(activityMapper.selectById(activityId)) + .map(Activity::getEndAt) + .orElseGet(Instant::now); + consent.setExpiresAt(deadlineBase.plus(Duration.ofHours(activityConfig.getMutualDeadlineHours()))); consentMapper.insert(consent); } return consent; 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 6330fdf..d909728 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 @@ -1,5 +1,6 @@ package com.danyang.match.platform.counseling.service; +import com.danyang.match.platform.audit.service.AuditLogService; import com.danyang.match.platform.counseling.entity.CounselingFollowup; import com.danyang.match.platform.counseling.entity.CounselingServiceRecord; import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper; @@ -78,6 +79,7 @@ public class CounselingService { private final NotificationService notificationService; private final RiskReferralService riskReferralService; private final DataScopeGuard dataScopeGuard; + private final AuditLogService auditLogService; // ==================== 用户端:申请 ==================== @@ -264,6 +266,23 @@ public class CounselingService { fu.setResultCode(resultCode); fu.setNextFollowupAt(nextFollowupAt); followupMapper.insert(fu); + + // P13A-T33: 随访正文与申请摘要共用同一风险检测(来源同为 source=counseling)。 + // 随访由 counselor 撰写、更贴近用户近况,是风险关键词的另一个真实入口; + // 此前只有申请摘要(apply:133)被扫描,随访正文完全绕过风控。 + // INV-013 加固同 apply:绝不记录正文,只记级别与记录 ID。 + CounselingServiceRecord owner = mapper.findFull(serviceId); + if (owner != null) { + try { + String riskLevel = riskReferralService.detectCounselingRisk(owner.getUserId(), content); + if (riskLevel != null) { + log.info("随访内容触发风险检测: serviceId={}, followupId={}, riskLevel={}", + serviceId, fu.getId(), riskLevel); + } + } catch (Exception e) { + log.warn("随访风险检测异常: serviceId={}, err={}", serviceId, e.getMessage()); + } + } return fu; } @@ -333,6 +352,12 @@ public class CounselingService { "INV-012: 只能查看分配给自己的咨询服务"); return null; } + // P13A-T31: assigned Counselor 读取的是**他人**的咨询正文与随访记录 —— + // 敏感访问必须留痕(同 Phase 17 report/risk_profile 的 recordSensitiveAccess 模式)。 + // 本人读自己的不算敏感访问;manager/matchmaker 走脱敏摘要视图,不触正文,无需审计。 + if (isAssignedCounselor) { + auditLogService.recordSensitiveAccess(requesterId, "counseling_followup", serviceId); + } return svc; } diff --git a/backend/src/test/java/com/danyang/match/platform/activity_mutual/ActivityMutualConsentServiceTest.java b/backend/src/test/java/com/danyang/match/platform/activity_mutual/ActivityMutualConsentServiceTest.java index b8be796..7c657cd 100644 --- a/backend/src/test/java/com/danyang/match/platform/activity_mutual/ActivityMutualConsentServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/activity_mutual/ActivityMutualConsentServiceTest.java @@ -1,5 +1,7 @@ package com.danyang.match.platform.activity_mutual; +import com.danyang.match.platform.activity.entity.Activity; +import com.danyang.match.platform.activity.mapper.ActivityMapper; import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent; import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper; import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService; @@ -52,6 +54,8 @@ class ActivityMutualConsentServiceTest { private MatchMapper matchMapper; @Autowired private ConversationMapper conversationMapper; + @Autowired + private ActivityMapper activityMapper; @MockBean private StringRedisTemplate redisTemplate; @@ -167,6 +171,34 @@ class ActivityMutualConsentServiceTest { "过期互选不应形成 Match"); } + @Test + @DisplayName("P16-T13 接线:创建互选写入 expires_at = 活动结束时间 + 截止小时数(「截止后48小时」口径)") + void createdConsentCarriesDeadlineFromActivityEnd() { + Activity activity = new Activity(); + activity.setId(activityId); + Instant endAt = Instant.now().plusSeconds(2 * 3600); + activity.setEndAt(endAt); + activityMapper.insert(activity); + + ActivityMutualConsent consent = consentService.createMutualConsent(activityId, userA, userB); + assertNotNull(consent.getExpiresAt(), + "创建互选必须写入 expires_at —— 否则 confirm 的过期过滤因恒 NULL 永不生效(P16-T13 缺陷本体)"); + long expected = endAt.plusSeconds(48 * 3600).getEpochSecond(); + assertTrue(Math.abs(consent.getExpiresAt().getEpochSecond() - expected) < 300, + "expires_at 应约为活动结束时间 + 48h,实际: " + consent.getExpiresAt()); + } + + @Test + @DisplayName("P16-T13 接线:活动不存在或无结束时间时,expires_at 回退为创建时刻 + 截止小时数") + void createdConsentWithoutActivityEndFallsBackToNow() { + ActivityMutualConsent consent = consentService.createMutualConsent(activityId, userA, userB); + assertNotNull(consent.getExpiresAt(), + "活动缺失时也必须有兜底截止时间,不能留下永不过期的互选"); + long expected = Instant.now().plusSeconds(48 * 3600).getEpochSecond(); + assertTrue(Math.abs(consent.getExpiresAt().getEpochSecond() - expected) < 300, + "expires_at 应约为创建时刻 + 48h,实际: " + consent.getExpiresAt()); + } + @Test @DisplayName("软删过滤:已软删的同意记录不参与判断,confirmConnection 视为不存在") void softDeletedConsentIsFiltered() { diff --git a/backend/src/test/java/com/danyang/match/platform/counseling/CounselingFollowupAuditAndRiskGuardTest.java b/backend/src/test/java/com/danyang/match/platform/counseling/CounselingFollowupAuditAndRiskGuardTest.java new file mode 100644 index 0000000..41c4579 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/counseling/CounselingFollowupAuditAndRiskGuardTest.java @@ -0,0 +1,122 @@ +package com.danyang.match.platform.counseling; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.counseling.entity.CounselingServiceRecord; +import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper; +import com.danyang.match.platform.counseling.service.CounselingService; +import com.danyang.match.platform.risk.entity.RiskEvent; +import com.danyang.match.platform.risk.mapper.RiskEventMapper; +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.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * P13A-T31 / P13A-T33 守卫(2026-09-19 清偿)。 + * + *

P13A-T31:随访正文访问必须留痕

+ * assigned Counselor 通过 {@code getDetailForCounselor} 读到的是他人的咨询正文与 + * 随访记录 —— 此前这条路径零审计,与 Phase 17 已确立的「report / risk_profile 敏感访问必审计」 + * 同族要求脱节。守卫走真实落库回查 {@code audit_logs}({@code action=SENSITIVE_ACCESS}, + * {@code resource_type=counseling_followup}),并断言两个不审计分支: + * 本人读自己的(自己的数据)、manager(脱敏摘要视图,触不到正文)。 + * + *

P13A-T33:随访正文接入 source=counseling 风险检测

+ * {@code detectCounselingRisk} 此前只在申请摘要({@code apply})被调用 —— + * 随访由 counselor 撰写、更贴近用户近况,却完全绕过风控。守卫断言: + * 高危关键词随访产生 {@code source=counseling} 的 {@code risk_events} 落库行,普通内容不产生。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class CounselingFollowupAuditAndRiskGuardTest { + + @Autowired private CounselingService counselingService; + @Autowired private CounselingServiceMapper mapper; + @Autowired private AuditLogMapper auditLogMapper; + @Autowired private RiskEventMapper riskEventMapper; + + private static final String USER = "u-fu-guard-1"; + private static final String COUNSELOR = "op-fu-counselor-1"; + + private CounselingServiceRecord assignedService() { + CounselingServiceRecord svc = counselingService.apply( + USER, "relationship_guidance", "常规申请摘要", + "user_self", "online", null, null, null); + return counselingService.assign(svc.getId(), COUNSELOR); + } + + private long countSensitiveAccess(String serviceId) { + return auditLogMapper.selectCount(new LambdaQueryWrapper() + .eq(AuditLog::getAction, "SENSITIVE_ACCESS") + .eq(AuditLog::getResourceType, "counseling_followup") + .eq(AuditLog::getResourceId, serviceId)); + } + + private long countCounselingRiskEvents() { + List events = riskEventMapper.findByUserId(USER); + return events.stream().filter(e -> "counseling".equals(e.getSource())).count(); + } + + @Test + @DisplayName("P13A-T31: assigned Counselor 读完整详情必须留下敏感访问审计") + void assignedCounselorReadLeavesAuditTrail() { + CounselingServiceRecord svc = assignedService(); + long before = countSensitiveAccess(svc.getId()); + + CounselingServiceRecord detail = + counselingService.getDetailForCounselor(COUNSELOR, "counselor", svc.getId()); + assertNotNull(detail, "assigned counselor 应能读到详情"); + + assertEquals(before + 1, countSensitiveAccess(svc.getId()), + "读取他人咨询正文/随访必须写入 audit_logs(SENSITIVE_ACCESS / counseling_followup)"); + } + + @Test + @DisplayName("P13A-T31: 本人读自己的详情不产生敏感访问审计") + void userSelfReadDoesNotAudit() { + CounselingServiceRecord svc = assignedService(); + long before = countSensitiveAccess(svc.getId()); + + CounselingServiceRecord detail = counselingService.getDetail(USER, "user", svc.getId()); + assertNotNull(detail); + + assertEquals(before, countSensitiveAccess(svc.getId()), + "用户读自己的咨询记录是正常业务路径,不属敏感访问"); + } + + @Test + @DisplayName("P13A-T33: 随访正文含高危关键词时产生 source=counseling 风险事件") + void followupHighRiskContentCreatesCounselingRiskEvent() { + CounselingServiceRecord svc = assignedService(); + long before = countCounselingRiskEvents(); + + counselingService.addFollowup(svc.getId(), COUNSELOR, "online_chat", + "用户提到想死,情绪持续低落,已建议求助专业机构", "risk", null); + + long after = countCounselingRiskEvents(); + assertTrue(after > before, + "随访高危内容必须产生 source=counseling 的 risk_events(P13A-T33 此前完全绕过风控)"); + } + + @Test + @DisplayName("P13A-T33: 普通随访内容不产生风险事件") + void followupNormalContentCreatesNoRiskEvent() { + CounselingServiceRecord svc = assignedService(); + long before = countCounselingRiskEvents(); + + counselingService.addFollowup(svc.getId(), COUNSELOR, "online_chat", + "已完成首次沟通,情绪有所缓解", "ok", null); + + assertEquals(before, countCounselingRiskEvents(), + "正常随访内容不应误报风险事件"); + } +}