Mushroom Notes Mushroom Notes
🍄首页
  • JavaSE

    • 基础篇
    • 数据结构
    • IO流
    • Stream流
    • 函数式接口
    • JUC
    • 反射
    • 网络编程
    • 设计模式
  • JavaEE

    • Servlet
    • JDBC
    • 会话技术
    • 过滤器监听器
    • 三层架构
  • JDK

    • 总览
  • JVM

    • 总览
  • 常用mate
  • CSS
  • JavaScript
  • rds 数据库

    • MySQL
    • MySQL 进阶
    • MySQL 库表规范
  • nosql 数据库

    • Redis
    • Redis 进阶
    • Redis 底层
    • MongoDB
  • Spring生态

    • Spring
    • Spring MVC
    • Spring boot
    • Spring Validation
  • Spring Cloud生态

    • Spring Cloud
    • 服务治理
    • 远程调用
    • 网关路由
    • 服务保护
    • 分布式事务
    • 消息中间件
  • 数据库

    • Mybatis
    • Mybatis Plus
    • Elasticsearch
    • Redisson
  • 通信

    • Netty
📚技术
  • 方案专题
  • 算法专题
  • BUG专题
  • 安装专题
  • 网安专题
  • 面试专题
  • 常用网站
  • 后端常用
  • 前端常用
  • 分类
  • 标签
  • 归档

kinoko

一位兴趣使然的热心码农
🍄首页
  • JavaSE

    • 基础篇
    • 数据结构
    • IO流
    • Stream流
    • 函数式接口
    • JUC
    • 反射
    • 网络编程
    • 设计模式
  • JavaEE

    • Servlet
    • JDBC
    • 会话技术
    • 过滤器监听器
    • 三层架构
  • JDK

    • 总览
  • JVM

    • 总览
  • 常用mate
  • CSS
  • JavaScript
  • rds 数据库

    • MySQL
    • MySQL 进阶
    • MySQL 库表规范
  • nosql 数据库

    • Redis
    • Redis 进阶
    • Redis 底层
    • MongoDB
  • Spring生态

    • Spring
    • Spring MVC
    • Spring boot
    • Spring Validation
  • Spring Cloud生态

    • Spring Cloud
    • 服务治理
    • 远程调用
    • 网关路由
    • 服务保护
    • 分布式事务
    • 消息中间件
  • 数据库

    • Mybatis
    • Mybatis Plus
    • Elasticsearch
    • Redisson
  • 通信

    • Netty
📚技术
  • 方案专题
  • 算法专题
  • BUG专题
  • 安装专题
  • 网安专题
  • 面试专题
  • 常用网站
  • 后端常用
  • 前端常用
  • 分类
  • 标签
  • 归档
  • 方案专题

    • 认证鉴权
    • 接口规范
    • 自动分发架构
    • 高性能计数服务
    • 消息未读数服务
    • 缓存数据库双写一致性问题
    • 优雅的后台操作日志
      • 前言
      • 效果展示
      • 技术选型
      • 表设计
      • 实现细节
        • 注解
        • @ULog
        • @ULogDiff
        • @ULogTag
        • @ULogTagIgnore
        • 操作类型
        • 业务占位符
        • 日志入库模型
        • ※ 业务层
        • ※ Mapper
        • ※ 注解切面
      • 使用规则
      • 优缺点
      • 风险及优化空间
    • 签到功能
    • 秒杀库存扣减
  • 算法专题

  • BUG专题

  • 安装专题

  • 网安专题

  • 面试专题

  • 专题
  • 方案专题
kinoko
2024-01-24
目录

优雅的后台操作日志方案

# 前言

基本上只要是个SaaS系统,就会存在后台管理系统,这里就会产生很多系统操作,伴随着会有操作日志来记录这些行为,本文分享一个作者自己在公司项目上实现的方案。

提示

这是一个尝试,目前已部署在了企业项目上,还有可优化空间。

# 效果展示

image.png

使用

@ULog(success = "{{#_uname}}创建了广告【{{#req.advName}}】", type = ULogTypeEnum.INSERT, menuAlias = "advertisement")
@PostMapping("/saveDt")
@ApiOperation("广告位管理 - 广告位编辑 - 新增广告")
public Resp<Object> saveDt(@Valid @RequestBody AdvDtReq req) {
    return Resp.success(advertisementService.saveDt(req));
}
1
2
3
4
5
6

