第 10 章:操作日志
学习目标
- 用 AOP 自动记录操作日志
- 实现登录日志(成功/失败)
- 实现登录失败次数限制(Redis 计数)
一、操作日志表
sql
CREATE TABLE `sys_operation_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT COMMENT '操作用户',
`username` VARCHAR(50) COMMENT '用户名(冗余)',
`module` VARCHAR(50) COMMENT '模块:用户管理',
`action` VARCHAR(100) COMMENT '操作:新增/删除/修改',
`description` VARCHAR(255) COMMENT '描述',
`method` VARCHAR(255) COMMENT '方法签名',
`request_url` VARCHAR(255),
`request_method` VARCHAR(10) COMMENT 'HTTP方法',
`request_params` TEXT COMMENT '请求参数JSON',
`ip` VARCHAR(50),
`user_agent` VARCHAR(500),
`cost` BIGINT COMMENT '耗时ms',
`status` TINYINT DEFAULT 1 COMMENT '1=成功 0=失败',
`error_msg` TEXT,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_module` (`module`),
KEY `idx_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志';二、自定义注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
String module(); // 模块名
String action(); // 操作:新增/修改/删除/查询
String description() default ""; // 业务描述
boolean saveParams() default true; // 是否保存请求参数
boolean saveResponse() default false; // 是否保存响应数据(敏感接口慎用)
}使用示例:
java
@OperationLog(module = "用户管理", action = "新增", description = "创建新用户")
@PreAuthorize("hasAuthority('user:create')")
@PostMapping
public Result<Long> create(@RequestBody @Valid UserCreateDTO dto) {
return Result.ok(userService.createUser(dto));
}三、AOP 切面
java
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class OperationLogAspect {
private final OperationLogMapper operationLogMapper;
private final ObjectMapper objectMapper;
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable {
long start = System.currentTimeMillis();
OperationLog log = new OperationLog();
log.setModule(operationLog.module());
log.setAction(operationLog.action());
log.setDescription(operationLog.description());
try {
// ① 当前用户
try {
Long userId = SecurityUtils.getCurrentUserId();
log.setUserId(userId);
log.setUsername(SecurityUtils.getCurrentUsername());
} catch (Exception ignore) {
// 未登录场景,跳过
}
// ② 请求信息
HttpServletRequest req = getRequest();
if (req != null) {
log.setIp(getIp(req));
log.setRequestUrl(req.getRequestURI());
log.setRequestMethod(req.getMethod());
log.setUserAgent(req.getHeader("User-Agent"));
}
log.setMethod(pjp.getSignature().toLongString());
// ③ 请求参数(可选,密码字段注意脱敏)
if (operationLog.saveParams()) {
Object[] args = pjp.getArgs();
Object[] safeArgs = filterSensitive(args);
log.setRequestParams(objectMapper.writeValueAsString(safeArgs));
}
// ④ 执行原方法
Object result = pjp.proceed();
log.setStatus(1);
return result;
} catch (Throwable e) {
log.setStatus(0);
log.setErrorMsg(e.getMessage());
throw e;
} finally {
log.setCost(System.currentTimeMillis() - start);
// ⑤ 异步保存(不阻塞业务)
saveAsync(log);
}
}
/**
* 过滤敏感字段(密码、token 等)
*/
private Object[] filterSensitive(Object[] args) {
return Arrays.stream(args).map(arg -> {
if (arg == null) return null;
// 简化:含 password 关键字的对象转 JSON 后替换密码字段
try {
String json = objectMapper.writeValueAsString(arg);
json = json.replaceAll("\"password\":\"[^\"]+\"", "\"password\":\"***\"");
return objectMapper.readTree(json);
} catch (Exception e) {
return arg;
}
}).toArray();
}
@Async("logExecutor") // 异步线程池
public void saveAsync(OperationLog log) {
try {
operationLogMapper.insert(log);
} catch (Exception e) {
OperationLogAspect.log.error("保存操作日志失败", e);
}
}
private HttpServletRequest getRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();
return attrs == null ? null : attrs.getRequest();
}
private String getIp(HttpServletRequest req) {
String ip = req.getHeader("X-Forwarded-For");
if (StringUtils.isBlank(ip) || "unknown".equals(ip)) {
ip = req.getRemoteAddr();
}
// 取第一个 IP(代理链里的客户端)
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}⚠️ 坑 1:必须过滤密码字段。日志里有明文密码等于把数据库交给攻击者。简单实现:JSON 序列化后正则替换
"password":"xxx"→"password":"***"。
⚠️ 坑 2:保存日志用
@Async或CompletableFuture.runAsync(),绝对不能在主线程保存。同步保存会让接口 P99 上升 50-200ms,而且日志表 INSERT 锁一旦阻塞,整个系统雪崩。
四、专用日志线程池
java
@Configuration
public class LogAsyncConfig {
@Bean("logExecutor")
public Executor logExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(2000);
executor.setThreadNamePrefix("log-async-");
// 日志写失败不阻塞业务,直接丢弃
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
executor.initialize();
return executor;
}
}⚠️ 坑 3:拒绝策略必须 DiscardPolicy。日志是辅助功能,不能阻塞业务。如果用
CallerRunsPolicy,当队列满时主线程帮忙写日志,接口直接阻塞。
五、登录日志
java
@Data
@TableName("sys_login_log")
public class LoginLog {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String username;
private String ip;
private String userAgent;
private String location; // IP 归属地
private Integer status; // 1=成功 0=失败
private String message;
private LocalDateTime loginTime;
}java
@Component
@RequiredArgsConstructor
public class LoginLogHelper {
private final LoginLogMapper loginLogMapper;
public void recordSuccess(String username, String ip, String userAgent) {
LoginLog log = new LoginLog();
log.setUsername(username);
log.setIp(ip);
log.setUserAgent(userAgent);
log.setStatus(1);
log.setMessage("登录成功");
log.setLoginTime(LocalDateTime.now());
loginLogMapper.insert(log);
}
public void recordFailure(String username, String ip, String userAgent, String reason) {
LoginLog log = new LoginLog();
log.setUsername(username);
log.setIp(ip);
log.setUserAgent(userAgent);
log.setStatus(0);
log.setMessage(reason);
log.setLoginTime(LocalDateTime.now());
loginLogMapper.insert(log);
}
}六、登录失败监听器
java
@Component
@RequiredArgsConstructor
public class AuthenticationFailureListener
implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {
private final LoginLogHelper loginLogHelper;
@Override
public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {
String username = (String) event.getAuthentication().getPrincipal();
HttpServletRequest req = getRequest();
loginLogHelper.recordFailure(username,
getIp(req),
req.getHeader("User-Agent"),
"用户名或密码错误");
}
}七、登录失败锁定(Redis 计数)
java
@Service
@RequiredArgsConstructor
public class LoginAttemptService {
private final RedisTemplate<String, Object> redis;
private static final int MAX_ATTEMPTS = 5;
private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
public void loginFailed(String username) {
String key = "login:attempt:" + username;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1L) {
redis.expire(key, LOCK_DURATION);
}
if (count != null && count >= MAX_ATTEMPTS) {
throw new BusinessException(ErrorCode.USER_LOCKED,
"登录失败次数过多,账号已被锁定 15 分钟");
}
}
public void loginSucceeded(String username) {
redis.delete("login:attempt:" + username);
}
public boolean isLocked(String username) {
Long count = (Long) redis.opsForValue().get("login:attempt:" + username);
return count != null && count >= MAX_ATTEMPTS;
}
}集成到登录流程:
java
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto, HttpServletRequest req) {
// ① 检查是否被锁定
if (loginAttemptService.isLocked(dto.getUsername())) {
throw new BusinessException("账号已被锁定,请 15 分钟后再试");
}
try {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()));
loginAttemptService.loginSucceeded(dto.getUsername());
loginLogHelper.recordSuccess(dto.getUsername(), getIp(req), req.getHeader("User-Agent"));
return Result.ok(buildLoginVO(auth));
} catch (BadCredentialsException e) {
loginAttemptService.loginFailed(dto.getUsername());
loginLogHelper.recordFailure(dto.getUsername(), getIp(req),
req.getHeader("User-Agent"), "用户名或密码错误");
throw new BusinessException(ErrorCode.USER_PASSWORD_ERROR);
}
}⚠️ 坑 4:必须在记录完登录日志后再抛出业务异常,否则全局异常处理器会中断后续逻辑。失败计数和日志记录都是关键安全审计,任何一步都不能漏。
八、日志查询接口
java
@RestController
@RequestMapping("/api/log/operation")
@RequiredArgsConstructor
@Tag(name = "操作日志")
public class OperationLogController {
private final OperationLogMapper operationLogMapper;
@GetMapping("/page")
@PreAuthorize("hasAuthority('log:operation:view')")
public Result<PageResult<OperationLog>> page(
@RequestParam(defaultValue = "1") Long current,
@RequestParam(defaultValue = "20") Long size,
@RequestParam(required = false) String module,
@RequestParam(required = false) String username) {
Page<OperationLog> page = new Page<>(current, size);
LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<OperationLog>()
.like(StringUtils.hasText(module), OperationLog::getModule, module)
.like(StringUtils.hasText(username), OperationLog::getUsername, username)
.orderByDesc(OperationLog::getCreateTime);
IPage<OperationLog> result = operationLogMapper.selectPage(page, wrapper);
return Result.ok(PageResult.of(result));
}
@DeleteMapping("/batch")
@PreAuthorize("hasAuthority('log:operation:delete')")
public Result<Void> batchDelete(@RequestBody List<Long> ids) {
operationLogMapper.deleteBatchIds(ids);
return Result.ok();
}
}九、日志清理(定时任务)
日志数据量大,必须定期清理:
java
@Component
@RequiredArgsConstructor
public class LogCleanTask {
private final OperationLogMapper operationLogMapper;
private final LoginLogMapper loginLogMapper;
// 每天凌晨 3 点清理 90 天前的日志
@Scheduled(cron = "0 0 3 * * ?")
public void clean() {
LocalDateTime threshold = LocalDateTime.now().minusDays(90);
int opCount = operationLogMapper.delete(
new LambdaQueryWrapper<OperationLog>().lt(OperationLog::getCreateTime, threshold));
int loginCount = loginLogMapper.delete(
new LambdaQueryWrapper<LoginLog>().lt(LoginLog::getLoginTime, threshold));
log.info("清理完成,操作日志 {} 条,登录日志 {} 条", opCount, loginCount);
}
}⚠️ 坑 5:生产环境日志必须定时清理,否则:
- 表数据千万级后查询变慢
- 备份文件膨胀
- 索引重建时间指数级增长
十、本章小结
| 要点 | 关键 |
|---|---|
| 自定义注解 | @OperationLog(module, action) |
| AOP 切面 | @Around 包裹,记录输入/输出/耗时/异常 |
| 异步保存 | 专用线程池 + DiscardPolicy |
| 密码脱敏 | JSON 序列化后正则替换 |
| 登录失败计数 | Redis INCR + 过期时间 |
| 日志清理 | 定时任务定期删旧数据 |
动手练习
- AOP 日志:实现
@OperationLog,在用户 CRUD 接口加注解,操作后能在sys_operation_log看到记录 - 登录失败:故意输错 6 次密码,验证账号被锁定
- 日志查询:写查询接口,按模块/用户名/时间筛选
下一章:第 11 章:文件存储 →