Skip to content

数据变更日志详解

模块路径: wemirr-platform-framework/diff-log-spring-boot-starter 包路径: com.wemirr.framework.log.diff

概述

diff-log-spring-boot-starter 是一个操作审计/数据变更日志模块,提供:

  • 自动记录数据变更(字段级差异对比)
  • 支持 SpEL 表达式动态生成日志内容
  • 操作人、IP、设备等上下文信息自动收集
  • 灵活的日志模板配置

核心原理

工作流程

┌─────────────────────────────────────────────────────────────────┐
│                        请求流程                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. 用户发起请求                                                 │
│     │                                                            │
│     ▼                                                            │
│  2. Controller 接收请求                                          │
│     │                                                            │
│     ▼                                                            │
│  3. Service 方法执行                                             │
│     │  ├─ 查询旧数据: User oldUser = userMapper.selectById(id)   │
│     │  ├─ 设置上下文: DiffLogContext.putDiffItem(oldUser, newUser)│
│     │  └─ 更新数据: userMapper.updateById(newUser)               │
│                                                                  │
│  4. @DiffLog 注解触发 AOP 拦截                                   │
│     │  ├─ 计算字段差异                                           │
│     │  ├─ 解析 SpEL 表达式                                        │
│     │  ├─ 收集上下文信息(用户、IP、设备)                        │
│     │  └─ 保存日志                                               │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

目录结构

diff-log/
├── configuration/
│   └── DiffLogProperties.java           # 配置属性
├── core/
│   ├── annotation/
│   │   ├── DiffLog.java                 # 日志注解
│   │   ├── DiffLogs.java                # 重复注解
│   │   ├── DiffField.java               # 字段注解
│   │   └── EnableDiffLog.java           # 启用日志
│   └── context/
│       └── DiffLogContext.java          # 日志上下文
├── domain/
│   ├── DiffLogInfo.java                 # 日志实体
│   ├── FieldChange.java                 # 字段变更记录
│   └── ChangeAction.java                # 变更动作枚举
├── service/
│   ├── IDiffLogService.java             # 日志服务接口
│   └── IDiffItemsToLogContentService.java # 差异计算服务
└── support/
    └── aop/
        ├── DiffLogInterceptor.java      # AOP 拦截器
        └── DiffLogOperationSource.java  # 操作源

核心注解

1. @DiffLog - 日志注解

文件: DiffLog.java

java
@DiffLog(
    group = "用户管理",              // 业务分组
    tag = "编辑用户",                 // 操作标签
    businessKey = "{{#id}}",        // 业务ID(SpEL)
    success = "更新用户信息 {_DIFF{#_newObj}}"  // 成功日志模板
)
public void modify(Long id, UserUpdateReq req) {
    // 业务代码
}

参数说明:

参数类型必填说明
groupString业务分组,用于日志归类
tagString操作标签,如:编辑用户、删除订单
businessKeyString业务唯一标识,支持 SpEL
successString成功日志模板,支持 SpEL
failString失败日志模板
conditionString记录条件表达式
successConditionString成功判断条件

2. @DiffField - 字段注解

文件: DiffField.java

java
public class User {
    
    @DiffField(name = "用户名")
    private String username;
    
    @DiffField(name = "用户状态", function = "statusName")
    private Integer status;
    
    @DiffField(name = "创建时间", strategy = DiffFieldStrategy.IGNORE)
    private Instant createTime;
}

参数说明:

参数类型默认值说明
nameString字段显示名称
functionString""解析函数名
strategyDiffFieldStrategyALWAYS对比策略

核心类详解

1. DiffLogContext - 日志上下文

文件: DiffLogContext.java

作用: 在业务方法中传递对比对象

java
@Service
public class UserService {
    
    public void updateUser(Long id, UserUpdateReq req) {
        // 1. 查询旧数据
        User oldUser = userMapper.selectById(id);
        
        // 2. 构造新对象
        User newUser = BeanUtil.copyProperties(oldUser, User.class);
        BeanUtil.copyProperties(req, newUser);
        
        // 3. 设置到上下文(关键!)
        DiffLogContext.putDiffItem(oldUser, newUser);
        
        // 4. 执行更新
        userMapper.updateById(newUser);
    }
}

API 说明:

方法说明
putDiffItem(oldVal, newVal)设置新旧对比对象
putVariable(name, value)设置自定义变量
putGlobalVariable(name, value)设置全局变量
getVariable(key)获取变量值
clear()清理方法级变量
clearGlobal()清理全局变量

2. DiffLogInfo - 日志实体

文件: DiffLogInfo.java

java
@Data
@Builder
public class DiffLogInfo {
    private String serviceName;        // 服务名
    private String businessGroup;      // 业务分组
    private String businessTag;        // 操作标签
    private String businessKey;        // 业务ID
    private String description;        // 日志内容
    private Boolean status;            // 日志状态(成功/失败)
    private Long tenantId;             // 租户ID
    private Long createBy;             // 操作人ID
    private String createName;         // 操作人名称
    private Instant createTime;        // 创建时间
    private Map<String, Object> extra; // 请求上下文
    private List<FieldChange> variables; // 字段变更列表
}