效果 image.png

使用

@ULog(success = "{{#_uname}}编辑了广告【{{#_biz_advName}}】", type = ULogTypeEnum.UPDATE, menuAlias = "advertisement",
      uLogDiff = @ULogDiff(bizId = "#req.id", bizClass = AdvertisementDtEntity.class)
     )
@PostMapping("/updateDt")
@ApiOperation("广告位管理 - 编辑广告")
public Resp<Object> updateDt(@Valid @RequestBody AdvDtReq req) {
    return Resp.success(advertisementService.updateDt(req));
}
1
2
3
4
5
6
7
8

效果 image.png

使用

@ULog(success = "{{#_uname}}在门店【{{#_biz_minsuName}}】创建了区域{{#req.roomCodeList}}", type = ULogTypeEnum.UPDATE, menuAlias = "minsu",
      uLogDiff = @ULogDiff(bizId = "#req.minsuId", bizClass = MinsuEntity.class)
     )
@PostMapping("/saveRoom")
@ApiOperation("房间信息管理 - 新增")
public Resp<Object> saveRoom(@RequestBody @Validated RoomReq req) {
    return Resp.success(roomService.save(req));
}
1
2
3
4
5
6
7
8

效果

image.png

# 技术选型

springboot+mybatis plus+swagger

# 表设计

create table if not exists user_operate_log
(
  id              bigint unsigned auto_increment     						comment '主键id' primary key,
  type            tinyint unsigned                   						not null comment '日志类型',
  user_id         bigint unsigned                    						not null comment '用户id',
  description     varchar(255)                       						not null comment '描述',
  diff            text                               						null comment '变化',
  biz_id          bigint unsigned                    						null comment '业务id',
  menu_id         bigint unsigned                    						null comment '菜单id',
  menu_name       varchar(255)                       						null comment '菜单名称',
  ip              varchar(255)                       						not null comment 'ip地址',
  path            varchar(255)                       						not null comment '请求地址',
  machine_no      varchar(255)                       						not null comment '机器号',
  request_params  text                               						null comment '请求对象',
  response_params text                               						null comment '响应对象',
  create_date     datetime            default CURRENT_TIMESTAMP not null comment '创建时间',
  is_delete       tinyint(1) unsigned default 0      						not null comment '是否已删除 0否1是'
)
    comment '用户操作日志表';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

diff存的是json字符串,结构是一个Map<String, String[]> 如:{"字段名": ["修改前", "修改后"]} {"评论内容":["水电费","水电费12312"]}

# 实现细节

日志是通过注解驱动的,在需要记录的接口上加上注解即可。

# 注解

# @ULog

说明:操作日志切面入口。

@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface ULog {

    /**
     * @return 方法执行成功后的日志模版
     */
    String success() default "";
    /**
     * @return 操作日志的类型
     */
    ULogTypeEnum type();
    /**
     * @return 是否记录日志
     */
    String condition() default "";
    /**
     * @return 菜单别名
     */
    String menuAlias() default "";

    /**
     * @return 记录差异
     */
    ULogDiff uLogDiff() default @ULogDiff(bizClass = Object.class, bizId = "null");

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

使用

@ULog(success = "{{#_uname}}创建了任务【{{#req.taskName}}】", type = ULogTypeEnum.INSERT, menuAlias = "task")
@PostMapping("/save")
@ApiOperation("任务管理 - 创建任务")
public Resp<Object> save(@RequestBody @Valid TaskReq req) {
    return Resp.success(taskService.save(req));
}
1
2
3
4
5
6

原理是通过menuAlias别名查询菜单模块进行模块归属

# @ULogDiff

说明:变化注解,用于记录业务实体的在操作前后的变化。

@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.ANNOTATION_TYPE})
public @interface ULogDiff {

    /**
     * @return 日志绑定的业务标识
     */
    String bizId();

