标题: 操作日志还在手动记?AOP自动化来了!
副标题: 从简单记录到数据对比,全方位操作审计
🎬 开篇:一次操作纠纷的追溯
某电商平台客服系统:
客户:我的订单被人改了,原来是2000元,现在变成了3000元!
客服:查询订单记录...(没有操作日志)💀
客服:不知道谁改的,什么时候改的...
客户:我要投诉!
公司:无法追溯,只能赔偿 💸
损失:赔偿5000元 + 信誉受损
老板:为什么没有操作日志?!
开发:我忘了加... 😭
改造后(操作日志系统):
客户:订单被改了!
客服:查询日志...
日志:2025-01-15 10:30:25,用户张三(ID:123),将订单金额从2000元改为3000元
结果:
- 找到操作人 ✅
- 证据确凿 ✅
- 恢复原价 ✅
- 追究责任 ✅
教训:操作日志是系统的"黑匣子"!
🤔 什么是操作日志?
想象一下:
- 淘宝: 谁修改了商品价格?
- 银行: 谁给客户转了账?
- ERP: 谁删除了库存记录?
- CRM: 谁导出了客户数据?
操作日志 = 谁(Who) + 什么时候(When) + 在哪里(Where) + 做了什么(What)!
📚 知识地图
操作日志系统
├── 🎯 核心要素
│ ├── 操作人(Who)
│ ├── 操作时间(When)
│ ├── 操作模块(Where)
│ ├── 操作类型(What)
│ ├── 操作详情(How)
│ └── 操作结果(Result)
├── 🏗️ 实现方式
│ ├── AOP切面(推荐)⭐⭐⭐⭐⭐
│ ├── 自定义注解 ⭐⭐⭐⭐⭐
│ ├── 拦截器 ⭐⭐⭐⭐
│ └── 手动记录 ⭐⭐
├── ⚡ 高级功能
│ ├── 数据对比(前后值)
│ ├── 异步记录
│ ├── 敏感数据脱敏
│ ├── 批量操作记录
│ └── 操作回滚
└── 🛡️ 日志管理
├── 日志查询
├── 日志统计
├── 日志导出
└── 日志归档
💾 数据库设计
-- 操作日志表
CREATE TABLE operation_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
trace_id VARCHAR(64) COMMENT '链路追踪ID',
user_id BIGINT NOT NULL COMMENT '操作人ID',
user_name VARCHAR(50) NOT NULL COMMENT '操作人姓名',
module VARCHAR(50) NOT NULL COMMENT '操作模块:order/product/user',
operation_type VARCHAR(20) NOT NULL COMMENT '操作类型:CREATE/UPDATE/DELETE/QUERY/EXPORT',
business_type VARCHAR(50) COMMENT '业务类型',
business_id BIGINT COMMENT '业务ID',
method VARCHAR(200) COMMENT '方法名',
request_uri VARCHAR(200) COMMENT '请求URI',
request_method VARCHAR(10) COMMENT '请求方式:GET/POST',
request_params TEXT COMMENT '请求参数',
response_data TEXT COMMENT '响应数据',
old_value TEXT COMMENT '修改前的值',
new_value TEXT COMMENT '修改后的值',
diff_fields VARCHAR(500) COMMENT '变更字段列表',
ip VARCHAR(50) COMMENT 'IP地址',
location VARCHAR(100) COMMENT 'IP归属地',
user_agent VARCHAR(500) COMMENT '浏览器UA',
status TINYINT NOT NULL DEFAULT 1 COMMENT '操作状态:1成功 2失败',
error_msg TEXT COMMENT '错误信息',
cost_time INT COMMENT '耗时(毫秒)',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_module_type (module, operation_type),
INDEX idx_business (business_type, business_id),
INDEX idx_create_time (create_time),
INDEX idx_trace_id (trace_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
-- 数据变更详情表(可选,用于记录字段级变更)
CREATE TABLE operation_log_detail (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
log_id BIGINT NOT NULL COMMENT '操作日志ID',
field_name VARCHAR(50) NOT NULL COMMENT '字段名',
field_label VARCHAR(100) COMMENT '字段标签',
old_value VARCHAR(1000) COMMENT '旧值',
new_value VARCHAR(1000) COMMENT '新值',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_log_id (log_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据变更详情表';
🔧 方案1:注解+AOP(推荐)
自定义注解
/**
* 操作日志注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperationLog {
/**
* 操作模块
*/
String module();
/**
* 操作类型
*/
OperationType type();
/**
* 业务类型
*/
String businessType() default "";
/**
* 业务ID的SpEL表达式
* 例如:#id、#order.id、#result.data.id
*/
String businessId() default "";
/**
* 操作描述
* 支持SpEL表达式:#{#user.name}修改了订单#{#order.orderNo}
*/
String description();
/**
* 是否记录请求参数
*/
boolean recordParams() default true;
/**
* 是否记录响应数据
*/
boolean recordResponse() default false;
/**
* 是否记录数据变更(对比前后值)
*/
boolean recordDiff() default false;
/**
* 是否异步记录
*/
boolean async() default true;
}
/**
* 操作类型
*/
public enum OperationType {
/**
* 新增
*/
CREATE("新增"),
/**
* 修改
*/
UPDATE("修改"),
/**
* 删除
*/
DELETE("删除"),
/**
* 查询
*/
QUERY("查询"),
/**
* 导出
*/
EXPORT("导出"),
/**
* 导入
*/
IMPORT("导入"),
/**
* 授权
*/
GRANT("授权"),
/**
* 其他
*/
OTHER("其他");
private final String label;
OperationType(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
AOP切面实现
/**
* 操作日志切面
*/
@Aspect
@Component
@Slf4j
public class OperationLogAspect {
@Autowired
private OperationLogService operationLogService;
@Autowired
private SpelExpressionParser spelParser;
@Autowired
private AsyncTaskExecutor asyncExecutor;
/**
* 环绕通知
*/
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable {
long startTime = System.currentTimeMillis();
// 1. 获取当前用户信息
UserContext currentUser = UserContextHolder.get();
// 2. 获取请求信息
HttpServletRequest request = getRequest();
// 3. 记录前的数据(用于对比)
Object oldValue = null;
if (operationLog.recordDiff()) {
oldValue = getOldValue(pjp, operationLog);
}
// 4. 执行方法
Object result = null;
Throwable exception = null;
try {
result = pjp.proceed();
return result;
} catch (Throwable e) {
exception = e;
throw e;
} finally {
// 5. ⚡ 记录操作日志(异步)
long costTime = System.currentTimeMillis() - startTime;
recordLog(pjp, operationLog, currentUser, request, result,
oldValue, exception, costTime);
}
}
/**
* 记录日志
*/
private void recordLog(ProceedingJoinPoint pjp,
OperationLog operationLog,
UserContext currentUser,
HttpServletRequest request,
Object result,
Object oldValue,
Throwable exception,
long costTime) {
Runnable task = () -> {
try {
// 构建日志对象
OperationLogDTO logDTO = new OperationLogDTO();
// 基本信息
logDTO.setTraceId(MDC.get("traceId"));
logDTO.setUserId(currentUser != null ? currentUser.getUserId() : null);
logDTO.setUserName(currentUser != null ? currentUser.getUserName() : "");
logDTO.setModule(operationLog.module());
logDTO.setOperationType(operationLog.type().name());
logDTO.setBusinessType(operationLog.businessType());
// 方法信息
MethodSignature signature = (MethodSignature) pjp.getSignature();
logDTO.setMethod(signature.getDeclaringTypeName() + "." + signature.getName());
// 请求信息
if (request != null) {
logDTO.setRequestUri(request.getRequestURI());
logDTO.setRequestMethod(request.getMethod());
logDTO.setIp(getIpAddress(request));
logDTO.setUserAgent(request.getHeader("User-Agent"));
}
// 请求参数
if (operationLog.recordParams()) {
Object[] args = pjp.getArgs();
logDTO.setRequestParams(JSON.toJSONString(args));
}
// 响应数据
if (operationLog.recordResponse() && result != null) {
logDTO.setResponseData(JSON.toJSONString(result));
}
// ⚡ 业务ID(SpEL表达式解析)
if (StringUtils.isNotBlank(operationLog.businessId())) {
Long businessId = parseSpel(operationLog.businessId(), pjp, result, Long.class);
logDTO.setBusinessId(businessId);
}
// ⚡ 数据对比
if (operationLog.recordDiff() && oldValue != null) {
Object newValue = getNewValue(pjp, operationLog, result);
if (newValue != null) {
logDTO.setOldValue(JSON.toJSONString(oldValue));
logDTO.setNewValue(JSON.toJSONString(newValue));
// 对比差异字段
List<String> diffFields = compareObjects(oldValue, newValue);
logDTO.setDiffFields(String.join(",", diffFields));
}
}
// 操作状态
if (exception != null) {
logDTO.setStatus(0); // 失败
logDTO.setErrorMsg(exception.getMessage());
} else {
logDTO.setStatus(1); // 成功
}
// 耗时
logDTO.setCostTime((int) costTime);
// 保存日志
operationLogService.saveLog(logDTO);
} catch (Exception e) {
log.error("记录操作日志失败", e);
}
};
// 异步或同步执行
if (operationLog.async()) {
asyncExecutor.execute(task);
} else {
task.run();
}
}
/**
* 获取修改前的数据
*/
private Object getOldValue(ProceedingJoinPoint pjp, OperationLog operationLog) {
if (operationLog.type() != OperationType.UPDATE) {
return null;
}
try {
// 从参数中获取ID
Object[] args = pjp.getArgs();
Long id = null;
for (Object arg : args) {
if (arg instanceof Long) {
id = (Long) arg;
break;
} else if (arg != null && hasIdField(arg)) {
id = getIdFromObject(arg);
break;
}
}
if (id != null) {
// 查询修改前的数据
return queryOldValue(pjp, id);
}
} catch (Exception e) {
log.error("获取修改前数据失败", e);
}
return null;
}
/**
* 获取修改后的数据
*/
private Object getNewValue(ProceedingJoinPoint pjp, OperationLog operationLog, Object result) {
if (operationLog.type() != OperationType.UPDATE) {
return null;
}
try {
// 从参数中获取ID
Object[] args = pjp.getArgs();
Long id = null;
for (Object arg : args) {
if (arg instanceof Long) {
id = (Long) arg;
break;
} else if (arg != null && hasIdField(arg)) {
id = getIdFromObject(arg);
break;
}
}
if (id != null) {
// 查询修改后的数据
return queryNewValue(pjp, id);
}
} catch (Exception e) {
log.error("获取修改后数据失败", e);
}
return null;
}
/**
* 对比对象差异
*/
private List<String> compareObjects(Object oldObj, Object newObj) {
List<String> diffFields = new ArrayList<>();
if (oldObj == null || newObj == null) {
return diffFields;
}
try {
Class<?> clazz = oldObj.getClass();
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
Object oldValue = field.get(oldObj);
Object newValue = field.get(newObj);
// 跳过null值
if (oldValue == null && newValue == null) {
continue;
}
// 比较值
if (oldValue == null || !oldValue.equals(newValue)) {
diffFields.add(field.getName());
}
}
} catch (Exception e) {
log.error("对比对象差异失败", e);
}
return diffFields;
}
/**
* SpEL表达式解析
*/
private <T> T parseSpel(String spelExpression, ProceedingJoinPoint pjp,
Object result, Class<T> clazz) {
try {
Expression expression = spelParser.parseExpression(spelExpression);
StandardEvaluationContext context = new StandardEvaluationContext();
// 设置方法参数
MethodSignature signature = (MethodSignature) pjp.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = pjp.getArgs();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
// 设置返回值
context.setVariable("result", result);
return expression.getValue(context, clazz);
} catch (Exception e) {
log.error("SpEL表达式解析失败:{}", spelExpression, e);
return null;
}
}
/**
* 获取请求对象
*/
private HttpServletRequest getRequest() {
try {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes != null ? attributes.getRequest() : null;
} catch (Exception e) {
return null;
}
}
/**
* 获取IP地址
*/
private String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (StringUtils.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (StringUtils.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
/**
* 判断对象是否有ID字段
*/
private boolean hasIdField(Object obj) {
try {
obj.getClass().getDeclaredField("id");
return true;
} catch (NoSuchFieldException e) {
return false;
}
}
/**
* 从对象中获取ID
*/
private Long getIdFromObject(Object obj) {
try {
Field field = obj.getClass().getDeclaredField("id");
field.setAccessible(true);
return (Long) field.get(obj);
} catch (Exception e) {
return null;
}
}
/**
* 查询修改前的值
*/
private Object queryOldValue(ProceedingJoinPoint pjp, Long id) {
// 这里需要根据具体业务实现
// 例如:orderService.getById(id)
return null;
}
/**
* 查询修改后的值
*/
private Object queryNewValue(ProceedingJoinPoint pjp, Long id) {
// 这里需要根据具体业务实现
return null;
}
}
使用示例
/**
* 订单Controller
*/
@RestController
@RequestMapping("/order")
@Slf4j
public class OrderController {
@Autowired
private OrderService orderService;
/**
* ⚡ 创建订单(自动记录日志)
*/
@PostMapping
@OperationLog(
module = "订单管理",
type = OperationType.CREATE,
businessType = "order",
businessId = "#result.data.id",
description = "创建订单:#{#orderDTO.orderNo}"
)
public Result<Order> createOrder(@RequestBody OrderDTO orderDTO) {
Order order = orderService.createOrder(orderDTO);
return Result.success(order);
}
/**
* ⚡ 修改订单(自动记录日志+数据对比)
*/
@PutMapping("/{id}")
@OperationLog(
module = "订单管理",
type = OperationType.UPDATE,
businessType = "order",
businessId = "#id",
description = "修改订单:#{#id}",
recordDiff = true // 记录数据变更
)
public Result<Void> updateOrder(@PathVariable Long id,
@RequestBody OrderDTO orderDTO) {
orderService.updateOrder(id, orderDTO);
return Result.success();
}
/**
* ⚡ 删除订单(自动记录日志)
*/
@DeleteMapping("/{id}")
@OperationLog(
module = "订单管理",
type = OperationType.DELETE,
businessType = "order",
businessId = "#id",
description = "删除订单:#{#id}"
)
public Result<Void> deleteOrder(@PathVariable Long id) {
orderService.deleteOrder(id);
return Result.success();
}
/**
* ⚡ 导出订单(自动记录日志)
*/
@GetMapping("/export")
@OperationLog(
module = "订单管理",
type = OperationType.EXPORT,
description = "导出订单数据",
recordParams = true
)
public void exportOrders(OrderQueryDTO query, HttpServletResponse response) {
orderService.exportOrders(query, response);
}
}
⚡ 高级功能:数据对比
/**
* 数据对比工具
*/
@Component
public class DataDiffUtils {
/**
* 对比两个对象的差异
*/
public static List<FieldDiff> compareObjects(Object oldObj, Object newObj) {
List<FieldDiff> diffs = new ArrayList<>();
if (oldObj == null || newObj == null) {
return diffs;
}
try {
Class<?> clazz = oldObj.getClass();
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
// 获取字段标签(从注解中)
String fieldLabel = getFieldLabel(field);
Object oldValue = field.get(oldObj);
Object newValue = field.get(newObj);
// 跳过相同的值
if (Objects.equals(oldValue, newValue)) {
continue;
}
// 记录差异
FieldDiff diff = new FieldDiff();
diff.setFieldName(field.getName());
diff.setFieldLabel(fieldLabel);
diff.setOldValue(oldValue != null ? oldValue.toString() : "");
diff.setNewValue(newValue != null ? newValue.toString() : "");
diffs.add(diff);
}
} catch (Exception e) {
log.error("对比对象失败", e);
}
return diffs;
}
/**
* 获取字段标签
*/
private static String getFieldLabel(Field field) {
// 可以从自定义注解中获取
// @FieldLabel("订单金额")
// private BigDecimal amount;
FieldLabel annotation = field.getAnnotation(FieldLabel.class);
if (annotation != null) {
return annotation.value();
}
return field.getName();
}
}
/**
* 字段标签注解
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FieldLabel {
String value();
}
/**
* 字段差异VO
*/
@Data
public class FieldDiff {
/**
* 字段名
*/
private String fieldName;
/**
* 字段标签
*/
private String fieldLabel;
/**
* 旧值
*/
private String oldValue;
/**
* 新值
*/
private String newValue;
}
/**
* 使用示例
*/
@Data
public class Order {
@FieldLabel("订单ID")
private Long id;
@FieldLabel("订单编号")
private String orderNo;
@FieldLabel("订单金额")
private BigDecimal amount;
@FieldLabel("订单状态")
private Integer status;
}
📊 日志查询与统计
/**
* 操作日志查询服务
*/
@Service
public class OperationLogQueryService {
@Autowired
private OperationLogMapper operationLogMapper;
/**
* 分页查询操作日志
*/
public PageResult<OperationLogVO> queryLogs(OperationLogQueryDTO query) {
Page<OperationLog> page = new Page<>(query.getPageNo(), query.getPageSize());
LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(query.getUserId() != null, OperationLog::getUserId, query.getUserId())
.eq(StringUtils.isNotBlank(query.getModule()),
OperationLog::getModule, query.getModule())
.eq(StringUtils.isNotBlank(query.getOperationType()),
OperationLog::getOperationType, query.getOperationType())
.eq(query.getBusinessId() != null,
OperationLog::getBusinessId, query.getBusinessId())
.between(query.getStartTime() != null && query.getEndTime() != null,
OperationLog::getCreateTime, query.getStartTime(), query.getEndTime())
.orderByDesc(OperationLog::getCreateTime);
Page<OperationLog> result = operationLogMapper.selectPage(page, wrapper);
List<OperationLogVO> voList = result.getRecords().stream()
.map(this::convertToVO)
.collect(Collectors.toList());
return PageResult.of(result.getTotal(), voList);
}
/**
* 查询某条数据的操作历史
*/
public List<OperationLogVO> queryDataHistory(String businessType, Long businessId) {
LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OperationLog::getBusinessType, businessType)
.eq(OperationLog::getBusinessId, businessId)
.orderByDesc(OperationLog::getCreateTime);
List<OperationLog> logs = operationLogMapper.selectList(wrapper);
return logs.stream()
.map(this::convertToVO)
.collect(Collectors.toList());
}
/**
* 统计操作次数
*/
public Map<String, Long> statisticsOperationCount(Date startTime, Date endTime) {
List<OperationLog> logs = operationLogMapper.selectBetweenTime(startTime, endTime);
return logs.stream()
.collect(Collectors.groupingBy(
OperationLog::getOperationType,
Collectors.counting()
));
}
/**
* 统计用户操作排行
*/
public List<UserOperationStatVO> statisticsUserOperation(Date startTime, Date endTime) {
return operationLogMapper.statisticsUserOperation(startTime, endTime);
}
/**
* 转换VO
*/
private OperationLogVO convertToVO(OperationLog log) {
OperationLogVO vo = new OperationLogVO();
BeanUtils.copyProperties(log, vo);
// 解析差异字段
if (StringUtils.isNotBlank(log.getOldValue()) &&
StringUtils.isNotBlank(log.getNewValue())) {
List<FieldDiff> diffs = parseDiff(log.getOldValue(), log.getNewValue());
vo.setDiffList(diffs);
}
return vo;
}
/**
* 解析差异
*/
private List<FieldDiff> parseDiff(String oldValueJson, String newValueJson) {
// 解析JSON并对比差异
return DataDiffUtils.compareObjects(
JSON.parseObject(oldValueJson, Object.class),
JSON.parseObject(newValueJson, Object.class)
);
}
}
✅ 最佳实践
操作日志系统设计要点:
1️⃣ 日志内容:
□ 操作人(谁)
□ 操作时间(何时)
□ 操作模块(哪里)
□ 操作类型(做什么)
□ 操作详情(怎么做)
□ 操作结果(成功/失败)
2️⃣ 技术实现:
□ AOP切面(推荐)⭐⭐⭐⭐⭐
□ 自定义注解
□ SpEL表达式
□ 异步记录
3️⃣ 高级功能:
□ 数据对比(前后值)
□ 敏感数据脱敏
□ 链路追踪ID
□ IP归属地
4️⃣ 性能优化:
□ 异步记录(不影响主流程)
□ 批量插入
□ 定期归档
□ 索引优化
5️⃣ 日志管理:
□ 分页查询
□ 条件筛选
□ 操作历史
□ 数据导出
□ 日志归档
6️⃣ 安全防护:
□ 敏感字段脱敏
□ 访问权限控制
□ 防止篡改
□ 审计日志
🎉 总结
操作日志系统核心:
1️⃣ 自动化:AOP自动记录,无需手动调用
2️⃣ 完整性:记录操作全过程
3️⃣ 可追溯:完整的操作链路
4️⃣ 数据对比:清晰展示前后值变化
5️⃣ 高性能:异步记录不影响主流程
记住:操作日志是系统的"黑匣子",关键时刻能救命! 📝
文档编写时间:2025年10月24日
作者:热爱审计追溯的日志工程师
版本:v1.0
愿每个操作都有迹可循! ✨