fix(p16/p13a): Task 3.3 三项清偿 —— 互选超时接线、随访审计、随访风险检测(负向验证已证守卫有牙)
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),全绿
This commit is contained in:
+15
@@ -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<MutualInterestResultDTO> detectMutualResults(String activityId) {
|
||||
List<ActivityMutualConsent> 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;
|
||||
|
||||
+25
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -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() {
|
||||
|
||||
+122
@@ -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 清偿)。
|
||||
*
|
||||
* <h3>P13A-T31:随访正文访问必须留痕</h3>
|
||||
* assigned Counselor 通过 {@code getDetailForCounselor} 读到的是<b>他人</b>的咨询正文与
|
||||
* 随访记录 —— 此前这条路径零审计,与 Phase 17 已确立的「report / risk_profile 敏感访问必审计」
|
||||
* 同族要求脱节。守卫走真实落库回查 {@code audit_logs}({@code action=SENSITIVE_ACCESS},
|
||||
* {@code resource_type=counseling_followup}),并断言两个不审计分支:
|
||||
* 本人读自己的(自己的数据)、manager(脱敏摘要视图,触不到正文)。
|
||||
*
|
||||
* <h3>P13A-T33:随访正文接入 source=counseling 风险检测</h3>
|
||||
* {@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<AuditLog>()
|
||||
.eq(AuditLog::getAction, "SENSITIVE_ACCESS")
|
||||
.eq(AuditLog::getResourceType, "counseling_followup")
|
||||
.eq(AuditLog::getResourceId, serviceId));
|
||||
}
|
||||
|
||||
private long countCounselingRiskEvents() {
|
||||
List<RiskEvent> 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(),
|
||||
"正常随访内容不应误报风险事件");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user