基于 mzt-biz-log 实现接口调用日志记录

🎯导读:mzt-biz-log 是一个用于记录操作日志的通用组件,旨在追踪系统中"谁"在"何时"对"何事"执行了"何种操作"。该组件通过简单的注解配置,如 @LogRecord,即可实现接口调用的日志记录,支持成功与失败场景下的差异化日志描述。它还提供了丰富的功能,包括但不限于租户隔离、日志子类型划分、条件性日志记录以及枚举值解析等。此外,mzt-biz-log 支持自定义日志存储逻辑,允许开发者根据业务需求将日志持久化到数据库或其他存储媒介。整体设计简洁高效,适用于微服务架构中的日志管理需求。

🏠️ HelloDam/场快订(场馆预定 SaaS 平台)

mzt-biz-log介绍

mzt-biz-log:一套通用操作日志组件,用来记录「谁」在「什么时间」对「什么」做了「什么事」

具体实现

依赖

xml 复制代码
<dependency>
    <groupId>io.github.mouzt</groupId>
    <artifactId>bizlog-sdk</artifactId>
    <version>3.0.6</version>
</dependency>

添加注解

首先需要在具体的服务启动类中添加注解@EnableLogRecord(tenant = "venue"),其中tenant是租户标识,我这里设置为了服务的名称,一般一个服务或者一个业务下的多个服务都用一个 tenant 就可以了

然后在具体的接口添加注解@LogRecord,在调用相应的接口之后,就会触发日志

java 复制代码
@Repeatable(LogRecords.class)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecord {
    String success();

    String fail() default "";

    String operator() default "";

    String type();

    String subType() default "";

    String bizNo();

    String extra() default "";

    String condition() default "";

    String successCondition() default "";
}
  • type:日志类型,可以用来区分不同的接口,我这里直接设置为接口名称,方便辨识
  • subType:日志子类型,可以用来区分不同的操作者身份
  • bizNo:日志ID,可以设置为具体的数据的ID,这样查询日志的时候,直接使用相应数据的ID来查询,例如说bizNo存储的是订单ID,后面可以凭借这个来查询该订单相关的日志
  • success:接口调用成功之后,action存放什么数据(action字段是什么,看日志持久化就知道了),一般通过描述语言拼接字段值来实现快速让用户知道日志的内容
  • fail:接口调用异常之后,action存放什么数据
  • extra:需要记录的额外信息,如直接将用户提交的数据的 json 进行存储,因为action存储的是简略的信息
  • operator:存储操作人信息,需要用户的系统已经实现了用户上下文

【成功调用示例】

java 复制代码
/**
 * 增添数据
 */
@PostMapping("/save")
@LogRecord(
        bizNo = "{{#id}}",
        type = "新增分区",
        success = """
            场馆ID:{{#partitionDO.venueId}}, \
            分区名称:{{#partitionDO.name}}, \
            分区类型:{{#partitionDO.type}}, \
            描述:{{#partitionDO.description}}, \
            场区拥有的场数量:{{#partitionDO.num}}, \
            场区状态:{{#partitionDO.status}}; \
            结果:{{#_ret}}
            """,
        fail = "接口调用失败,失败原因:{{#_errorMsg}}",
        extra = "{{#partitionDO.toString()}}",
        operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}"
)
public Result save(@Validated({AddGroup.class}) @RequestBody PartitionDO partitionDO) {
    partitionService.save(partitionDO);
    // 因为 ID 是存储到数据库中才生成的,@LogRecord 默认拿不到,需要我们将信息手动设置到上下文中
    LogRecordContext.putVariable("id", partitionDO.getId());
    return Results.success();
}

