fix(risk/report): close Phase 17 KEEP-VERIFY/EXTEND — 6 defects + 1 hidden compile defect (jakarta) + test-coverage blind zone (3 missing tables); add sensitive-access audit + ReportAndRiskPhase17IntegrationTest (7); full regression 488 green
This commit is contained in:
@@ -6,6 +6,11 @@ import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -61,4 +66,24 @@ public class AuditLogService {
|
||||
public Map<String, Object> getSensitiveAccessLogs(String operatorId, Integer page, Integer pageSize) {
|
||||
return listAuditLogs(null, null, operatorId, page, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* D3(Phase 17):记录后台敏感访问(查看他人风险画像 / 举报详情 / 风险事件列表)。
|
||||
* AGENTS.md #8 要求敏感数据默认拒绝,查看动作本身必须可溯源。
|
||||
*/
|
||||
@Transactional
|
||||
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction("SENSITIVE_ACCESS");
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
auditLog.setIp(req.getRemoteAddr());
|
||||
String ua = req.getHeader("User-Agent");
|
||||
auditLog.setUserAgent(ua != null ? (ua.length() > 512 ? ua.substring(0, 512) : ua) : null);
|
||||
}
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -3,6 +3,7 @@ package com.danyang.match.platform.risk.controller;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.risk.dto.ReportProcessDTO;
|
||||
import com.danyang.match.platform.risk.service.ReportService;
|
||||
import com.danyang.match.platform.audit.service.AuditLogService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -21,6 +22,7 @@ import java.util.Map;
|
||||
public class ReportOperatorController {
|
||||
|
||||
private final ReportService reportService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
/**
|
||||
* 获取举报列表(可带 status 筛选)
|
||||
@@ -63,6 +65,7 @@ public class ReportOperatorController {
|
||||
public ApiResponse<Map<String, Object>> getReportDetail(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
auditLogService.recordSensitiveAccess(principal.id(), "report", id);
|
||||
return ApiResponse.ok(reportService.getReportDetail(id));
|
||||
}
|
||||
|
||||
@@ -99,6 +102,7 @@ public class ReportOperatorController {
|
||||
public ApiResponse<Map<String, Object>> getUserRiskProfile(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String userId) {
|
||||
auditLogService.recordSensitiveAccess(principal.id(), "user_risk_profile", userId);
|
||||
return ApiResponse.ok(reportService.getUserRiskProfile(userId));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.danyang.match.platform.risk.controller;
|
||||
|
||||
import com.danyang.match.platform.risk.entity.RiskEvent;
|
||||
import com.danyang.match.platform.risk.service.RiskReferralService;
|
||||
import com.danyang.match.platform.audit.service.AuditLogService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -22,6 +23,7 @@ import java.util.Map;
|
||||
public class RiskController {
|
||||
|
||||
private final RiskReferralService riskReferralService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
/**
|
||||
* P17-T04: 运营人员手动标记风险
|
||||
@@ -48,6 +50,7 @@ public class RiskController {
|
||||
public ApiResponse<List<RiskEvent>> getRiskEvents(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String userId) {
|
||||
auditLogService.recordSensitiveAccess(principal.id(), "risk_event", userId);
|
||||
return ApiResponse.ok(riskReferralService.getRiskEventsByUser(userId));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface RiskEventMapper extends BaseMapper<RiskEvent> {
|
||||
|
||||
@Select("SELECT * FROM risk_events WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT 20")
|
||||
// D5(Phase 17):与 sumScoreByUserId / findByEventType 保持一致,过滤软删事件,
|
||||
// 否则 getUserRiskProfile / getReportDetail 的 recentEvents 会混入已删除事件,
|
||||
// 与已过滤的风险评分不一致(静默功能失效)。
|
||||
@Select("SELECT * FROM risk_events WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20")
|
||||
List<RiskEvent> findByUserId(String userId);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(score), 0) FROM risk_events WHERE user_id = #{userId} AND deleted_at IS NULL")
|
||||
|
||||
@@ -21,6 +21,10 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
@@ -383,6 +387,20 @@ public class ReportService {
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setBeforeData(beforeData);
|
||||
auditLog.setAfterData(afterData);
|
||||
// D4(Phase 17):补全审计来源(IP / User-Agent),敏感访问与处置审计必须可溯源。
|
||||
enrichAuditSource(auditLog);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
|
||||
// D4(Phase 17):从当前请求上下文补全审计的 IP 与 User-Agent(与 AuthorizationAuditListener 同模式)。
|
||||
private void enrichAuditSource(AuditLog auditLog) {
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
auditLog.setIp(req.getRemoteAddr());
|
||||
String ua = req.getHeader("User-Agent");
|
||||
auditLog.setUserAgent(ua != null ? (ua.length() > 512 ? ua.substring(0, 512) : ua) : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -5,10 +5,16 @@ import com.danyang.match.platform.risk.entity.RiskEvent;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskEventMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -25,6 +31,7 @@ public class RiskReferralService {
|
||||
|
||||
private final RiskEventMapper riskEventMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
|
||||
/**
|
||||
* P17-T01: 咨询内容风险检测
|
||||
@@ -113,7 +120,10 @@ public class RiskReferralService {
|
||||
*/
|
||||
@Transactional
|
||||
public RiskEvent manualFlagRisk(String userId, String eventType, String note, String createdBy) {
|
||||
return createRiskEvent(userId, eventType, 5, "manual", createdBy, note);
|
||||
RiskEvent event = createRiskEvent(userId, eventType, 5, "manual", createdBy, note);
|
||||
// D2(Phase 17):人工风险标记属处置类操作,必须审计("处置继续审计")。
|
||||
saveAuditLog(createdBy, "MANUAL_FLAG_RISK", "risk_event", event.getId(), null, null);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,4 +151,23 @@ public class RiskReferralService {
|
||||
log.info("创建风险事件: userId={}, eventType={}, score={}, source={}", userId, eventType, score, source);
|
||||
return event;
|
||||
}
|
||||
|
||||
// D2(Phase 17):与 ReportService 同模式的审计写入(含 IP / User-Agent 溯源)。
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setBeforeData(beforeData);
|
||||
auditLog.setAfterData(afterData);
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
auditLog.setIp(req.getRemoteAddr());
|
||||
String ua = req.getHeader("User-Agent");
|
||||
auditLog.setUserAgent(ua != null ? (ua.length() > 512 ? ua.substring(0, 512) : ua) : null);
|
||||
}
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -62,11 +62,8 @@ class EntityMigrationParityTest {
|
||||
"operators",
|
||||
"organization_relationships",
|
||||
"permissions",
|
||||
"reports",
|
||||
"review_items",
|
||||
"review_reasons",
|
||||
"risk_actions",
|
||||
"risk_events",
|
||||
"role_permissions",
|
||||
"roles",
|
||||
"sensitive_access_logs",
|
||||
@@ -116,7 +113,13 @@ class EntityMigrationParityTest {
|
||||
"activity_staff_assignments",
|
||||
// Phase 16:活动互选二阶段同意表此前在可选白名单(= 无人测),
|
||||
// 导致双确认→建Match+会话的核心路径零覆盖。已补入 schema-h2.sql。
|
||||
"activity_mutual_connection_consents"
|
||||
"activity_mutual_connection_consents",
|
||||
// Phase 17:举报与风控三表此前全在可选白名单(= 无人测),
|
||||
// 导致 submitReport / detect* / processReport / manualFlagRisk 整条真实 DB 路径零覆盖,
|
||||
// 且 ReportServiceTest 只能写 assertTrue(true) 假绿。已补入 schema-h2.sql。
|
||||
"reports",
|
||||
"risk_events",
|
||||
"risk_actions"
|
||||
);
|
||||
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
|
||||
private static final Set<String> BASE_ENTITY_COLUMNS =
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
package com.danyang.match.platform.risk;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.risk.controller.ReportOperatorController;
|
||||
import com.danyang.match.platform.risk.controller.RiskController;
|
||||
import com.danyang.match.platform.risk.dto.ReportProcessDTO;
|
||||
import com.danyang.match.platform.risk.dto.ReportSubmitDTO;
|
||||
import com.danyang.match.platform.risk.entity.RiskAction;
|
||||
import com.danyang.match.platform.risk.entity.RiskEvent;
|
||||
import com.danyang.match.platform.risk.entity.Report;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskEventMapper;
|
||||
import com.danyang.match.platform.risk.service.ReportService;
|
||||
import com.danyang.match.platform.risk.service.RiskReferralService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 17(举报与风控)真实 DB 集成测试。
|
||||
*
|
||||
* 背景:此前 risk_events / reports / risk_actions 三张表在 H2 测试库完全缺失(只在
|
||||
* TEST_SCHEMA_OPTIONAL_TABLES 白名单里 = 无人测),导致 ReportService / RiskReferralService
|
||||
* 整条真实 DB 路径零覆盖,ReportServiceTest 只能写 assertTrue(true) 假绿。
|
||||
*
|
||||
* 本测试在补齐三表后,真实验证:
|
||||
* - D1:submitReport 写入 reports + risk_events(abuse_report);三来源 detect* 写入对应风险事件;
|
||||
* 处置封禁写入 risk_actions(ban)。
|
||||
* - D2:manualFlagRisk(人工标记)写入 MANUAL_FLAG_RISK 审计。
|
||||
* - D3:后台敏感访问(查看风险画像 / 举报详情 / 风险事件列表)写入 SENSITIVE_ACCESS 审计。
|
||||
* - D4:submitReport / processReport 写入审计(PROCESS_REPORT 等),且在无请求上下文时 ip/userAgent 为 null 不崩。
|
||||
* - D5:RiskEventMapper.findByUserId 过滤软删事件,与 sumScoreByUserId 一致。
|
||||
* - 最小可见:getUserRiskProfile 不把咨询正文(userRequestSummary)等无关字段带出。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class ReportAndRiskPhase17IntegrationTest {
|
||||
|
||||
@MockBean
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Autowired
|
||||
private ReportService reportService;
|
||||
@Autowired
|
||||
private RiskReferralService riskReferralService;
|
||||
@Autowired
|
||||
private ReportMapper reportMapper;
|
||||
@Autowired
|
||||
private RiskEventMapper riskEventMapper;
|
||||
@Autowired
|
||||
private RiskActionMapper riskActionMapper;
|
||||
@Autowired
|
||||
private AuditLogMapper auditLogMapper;
|
||||
@Autowired
|
||||
private ReportOperatorController reportOperatorController;
|
||||
@Autowired
|
||||
private RiskController riskController;
|
||||
|
||||
private String auditCount(String action) {
|
||||
return String.valueOf(auditLogMapper.selectCount(
|
||||
new QueryWrapper<AuditLog>().eq("action", action)));
|
||||
}
|
||||
|
||||
// ==================== D1:举报提交写入 reports + risk_events ====================
|
||||
|
||||
@Test
|
||||
void submitReportCreatesReportAndAbuseRiskEvent() {
|
||||
ReportSubmitDTO dto = new ReportSubmitDTO();
|
||||
dto.setReportedUserId("reported-phase17-1");
|
||||
dto.setReasonType(Constants.REPORT_REASON_FRAUD);
|
||||
dto.setDescription("诈骗测试描述");
|
||||
|
||||
Map<String, Object> result = reportService.submitReport("reporter-phase17-1", dto);
|
||||
String reportId = (String) result.get("reportId");
|
||||
assertNotNull(reportId, "举报应返回 reportId");
|
||||
|
||||
Report report = reportMapper.selectById(reportId);
|
||||
assertNotNull(report, "reports 表应有真实写入");
|
||||
assertEquals("reported-phase17-1", report.getReportedUserId());
|
||||
assertEquals(Constants.REPORT_STATUS_PROCESSING, report.getStatus(),
|
||||
"诈骗类举报应自动置为 processing");
|
||||
|
||||
List<RiskEvent> events = riskEventMapper.findByUserId("reported-phase17-1");
|
||||
assertEquals(1, events.size(), "被举报人应有一条 abuse_report 风险事件");
|
||||
RiskEvent ev = events.get(0);
|
||||
assertEquals(Constants.RISK_EVENT_ABUSE_REPORT, ev.getEventType());
|
||||
assertEquals("report", ev.getSource());
|
||||
assertEquals(reportId, ev.getSourceId(), "风险事件应溯源到举报 ID");
|
||||
}
|
||||
|
||||
// ==================== D1:三来源接入统一 RiskEvent ====================
|
||||
|
||||
@Test
|
||||
void threeSourcesCreateUnifiedRiskEvents() {
|
||||
assertEquals("high", riskReferralService.detectCounselingRisk("u-counsel-17", "我想要自杀"));
|
||||
assertEquals("high", riskReferralService.detectAppointmentRisk("u-appt-17", "带刀赴约报复"));
|
||||
assertEquals("high", riskReferralService.detectActivityRisk("u-act-17", "现场暴力冲突", null));
|
||||
|
||||
List<RiskEvent> c = riskEventMapper.findByUserId("u-counsel-17");
|
||||
List<RiskEvent> a = riskEventMapper.findByUserId("u-appt-17");
|
||||
List<RiskEvent> ac = riskEventMapper.findByUserId("u-act-17");
|
||||
|
||||
assertEquals(1, c.size());
|
||||
assertEquals(Constants.RISK_EVENT_COUNSELING_RISK, c.get(0).getEventType());
|
||||
assertEquals(Constants.RISK_SOURCE_COUNSELING, c.get(0).getSource());
|
||||
|
||||
assertEquals(1, a.size());
|
||||
assertEquals(Constants.RISK_EVENT_APPOINTMENT_RISK, a.get(0).getEventType());
|
||||
assertEquals(Constants.RISK_SOURCE_SERVICE_STATION, a.get(0).getSource());
|
||||
|
||||
assertEquals(1, ac.size());
|
||||
assertEquals(Constants.RISK_EVENT_ACTIVITY_RISK, ac.get(0).getEventType());
|
||||
assertEquals(Constants.RISK_SOURCE_OFFLINE_ACTIVITY, ac.get(0).getSource());
|
||||
}
|
||||
|
||||
// ==================== D1 + D4:处置封禁写入 risk_actions(ban) + 审计 ====================
|
||||
|
||||
@Test
|
||||
void processReportBanCreatesRiskActionAndAudit() {
|
||||
ReportSubmitDTO dto = new ReportSubmitDTO();
|
||||
dto.setReportedUserId("reported-phase17-ban");
|
||||
dto.setReasonType(Constants.REPORT_REASON_FRAUD);
|
||||
dto.setDescription("封禁测试");
|
||||
String reportId = (String) reportService.submitReport("reporter-phase17-ban", dto).get("reportId");
|
||||
|
||||
long auditBefore = auditLogMapper.selectCount(
|
||||
new QueryWrapper<AuditLog>().eq("action", "PROCESS_REPORT"));
|
||||
|
||||
ReportProcessDTO pdto = new ReportProcessDTO();
|
||||
pdto.setProcessResult(Constants.PROCESS_RESULT_BAN);
|
||||
pdto.setProcessNote("确认诈骗,予以封禁");
|
||||
reportService.processReport(reportId, pdto, "op-phase17-ban");
|
||||
|
||||
List<RiskAction> actions = riskActionMapper.findActiveByUserId("reported-phase17-ban");
|
||||
assertEquals(1, actions.size(), "应有 1 条 active ban 处置");
|
||||
assertEquals(Constants.RISK_ACTION_BAN, actions.get(0).getActionType());
|
||||
|
||||
long auditAfter = auditLogMapper.selectCount(
|
||||
new QueryWrapper<AuditLog>().eq("action", "PROCESS_REPORT"));
|
||||
assertEquals(auditBefore + 1, auditAfter, "PROCESS_REPORT 审计应新增一条");
|
||||
}
|
||||
|
||||
// ==================== D2:人工风险标记必须审计 ====================
|
||||
|
||||
@Test
|
||||
void manualFlagRiskWritesAudit() {
|
||||
long before = Long.parseLong(auditCount("MANUAL_FLAG_RISK"));
|
||||
riskReferralService.manualFlagRisk("u-manual-17", "manual_flag", "运营人工标记", "op-manual-17");
|
||||
long after = Long.parseLong(auditCount("MANUAL_FLAG_RISK"));
|
||||
assertEquals(before + 1, after, "人工风险标记应写入 MANUAL_FLAG_RISK 审计");
|
||||
|
||||
AuditLog log = auditLogMapper.selectOne(
|
||||
new QueryWrapper<AuditLog>().eq("action", "MANUAL_FLAG_RISK")
|
||||
.eq("operator_id", "op-manual-17"));
|
||||
assertNotNull(log, "审计记录应可溯源到操作人");
|
||||
}
|
||||
|
||||
// ==================== D3:后台敏感访问必须审计 ====================
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void operatorRiskEndpointsRecordSensitiveAccessAudit() {
|
||||
OperatorPrincipal principal =
|
||||
new OperatorPrincipal("op-sensitive-17", Set.of("risk.report.view"));
|
||||
// 直接调用代理 bean 方法会经过 @PreAuthorize 方法安全拦截器,
|
||||
// 必须在 SecurityContext 中装配含对应权限的 Authentication。
|
||||
List<GrantedAuthority> authorities = principal.permissions().stream()
|
||||
.map(SimpleGrantedAuthority::new).collect(Collectors.toList());
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, authorities));
|
||||
|
||||
// 准备一条真实存在的举报,供 getReportDetail 业务层读取(不存在会抛异常)。
|
||||
ReportSubmitDTO rdto = new ReportSubmitDTO();
|
||||
rdto.setReportedUserId("reported-sensitive-17");
|
||||
rdto.setReasonType(Constants.REPORT_REASON_FRAUD);
|
||||
rdto.setDescription("敏感访问审计测试");
|
||||
String reportId = (String) reportService.submitReport("reporter-sensitive-17", rdto)
|
||||
.get("reportId");
|
||||
|
||||
long before = Long.parseLong(auditCount("SENSITIVE_ACCESS"));
|
||||
|
||||
riskController.getRiskEvents(principal, "u-sensitive-x");
|
||||
reportOperatorController.getUserRiskProfile(principal, "u-sensitive-y");
|
||||
reportOperatorController.getReportDetail(principal, reportId);
|
||||
|
||||
long after = Long.parseLong(auditCount("SENSITIVE_ACCESS"));
|
||||
assertEquals(before + 3, after, "三个敏感访问端点都应写入 SENSITIVE_ACCESS 审计");
|
||||
}
|
||||
|
||||
// ==================== D5:findByUserId 过滤软删事件 ====================
|
||||
|
||||
@Test
|
||||
void findByUserIdFiltersSoftDeletedEvents() {
|
||||
RiskEvent live = new RiskEvent();
|
||||
live.setId(UUID.randomUUID().toString());
|
||||
live.setUserId("u-filter-17");
|
||||
live.setEventType("counseling_risk");
|
||||
live.setScore(5);
|
||||
live.setSource("counseling");
|
||||
live.setCreatedAt(Instant.now());
|
||||
riskEventMapper.insert(live);
|
||||
|
||||
RiskEvent deleted = new RiskEvent();
|
||||
deleted.setId(UUID.randomUUID().toString());
|
||||
deleted.setUserId("u-filter-17");
|
||||
deleted.setEventType("activity_risk");
|
||||
deleted.setScore(5);
|
||||
deleted.setSource("offline_activity");
|
||||
deleted.setCreatedAt(Instant.now());
|
||||
deleted.setDeletedAt(Instant.now());
|
||||
riskEventMapper.insert(deleted);
|
||||
|
||||
List<RiskEvent> events = riskEventMapper.findByUserId("u-filter-17");
|
||||
assertEquals(1, events.size(), "软删事件不应出现在查询中");
|
||||
assertEquals("counseling_risk", events.get(0).getEventType());
|
||||
}
|
||||
|
||||
// ==================== 最小可见:风险画像不泄露无关咨询正文 ====================
|
||||
|
||||
@Test
|
||||
void userRiskProfileDoesNotLeakCounselingDetail() {
|
||||
// 该接口只返回风险统计/事件/处置,不返回 Counseling 正文(userRequestSummary)。
|
||||
Map<String, Object> profile = reportService.getUserRiskProfile("u-minvis-17");
|
||||
assertNotNull(profile);
|
||||
assertFalse(profile.containsKey("userRequestSummary"),
|
||||
"风险画像不得携带咨询正文原文");
|
||||
assertFalse(profile.containsKey("counselingDetail"),
|
||||
"风险画像不得携带咨询正文原文");
|
||||
}
|
||||
}
|
||||
@@ -358,3 +358,56 @@ CREATE TABLE matchmaker_followups (
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Phase 17:举报与风控三表(与 V012__add_risk_and_report_tables.sql 生产 DDL 对齐)。
|
||||
-- 此前完全缺失 → ReportService / RiskReferralService 整条真实 DB 路径零覆盖,
|
||||
-- 且 ReportServiceTest 只能写 assertTrue(true) 假绿。已补入并移出 TEST_SCHEMA_OPTIONAL_TABLES 白名单。
|
||||
-- 注意:H2 无 operators 表,故去掉生产 DDL 中的 REFERENCES operators(id) 外键;
|
||||
-- 保留 status / action_type 的 CHECK 约束,防止非法取值在测试期被静默放过(同 Phase 14/15/16 原则)。
|
||||
CREATE TABLE reports (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
reporter_user_id VARCHAR(36) NOT NULL,
|
||||
reported_user_id VARCHAR(36),
|
||||
reason_type VARCHAR(64) NOT NULL,
|
||||
description CLOB,
|
||||
evidence_file_ids CLOB,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
process_result VARCHAR(64),
|
||||
processed_by VARCHAR(36),
|
||||
processed_at TIMESTAMP WITH TIME ZONE,
|
||||
process_note CLOB,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT chk_report_status CHECK (status IN ('pending', 'processing', 'resolved', 'rejected', 'dismissed'))
|
||||
);
|
||||
|
||||
CREATE TABLE risk_events (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
score INT NOT NULL DEFAULT 0,
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'auto',
|
||||
source_id VARCHAR(128),
|
||||
note CLOB,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TABLE risk_actions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
action_type VARCHAR(64) NOT NULL,
|
||||
reason VARCHAR(256) NOT NULL,
|
||||
reason_detail CLOB,
|
||||
created_by VARCHAR(36) NOT NULL,
|
||||
effective_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT chk_risk_action_type CHECK (action_type IN ('warning', 'feature_limit', 'temporary_suspend', 'ban')),
|
||||
CONSTRAINT chk_risk_action_status CHECK (status IN ('active', 'resolved', 'expired'))
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user