3. FieldChange - 字段变更记录

文件: FieldChange.java

java
@Data
@Builder
public class FieldChange {
    private String name;     // 字段名
    private String label;    // 显示名称
    private Object oldVal;   // 旧值
    private Object newVal;   // 新值
}

示例:

json
{
  "name": "username",
  "label": "用户名",
  "oldVal": "old_name",
  "newVal": "new_name"
}

使用示例

基础用法

java
@Service
public class UserService {
    
    @DiffLog(
        group = "用户管理",
        tag = "编辑用户",
        businessKey = "{{#id}}",
        success = "更新用户信息 {_DIFF{#_newObj}}"
    )
    public void updateUser(Long id, UserUpdateReq req) {
        // 1. 查询旧数据
        User oldUser = userMapper.selectById(id);
        
        // 2. 构造新对象
        User newUser = BeanUtil.copyProperties(oldUser, User.class);
        BeanUtil.copyProperties(req, newUser);
        
        // 3. 设置对比对象
        DiffLogContext.putDiffItem(oldUser, newUser);
        
        // 4. 执行更新
        userMapper.updateById(newUser);
    }
}

生成的日志:

json
{
  "businessGroup": "用户管理",
  "businessTag": "编辑用户",
  "businessKey": "1001",
  "description": "更新用户信息 【用户名】从【张三】修改为【李四】;【手机号】从【13800138000】修改为【13900139000】",
  "status": true,
  "createBy": 1,
  "createName": "管理员",
  "createTime": "2026-04-09T10:30:00Z",
  "extra": {
    "ip": "192.168.1.100",
    "uri": "/api/user/update",
    "browser": "Chrome",
    "os": "Windows"
  },
  "variables": [
    {"name": "username", "label": "用户名", "oldVal": "张三", "newVal": "李四"},
    {"name": "phone", "label": "手机号", "oldVal": "13800138000", "newVal": "13900139000"}
  ]
}

SpEL 表达式使用

java
// 1. 使用方法参数
@DiffLog(
    businessKey = "{{#id}}",              // 使用参数 id
    success = "删除用户 {{#id}}"           // 使用参数 id
)
public void deleteUser(Long id) {
    userMapper.deleteById(id);
}

// 2. 使用对象属性
@DiffLog(
    businessKey = "{{#req.userId}}",     // 使用参数的属性
    success = "用户 {{#req.username}} 请求重置密码"
)
public void resetPassword(ResetReq req) {
    // ...
}

// 3. 使用上下文变量
@DiffLog(
    success = "当前操作人: {_USER{#_context.nickname}}"
)
public void someMethod() {
    // _context 是框架内置的认证上下文
}

// 4. 条件记录
@DiffLog(
    condition = "{{#req.type == 'important'}}",  // 只在特定条件下记录
    success = "重要操作: {{#req.content}}"
)
public void doSomething(Req req) {
    // ...
}

内置变量:

