系列第 5 篇,兑现上一篇的预告,拆
forge-starter-log(1383 行)。他问的时候我还挺自信------我们登录日志、操作日志都有,查个失败记录不是分分钟的事?
然后我写了条 SQL:
SELECT * FROM sys_login_log WHERE login_status = 0。返回 0 行。
我又查了全表,几万条记录,没有一条是失败的。
一、先说结论,再拆代码
我们的登录日志有 6 种类型:LOGIN、LOGOUT、KICKOUT、REPLACED、DISABLE、UNTIE_DISABLE。
看着挺全对吧?但它们全部只在"成功"时记录。
原因藏在 Sa-Token 的监听机制里。往下看。
二、模块全景
整个 starter 只有 1383 行,7 个类,两条完全独立的链路:
perl
forge-starter-log/
├── aspect/
│ └── OperationLogAspect.java # 676 行 ------ 操作日志(链路 A:AOP 切面)
├── listener/
│ └── LoginLogListener.java # 登录日志(链路 B:SaTokenListener 事件)
├── context/
│ └── OperationAuditContext.java # 审计快照(普通 ThreadLocal)
├── config/
│ └── LogThreadPoolConfig.java # 共用线程池
├── domain/
│ ├── OperationLogInfo.java # 操作日志实体(23 个字段)
│ └── LoginLogInfo.java # 登录日志实体
├── service/
│ └── ILogService.java # ← 持久化 SPI,框架层不实现
└── (无 Mapper、无 Entity、无 SQL)
less
链路 A:操作日志
HTTP 请求 → OperationLogAspect(@Around) → 构造 OperationLogInfo → 异步线程池 → ILogService.saveOperationLog
链路 B:登录日志
StpUtil.login() → Sa-Token 事件 → LoginLogListener.doLogin() → 构造 LoginLogInfo → 异步线程池 → ILogService.saveLoginLog
两条链路共用同一个线程池:logTaskExecutor
两条链路共用同一个持久化出口:ILogService
三、先看设计得好的地方:框架层不碰数据库
这是整个模块最值得学的一点。ILogService 只有两个方法:
java
public interface ILogService {
void saveOperationLog(OperationLogInfo logInfo);
void saveLoginLog(LoginLogInfo logInfo);
}
而框架层没有任何实现类。两个入口组件都是这么注册的:
java
@Aspect
@Component
@ConditionalOnBean(ILogService.class) // ← 关键
public class OperationLogAspect { ... }
@Component
@ConditionalOnBean(ILogService.class) // ← 同样
public class LoginLogListener implements SaTokenListener { ... }
业务方不实现 ILogService,这两个 Bean 根本不会注册,切面和监听器都不会进 Spring 容器,连 AOP 代理都不会创建。
对比一下常见写法:starter 里塞一个默认实现,写死一张表,业务方要么接受你的表结构,要么自己写一堆排除逻辑。这里的选择干净得多------框架定义契约,业务决定落地。
在 forge-plugin-system 里的实现长这样:
java
@Service
public class SystemLogServiceImpl implements ILogService {
@Override
public void saveLoginLog(LoginLogInfo logInfo) {
fillLoginLogUserInfo(logInfo); // 补全用户名、部门等冗余字段
loginLogMapper.insert(convert(logInfo));
}
}
四、5 个反直觉的设计
设计 1:切点拦的是所有 Controller,不是 @OperationLog
注解挂在 @OperationLog 上,那切点应该是 @annotation 吧?看代码:
java
/**
* 定义切点:拦截所有标注@OperationLog的方法
*/
//@Pointcut("@annotation(com.mdframe.forge.starter.core.annotation.log.OperationLog)")
@Pointcut("@within(org.springframework.stereotype.Controller)
|| @within(org.springframework.web.bind.annotation.RestController)")
public void operationLogPointcut() {
}
@annotation 那行被注释掉了 ,实际生效的是 @within(@Controller) || @within(@RestController)。
注释还停留在"拦截所有标注@OperationLog的方法"------注释和代码已经对不上了。
后果:只要类上有 @RestController,这个类里的每个方法都会进切面 ,不管你有没有加 @OperationLog。你以为只给 3 个接口加了日志,实际是整个项目的接口都被拦了一遍。
为什么这么改?因为下一步它会"自动推断"要不要记、记成什么类型。这确实方便------但同时意味着每个请求都要付一次切面进入的成本。
设计 2:决定"不记"之前,活已经干完了
切面里的顺序是这样的:
java
// 1. 拿 request
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 2. 判断是否排除路径(遍历 8 项敏感路径 + 正则匹配配置的排除路径)
if (isExcludePath(requestUrl)) {
return joinPoint.proceed();
}
// 3. 构造日志对象,填 6 个字段
OperationLogInfo logInfo = new OperationLogInfo();
logInfo.setOperationTime(LocalDateTime.now());
logInfo.setRequestUrl(requestUrl);
logInfo.setRequestMethod(request.getMethod());
logInfo.setOperationIp(getClientIp(request));
logInfo.setUserAgent(request.getHeader("User-Agent"));
logInfo.setOperationPage(...);
logInfo.setOperationPageTitle(...);
// 4. 没加注解的话,去查一遍 API 配置
OperationLog annotation = method.getAnnotation(OperationLog.class);
if (annotation != null) {
// 用注解上的信息
} else {
apiConfig = apiConfigManager.getApiConfig(request.getRequestURI(), request.getMethod());
}
// 5. 到这里才判断:这条到底要不要记?
if (shouldSkipOperationLog(logInfo.getOperationType(), request.getMethod())) {
return proceedWithoutOperationLog(joinPoint);
}
先做饭,再问你饿不饿。
第 4 步是重点。所有没加 @OperationLog 的接口(也就是绝大多数),每个请求都要走一次 apiConfigManager.getApiConfig()。你的列表查询、下拉框、树形菜单------这些压根不该记日志的高频 GET 请求,全都在查配置。
合理的顺序应该是:先做便宜的判断 (是不是 GET、URL 是不是查询类后缀),再做贵的(构造对象、查配置)。
设计 3:操作日志和登录日志抢同一个只有 2 个线程的池子
java
@Bean("logTaskExecutor")
public Executor logTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(logProperties.getThreadPoolCoreSize()); // 默认 2
executor.setMaxPoolSize(logProperties.getThreadPoolMaxSize()); // 默认 5
executor.setQueueCapacity(logProperties.getThreadPoolQueueCapacity());// 默认 500
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
return executor;
}
两个问题叠在一起:
问题 A:池子太小。 core=2、max=5,最多 5 个线程写日志。
问题 B:两条链路共用它。 早上 9 点全员打卡登录,登录日志瞬间涌入;同时业务操作也在产生操作日志。两边抢 5 个线程。
问题 C(最致命):CallerRunsPolicy。 队列 500 满之后,提交任务的业务线程自己执行写日志。
于是就形成了正反馈:
markdown
高峰期 → 日志堆积 → 队列满 → 业务线程被迫自己写日志 → 接口变慢
→ 请求堆积 → 更多日志 → 更多业务线程被拖下水
我见过最典型的现象是"系统下午两三点特别卡,其他时间正常"。查 CPU、内存、慢 SQL 全都正常,因为瓶颈根本不在那儿。
解法:
yaml
forge:
log:
thread-pool-core-size: 8
thread-pool-max-size: 16
thread-pool-queue-capacity: 5000
再把拒绝策略换掉。日志是旁路任务,丢几条可以接受,把业务线程拖死不行:
java
// 丢弃 + 计数告警,比 CallerRunsPolicy 适合旁路场景
executor.setRejectedExecutionHandler((r, ex) -> {
logDiscardCounter.increment();
log.warn("日志队列已满,丢弃一条日志,当前丢弃总数: {}", logDiscardCounter.get());
});
设计 4:登录日志里永远没有"失败"
回到开头那个问题。
LoginLogListener 实现的是 Sa-Token 的 SaTokenListener,6 个事件方法:
java
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
LoginLogInfo logInfo = buildLoginLog(loginId, "LOGIN", 1, "登录成功"); // ← 状态码写死 1
saveLoginLogAsync(logInfo);
}
@Override
public void doLogout(...) { buildLoginLog(loginId, "LOGOUT", 1, "登出成功"); }
@Override
public void doKickout(...) { buildLoginLog(loginId, "KICKOUT", 1, "被踢下线"); }
@Override
public void doReplaced(...) { buildLoginLog(loginId, "REPLACED", 1, "被顶下线"); }
@Override
public void doDisable(...) { buildLoginLog(loginId, "DISABLE", 0, "账号被封禁"); }
@Override
public void doUntieDisable(...) { buildLoginLog(loginId, "UNTIE_DISABLE", 1, "账号解封"); }
doLogin 只在登录成功时才会被 Sa-Token 回调。 密码错了?压根不触发这个监听器。
那失败去哪了?在认证策略里:
java
// AbstractAuthStrategy
protected void recordLoginFailure(LoginUser loginUser, String errorMessage) {
if (!isLoginLockEnabled() || loginUser == null) {
throw new RuntimeException(errorMessage);
}
int remaining = loginLockService.recordLoginFailure(loginUser); // ← 只记了失败次数
if (remaining > 0) {
throw new RuntimeException(String.format("%s,还剩 %d 次尝试机会", errorMessage, remaining));
} else {
throw new RuntimeException(String.format("%s,账号已被锁定 %d 分钟", ...));
}
}
失败次数进了 loginLockService(用来做账号锁定),但没有任何一行代码把它写进 sys_login_log。
所以:
| 场景 | 进 login_lock? | 进 sys_login_log? |
|---|---|---|
| 登录成功 | --- | ✅ |
| 密码错误 | --- | ❌ |
| 用户不存在 | --- | ❌ |
| 账号被锁定 | --- | ❌ |
| 被踢下线 | --- | ✅ |
结果是:你的登录日志表只能回答"谁登录过",回答不了"谁在尝试攻击我们"。
而"记录登录失败"恰恰是等保三级里明明白白写着的审计要求。
解法 :让 recordLoginFailure 同时落一条登录日志:
java
protected void recordLoginFailure(LoginUser loginUser, String errorMessage) {
// 补一条失败日志(loginUser 可能为 null,用请求里的用户名兜底)
LoginLogInfo failLog = new LoginLogInfo();
failLog.setLoginType("LOGIN");
failLog.setLoginStatus(0);
failLog.setLoginMessage(errorMessage);
failLog.setLoginTime(LocalDateTime.now());
if (loginUser != null) {
failLog.setUserId(loginUser.getUserId());
failLog.setUsername(loginUser.getUsername());
}
fillFromRequest(failLog);
saveLoginLogAsync(failLog);
if (!isLoginLockEnabled() || loginUser == null) {
throw new RuntimeException(errorMessage);
}
// ... 原有锁定逻辑
}
设计 5:traceId 只进 MDC,不进数据库
切面里生成了 traceId:
java
private static final String TRACE_ID_KEY = "traceId";
String traceId = generateTraceId();
MDC.put(TRACE_ID_KEY, traceId);
然后呢?我翻遍了 OperationLogInfo 的 23 个字段:
java
private Long id;
private Long tenantId;
private Long userId;
private String username;
private String operatorName;
private String operationModule;
private String operationType;
private String operationDesc;
private String operationPage;
private String operationPageTitle;
private String operationContent;
private String requestMethod;
private String requestUrl;
private String requestParams;
private String responseResult;
private String beforeData;
private String afterData;
private String diffData;
private String errorMsg;
private Integer operationStatus;
private String operationIp;
private String operationLocation;
private String userAgent;
private Long executeTime;
private LocalDateTime operationTime;
没有 traceId。
它只活在 MDC 里,也就是只会出现在日志文件的那一行。数据库里的日志记录和文件日志对不上号 。排查问题的时候,你只能靠 operation_time 加 IP 去猜。
更尴尬的是这段代码:
java
} finally {
saveLogAsync(logInfo); // ① 提交异步任务
OperationAuditContext.clear();
MDC.remove(TRACE_ID_KEY); // ② 紧接着就清掉了
}
saveLogAsync 只是把任务丢进线程池,线程真正开始执行时,主线程的 MDC 已经被清空了。MDC 默认不是 InheritableThreadLocal,异步线程拿不到 traceId。
结果:写日志的那个线程,既打不出 traceId,也存不进 traceId。
解法 :把 traceId 作为一个字段塞进 logInfo,在提交异步任务之前就填好。这样数据库里有、MDC 里也有,两边能对上。
java
logInfo.setTraceId(traceId); // 在 MDC.put 之后、saveLogAsync 之前
五、补充:4 个方法漏了开关检查
LogProperties 有个开关:
java
private Boolean enableLoginLog = true;
doLogin 和 doLogout 老老实实检查了:
java
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
if (logProperties.getEnableLoginLog() == null || !logProperties.getEnableLoginLog()) {
return;
}
...
}
但 doKickout、doReplaced、doDisable、doUntieDisable 四个方法一个都没检查。
把 forge.log.enable-login-log 设成 false 之后,登录/登出不记了,踢下线、顶下线、封禁、解封照样在记。
这个不一致不会造成故障,但会让人困惑------"我明明关了登录日志,怎么表还在涨?"
六、几个做得对的细节
吐槽完了,说点值得抄的。
敏感路径整个不进切面。
java
private static final Set<String> SENSITIVE_CREDENTIAL_PATH_SUFFIXES = Set.of(
"/auth/login",
"/auth/register",
"/auth/changePassword",
"/auth/resetPassword",
"/auth/resetPassword/code",
"/auth/online/kickout",
"/auth/online/batchKickout",
"/oauth2/token"
);
登录、改密、踢人下线这些接口,请求体里有明文密码或 Token。它们连切面都不进,从源头上杜绝了"密码被写进日志表"。
开了接口加密的参数会被替换掉。
java
if (redactRequestBody && hasParameterAnnotation(signature.getMethod(), i, RequestBody.class)) {
params.put(paramName, "[DECRYPTED_REQUEST_BODY_OMITTED]");
}
带 @RequestBody 的参数,只要接口标了 @ApiDecrypt,日志里存的是占位符。而且后面还有一道防线:
java
private boolean containsOmittedRequestBody(String value) {
return StrUtil.isNotBlank(value) && value.contains(DECRYPTED_REQUEST_BODY_OMITTED);
}
兜底填 afterData 的时候,还要再检查一次,防止把占位符当成真实数据存进去。
没加注解也能自动分类操作类型。
java
private String resolveDefaultOperationType(String requestMethod, String requestUrl) {
if (isReadOnlyMethod(requestMethod)) return "QUERY";
if (isQueryLikeRequest(requestUrl)) return "QUERY"; // /page /list /tree /detail /getbyid /options /profile /query
if (isDeleteLikeRequest(requestUrl)) return "DELETE"; // /remove /delete /deletebatch
if (isAddLikeRequest(requestUrl)) return "ADD"; // /add /create /import
return "UPDATE";
}
这是"约定优于配置"------只要你 URL 命名规范,不加注解也知道是增删改查里的哪一种。比要求每个开发都记得加注解靠谱得多。
QUERY 全部不记。
java
if ("QUERY".equals(operationType)) {
return true; // 跳过
}
查询量是写操作的几十上百倍,全记下来数据库撑不住。这个取舍是对的。
审计快照在提交异步前就拷贝好了。
OperationAuditContext 用的是普通 ThreadLocal(不是 TTL):
java
private static final ThreadLocal<Snapshot> HOLDER = ThreadLocal.withInitial(Snapshot::new);
如果切面在异步线程里读它,必然读到空。但代码是这么处理的------在主线程就把快照值拷进 POJO:
java
fillAuditSnapshot(logInfo, requestParams, responseResult); // 主线程:ThreadLocal → POJO
...
} finally {
saveLogAsync(logInfo); // 之后才提交异步
OperationAuditContext.clear();
}
异步线程拿到的 logInfo 已经是个纯 POJO,跟 ThreadLocal 没关系了。用"快照对象传递"替代"上下文传递",规避了跨线程丢失。这个手法比换 TTL 更轻量,值得记下来。
七、可带走的 6 条诀窍
-
切点越宽,越要把便宜的判断放前面。 拦截所有 Controller 很方便,但"要不要记"的判断必须在构造对象、查配置之前完成。
-
旁路组件的线程池不能太小,也不能用 CallerRunsPolicy。 队列满了让业务线程自己写日志,等于把旁路变成主链路。日志可以丢,接口不能卡。
-
链路共用线程池要考虑峰值叠加。 登录日志的峰值(早上打卡)和操作日志的峰值(下午业务高峰)不是同一时刻,共用时至少要按叠加后的量估。
-
"成功"事件监听不到"失败"。 Sa-Token 的
doLogin只在成功时回调,Spring 的很多事件监听器同理。想要失败审计,得在失败分支里手动补。 -
traceId 要进数据库,不能只进 MDC。 只进 MDC 意味着日志表和文件日志是两个世界,排查时只能靠时间范围猜。
-
开关要覆盖所有分支。 6 个事件方法里 4 个漏了开关检查,这种不一致不会报错,但会在某一天让人困惑半小时。
八、那个没说完的事
写这篇的时候,我顺着"登录失败"这条线往下追,发现了另一个更麻烦的问题。
UsernamePasswordAuthStrategy 里是这么写的:
java
@Override
protected LoginUser doAuthenticate(LoginRequest request) {
String rawPassword = loginPasswordDecoder.decode(request.getPassword());
LoginUser loginUser = userLoadService.authenticateByUsernamePassword(
request.getUsername(), rawPassword, request.getTenantId());
checkAccountLocked(loginUser);
if (loginUser == null) {
recordLoginFailure(null, "用户名或密码错误");
}
return loginUser;
}
看起来没问题对吧?但去翻 authenticateByUsernamePassword 的实现,密码错误时它是抛异常的:
java
List<SysUser> matched = candidates.stream()
.filter(user -> matchPassword(rawPassword, user.getPassword()))
.toList();
if (matched.isEmpty()) {
throw new RuntimeException("用户名或密码错误"); // ← 抛异常,不返回 null
}
也就是说,if (loginUser == null) 这个分支永远进不去 。而 recordLoginFailure 里第一行就是:
java
if (!isLoginLockEnabled() || loginUser == null) {
throw new RuntimeException(errorMessage); // ← 就算进去了,传 null 也会直接抛出,不计数
}
两道保险叠加的结果:密码错误时,失败次数不会被记录,账号永远不会被锁定。
暴力破解防护形同虚设。
这个问题我已经反馈给作者了,修复思路是让认证方法在失败时返回可区分的结果(比如 Optional.empty() 或者带错误码的包装对象),而不是抛异常------失败路径用异常来表达,就等于把"计数""锁定""审计"这些后续动作全给跳过了。
完整的登录链路拆解我下一篇单独写,包括认证策略链、账号锁定的正确实现、多租户下的用户匹配。
这篇要是帮你看清了日志组件的坑,点个赞吧 🙏
我赌你们项目的登录日志表里也是 0 条失败记录------评论区可以现在去查一下,SELECT COUNT(*) FROM sys_login_log WHERE login_status = 0,看看是不是 0。
项目地址
- Gitee:gitee.com/ForgeLab/fo...
- GitHub:github.com/yaomindong1...
系列回顾
- 数据权限拦截器:681 行是怎么改写 SQL 的
- 多租户 tenant Starter:数据源级 + 行级双隔离
- 多租户 × 数据权限共存:拦截器注册顺序的 4 个坑
- 幂等 Starter:1279 行里的 5 个隐蔽坑
- 操作日志 Starter:1383 行里的 5 个反直觉设计(本文)
标签 :#Java #Spring Boot #日志 #Sa-Token #源码拆解 #架构设计 #性能优化