注意:

  • 获取接口返回的结果:{{#_ret}}
  • 通过日志上下文记录信息:因为 id 是存储到数据库中才生成的,@LogRecord 一开始拿不到,需要我们将信息手动设置到上下文中。可以通过LogRecordContext.putVariable("id", partitionDO.getId());来设置键值对,然后在注解中凭借键来获取值就可以,如bizNo = "{{#id}}"

接口调用成功的日志内容如下:

Shell 复制代码
【logRecord】log=LogRecord(id=null, tenant=venue, type=新增分区, subType=, bizNo=1868205198032568320, operator=admin, action=场馆ID:12345, 分区名称:篮球场A区, 分区类型:营业中, 描述:提供标准篮球设施,包括篮球和球架。, 场区拥有的场数量:4, 场区状态:篮球; 结果:Result(code=0, message=null, data=null, requestId=null)
, fail=false, createTime=Sun Dec 15 16:03:23 CST 2024, extra=PartitionDO(venueId=1865271207637635072, name=篮球场A区, type=1, description=提供标准篮球设施,包括篮球和球架。, num=4, status=1), codeVariable={MethodName=save, ClassName=class com.vrs.controller.PartitionController})

【失败调用示例】

首先在接口中模拟一个除以 0 异常,即System.out .println(1/0);。然后在注解中添加fail = "接口调用失败,失败原因:{{#_errorMsg}}",其中#_errorMsg获取的是异常的信息

java 复制代码
@PostMapping("/save")
@LogRecord(
        bizNo = "{{#id}}",
        type = "新增分区",
        success = """
            场馆ID:{{#partitionDO.venueId}}, \
            分区名称:{{#partitionDO.name}}, \
            分区类型:{{#partitionDO.type}}, \
            描述:{{#partitionDO.description}}, \
            场区拥有的场数量:{{#partitionDO.num}}, \
            场区状态:{{#partitionDO.status}}; \
            结果:{{#_ret}}
            """,
        fail = "接口调用失败,失败原因:{{#_errorMsg}}",
        extra = "{{#partitionDO.toString()}}",
        operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}"
)
public Result save(@Validated({AddGroup.class}) @RequestBody PartitionDO partitionDO) {
    partitionService.save(partitionDO);
    // 因为 ID 是存储到数据库中才生成的,@LogRecord 默认拿不到,需要我们将信息手动设置到上下文中
    LogRecordContext.putVariable("id", partitionDO.getId());
    System.out.println(1/0);
    return Results.success();
}

调用失败之后的日志如下:

java 复制代码
【logRecord】log=LogRecord(id=null, tenant=venue, type=新增分区, subType=, bizNo=1868210725902950400, operator=admin, action=接口调用失败,失败原因:/ by zero, fail=true, createTime=Sun Dec 15 16:25:21 CST 2024, extra=PartitionDO(venueId=1865271207637635072, name=篮球场A区, type=1, description=提供标准篮球设施,包括篮球和球架。, num=4, status=1), codeVariable={MethodName=save, ClassName=class com.vrs.controller.PartitionController})

枚举类型转化为具体值

上面日志输出中,分区类型和场区状态的是具体的数字值

如果说想要将类型对应为具体的值,应该如何实现呢?

枚举类

【场馆类型枚举】

java 复制代码
package com.vrs.enums;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

/**
 * 场馆类型枚举
 */
@RequiredArgsConstructor
public enum PartitionStatusEnum {

    BASKET_BALL(1, "篮球"),
    FOOT_BALL(2, "足球"),
    BADMINTON(3, "羽毛球"),
    VOLLEYBALL(4, "排球"),
    TABLE_TENNIS(5, "乒乓球"),
    TENNIS(6, "网球"),
    SWIMMING(7, "游泳"),
    GYMNASTICS(8, "体操"),
    FITNESS_CENTER(9, "健身房"),
    HANDBALL(10, "手球"),
    ICE_SKATING(11, "滑冰"),
    SKATEBOARDING(12, "滑板"),
    CLIMBING(13, "攀岩"),
    CYCLING_INDOOR(14, "室内自行车"),
    YOGA(15, "瑜伽");

    @Getter
    private final int type;

    @Getter
    private final String value;

    /**
     * 根据 type 找到对应的 value
     *
     * @param type 要查找的类型代码
     * @return 对应的描述值,如果没有找到抛异常
     */
    public static String findValueByType(int type) {
        for (PartitionStatusEnum target : PartitionStatusEnum.values()) {
            if (target.getType() == type) {
                return target.getValue();
            }
        }
        throw new IllegalArgumentException();
    }
}

【场区状态枚举】

java 复制代码
package com.vrs.enums;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

/**
 * 场区状态枚举
 */
@RequiredArgsConstructor
public enum VenueTypeEnum {

    CLOSED(0, "已关闭"),
    OPEN(1, "营业中"),
    MAINTAIN(2, "维护中");

    @Getter
    private final int type;

    @Getter
    private final String value;

    /**
     * 根据 type 找到对应的 value
     *
     * @param type 要查找的类型代码
     * @return 对应的描述值,如果没有找到抛异常
     */
    public static String findValueByType(int type) {
        for (VenueTypeEnum target : VenueTypeEnum.values()) {
            if (target.getType() == type) {
                return target.getValue();
            }
        }
        throw new IllegalArgumentException();
    }
}

实现解析器类

转换类需要继承IParseFunction接口,然后实现两个方法

  • functionName:返回解析器的标识,后面需要在注解中使用来辨识不同的解析器
  • apply:主要用来实现解析工作,如将枚举类型转化为具体的值
java 复制代码
package com.vrs.biglog;

import com.mzt.logapi.service.IParseFunction;
import com.vrs.enums.PartitionStatusEnum;
import org.springframework.stereotype.Component;

/**
 * @Author dam
 * @create 2024/12/15 16:43
 */
@Component
public class PartitionStatusEnumParse implements IParseFunction {

    @Override
    public String functionName() {
        return "PartitionStatusEnumParse";
    }

    @Override
    public String apply(Object value) {
        return PartitionStatusEnum.findValueByType(Integer.parseInt(value.toString()));
    }
}
package com.vrs.biglog;

import com.mzt.logapi.service.IParseFunction;
import com.vrs.enums.VenueTypeEnum;
import org.springframework.stereotype.Component;

/**
 * @Author dam
 * @create 2024/12/15 16:43
 */
@Component
public class VenueTypeEnumParse implements IParseFunction {

    @Override
    public String functionName() {
        return "VenueTypeEnumParse";
    }

    @Override
    public String apply(Object value) {
        return VenueTypeEnum.findValueByType(Integer.parseInt(value.toString()));
    }
}

使用

java 复制代码
@PostMapping("/save")
@LogRecord(
        bizNo = "{{#id}}",
        type = "新增分区",
        success = """
            场馆ID:{{#partitionDO.venueId}}, \
            分区名称:{{#partitionDO.name}}, \
            分区类型:{VenueTypeEnumParse{#partitionDO.type}}, \
            描述:{{#partitionDO.description}}, \
            场区拥有的场数量:{{#partitionDO.num}}, \
            场区状态:{PartitionStatusEnumParse{#partitionDO.type}};\
            结果:{{#_ret}}
            """,
        fail = "接口调用失败,失败原因:{{#_errorMsg}}",
        extra = "{{#partitionDO.toString()}}",
        operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}"
)
public Result save(@Validated({AddGroup.class}) @RequestBody PartitionDO partitionDO) {
    partitionService.save(partitionDO);
    // 因为 ID 是存储到数据库中才生成的,@LogRecord 默认拿不到,需要我们将信息手动设置到上下文中
    LogRecordContext.putVariable("id", partitionDO.getId());
    return Results.success();
}

