Skip to content
第 10 章 ⏱ 10 分钟阅读

第 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 + 过期时间
日志清理定时任务定期删旧数据

动手练习 ​

  1. AOP 日志:实现 @OperationLog,在用户 CRUD 接口加注解,操作后能在 sys_operation_log 看到记录
  2. 登录失败:故意输错 6 次密码,验证账号被锁定
  3. 日志查询:写查询接口,按模块/用户名/时间筛选

下一章:第 11 章:文件存储 →

本站基于 VitePress 构建 · 由 StackHub 团队维护