    /**
     * @return 业务类
     */
    Class<?> bizClass();

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

使用

@ULog(success = "{{#_uname}}修改了云空间文件【{{#_biz_folderName}}】", type = ULogTypeEnum.UPDATE,
      uLogDiff = @ULogDiff(bizId = "#req.folderId", bizClass = RecordCloudEntity.class)
     )
@PostMapping("/update")
@ApiOperation("作业云空间 - 修改")
public Resp<Object> update(@RequestBody @Validated RecordCloudUpdateReq req) {
    return Resp.success(recordCloudService.update(req));
}
1
2
3
4
5
6
7
8

原理是通过bizClass的类型找到mybatis的@TableId标识的id字段进行主键查询实体,进而做到修改前后的比较或者是获取业务主体的某个属性值。

# @ULogTag

说明:字段名别名注解,用于标识字段名的别名

@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
public @interface ULogTag {

    /**
     * 别名
     * @return 别名
     */
    String alias();

}
1
2
3
4
5
6
7
8
9
10
11
12
13

使用

@ULogTag(alias = "操作类型")
@ApiModelProperty(value = "日志类型")
private Integer type;
1
2
3

# @ULogTagIgnore

说明:字段忽略注解,标注后将不记录修改后的差异

@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
public @interface ULogTagIgnore {}
1
2
3
4
5

使用

@ULogTagIgnore
@ApiModelProperty(value = "创建人")
public Long createBy;
1
2
3

# 操作类型

@Getter
public enum ULogTypeEnum {

    LOGIN(1, "登录"),
    LOGOUT(2, "登出"),
    INSERT(3, "新增"),
    DELETE(4, "删除"),
    UPDATE(6, "编辑"),
    ENABLE(7, "启用/禁用"),
    EXPORT(8, "导出"),
    ;
    private final int code;
    private final String desc;