注意分区类型:{VenueTypeEnumParse{#partitionDO.type}}中使用了解析器的标识

重新运行之后,发现枚举类型已经转化了具体值

日志子类型划分

日志子类型划分为了区分日志的所属身份,比如说普通用户修改了数据,管理员也修改了数据。但通常指允许管理员查看用户的操作日志,不允许普通用户查看管理员的操作日志。因此可以使用subType字段来做一些区分,后面实现日志查询的时候,针对用户的身份对该字段做一些处理即可

java 复制代码
@LogRecord(
        bizNo = "{{#id}}",
        type = "新增分区",
        subType = "{{T(com.vrs.common.context.UserContext).getUserType()}}",
        success = """
                场馆ID:{{#partitionDO.venueId}}, \
                分区名称:{{#partitionDO.name}}, \
                分区类型:{VenueTypeEnumParse{#partitionDO.type}}, \
                描述:{{#partitionDO.description}}, \
                场区拥有的场数量:{{#partitionDO.num}}, \
                场区状态:{PartitionStatusEnumParse{#partitionDO.type}}; \
                结果:{{#_ret}}
                """,
        fail = "接口调用失败,失败原因:{{#_errorMsg}}",
        extra = "{{#partitionDO.toString()}}",
        operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}"
)

日志过滤

只有在满足一定条件的时候,才记录日志,可以使用condition字段,比如说用户提交的数量为null,才记录日志

java 复制代码
@LogRecord(
        bizNo = "{{#id}}",
        type = "新增分区",
        subType = "{{T(com.vrs.common.context.UserContext).getUserType()}}",
        success = """
                场馆ID:{{#partitionDO.venueId}}, \
                分区名称:{{#partitionDO.name}}, \
                分区类型:{VenueTypeEnumParse{#partitionDO.type}}, \
                描述:{{#partitionDO.description}}, \
                场区拥有的场数量:{{#partitionDO.num}}, \
                场区状态:{PartitionStatusEnumParse{#partitionDO.type}}; \
                结果:{{#_ret}}
                """,
        fail = "接口调用失败,失败原因:{{#_errorMsg}}",
        extra = "{{#partitionDO.toString()}}",
        operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}",
        condition = "{{#partitionDO.num == null}}"
)

