臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速Obsidian、双向链接与知识图谱
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
中华财险-公司项目
对外接口文档
基础数据码表对外接口(仅支持rpc调用)业务码表对外接口应用主数据对外接口
基础运营查询版本更新日志详情消息中心对外接口站内信对外接口站内信模板配置手册
权限中心权限中心对外接口文档(最新)权限中心对外接口文档前端ACL-CORE FACADE依赖版本
审计中心审计中心对外接口文档audit-center-facade版本
审批中心工作流迁审批流现状工作流迁审批流API能力替换方案老审批流接口文档审批中心接口文档审批中心业务回调FAQ
账号中心内部系统对接单点登录内部系统对接认证中心三方网页应用登录授权账号中心对外接口文档前端账号中心RPC接口文档账户变更对外广播消息文档账户中心对外接口HRMS外部员工变更广播消息文档HRMS外部员工对外接口
组织员工岗位🔥机构映射SDK接口文档员工岗位变更通知说明组织机构管营一体概念与用法组织员工对外广播消息文档组织员工岗位对外接口文档组织员工岗位对外接口文档前端组织员工岗位数据模型组织员工主数据业务场景案例organization-facade版本
hrms(大型人力资源外包管理系统)
项目架构说明HRMS Maven模块与依赖说明
项目说明0.流程中心模块0.组织模块说明1.业务线模块2.计划模块3.项目模块4.协议模块5.供应商模块6.合约域模块7.外包人员模块*核心:外包人员生命周期
项目运维外包人员项目编制差异排查与修复2.HRMS相关问题排查4.修改externalId(externalId和accountId不一致)
需求-系分
1.内部转外包0723外包人员关联历史内部账号系分新增外包人员关联历史账号内容(紧急0723上线)需求
2.工作岗位0820工作岗位0820需求工作岗位0820需求-系分
3.用工模式调整0917内部人员转外包用工系统需求2内部人员转外包用工系统需求-系分
sso(账号中心-单点登录)
项目架构说明aboss-sso项目架构入门AOP统一日志打印链路Maven多模块项目高级知识OAuth2.0
项目说明1.SSO-OAuth2.0与IDaaS登录流程2.外部账号创建流水号并发问题分析
AI
使用说明
第三方插件&技能简介Archify使用与安装指南Ponytail使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.完成可运行聊天接口
GitGit常用命令与Obsidian推送排查
Java
面试题Java基础与集合面试题
AtomicInteger原子计数与并发安全ConcurrentHashMap并发安全与计数Java线程、线程池与Future
python
基础Python基础语法
HTTPXHRMS员工详情接口调用(Python HTTPX)

AOP 统一日志打印链路

1. 背景

aboss-sso 项目使用 Spring AOP 统一记录部分业务方法的请求参数、返回参数和执行耗时。

需要先区分两种日志来源。业务代码如果显式执行以下日志:

log.info("personNo: {}", userInfoDTO.getPersonNo());

那么日志框架会把第二个参数的值替换第一个参数中的 {},例如:

personNo: 123456

而当前项目的 LogAopConfig 并没有单独调用这行 personNo 日志;它在业务方法返回后统一执行:

log.info("请求类 : {}.{} 请求参数 : {} 返回参数 : {} 耗时 : {} ms", ...);

如果统一日志中仍然能看到 personNo,原因是 AOP 将整个返回对象序列化成 JSON;UserInfoDTO 位于返回对象的 data 字段中,所以其 personNo 会被递归序列化并打印。

2. 核心代码位置