    ULogTypeEnum(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public static ULogTypeEnum getEnum(int code) {
        for (ULogTypeEnum type : ULogTypeEnum.values()) {
            if (type.getCode() == code) {
                return type;
            }
        }
        return null;
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 业务占位符

@Getter
public enum ULogUniqueTextEnum {

    USER_NAME("#_uname", "用户名称"),
    BIZ_NAME("#_biz_", "业务名称"),
    ;
    private final String text;
    private final String desc;

    ULogUniqueTextEnum(String code, String desc) {
        this.text = code;
        this.desc = desc;
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 日志入库模型

public class ULogDTO extends UserOperateLogEntity {

    @ApiModelProperty(value = "菜单别名")
    private String menuAlias;

    @ApiModelProperty(value = "操作前的业务实体")
    private List<Map<String, Object>> beforeEntity;

    @ApiModelProperty(value = "操作后的业务实体")
    private Map<String, Object> afterEntity;

    @ApiModelProperty(value = "业务实体类")
    private Class<?> bizClass;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@TableName("user_operate_log")
@ApiModel(value = "UserOperateLogEntity对象", description = "用户操作日志表")
public class UserOperateLogEntity {
    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "主键id")
    @TableId(value = "id", type = IdType.ASSIGN_ID)
    private Long id;

    @ApiModelProperty(value = "日志类型")
    private Integer type;

    @ApiModelProperty(value = "用户id")
    private Long userId;

    @ApiModelProperty(value = "描述")
    @TableField(value = "description")
    private String desc;

    @ApiModelProperty(value = "业务id")
    private Long bizId;

    @ApiModelProperty(value = "菜单id")
    private Long menuId;

    @ApiModelProperty(value = "菜单名称")
    private String menuName;

    @ApiModelProperty(value = "ip地址")
    private String ip;

    @ApiModelProperty(value = "请求地址")
    private String path;

    @ApiModelProperty(value = "请求对象")
    private String requestParams;

    @ApiModelProperty(value = "响应对象")
    private String responseParams;

    @ApiModelProperty(value = "操作明细")
    private String diff;

    @ApiModelProperty(value = "机器号")
    private String machineNo;

    @ApiModelProperty("创建时间")
    public Date createDate;

    @ULogTagIgnore
    @JsonIgnore
    @TableLogic
    private Integer isDelete;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

# ※ 业务层

Service

public interface UserOperateLogService extends BaseService<UserOperateLogEntity> {

    /**
     * 入库
     * @param uLogDTO 日志对象
     */
    void saveLog(ULogDTO uLogDTO);

    /**
     * 查询对象
     * @param aClass 实体类
     * @param bizId 业务id
     * @return 实体类
     */
    Map<String, Object> queryObj(Class<?> aClass, Long bizId);

    /**
     * 查询对象
     * @param aClass 实体类
     * @param bizIds 业务id
     * @return 实体类
     */
    List<Map<String, Object>> queryObj(Class<?> aClass, List<Long> bizIds);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

ServiceImpl

业务层实现
@Service
@Slf4j
public class UserOperateLogServiceImpl extends BaseServiceImpl<UserOperateLogMapper, UserOperateLogEntity> implements UserOperateLogService {

    @Resource
    private CenterMenuService centerMenuService;
    @Resource
    private SysUserService sysUserService;

    /**
     * 入库
     * @param uLogDTO 日志对象
     */
    @Override
    public void saveLog(ULogDTO uLogDTO) {
        // 检查是否存在用户名占位符
        checkAndReplaceUserName(uLogDTO);
        // 检查是否存在业务名称占位符
        checkAndReplaceBizName(uLogDTO);
        // 组装日志对象
        UserOperateLogEntity logEntity = DataUtils.convertToOne(uLogDTO, UserOperateLogEntity.class);
        // 填充菜单
        if (StringUtils.isNotBlank(uLogDTO.getMenuAlias())) {
            // 查询对应的菜单
            List<Menu> list = centerMenuService.lambdaQuery()
                    .eq(Menu::getCode, uLogDTO.getMenuAlias())
                    .list();
            if (CollectionUtils.isNotEmpty(list)) {
                // 取第一位
                Menu menu = list.get(0);
                logEntity.setMenuId(menu.getId());
                logEntity.setMenuName(menu.getName());
            }
        }
        // 填充实体变化
        if (CollectionUtils.isNotEmpty(uLogDTO.getBeforeEntity()) && uLogDTO.getAfterEntity()!= null) {
            // 获取实体属性别名
            Map<String, String> aliasMap = getAliasMap(uLogDTO.getBizClass());
            // 比较变化
            Map<String, String[]> mapDiff = compareMapDiff(aliasMap, uLogDTO.getBeforeEntity().get(0), uLogDTO.getAfterEntity());
            if (!mapDiff.isEmpty()) {
                // 填充变化
                logEntity.setDiff(JSON.toJSONString(mapDiff));
            }
        }
        this.save(logEntity);
    }

    /**
     * 检查是否存在业务名称占位符,如果存在,替换
     * @param uLogDTO 日志对象
     */
    private void checkAndReplaceUserName(ULogDTO uLogDTO) {
        if (uLogDTO.getDesc().contains(ULogUniqueTextEnum.USER_NAME.getText())) {
            SysUserEntity user = sysUserService.getByIdFromCache(uLogDTO.getUserId());
            // 替换用户名占位符
            uLogDTO.setDesc(uLogDTO.getDesc().replace("{{" + ULogUniqueTextEnum.USER_NAME.getText() + "}}", user != null ? user.getName() : ""));
        }
    }

    /**
     * 检查是否存在业务名称占位符,如果存在,替换
     * @param uLogDTO 日志对象
     */
    private static void checkAndReplaceBizName(ULogDTO uLogDTO) {
        // 检查是否存在业务名称占位符
        if (uLogDTO.getDesc().contains(ULogUniqueTextEnum.BIZ_NAME.getText()) && CollectionUtils.isNotEmpty(uLogDTO.getBeforeEntity())) {
            // 按key分组,拼接value
            Map<String, String> bizNameMap = uLogDTO.getBeforeEntity().stream()
                    .flatMap(map -> map.entrySet().stream())
                    .collect(Collectors.groupingBy(Map.Entry::getKey,
                            Collectors.mapping(entry -> String.valueOf(entry.getValue()), Collectors.joining("、"))));
            String desc = uLogDTO.getDesc();
            // 替换业务名称占位符
            for (Map.Entry<String, String> entry : bizNameMap.entrySet()) {
                desc = desc.replace("{{" + ULogUniqueTextEnum.BIZ_NAME.getText() + StrUtil.toCamelCase(entry.getKey()) + "}}", entry.getValue());
            }
            uLogDTO.setDesc(desc);
        }
    }

    /**
     * 获取实体属性别名
     * @param bizClass 实体类
     * @return 别名map
     */
    private static Map<String, String> getAliasMap(Class<?> bizClass) {
        // 查询实体属性别名
        Map<String, String> aliasMap;
        if (bizClass != null && !bizClass.equals(Object.class)) {
            // 获取实体类所有属性(包括父类)
            aliasMap = ObjectUtil.getAllFields(bizClass).stream()
                    // 过滤掉忽略的属性
                    .filter(field -> !field.isAnnotationPresent(ULogTagIgnore.class))
                    .filter(field -> field.isAnnotationPresent(ApiModelProperty.class) || field.isAnnotationPresent(ULogTag.class))
                    .collect(Collectors.toMap(field -> {
                        TableField tableField = field.getAnnotation(TableField.class);
                        if (tableField != null && StringUtils.isNotBlank(tableField.value())) {
                            return tableField.value();
                        }
                        return field.getName();
                    }, field -> {
                        ULogTag uLogTag = field.getAnnotation(ULogTag.class);
                        // 优先使用别名
                        if (uLogTag != null) {
                            return uLogTag.alias();
                        }
                        return field.getAnnotation(ApiModelProperty.class).value();
                    }));
        } else {
            aliasMap = new HashMap<>();
        }
        return aliasMap;
    }

    /**
     * 比较两个map的差异
     * @param aliasMap 别名map
     * @param oldMap   旧map
     * @param newMap   新map
     * @return 差异map
     */
    private Map<String, String[]> compareMapDiff(Map<String, String> aliasMap, Map<String, Object> oldMap, Map<String, Object> newMap) {
        Map<String, String[]> result = new HashMap<>();
        for (Map.Entry<String, Object> entry : oldMap.entrySet()) {
            String key = entry.getKey();
            // 转驼峰
            String camelKey = StrUtil.toCamelCase(key);
            // 排除不存在名称的属性
            if (!aliasMap.containsKey(camelKey)) {
                continue;
            }
            Object oldValue = entry.getValue();
            Object newValue = newMap.get(key);
            // 不相等
            if (!Objects.equals(oldValue, newValue)) {
                result.put(aliasMap.getOrDefault(camelKey, camelKey), new String[]{String.valueOf(oldValue), String.valueOf(newValue)});
            }
        }
        return result;
    }

    /**
     * 查询对象
     * @param aClass 实体类
     * @param bizId  业务id
     * @return 实体类
     */
    @Transactional(rollbackFor = RuntimeException.class)
    @Override
    public Map<String, Object> queryObj(Class<?> aClass, Long bizId) {
        // 获取表名
        TableName tableName = aClass.getAnnotation(TableName.class);
        // 没有找到表名
        if (tableName == null) {
            return null;
        }
        // 获取id
        Optional<TableId> tableId = Arrays.stream(aClass.getDeclaredFields())
                .filter(field -> field.isAnnotationPresent(TableId.class))
                .map(field -> field.getAnnotation(TableId.class))
                .findAny();
        // 查询对象,若id为空直接返回null
        return tableId.map(id -> getBaseMapper().findObjById(tableName.value(), StringUtils.isBlank(id.value()) ? "id" : id.value(), bizId)).orElse(null);
    }

    /**
     * 查询对象
     * @param aClass 实体类
     * @param bizIds 业务id
     * @return 实体类
     */
    @Transactional(rollbackFor = RuntimeException.class)
    @Override
    public List<Map<String, Object>> queryObj(Class<?> aClass, List<Long> bizIds) {
        // 获取表名
        TableName tableName = aClass.getAnnotation(TableName.class);
        // 没有找到表名
        if (tableName == null) {
            return null;
        }
        // 获取id
        Optional<TableId> tableId = Arrays.stream(aClass.getDeclaredFields())
                .filter(field -> field.isAnnotationPresent(TableId.class))
                .map(field -> field.getAnnotation(TableId.class))
                .findAny();
        // 查询对象,若id为空直接返回null
        return tableId.map(id -> getBaseMapper().findObjByIds(tableName.value(), StringUtils.isBlank(id.value()) ? "id" : id.value(), bizIds)).orElse(null);
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191

# ※ Mapper

@Mapper
public interface UserOperateLogMapper extends BaseMapper<UserOperateLogEntity> {
    
    @Select("select * from ${tableName} where ${idField} = ${id} and is_delete = 0 for update")
    Map<String, Object> findObjById(@Param("tableName") String tableName, @Param("idField") String idField, @Param("id") Long id);

    /**
     * 根据业务id集合查询对象
     * @param tableName 表名
     * @param idField 主键字段
     * @param bizIds 业务id集合
     * @return 对象列表
     */
    List<Map<String, Object>> findObjByIds(@Param("tableName") String tableName, @Param("idField") String idField, @Param("bizIds") List<Long> bizIds);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ydhl.hxpx.api.uLog.mapper.UserOperateLogMapper">

  <!-- 根据业务id集合查询对象 -->
  <select id="findObjByIds" resultType="java.util.Map">
    select * from ${tableName} where ${idField} in
    <foreach collection="bizIds" item="id" open="(" separator="," close=")">
      #{id}
    </foreach>
    and is_delete = 0 for update
  </select>
  
</mapper>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# ※ 注解切面

切面类实现
@Slf4j
@Aspect
@Component
public class ULogAop {

    // 创建参数名称解析器
    private static final DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
    // 创建spel表达式解析器
    private static final SpelExpressionParser parser = new SpelExpressionParser();

    @Resource
    private UserOperateLogService userOperateLogService;
    @Resource
    private ThreadPoolExecutor threadPoolExecutor;

    @Around("@annotation(uLog)")
    public Object around(ProceedingJoinPoint joinPoint, ULog uLog) throws Throwable {
        // 获取请求参数
        HttpServletRequest currentRequest = RequestUtil.getCurrentRequest();
        // 获取操作用户id
        Long userId = LoginUtil.getUserId();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        // 获取切面方法
        Method method = signature.getMethod();
        // 获取参数名称
        String[] parameterNames = parameterNameDiscoverer.getParameterNames(method);
        // 获取参数
        Object[] parameterValues = joinPoint.getArgs();
        // 解析记录条件
        Optional<Boolean> condition = parseSpel(uLog.condition(), Boolean.class, parameterNames, parameterValues);
        // 判断是否需要记录
        boolean needLog = !condition.isPresent() || condition.get();
        // 准备差异记录数据
        Optional<List<Long>> bizIds;
        Optional<List<Map<String, Object>>> beforeEntity;
        ULogDiff uLogDiff = uLog.uLogDiff();
        // 判断是否需要记录差异
        if (needLog && !"null".equals(uLogDiff.bizId()) && !Object.class.equals(uLogDiff.bizClass())) {
            // 解析业务id
            bizIds = parseBizId(uLogDiff.bizId(), parameterNames, parameterValues);
            // 查询操作前的业务对象
            beforeEntity = bizIds.map(id -> userOperateLogService.queryObj(uLogDiff.bizClass(), id));
        } else {
            bizIds = Optional.empty();
            beforeEntity = Optional.empty();
        }

        // 执行业务方法
        Object target = joinPoint.proceed();

        // 判断是否需要记录
        if (needLog) {
            String ip = currentRequest.getRemoteAddr();
            String url = currentRequest.getRequestURL().toString();
            // ※ 机器号好像无法通过请求获取,目前使用ip代替
            String machineNo = currentRequest.getRemoteHost();
            // 异步入库
            CompletableFuture.runAsync(() -> {
                try {
                    ULogDTO uLogDTO = new ULogDTO();
                    // 查询操作后的业务对象
                    bizIds.ifPresent(id -> {
                        // 当id只有一个时,认为是对单操作需要记录变化
                        if (id.size() == 1) {
                            uLogDTO.setAfterEntity(userOperateLogService.queryObj(uLogDiff.bizClass(), id.get(0)));
                            uLogDTO.setBizId(id.get(0));
                        }
                    });
                    // 填充操作前的业务对象
                    beforeEntity.ifPresent(uLogDTO::setBeforeEntity);
                    uLogDTO.setBizClass(uLogDiff.bizClass());

                    uLogDTO.setDesc(parseDesc(uLog, parameterNames, parameterValues));
                    uLogDTO.setType(uLog.type().getCode());
                    uLogDTO.setIp(ip);
                    uLogDTO.setPath(url);
                    uLogDTO.setUserId(userId);
                    uLogDTO.setMenuAlias(uLog.menuAlias());
                    // 导出不记录接参出参
                    if (!uLog.type().equals(ULogTypeEnum.EXPORT)) {
                        uLogDTO.setRequestParams(JSON.toJSONString(parameterValues));
                        uLogDTO.setResponseParams(JSON.toJSONString(target));
                    }
                    uLogDTO.setMachineNo(machineNo);
                    userOperateLogService.saveLog(uLogDTO);
                } catch (Exception e) {
                    log.error("[用户操作日志] 捕获异常:", e);
                }
            }, threadPoolExecutor);
        }
        return target;
    }

    /**
     * 解析业务id
     * @param spel            spel表达式
     * @param parameterNames  参数名称
     * @param parameterValues 参数值
     * @return 解析结果
     */
    private static Optional<List<Long>> parseBizId(String spel, String[] parameterNames, Object[] parameterValues) {
        if (StringUtils.isNotBlank(spel) && parameterNames != null && parameterNames.length != 0) {
            // 创建评估上下文
            EvaluationContext evaluationContext = new StandardEvaluationContext();
            // 为表达式设置参数变量
            for (int i = 0; i < parameterValues.length; ++i) {
                evaluationContext.setVariable(parameterNames[i], parameterValues[i]);
            }
            Expression expression = parser.parseExpression(spel);
            Object value = expression.getValue(evaluationContext);
            // 匹配业务id类型
            return matchBizIdType(value);
        }
        return Optional.empty();
    }

    /**
     * 匹配业务id类型
     * @param value 业务id
     * @return 解析结果
     */
    private static Optional<List<Long>> matchBizIdType(Object value) {
        // 如果是Long类型,直接返回
        if (value instanceof Long) {
            return Optional.of(Collections.singletonList((Long) value));
        }
        // 如果是Integer类型,转Long返回
        if (value instanceof Integer) {
            return Optional.of(Collections.singletonList(Long.valueOf((Integer) value)));
        }
        // 如果是List类型,转Long返回
        if (value instanceof List) {
            if (((List<?>) value).get(0) instanceof Long) {
                return Optional.of((List<Long>) value);
            } else if (((List<?>) value).get(0) instanceof Integer) {
                return Optional.of(((List<Integer>) value).stream().map(Long::valueOf).collect(Collectors.toList()));
            }
        }
        // 如果是long[]类型,转为List<Long>
        if (value instanceof Long[]) {
            return Optional.of(Arrays.asList((Long[]) value));
        }
        // 如果是int[]类型,转为List<Long>
        if (value instanceof Integer[]) {
            return Optional.of(Arrays.stream((Integer[]) value).mapToLong(Long::valueOf).boxed().collect(Collectors.toList()));
        }
        return Optional.empty();
    }

    /**
     * 解析spel表达式
     * @param spel            spel表达式
     * @param clazz           类型
     * @param parameterNames  参数名称
     * @param parameterValues 参数值
     * @param <T>             泛型
     * @return 解析结果
     */
    private static <T> Optional<T> parseSpel(String spel, Class<T> clazz, String[] parameterNames, Object[] parameterValues) {
        if (StringUtils.isNotBlank(spel) && parameterNames != null && parameterNames.length != 0) {
            // 创建评估上下文
            EvaluationContext evaluationContext = new StandardEvaluationContext();
            // 为表达式设置参数变量
            for (int i = 0; i < parameterValues.length; ++i) {
                evaluationContext.setVariable(parameterNames[i], parameterValues[i]);
            }
            Expression expression = parser.parseExpression(spel);
            return Optional.ofNullable(expression.getValue(evaluationContext, clazz));
        }
        return Optional.empty();
    }


    /**
     * 解析日志描述
     * @param uLog 日志注解
     * @return 日志描述
     */
    private String parseDesc(ULog uLog, String[] parameterNames, Object[] parameterValues) {
        String desc = uLog.success();
        if (StringUtils.isBlank(desc)) {
            desc = uLog.type().getDesc();
        }
        // 拼接参数
        if (StringUtils.isNotBlank(desc) && parameterNames != null && parameterNames.length != 0) {
            // 解析spel表达式
            List<String> spelExpressions = extractSpelFromSuccess(desc);
            // 创建评估上下文
            EvaluationContext evaluationContext = new StandardEvaluationContext();
            // 为表达式设置参数变量
            for (int i = 0; i < parameterValues.length; ++i) {
                evaluationContext.setVariable(parameterNames[i], parameterValues[i]);
            }
            // 替换占位符
            for (String expression : spelExpressions) {
                Optional<Object> value = Optional.ofNullable(parser.parseExpression(expression).getValue(evaluationContext));
                if (value.isPresent()) {
                    desc = replaceSpelExpression(desc, expression, value.get().toString());
                }
            }
        }
        return desc;
    }

    /**
     * 从日志描述中提取spel表达式
     * @param success 输入字符串
     * @return 表达式列表
     */
    public static List<String> extractSpelFromSuccess(String success) {
        List<String> expressions = new ArrayList<>();
        Pattern pattern = Pattern.compile("\\{\\{([^}]+)}}");
        Matcher matcher = pattern.matcher(success);

        while (matcher.find()) {
            expressions.add(matcher.group(1));
        }

        return expressions;
    }

    /**
     * 替换spel表达式
     * @param input       输入字符串
     * @param expression  表达式
     * @param replacement 替换字符串
     * @return 替换后的字符串
     */
    public static String replaceSpelExpression(String input, String expression, String replacement) {
        return input.replace("{{" + expression + "}}", replacement);
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233

# 使用规则

注解设计上已经包含了许多默认值,只传入必传参数时即可达到最基本的日志记录功能,例如:

@ULog(success = "{{#_uname}}创建了广告【{{#req.advName}}】", type = ULogTypeEnum.INSERT, menuAlias = "advertisement")
@PostMapping("/saveDt")
@ApiOperation("广告位管理 - 广告位编辑 - 新增广告")
public Resp<Object> saveDt(@Valid @RequestBody AdvDtReq req) {
    return Resp.success(advertisementService.saveDt(req));
}
1
2
3
4
5
6

这样记录的就是很简单的一个文案。

但是如果使用了#_biz_就必须使用@ULogDiff注解标识业务id,例如:

@ULog(success = "{{#_uname}}在门店【{{#_biz_minsuName}}】创建了区域{{#req.roomCodeList}}", type = ULogTypeEnum.UPDATE, menuAlias = "minsu",
      uLogDiff = @ULogDiff(bizId = "#req.minsuId", bizClass = MinsuEntity.class)
     )
@PostMapping("/saveRoom")
@ApiOperation("房间信息管理 - 新增")
public Resp<Object> saveRoom(@RequestBody @Validated RoomReq req) {
    return Resp.success(roomService.save(req));
}
1
2
3
4
5
6
7
8

@ULogDiff的原理是通过bizClass的类型找到mybatis的@TableId标识的id字段进行主键查询实体,进而获取到业务实体的某个属性值。 而#_biz_后面跟的就是实体的属性名,如上#_biz_minsuName对应的就是MinsuEntity.class中的:

@ApiModelProperty(value = "门店名称")
private String minsuName;
1
2

# 优缺点

优点:扩展性强、可以做到细粒度记录

缺点:

  1. 需要遵循一定规则,对于已成型的项目追加功能的话需要一个个接口添加注解,工作量可能较大。
  2. 目前版本的实体变化比较只能记录简单的实体,例如前端编辑视图是由多个实体组合而成,只能记录到其中一个实体的变化。

# 风险及优化空间

  • 存在一定的并发问题,如果两个人同时修改同一条数据可能会出现一遍记录到前者修改前的数据,但考虑到对业务影响不大,以及当前系统中这种情况较少所以未做处理,如果需要的话可以加入CAS去判断,就是利用mybatis+表数据版本号字段来判断当前记录是否在更新过程中被其他请求修改。
  • spring的spel表达式解析可能存在性能瓶颈,可以用Caffeine对解析结果做本地缓存,不过后台管理系统的并发量一般不会很大,造成不了明显的使用体验。
  • 对于只能记录单个实体变化的问题,有两个思路,一个是将@Ulog中的@ULogDiff改为数组,从而记录多个实体,或者是指定查询前端视图的方法,从而比较修改前后前端视图实体的变化。
#操作日志#aop
上次更新: 2024/01/24 11:49:27
缓存数据库双写一致性问题
签到功能

← 缓存数据库双写一致性问题 签到功能→

最近更新
01
JVM 底层
09-13
02
JVM 理论
09-13
03
JVM 应用
09-13
更多文章>
Theme by Vdoing | Copyright © 2022-2024 kinoko | MIT License | 粤ICP备2024165634号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式