数据库设计
页面展示:
①新增:
新增操作详情:
②编辑:
编辑操作详情展示,编辑的字段(被编辑字段,高亮展示):
部分核心代码
/** * 插入日志字段改变对比结果 */ private AuditLog insertLogCompareResult(AuditLog auditLog, AuditLogIntercepts auditLogIntercept) { // 获取targetQueryBindParam String targetQueryBindParam = auditLogIntercept.getTargetQueryBindParam(); if (StringUtils.isEmpty(targetQueryBindParam)) { new ArrayList<>(); } // 获取logDataType --> 返回翻译字典:map集合(英文key,中文value) Map<String, String> transSourceMap = this.findTransSourceMap(auditLogIntercept); // 只有新增数据 if (auditLogIntercept.getOperationAction().equals(OperateActionEnum.SAVE.getValue())) { // 英转中:字段翻译 TreeMap<String, Object> trans = this.transLanguage(auditLog.getStandardLogData(), transSourceMap); // 组装返回对象 ArrayList<LogCompareVO> list = new ArrayList<>(); long i = 1L; for (String key : trans.keySet()) { if (Strings.isNotBlank(key)) { // 处理key,去掉同名key的序号 LogCompareVO compareVO = new LogCompareVO(i, key.split("@")[0], "-", trans.get(key).toString(), "add"); i++; list.add(compareVO); } } auditLog.setCompareResult(JSONObject.toJSONString(list)); return BeanUtils.convertTo(auditLog, AuditLog::new); } else { // 查询最新的条1日志 List<AuditLog> oldLog = this.queryLogDesc(auditLog.getTargetId(), auditLog.getTargetQueryBindParam(), 1); // 编辑情况:字段--新旧值对比 List<AuditLog> auditLogs = new ArrayList<>(2); auditLogs.add(auditLog); auditLogs.add(oldLog.get(0)); // 对比、翻译及去重 return this.findDiff(auditLogs, transSourceMap); } }
/** * 对比获取对象的字段差异 */ private AuditLog findDiff(List<AuditLog> auditLogs, Map<String, String> map) { // 旧值 String oldValue = auditLogs.get(1).getStandardLogData(); // 新值 String newValue = auditLogs.get(0).getOperateData(); // 查询中文翻译并替换 TreeMap<String, Object> transN = this.transLanguage(String.valueOf(newValue), map); TreeMap<String, Object> transO = this.transLanguage(String.valueOf(oldValue), map); // 编辑比对--产生改变的字段 return this.compareMap(auditLogs, transN, transO); }
/** * map比较编辑--产生改变的字段 */ private AuditLog compareMap(List<AuditLog> auditLogs, TreeMap<String, Object> nMap, TreeMap<String, Object> oMap) { // 转对象用于返回数 组装返回对象LogCompareVO ArrayList<LogCompareVO> list = new ArrayList<>(); long i = 1L; for (String key : nMap.keySet()) { if (Strings.isNotBlank(key)) { LogCompareVO compareVO = null; // value改变则存“change”标识,用于前端突出渲染 if (oMap.get(key) != null) { if (oMap.get(key).equals(nMap.get(key))) { // 比较相同则copy & 处理key,去掉同名key的序号 compareVO = new LogCompareVO(i, key.split("@")[0], oMap.get(key).toString(), nMap.get(key).toString(), "copy"); } else { // 比较不同则change & 处理key,去掉同名key的序号 compareVO = new LogCompareVO(i, key.split("@")[0], oMap.get(key).toString(), nMap.get(key).toString(), "change"); } } else { // oldValue为空 & 处理key,去掉同名key的序号 compareVO = new LogCompareVO(i, key.split("@")[0], "-", nMap.get(key).toString(), "change"); } i++; list.add(compareVO); } } auditLogs.get(0).setCompareResult(JSONObject.toJSONString(list)); return auditLogs.get(0); } /** * 获取字段中文翻译集合 */ private Map<String, String> findTransSourceMap(AuditLogIntercepts auditLogIntercept) { String logDataType = auditLogIntercept.getLogDataType(); String[] dataTypes = logDataType.split(";"); HashMap<String, String> map = new HashMap<>(); for (String type : dataTypes) { Field[] fields = LogDataTypeChoose.chooseEnumType(type); if (fields != null) { for (Field field : fields) { FieldCompareName annotation = field.getAnnotation(FieldCompareName.class); // 防止没加注解的字段报错,有加注解才进翻译集 if (!Objects.isNull(annotation)) { map.put(field.getName(), annotation.name()); } } } } return map; } /** * 字段:英转中 */ private TreeMap<String, Object> transLanguage(String s, Map<String, String> transSource) { Map<String, Object> sourceMap = new HashMap<>(); TreeMap<String, Object> resultMap = new TreeMap<>(new SortStrUtil()); // 展平多层级json为一层 AuditUtil.parseJson2Map(sourceMap, s, null); // 逐级翻译字段名称 int num = 1; for (String f : sourceMap.keySet()) { // 处理e,去掉AuditUtil.parseJson2Map加的同名key尾部序号@x String e = f.split("@")[0]; // 一级key则直接转换保存,一级key不会有重名 if (transSource.containsKey(e)) { resultMap.put(transSource.get(e), sourceMap.get(e)); } else { // 多级key,先拆分key,拼接翻译,保存 String[] keys = e.split("\\."); if (keys.length > 1) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < keys.length; i++) { // 最后拼接的不拼”-“ String tr = transSource.get(keys[i]); if (transSource.containsKey(keys[i]) && i == (keys.length - 1)) { sb.append(tr); } else { sb.append(tr).append("-"); } } // 拼接有null字符串的,排除 if (!sb.toString().contains("null")) { // 如果没有key,直接保存进map // 如果已经有key,则表示key有同名为数组,同名的加序号保存 if (resultMap.get(sb.toString()) == null) { resultMap.put(sb.toString(), sourceMap.get(f)); } else { String k = sb.toString() + "@" + sourceMap.get(f); num++; resultMap.put(k, sourceMap.get(f)); } } } // 没有翻译源的,即没配置翻译信息的,过滤掉,不呈现 } } return resultMap; }
说明
一、被审计接口需要遵循的规范
二、kafka的topic命名
三、日志记录过程说明
四、问题
五、配置项示例
****************************************************************************************************************************
一、被审计接口需要遵循的规范:
新增接口需要返回id,用于targetId;批量新增需要返回新增成功的id数组,格式示例[1,x,x]
json数据尽量符合key-value格式-->[x,x,x]不含K-V格式的数组无法进一步解析,直接呈现;
Key值尽量为可以翻译的字段名,如1:“xx”将不会被翻译,因为1数字key没有翻译源
**************************************************************************************************************************
二、kafka的topic命名:
producer:
router服务里配置yml:(最终效果为"模块名"+"下列配置",如:xxx.audit)
开发环境:audit
sit环境:audit.sit
uat环境:audit.uat
consumer:
各个服务里配置ice的yml:(最终效果为"模块名"+"下列配置",如:project.audit)
开发环境:"模块名称"+audit (配置多个topic以,分割)
sit环境:"模块名称"+audit.sit
uat环境:"模块名称"+audit.uat
动态配置多个topics-->kafka的lietener类,配置topic格式:@KafkaListener(topics = "#{'${kafka.listener_topics}'.split(',')}")
yml配置格式(逗号分割topic):listener_topics: kafka-topic-a2b,kafka-topic-c2b
**************************************************************************************************************************
三、日志记录过程说明:
①router截取请求和响应信息,通过kafka发送出来,在各个模块分别接收日志数据,处理数据:基础数据和操作详情。
配置一处:ModuleFilterUtil 的模块名(白名单),否则kafka发送不了对应模块的消息
②操作详情即对新增和编辑的字段进行展示,此处字段名称需要翻译为中文。
翻译:在字段上添加翻译注解FieldCompareName,在配置文件AUDIT_CONFIG.yml中配置对象类型如logDataType:TaskDTO,通过枚举LogDataTypeEnum配置解读出对象类型传递到翻译处理类LogDataTypeChoose,通过反射获取翻译信息存放到一个map中(key为英文字段名,value为中文字段名);
请求信息为多层嵌套的json格式,通过工具将其转化为一层的map,根据map的key与翻译的map的key相同,将value替换为中文翻译。拼装每一个返回对象LogCompareVO,放到集合中返回给前端。
配置四处:FieldCompareName、AUDIT_CONFIG.yml、LogDataTypeEnum、LogDataTypeChoose
****************************************************************************************************************************
四、问题:
targetId取请求或者返回数据中的id
优化日志内容冗余::筛选掉不该展现的值-->没配置注解的,不翻译也不放进比较结果里,翻译过程种中出现null值的key,将被过滤掉,因此没配置翻译的注解,就会被过滤掉
日志ip问题:nginx有代理,所以要处理下拿到真实ip,再router里进行相关操作
topic:①kafka:topic模块分发配置;②日志功能封装和按模块拆分;
动态配置多个topics--》lietener配置topic格式:@KafkaListener(topics = "#{'${kafka.listener_topics}'.split(',')}")
yml配置格式(逗号分割topic):listener_topics: kafka-topic-a2b,kafka-topic-c2b
删除操作返回对象名称如xx任务,配置 targetName: taskName ,进行了 删除 操作-->删除描述统一为?,新增和便编辑类似,后续替换该内容为具体targetName
新增请求没有id-->补id:新增操作的日志logdata没有“id”:xx,需要从新增成功后返回的response里拿到id,插入到logdata里
操作详情:增加标识,对比修改的放修改,前端高亮显示。传给前端参数,带change的显示高亮,其他add、copy不传
logDataType: ProjectDTO--》配置操作的数据对象类型,用于翻译------》logData问题--比较后回填失败:待解决---》修改op的remove,改源码使之失效
①处理日志logdata的更细问题---》修改json2map方法源码,每次logdata和编辑的json数据比对,将更新的部分更新,其他的部分保存,这里会产生remove,去掉logdata的部分数据,解决方法,修改jsonpatch源码,不进行remove补丁操作
②处理入参出现value值为字符串数组导致出错的问题;---》非结构化多层数据转单层结构
批量新增的问题,无法记录id,无法生成日志记录。----返回的id组也拆开;………req和respon都拆开来,分开多次进行日志审计
处理请求数据格式,包括-->[1,2,3]格式和[{xx},{xx}]格式及{”k“:["a","b","c"],}格式
message的尾部名称获取解决--》targetName配置,从req或resp里根据配置的字段名称作为jsonObject的key,从而拿到名称
***********************************************************************************************************
五、配置项示例:
①# router服务里配置module名,不在该集合内的module将被过滤掉,无法发送kafka消息。示例--> url: /project/TaskService/save里的project
public class ModuleFilterUtil {
private static final String[] MODULES = {
"contract",
"budget",
"contractbill",
"contractestimate",
"core",
"finance",
"market",
"customer",
"project",
"subcontract",
};
}
②# AUDIT_CONFIG.yml配置文件示例
audit:
intercepts:
# 被审计接口的api,查询接口不产生审计日志
- url: /project/TaskService/save
# 请求方式
method: POST
# 模块名称
moduleCode: project
moduleName: 项目管理
# 用于产生targetId(动态配置,取请求或者返回的id其一),一般取request里的id,新增入参没有id才取response里的id,注意有些编辑和删除接口,不是用id而是如taskId
requestKey: id
responseKey: id
# 操作类型(新增、编辑、禁用、启用、删除、审核、导入、导出)
operationAction: 新增
# 配合targetId,绑定新增、编辑和删除的查询参数,配合targetId用于查询同一个操作对象的日志记录对象;命名规则-->同一url: /project/TaskService/XXX下,新增、编辑和删除相同,建议取Url里的Service的名字,如/project/TaskService/save取Task
targetQueryBindParam: Task
# 日志记录--对象类型(入参包含的对象,配置多个时以;间隔)//没配为特殊新增如转级,操作详情不记录,用于新增和编辑的操作详情的key字段翻译
logDataType: TaskDTO;ProjectDTO
# 日志记录--对象名称-->需要确认字段名称,用于产生message里的被操作日志对象名称, 如message-->项目管理,邱晓东,删除“成员”:张三--<的张三
targetName: projectName
# 操作信息-->详见下面的message格式
message: 项目管理,${USER_NAME},查询项目列表
message格式--> 项目管理,xxx,删除“成员”:张三
项目管理,xxx,编辑“成员”:张三
项目管理,xxx,新增“任务”:发电设备安全检查
项目管理,xxx,删除“项目”:项目二期
项目合同,xxx,删除“分包合同”:项目执行部门合同
③FieldCompareName配置示例
ProjectDTO extends OperationDTO {
@FieldCompareName(name = "项目编号")
private String projectNo;...}
④LogDataTypeEnum配置示例
public enum LogDataTypeEnum {
/**
* 任务对象
*/
TASK("TaskDTO"),
/**
* 项目对象
*/
PROJECT("ProjectDTO")...}
⑤LogDataTypeChoose配置示例
public class LogDataTypeChoose {
public static Field[] chooseEnumType(String enumType) {
return switch (Objects.requireNonNull(LogDataTypeEnum.fromValue(enumType))) {
case TASK -> TaskDTO.class.getDeclaredFields();
case PROJECT -> ProjectDTO.class.getDeclaredFields();...}