作用文件
应用启动与组件扫描start/src/main/java/com/aliyun/fsi/insurance/sso/Application.java
统一日志切面aboss-sso-infrastructure/src/main/java/com/aliyun/fsi/insurance/sso/config/LogAopConfig.java
用户信息 HTTP 入口aboss-sso-adapter/src/main/java/com/aliyun/fsi/insurance/sso/web/LoginController.java
用户信息 Facadeaboss-sso-app/src/main/java/com/aliyun/fsi/insurance/sso/impl/LoginFacadeImpl.java
JWT 解析和 DTO 组装aboss-sso-app/src/main/java/com/aliyun/fsi/insurance/sso/executor/account/query/UserInfoQryExe.java
用户信息 DTOaboss-sso-client/src/main/java/com/aliyun/fsi/insurance/sso/dto/data/UserInfoDTO.java
日志输出配置start/src/main/resources/log4j2.xml

3. AOP 为什么能够生效

3.1 Spring 扫描切面

项目启动类配置了扫描根包:

@SpringBootApplication(scanBasePackages = {"com.aliyun.fsi.insurance.sso"})
public class Application {
}

LogAopConfig 位于该根包之下,并使用了以下注解:

@Slf4j
@Aspect
@Component
public class LogAopConfig {
}
  • @Component:将类注册为 Spring Bean。
  • @Aspect:声明该类是一个 AOP 切面。
  • @Slf4j:由 Lombok 生成日志对象 log

Spring 启动时发现这个切面,并为符合切点条件的 Spring Bean 创建代理对象。其他 Bean 注入目标服务时,拿到的是代理对象,而不是未经包装的原始对象。

3.2 切点范围

统一日志切点如下:

@Pointcut("execution(public * com.aliyun.fsi.insurance.sso.impl.*Impl.*(..))||" +
        "execution(public * com.aliyun.fsi.insurance.sso.external.*Impl.*(..))")
public void aop() {
}

它会匹配:

com.aliyun.fsi.insurance.sso.impl 包下,*Impl 类中的 public 方法
com.aliyun.fsi.insurance.sso.external 包下,*Impl 类中的 public 方法

表达式含义:

execution(public * 包名.*Impl.*(..))
          │      │     │    └─ 任意数量、任意类型的参数
          │      │     └────── 任意方法名
          │      └──────────── 类名以 Impl 结尾
          └─────────────────── 任意返回类型的 public 方法

因此它并非拦截项目中的所有方法。Controller、Executor 等不属于上述包和类名规则的方法,不会被这个切面统一打印。

4. 环绕通知的执行流程

切面使用 @Around 环绕通知:

@Around("aop()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
    long startTime = System.currentTimeMillis();
    MethodSignature methodSignature =
            (MethodSignature) joinPoint.getSignature();
 
    Object result = joinPoint.proceed();
 
    log.info("请求类 : {}.{} 请求参数 : {} 返回参数 : {} 耗时 : {} ms",
            methodSignature.getDeclaringTypeName(),
            methodSignature.getName(),
            JSON.toJSONString(joinPoint.getArgs()),
            JSON.toJSONString(result),
            System.currentTimeMillis() - startTime);
 
    return result;
}

执行顺序为:

调用 AOP 代理对象

进入 doAround()

记录开始时间和方法信息

joinPoint.proceed() 执行真正的业务方法

获取业务方法返回值 result

将入参和返回值序列化成 JSON

打印类名、方法名、入参、返回值和耗时

把 result 返回给原调用方

joinPoint.proceed() 是连接切面和实际业务方法的关键点。它之前的代码在业务方法调用前执行,它之后的代码在业务方法正常返回后执行。

5. /user-info 完整调用链

5.1 接口入口

请求地址:

POST /platform/api/aboss/sso/user-info

Controller 接收请求:

@PostMapping("/user-info")
public ResultModel<UserInfoDTO> userInfo(@RequestBody UserInfoQry userInfoQry) {
    return loginFacade.userInfo(userInfoQry);
}

loginFacade 是由 Spring 注入的对象。由于 LoginFacadeImpl 符合日志切点规则,注入的对象会经过 AOP 代理。

5.2 AOP 拦截 Facade 调用

目标方法为:

com.aliyun.fsi.insurance.sso.impl.LoginFacadeImpl.userInfo(...)