日志持久化

数据库

sql 复制代码
DROP TABLE IF EXISTS `mt_biz_log`;
CREATE TABLE `mt_biz_log` (
  `id` bigint NOT NULL COMMENT 'ID',
  `create_time` datetime,
  `update_time` datetime,
  `is_deleted` tinyint default 0 COMMENT '逻辑删除 0:没删除 1:已删除',
  `tenant` varchar(50) DEFAULT NULL COMMENT '租户',
  `type` varchar(50) DEFAULT NULL COMMENT '类型',
  `sub_type` varchar(50) DEFAULT NULL COMMENT '子类型',
  `class_name` varchar(100) DEFAULT NULL COMMENT '方法名称',
  `method_name` varchar(100) DEFAULT NULL COMMENT '方法名称',
  `operator` varchar(50) DEFAULT NULL COMMENT '操作人员',
  `action` longtext COMMENT '操作',
  `extra` longtext COMMENT '其他补充',
  `status` tinyint DEFAULT NULL COMMENT '操作状态 (0正常 1异常)',
  PRIMARY KEY (`id`) USING BTREE
) COMMENT='操作日志表';

【实体类】

java 复制代码
package com.vrs.entity;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.vrs.domain.base.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
 * 操作日志表
 * @TableName mt_biz_log
 */
@TableName(value ="mt_biz_log")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MtBizLog extends BaseEntity implements Serializable {

    /**
     * 租户
     */
    private String tenant;

    /**
     * 类型
     */
    private String type;

    /**
     * 子类型
     */
    private String subType;

    /**
     * 方法名称
     */
    private String className;

    /**
     * 方法名称
     */
    private String methodName;

    /**
     * 操作人员
     */
    private String operator;

    /**
     * 操作
     */
    private String action;

    /**
     * 其他补充
     */
    private String extra;

    /**
     * 操作状态 (0正常 1异常)
     */
    private Integer status;

    @TableField(exist = false)
    private static final long serialVersionUID = 1L;
}

增删改查方法我这里就不再介绍了,请大家自行实现

继承存储接口

只需要实现ILogRecordService接口,然后重写record方法,然后在该方法里面调用持久化方法即可

我这里统一将所有日志记录到一个表中,如果想要根据业务分表存储,可以根据logRecord.getTenant()logRecord.getType()来判断存储到哪个表即可

java 复制代码
package com.vrs.service.impl;

import com.mzt.logapi.beans.CodeVariableType;
import com.mzt.logapi.beans.LogRecord;
import com.mzt.logapi.service.ILogRecordService;
import com.vrs.entity.MtBizLog;
import com.vrs.service.MtBizLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * @Author dam
 * @create 2024/12/15 21:02
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class BizlogStoreService implements ILogRecordService {

    private final MtBizLogService mtBizLogService;

    @Override
    public void record(LogRecord logRecord) {
        mtBizLogService.save(MtBizLog.builder()
                .tenant(logRecord.getTenant())
                .type(logRecord.getType())
                .subType(logRecord.getSubType())
                .className(logRecord.getCodeVariable().get(CodeVariableType.ClassName).toString())
                .methodName(logRecord.getCodeVariable().get(CodeVariableType.MethodName).toString())
                .operator(logRecord.getOperator())
                .action(logRecord.getAction())
                .extra(logRecord.getExtra())
                .status(logRecord.isFail() ? 1 : 0)
                .build());
    }

    @Override
    public List<LogRecord> queryLog(String bizNo, String type) {
        return null;
    }

    @Override
    public List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType) {
        return null;
    }
}
相关推荐
计算机-秋大田13 分钟前
基于微信小程序的电子竞技信息交流平台设计与实现(LW+源码+讲解)
spring boot·后端·微信小程序·小程序·课程设计
加油,旭杏2 小时前
【go语言】接口
开发语言·后端·golang
谢大旭3 小时前
ASP.NET Core 中间件
后端·中间件·c#
customer083 小时前
【开源免费】基于SpringBoot+Vue.JS景区民宿预约系统(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
SomeB1oody5 小时前
【Rust自学】15.6. RefCell与内部可变性:“摆脱”安全性限制
开发语言·后端·rust
兮动人8 小时前
Golang 执行流程分析
开发语言·后端·golang·golang 执行流程分析
码农小旋风8 小时前
如何自己设计一个类似 Dubbo 的 RPC 框架?
后端
拾忆,想起9 小时前
如何选择Spring AOP的动态代理?JDK与CGLIB的适用场景
spring boot·后端·spring·spring cloud·微服务