变量说明
{{#_newObj}}新对象(DiffLogContext 设置的新值)
{{#_oldObj}}旧对象(DiffLogContext 设置的旧值)
{{#_context}}认证上下文(AuthenticationContext)
{{#_USER{...}}}用户信息函数
{{#_DIFF{...}}}差异计算函数

自定义字段解析

java
// 1. 定义字段注解
public class User {
    
    @DiffField(name = "用户状态", function = "parseStatus")
    private Integer status;
    
    // getter/setter...
}

// 2. 实现解析函数
@Component
public class UserParseFunction implements IParseFunction {
    
    @Override
    public String functionName() {
        return "parseStatus";
    }
    
    @Override
    public String apply(Object value) {
        Integer status = (Integer) value;
        return switch (status) {
            case 1 -> "正常";
            case 0 -> "禁用";
            default -> "未知";
        };
    }
}

// 3. 生成的日志
// 【用户状态】从【正常】修改为【禁用】

批量操作日志

java
@DiffLogs({
    @DiffLog(
        group = "用户管理",
        tag = "批量删除",
        businessKey = "{{#ids}}",
        success = "批量删除用户: {{#ids}}"
    ),
    @DiffLog(
        group = "用户管理",
        tag = "删除详情",
        businessKey = "{{#_eachId}}",
        success = "删除用户 {{#_eachId}}",
        condition = "{{#_eachId != null}}"
    )
})
public void batchDeleteUsers(List<Long> ids) {
    for (Long id : ids) {
        DiffLogContext.putVariable("_eachId", id);
        userMapper.deleteById(id);
        DiffLogContext.clear();
    }
}

配置说明

application.yml

yaml
extend:
  boot:
    log:
      diff:
        # 是否记录日志
        diff-log: true

        # 全局忽略字段(不参与对比)
        ignore-global-fields:
          - deleted
          - createTime
          - createBy
          - createName
          - lastModifyTime
          - lastModifyBy
          - lastModifyName

        # 日志模板配置
        # 添加(从空到有值)
        add-template: "【__fieldName】从【空】修改为【__targetValue】"
        
        # 更新(值变更)
        update-template: "【__fieldName】从【__sourceValue】修改为【__targetValue】"
        
        # 删除(设为空)
        delete-template: "删除了【__fieldName】:【__sourceValue】"
        
        # 字段分隔符
        field-separator: ";"
        
        # 列表项分隔符
        list-item-separator: ","

        # 是否检查注解
        check-annotation: true

        # 美化输出
        pretty-value-printer: true

模板变量:

变量说明
__fieldName字段名称
__sourceValue旧值
__targetValue新值
__addValues列表新增项
__delValues列表删除项

实现日志存储

业务系统需要实现 IDiffLogService 接口:

java
@Service
public class MyDiffLogService implements IDiffLogService {
    
    @Autowired
    private DiffLogMapper diffLogMapper;
    
    @Override
    public void handler(DiffLogInfo logInfo) {
        // 保存到数据库
        DiffLogEntity entity = new DiffLogEntity();
        BeanUtil.copyProperties(logInfo, entity);
        
        // 字段变更列表转为 JSON 存储
        entity.setVariables(JSON.toJSONString(logInfo.getVariables()));
        entity.setExtra(JSON.toJSONString(logInfo.getExtra()));
        
        diffLogMapper.insert(entity);
    }
    
    @Override
    public List<DiffLogInfo> queryLog(DiffLogInfoQueryReq req) {
        // 查询日志
        return diffLogMapper.selectList(req);
    }
}

数据库表设计:

sql
CREATE TABLE `sys_diff_log` (
  `id` BIGINT NOT NULL COMMENT '主键',
  `service_name` VARCHAR(64) COMMENT '服务名',
  `business_group` VARCHAR(64) COMMENT '业务分组',
  `business_tag` VARCHAR(64) COMMENT '操作标签',
  `business_key` VARCHAR(128) COMMENT '业务ID',
  `description` TEXT COMMENT '日志内容',
  `status` TINYINT COMMENT '状态',
  `tenant_id` BIGINT COMMENT '租户ID',
  `create_by` BIGINT COMMENT '操作人ID',
  `create_name` VARCHAR(64) COMMENT '操作人名称',
  `create_time` DATETIME COMMENT '创建时间',
  `extra` TEXT COMMENT '请求上下文(JSON)',
  `variables` TEXT COMMENT '字段变更(JSON)',
  PRIMARY KEY (`id`),
  INDEX `idx_tenant_business` (`tenant_id`, `business_key`, `create_time`)
) COMMENT='操作审计日志';

常见问题 (Q&A)

Q1: DiffLogContext 为什么要放在方法内部?

A: 因为需要查询旧数据后再对比

java
public void updateUser(Long id, UserUpdateReq req) {
    // 必须先查旧数据
    User oldUser = userMapper.selectById(id);
    
    // 再构造新对象
    User newUser = ...;
    
    // 然后设置对比对象
    DiffLogContext.putDiffItem(oldUser, newUser);
    
    // 才能更新
    userMapper.updateById(newUser);
}

Q2: 如果不设置 DiffLogContext 会怎样?

A: 日志仍会记录,但没有字段变更详情

java
// 不设置 DiffLogContext
@DiffLog(success = "更新用户信息")
public void updateUser(Long id, UserUpdateReq req) {
    userMapper.updateById(req);
}

// 生成的日志
{
  "description": "更新用户信息",
  "variables": null  // 没有字段变更
}

Q3: SpEL 表达式 {{#id}} 是什么意思?

A: SpEL = Spring Expression Language,用于动态获取值

表达式说明
{{#id}}获取参数 id 的值
{{#req.userId}}获取参数对象的属性
{{#_newObj.username}}获取新对象的属性
{{#_context.userId}}获取当前用户ID

Q4: 如何忽略某些字段不参与对比?

A: 方式1: 配置全局忽略;方式2: 使用 @DiffField 策略

java
// 方式1: 配置文件
extend:
  boot:
    log:
      diff:
        ignore-global-fields:
          - createTime
          - updateTime

// 方式2: 字段注解
@DiffField(name = "创建时间", strategy = DiffFieldStrategy.IGNORE)
private Instant createTime;

Q5: 异步场景下如何使用?

A: 使用 InheritableThreadLocal,子线程可继承父线程上下文

java
@Async
public void asyncMethod() {
    // 可以获取父线程设置的 DiffLogContext 变量
    Object oldObj = DiffLogContext.getVariable(DiffParseFunction.OLD_OBJECT);
}

学习建议

  1. 先掌握基础用法: @DiffLog + DiffLogContext.putDiffItem
  2. 理解 SpEL 表达式: 灵活生成动态日志
  3. 自定义字段解析: 处理枚举、字典等特殊字段
  4. 实现日志存储: 根据业务需求持久化日志

下一步学习

  • Excel 处理 - 导入导出
  • WebSocket - 实时消息
  • 国际化 - 多语言支持