package com.mydreamjob.risk;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditLog;
import com.mydreamjob.audit.AuditLogRepository;
import com.mydreamjob.audit.AuditService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 风险事件扫描器(V37)。
*
*
{@code scanOnce()} 为可直接单测的方法;{@code @Scheduled} 只调用它。扫描基于既有
* {@code audit_logs},仅消费 {@code REPORT_RATE_LIMITED}/{@code BLOCKED_SEND_PROHIBITED} 两个
* 白名单 action,且 user_id 非空。整批成功后才推进 checkpoint;任一处理失败整体回滚。
*
* 去刷屏:仅当窗口内命中数恰等于 thresholdCount 时创建事件;阈值之后同窗口的更多命中不再
* 创建新事件。事件插入受唯一键 {@code (rule_config_id, source_audit_log_id)} 与 existsBy 防重保护。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RiskEventDetector {
public static final int BATCH_SIZE = 500;
public static final Set ALLOWED_ACTIONS =
Set.of("REPORT_RATE_LIMITED", "BLOCKED_SEND_PROHIBITED");
private static final String AUDIT_DETECTED = "RISK_EVENT_DETECTED";
private final ScoringRuleConfigRepository ruleRepository;
private final ScoringRiskEventRepository eventRepository;
private final ScoringRiskScanCheckpointRepository checkpointRepository;
private final AuditLogRepository auditLogRepository;
private final AuditService auditService;
private final Clock clock;
/**
* 执行一次风险扫描。返回本轮新建的风险事件数。
*/
@Transactional
public int scanOnce() {
ScoringRiskScanCheckpoint checkpoint = checkpointRepository
.findByCheckpointKeyForUpdate(ScoringRiskScanCheckpoint.KEY_RISK_EVENT_DETECTOR)
.orElseGet(this::createCheckpoint);
long lastId = checkpoint.getLastAuditLogId();
List batch = auditLogRepository
.findByIdAfterOrderByIdAsc(lastId, PageRequest.of(0, BATCH_SIZE));
if (batch.isEmpty()) {
return 0;
}
Map ruleByAction = ruleRepository.findAllByEnabledTrue().stream()
.collect(Collectors.toMap(ScoringRuleConfig::getSourceAction, Function.identity()));
LocalDateTime now = LocalDateTime.now(clock);
long maxId = lastId;
int created = 0;
for (AuditLog a : batch) {
if (a.getId() > maxId) {
maxId = a.getId();
}
// 仅处理带用户主体且 action 在规则白名单内的审计
if (a.getUserId() == null || a.getCreatedAt() == null) {
continue;
}
ScoringRuleConfig rule = ruleByAction.get(a.getAction());
if (rule == null) {
// 规则已禁用或非白名单 action:审计照常越过 checkpoint;重新启用不回放历史
continue;
}
LocalDateTime winStart = a.getCreatedAt().minusMinutes(rule.getWindowMinutes());
long occurrence = auditLogRepository.countByUserIdAndActionAndCreatedAtBetween(
a.getUserId(), a.getAction(), winStart, a.getCreatedAt());
// 仅恰好等于阈值才生成事件(防止阈值后每次操作刷屏)
if (occurrence == rule.getThresholdCount().longValue()
&& !eventRepository.existsByRuleIdAndSourceAuditLogId(rule.getId(), a.getId())) {
ScoringRiskEvent ev = ScoringRiskEvent.builder()
.rule(rule)
.subjectUserId(a.getUserId())
.sourceAuditLogId(a.getId())
.sourceAction(rule.getSourceAction())
.severity(rule.getSeverity())
.windowStartedAt(winStart)
.windowEndedAt(a.getCreatedAt())
.occurrenceCount((int) occurrence)
.status(ScoringRiskEvent.STATUS_OPEN)
.detectedAt(now)
.build();
// 使用 saveAndFlush 的返回对象,确保事件 ID 已生成(IDENTITY 生成策略下必须用返回值,
// 否则 ev.getId() 可能仍为 null,导致检测审计 entity_id/details.riskEventId 无法定位)。
ScoringRiskEvent savedEvent = eventRepository.saveAndFlush(ev);
created++;
auditService.log(null, AUDIT_DETECTED, "SCORING_RISK_EVENT", savedEvent.getId(),
detectedAuditDetails(savedEvent), AuditContext.builder().build());
}
}
checkpoint.setLastAuditLogId(maxId);
checkpointRepository.save(checkpoint);
return created;
}
private ScoringRiskScanCheckpoint createCheckpoint() {
ScoringRiskScanCheckpoint cp = ScoringRiskScanCheckpoint.builder()
.checkpointKey(ScoringRiskScanCheckpoint.KEY_RISK_EVENT_DETECTOR)
.lastAuditLogId(0L)
.build();
return checkpointRepository.saveAndFlush(cp);
}
/**
* RISK_EVENT_DETECTED 审计 details(JSON 形式规避 AuditService 对 {@code code=} 的脱敏误伤,
* 只记录非敏感追踪字段,不含用户身份/原始审计 details/IP/UA/凭据)。
*/
private static String detectedAuditDetails(ScoringRiskEvent ev) {
return "{\"riskEventId\":" + ev.getId()
+ ",\"ruleCode\":\"" + ev.getRule().getRuleCode() + "\""
+ ",\"severity\":\"" + ev.getSeverity() + "\""
+ ",\"occurrenceCount\":" + ev.getOccurrenceCount() + "}";
}
}