# fox-logrecord **Repository Path**: mamba-framework/fox-logrecord ## Basic Information - **Project Name**: fox-logrecord - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-04-02 - **Last Updated**: 2025-04-02 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 前章 ## 应用场景 项目中我们会遇到这样的需求,记录核心业务表所有操作记录(数据变化),用来做安全审计,记录谁在什么时候改变了什么数据,并将操作记录入库提供查询。操作记录本质是比较前端传入字段和数据库字段进行比较,将变更记录到数据库。 ![](doc/markdown/rp.png) 问题: 1. po或dto的哪些字段要进行比较? 2. po或dto的字段如果是枚举、字典、集合等复杂字段,比如性别,数据库存的1|2,前端要展示男|女,如何统一处理? 3. oldObj如何获取,新增的时候,数据库没有记录;编辑的时候,怎么获取id去查对应的记录? 4. oldObj和newObj的比较,要嵌入到各个业务代码里么? ## 处理流程 ![](doc/markdown/process.png) # 项目结构 ``` - fox-logrecord - ├── fox-logrecord-core -- 操作记录核心模块 - ├── fox-logrecord-extension -- 操作记录扩展模块(非必须,可自己扩展) - ├ ├── fox-logrecord-extension-mybatis -- Mybatis plus公共配置,基类等 - ├ ├── fox-logrecord-extension-dictionary -- Mybatis plus实现core模块中的字典接口 - ├ ├── fox-logrecord-extension-operation -- Mybatis plus实现core模块中的操作记录入库 - ├── fox-logrecord-demo -- Springboot测试工程 ``` ## fox-logrecord-core【必须】 **fox-logrecord-core**基于切面+注解解决了上述问题,可以无侵入的记录操作,针对复杂字段的中文描述做了统一的处理,并且可扩展。 涉及的技术点: 1. Spring aop 2. Spel表达式 3. Reflect反射 4. 模板方法模式 5. Disruptor队列 6. Mybatis TypeHandler ## fox-logrecord-extension【可选】 **fox-logrecord-extension**是基于Mybatis plus扩展实现了**fox-logrecord-core**的两个接口: 1. ObjectDiffService:操作记录比较入库接口 2. DictionaryTranslator:字典查询接口 使用者可以自己基于Springboot AutoConfiguration实现这两个接口 ## fox-logrecord-extension-mybatis Mybatis plus公共配置,基类等。如果使用了**fox-logrecord-extension**,则需要实现接口: 1. ResourceOwnerContext:获取当前登录对象Principal # 表设计(DDL sql见工程/doc/db文件夹) ## 核心表 operation:操作记录主表,business_id是业务数据id operation_field:存哪些字段修改的详情 ## demo表 user:demo工程的测试表 dict:字典表 dict_item:字典项表。修改字段是字典的,根据code从这里取字典中文 ![](doc/markdown/er.png) # UML类图 ![](doc/markdown/uml.png) ## 使用说明 ### po或dto属性添加注解@LogRecordField或@LogRecordFieldObj ```java @Target({ElementType.FIELD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface LogRecordField { /** 被标注的字段的中文名 */ String value() default ""; /** 空值描述 */ String nullDesc() default "空"; /** 填充策略 */ FieldStrategy fieldStrategy() default FieldStrategy.DEFAULT; /** 修改描述翻译(基本数据类型) */ Class translator() default Translator.None.class; /** 自定义字段比较模板 */ Class translatorTemplate() default TranslatorTemplate.None.class; } ``` ```java @Target({ElementType.FIELD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface LogRecordFieldObj { /** 被标注的字段的中文名 */ String value() default ""; /** 实体类中的对象属性的Class,用来反射创建对象 */ Class clazz() default Object.class; } ``` 下面示例了 1. 普通字段:@LogRecordField(value = "用户名"),标记字段中文名即可 2. 枚举字段:@LogRecordField(value = "性别", translator = EnumTranslator.class) ,基于EnumDefinition的注解,要添加转换器translator = EnumTranslator.class 3. 字典字段:@LogRecordField(value = "用户来源"),标记字段中文名即可,会根据@Dictionary(DictionaryEnum.Names.UserSource) 找字典中文 4. List比较器:@LogRecordField(value = "用户角色", translator = ListTranslator.class),ListTranslator中,将List转为json string输出比较 5. 自定义比较器:@LogRecordField(value = "地址", translatorTemplate = AddressTranslatorTemplate.class) ,在AddressTranslatorTemplate中,开发人员根据Object oldObject, Object newObject比较差异 6. 对象比较器:@LogRecordFieldObj(value = "地址", clazz = Address.class) ,在嵌入对象上添加@LogRecordFieldObj,Address内部使用@LogRecordField ```java @JsonIgnoreProperties(ignoreUnknown = true) @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor @ToString @ApiModel(description = "用户表") public class UserDto { private Long id; @LogRecordField(value = "用户名") @ApiModelProperty(value = "用户名") private String username; @ApiModelProperty(value = "密码") @JsonIgnore private String password; @LogRecordField(value = "手机号") @ApiModelProperty(value = "手机号") private String cellphone; @LogRecordField(value = "邮箱") @ApiModelProperty(value = "邮箱") private String email; @Max(100) @Min(1) @ApiModelProperty(value = "年龄") @LogRecordField(value = "年龄", fieldStrategy = FieldStrategy.UPDATE) private Integer age; @LogRecordField(value = "用户来源") @Dictionary(DictionaryEnum.Names.UserSource) @ApiModelProperty(value = "用户来源") private String source; @LogRecordField(value = "用户类型", translator = EnumTranslator.class) @ApiModelProperty(value = "用户类型") private SexEnum sex; @LogRecordField(value = "用户角色", translator = ListTranslator.class) @ApiModelProperty(value = "用户角色") private List roles; @LogRecordField(value = "是否启用", translator = BoolTranslator.class) @ApiModelProperty(value = "是否启用") private Boolean enabled; @LogRecordFieldObj(value = "地址", clazz = Address.class) @ApiModelProperty(value = "地址") private Address address; @LogRecordField(value = "家庭成员", translatorTemplate = UserFamilyTranslatorTemplate.class) private List userFamilyList; } ``` #### 框架内置字段显示名转换器Translator 1. BoolTranslator 2. ListTranslator 3. EnumTranslator 要想扩展其他类型,实现interface Translator即可 ```java public interface Translator { /** * 将输入IN转成输出OUT * @param var * @return */ OUT translate(IN var); /** * 用来作为注解的默认值 */ abstract class None implements Translator { public None() { } } } ``` #### 自定义翻译模板(抽象模板) ```java public abstract class TranslatorTemplate { /** * 自定义翻译器抽象模板方法 * @param oldObject * @param newObject * @return */ public abstract List translate(Object oldObject, Object newObject, LogRecordField logRecordField); public abstract class None extends TranslatorTemplate { @Override public List translate(Object oldObject, Object newObject, LogRecordField logRecordField) { return new ArrayList<>(); } } } ``` 注意 1. 自己根据oldObject,newObject判断是否字段有修改,构造List,处理EditType 2. fieldDiffDTO的setName(), setFieldName()可以不塞(根据实际情况),ObjectDiffUtil会塞值 ```java public class TestTranslatorTemplate extends TranslatorTemplate { @Override public List translate(Object oldObject, Object newObject, LogRecordField logRecordField) { FieldDiffDTO fieldDiffDTO = new FieldDiffDTO(); fieldDiffDTO.setFieldName("age") .setName("年龄") .setOldValue(12) .setNewValue(13) .setNewValueShow(13) .setOldValueShow(12) .setEditType(EditType.UPDATE); return ListUtil.of(fieldDiffDTO); } } ``` ### service方法添加@LogRecord ```java @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface LogRecord { /** 必填,业务主键spel表达式 */ String key() default ""; /** 必填,业务模块 */ String module() default ""; /** 必填,描述文本 */ String desc() default ""; /** 必填,操作类型,使用枚举LogOperate.Type.APPLY */ String operateType() default ""; /** 修改时候必填,获取数据库记录的spel表达式。 */ String method() default ""; } ``` 最终LogRecordAop中会获取到比较结果,通过ObjectDiffService接口的save()方法入库 #### 新增service接口 - key = "#result.id",使用Spel从返回值中取businessId。 - 接口返回新增的entity,传给Spel表达式。 ```java @LogRecord(key = "#result.id", module = LogModule.Type.USER, desc = "新增用户", operateType = LogOperate.Type.SAVE) @Transactional(rollbackFor = Exception.class) @Override public UserDto insert(@LogRecordModel("userDto") UserDto userDto) { User user = BeanUtil.toBean(userDto, User.class); user.setAddress(JSON.toJSONString(userDto.getAddress())); this.save(user); userDto.setId(user.getId()); if (CollectionUtil.isNotEmpty(userDto.getUserFamilyList())) { userDto.getUserFamilyList().forEach(userFamily -> { userFamily.setUserId(user.getId()); }); userFamilyService.saveBatch(userDto.getUserFamilyList()); } return userDto; } ``` #### 更新service接口 - key = "#userDto.id",Spel从入参中获取数据库id,传递给method = "@userMapper.selectById(#root)"第二个Spel表达式,查询数据库记录。 - method的参数#root由key = "#userDto.id"获取,这里填写#root即可。 - 接口返回entity,作为method = "@userMapper.selectById(#root)"的转换类型 ```java @LogRecord(key = "#userDto.id", module = LogModule.Type.USER, desc = "更新用户", operateType = LogOperate.Type.UPDATE, method = "@userServiceImpl.findById(#root)") @Transactional(rollbackFor = Exception.class) @Override public UserDto edit(@LogRecordModel("userDto") UserDto userDto) { User user = BeanUtil.toBean(userDto, User.class); user.setAddress(JSON.toJSONString(userDto.getAddress())); this.updateById(user); if (CollectionUtil.isNotEmpty(userDto.getUserFamilyList())) { userFamilyService.lambdaUpdate().eq(UserFamily::getUserId, userDto.getId()).set(UserFamily::getDeleted, true).update(); userDto.getUserFamilyList().forEach(userFamily -> { userFamily.setUserId(user.getId()); }); userFamilyService.saveBatch(userDto.getUserFamilyList()); } return userDto; } ``` #### service方法参数添加@LogRecordModel @LogRecordModel用来标记newObj ```java @Override public UserDto insert(@LogRecordModel("userDto") UserDto userDto) ``` # 测试 ## 安装环境 - JDK 1.8+ - Mysql 5.7+ ## 配置管理 1. 执行工程**/doc/db**下的sql,初始化数据库 2. 修改**fox-logrecord-demo**中application-dev.yml的mysql url, username, password 3. 导入工程**/doc/apifox**中的接口测试用例到Apifox(非必要) [Apifox下载地址]: https://apifox.com/ ### 新增用户 POST http://127.0.0.1:8080/users 请求参数 ```java { "source":"1", "age":35, "address":{ "country":"中国", "province":"云南省", "city":"六盘水市" }, "roles":[ "admin", "teacher" ], "sex":"1", "email":"12345@qq.com", "cellphone":"18657158538", "username":"李四" } ``` 日志打印ObjectDiffDTO ```java { "businessId":"5", "description":"新增用户", "fieldDiffDTOList":[ { "editType":"SAVE", "fieldName":"country", "name":"国家", "newValue":"中国", "newValueShow":"中国", "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"province", "name":"省", "newValue":"云南省", "newValueShow":"云南省", "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"city", "name":"市", "newValue":"六盘水市", "newValueShow":"六盘水市", "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"sex", "name":"用户类型", "newValue":"Female", "newValueShow":"男性", "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"roles", "name":"用户类型", "newValue":[ "admin", "teacher" ], "newValueShow":"[\"admin\",\"teacher\"]", "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"cellphone", "name":"手机号", "newValue":"18657158538", "newValueShow":"18657158538", "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"source", "name":"用户来源", "newValue":"1", "newValueShow":"系统", "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"email", "name":"邮箱", "newValue":"12345@qq.com", "newValueShow":"12345@qq.com", "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"age", "name":"年龄", "newValue":35, "newValueShow":35, "oldValueShow":"空" }, { "editType":"SAVE", "fieldName":"username", "name":"用户名", "newValue":"李四", "newValueShow":"李四", "oldValueShow":"空" } ], "jsonAfter":"{\"address\":{\"city\":\"六盘水市\",\"country\":\"中国\",\"province\":\"云南省\"},\"age\":35,\"cellphone\":\"18657158538\",\"email\":\"12345@qq.com\",\"roles\":[\"admin\",\"teacher\"],\"sex\":\"Female\",\"source\":\"1\",\"username\":\"李四\"}", "jsonBefore":"{}", "logOperate":"SAVE", "newClassName":"dto.pojo.demo.com.chery.foxlogrecord.UserDto", "oldClassName":"entity.demo.com.chery.foxlogrecord.User", "operatorName":"ANONYMOUS" } ``` ### 编辑用户 PUT http://127.0.0.1:8080/users 请求参数 手机号:18657158538改成18682575358 地址:云南省六盘水市改成安徽省芜湖市 年龄:35改36 ```java { "email":"12345@qq.com", "roles":[ "admin", "teacher" ], "id":5, "cellphone":"18682575358", "address":{ "city":"芜湖市", "province":"安徽省", "country":"中国" }, "username":"李四", "age":36 } ``` 日志打印ObjectDiffDTO ```java { "businessId":"5", "description":"更新用户", "fieldDiffDTOList":[ { "editType":"UPDATE", "fieldName":"province", "name":"省", "newValue":"安徽省", "newValueShow":"安徽省", "oldValue":"云南省", "oldValueShow":"云南省" }, { "editType":"UPDATE", "fieldName":"city", "name":"市", "newValue":"芜湖市", "newValueShow":"芜湖市", "oldValue":"六盘水市", "oldValueShow":"六盘水市" }, { "editType":"UPDATE", "fieldName":"cellphone", "name":"手机号", "newValue":"18682575358", "newValueShow":"18682575358", "oldValue":"18657158538", "oldValueShow":"18657158538" }, { "editType":"UPDATE", "fieldName":"age", "name":"年龄", "newValue":36, "newValueShow":36, "oldValue":35, "oldValueShow":35 } ], "jsonAfter":"{\"address\":{\"city\":\"芜湖市\",\"country\":\"中国\",\"province\":\"安徽省\"},\"age\":36,\"cellphone\":\"18682575358\",\"email\":\"12345@qq.com\",\"id\":5,\"roles\":[\"admin\",\"teacher\"],\"username\":\"李四\"}", "jsonBefore":"{\"address\":\"{\\\"city\\\": \\\"六盘水市\\\", \\\"country\\\": \\\"中国\\\", \\\"province\\\": \\\"云南省\\\"}\",\"age\":35,\"cellphone\":\"18657158538\",\"createBy\":922337203685477,\"createTime\":1709535747000,\"deleted\":false,\"email\":\"12345@qq.com\",\"enabled\":true,\"id\":5,\"roles\":[\"admin\",\"teacher\"],\"sex\":\"Female\",\"source\":\"1\",\"updateBy\":922337203685477,\"updateTime\":1709535747000,\"username\":\"李四\"}", "logOperate":"UPDATE", "newClassName":"dto.pojo.demo.com.chery.foxlogrecord.UserDto", "oldClassName":"entity.demo.com.chery.foxlogrecord.User", "operatorName":"ANONYMOUS" } ```