该方法满足以下条件:

  • 位于 com.aliyun.fsi.insurance.sso.impl 包下;
  • 类名 LoginFacadeImplImpl 结尾;
  • userInfopublic 方法。

所以 Controller 调用 loginFacade.userInfo() 时,会先进入 LogAopConfig.doAround()

5.3 Facade 组织返回结果

业务方法调用查询执行器,并将结果放入统一返回模型:

public ResultModel<UserInfoDTO> userInfo(UserInfoQry userInfoQry) {
    ResultModelSupport<UserInfoDTO> resultModel =
            new PageResultModelSupport<>();
 
    abossBaseOperateTemplate.operate(new AbossBaseOperateCallback() {
        @Override
        public void execute() {
            UserInfoDTO singleResponse = userInfoQryExe.execute(userInfoQry);
            resultModel.setData(singleResponse);
        }
    }, resultModel);
 
    return resultModel;
}

此处的数据关系为:

ResultModel
└── data
    └── UserInfoDTO
        ├── accType
        ├── accId
        ├── username
        ├── personNo
        └── personName

5.4 解析 JWT 并设置 personNo

UserInfoQryExe 先解析请求中的 Token:

Map<String, Object> map = jwtUtils.parserJWT(userInfoQry.getToken());

然后组装 UserInfoDTO

return new UserInfoDTO()
        .setAccType((String) map.get(JwtTokenConstant.ACC_TYPE))
        .setAccId((String) map.get(JwtTokenConstant.ACC_ID))
        .setUsername((String) map.get(JwtTokenConstant.ACC_NAME))
        .setPersonNo((String) map.get(JwtTokenConstant.ACC_ID))
        .setPersonName((String) map.get(JwtTokenConstant.PERSON_NAME));

当前代码中 personNo 取自 JWT 的 ACC_ID

.setPersonNo((String) map.get(JwtTokenConstant.ACC_ID))

因此在当前实现下,正常情况下 personNoaccId 相同。如果业务要求员工编号来自 JWT 中独立的 personNo 字段,应进一步确认这里是否应改用:

map.get(JwtTokenConstant.PERSON_NO)

5.5 返回结果被统一打印

LoginFacadeImpl.userInfo() 返回后,控制权回到环绕通知:

Object result = joinPoint.proceed();

此时 result 中已经包含 UserInfoDTO。切面执行:

JSON.toJSONString(result)

Fastjson 会递归序列化整个对象,所以最终日志类似:

请求类 : com.aliyun.fsi.insurance.sso.impl.LoginFacadeImpl.userInfo
请求参数 : [{"token":"..."}]
返回参数 : {"data":{"accId":"123456","personNo":"123456","username":"zhangsan"}}
耗时 : 15 ms

这就是没有直接打印 userInfoDTO.getPersonNo(),日志中仍然能看到 personNo 的原因。

6. 日志落盘位置

log4j2.xml 中的日志根目录为:

<property name="APP_NAME" value="aboss-sso"/>
<property name="LOG_HOME" value="./logs/${APP_NAME}"/>

普通业务日志写入:

<RollingFile name="COMMON-APPENDER"
             fileName="${LOG_HOME}/common-default.log">

因此默认日志文件是:

./logs/aboss-sso/common-default.log

可以按方法搜索:

rg 'LoginFacadeImpl.userInfo' ./logs/aboss-sso/common-default.log

也可以直接搜索字段:

rg '"personNo"' ./logs/aboss-sso/common-default.log

7. 为什么有时看不到日志或 personNo

7.1 方法不符合切点规则

只有指定包下、类名以 Impl 结尾的 public 方法才会被当前切面拦截。Controller 或 Executor 方法不会因为这个切面而自动打印。

7.2 同一个类内部自调用

如果一个类通过 this.xxx() 调用自身另一个方法,调用通常没有经过 Spring 代理,因此可能不会触发该方法对应的 AOP 通知。

