package com.mydreamjob.audit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.time.LocalDateTime; /** * 审计日志服务:持久化到 audit_logs 表,写入失败不阻断主业务流程。 */ @Slf4j @Component @RequiredArgsConstructor public class AuditService { private final AuditLogRepository auditLogRepository; /** * 记录一个审计事件(兼容旧签名,上下文字段为 null 时跳过填充) */ public void log(Long userId, String action, String entityType, Long entityId, String details, String ipAddress, String userAgent, String requestId) { logWithContext(userId, action, entityType, entityId, details, AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build(), LocalDateTime.now()); } /** * 记录一个审计事件(统一上下文版本) */ public void log(Long userId, String action, String entityType, Long entityId, String details, AuditContext context) { logWithContext(userId, action, entityType, entityId, details, context, LocalDateTime.now()); } /** * 记录一个审计事件(显式时间戳版本,S02)。 * *

供 S02 认证入口使用:登录、刷新、登出、重放处置与全局吊销在各自入口 * 只捕获一次 {@code capturedNow},审计时间必须与之同源,不得再次读时。

*/ public void log(Long userId, String action, String entityType, Long entityId, String details, AuditContext context, LocalDateTime capturedAt) { logWithContext(userId, action, entityType, entityId, details, context, capturedAt); } private void logWithContext(Long userId, String action, String entityType, Long entityId, String details, AuditContext ctx, LocalDateTime createdAt) { try { // 脱敏:details 中不得包含 code/password/token/secret 等关键词的值 String safeDetails = sanitize(details); AuditLog auditLog = AuditLog.builder() .userId(userId) .action(action) .entityType(entityType) .entityId(entityId) .details(safeDetails) .ipAddress(ctx != null ? ctx.getIpAddress() : null) .userAgent(ctx != null ? ctx.getUserAgent() : null) .requestId(ctx != null ? ctx.getRequestId() : null) .createdAt(createdAt) .build(); auditLogRepository.save(auditLog); } catch (Exception e) { // 审计持久化失败不能阻断主业务流程 log.error("Failed to persist audit log: action={}, userId={}, error={}", action, userId, e.getMessage()); } } public void logLogin(Long userId, String ipAddress, String userAgent, String requestId) { log(userId, "LOGIN", "USER", userId, "User logged in", ipAddress, userAgent, requestId); } public void logLogin(Long userId, AuditContext ctx) { log(userId, "LOGIN", "USER", userId, "User logged in", ctx); } public void logLogout(Long userId, String ipAddress, String userAgent, String requestId) { log(userId, "LOGOUT", "USER", userId, "User logged out", ipAddress, userAgent, requestId); } public void logLogout(Long userId, AuditContext ctx) { log(userId, "LOGOUT", "USER", userId, "User logged out", ctx); } /** S02:登录会话创建,使用显式 capturedNow。 */ public void logSessionCreated(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) { log(userId, "AUTH_SESSION_CREATED", "AUTH_SESSION", null, "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); } /** S02:Refresh Token 轮换成功,使用显式 capturedNow。 */ public void logTokenRefreshed(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) { log(userId, "AUTH_TOKEN_REFRESHED", "AUTH_SESSION", null, "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); } /** S02:会话被撤销(登出、重放处置或全局吊销)。 */ public void logSessionRevoked(Long userId, String sessionRef, String reason, AuditContext ctx, LocalDateTime capturedAt) { log(userId, "AUTH_SESSION_REVOKED", "AUTH_SESSION", null, "{\"sessionRef\":\"" + sessionRef + "\",\"reason\":\"" + reason + "\"}", ctx, capturedAt); } /** S02:检测到 Refresh Token 重放。 */ public void logRefreshReuseDetected(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) { log(userId, "AUTH_REFRESH_REUSE_DETECTED", "AUTH_SESSION", null, "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); } /** * 脱敏:不记录可能的敏感字段值 */ private String sanitize(String details) { if (details == null) return null; return details.replaceAll("(?i)code[=:]\\s*\\S+", "code=[REDACTED]") .replaceAll("(?i)password[=:]\\s*\\S+", "password=[REDACTED]") .replaceAll("(?i)token[=:]\\s*\\S+", "token=[REDACTED]") .replaceAll("(?i)secret[=:]\\s*\\S+", "secret=[REDACTED]"); } }