一、操作日志的核心价值与挑战
1.1 操作日志与系统日志的本质区别
| 维度 | 系统日志 | 操作日志 |
|---|---|---|
| 目标用户 | 开发人员、运维人员 | 最终用户、客服、运营人员 |
| 可读性要求 | 低(包含代码信息) | 高(自然语言描述) |
| 记录目的 | 问题排查、系统监控 | 业务追踪、审计记录 |
| 格式要求 | 机器可解析 | 人类可理解 |
| 存储方式 | 日志文件、集中式日志 | 数据库、专用存储 |
1.2 操作日志的典型场景
java
// 操作日志的四种典型格式 // 1. 纯文字记录 "2021-09-16 10:00 订单创建" // 2. 带变量的文本记录 "2021-09-16 10:00 订单创建,订单号:NO.11089999" // 3. 修改前后的对比 "2021-09-16 10:00 用户小明修改了订单的配送地址:从"金灿灿小区"修改到"银盏盏小区"" // 4. 批量字段修改 "2021-09-16 10:00 用户修改了订单信息:{ "配送地址": ["金灿灿小区", "银盏盏小区"], "联系电话": ["13800138000", "13900139000"] }"二、传统实现方案的局限性分析
2.1 基于Canal的方案
java
// Canal监听数据库变化的局限性 public class CanalBasedLogRecorder { /** * 优点: * 1. 完全解耦,不影响业务代码 * 2. 自动捕获所有数据变更 * * 缺点: * 1. 只能监听数据库变更,无法记录业务操作 * 2. 无法获取操作人信息 * 3. 无法理解业务语义 * 4. 无法处理复杂业务逻辑(如RPC调用) */ }2.2 基于Log文件的方案
java
// 使用SLF4J MDC记录操作人 @Component @Slf4j public class LogFileBasedRecorder { // 问题1:操作人记录 - 通过拦截器设置 @Component public class UserInterceptor extends HandlerInterceptorAdapter { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String userNo = getUserNo(request); MDC.put("userId", userNo); // 放入线程上下文 return super.preHandle(request, response, handler); } } // 问题2:操作日志分离 - 独立的日志配置 // logback-spring.xml /* <appender name="businessLogAppender" class="...RollingFileAppender"> <File>logs/business.log</File> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>INFO</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> <encoder> <pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} - %msg%n"</pattern> </encoder> </appender> */ // 问题3:生成可读文案 - 通过工具类拼接 public void updateAddress(UpdateDeliveryRequest request) { DeliveryOrder oldOrder = queryOldAddress(request.getDeliveryOrderNo()); doUpdate(request); // 业务逻辑中嵌入日志记录代码 String logContent = String.format( "用户%s修改了订单的配送地址:从\"%s\"修改到\"%s\"", request.getUserName(), oldOrder.getAddress(), request.getAddress() ); businessLog.info(logContent); } }传统方案的痛点:
业务代码污染:日志记录代码与业务逻辑混杂
重复劳动:每个业务方法都需要手动拼接日志
难以维护:日志格式变更需要修改大量代码
可读性差:业务逻辑被日志代码淹没
三、基于注解+AOP的优雅解决方案
3.1 核心设计思路
text
┌─────────────────────────────────────────┐ │ 业务方法(纯净无染) │ │ ┌───────────────────────────────────┐ │ │ │ @LogRecord │ │ │ │ public void updateOrder() { ... } │ │ │ └───────────────────────────────────┘ │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ AOP切面(统一处理) │ │ ┌───────────────────────────────────┐ │ │ │ 1. 解析注解 │ │ │ │ 2. 提取参数 │ │ │ │ 3. 渲染模板 │ │ │ │ 4. 记录日志 │ │ │ └───────────────────────────────────┘ │ └─────────────────────────────────────────┘3.2 核心注解设计
java
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented public @interface LogRecord { /** * 成功时的操作日志模板 * 支持SpEL表达式:#{#order.orderNo} * 支持自定义函数:{parseUser{#userId}} */ String success(); /** * 失败时的操作日志模板(可选) */ String fail() default ""; /** * 操作人(SpEL表达式) * 默认从线程上下文获取 */ String operator() default ""; /** * 业务编号(SpEL表达式) * 用于关联业务对象 */ String bizNo(); /** * 操作类别(用于分类查询) */ String category() default ""; /** * 详情信息(JSON格式,记录修改详情) */ String detail() default ""; /** * 记录条件(SpEL表达式) * 满足条件才记录日志 */ String condition() default ""; }3.3 AOP切面实现
java
@Aspect @Component @Slf4j public class LogRecordAspect { @Autowired private LogRecordService logRecordService; @Autowired private SpelExpressionParser spelParser; @Around("@annotation(logRecord)") public Object around(ProceedingJoinPoint joinPoint, LogRecord logRecord) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); Object[] args = joinPoint.getArgs(); // 1. 构建评估上下文 EvaluationContext context = createEvaluationContext(method, args); // 2. 检查记录条件 if (!shouldRecord(logRecord.condition(), context)) { return joinPoint.proceed(); } // 3. 执行前准备(如查询旧值) prepareBeforeExecution(logRecord, context, args); // 4. 执行业务方法 Object result; boolean success = true; String errorMsg = null; try { result = joinPoint.proceed(); context.setVariable("_result", result); success = true; } catch (Exception e) { success = false; errorMsg = e.getMessage(); context.setVariable("_error", e); throw e; } // 5. 记录操作日志 try { recordLog(logRecord, context, success, errorMsg, args); } catch (Exception e) { // 记录日志失败不影响主业务 log.error("记录操作日志失败", e); } // 6. 清理上下文 LogRecordContext.clear(); return result; } private void recordLog(LogRecord annotation, EvaluationContext context, boolean success, String errorMsg, Object[] args) { // 选择模板 String template = success ? annotation.success() : annotation.fail(); if (StringUtils.isEmpty(template)) { return; } // 渲染模板 String content = renderTemplate(template, context); // 解析操作人 String operator = parseOperator(annotation.operator(), context); // 解析业务编号 String bizNo = parseBizNo(annotation.bizNo(), context); // 构建操作日志 OperationLog log = OperationLog.builder() .content(content) .operator(operator) .bizNo(bizNo) .category(annotation.category()) .detail(parseDetail(annotation.detail(), context)) .success(success) .errorMsg(errorMsg) .build(); // 保存日志 logRecordService.save(log); } private String renderTemplate(String template, EvaluationContext context) { // 处理自定义函数:{function{#param}} template = processCustomFunctions(template, context); // 处理SpEL表达式:#param template = processSpELExpressions(template, context); return template; } }3.4 模板渲染引擎
java
@Component public class LogTemplateParser { // 自定义函数模式:{function{#param}} private static final Pattern CUSTOM_FUNCTION_PATTERN = Pattern.compile("\\{([^}]+)\\{(#[^}]+)\\}\\}"); // SpEL表达式模式:#param private static final Pattern SPEL_PATTERN = Pattern.compile("#([a-zA-Z0-9_.]+)"); /** * 解析模板中的自定义函数 */ public String parseCustomFunctions(String template, EvaluationContext context) { Matcher matcher = CUSTOM_FUNCTION_PATTERN.matcher(template); StringBuffer result = new StringBuffer(); while (matcher.find()) { String functionName = matcher.group(1); String paramExpression = matcher.group(2); // 解析参数 String param = parseSpEL(paramExpression, context); // 执行自定义函数 String functionResult = executeCustomFunction(functionName, param); matcher.appendReplacement(result, functionResult); } matcher.appendTail(result); return result.toString(); } /** * 解析SpEL表达式 */ public String parseSpEL(String expression, EvaluationContext context) { try { Expression exp = spelParser.parseExpression(expression); Object value = exp.getValue(context); return value != null ? value.toString() : ""; } catch (Exception e) { log.warn("SpEL解析失败: {}", expression, e); return ""; } } }3.5 日志上下文管理
java
/** * 操作日志上下文,用于在方法执行前后传递数据 */ public class LogRecordContext { private static final ThreadLocal<Map<String, Object>> VARIABLE_MAP = ThreadLocal.withInitial(HashMap::new); /** * 设置变量(用于模板中引用) */ public static void putVariable(String key, Object value) { VARIABLE_MAP.get().put(key, value); } /** * 获取变量 */ public static Object getVariable(String key) { return VARIABLE_MAP.get().get(key); } /** * 清除上下文 */ public static void clear() { VARIABLE_MAP.remove(); } /** * 获取所有变量 */ public static Map<String, Object> getVariables() { return new HashMap<>(VARIABLE_MAP.get()); } }四、完整使用示例
4.1 基础使用
java
@Service @Slf4j public class OrderService { /** * 简单场景:创建订单 */ @LogRecord( success = "创建订单,订单号:#{#order.orderNo},金额:#{#order.amount}元", operator = "#{#currentUser.name}", bizNo = "#{#order.orderNo}", category = "ORDER_CREATE" ) public Order createOrder(OrderCreateRequest request) { // 纯净的业务逻辑 Order order = Order.builder() .orderNo(generateOrderNo()) .amount(request.getAmount()) .status(OrderStatus.CREATED) .build(); return orderRepository.save(order); } /** * 修改场景:更新配送地址 */ @LogRecord( success = "修改订单配送地址:从{getOldAddress{#orderId}}修改到#{#request.newAddress}", operator = "#{#currentUser.name}", bizNo = "#{#orderId}", category = "ORDER_UPDATE" ) public void updateDeliveryAddress(String orderId, UpdateAddressRequest request) { // 查询旧地址(通过自定义函数在切面中完成) // 业务代码专注于更新逻辑 orderRepository.updateAddress(orderId, request.getNewAddress()); } }4.2 复杂场景:批量修改
java
@Service public class UserService { /** * 批量修改用户信息 */ @LogRecord( success = "批量修改用户信息:#{#changes.size()}项变更", detail = """ { "changes": #{#changes}, "operator": #{#currentUser.name}, "timestamp": #{T(java.time.LocalDateTime).now()} } """, operator = "#{#currentUser.name}", bizNo = "'BATCH_UPDATE_' + T(java.time.LocalDate).now()", category = "USER_BATCH_UPDATE" ) public void batchUpdateUsers(List<UserUpdateDTO> changes) { // 批量更新逻辑 changes.forEach(change -> { userRepository.update(change.getUserId(), change.getUpdates()); }); } }4.3 自定义函数的使用
java
/** * 自定义函数注册中心 */ @Component public class LogFunctionRegistry { private final Map<String, Function<String, String>> functionMap = new HashMap<>(); public LogFunctionRegistry() { // 注册内置函数 registerFunction("parseUser", this::parseUser); registerFunction("getOldAddress", this::getOldAddress); registerFunction("formatDate", this::formatDate); registerFunction("maskPhone", this::maskPhone); } /** * 用户ID转用户姓名 */ private String parseUser(String userId) { User user = userRepository.findById(userId) .orElse(new User(userId, "未知用户")); return String.format("%s(%s)", user.getName(), user.getPhone()); } /** * 获取旧地址 */ private String getOldAddress(String orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); return order.getDeliveryAddress(); } /** * 日期格式化 */ private String formatDate(String timestamp) { LocalDateTime dateTime = LocalDateTime.parse(timestamp); return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } /** * 手机号脱敏 */ private String maskPhone(String phone) { if (phone == null || phone.length() != 11) { return phone; } return phone.substring(0, 3) + "****" + phone.substring(7); } public void registerFunction(String name, Function<String, String> function) { functionMap.put(name, function); } public Function<String, String> getFunction(String name) { return functionMap.get(name); } }五、高级特性实现
5.1 条件记录
java
@Service public class PaymentService { /** * 只有支付成功时才记录日志 */ @LogRecord( success = "用户支付成功,订单号:#{#orderNo},支付金额:#{#amount}元", condition = "#{#result.success == true}", operator = "#{#currentUser.name}", bizNo = "#{#orderNo}", category = "PAYMENT_SUCCESS" ) public PaymentResult payOrder(String orderNo, BigDecimal amount) { // 支付逻辑 boolean success = paymentGateway.pay(orderNo, amount); return PaymentResult.builder() .success(success) .orderNo(orderNo) .amount(amount) .build(); } }5.2 修改详情记录
java
@Service public class ProductService { /** * 记录商品修改详情 */ @LogRecord( success = "修改商品信息:#{#product.name}", detail = """ { "productId": #{#product.id}, "changes": { "price": [#{#oldProduct.price}, #{#product.price}], "stock": [#{#oldProduct.stock}, #{#product.stock}], "status": [#{#oldProduct.status}, #{#product.status}] }, "operator": #{#currentUser.name} } """, operator = "#{#currentUser.name}", bizNo = "#{#product.id}", category = "PRODUCT_UPDATE" ) public void updateProduct(Product product) { // 查询旧数据(可通过切面自动完成) Product oldProduct = productRepository.findById(product.getId()); // 更新逻辑 productRepository.save(product); } }5.3 异步记录与性能优化
java
@Component @Slf4j public class AsyncLogRecordService { @Autowired private ThreadPoolTaskExecutor logExecutor; @Autowired private LogStorageService logStorageService; /** * 异步记录日志 */ @Async("logExecutor") public void saveAsync(OperationLog log) { try { logStorageService.save(log); } catch (Exception e) { // 异步记录失败,降级到同步记录或本地文件 log.error("异步记录操作日志失败,降级处理", e); saveToLocalFile(log); } } /** * 批量记录优化 */ public void batchSave(List<OperationLog> logs) { if (logs.size() > BATCH_THRESHOLD) { // 分批处理 Lists.partition(logs, BATCH_SIZE) .forEach(batch -> logExecutor.execute(() -> logStorageService.batchSave(batch) )); } else { logs.forEach(this::saveAsync); } } }六、存储与查询设计
6.1 数据模型设计
java
@Entity @Table(name = "operation_log") @Data @Builder @NoArgsConstructor @AllArgsConstructor public class OperationLog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; /** * 操作内容(渲染后的文本) */ @Column(length = 2000) private String content; /** * 操作人 */ private String operator; /** * 业务编号 */ private String bizNo; /** * 操作类别 */ private String category; /** * 详情(JSON格式) */ @Column(columnDefinition = "json") private String detail; /** * 是否成功 */ private Boolean success; /** * 错误信息 */ private String errorMsg; /** * 操作时间 */ @CreationTimestamp private LocalDateTime operateTime; /** * 操作IP */ private String operateIp; /** * 用户代理 */ private String userAgent; /** * 请求路径 */ private String requestPath; /** * 方法名 */ private String methodName; /** * 参数(JSON格式) */ @Column(columnDefinition = "json") private String params; }6.2 查询接口设计
java
public interface OperationLogRepository extends JpaRepository<OperationLog, Long> { /** * 按业务编号查询 */ List<OperationLog> findByBizNoOrderByOperateTimeDesc(String bizNo); /** * 按操作人查询 */ Page<OperationLog> findByOperator(String operator, Pageable pageable); /** * 按类别和时间范围查询 */ List<OperationLog> findByCategoryAndOperateTimeBetween( String category, LocalDateTime startTime, LocalDateTime endTime ); /** * 复杂条件查询 */ @Query("SELECT ol FROM OperationLog ol WHERE " + "(:bizNo IS NULL OR ol.bizNo = :bizNo) AND " + "(:operator IS NULL OR ol.operator LIKE %:operator%) AND " + "(:category IS NULL OR ol.category = :category) AND " + "(:success IS NULL OR ol.success = :success) AND " + "ol.operateTime BETWEEN :startTime AND :endTime") Page<OperationLog> search( @Param("bizNo") String bizNo, @Param("operator") String operator, @Param("category") String category, @Param("success") Boolean success, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, Pageable pageable ); }6.3 扩展存储方案
java
/** * 存储策略接口 */ public interface LogStorageStrategy { /** * 保存日志 */ void save(OperationLog log); /** * 批量保存 */ void batchSave(List<OperationLog> logs); /** * 查询日志 */ List<OperationLog> query(LogQueryCondition condition); } /** * 数据库存储策略 */ @Component @Primary public class DatabaseStorageStrategy implements LogStorageStrategy { @Autowired private OperationLogRepository repository; @Override public void save(OperationLog log) { repository.save(log); } @Override public void batchSave(List<OperationLog> logs) { repository.saveAll(logs); } } /** * Elasticsearch存储策略(用于海量日志) */ @Component @ConditionalOnProperty(name = "log.storage.strategy", havingValue = "elasticsearch") public class ElasticsearchStorageStrategy implements LogStorageStrategy { @Autowired private ElasticsearchRestTemplate elasticsearchTemplate; @Override public void save(OperationLog log) { IndexQuery query = new IndexQueryBuilder() .withObject(log) .withId(log.getId().toString()) .build(); elasticsearchTemplate.index(query, IndexCoordinates.of("operation_log")); } @Override public List<OperationLog> query(LogQueryCondition condition) { // 构建ES查询 NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(buildQuery(condition)) .withPageable(condition.getPageable()) .build(); return elasticsearchTemplate.queryForList(searchQuery, OperationLog.class); } }七、最佳实践与部署方案
7.1 Spring Boot Starter封装
java
@Configuration @EnableAspectJAutoProxy @AutoConfigureAfter({WebMvcAutoConfiguration.class}) @ConditionalOnProperty(prefix = "operation.log", name = "enabled", havingValue = "true", matchIfMissing = true) public class OperationLogAutoConfiguration { @Bean @ConditionalOnMissingBean public LogRecordAspect logRecordAspect() { return new LogRecordAspect(); } @Bean @ConditionalOnMissingBean public LogTemplateParser logTemplateParser() { return new LogTemplateParser(); } @Bean @ConditionalOnMissingBean public LogFunctionRegistry logFunctionRegistry() { return new LogFunctionRegistry(); } @Bean @ConditionalOnMissingBean public LogStorageStrategy logStorageStrategy(OperationLogRepository repository) { return new DatabaseStorageStrategy(repository); } }7.2 配置文件示例
yaml
# application-operation-log.yml operation: log: enabled: true storage: strategy: database # database, elasticsearch, mixed async: true batch-size: 100 template: default-operator: "#{T(com.xxx.UserContext).getCurrentUser()}" date-format: "yyyy-MM-dd HH:mm:ss" function: enabled: true packages: "com.xxx.functions"7.3 监控与告警
java
@Component @Slf4j public class LogMonitor { @Autowired private MeterRegistry meterRegistry; private final Counter logRecordCounter; private final Timer logRecordTimer; private final Counter logErrorCounter; public LogMonitor(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; // 记录操作日志数量 this.logRecordCounter = Counter.builder("operation.log.count") .description("操作日志记录数量") .register(meterRegistry); // 记录操作日志耗时 this.logRecordTimer = Timer.builder("operation.log.time") .description("操作日志记录耗时") .register(meterRegistry); // 记录操作日志错误 this.logErrorCounter = Counter.builder("operation.log.error") .description("操作日志记录错误") .register(meterRegistry); } public void recordLog(OperationLog log, long duration) { logRecordCounter.increment(); logRecordTimer.record(duration, TimeUnit.MILLISECONDS); // 按类别统计 Tags tags = Tags.of("category", log.getCategory(), "success", String.valueOf(log.getSuccess())); meterRegistry.counter("operation.log.category", tags).increment(); } public void recordError(String category, Exception e) { logErrorCounter.increment(); log.error("操作日志记录失败: {}", category, e); } }7.4 测试策略
java
@SpringBootTest @AutoConfigureMockMvc class LogRecordAspectTest { @Autowired private MockMvc mockMvc; @Autowired private OperationLogRepository logRepository; @Test void testLogRecordAnnotation() throws Exception { // 准备测试数据 OrderCreateRequest request = new OrderCreateRequest(); request.setAmount(new BigDecimal("100.00")); // 执行测试 mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content(JsonUtils.toJson(request))) .andExpect(status().isOk()); // 验证日志记录 List<OperationLog> logs = logRepository.findAll(); assertThat(logs).hasSize(1); OperationLog log = logs.get(0); assertThat(log.getContent()).contains("创建订单"); assertThat(log.getBizNo()).isNotNull(); assertThat(log.getSuccess()).isTrue(); } @Test void testCustomFunction() { // 测试自定义函数 String template = "用户{parseUser{#userId}}修改了订单"; String userId = "123"; EvaluationContext context = new StandardEvaluationContext(); context.setVariable("userId", userId); String result = logTemplateParser.parseCustomFunctions(template, context); assertThat(result).contains("张三(13800138000)"); } }八、总结
8.1 方案优势总结
彻底解耦:业务代码零侵入,专注业务逻辑
灵活扩展:支持自定义函数、存储策略、模板引擎
高性能:异步记录、批量处理、条件过滤
易维护:统一配置、集中管理、标准格式
强可读:自然语言模板、用户友好
8.2 适用场景
电商系统:订单状态变更、物流跟踪
CRM系统:客户信息修改、跟进记录
OA系统:审批流程、文档操作
金融系统:资金变动、审核记录
后台管理系统:配置修改、数据操作
8.3 部署建议
通过本文介绍的设计方案,我们可以实现一个高可用、高性能、易扩展的操作日志系统。这个系统不仅能够满足当前业务需求,还能随着业务发展灵活扩展,真正实现操作日志记录的"优雅"之道。