7.3 对象不是 Spring Bean

通过 new XxxImpl() 手动创建的对象不受 Spring 管理,也不会获得 AOP 代理。

7.4 目标方法抛出未处理异常

当前日志代码位于:

Object result = joinPoint.proceed();
log.info(...);

如果 joinPoint.proceed() 抛出异常并且异常继续向外传播,后面的 log.info() 不会执行。因此当前切面主要记录正常完成的方法,不保证记录异常调用。

如果希望无论成功失败都记录耗时,需要使用 try/catch/finally 重新组织切面逻辑。

7.5 日志级别过滤

统一日志使用的是 log.info()。如果部署环境将包日志级别调到 WARNERROR,INFO 日志将不会输出。

7.6 运行版本未更新

如果本地源码存在 personNo,但线上运行的旧包中还没有该字段或赋值逻辑,线上日志也不会出现预期字段。需要确认构建版本和部署版本。

7.7 字段为空时的序列化行为

Fastjson 的具体配置会影响空字段是否输出。如果 personNo 没有成功赋值且序列化配置忽略空值,日志中可能完全看不到该字段,而不是显示 "personNo": null

8. 当前实现的注意事项

8.1 Token 泄露风险

切面会序列化所有入参:

JSON.toJSONString(joinPoint.getArgs())

UserInfoQry 中包含 Token,因此日志可能完整记录 Token。Token、密码、手机号、身份证号等敏感信息建议在统一日志层脱敏或排除。

8.2 序列化风险

统一序列化任意方法的入参和出参可能带来:

  • 大对象导致日志量过大;
  • 循环引用或复杂对象导致序列化异常;
  • 文件、流、Servlet 请求等对象不适合直接序列化;
  • 序列化开销被计入接口调用后的处理时间;
  • 返回数据中敏感字段被写入日志。

8.3 “全局日志”的准确理解

这里的“全局打印”是指:

对切点覆盖范围内的方法统一执行日志逻辑。

它不是对整个应用的每个方法、每个 HTTP 请求进行无差别打印。覆盖范围完全由 @Pointcut 表达式决定。

9. 链路总结

客户端调用 /platform/api/aboss/sso/user-info

LoginController.userInfo() 接收 UserInfoQry

调用 Spring 注入的 LoginFacade AOP 代理对象

LogAopConfig.doAround() 记录开始时间

joinPoint.proceed()

LoginFacadeImpl.userInfo()

UserInfoQryExe.execute()

解析 JWT,创建 UserInfoDTO,设置 personNo

UserInfoDTO 放入 ResultModel.data

ResultModel 返回到 LogAopConfig

JSON.toJSONString(result) 递归序列化返回值

personNo 随 data 一起进入返回参数日志

日志写入 ./logs/aboss-sso/common-default.log

ResultModel 返回 Controller 和客户端

10. AOP统一日志打印链路图

交互图已按当前项目的 LogAopConfigLoginFacadeImplUserInfoQryExeUserInfoDTO 关系生成,图中同时标出了显式 log.info("personNo: {}", userInfoDTO.getPersonNo()) 的占位符替换过程,以及 AOP 统一日志的 JSON 序列化过程:

打开 AOP统一日志打印链路交互图

图中关键顺序如下:

业务调用方

Spring AOP 代理

LogAopConfig.doAround()

joinPoint.proceed() 执行业务方法

业务代码显式 log.info(...)(如果有)

返回 result

JSON.toJSONString(joinPoint.getArgs()) / JSON.toJSONString(result)

统一 log.info(...) 写出类名、方法名、入参、返回值和耗时

11. 一句话结论

personNo 不是被单独打印的,而是在 UserInfoQryExe 中设置到 UserInfoDTO 后,随 ResultModel 返回给 LoginFacadeImpl 的 AOP 代理,再由 LogAopConfig 将整个返回对象序列化为 JSON 并统一写入日志。