在金融业务系统开发中,很多时候我们不仅要知道一条数据当前是什么状态,还需要知道:是谁修改的、什么时候修改的、修改前后有什么区别,以及操作是否成功。
本文以结构性存款产品管理、认购订单审核、用户权限调整为例,介绍如何使用 Spring Boot + AOP + 自定义注解实现统一操作日志,并逐步完善日志详情、事务处理和敏感数据保护。
一、为什么需要统一操作日志?
在实际金融业务系统中,我们可能经常遇到以下需求。
场景一:结构性存款产品收益率调整
某结构性存款产品原预期年化收益率为:
调整前:2.50% 调整后:2.80%业务希望查询到:
操作模块:结构性存款产品管理 产品编号:PRD20260001 操作人:张三 操作类型:调整产品预期收益率 操作详情:预期年化收益率从 2.50% 调整为 2.80% 操作结果:成功场景二:认购订单审核
一笔结构性存款认购订单原状态为待审核,经过审核后变为审核通过。
订单编号:ORD20260001 操作人:李四 操作类型:认购订单审核 修改前:待审核 修改后:审核通过场景三:用户权限调整
用户编号:10086 操作人:管理员 操作类型:调整用户角色 修改前:普通操作员 修改后:产品审核员这些需求有一个共同点:
记录的不是普通程序运行日志,而是业务操作审计日志。
普通应用日志关注程序执行情况,而业务操作日志更关注业务数据的变化过程。
二、最初方案:直接在 Service 中记录日志
一开始,我们可能采用最简单的实现方式:在业务代码中调用日志服务。
例如结构性存款产品收益率调整:
@Transactional public void updateExpectedRate( Long productId, BigDecimal newRate, Long operatorId) { DepositProduct product = productRepository .findById(productId) .orElseThrow(); BigDecimal oldRate = product.getExpectedRate(); product.setExpectedRate(newRate); operationLogService.save( operatorId, "DEPOSIT_PRODUCT", productId, "预期收益率从 " + oldRate + " 调整为 " + newRate ); }这种写法能够满足基础需求,但存在几个明显问题。
业务代码与日志记录代码耦合。
每个业务方法都需要重复编写日志逻辑。
不同开发人员记录的日志格式可能不统一。
新增业务功能时,容易忘记记录操作日志。
当前日志如果与业务使用同一事务,事务回滚时日志也会回滚。
随着系统业务增加,我们会发现大量代码都在重复执行:
修改产品 → 记录日志 审核订单 → 记录日志 调整权限 → 记录日志 撤销订单 → 记录日志 修改产品状态 → 记录日志因此,我们需要将公共日志功能从业务代码中抽离。
三、第一版:自定义注解 + AOP 实现统一日志
核心思路:
业务方法只声明需要记录什么操作,真正的日志记录交给 AOP 切面统一完成。
3.1 创建自定义操作日志注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface OperationLog { // 业务模块 String module(); // 操作名称 String action(); // 业务主键表达式 String bizId() default ""; // 业务详情表达式 String detail() default ""; }这里定义四个属性:
| 属性 | 作用 | 示例 |
|---|---|---|
| module | 业务模块 | DEPOSIT_PRODUCT |
| action | 操作名称 | 调整预期收益率 |
| bizId | 业务对象ID | #p0 |
| detail | 操作详情 | 使用 SpEL 表达式 |
这里使用#p0表示第一个方法参数,可以避免 Java 方法参数名称未保留时导致的表达式解析问题。
3.2 在结构性存款产品业务方法上添加注解
@OperationLog( module = "DEPOSIT_PRODUCT", action = "调整产品预期收益率", bizId = "#p0" ) @Transactional public void updateExpectedRate( Long productId, BigDecimal newRate) { DepositProduct product = productRepository .findById(productId) .orElseThrow(); product.setExpectedRate(newRate); }可以看到,业务方法中已经不需要再手动编写:
operationLogService.save(...);以后新增需要审计的业务接口,只需要配置对应注解即可。
3.3 使用 AOP 统一拦截
先实现基础版本:
@Aspect @Component @RequiredArgsConstructor public class OperationLogAspect { private final OperationLogService logService; @Around("@annotation(operationLog)") public Object around( ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long start = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; logService.saveSuccess( operationLog.module(), operationLog.action(), cost ); return result; } catch (Throwable e) { long cost = System.currentTimeMillis() - start; logService.saveFailure( operationLog.module(), operationLog.action(), e.getMessage(), cost ); throw e; } } }这样便可以统一记录:
业务模块:DEPOSIT_PRODUCT 操作类型:调整产品预期收益率 操作结果:SUCCESS 执行耗时:83ms注意:这只是介绍 AOP 基本机制的简化代码。它还没有实现业务 ID 解析、详情解析及完整的事务处理,后面会继续完善。
四、第二版:不仅记录操作,还要记录数据变化
第一版能够回答:
谁执行了收益率调整操作?
但无法回答:
调整前是多少?调整后又是多少?
例如只记录:
操作类型:调整产品预期收益率 结果:成功对业务排查来说,信息仍然不够。
我们真正希望得到:
产品编号:PRD20260001 调整前收益率:2.50% 调整后收益率:2.80%4.1 为什么不建议直接对所有实体进行自动 Diff?
一种常见思路是:
方法执行前查询数据库,获取旧对象。
方法执行后再次查询数据库,获取新对象。
比较两个对象的属性差异。
这种方案看起来很通用,但实际上存在问题。
例如:
DepositProduct before = productRepository.findById(productId) .orElseThrow(); updateExpectedRate(productId, newRate); DepositProduct after = productRepository.findById(productId) .orElseThrow();这里并不能保证before和after是两份相互独立的快照。
如果使用 JPA,在同一个持久化上下文中,两次查询可能得到同一个受管理实体。
而且,结构性存款产品往往包含大量业务属性,例如:
产品编号 产品名称 产品类型 币种 预期收益率 起息日 到期日 销售状态 版本号 最后修改时间自动比较全部字段,可能会产生大量无意义的日志。
例如:
updateTime:10:21 → 10:22 version:3 → 4 modifiedBy:1001 → 1002这些变化可能不是业务真正想关注的内容。
因此,对于金融业务系统,更推荐:
公共框架负责日志生命周期,具体业务负责描述有意义的数据变化。
4.2 使用业务返回对象保存修改前后的值
例如定义一个收益率调整结果对象:
public record RateChangeResult( Long productId, BigDecimal oldRate, BigDecimal newRate ) { }然后修改业务方法:
@OperationLog( module = "DEPOSIT_PRODUCT", action = "调整产品预期收益率", bizId = "#result.productId()", detail = "'预期收益率从 ' + " + "#result.oldRate()" + " + '% 调整为 ' + " + "#result.newRate() + '%'" ) @Transactional public RateChangeResult updateExpectedRate( Long productId, BigDecimal newRate) { DepositProduct product = productRepository .findById(productId) .orElseThrow(); BigDecimal oldRate = product.getExpectedRate(); product.setExpectedRate(newRate); return new RateChangeResult( productId, oldRate, newRate ); }这里假设收益率字段保存的是百分数数值,例如2.50代表2.50%,具体系统应统一收益率的数据存储口径。
最终日志详情:
预期收益率从 2.50% 调整为 2.80%这样的好处是:
不需要为了获取旧值而进行额外数据库查询。
业务方法明确知道哪些数据需要记录。
AOP 无须理解复杂的金融业务规则。
日志内容更加直观。
这里还有一个细节:如果需要把2.5固定显示为2.50%,建议在业务层提前完成格式化,不要把复杂的金融数值格式化逻辑放在注解表达式中。
五、第三版:使用 SpEL 动态生成日志详情
Spring 提供了 SpEL(Spring Expression Language,Spring 表达式语言),可以在运行时解析变量和表达式。
例如注解:
@OperationLog( module = "DEPOSIT_PRODUCT", action = "调整产品预期收益率", bizId = "#p0", detail = "'新收益率:' + #p1 + '%'" )这里:
#p0:第一个方法参数 productId #p1:第二个方法参数 newRate #result:业务方法返回值注意,#result不是 Spring AOP 自动提供的变量,需要我们在切面中显式放入表达式上下文。
5.1 创建 SpEL 表达式解析工具
@Component public class LogExpressionParser { private final ExpressionParser parser = new SpelExpressionParser(); private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer(); public String parse( String expression, Method method, Object[] args, Object result) { if (expression == null || expression.isBlank()) { return ""; } EvaluationContext context = new MethodBasedEvaluationContext( null, method, args, nameDiscoverer ); context.setVariable("result", result); Object value = parser.parseExpression(expression) .getValue(context); return value == null ? "" : value.toString(); } }需要导入:
import java.lang.reflect.Method; import org.springframework.context.expression.MethodBasedEvaluationContext; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.expression.EvaluationContext; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser;通过这个工具,就可以从业务方法参数和返回值中获取信息。
例如:
@OperationLog( module = "ORDER", action = "驳回认购订单", bizId = "#p0", detail = "'订单驳回原因:' + #p1" )运行时将实际参数放入上下文,便可以解析对应内容。
生产系统应进一步限制表达式的可执行范围、做好空值处理,并控制哪些数据允许进入审计日志,避免表达式意外访问敏感对象。
六、金融业务场景下的三个应用实例
6.1 结构性存款产品管理
适合记录:
新增结构性存款产品 修改产品基本信息 调整产品预期收益率 修改产品销售状态 发布产品 停售产品例如:
@OperationLog( module = "DEPOSIT_PRODUCT", action = "修改产品销售状态", bizId = "#p0", detail = "'产品销售状态调整为:' + #p1" ) @Transactional public void updateSaleStatus( Long productId, String status) { // 校验状态变更权限及业务规则 // 更新产品销售状态 }在真实系统中,这里还需要判断产品是否允许从当前状态变更为目标状态,以及是否需要审批。
6.2 认购订单审核
例如:
@OperationLog( module = "ORDER", action = "审核认购订单", bizId = "#p0", detail = "'认购订单审核通过'" ) @Transactional public void approveOrder(Long orderId) { // 查询认购订单 // 校验审核权限 // 检查订单状态 // 更新审核状态 }记录结果:
模块:ORDER 订单编号:ORD20260001 操作人:张三 操作类型:审核认购订单 操作详情:认购订单审核通过 结果:SUCCESS如果是订单驳回:
@OperationLog( module = "ORDER", action = "驳回认购订单", bizId = "#p0", detail = "'驳回原因:' + #p1" ) @Transactional public void rejectOrder( Long orderId, String reason) { // 校验审核权限及订单状态 // 更新审核状态 // 保存驳回原因 }需要注意:驳回原因可能包含客户个人信息,真实系统应限制日志内容并进行必要脱敏。
6.3 用户权限调整
@OperationLog( module = "USER", action = "调整用户角色", bizId = "#p0", detail = "'用户角色调整为:' + #p1" ) @Transactional public void changeUserRole( Long userId, String role) { // 查询用户 // 校验当前操作人的授权范围 // 修改用户角色 }可以记录:
模块:USER 用户编号:10086 操作人:管理员 操作类型:调整用户角色 操作详情:用户角色调整为产品审核员 结果:SUCCESS如果需要精确记录角色变化,则同样可以通过返回对象保存:
旧角色:普通操作员 新角色:产品审核员这比只记录一句“用户权限修改成功”更适合后续问题追溯。
七、操作日志表应该如何设计?
建议至少记录以下字段。
以下为 MySQL 风格的示例 DDL:
CREATE TABLE operation_log ( id BIGINT PRIMARY KEY, module VARCHAR(50) NOT NULL, action VARCHAR(100) NOT NULL, biz_id VARCHAR(100), operator_id BIGINT, operator_name VARCHAR(100), request_uri VARCHAR(500), request_method VARCHAR(20), client_ip VARCHAR(64), detail TEXT, success TINYINT NOT NULL, error_message VARCHAR(1000), cost_ms BIGINT, create_time DATETIME NOT NULL );这里的关键字段分别代表:
| 字段 | 含义 |
|---|---|
| module | 业务模块 |
| action | 操作类型 |
| biz_id | 产品、订单或用户编号 |
| operator_id | 操作人 ID |
| operator_name | 操作人名称 |
| request_uri | 请求路径 |
| client_ip | 客户端 IP |
| detail | 业务修改详情 |
| success | 执行结果 |
| error_message | 失败原因 |
| cost_ms | 执行耗时 |
| create_time | 操作时间 |
这样以后出现问题时,就可以根据产品编号或订单编号检索操作历史。
例如:
SELECT * FROM operation_log WHERE module = 'DEPOSIT_PRODUCT' AND biz_id = '10086' ORDER BY create_time DESC;如果使用 Oracle,需要根据数据库版本调整主键生成策略、字段类型及时间类型。
另外,生产环境建议增加请求追踪编号,并为常用查询条件建立适当索引。
八、事务处理:为什么日志不能简单跟随业务事务?
这是实现操作日志时很重要的一点。
假设用户审核一笔认购订单:
@Transactional public void approveOrder(Long orderId) { // 查询订单 // 检查订单状态 // 执行审核 }如果订单已经撤销,业务方法可能抛出异常:
throw new IllegalStateException("订单已撤销");业务事务会回滚。
但是从审计角度,我们仍然希望保留:
操作人:张三 操作类型:审核认购订单 操作结果:失败 失败原因:订单已撤销如果日志保存也在同一个事务中,那么业务回滚时,日志可能一起被回滚。
8.1 使用独立事务保存日志
可以使用:
@Service @RequiredArgsConstructor public class OperationLogService { private final OperationLogRepository repository; @Transactional( propagation = Propagation.REQUIRES_NEW ) public void save(OperationLogEntity log) { repository.save(log); } }REQUIRES_NEW(新建独立事务)表示日志保存可以使用一个独立事务。
即使业务事务回滚,日志事务也可以单独提交。
需要注意两个条件:
日志保存方法必须通过 Spring 代理调用,不能依赖同类内部自调用触发事务。
事务切面和操作日志切面的执行顺序必须明确,不能仅凭业务方法返回就认定事务已经成功提交。
8.2 日志切面和事务切面的执行顺序
假设我们使用 Spring AOP 对声明式事务进行拦截。
希望整体执行顺序为:
操作日志切面 ↓ 事务拦截器 ↓ 开启业务事务 ↓ 执行 Service 业务方法 ↓ 提交或回滚业务事务 ↓ 操作日志切面获取业务结果 ↓ 独立事务保存操作日志可以为日志切面指定执行顺序,例如:
@Aspect @Component @Order(Ordered.LOWEST_PRECEDENCE - 1) public class OperationLogAspect { // 日志切面逻辑 }在事务拦截器保持 Spring 默认顺序的前提下,这样可以让日志切面包裹事务拦截器。
但如果项目已经自定义事务通知顺序,就需要根据实际配置进行调整。
此外,如果事务并不由当前 Service 方法的事务拦截器完整管理,例如参与外部事务,那么正常返回仍不等于最终事务提交成功。
对于要求严格审计的金融业务,建议结合事务完成状态、可靠事件机制或专门的审计设计,而不是完全依赖简单 AOP 日志。
九、日志到底应该同步保存,还是通过 MQ 异步保存?
这需要根据业务场景决定,而不是一开始就引入复杂架构。
方案一:同步写数据库
流程:
业务操作完成 ↓ 生成操作日志 ↓ 独立事务写入数据库 ↓ 返回结果优势:
实现简单。
排查方便。
不需要额外中间件。
日志量较低时容易维护。
不足:
每次操作增加一次数据库写入。
日志数据库异常可能影响请求响应时间。
需要明确日志写入失败时如何处理。
适用于普通后台管理操作及对吞吐量要求不高的场景。
方案二:通过 MQ 异步写入
流程:
业务操作完成 ↓ 生成日志消息 ↓ 发送到 MQ ↓ 日志消费者 ↓ 写入操作日志表优势:
减少业务线程直接写日志数据库的开销。
可以独立扩展日志消费能力。
有利于集中管理多个业务服务的日志。
不足:
系统复杂度增加。
需要处理消息丢失、重复消费、积压和重试。
不能仅凭普通异步发送就保证业务和审计记录一致。
对于金融关键审计场景,还需要考虑可靠消息、事务外盒、幂等消费等机制。
因此,不建议仅仅因为项目用了 MQ,就认为操作日志一定要走 MQ。
应根据日志量、审计要求以及可接受的故障风险来选择。
十、金融系统操作日志必须考虑敏感信息保护
在金融业务系统中,这一点尤其重要。
以下信息不应该被直接、无控制地写入普通操作日志:
登录密码 身份证号码 完整银行卡号 客户完整手机号 Access Token Authorization 请求头 Cookie 其他认证凭证例如下面这种写法:
objectMapper.writeValueAsString( joinPoint.getArgs() );会把方法参数直接序列化。
如果业务方法包含身份证、银行卡等信息,就可能导致敏感数据进入日志数据库。
推荐方案
采用白名单原则:
默认不记录完整请求参数,只记录业务明确声明允许写入的字段。
例如:
@OperationLog( module = "USER", action = "调整用户角色", bizId = "#p0", detail = "'用户角色调整为:' + #p1" )这里明确只记录用户 ID 和角色名称。
对于需要序列化业务对象的场景,还可以提供:
@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT }) @Retention(RetentionPolicy.RUNTIME) public @interface LogIgnore { }然后配合自定义序列化逻辑忽略敏感字段。
不过需要注意:@LogIgnore只是自定义标记,它本身不会让 Jackson 自动忽略字段,必须由日志序列化组件主动识别并处理。
在金融系统中,脱敏、日志访问权限和保留周期同样需要纳入统一设计。
十一、最终推荐的操作日志架构
经过上面的演进,可以把整个功能分成四个部分。
Controller | v Service 方法 | @OperationLog 注解 | v OperationLogAspect | +--------+--------+ | | v v 业务方法执行 采集公共信息 | | v | 业务事务完成 | | | +--------+--------+ | v SpEL 解析 | v 生成 OperationLog | v OperationLogService | v 独立事务保存 | v operation_log公共框架负责:
操作人 操作时间 业务模块 业务主键 请求路径 客户端 IP 执行耗时 成功或失败 失败原因业务方法负责:
业务动作是什么 业务对象是谁 哪些字段发生变化 哪些变化值得记录两者职责分离,才能同时保证通用性和日志可读性。
十二、实际项目中还需要注意什么?
上面的设计适合学习 AOP 操作日志的实现思路,但如果要应用到真正的金融业务系统,还需要关注以下问题。
1. 注解可能不生效
Spring AOP 采用代理机制,同一个类中的方法自调用通常不会经过代理,因此可能导致日志切面不执行。
2. 异常被业务代码吞掉
如果业务代码捕获异常后直接返回失败状态,日志切面不一定能通过异常判断业务失败。
此时需要结合统一响应对象或业务返回结果进行判断。
3. 异步业务不能只看接口返回值
对于异步审批、异步文件生成等操作,接口执行成功不代表实际业务处理成功。
需要区分操作受理成功和最终处理成功。
4. 独立事务不等于日志绝对可靠
即使使用独立事务,仍可能出现数据库不可用、网络故障或者进程异常退出等情况。
如果日志属于必须保留的金融审计记录,就需要进一步设计可靠性保障机制。
5. 普通业务日志不能完全替代金融审计系统
金融系统还可能要求双人复核、修改留痕、审计记录防篡改、权限隔离、留存周期等。
这些要求不是一个 AOP 注解就能够全部解决的。
十三、总结
使用 Spring Boot + AOP 实现统一操作日志,核心并不是简单地把operationLogService.save()从业务代码中移动到切面中。
真正重要的是形成一套统一的记录规范。
对于结构性存款、认购订单和用户权限这类业务,我们不仅需要知道一条数据当前的状态,还应该能够回答:
谁执行了这次操作?
操作发生在什么时候?
修改的是哪条业务数据?
修改前是什么?
修改后是什么?
操作是否成功?
失败的原因是什么?
最终建议采用:
自定义注解 + AOP 切面 + SpEL 业务描述 + 独立日志服务
对于普通后台管理日志,这是一套相对容易落地的方案。
对于金融核心审计,则需要在此基础上进一步考虑事务一致性、可靠性、权限控制及日志安全。
操作日志的价值,不只是让开发人员少写重复代码,更是让每一次关键业务变化都有迹可循。