init
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<artifactId>BladeX-Tool</artifactId>
|
||||
<groupId>org.springblade</groupId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>blade-starter-data-record</artifactId>
|
||||
<name>${project.artifactId}</name>
|
||||
<version>${project.parent.version}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<module.name>org.springblade.blade.starter.data.record</module.name>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springblade</groupId>
|
||||
<artifactId>blade-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springblade</groupId>
|
||||
<artifactId>blade-starter-mybatis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springblade</groupId>
|
||||
<artifactId>blade-core-auto</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 数据审计注解
|
||||
* <p>
|
||||
* 用于标记需要进行数据审计的实体类,提供全局级别的配置选项
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
@Target({ElementType.TYPE, ElementType.FIELD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface DataRecord {
|
||||
|
||||
/**
|
||||
* 业务模块名称
|
||||
*/
|
||||
String module() default "";
|
||||
|
||||
/**
|
||||
* 是否记录详细变更数据
|
||||
*/
|
||||
boolean recordDetail() default true;
|
||||
|
||||
/**
|
||||
* 是否记录旧数据
|
||||
*/
|
||||
boolean recordOldData() default true;
|
||||
|
||||
/**
|
||||
* 是否记录新数据
|
||||
*/
|
||||
boolean recordNewData() default true;
|
||||
|
||||
/**
|
||||
* 忽略的字段列表(字段级别使用时无效)
|
||||
*/
|
||||
String[] ignoreFields() default {};
|
||||
|
||||
/**
|
||||
* 只记录指定字段列表(字段级别使用时无效)
|
||||
*/
|
||||
String[] includeFields() default {};
|
||||
|
||||
/**
|
||||
* 数据审计级别
|
||||
*/
|
||||
DataRecordLevel level() default DataRecordLevel.INFO;
|
||||
|
||||
/**
|
||||
* 是否异步处理
|
||||
*/
|
||||
boolean async() default false;
|
||||
|
||||
/**
|
||||
* 记录条件表达式
|
||||
* <p>
|
||||
* 支持的变量:
|
||||
* - #oldData: 修改前的数据Map
|
||||
* - #newData: 修改后的数据Map
|
||||
* <p>
|
||||
* 示例:
|
||||
* - "#oldData.amount > 1000": 实体金额大于1000时才记录
|
||||
* - "#oldData.status != #newData.status": 状态发生变化时才记录
|
||||
*/
|
||||
String condition() default "";
|
||||
|
||||
/**
|
||||
* 业务操作描述
|
||||
* <p>
|
||||
* 用于描述具体的业务操作类型,将与数据库操作类型组合显示
|
||||
* 例如:如果设置为"用户信息变更",最终记录的操作类型为"UPDATE - 用户信息变更"
|
||||
* <p>
|
||||
* 如果不设置,则只显示数据库操作类型(INSERT/UPDATE/DELETE)
|
||||
*/
|
||||
String operation() default "";
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.annotation;
|
||||
|
||||
/**
|
||||
* 数据审计级别枚举
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
public enum DataRecordLevel {
|
||||
/**
|
||||
* 调试级别
|
||||
*/
|
||||
DEBUG,
|
||||
/**
|
||||
* 信息级别
|
||||
*/
|
||||
INFO,
|
||||
/**
|
||||
* 警告级别
|
||||
*/
|
||||
WARN,
|
||||
/**
|
||||
* 错误级别
|
||||
*/
|
||||
ERROR
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 字段记录注解
|
||||
* <p>
|
||||
* 用于标记需要进行数据审计的实体字段,提供字段级别的精细化控制
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
@Target({ElementType.FIELD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface FieldRecord {
|
||||
|
||||
/**
|
||||
* 是否记录该字段的变更
|
||||
*/
|
||||
boolean value() default true;
|
||||
|
||||
/**
|
||||
* 数据审计级别
|
||||
*/
|
||||
DataRecordLevel level() default DataRecordLevel.INFO;
|
||||
|
||||
/**
|
||||
* 字段描述
|
||||
* <p>
|
||||
* 用于在日志输出中提供更友好的字段名称显示
|
||||
* <p>
|
||||
* 示例:
|
||||
* - 设置 description = "用户名",日志显示:{用户名: oldValue->newValue}
|
||||
* - 不设置 description,日志显示:{username: oldValue->newValue}
|
||||
*/
|
||||
String description() default "";
|
||||
|
||||
/**
|
||||
* 记录条件表达式
|
||||
* <p>
|
||||
* 支持的变量:
|
||||
* - #oldValue: 字段的旧值
|
||||
* - #newValue: 字段的新值
|
||||
* <p>
|
||||
* 示例:
|
||||
* - "#oldValue != #newValue": 只有值变更时才记录
|
||||
* - "#newValue > 1000": 新值大于1000时才记录
|
||||
*/
|
||||
String condition() default "";
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.capture;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
|
||||
/**
|
||||
* 审计执行计划
|
||||
* <p>
|
||||
* 每个 MappedStatement 的审计决策只依赖静态元数据(注解与表信息),
|
||||
* 首次解析后长期缓存,使非审计表的语句只承担一次缓存查找的成本。
|
||||
* <p>
|
||||
* 自定义语句的目标表随 SQL 内容而定,无法静态判定,其表名与注解在每次执行时解析。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class AuditPlan {
|
||||
|
||||
/**
|
||||
* 跳过审计的共享计划
|
||||
*/
|
||||
public static final AuditPlan SKIP = new AuditPlan(true, null, null, null, null);
|
||||
|
||||
/**
|
||||
* 自定义语句的共享计划,审计目标延迟到执行期解析
|
||||
*/
|
||||
public static final AuditPlan CUSTOM = new AuditPlan(false, StatementKind.CUSTOM, null, null, null);
|
||||
|
||||
/**
|
||||
* 是否跳过审计
|
||||
*/
|
||||
private final boolean skip;
|
||||
|
||||
/**
|
||||
* 语句类型
|
||||
*/
|
||||
private final StatementKind kind;
|
||||
|
||||
/**
|
||||
* 表名
|
||||
*/
|
||||
private final String tableName;
|
||||
|
||||
/**
|
||||
* 实体表信息
|
||||
*/
|
||||
private final TableInfo tableInfo;
|
||||
|
||||
/**
|
||||
* 数据审计配置
|
||||
*/
|
||||
private final DataRecord dataRecord;
|
||||
|
||||
/**
|
||||
* 是否为自定义语句
|
||||
*
|
||||
* @return 是否自定义
|
||||
*/
|
||||
public boolean isCustom() {
|
||||
return StatementKind.CUSTOM == kind;
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.capture;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.Mapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.ReflectionKit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.processor.DataRecordDetector;
|
||||
import org.springblade.core.datarecord.props.DataRecordProperties;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 审计执行计划解析器
|
||||
* <p>
|
||||
* 数据变更拦截器的准入门卫:按 MappedStatement 标识缓存审计决策,
|
||||
* 通用 Mapper 的标准方法直接从 Mapper 泛型定位实体表并归类语句类型,
|
||||
* 其余语句归入自定义类型由 SQL 解析路径兜底。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AuditPlanResolver {
|
||||
|
||||
private final DataRecordDetector dataRecordDetector;
|
||||
|
||||
private final DataRecordProperties dataRecordProperties;
|
||||
|
||||
/**
|
||||
* MappedStatement 标识与审计计划的缓存
|
||||
*/
|
||||
private final Map<String, AuditPlan> planCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 解析语句的审计计划
|
||||
*
|
||||
* @param mappedStatement 映射语句
|
||||
* @return 审计计划
|
||||
*/
|
||||
public AuditPlan resolve(MappedStatement mappedStatement) {
|
||||
return planCache.computeIfAbsent(mappedStatement.getId(), statementId -> buildPlan(mappedStatement));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建审计计划,任何解析异常都降级为跳过,不影响业务执行
|
||||
*
|
||||
* @param mappedStatement 映射语句
|
||||
* @return 审计计划
|
||||
*/
|
||||
private AuditPlan buildPlan(MappedStatement mappedStatement) {
|
||||
try {
|
||||
String statementId = mappedStatement.getId();
|
||||
int splitIndex = statementId.lastIndexOf('.');
|
||||
if (splitIndex < 0) {
|
||||
return AuditPlan.CUSTOM;
|
||||
}
|
||||
StatementKind kind = resolveKind(statementId.substring(splitIndex + 1));
|
||||
if (kind == StatementKind.CUSTOM) {
|
||||
return AuditPlan.CUSTOM;
|
||||
}
|
||||
// 标准方法名只在通用 Mapper 上有稳定语义,泛型解析失败则视为同名的自定义方法
|
||||
Class<?> mapperClass = Class.forName(statementId.substring(0, splitIndex), false, Thread.currentThread().getContextClassLoader());
|
||||
Class<?> entityClass = ReflectionKit.getSuperClassGenericType(mapperClass, Mapper.class, 0);
|
||||
if (entityClass == null || Object.class == entityClass) {
|
||||
return AuditPlan.CUSTOM;
|
||||
}
|
||||
TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass);
|
||||
if (tableInfo == null) {
|
||||
return AuditPlan.SKIP;
|
||||
}
|
||||
String tableName = tableInfo.getTableName();
|
||||
DataRecord dataRecord = dataRecordDetector.getTableDataRecord(tableName);
|
||||
if (dataRecord == null || dataRecordProperties.isTableIgnored(tableName)) {
|
||||
return AuditPlan.SKIP;
|
||||
}
|
||||
return new AuditPlan(false, kind, tableName, tableInfo, dataRecord);
|
||||
} catch (Exception exception) {
|
||||
log.debug("解析审计执行计划失败,该语句跳过审计, statement={}", mappedStatement.getId(), exception);
|
||||
return AuditPlan.SKIP;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按通用 Mapper 的方法名归类语句类型
|
||||
*
|
||||
* @param methodName 方法名
|
||||
* @return 语句类型
|
||||
*/
|
||||
private StatementKind resolveKind(String methodName) {
|
||||
return switch (methodName) {
|
||||
case "insert", "insertIgnore", "replace" -> StatementKind.INSERT_ENTITY;
|
||||
case "insertBatchSomeColumn" -> StatementKind.INSERT_BATCH;
|
||||
case "updateById" -> StatementKind.UPDATE_BY_ID;
|
||||
case "update" -> StatementKind.UPDATE_BY_WRAPPER;
|
||||
case "deleteById" -> StatementKind.DELETE_BY_ID;
|
||||
case "deleteBatchIds", "deleteByIds" -> StatementKind.DELETE_BY_IDS;
|
||||
case "delete" -> StatementKind.DELETE_BY_WRAPPER;
|
||||
default -> StatementKind.CUSTOM;
|
||||
};
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.capture;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IEnum;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.mapping.BoundSql;
|
||||
import org.apache.ibatis.mapping.ParameterMapping;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.springblade.core.datarecord.constant.DataRecordConstant;
|
||||
|
||||
import java.io.Reader;
|
||||
import java.sql.Clob;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 取证工具
|
||||
* <p>
|
||||
* 实体路径与 SQL 解析路径共用的值规范化、列名映射与参数取值逻辑。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class CaptureKit {
|
||||
|
||||
private CaptureKit() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 值规范化
|
||||
* <p>
|
||||
* 前镜像来自 JDBC 读取、后镜像来自实体字段,两侧的时间与枚举装箱类型不一致,
|
||||
* 统一规范化后才能进行变更比对:JDBC 时间类型转为 java.time,枚举取其存储值。
|
||||
*
|
||||
* @param value 原始值
|
||||
* @return 规范化后的值
|
||||
*/
|
||||
public static Object normalizeValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof IEnum<?> enumValue) {
|
||||
return enumValue.getValue();
|
||||
}
|
||||
if (value instanceof Enum<?> enumValue) {
|
||||
return enumValue.name();
|
||||
}
|
||||
if (value instanceof java.sql.Timestamp timestamp) {
|
||||
return timestamp.toLocalDateTime();
|
||||
}
|
||||
if (value instanceof java.sql.Date sqlDate) {
|
||||
return sqlDate.toLocalDate();
|
||||
}
|
||||
if (value instanceof java.sql.Time sqlTime) {
|
||||
return sqlTime.toLocalTime();
|
||||
}
|
||||
if (value instanceof java.util.Date date) {
|
||||
return LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
|
||||
}
|
||||
if (value instanceof Clob clob) {
|
||||
return readClob(clob);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取Clob内容
|
||||
*
|
||||
* @param clob 大字段对象
|
||||
* @return 文本内容
|
||||
*/
|
||||
public static String readClob(Clob clob) {
|
||||
try {
|
||||
return clob.getSubString(1, (int) clob.length());
|
||||
} catch (Exception exception) {
|
||||
// 部分驱动不支持按定位读取,降级为字符流读取
|
||||
try (Reader reader = clob.getCharacterStream()) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
char[] buffer = new char[256];
|
||||
int readCount;
|
||||
while ((readCount = reader.read(buffer)) != -1) {
|
||||
content.append(buffer, 0, readCount);
|
||||
}
|
||||
return content.toString();
|
||||
} catch (Exception streamException) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除标识符的包裹符(反引号、双引号、方括号),保证跨数据库的表名与列名可比
|
||||
*
|
||||
* @param identifier 标识符
|
||||
* @return 裸标识符
|
||||
*/
|
||||
public static String unquote(String identifier) {
|
||||
return identifier == null ? null : identifier.replaceAll(DataRecordConstant.SQL_CHAR_CLEANUP_REGEX, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计SQL片段中的参数占位符数量,跳过单引号字符串内的问号
|
||||
*
|
||||
* @param sqlFragment SQL片段
|
||||
* @return 占位符数量
|
||||
*/
|
||||
public static int countPlaceholders(String sqlFragment) {
|
||||
int count = 0;
|
||||
boolean inQuote = false;
|
||||
for (int i = 0; i < sqlFragment.length(); i++) {
|
||||
char current = sqlFragment.charAt(i);
|
||||
if (current == '\'') {
|
||||
inQuote = !inQuote;
|
||||
} else if (current == '?' && !inQuote) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按占位符顺序取出参数值
|
||||
* <p>
|
||||
* 与 MyBatis 参数绑定保持一致的取值优先级:动态SQL附加参数(如 foreach 展开项)优先,
|
||||
* 简单类型参数整体即值,其余按属性表达式从参数对象读取。
|
||||
*
|
||||
* @param configuration MyBatis配置
|
||||
* @param boundSql 绑定SQL
|
||||
* @param parameterObject 参数对象
|
||||
* @param mappingIndex 占位符序号
|
||||
* @return 参数值
|
||||
*/
|
||||
public static Object parameterValue(Configuration configuration, BoundSql boundSql, Object parameterObject, int mappingIndex) {
|
||||
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
|
||||
if (mappingIndex < 0 || mappingIndex >= parameterMappings.size()) {
|
||||
return null;
|
||||
}
|
||||
String property = parameterMappings.get(mappingIndex).getProperty();
|
||||
if (boundSql.hasAdditionalParameter(property)) {
|
||||
return boundSql.getAdditionalParameter(property);
|
||||
}
|
||||
if (parameterObject == null) {
|
||||
return null;
|
||||
}
|
||||
if (configuration.getTypeHandlerRegistry().hasTypeHandler(parameterObject.getClass())) {
|
||||
return parameterObject;
|
||||
}
|
||||
return configuration.newMetaObject(parameterObject).getValue(property);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按表名查找实体表信息
|
||||
*
|
||||
* @param tableName 表名
|
||||
* @return 表信息,无对应实体时返回null
|
||||
*/
|
||||
public static TableInfo findTableInfo(String tableName) {
|
||||
for (TableInfo tableInfo : TableInfoHelper.getTableInfos()) {
|
||||
if (tableName.equalsIgnoreCase(tableInfo.getTableName())) {
|
||||
return tableInfo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建「大写裸列名 -> 实体属性名」的映射
|
||||
*
|
||||
* @param tableInfo 表信息
|
||||
* @return 列名属性映射
|
||||
*/
|
||||
public static Map<String, String> columnPropertyMap(TableInfo tableInfo) {
|
||||
Map<String, String> columnProperty = new HashMap<>(tableInfo.getFieldList().size() + 1);
|
||||
if (tableInfo.getKeyColumn() != null && tableInfo.getKeyProperty() != null) {
|
||||
columnProperty.put(unquote(tableInfo.getKeyColumn()).toUpperCase(), tableInfo.getKeyProperty());
|
||||
}
|
||||
for (TableFieldInfo fieldInfo : tableInfo.getFieldList()) {
|
||||
columnProperty.put(unquote(fieldInfo.getColumn()).toUpperCase(), fieldInfo.getProperty());
|
||||
}
|
||||
return columnProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列名转属性名,无实体映射时回退为小写裸列名
|
||||
*
|
||||
* @param columnProperty 列名属性映射
|
||||
* @param columnName 列名
|
||||
* @return 属性名
|
||||
*/
|
||||
public static String propertyName(Map<String, String> columnProperty, String columnName) {
|
||||
String bareColumn = unquote(columnName);
|
||||
return columnProperty.getOrDefault(bareColumn.toUpperCase(), bareColumn.toLowerCase());
|
||||
}
|
||||
}
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.capture;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Constants;
|
||||
import com.baomidou.mybatisplus.core.toolkit.ReflectionKit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.executor.Executor;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.apache.ibatis.session.RowBounds;
|
||||
import org.springblade.core.datarecord.props.DataRecordProperties;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 实体路径取证
|
||||
* <p>
|
||||
* 通用 Mapper 标准方法的参数对象保留了完整的实体与条件语义,直接从活对象取数,
|
||||
* 全程不解析 SQL。这是绝大多数业务 DML 的路径,也是本插件性能优势的来源。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class EntityChangeCapture {
|
||||
|
||||
/**
|
||||
* 主键 IN 查询的单批数量上限,兼顾各数据库对 IN 列表长度的限制
|
||||
*/
|
||||
private static final int KEY_CHUNK_SIZE = 500;
|
||||
|
||||
/**
|
||||
* MyBatis 对单一集合参数的包装键
|
||||
*/
|
||||
private static final String BATCH_PARAM_KEY = "list";
|
||||
|
||||
/**
|
||||
* 条件构造器 SET 片段中的占位符赋值对,值键与 paramNameValuePairs 一一对应
|
||||
*/
|
||||
private static final Pattern SQL_SET_PLACEHOLDER = Pattern.compile("([^\\s,=]+)\\s*=\\s*#\\{ew\\.paramNameValuePairs\\.(\\w+)}");
|
||||
|
||||
private final DataRecordProperties dataRecordProperties;
|
||||
|
||||
/**
|
||||
* 采集变更前镜像
|
||||
*
|
||||
* @param plan 审计计划
|
||||
* @param mappedStatement 映射语句
|
||||
* @param parameter 参数对象
|
||||
* @param executor 执行器
|
||||
* @return 前镜像行集,最多返回上限加一行,由调用方判定截断
|
||||
* @throws SQLException 查询异常
|
||||
*/
|
||||
public List<Map<String, Object>> beforeImage(AuditPlan plan, MappedStatement mappedStatement, Object parameter, Executor executor) throws SQLException {
|
||||
return switch (plan.getKind()) {
|
||||
case UPDATE_BY_ID -> {
|
||||
Object keyValue = entityKeyValue(plan.getTableInfo(), extractEntity(plan, parameter));
|
||||
yield keyValue == null ? Collections.emptyList() : selectByKeys(plan.getTableInfo(), Collections.singletonList(keyValue), executor);
|
||||
}
|
||||
case DELETE_BY_ID -> {
|
||||
Object keyValue = plan.getTableInfo().getEntityType().isInstance(parameter)
|
||||
? entityKeyValue(plan.getTableInfo(), parameter) : parameter;
|
||||
yield keyValue == null ? Collections.emptyList() : selectByKeys(plan.getTableInfo(), Collections.singletonList(keyValue), executor);
|
||||
}
|
||||
case DELETE_BY_IDS -> selectByKeys(plan.getTableInfo(), extractKeys(plan, parameter), executor);
|
||||
case UPDATE_BY_WRAPPER, DELETE_BY_WRAPPER -> selectByWrapper(plan, mappedStatement, parameter, executor);
|
||||
default -> Collections.emptyList();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取更新赋值内容:实体的非空字段与条件构造器的 SET 片段合并
|
||||
* <p>
|
||||
* setSql 写入的字面量片段无法可靠归属到具体列,不参与字段级比对
|
||||
*
|
||||
* @param plan 审计计划
|
||||
* @param parameter 参数对象
|
||||
* @return 属性名与新值的映射
|
||||
*/
|
||||
public Map<String, Object> updateValues(AuditPlan plan, Object parameter) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
Object entity = extractEntity(plan, parameter);
|
||||
if (entity != null) {
|
||||
values.putAll(entityToMap(plan.getTableInfo(), entity, false));
|
||||
}
|
||||
Object wrapper = parameter instanceof Map<?, ?> parameterMap && parameterMap.containsKey(Constants.WRAPPER)
|
||||
? parameterMap.get(Constants.WRAPPER) : null;
|
||||
if (wrapper instanceof AbstractWrapper<?, ?, ?> abstractWrapper) {
|
||||
String sqlSet = abstractWrapper.getSqlSet();
|
||||
if (StringUtil.isNotBlank(sqlSet)) {
|
||||
Map<String, String> columnProperty = CaptureKit.columnPropertyMap(plan.getTableInfo());
|
||||
Map<String, Object> paramPairs = abstractWrapper.getParamNameValuePairs();
|
||||
Matcher matcher = SQL_SET_PLACEHOLDER.matcher(sqlSet);
|
||||
while (matcher.find()) {
|
||||
String property = CaptureKit.propertyName(columnProperty, matcher.group(1));
|
||||
values.put(property, CaptureKit.normalizeValue(paramPairs.get(matcher.group(2))));
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取写入数据
|
||||
* <p>
|
||||
* 必须在语句执行后调用:主键分配与公共字段填充发生在参数绑定阶段,
|
||||
* 执行后实体对象上才具备完整的落库值。
|
||||
*
|
||||
* @param plan 审计计划
|
||||
* @param parameter 参数对象
|
||||
* @return 写入数据行集
|
||||
*/
|
||||
public List<Map<String, Object>> insertRows(AuditPlan plan, Object parameter) {
|
||||
if (plan.getKind() == StatementKind.INSERT_BATCH) {
|
||||
Object batchList = parameter instanceof Map<?, ?> parameterMap && parameterMap.containsKey(BATCH_PARAM_KEY)
|
||||
? parameterMap.get(BATCH_PARAM_KEY) : null;
|
||||
if (!(batchList instanceof Collection<?> entityList)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>(entityList.size());
|
||||
for (Object entity : entityList) {
|
||||
if (plan.getTableInfo().getEntityType().isInstance(entity)) {
|
||||
rows.add(entityToMap(plan.getTableInfo(), entity, true));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
Object entity = extractEntity(plan, parameter);
|
||||
return entity == null ? Collections.emptyList() : Collections.singletonList(entityToMap(plan.getTableInfo(), entity, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体转「属性名 -> 规范化值」映射,空值字段不参与(与更新语句的空值不落 SET 语义一致)
|
||||
*
|
||||
* @param tableInfo 表信息
|
||||
* @param entity 实体对象
|
||||
* @param includeKey 是否包含主键
|
||||
* @return 属性值映射
|
||||
*/
|
||||
public Map<String, Object> entityToMap(TableInfo tableInfo, Object entity, boolean includeKey) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
Map<String, Field> fieldMap = ReflectionKit.getFieldMap(entity.getClass());
|
||||
if (includeKey && tableInfo.getKeyProperty() != null) {
|
||||
Object keyValue = fieldValue(entity, fieldMap.get(tableInfo.getKeyProperty()));
|
||||
if (keyValue != null) {
|
||||
data.put(tableInfo.getKeyProperty(), CaptureKit.normalizeValue(keyValue));
|
||||
}
|
||||
}
|
||||
for (TableFieldInfo fieldInfo : tableInfo.getFieldList()) {
|
||||
Object value = fieldValue(entity, fieldMap.get(fieldInfo.getProperty()));
|
||||
if (value != null) {
|
||||
data.put(fieldInfo.getProperty(), CaptureKit.normalizeValue(value));
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键集合查询前镜像
|
||||
* <p>
|
||||
* 使用事务连接直查而非执行器查询:主键定位无需租户条件改写,
|
||||
* 且不会触发批量执行器为保证可见性而进行的语句刷新,保住批量写入的性能。
|
||||
*
|
||||
* @param tableInfo 表信息
|
||||
* @param keys 主键值集合
|
||||
* @param executor 执行器
|
||||
* @return 前镜像行集
|
||||
* @throws SQLException 查询异常
|
||||
*/
|
||||
private List<Map<String, Object>> selectByKeys(TableInfo tableInfo, List<Object> keys, Executor executor) throws SQLException {
|
||||
if (keys.isEmpty() || tableInfo.getKeyColumn() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
int captureLimit = dataRecordProperties.getMaxCaptureRows() + 1;
|
||||
List<TableFieldInfo> fieldList = tableInfo.getFieldList();
|
||||
StringBuilder columns = new StringBuilder(tableInfo.getKeyColumn());
|
||||
for (TableFieldInfo fieldInfo : fieldList) {
|
||||
columns.append(", ").append(fieldInfo.getColumn());
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>(Math.min(keys.size(), 16));
|
||||
Connection connection = executor.getTransaction().getConnection();
|
||||
for (int from = 0; from < keys.size() && rows.size() < captureLimit; from += KEY_CHUNK_SIZE) {
|
||||
List<Object> chunk = keys.subList(from, Math.min(from + KEY_CHUNK_SIZE, keys.size()));
|
||||
String selectSql = "SELECT " + columns + " FROM " + tableInfo.getTableName()
|
||||
+ " WHERE " + tableInfo.getKeyColumn()
|
||||
+ (chunk.size() == 1 ? " = ?" : " IN (" + "?, ".repeat(chunk.size() - 1) + "?)");
|
||||
try (PreparedStatement statement = connection.prepareStatement(selectSql)) {
|
||||
int parameterIndex = 1;
|
||||
for (Object key : chunk) {
|
||||
statement.setObject(parameterIndex++, key);
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next() && rows.size() < captureLimit) {
|
||||
Map<String, Object> row = new LinkedHashMap<>(fieldList.size() + 1);
|
||||
row.put(tableInfo.getKeyProperty(), CaptureKit.normalizeValue(resultSet.getObject(1)));
|
||||
int columnIndex = 2;
|
||||
for (TableFieldInfo fieldInfo : fieldList) {
|
||||
row.put(fieldInfo.getProperty(), CaptureKit.normalizeValue(resultSet.getObject(columnIndex++)));
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按条件构造器查询前镜像
|
||||
* <p>
|
||||
* 复用本 Mapper 已注册的 selectMaps 语句经执行器查询:条件构造器的 WHERE 片段、
|
||||
* 逻辑删除过滤与租户改写全部沿用框架既有语义,与随后执行的更新语句命中同一批行。
|
||||
*
|
||||
* @param plan 审计计划
|
||||
* @param mappedStatement 映射语句
|
||||
* @param parameter 参数对象
|
||||
* @param executor 执行器
|
||||
* @return 前镜像行集
|
||||
* @throws SQLException 查询异常
|
||||
*/
|
||||
private List<Map<String, Object>> selectByWrapper(AuditPlan plan, MappedStatement mappedStatement, Object parameter, Executor executor) throws SQLException {
|
||||
String statementId = mappedStatement.getId();
|
||||
String selectMapsId = statementId.substring(0, statementId.lastIndexOf('.') + 1) + "selectMaps";
|
||||
Configuration configuration = mappedStatement.getConfiguration();
|
||||
if (!configuration.hasStatement(selectMapsId, false)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Object wrapper = parameter instanceof Map<?, ?> parameterMap && parameterMap.containsKey(Constants.WRAPPER)
|
||||
? parameterMap.get(Constants.WRAPPER) : null;
|
||||
Map<String, Object> queryParameter = new LinkedHashMap<>(2);
|
||||
queryParameter.put(Constants.WRAPPER, wrapper);
|
||||
RowBounds rowBounds = new RowBounds(0, dataRecordProperties.getMaxCaptureRows() + 1);
|
||||
List<Map<String, Object>> resultRows = executor.query(configuration.getMappedStatement(selectMapsId),
|
||||
queryParameter, rowBounds, Executor.NO_RESULT_HANDLER);
|
||||
if (resultRows.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Map<String, String> columnProperty = CaptureKit.columnPropertyMap(plan.getTableInfo());
|
||||
List<Map<String, Object>> rows = new ArrayList<>(resultRows.size());
|
||||
for (Map<String, Object> resultRow : resultRows) {
|
||||
Map<String, Object> row = new LinkedHashMap<>(resultRow.size());
|
||||
resultRow.forEach((column, value) -> row.put(CaptureKit.propertyName(columnProperty, column), CaptureKit.normalizeValue(value)));
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从参数对象提取实体
|
||||
*
|
||||
* @param plan 审计计划
|
||||
* @param parameter 参数对象
|
||||
* @return 实体对象,无实体参数时返回null
|
||||
*/
|
||||
private Object extractEntity(AuditPlan plan, Object parameter) {
|
||||
if (parameter instanceof Map<?, ?> parameterMap) {
|
||||
return parameterMap.containsKey(Constants.ENTITY) ? parameterMap.get(Constants.ENTITY) : null;
|
||||
}
|
||||
return plan.getTableInfo().getEntityType().isInstance(parameter) ? parameter : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从主键集合参数提取主键值,集合元素允许是主键或实体
|
||||
*
|
||||
* @param plan 审计计划
|
||||
* @param parameter 参数对象
|
||||
* @return 主键值列表
|
||||
*/
|
||||
private List<Object> extractKeys(AuditPlan plan, Object parameter) {
|
||||
Object collection = parameter instanceof Map<?, ?> parameterMap && parameterMap.containsKey(Constants.COLL)
|
||||
? parameterMap.get(Constants.COLL) : null;
|
||||
if (!(collection instanceof Collection<?> keyCollection)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Object> keys = new ArrayList<>(keyCollection.size());
|
||||
for (Object element : keyCollection) {
|
||||
Object keyValue = plan.getTableInfo().getEntityType().isInstance(element)
|
||||
? entityKeyValue(plan.getTableInfo(), element) : element;
|
||||
if (keyValue != null) {
|
||||
keys.add(keyValue);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取实体的主键值
|
||||
*
|
||||
* @param tableInfo 表信息
|
||||
* @param entity 实体对象
|
||||
* @return 主键值
|
||||
*/
|
||||
private Object entityKeyValue(TableInfo tableInfo, Object entity) {
|
||||
if (entity == null || tableInfo.getKeyProperty() == null) {
|
||||
return null;
|
||||
}
|
||||
return fieldValue(entity, ReflectionKit.getFieldMap(entity.getClass()).get(tableInfo.getKeyProperty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取实体字段值
|
||||
*
|
||||
* @param entity 实体对象
|
||||
* @param field 字段
|
||||
* @return 字段值
|
||||
*/
|
||||
private Object fieldValue(Object entity, Field field) {
|
||||
if (field == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
field.setAccessible(true);
|
||||
return field.get(entity);
|
||||
} catch (Exception exception) {
|
||||
log.debug("读取实体字段值失败, entity={}, field={}", entity.getClass().getName(), field.getName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.capture;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.JdbcParameter;
|
||||
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.schema.Column;
|
||||
import net.sf.jsqlparser.statement.Statement;
|
||||
import net.sf.jsqlparser.statement.delete.Delete;
|
||||
import net.sf.jsqlparser.statement.insert.Insert;
|
||||
import net.sf.jsqlparser.statement.select.Select;
|
||||
import net.sf.jsqlparser.statement.select.Values;
|
||||
import net.sf.jsqlparser.statement.update.Update;
|
||||
import net.sf.jsqlparser.statement.update.UpdateSet;
|
||||
import org.apache.ibatis.executor.Executor;
|
||||
import org.apache.ibatis.mapping.BoundSql;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.mapping.ParameterMapping;
|
||||
import org.apache.ibatis.mapping.SqlCommandType;
|
||||
import org.apache.ibatis.reflection.SystemMetaObject;
|
||||
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
|
||||
import org.springblade.core.datarecord.model.DataChangeEvent;
|
||||
import org.springblade.core.datarecord.processor.DataRecordDetector;
|
||||
import org.springblade.core.datarecord.props.DataRecordProperties;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SQL 解析路径取证
|
||||
* <p>
|
||||
* 自定义 XML 语句的兜底路径:解析 DML 语句结构,改写为同条件查询采集前镜像,
|
||||
* 并按占位符位置从绑定参数还原写入值。多表操作与来源为查询结果的写入无法静态归因,
|
||||
* 直接放弃取证而非产出不完整的记录。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SqlChangeCapture {
|
||||
|
||||
private final DataRecordProperties dataRecordProperties;
|
||||
|
||||
/**
|
||||
* 采集自定义更新与删除语句的变更事件,需在语句执行前调用以获取前镜像
|
||||
*
|
||||
* @param mappedStatement 映射语句
|
||||
* @param parameter 参数对象
|
||||
* @param executor 执行器
|
||||
* @param dataRecordDetector 审计检测器
|
||||
* @return 变更事件,目标表未启用审计或语句无法解析时返回null
|
||||
* @throws SQLException 查询异常
|
||||
*/
|
||||
public DataChangeEvent captureUpdateDelete(MappedStatement mappedStatement, Object parameter, Executor executor, DataRecordDetector dataRecordDetector) throws SQLException {
|
||||
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
|
||||
if (skipByProbableTable(boundSql.getSql(), mappedStatement.getSqlCommandType(), dataRecordDetector)) {
|
||||
return null;
|
||||
}
|
||||
Statement statement = parse(boundSql.getSql(), mappedStatement.getId());
|
||||
if (statement instanceof Update updateStatement) {
|
||||
return captureUpdate(updateStatement, mappedStatement, boundSql, parameter, executor, dataRecordDetector);
|
||||
}
|
||||
if (statement instanceof Delete deleteStatement) {
|
||||
return captureDelete(deleteStatement, mappedStatement, boundSql, parameter, executor, dataRecordDetector);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集自定义写入语句的变更事件,需在语句执行后调用以获得完整的绑定值
|
||||
*
|
||||
* @param mappedStatement 映射语句
|
||||
* @param parameter 参数对象
|
||||
* @param dataRecordDetector 审计检测器
|
||||
* @return 变更事件,目标表未启用审计或写入内容无法静态归因时返回null
|
||||
*/
|
||||
public DataChangeEvent captureInsert(MappedStatement mappedStatement, Object parameter, DataRecordDetector dataRecordDetector) {
|
||||
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
|
||||
if (skipByProbableTable(boundSql.getSql(), mappedStatement.getSqlCommandType(), dataRecordDetector)) {
|
||||
return null;
|
||||
}
|
||||
Statement statement = parse(boundSql.getSql(), mappedStatement.getId());
|
||||
if (!(statement instanceof Insert insertStatement)) {
|
||||
return null;
|
||||
}
|
||||
String tableName = CaptureKit.unquote(insertStatement.getTable().getName());
|
||||
if (dataRecordDetector.skipRecord(tableName)) {
|
||||
return null;
|
||||
}
|
||||
ExpressionList<Column> columns = insertStatement.getColumns();
|
||||
Select select = insertStatement.getSelect();
|
||||
if (columns == null || columns.isEmpty() || !(select instanceof Values valuesClause)) {
|
||||
return null;
|
||||
}
|
||||
TableInfo tableInfo = CaptureKit.findTableInfo(tableName);
|
||||
Map<String, String> columnProperty = tableInfo != null ? CaptureKit.columnPropertyMap(tableInfo) : Collections.emptyMap();
|
||||
List<List<Expression>> valueRows = resolveValueRows(valuesClause);
|
||||
List<Map<String, Object>> afterRows = new ArrayList<>(valueRows.size());
|
||||
int mappingIndex = 0;
|
||||
for (List<Expression> valueRow : valueRows) {
|
||||
Map<String, Object> row = new LinkedHashMap<>(columns.size());
|
||||
for (int i = 0; i < columns.size() && i < valueRow.size(); i++) {
|
||||
Expression valueExpression = valueRow.get(i);
|
||||
String property = CaptureKit.propertyName(columnProperty, columns.get(i).getColumnName());
|
||||
if (valueExpression instanceof JdbcParameter) {
|
||||
row.put(property, CaptureKit.normalizeValue(
|
||||
CaptureKit.parameterValue(mappedStatement.getConfiguration(), boundSql, parameter, mappingIndex)));
|
||||
mappingIndex++;
|
||||
} else {
|
||||
int embedded = CaptureKit.countPlaceholders(valueExpression.toString());
|
||||
mappingIndex += embedded;
|
||||
row.put(property, valueExpression.toString());
|
||||
}
|
||||
}
|
||||
afterRows.add(row);
|
||||
if (afterRows.size() >= dataRecordProperties.getMaxCaptureRows()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new DataChangeEvent()
|
||||
.setTableName(tableName)
|
||||
.setOperation(SqlCommandType.INSERT)
|
||||
.setStatementId(mappedStatement.getId())
|
||||
.setKeyProperty(tableInfo != null ? tableInfo.getKeyProperty() : null)
|
||||
.setAfterRows(afterRows)
|
||||
.setOverflow(valueRows.size() > afterRows.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集更新语句:解析 SET 片段还原新值,并以原 WHERE 条件回查前镜像
|
||||
*/
|
||||
private DataChangeEvent captureUpdate(Update updateStatement, MappedStatement mappedStatement, BoundSql boundSql,
|
||||
Object parameter, Executor executor, DataRecordDetector dataRecordDetector) throws SQLException {
|
||||
if (hasJoin(updateStatement.getJoins()) || hasJoin(updateStatement.getStartJoins())) {
|
||||
return null;
|
||||
}
|
||||
String tableName = CaptureKit.unquote(updateStatement.getTable().getName());
|
||||
if (dataRecordDetector.skipRecord(tableName)) {
|
||||
return null;
|
||||
}
|
||||
TableInfo tableInfo = CaptureKit.findTableInfo(tableName);
|
||||
Map<String, String> columnProperty = tableInfo != null ? CaptureKit.columnPropertyMap(tableInfo) : Collections.emptyMap();
|
||||
Map<String, Object> updateValues = new LinkedHashMap<>();
|
||||
int setPlaceholderCount = 0;
|
||||
for (UpdateSet updateSet : updateStatement.getUpdateSets()) {
|
||||
ExpressionList<Column> columns = updateSet.getColumns();
|
||||
ExpressionList<?> values = updateSet.getValues();
|
||||
for (int i = 0; i < columns.size() && i < values.size(); i++) {
|
||||
Expression valueExpression = (Expression) values.get(i);
|
||||
String property = CaptureKit.propertyName(columnProperty, columns.get(i).getColumnName());
|
||||
if (valueExpression instanceof JdbcParameter) {
|
||||
updateValues.put(property, CaptureKit.normalizeValue(
|
||||
CaptureKit.parameterValue(mappedStatement.getConfiguration(), boundSql, parameter, setPlaceholderCount)));
|
||||
setPlaceholderCount++;
|
||||
} else {
|
||||
// 复合表达式内嵌的占位符只推进位置计数,表达式本身按原文记录
|
||||
setPlaceholderCount += CaptureKit.countPlaceholders(valueExpression.toString());
|
||||
updateValues.put(property, valueExpression.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> beforeRows = selectBefore(mappedStatement, boundSql, parameter, executor,
|
||||
updateStatement.getTable().toString(), updateStatement.getWhere(), setPlaceholderCount, columnProperty);
|
||||
if (beforeRows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new DataChangeEvent()
|
||||
.setTableName(tableName)
|
||||
.setOperation(SqlCommandType.UPDATE)
|
||||
.setStatementId(mappedStatement.getId())
|
||||
.setKeyProperty(tableInfo != null ? tableInfo.getKeyProperty() : null)
|
||||
.setBeforeRows(beforeRows)
|
||||
.setUpdateValues(updateValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集删除语句:以原 WHERE 条件回查前镜像
|
||||
*/
|
||||
private DataChangeEvent captureDelete(Delete deleteStatement, MappedStatement mappedStatement, BoundSql boundSql,
|
||||
Object parameter, Executor executor, DataRecordDetector dataRecordDetector) throws SQLException {
|
||||
if (hasJoin(deleteStatement.getJoins())) {
|
||||
return null;
|
||||
}
|
||||
String tableName = CaptureKit.unquote(deleteStatement.getTable().getName());
|
||||
if (dataRecordDetector.skipRecord(tableName)) {
|
||||
return null;
|
||||
}
|
||||
TableInfo tableInfo = CaptureKit.findTableInfo(tableName);
|
||||
Map<String, String> columnProperty = tableInfo != null ? CaptureKit.columnPropertyMap(tableInfo) : Collections.emptyMap();
|
||||
List<Map<String, Object>> beforeRows = selectBefore(mappedStatement, boundSql, parameter, executor,
|
||||
deleteStatement.getTable().toString(), deleteStatement.getWhere(), 0, columnProperty);
|
||||
if (beforeRows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new DataChangeEvent()
|
||||
.setTableName(tableName)
|
||||
.setOperation(SqlCommandType.DELETE)
|
||||
.setStatementId(mappedStatement.getId())
|
||||
.setKeyProperty(tableInfo != null ? tableInfo.getKeyProperty() : null)
|
||||
.setBeforeRows(beforeRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以原语句的 WHERE 条件回查前镜像
|
||||
* <p>
|
||||
* DML 语句的占位符排列固定为 SET 片段在前、WHERE 片段在后,截去 SET 段的参数映射后,
|
||||
* 余下映射恰好按序对应查询语句的占位符,可直接复用 MyBatis 的参数绑定器完成赋值。
|
||||
*
|
||||
* @param fromItem 原语句的表引用原文(含别名,保证 WHERE 中的别名引用有效)
|
||||
*/
|
||||
private List<Map<String, Object>> selectBefore(MappedStatement mappedStatement, BoundSql boundSql, Object parameter,
|
||||
Executor executor, String fromItem, Expression where,
|
||||
int trimCount, Map<String, String> columnProperty) throws SQLException {
|
||||
String selectSql = "SELECT * FROM " + fromItem + (where != null ? " WHERE " + where : "");
|
||||
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
|
||||
List<ParameterMapping> whereMappings = parameterMappings.subList(Math.min(trimCount, parameterMappings.size()), parameterMappings.size());
|
||||
BoundSql selectBoundSql = new BoundSql(mappedStatement.getConfiguration(), selectSql, whereMappings, parameter);
|
||||
copyAdditionalParameters(boundSql, selectBoundSql);
|
||||
int captureLimit = dataRecordProperties.getMaxCaptureRows() + 1;
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Connection connection = executor.getTransaction().getConnection();
|
||||
try (PreparedStatement statement = connection.prepareStatement(selectSql)) {
|
||||
new DefaultParameterHandler(mappedStatement, parameter, selectBoundSql).setParameters(statement);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
ResultSetMetaData metaData = resultSet.getMetaData();
|
||||
int columnCount = metaData.getColumnCount();
|
||||
while (resultSet.next() && rows.size() < captureLimit) {
|
||||
Map<String, Object> row = new LinkedHashMap<>(columnCount);
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
row.put(CaptureKit.propertyName(columnProperty, metaData.getColumnLabel(i)),
|
||||
CaptureKit.normalizeValue(resultSet.getObject(i)));
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态SQL的附加参数(如 foreach 展开项)挂在原绑定对象上,回查语句需原样继承
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void copyAdditionalParameters(BoundSql source, BoundSql target) {
|
||||
Object additionalParameters = SystemMetaObject.forObject(source).getValue("additionalParameters");
|
||||
if (additionalParameters instanceof Map) {
|
||||
((Map<String, Object>) additionalParameters).forEach(target::setAdditionalParameter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 VALUES 子句的行分组,兼容单行与多行两种表达形式
|
||||
*/
|
||||
private List<List<Expression>> resolveValueRows(Values valuesClause) {
|
||||
ExpressionList<?> expressions = valuesClause.getExpressions();
|
||||
if (expressions == null || expressions.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
boolean multiRow = expressions.stream().allMatch(item -> item instanceof ExpressionList<?>);
|
||||
if (!multiRow) {
|
||||
List<Expression> singleRow = new ArrayList<>(expressions.size());
|
||||
for (Object expression : expressions) {
|
||||
singleRow.add((Expression) expression);
|
||||
}
|
||||
return Collections.singletonList(singleRow);
|
||||
}
|
||||
List<List<Expression>> valueRows = new ArrayList<>(expressions.size());
|
||||
for (Object rowGroup : expressions) {
|
||||
ExpressionList<?> rowExpressions = (ExpressionList<?>) rowGroup;
|
||||
List<Expression> row = new ArrayList<>(rowExpressions.size());
|
||||
for (Object expression : rowExpressions) {
|
||||
row.add((Expression) expression);
|
||||
}
|
||||
valueRows.add(row);
|
||||
}
|
||||
return valueRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析前的审计准入判断
|
||||
* <p>
|
||||
* 完整的语法解析成本较高,先以词法方式定位目标表名:未启用审计的表不承担解析成本,
|
||||
* 无法定位表名的语句(如 MERGE、多表删除)放弃审计。解析后仍以语法树中的表名复核。
|
||||
*
|
||||
* @return 是否跳过取证
|
||||
*/
|
||||
private boolean skipByProbableTable(String sql, SqlCommandType commandType, DataRecordDetector dataRecordDetector) {
|
||||
String[] tokens = sql.trim().split("\\s+", 4);
|
||||
String token = null;
|
||||
if (commandType == SqlCommandType.UPDATE && tokens.length > 1 && "UPDATE".equalsIgnoreCase(tokens[0])) {
|
||||
token = tokens[1];
|
||||
} else if (commandType == SqlCommandType.DELETE && tokens.length > 2
|
||||
&& "DELETE".equalsIgnoreCase(tokens[0]) && "FROM".equalsIgnoreCase(tokens[1])) {
|
||||
token = tokens[2];
|
||||
} else if (commandType == SqlCommandType.INSERT && tokens.length > 2
|
||||
&& "INSERT".equalsIgnoreCase(tokens[0]) && "INTO".equalsIgnoreCase(tokens[1])) {
|
||||
token = tokens[2];
|
||||
}
|
||||
if (token == null) {
|
||||
return true;
|
||||
}
|
||||
// 列清单可能与表名连写,模式名前缀不参与准入比对
|
||||
int parenIndex = token.indexOf('(');
|
||||
if (parenIndex > 0) {
|
||||
token = token.substring(0, parenIndex);
|
||||
}
|
||||
String tableName = CaptureKit.unquote(token);
|
||||
int dotIndex = tableName.lastIndexOf('.');
|
||||
if (dotIndex >= 0) {
|
||||
tableName = tableName.substring(dotIndex + 1);
|
||||
}
|
||||
return tableName.isEmpty() || dataRecordDetector.skipRecord(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析SQL语句,解析失败返回null由调用方放弃取证
|
||||
*/
|
||||
private Statement parse(String sql, String statementId) {
|
||||
try {
|
||||
return CCJSqlParserUtil.parse(sql);
|
||||
} catch (Exception exception) {
|
||||
log.debug("解析自定义语句失败,跳过审计, statement={}", statementId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否存在关联表
|
||||
*/
|
||||
private boolean hasJoin(List<?> joins) {
|
||||
return joins != null && !joins.isEmpty();
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.capture;
|
||||
|
||||
/**
|
||||
* 审计语句类型
|
||||
* <p>
|
||||
* 通用 Mapper 的标准方法可直接从参数对象获取实体与条件语义,走零 SQL 解析的实体路径;
|
||||
* 无法归类的语句统一落入 CUSTOM,由 SQL 解析路径兜底。
|
||||
* <p>
|
||||
* 逻辑删除表的 delete 系列方法实际生成 UPDATE 语句,语义仍按删除处理,
|
||||
* 因此类型判定以方法名为准,而非语句的 SqlCommandType。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public enum StatementKind {
|
||||
|
||||
/**
|
||||
* 单实体写入(insert、insertIgnore、replace)
|
||||
*/
|
||||
INSERT_ENTITY,
|
||||
|
||||
/**
|
||||
* 批量实体写入(insertBatchSomeColumn)
|
||||
*/
|
||||
INSERT_BATCH,
|
||||
|
||||
/**
|
||||
* 按主键更新(updateById)
|
||||
*/
|
||||
UPDATE_BY_ID,
|
||||
|
||||
/**
|
||||
* 按条件构造器更新(update)
|
||||
*/
|
||||
UPDATE_BY_WRAPPER,
|
||||
|
||||
/**
|
||||
* 按主键删除(deleteById)
|
||||
*/
|
||||
DELETE_BY_ID,
|
||||
|
||||
/**
|
||||
* 按主键集合删除(deleteBatchIds、deleteByIds)
|
||||
*/
|
||||
DELETE_BY_IDS,
|
||||
|
||||
/**
|
||||
* 按条件构造器删除(delete)
|
||||
*/
|
||||
DELETE_BY_WRAPPER,
|
||||
|
||||
/**
|
||||
* 自定义语句,走 SQL 解析路径
|
||||
*/
|
||||
CUSTOM
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.config;
|
||||
|
||||
import org.springblade.core.datarecord.dispatch.DataRecordEventDispatcher;
|
||||
import org.springblade.core.datarecord.interceptor.BladeDataRecordInterceptor;
|
||||
import org.springblade.core.datarecord.processor.BladeDataRecordDetector;
|
||||
import org.springblade.core.datarecord.processor.BladeDataRecordHandler;
|
||||
import org.springblade.core.datarecord.processor.BladeDataRecordParser;
|
||||
import org.springblade.core.datarecord.processor.DataRecordDetector;
|
||||
import org.springblade.core.datarecord.processor.DataRecordHandler;
|
||||
import org.springblade.core.datarecord.processor.DataRecordParser;
|
||||
import org.springblade.core.datarecord.props.DataRecordProperties;
|
||||
import org.springblade.core.launch.props.BladeProperties;
|
||||
import org.springblade.core.launch.server.ServerInfo;
|
||||
import org.springblade.core.mp.config.MybatisPlusConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 数据审计配置类
|
||||
* <p>
|
||||
* 数据变更拦截器为原生 MyBatis 插件,声明为 Bean 后由 MyBatis 自动配置
|
||||
* 统一收集并注入插件体系,无需挂接 MyBatis-Plus 的拦截器链。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(value = "blade.data-record.enabled", matchIfMissing = true)
|
||||
@AutoConfigureBefore(MybatisPlusConfiguration.class)
|
||||
@EnableConfigurationProperties(DataRecordProperties.class)
|
||||
public class DataRecordConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DataRecordHandler.class)
|
||||
public DataRecordHandler dataRecordHandler() {
|
||||
return new BladeDataRecordHandler();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DataRecordDetector.class)
|
||||
public DataRecordDetector dataRecordDetector(DataRecordProperties dataRecordProperties) {
|
||||
return new BladeDataRecordDetector(dataRecordProperties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DataRecordParser.class)
|
||||
public DataRecordParser dataRecordParser(DataRecordDetector dataRecordDetector,
|
||||
DataRecordProperties dataRecordProperties,
|
||||
BladeProperties bladeProperties,
|
||||
ServerInfo serverInfo) {
|
||||
return new BladeDataRecordParser(dataRecordDetector, dataRecordProperties, bladeProperties, serverInfo);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DataRecordEventDispatcher.class)
|
||||
public DataRecordEventDispatcher dataRecordEventDispatcher(DataRecordProperties dataRecordProperties,
|
||||
DataRecordParser dataRecordParser,
|
||||
DataRecordHandler dataRecordHandler) {
|
||||
return new DataRecordEventDispatcher(dataRecordProperties, dataRecordParser, dataRecordHandler);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(BladeDataRecordInterceptor.class)
|
||||
public BladeDataRecordInterceptor bladeDataRecordInterceptor(DataRecordProperties dataRecordProperties,
|
||||
DataRecordDetector dataRecordDetector,
|
||||
DataRecordEventDispatcher dataRecordEventDispatcher) {
|
||||
return new BladeDataRecordInterceptor(dataRecordProperties, dataRecordDetector, dataRecordEventDispatcher);
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.constant;
|
||||
|
||||
/**
|
||||
* 数据审计常量
|
||||
* <p>
|
||||
* 定义数据审计模块中使用的各种常量
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
public class DataRecordConstant {
|
||||
|
||||
// ==================== 操作符常量 ====================
|
||||
/**
|
||||
* 不等于操作符
|
||||
*/
|
||||
public static final String NOT_EQUALS = " != ";
|
||||
|
||||
/**
|
||||
* 等于操作符
|
||||
*/
|
||||
public static final String EQUALS = " == ";
|
||||
|
||||
/**
|
||||
* 大于等于操作符
|
||||
*/
|
||||
public static final String GREATER_THAN_OR_EQUAL = " >= ";
|
||||
|
||||
/**
|
||||
* 小于等于操作符
|
||||
*/
|
||||
public static final String LESS_THAN_OR_EQUAL = " <= ";
|
||||
|
||||
/**
|
||||
* 大于操作符
|
||||
*/
|
||||
public static final String GREATER_THAN = " > ";
|
||||
|
||||
/**
|
||||
* 小于操作符
|
||||
*/
|
||||
public static final String LESS_THAN = " < ";
|
||||
|
||||
// ==================== 特殊值常量 ====================
|
||||
|
||||
/**
|
||||
* 旧数据字段名
|
||||
*/
|
||||
public static final String OLD_DATA = "oldData";
|
||||
|
||||
/**
|
||||
* 新数据字段名
|
||||
*/
|
||||
public static final String NEW_DATA = "newData";
|
||||
|
||||
/**
|
||||
* 旧字段值
|
||||
*/
|
||||
public static final String OLD_VALUE = "oldValue";
|
||||
|
||||
/**
|
||||
* 新字段值
|
||||
*/
|
||||
public static final String NEW_VALUE = "newValue";
|
||||
|
||||
/**
|
||||
* 空值字符串
|
||||
*/
|
||||
public static final String NULL_VALUE = "null";
|
||||
|
||||
/**
|
||||
* 匿名用户ID
|
||||
*/
|
||||
public static final String ANONYMOUS_USER_ID = "anonymous";
|
||||
|
||||
/**
|
||||
* 匿名用户名
|
||||
*/
|
||||
public static final String ANONYMOUS_USER_NAME = "Anonymous User";
|
||||
|
||||
// ==================== 表达式相关常量 ====================
|
||||
/**
|
||||
* 变量占位符前缀
|
||||
*/
|
||||
public static final String VARIABLE_PLACEHOLDER_PREFIX = "__VAR_";
|
||||
|
||||
/**
|
||||
* 变量占位符后缀
|
||||
*/
|
||||
public static final String VARIABLE_PLACEHOLDER_SUFFIX = "__";
|
||||
|
||||
/**
|
||||
* 引号字符
|
||||
*/
|
||||
public static final String QUOTE = "\"";
|
||||
|
||||
/**
|
||||
* 单引号字符
|
||||
*/
|
||||
public static final String SINGLE_QUOTE = "'";
|
||||
|
||||
// ==================== 正则表达式模式常量 ====================
|
||||
/**
|
||||
* 变量模式 - 匹配 #variable 或 #object.property 格式
|
||||
*/
|
||||
public static final String VARIABLE_PATTERN_REGEX = "#(\\w+(?:\\.\\w+)*)";
|
||||
|
||||
/**
|
||||
* 数值模式 - 匹配整数和小数
|
||||
*/
|
||||
public static final String NUMBER_PATTERN_REGEX = "^\\d+(?:\\.\\d+)?$";
|
||||
|
||||
/**
|
||||
* 字符串模式 - 匹配被引号包围的字符串
|
||||
*/
|
||||
public static final String STRING_PATTERN_REGEX = "^[\"'](.+?)[\"']$";
|
||||
|
||||
// ==================== SQL相关常量 ====================
|
||||
/**
|
||||
* SQL字符清理模式 - 清理引号和括号
|
||||
*/
|
||||
public static final String SQL_CHAR_CLEANUP_REGEX = "[`\"\\[\\]]";
|
||||
|
||||
/**
|
||||
* SQL入库模式 - 用于插入数据记录的SQL语句模板
|
||||
*/
|
||||
public final static String RECORD_INSERT_SQL = """
|
||||
INSERT INTO blade_record_data (
|
||||
id, service_id, server_host, server_ip, env, record_level, method,
|
||||
request_uri, user_agent, remote_ip, operation, table_name,
|
||||
old_data, new_data, record_message, record_result, record_cost,
|
||||
record_time, record_user, status, is_deleted
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
/**
|
||||
* 私有构造函数,防止实例化
|
||||
*/
|
||||
private DataRecordConstant() {
|
||||
throw new UnsupportedOperationException("Utility class");
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.dispatch;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.expression.DataRecordEvaluator;
|
||||
import org.springblade.core.datarecord.model.DataChangeEvent;
|
||||
import org.springblade.core.datarecord.model.DataRecordInfo;
|
||||
import org.springblade.core.datarecord.processor.DataRecordHandler;
|
||||
import org.springblade.core.datarecord.processor.DataRecordParser;
|
||||
import org.springblade.core.datarecord.props.DataRecordProperties;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 数据审计事件分发器
|
||||
* <p>
|
||||
* 承接捕获层产出的变更事件,完成解析、条件评估与处理器调度。
|
||||
* 默认将事件挂靠到事务提交回调,回滚的事务不产生审计记录,保证审计与数据的一致性;
|
||||
* 注解声明异步的事件投递到专用线程池处理,队列满时回退由提交线程执行以避免丢失记录。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
public class DataRecordEventDispatcher implements DisposableBean {
|
||||
|
||||
private final DataRecordProperties dataRecordProperties;
|
||||
|
||||
private final DataRecordParser dataRecordParser;
|
||||
|
||||
private final DataRecordHandler dataRecordHandler;
|
||||
|
||||
private final ThreadPoolExecutor asyncExecutor;
|
||||
|
||||
public DataRecordEventDispatcher(DataRecordProperties dataRecordProperties,
|
||||
DataRecordParser dataRecordParser,
|
||||
DataRecordHandler dataRecordHandler) {
|
||||
this.dataRecordProperties = dataRecordProperties;
|
||||
this.dataRecordParser = dataRecordParser;
|
||||
this.dataRecordHandler = dataRecordHandler;
|
||||
AtomicInteger threadNumber = new AtomicInteger(1);
|
||||
this.asyncExecutor = new ThreadPoolExecutor(
|
||||
dataRecordProperties.getAsyncPoolSize(), dataRecordProperties.getAsyncPoolSize(),
|
||||
60L, TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(dataRecordProperties.getAsyncQueueCapacity()),
|
||||
runnable -> {
|
||||
Thread thread = new Thread(runnable, "blade-data-record-" + threadNumber.getAndIncrement());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
},
|
||||
new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
this.asyncExecutor.allowCoreThreadTimeOut(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发变更事件
|
||||
*
|
||||
* @param event 变更事件
|
||||
* @param dataRecord 数据审计配置
|
||||
*/
|
||||
public void dispatch(DataChangeEvent event, DataRecord dataRecord) {
|
||||
if (Boolean.TRUE.equals(dataRecordProperties.getAfterCommit()) && TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
deliver(event, dataRecord);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
deliver(event, dataRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按注解声明的处理方式投递事件
|
||||
*
|
||||
* @param event 变更事件
|
||||
* @param dataRecord 数据审计配置
|
||||
*/
|
||||
private void deliver(DataChangeEvent event, DataRecord dataRecord) {
|
||||
if (dataRecord.async()) {
|
||||
asyncExecutor.execute(() -> process(event, dataRecord));
|
||||
} else {
|
||||
process(event, dataRecord);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析事件并调度处理器,处理失败只记录日志,不向业务侧传播
|
||||
*
|
||||
* @param event 变更事件
|
||||
* @param dataRecord 数据审计配置
|
||||
*/
|
||||
private void process(DataChangeEvent event, DataRecord dataRecord) {
|
||||
try {
|
||||
List<DataRecordInfo> recordList = dataRecordParser.parse(event, dataRecord);
|
||||
if (recordList == null || recordList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (DataRecordInfo recordInfo : recordList) {
|
||||
if (DataRecordEvaluator.evaluateEntityCondition(dataRecord.condition(), recordInfo.getOldData(), recordInfo.getNewData())) {
|
||||
dataRecordHandler.handle(recordInfo, dataRecord);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.error("数据审计处理失败, table={}, statement={}", event.getTableName(), event.getStatementId(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
asyncExecutor.shutdown();
|
||||
}
|
||||
}
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.expression;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.datarecord.constant.DataRecordConstant;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 表达式评估器
|
||||
* <p>
|
||||
* 支持简单的条件表达式评估,用于DataRecord和FieldRecord的条件判断。
|
||||
* <p>
|
||||
* 特性:
|
||||
* - 支持基本的比较操作符:==, !=, >, <, >=, <=
|
||||
* - 支持类级别变量访问:#oldData.field, #newData.field
|
||||
* - 支持字段级别变量访问:#oldValue, #newValue
|
||||
* - 支持大小写不敏感的字段访问:#oldData.category 可以匹配 CATEGORY、Category 等
|
||||
* - 表达式缓存机制提升性能
|
||||
* - 宽松的错误处理,失败时默认允许记录
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
@Slf4j
|
||||
public class DataRecordEvaluator {
|
||||
|
||||
/**
|
||||
* 表达式缓存
|
||||
*/
|
||||
private static final Map<String, CompiledExpression> EXPRESSION_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 变量模式 - 匹配 #variable 或 #object.property 格式
|
||||
*/
|
||||
private static final Pattern VARIABLE_PATTERN = Pattern.compile(DataRecordConstant.VARIABLE_PATTERN_REGEX);
|
||||
|
||||
/**
|
||||
* 数值模式 - 匹配整数和小数
|
||||
*/
|
||||
private static final Pattern NUMBER_PATTERN = Pattern.compile(DataRecordConstant.NUMBER_PATTERN_REGEX);
|
||||
|
||||
/**
|
||||
* 字符串模式 - 匹配被引号包围的字符串
|
||||
*/
|
||||
private static final Pattern STRING_PATTERN = Pattern.compile(DataRecordConstant.STRING_PATTERN_REGEX);
|
||||
|
||||
/**
|
||||
* 评估实体级别的条件表达式
|
||||
*
|
||||
* @param condition 条件表达式
|
||||
* @param oldData 修改前数据
|
||||
* @param newData 修改后数据
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
public static boolean evaluateEntityCondition(String condition, Map<String, Object> oldData, Map<String, Object> newData) {
|
||||
if (StringUtil.isBlank(condition)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建上下文
|
||||
EvaluationContext context = new EvaluationContext();
|
||||
context.putVariable(DataRecordConstant.OLD_DATA, oldData);
|
||||
context.putVariable(DataRecordConstant.NEW_DATA, newData);
|
||||
|
||||
return evaluateCondition(condition, context);
|
||||
} catch (Exception e) {
|
||||
if (!Func.hasEmpty(oldData, newData)) {
|
||||
log.warn("评估实体条件表达式失败: {}, 错误: {}", condition, e.getMessage());
|
||||
}
|
||||
return true; // 表达式错误时默认允许记录
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估字段级别的条件表达式
|
||||
*
|
||||
* @param condition 条件表达式
|
||||
* @param oldValue 旧值
|
||||
* @param newValue 新值
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
public static boolean evaluateFieldCondition(String condition, Object oldValue, Object newValue) {
|
||||
if (StringUtil.isBlank(condition)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建上下文
|
||||
EvaluationContext context = new EvaluationContext();
|
||||
context.putVariable(DataRecordConstant.OLD_VALUE, oldValue);
|
||||
context.putVariable(DataRecordConstant.NEW_VALUE, newValue);
|
||||
|
||||
return evaluateCondition(condition, context);
|
||||
} catch (Exception e) {
|
||||
if (!Func.hasEmpty(oldValue, newValue)) {
|
||||
log.warn("评估字段条件表达式失败: {}, 错误: {}", condition, e.getMessage());
|
||||
}
|
||||
return true; // 表达式错误时默认允许记录
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证表达式语法
|
||||
*
|
||||
* @param condition 条件表达式
|
||||
* @return 验证结果
|
||||
*/
|
||||
public static ExpressionValidationResult validateExpression(String condition) {
|
||||
if (StringUtil.isBlank(condition)) {
|
||||
return ExpressionValidationResult.valid();
|
||||
}
|
||||
|
||||
try {
|
||||
// 编译表达式检查语法
|
||||
CompiledExpression compiled = compileExpression(condition);
|
||||
|
||||
// 检查是否包含有效的操作符
|
||||
String template = compiled.template;
|
||||
boolean hasOperator = template.contains(DataRecordConstant.EQUALS) || template.contains(DataRecordConstant.NOT_EQUALS) ||
|
||||
template.contains(DataRecordConstant.GREATER_THAN) || template.contains(DataRecordConstant.LESS_THAN) ||
|
||||
template.contains(DataRecordConstant.GREATER_THAN_OR_EQUAL) || template.contains(DataRecordConstant.LESS_THAN_OR_EQUAL);
|
||||
|
||||
if (!hasOperator && !compiled.variables.isEmpty()) {
|
||||
return ExpressionValidationResult.invalid("表达式缺少有效的比较操作符");
|
||||
}
|
||||
|
||||
return ExpressionValidationResult.valid();
|
||||
} catch (Exception e) {
|
||||
return ExpressionValidationResult.invalid("表达式语法错误: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估条件表达式
|
||||
*
|
||||
* @param condition 条件表达式
|
||||
* @param context 评估上下文
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private static boolean evaluateCondition(String condition, EvaluationContext context) {
|
||||
// 从缓存获取或编译表达式
|
||||
CompiledExpression compiled = EXPRESSION_CACHE.computeIfAbsent(condition, DataRecordEvaluator::compileExpression);
|
||||
|
||||
// 替换变量
|
||||
String expression = compiled.template;
|
||||
for (Map.Entry<String, String> entry : compiled.variables.entrySet()) {
|
||||
String variable = entry.getKey();
|
||||
String placeholder = entry.getValue();
|
||||
// 如果 value 为 null,则代表此变量未参与记录,将跳过表达式验证
|
||||
Object value = context.getVariableValue(variable);
|
||||
String valueStr = convertToString(value);
|
||||
expression = expression.replace(placeholder, valueStr);
|
||||
}
|
||||
|
||||
// 评估表达式
|
||||
return evaluateSimpleExpression(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编译表达式
|
||||
*
|
||||
* @param condition 条件表达式
|
||||
* @return 编译后的表达式
|
||||
*/
|
||||
private static CompiledExpression compileExpression(String condition) {
|
||||
CompiledExpression compiled = new CompiledExpression();
|
||||
compiled.template = condition;
|
||||
compiled.variables = new ConcurrentHashMap<>();
|
||||
|
||||
Matcher matcher = VARIABLE_PATTERN.matcher(condition);
|
||||
int index = 0;
|
||||
while (matcher.find()) {
|
||||
String variable = matcher.group(1);
|
||||
String placeholder = DataRecordConstant.VARIABLE_PLACEHOLDER_PREFIX + (index++) + DataRecordConstant.VARIABLE_PLACEHOLDER_SUFFIX;
|
||||
compiled.variables.put(variable, placeholder);
|
||||
compiled.template = compiled.template.replace("#" + variable, placeholder);
|
||||
}
|
||||
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估简单表达式
|
||||
*
|
||||
* @param expression 表达式
|
||||
* @return 评估结果
|
||||
*/
|
||||
private static boolean evaluateSimpleExpression(String expression) {
|
||||
expression = expression.trim();
|
||||
|
||||
// 处理 != 操作符(优先级高,避免与 == 冲突)
|
||||
if (expression.contains(DataRecordConstant.NOT_EQUALS)) {
|
||||
return evaluateEqualityExpression(expression, DataRecordConstant.NOT_EQUALS, true);
|
||||
}
|
||||
|
||||
// 处理 == 操作符
|
||||
if (expression.contains(DataRecordConstant.EQUALS)) {
|
||||
return evaluateEqualityExpression(expression, DataRecordConstant.EQUALS, false);
|
||||
}
|
||||
|
||||
// 处理 >= 操作符(优先级高,避免与 > 冲突)
|
||||
if (expression.contains(DataRecordConstant.GREATER_THAN_OR_EQUAL)) {
|
||||
return evaluateNumericExpression(expression, DataRecordConstant.GREATER_THAN_OR_EQUAL, (left, right) -> left >= right);
|
||||
}
|
||||
|
||||
// 处理 <= 操作符(优先级高,避免与 < 冲突)
|
||||
if (expression.contains(DataRecordConstant.LESS_THAN_OR_EQUAL)) {
|
||||
return evaluateNumericExpression(expression, DataRecordConstant.LESS_THAN_OR_EQUAL, (left, right) -> left <= right);
|
||||
}
|
||||
|
||||
// 处理 > 操作符
|
||||
if (expression.contains(DataRecordConstant.GREATER_THAN)) {
|
||||
return evaluateNumericExpression(expression, DataRecordConstant.GREATER_THAN, (left, right) -> left > right);
|
||||
}
|
||||
|
||||
// 处理 < 操作符
|
||||
if (expression.contains(DataRecordConstant.LESS_THAN)) {
|
||||
return evaluateNumericExpression(expression, DataRecordConstant.LESS_THAN, (left, right) -> left < right);
|
||||
}
|
||||
|
||||
// 如果无法解析,默认返回true
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估等式表达式
|
||||
*
|
||||
* @param expression 表达式
|
||||
* @param operator 操作符
|
||||
* @param negate 是否取反
|
||||
* @return 评估结果
|
||||
*/
|
||||
private static boolean evaluateEqualityExpression(String expression, String operator, boolean negate) {
|
||||
String[] parts = expression.split(operator, 2);
|
||||
if (parts.length == 2) {
|
||||
Object left = parseValue(parts[0].trim());
|
||||
Object right = parseValue(parts[1].trim());
|
||||
boolean result = objectEquals(left, right);
|
||||
return negate != result;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估数值表达式
|
||||
*
|
||||
* @param expression 表达式
|
||||
* @param operator 操作符
|
||||
* @param comparator 比较函数
|
||||
* @return 评估结果
|
||||
*/
|
||||
private static boolean evaluateNumericExpression(String expression, String operator, NumericComparator comparator) {
|
||||
String[] parts = expression.split(operator, 2);
|
||||
if (parts.length == 2) {
|
||||
Number left = parseNumber(parts[0].trim());
|
||||
Number right = parseNumber(parts[1].trim());
|
||||
if (left != null && right != null) {
|
||||
return comparator.compare(left.doubleValue(), right.doubleValue());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数值比较函数接口
|
||||
*/
|
||||
private interface NumericComparator {
|
||||
boolean compare(double left, double right);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析值
|
||||
*
|
||||
* @param value 值字符串
|
||||
* @return 解析后的对象
|
||||
*/
|
||||
private static Object parseValue(String value) {
|
||||
if (value == null || DataRecordConstant.NULL_VALUE.equals(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 使用 NUMBER_PATTERN 精确匹配数字
|
||||
if (NUMBER_PATTERN.matcher(value).matches()) {
|
||||
return parseNumber(value);
|
||||
}
|
||||
|
||||
// 使用 STRING_PATTERN 匹配被引号包围的字符串
|
||||
Matcher stringMatcher = STRING_PATTERN.matcher(value);
|
||||
if (stringMatcher.matches()) {
|
||||
return stringMatcher.group(1); // 返回引号内的内容
|
||||
}
|
||||
|
||||
// 移除引号(兼容旧格式)
|
||||
if ((value.startsWith(DataRecordConstant.QUOTE) && value.endsWith(DataRecordConstant.QUOTE)) ||
|
||||
(value.startsWith(DataRecordConstant.SINGLE_QUOTE) && value.endsWith(DataRecordConstant.SINGLE_QUOTE))) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数字
|
||||
*
|
||||
* @param value 值字符串
|
||||
* @return 数字或null
|
||||
*/
|
||||
private static Number parseNumber(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 NUMBER_PATTERN 验证格式
|
||||
if (!NUMBER_PATTERN.matcher(value).matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.contains(".")) {
|
||||
return Double.parseDouble(value);
|
||||
} else {
|
||||
// 优先使用 Long,如果超出范围则使用 Double
|
||||
try {
|
||||
return Long.parseLong(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return Double.parseDouble(value);
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对象相等比较
|
||||
*
|
||||
* @param left 左值
|
||||
* @param right 右值
|
||||
* @return 是否相等
|
||||
*/
|
||||
private static boolean objectEquals(Object left, Object right) {
|
||||
if (left == null && right == null) {
|
||||
return true;
|
||||
}
|
||||
if (left == null || right == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果都是数字类型,使用数值比较
|
||||
if (left instanceof Number && right instanceof Number) {
|
||||
return ((Number) left).doubleValue() == ((Number) right).doubleValue();
|
||||
}
|
||||
|
||||
return left.toString().equals(right.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为字符串
|
||||
*
|
||||
* @param value 值
|
||||
* @return 字符串表示
|
||||
*/
|
||||
private static String convertToString(Object value) {
|
||||
if (value == null) {
|
||||
return DataRecordConstant.NULL_VALUE;
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return DataRecordConstant.QUOTE + value + DataRecordConstant.QUOTE;
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 编译后的表达式
|
||||
*/
|
||||
private static class CompiledExpression {
|
||||
String template;
|
||||
Map<String, String> variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估上下文
|
||||
*/
|
||||
private static class EvaluationContext {
|
||||
private final Map<String, Object> variables = new ConcurrentHashMap<>();
|
||||
|
||||
void putVariable(String name, Object value) {
|
||||
variables.put(name, value);
|
||||
}
|
||||
|
||||
Object getVariableValue(String path) {
|
||||
String[] parts = path.split("\\.");
|
||||
Object current = variables.get(parts[0]);
|
||||
|
||||
if (current == null || parts.length == 1) {
|
||||
return current;
|
||||
}
|
||||
|
||||
// 简单的属性访问
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
if (current instanceof Map<?, ?> map) {
|
||||
String fieldName = parts[i];
|
||||
|
||||
// 优先尝试精确匹配
|
||||
current = map.get(fieldName);
|
||||
|
||||
// 如果精确匹配失败,尝试大小写不敏感匹配
|
||||
if (current == null) {
|
||||
current = getCaseInsensitiveMapValue(map, fieldName);
|
||||
}
|
||||
} else {
|
||||
// 通过反射访问属性
|
||||
try {
|
||||
String fieldName = parts[i];
|
||||
java.lang.reflect.Field field = current.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
current = field.get(current);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (current == null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 大小写不敏感地从Map中获取值
|
||||
*
|
||||
* @param map Map对象
|
||||
* @param fieldName 字段名
|
||||
* @return 匹配的值,如果没找到返回null
|
||||
*/
|
||||
private Object getCaseInsensitiveMapValue(Map<?, ?> map, String fieldName) {
|
||||
// 首先尝试转换为大写匹配(数据库字段通常是大写)
|
||||
Object value = map.get(fieldName.toUpperCase());
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 如果大写匹配失败,遍历所有key进行大小写不敏感匹配
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
Object key = entry.getKey();
|
||||
if (key instanceof String && fieldName.equalsIgnoreCase((String) key)) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达式验证结果
|
||||
*/
|
||||
@Getter
|
||||
public static class ExpressionValidationResult {
|
||||
private final boolean valid;
|
||||
private final String errorMessage;
|
||||
|
||||
private ExpressionValidationResult(boolean valid, String errorMessage) {
|
||||
this.valid = valid;
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public static ExpressionValidationResult valid() {
|
||||
return new ExpressionValidationResult(true, null);
|
||||
}
|
||||
|
||||
public static ExpressionValidationResult invalid(String errorMessage) {
|
||||
return new ExpressionValidationResult(false, errorMessage);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.interceptor;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.executor.BatchExecutor;
|
||||
import org.apache.ibatis.executor.Executor;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.mapping.SqlCommandType;
|
||||
import org.apache.ibatis.plugin.Interceptor;
|
||||
import org.apache.ibatis.plugin.Intercepts;
|
||||
import org.apache.ibatis.plugin.Invocation;
|
||||
import org.apache.ibatis.plugin.Signature;
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.capture.AuditPlan;
|
||||
import org.springblade.core.datarecord.capture.AuditPlanResolver;
|
||||
import org.springblade.core.datarecord.capture.EntityChangeCapture;
|
||||
import org.springblade.core.datarecord.capture.SqlChangeCapture;
|
||||
import org.springblade.core.datarecord.dispatch.DataRecordEventDispatcher;
|
||||
import org.springblade.core.datarecord.model.DataChangeEvent;
|
||||
import org.springblade.core.datarecord.model.RecordContext;
|
||||
import org.springblade.core.datarecord.processor.DataRecordDetector;
|
||||
import org.springblade.core.datarecord.props.DataRecordProperties;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据变更记录插件
|
||||
* <p>
|
||||
* 原生 MyBatis 拦截器,挂载在 Executor.update 这一所有 DML 的唯一必经入口,
|
||||
* 不依赖 MyBatis-Plus 的插件链,注册为 Spring Bean 后由 MyBatis 自动配置注入插件体系。
|
||||
* <p>
|
||||
* 取证流水线:门卫(按语句缓存审计决策)-> 前镜像采集(同事务内)-> 语句执行 ->
|
||||
* 后镜像与影响行数采集 -> 上下文快照 -> 事务感知分发。写入数据在语句执行后采集,
|
||||
* 此时主键分配与公共字段填充均已完成;执行失败或未命中任何行的语句不产生审计事件。
|
||||
* <p>
|
||||
* 审计是业务执行的旁路:取证与分发环节的任何异常只降级放弃本次审计,绝不影响业务语句。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
|
||||
public class BladeDataRecordInterceptor implements Interceptor {
|
||||
|
||||
private final DataRecordProperties dataRecordProperties;
|
||||
|
||||
private final DataRecordDetector dataRecordDetector;
|
||||
|
||||
private final DataRecordEventDispatcher eventDispatcher;
|
||||
|
||||
private final AuditPlanResolver planResolver;
|
||||
|
||||
private final EntityChangeCapture entityCapture;
|
||||
|
||||
private final SqlChangeCapture sqlCapture;
|
||||
|
||||
public BladeDataRecordInterceptor(DataRecordProperties dataRecordProperties,
|
||||
DataRecordDetector dataRecordDetector,
|
||||
DataRecordEventDispatcher eventDispatcher) {
|
||||
this.dataRecordProperties = dataRecordProperties;
|
||||
this.dataRecordDetector = dataRecordDetector;
|
||||
this.eventDispatcher = eventDispatcher;
|
||||
this.planResolver = new AuditPlanResolver(dataRecordDetector, dataRecordProperties);
|
||||
this.entityCapture = new EntityChangeCapture(dataRecordProperties);
|
||||
this.sqlCapture = new SqlChangeCapture(dataRecordProperties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object intercept(Invocation invocation) throws Throwable {
|
||||
if (!Boolean.TRUE.equals(dataRecordProperties.getEnabled())) {
|
||||
return invocation.proceed();
|
||||
}
|
||||
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
|
||||
SqlCommandType commandType = mappedStatement.getSqlCommandType();
|
||||
if (commandType != SqlCommandType.INSERT && commandType != SqlCommandType.UPDATE && commandType != SqlCommandType.DELETE) {
|
||||
return invocation.proceed();
|
||||
}
|
||||
AuditPlan plan = planResolver.resolve(mappedStatement);
|
||||
if (plan.isSkip()) {
|
||||
return invocation.proceed();
|
||||
}
|
||||
Object parameter = invocation.getArgs()[1];
|
||||
Executor executor = (Executor) invocation.getTarget();
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
DataChangeEvent event = null;
|
||||
try {
|
||||
event = captureBefore(plan, mappedStatement, parameter, executor, commandType);
|
||||
} catch (Throwable captureError) {
|
||||
log.warn("数据审计取证失败,本次操作跳过审计, statement={}", mappedStatement.getId(), captureError);
|
||||
}
|
||||
|
||||
Object result = invocation.proceed();
|
||||
|
||||
try {
|
||||
event = captureAfter(event, plan, mappedStatement, parameter, commandType, result);
|
||||
if (event != null) {
|
||||
event.setCost(System.currentTimeMillis() - startTime);
|
||||
event.setContext(RecordContext.capture());
|
||||
DataRecord dataRecord = plan.isCustom() ? dataRecordDetector.getTableDataRecord(event.getTableName()) : plan.getDataRecord();
|
||||
if (dataRecord != null) {
|
||||
eventDispatcher.dispatch(event, dataRecord);
|
||||
}
|
||||
}
|
||||
} catch (Throwable recordError) {
|
||||
log.warn("数据审计事件构建失败,本次操作跳过审计, statement={}", mappedStatement.getId(), recordError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语句执行前的取证:更新与删除的前镜像必须在此刻、同一事务内采集
|
||||
* <p>
|
||||
* 操作语义以审计计划的语句类型为准而非 SqlCommandType:逻辑删除表的 delete
|
||||
* 系列方法实际生成 UPDATE 语句,审计仍应记录为携带完整前镜像的删除。
|
||||
*
|
||||
* @return 变更事件骨架,无审计价值时返回null
|
||||
*/
|
||||
private DataChangeEvent captureBefore(AuditPlan plan, MappedStatement mappedStatement, Object parameter,
|
||||
Executor executor, SqlCommandType commandType) throws SQLException {
|
||||
if (plan.isCustom()) {
|
||||
return commandType == SqlCommandType.INSERT ? null
|
||||
: sqlCapture.captureUpdateDelete(mappedStatement, parameter, executor, dataRecordDetector);
|
||||
}
|
||||
SqlCommandType operation = switch (plan.getKind()) {
|
||||
case UPDATE_BY_ID, UPDATE_BY_WRAPPER -> SqlCommandType.UPDATE;
|
||||
case DELETE_BY_ID, DELETE_BY_IDS, DELETE_BY_WRAPPER -> SqlCommandType.DELETE;
|
||||
default -> SqlCommandType.INSERT;
|
||||
};
|
||||
if (operation == SqlCommandType.INSERT) {
|
||||
return null;
|
||||
}
|
||||
List<Map<String, Object>> beforeRows = entityCapture.beforeImage(plan, mappedStatement, parameter, executor);
|
||||
if (beforeRows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
DataChangeEvent event = new DataChangeEvent()
|
||||
.setTableName(plan.getTableName())
|
||||
.setOperation(operation)
|
||||
.setStatementId(mappedStatement.getId())
|
||||
.setKeyProperty(plan.getTableInfo().getKeyProperty());
|
||||
applyRows(event, beforeRows, true);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语句执行后的取证:补齐影响行数,并采集写入语句的落库数据
|
||||
*
|
||||
* @return 完整的变更事件,本次执行未产生实际变更时返回null
|
||||
*/
|
||||
private DataChangeEvent captureAfter(DataChangeEvent event, AuditPlan plan, MappedStatement mappedStatement,
|
||||
Object parameter, SqlCommandType commandType, Object result) {
|
||||
int affectedRows = resolveAffectedRows(result);
|
||||
if (affectedRows == 0) {
|
||||
return null;
|
||||
}
|
||||
if (event == null) {
|
||||
if (commandType != SqlCommandType.INSERT) {
|
||||
return null;
|
||||
}
|
||||
event = plan.isCustom()
|
||||
? sqlCapture.captureInsert(mappedStatement, parameter, dataRecordDetector)
|
||||
: buildInsertEvent(plan, mappedStatement, parameter);
|
||||
if (event == null || event.getAfterRows() == null || event.getAfterRows().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// SET 值在语句执行后从实体与构造器读取:公共字段的自动填充发生在参数绑定阶段,
|
||||
// 执行后实体对象上才具备完整的落库值
|
||||
if (!plan.isCustom() && event.getOperation() == SqlCommandType.UPDATE && event.getUpdateValues() == null) {
|
||||
event.setUpdateValues(entityCapture.updateValues(plan, parameter));
|
||||
}
|
||||
event.setAffectedRows(affectedRows);
|
||||
event.setSuccess(true);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建实体路径的写入事件
|
||||
*/
|
||||
private DataChangeEvent buildInsertEvent(AuditPlan plan, MappedStatement mappedStatement, Object parameter) {
|
||||
List<Map<String, Object>> afterRows = entityCapture.insertRows(plan, parameter);
|
||||
if (afterRows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
DataChangeEvent event = new DataChangeEvent()
|
||||
.setTableName(plan.getTableName())
|
||||
.setOperation(SqlCommandType.INSERT)
|
||||
.setStatementId(mappedStatement.getId())
|
||||
.setKeyProperty(plan.getTableInfo().getKeyProperty());
|
||||
applyRows(event, afterRows, false);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 装载镜像行集并处理截断标记,采集上限之外的行不进入事件
|
||||
*/
|
||||
private void applyRows(DataChangeEvent event, List<Map<String, Object>> rows, boolean before) {
|
||||
int maxCaptureRows = dataRecordProperties.getMaxCaptureRows();
|
||||
if (rows.size() > maxCaptureRows) {
|
||||
event.setOverflow(true);
|
||||
rows = rows.subList(0, maxCaptureRows);
|
||||
}
|
||||
if (before) {
|
||||
event.setBeforeRows(rows);
|
||||
} else {
|
||||
event.setAfterRows(rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析影响行数,批量执行器返回哨兵值时无法即时统计,约定为 -1 视作生效
|
||||
*/
|
||||
private int resolveAffectedRows(Object result) {
|
||||
if (!(result instanceof Integer affectedRows)) {
|
||||
return -1;
|
||||
}
|
||||
return affectedRows == BatchExecutor.BATCH_UPDATE_RETURN_VALUE ? -1 : affectedRows;
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.apache.ibatis.mapping.SqlCommandType;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据变更事件
|
||||
* <p>
|
||||
* 审计域的自有模型,捕获层与解析层之间的唯一契约。前后镜像以「实体属性名 -> 类型化值」
|
||||
* 的形式直接传递:属性名在不同数据库间保持稳定(列名大小写因数据库元数据实现而异),
|
||||
* 且与 @FieldRecord 注解的字段定位、条件表达式的取值路径天然一致。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class DataChangeEvent {
|
||||
|
||||
/**
|
||||
* 表名
|
||||
*/
|
||||
private String tableName;
|
||||
|
||||
/**
|
||||
* 操作类型(INSERT、UPDATE、DELETE)
|
||||
*/
|
||||
private SqlCommandType operation;
|
||||
|
||||
/**
|
||||
* MappedStatement 标识
|
||||
*/
|
||||
private String statementId;
|
||||
|
||||
/**
|
||||
* 主键属性名
|
||||
*/
|
||||
private String keyProperty;
|
||||
|
||||
/**
|
||||
* 变更前镜像,每个命中行一条(UPDATE、DELETE)
|
||||
*/
|
||||
private List<Map<String, Object>> beforeRows;
|
||||
|
||||
/**
|
||||
* 写入数据,每个实体一条(INSERT)
|
||||
*/
|
||||
private List<Map<String, Object>> afterRows;
|
||||
|
||||
/**
|
||||
* SET 赋值内容,对本次语句命中的所有行统一生效(UPDATE)
|
||||
*/
|
||||
private Map<String, Object> updateValues;
|
||||
|
||||
/**
|
||||
* 影响行数,批量执行器下无法即时统计时约定为 -1
|
||||
*/
|
||||
private int affectedRows;
|
||||
|
||||
/**
|
||||
* 执行是否生效
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 语句执行与取证的总耗时(毫秒)
|
||||
*/
|
||||
private long cost;
|
||||
|
||||
/**
|
||||
* 镜像行数超出采集上限被截断
|
||||
*/
|
||||
private boolean overflow;
|
||||
|
||||
/**
|
||||
* 操作上下文快照
|
||||
*/
|
||||
private RecordContext context;
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据审计信息
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class DataRecordInfo {
|
||||
|
||||
/**
|
||||
* 记录ID
|
||||
*/
|
||||
private Long recordId;
|
||||
|
||||
/**
|
||||
* 操作用户ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 操作用户名
|
||||
*/
|
||||
private String userName;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 服务ID
|
||||
*/
|
||||
protected String serviceId;
|
||||
|
||||
/**
|
||||
* 服务器 ip
|
||||
*/
|
||||
protected String serverIp;
|
||||
|
||||
/**
|
||||
* 服务器名
|
||||
*/
|
||||
protected String serverHost;
|
||||
|
||||
/**
|
||||
* 环境
|
||||
*/
|
||||
protected String env;
|
||||
|
||||
/**
|
||||
* 操作IP地址
|
||||
*/
|
||||
protected String remoteIp;
|
||||
|
||||
/**
|
||||
* 用户代理
|
||||
*/
|
||||
protected String userAgent;
|
||||
|
||||
/**
|
||||
* 请求URI
|
||||
*/
|
||||
protected String requestUri;
|
||||
|
||||
/**
|
||||
* 操作方式
|
||||
*/
|
||||
protected String method;
|
||||
|
||||
/**
|
||||
* 业务模块
|
||||
*/
|
||||
private String module;
|
||||
|
||||
/**
|
||||
* 操作类型(INSERT、UPDATE、DELETE)
|
||||
*/
|
||||
private String operation;
|
||||
|
||||
/**
|
||||
* 表名
|
||||
*/
|
||||
private String tableName;
|
||||
|
||||
/**
|
||||
* 主键字段名
|
||||
*/
|
||||
private String primaryKey;
|
||||
|
||||
/**
|
||||
* 主键值
|
||||
*/
|
||||
private Object primaryKeyValue;
|
||||
|
||||
/**
|
||||
* 记录状态
|
||||
*/
|
||||
private Boolean recordStatus;
|
||||
|
||||
/**
|
||||
* 执行耗时(毫秒)
|
||||
*/
|
||||
private Long cost;
|
||||
|
||||
/**
|
||||
* 记录时间
|
||||
*/
|
||||
private LocalDateTime recordTime;
|
||||
|
||||
/**
|
||||
* 修改前的完整数据
|
||||
*/
|
||||
private Map<String, Object> oldData;
|
||||
|
||||
/**
|
||||
* 修改后的完整数据
|
||||
*/
|
||||
private Map<String, Object> newData;
|
||||
|
||||
/**
|
||||
* 变更的字段数据(只包含发生变化的字段)
|
||||
*/
|
||||
private Map<String, FieldChangeInfo> changeData;
|
||||
|
||||
/**
|
||||
* 原始记录数据(OperationResult的原始数据)
|
||||
*/
|
||||
private String recordResult;
|
||||
|
||||
/**
|
||||
* 变更字段列表
|
||||
*/
|
||||
private List<String> changedFields;
|
||||
|
||||
/**
|
||||
* 字段变更信息
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class FieldChangeInfo {
|
||||
/**
|
||||
* 字段名
|
||||
*/
|
||||
private String fieldName;
|
||||
|
||||
/**
|
||||
* 字段中文名(如果有)
|
||||
*/
|
||||
private String fieldLabel;
|
||||
|
||||
/**
|
||||
* 旧值
|
||||
*/
|
||||
private Object oldValue;
|
||||
|
||||
/**
|
||||
* 新值
|
||||
*/
|
||||
private Object newValue;
|
||||
|
||||
/**
|
||||
* 字段类型
|
||||
*/
|
||||
private String fieldType;
|
||||
|
||||
/**
|
||||
* 是否为主键
|
||||
*/
|
||||
private Boolean isPrimaryKey;
|
||||
|
||||
/**
|
||||
* 变更描述
|
||||
*/
|
||||
private String changeDescription;
|
||||
|
||||
/**
|
||||
* 获取变更描述
|
||||
*/
|
||||
public String getChangeDescription() {
|
||||
if (changeDescription != null) {
|
||||
return changeDescription;
|
||||
}
|
||||
|
||||
// 构建显示名称:如果有自定义 description,则显示为 "fieldName[description]",否则只显示 "fieldName"
|
||||
String displayName;
|
||||
if (fieldLabel != null && !fieldLabel.isEmpty() && !fieldLabel.equals(fieldName)) {
|
||||
// 有自定义字段标签,格式:fieldName[description]
|
||||
displayName = String.format("%s[%s]", fieldName, fieldLabel);
|
||||
} else {
|
||||
// 没有自定义字段标签,只显示字段名
|
||||
displayName = fieldName;
|
||||
}
|
||||
|
||||
return String.format("%s: %s->%s", displayName, oldValue, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.model;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.Data;
|
||||
import org.springblade.core.datarecord.constant.DataRecordConstant;
|
||||
import org.springblade.core.secure.BladeUser;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.constant.BladeConstant;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.UrlUtil;
|
||||
import org.springblade.core.tool.utils.WebUtil;
|
||||
|
||||
/**
|
||||
* 审计上下文快照
|
||||
* <p>
|
||||
* 审计事件可能延迟至事务提交后或在异步线程中处理,届时线程上绑定的登录用户与请求对象已不可用,
|
||||
* 因此用户身份与请求信息必须在业务线程上一次性固化为快照随事件传递。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
public class RecordContext {
|
||||
|
||||
/**
|
||||
* 操作用户ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 操作用户名
|
||||
*/
|
||||
private String userName;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 操作IP地址
|
||||
*/
|
||||
private String remoteIp;
|
||||
|
||||
/**
|
||||
* 用户代理
|
||||
*/
|
||||
private String userAgent;
|
||||
|
||||
/**
|
||||
* 请求URI
|
||||
*/
|
||||
private String requestUri;
|
||||
|
||||
/**
|
||||
* 请求方式
|
||||
*/
|
||||
private String method;
|
||||
|
||||
/**
|
||||
* 在当前业务线程上采集上下文快照
|
||||
*
|
||||
* @return 上下文快照
|
||||
*/
|
||||
public static RecordContext capture() {
|
||||
RecordContext context = new RecordContext();
|
||||
BladeUser user = AuthUtil.getUser();
|
||||
if (user != null) {
|
||||
context.setUserId(String.valueOf(user.getUserId()));
|
||||
context.setUserName(user.getUserName());
|
||||
} else {
|
||||
context.setUserId(DataRecordConstant.ANONYMOUS_USER_ID);
|
||||
context.setUserName(DataRecordConstant.ANONYMOUS_USER_NAME);
|
||||
}
|
||||
context.setTenantId(Func.toStrWithEmpty(AuthUtil.getTenantId(), BladeConstant.ADMIN_TENANT_ID));
|
||||
HttpServletRequest request = currentRequest();
|
||||
if (request != null) {
|
||||
context.setRemoteIp(WebUtil.getIP(request));
|
||||
context.setUserAgent(request.getHeader(WebUtil.USER_AGENT_HEADER));
|
||||
context.setRequestUri(UrlUtil.getPath(request.getRequestURI()));
|
||||
context.setMethod(request.getMethod());
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前HTTP请求,非Web环境(如定时任务线程)返回null
|
||||
*
|
||||
* @return HttpServletRequest 或 null
|
||||
*/
|
||||
private static HttpServletRequest currentRequest() {
|
||||
try {
|
||||
return WebUtil.getRequest();
|
||||
} catch (Exception exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.processor;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.annotation.DataRecordLevel;
|
||||
import org.springblade.core.datarecord.annotation.FieldRecord;
|
||||
import org.springblade.core.datarecord.props.DataRecordProperties;
|
||||
import org.springblade.core.datarecord.expression.DataRecordEvaluator;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 默认数据审计检测器实现
|
||||
* <p>
|
||||
* 用于检测表、实体类、字段是否需要进行数据审计
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class BladeDataRecordDetector implements DataRecordDetector {
|
||||
|
||||
private final DataRecordProperties dataRecordProperties;
|
||||
|
||||
/**
|
||||
* 表名与DataRecord注解的缓存
|
||||
*/
|
||||
private final ConcurrentHashMap<String, DataRecord> tableRecordCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 表名与实体类的缓存
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Class<?>> tableEntityCache = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public DataRecord detectDataRecord(String tableName) {
|
||||
if (tableName == null || tableName.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 先从缓存中获取
|
||||
DataRecord cachedRecord = tableRecordCache.get(tableName);
|
||||
if (cachedRecord != null) {
|
||||
// 如果是空标记,返回null
|
||||
if (isEmptyDataRecord(cachedRecord)) {
|
||||
return null;
|
||||
}
|
||||
return cachedRecord;
|
||||
}
|
||||
|
||||
try {
|
||||
// 通过MyBatis-Plus获取表信息
|
||||
TableInfo tableInfo = getTableInfoByTableName(tableName);
|
||||
if (tableInfo != null) {
|
||||
Class<?> entityClass = tableInfo.getEntityType();
|
||||
DataRecord dataRecord = entityClass.getAnnotation(DataRecord.class);
|
||||
|
||||
if (dataRecord != null) {
|
||||
// 验证实体级别的条件表达式
|
||||
validateConditionExpression(dataRecord.condition(), "实体 " + entityClass.getSimpleName());
|
||||
|
||||
// 缓存结果
|
||||
tableRecordCache.put(tableName, dataRecord);
|
||||
tableEntityCache.put(tableName, entityClass);
|
||||
return dataRecord;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到注解,缓存一个空标记以避免重复检查
|
||||
tableRecordCache.put(tableName, createEmptyDataRecord());
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("检测表 {} 的数据审计注解时发生异常: {}", tableName, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FieldRecord detectFieldRecord(String tableName, String fieldName) {
|
||||
if (tableName == null || fieldName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Class<?> entityClass = tableEntityCache.get(tableName);
|
||||
if (entityClass == null) {
|
||||
// 先检测表级别的注解以填充缓存
|
||||
detectDataRecord(tableName);
|
||||
entityClass = tableEntityCache.get(tableName);
|
||||
}
|
||||
|
||||
if (entityClass != null) {
|
||||
// 查找字段上的 @FieldRecord 注解
|
||||
Field field = findField(entityClass, fieldName);
|
||||
if (field != null) {
|
||||
FieldRecord fieldRecord = field.getAnnotation(FieldRecord.class);
|
||||
if (fieldRecord != null) {
|
||||
// 验证字段级别的条件表达式
|
||||
validateConditionExpression(fieldRecord.condition(), "字段 " + entityClass.getSimpleName() + "." + fieldName);
|
||||
return fieldRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.debug("检测字段 {}.{} 的 @FieldRecord 注解时发生异常: {}", tableName, fieldName, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean skipRecord(String tableName) {
|
||||
// 首先检查全局配置是否启用
|
||||
if (dataRecordProperties != null && !dataRecordProperties.getEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否在全局忽略表列表中
|
||||
if (dataRecordProperties != null && dataRecordProperties.isTableIgnored(tableName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
DataRecord dataRecord = detectDataRecord(tableName);
|
||||
return dataRecord == null || isEmptyDataRecord(dataRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataRecord getTableDataRecord(String tableName) {
|
||||
DataRecord dataRecord = detectDataRecord(tableName);
|
||||
return isEmptyDataRecord(dataRecord) ? null : dataRecord;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCache() {
|
||||
tableRecordCache.clear();
|
||||
tableEntityCache.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearTableCache(String tableName) {
|
||||
tableRecordCache.remove(tableName);
|
||||
tableEntityCache.remove(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过表名获取TableInfo
|
||||
*
|
||||
* @param tableName 表名
|
||||
* @return TableInfo
|
||||
*/
|
||||
private TableInfo getTableInfoByTableName(String tableName) {
|
||||
return TableInfoHelper.getTableInfos().stream()
|
||||
.filter(tableInfo -> tableName.equalsIgnoreCase(tableInfo.getTableName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找字段(支持驼峰命名转换)
|
||||
*
|
||||
* @param entityClass 实体类
|
||||
* @param fieldName 字段名
|
||||
* @return 字段
|
||||
*/
|
||||
private Field findField(Class<?> entityClass, String fieldName) {
|
||||
try {
|
||||
// 直接查找
|
||||
return entityClass.getDeclaredField(fieldName);
|
||||
} catch (NoSuchFieldException e) {
|
||||
// 尝试驼峰命名转换
|
||||
String camelFieldName = toCamelCase(fieldName);
|
||||
try {
|
||||
return entityClass.getDeclaredField(camelFieldName);
|
||||
} catch (NoSuchFieldException ex) {
|
||||
// 遍历所有字段查找
|
||||
for (Field field : entityClass.getDeclaredFields()) {
|
||||
if (field.getName().equalsIgnoreCase(fieldName) ||
|
||||
field.getName().equalsIgnoreCase(camelFieldName)) {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下划线转驼峰
|
||||
*
|
||||
* @param fieldName 字段名
|
||||
* @return 驼峰命名
|
||||
*/
|
||||
private String toCamelCase(String fieldName) {
|
||||
if (fieldName == null || fieldName.isEmpty()) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
boolean nextUpperCase = false;
|
||||
|
||||
for (int i = 0; i < fieldName.length(); i++) {
|
||||
char c = fieldName.charAt(i);
|
||||
if (c == '_') {
|
||||
nextUpperCase = true;
|
||||
} else {
|
||||
if (nextUpperCase) {
|
||||
result.append(Character.toUpperCase(c));
|
||||
nextUpperCase = false;
|
||||
} else {
|
||||
result.append(Character.toLowerCase(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空的DataRecord注解(用于缓存标记)
|
||||
*/
|
||||
private DataRecord createEmptyDataRecord() {
|
||||
return new DataRecord() {
|
||||
@Override
|
||||
public String module() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String operation() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recordDetail() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recordOldData() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recordNewData() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] ignoreFields() {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] includeFields() {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataRecordLevel level() {
|
||||
return DataRecordLevel.INFO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean async() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String condition() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends java.lang.annotation.Annotation> annotationType() {
|
||||
return DataRecord.class;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为空的DataRecord
|
||||
*/
|
||||
private boolean isEmptyDataRecord(DataRecord dataRecord) {
|
||||
return dataRecord != null && !dataRecord.recordDetail() &&
|
||||
!dataRecord.recordOldData() && !dataRecord.recordNewData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证条件表达式
|
||||
*
|
||||
* @param condition 条件表达式
|
||||
* @param context 上下文描述(用于日志)
|
||||
*/
|
||||
private void validateConditionExpression(String condition, String context) {
|
||||
if (condition == null || condition.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DataRecordEvaluator.ExpressionValidationResult result = DataRecordEvaluator.validateExpression(condition);
|
||||
if (!result.isValid()) {
|
||||
log.warn("数据审计条件表达式验证失败 [{}]: 表达式='{}', 错误={}",
|
||||
context, condition, result.getErrorMessage());
|
||||
} else {
|
||||
log.debug("数据审计条件表达式验证通过 [{}]: '{}'", context, condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.processor;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.model.DataRecordInfo;
|
||||
|
||||
/**
|
||||
* 日志数据审计处理器
|
||||
* <p>
|
||||
* 将数据审计输出到日志
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
@Slf4j
|
||||
public class BladeDataRecordHandler implements DataRecordHandler {
|
||||
|
||||
@Override
|
||||
public void handle(DataRecordInfo recordInfo, DataRecord dataRecord) {
|
||||
String recordMessage = formatRecordInfo(recordInfo);
|
||||
// 根据配置的日志级别输出
|
||||
switch (dataRecord.level()) {
|
||||
case DEBUG:
|
||||
log.debug("数据审计[{}]: {}", recordInfo.getModule(), recordMessage);
|
||||
break;
|
||||
case INFO:
|
||||
log.info("数据审计[{}]: {}", recordInfo.getModule(), recordMessage);
|
||||
break;
|
||||
case WARN:
|
||||
log.warn("数据审计[{}]: {}", recordInfo.getModule(), recordMessage);
|
||||
break;
|
||||
case ERROR:
|
||||
log.error("数据审计[{}]: {}", recordInfo.getModule(), recordMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化记录信息
|
||||
*
|
||||
* @param recordInfo 记录信息
|
||||
* @return 格式化后的字符串
|
||||
*/
|
||||
private String formatRecordInfo(DataRecordInfo recordInfo) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
// 添加模块信息
|
||||
sb.append("\n").append("[表名]: ").append(recordInfo.getTableName()).append("\n");
|
||||
sb.append("[操作]: ").append(recordInfo.getOperation()).append("\n");
|
||||
if (recordInfo.getPrimaryKeyValue() != null) {
|
||||
sb.append("[主键]: ").append(recordInfo.getPrimaryKeyValue()).append("\n");
|
||||
}
|
||||
// 添加用户信息
|
||||
if (recordInfo.getUserName() != null) {
|
||||
sb.append("[用户]: ").append(recordInfo.getUserName()).append("\n");
|
||||
}
|
||||
// 添加IP信息
|
||||
if (recordInfo.getRemoteIp() != null) {
|
||||
sb.append("[IP]: ").append(recordInfo.getRemoteIp()).append("\n");
|
||||
}
|
||||
// 添加请求URI
|
||||
if (recordInfo.getRequestUri() != null) {
|
||||
sb.append("[URI]: ").append(recordInfo.getRequestUri()).append("\n");
|
||||
}
|
||||
if (recordInfo.getChangedFields() != null && !recordInfo.getChangedFields().isEmpty()) {
|
||||
sb.append("[变更]: ").append(recordInfo.getChangedFields()).append("\n");
|
||||
}
|
||||
if (recordInfo.getCost() != null) {
|
||||
sb.append("[耗时]: ").append(recordInfo.getCost()).append("ms").append("\n");
|
||||
}
|
||||
// 如果需要详细信息,添加变更详情
|
||||
if (recordInfo.getChangeData() != null && !recordInfo.getChangeData().isEmpty()) {
|
||||
sb.append("[详情]: {");
|
||||
recordInfo.getChangeData().forEach((field, change) -> sb.append(change.getChangeDescription()).append(", "));
|
||||
if (sb.toString().endsWith(", ")) {
|
||||
sb.setLength(sb.length() - 2);
|
||||
}
|
||||
sb.append("}");
|
||||
}
|
||||
return sb.append("\n").toString();
|
||||
}
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.processor;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.annotation.FieldRecord;
|
||||
import org.springblade.core.datarecord.expression.DataRecordEvaluator;
|
||||
import org.springblade.core.datarecord.model.DataChangeEvent;
|
||||
import org.springblade.core.datarecord.model.DataRecordInfo;
|
||||
import org.springblade.core.datarecord.model.RecordContext;
|
||||
import org.springblade.core.datarecord.props.DataRecordProperties;
|
||||
import org.springblade.core.launch.props.BladeProperties;
|
||||
import org.springblade.core.launch.server.ServerInfo;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 默认数据审计解析器实现
|
||||
* <p>
|
||||
* 前后镜像以类型化的属性值映射直接比对,不经过任何字符串序列化往返,
|
||||
* 字段值的类型信息全程保留,供字段过滤与条件表达式使用。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class BladeDataRecordParser implements DataRecordParser {
|
||||
|
||||
private final DataRecordDetector dataRecordDetector;
|
||||
|
||||
private final DataRecordProperties dataRecordProperties;
|
||||
|
||||
private final BladeProperties bladeProperties;
|
||||
|
||||
private final ServerInfo serverInfo;
|
||||
|
||||
@Override
|
||||
public List<DataRecordInfo> parse(DataChangeEvent event, DataRecord dataRecord) {
|
||||
if (event == null || dataRecord == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return switch (event.getOperation()) {
|
||||
case INSERT -> parseInsert(event, dataRecord);
|
||||
case UPDATE -> parseUpdate(event, dataRecord);
|
||||
case DELETE -> parseDelete(event, dataRecord);
|
||||
default -> Collections.emptyList();
|
||||
};
|
||||
} catch (Exception exception) {
|
||||
log.error("解析数据审计事件失败, table={}, statement={}", event.getTableName(), event.getStatementId(), exception);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入操作:每个落库实体一条记录,所有字段视作 null 到新值的变更
|
||||
*/
|
||||
private List<DataRecordInfo> parseInsert(DataChangeEvent event, DataRecord dataRecord) {
|
||||
List<Map<String, Object>> afterRows = event.getAfterRows();
|
||||
if (afterRows == null || afterRows.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<DataRecordInfo> recordList = new ArrayList<>(afterRows.size());
|
||||
for (Map<String, Object> afterRow : afterRows) {
|
||||
Map<String, Object> oldData = new LinkedHashMap<>();
|
||||
Map<String, Object> newData = new LinkedHashMap<>();
|
||||
Map<String, DataRecordInfo.FieldChangeInfo> changeData = new LinkedHashMap<>();
|
||||
List<String> changedFields = new ArrayList<>();
|
||||
for (Map.Entry<String, Object> entry : afterRow.entrySet()) {
|
||||
String property = entry.getKey();
|
||||
Object newValue = entry.getValue();
|
||||
if (!shouldRecordField(property, dataRecord, event.getTableName(), null, newValue)) {
|
||||
continue;
|
||||
}
|
||||
oldData.put(property, null);
|
||||
newData.put(property, newValue);
|
||||
changeData.put(property, buildFieldChange(event, property, null, newValue));
|
||||
changedFields.add(property);
|
||||
}
|
||||
if (changedFields.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
DataRecordInfo recordInfo = buildRecord(event, dataRecord);
|
||||
assembleData(recordInfo, dataRecord, oldData, newData, changeData, changedFields);
|
||||
applyPrimaryKey(recordInfo, event, afterRow);
|
||||
applyRecordResult(recordInfo, event, oldData, newData);
|
||||
recordList.add(recordInfo);
|
||||
}
|
||||
return recordList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新操作:SET 赋值对所有命中行统一生效,逐行与前镜像比对得出实际变更
|
||||
*/
|
||||
private List<DataRecordInfo> parseUpdate(DataChangeEvent event, DataRecord dataRecord) {
|
||||
List<Map<String, Object>> beforeRows = event.getBeforeRows();
|
||||
Map<String, Object> updateValues = event.getUpdateValues();
|
||||
if (beforeRows == null || beforeRows.isEmpty() || updateValues == null || updateValues.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<DataRecordInfo> recordList = new ArrayList<>(beforeRows.size());
|
||||
for (Map<String, Object> beforeRow : beforeRows) {
|
||||
Map<String, Object> oldData = new LinkedHashMap<>();
|
||||
Map<String, Object> newData = new LinkedHashMap<>();
|
||||
Map<String, DataRecordInfo.FieldChangeInfo> changeData = new LinkedHashMap<>();
|
||||
List<String> changedFields = new ArrayList<>();
|
||||
for (Map.Entry<String, Object> entry : updateValues.entrySet()) {
|
||||
String property = entry.getKey();
|
||||
Object newValue = entry.getValue();
|
||||
Object oldValue = beforeRow.get(property);
|
||||
if (valueEquals(oldValue, newValue)) {
|
||||
continue;
|
||||
}
|
||||
if (!shouldRecordField(property, dataRecord, event.getTableName(), oldValue, newValue)) {
|
||||
continue;
|
||||
}
|
||||
oldData.put(property, oldValue);
|
||||
newData.put(property, newValue);
|
||||
changeData.put(property, buildFieldChange(event, property, oldValue, newValue));
|
||||
changedFields.add(property);
|
||||
}
|
||||
if (changedFields.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
DataRecordInfo recordInfo = buildRecord(event, dataRecord);
|
||||
assembleData(recordInfo, dataRecord, oldData, newData, changeData, changedFields);
|
||||
applyPrimaryKey(recordInfo, event, beforeRow);
|
||||
applyRecordResult(recordInfo, event, oldData, newData);
|
||||
recordList.add(recordInfo);
|
||||
}
|
||||
return recordList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除操作:每个命中行一条记录,保留完整的删除前数据
|
||||
*/
|
||||
private List<DataRecordInfo> parseDelete(DataChangeEvent event, DataRecord dataRecord) {
|
||||
List<Map<String, Object>> beforeRows = event.getBeforeRows();
|
||||
if (beforeRows == null || beforeRows.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<DataRecordInfo> recordList = new ArrayList<>(beforeRows.size());
|
||||
for (Map<String, Object> beforeRow : beforeRows) {
|
||||
Map<String, Object> oldData = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> entry : beforeRow.entrySet()) {
|
||||
if (shouldRecordField(entry.getKey(), dataRecord, event.getTableName(), entry.getValue(), null)) {
|
||||
oldData.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
if (oldData.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
DataRecordInfo recordInfo = buildRecord(event, dataRecord);
|
||||
assembleData(recordInfo, dataRecord, oldData, new LinkedHashMap<>(), new LinkedHashMap<>(), new ArrayList<>());
|
||||
applyPrimaryKey(recordInfo, event, beforeRow);
|
||||
applyRecordResult(recordInfo, event, oldData, Collections.emptyMap());
|
||||
recordList.add(recordInfo);
|
||||
}
|
||||
return recordList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建记录骨架:标识、操作、上下文与服务信息
|
||||
*/
|
||||
private DataRecordInfo buildRecord(DataChangeEvent event, DataRecord dataRecord) {
|
||||
DataRecordInfo recordInfo = new DataRecordInfo();
|
||||
recordInfo.setRecordId(IdWorker.getId());
|
||||
recordInfo.setModule(dataRecord.module());
|
||||
String operation = event.getOperation().name();
|
||||
if (StringUtil.isNotBlank(dataRecord.operation())) {
|
||||
operation = operation + "[" + dataRecord.operation() + "]";
|
||||
}
|
||||
recordInfo.setOperation(operation);
|
||||
recordInfo.setTableName(event.getTableName());
|
||||
recordInfo.setRecordStatus(event.isSuccess());
|
||||
recordInfo.setCost(event.getCost());
|
||||
recordInfo.setRecordTime(LocalDateTime.now());
|
||||
RecordContext context = event.getContext();
|
||||
if (context != null) {
|
||||
recordInfo.setUserId(context.getUserId());
|
||||
recordInfo.setUserName(context.getUserName());
|
||||
recordInfo.setTenantId(context.getTenantId());
|
||||
recordInfo.setRemoteIp(context.getRemoteIp());
|
||||
recordInfo.setUserAgent(context.getUserAgent());
|
||||
recordInfo.setRequestUri(context.getRequestUri());
|
||||
recordInfo.setMethod(context.getMethod());
|
||||
}
|
||||
if (bladeProperties != null) {
|
||||
recordInfo.setServiceId(bladeProperties.getName());
|
||||
recordInfo.setEnv(bladeProperties.getEnv());
|
||||
}
|
||||
if (serverInfo != null) {
|
||||
recordInfo.setServerHost(serverInfo.getHostName());
|
||||
recordInfo.setServerIp(serverInfo.getIpWithPort());
|
||||
}
|
||||
return recordInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按注解与全局配置装载变更数据
|
||||
*/
|
||||
private void assembleData(DataRecordInfo recordInfo, DataRecord dataRecord,
|
||||
Map<String, Object> oldData, Map<String, Object> newData,
|
||||
Map<String, DataRecordInfo.FieldChangeInfo> changeData, List<String> changedFields) {
|
||||
if (Boolean.TRUE.equals(dataRecordProperties.getRecordDetailedChanges())) {
|
||||
if (dataRecord.recordOldData()) {
|
||||
recordInfo.setOldData(oldData);
|
||||
}
|
||||
if (dataRecord.recordNewData()) {
|
||||
recordInfo.setNewData(newData);
|
||||
}
|
||||
if (dataRecord.recordDetail()) {
|
||||
recordInfo.setChangeData(changeData);
|
||||
}
|
||||
}
|
||||
recordInfo.setChangedFields(changedFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建字段变更明细
|
||||
*/
|
||||
private DataRecordInfo.FieldChangeInfo buildFieldChange(DataChangeEvent event, String property, Object oldValue, Object newValue) {
|
||||
DataRecordInfo.FieldChangeInfo fieldChangeInfo = new DataRecordInfo.FieldChangeInfo();
|
||||
fieldChangeInfo.setFieldName(property);
|
||||
fieldChangeInfo.setFieldLabel(fieldLabel(property, event.getTableName()));
|
||||
fieldChangeInfo.setOldValue(oldValue);
|
||||
fieldChangeInfo.setNewValue(newValue);
|
||||
fieldChangeInfo.setIsPrimaryKey(property.equals(event.getKeyProperty()));
|
||||
return fieldChangeInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充主键信息
|
||||
*/
|
||||
private void applyPrimaryKey(DataRecordInfo recordInfo, DataChangeEvent event, Map<String, Object> row) {
|
||||
String keyProperty = event.getKeyProperty();
|
||||
if (keyProperty != null && row.containsKey(keyProperty)) {
|
||||
recordInfo.setPrimaryKey(keyProperty);
|
||||
recordInfo.setPrimaryKeyValue(row.get(keyProperty));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按全局配置装载事件摘要
|
||||
*/
|
||||
private void applyRecordResult(DataRecordInfo recordInfo, DataChangeEvent event,
|
||||
Map<String, Object> oldData, Map<String, Object> newData) {
|
||||
if (Boolean.TRUE.equals(dataRecordProperties.getRecordRawData())) {
|
||||
recordInfo.setRecordResult(buildRecordResult(event, recordInfo, oldData, newData));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建事件摘要
|
||||
* <p>
|
||||
* 摘要是可独立阅读的完整快照:除元信息外,变更内容以「旧值->新值」的紧凑形式留存,
|
||||
* 删除操作没有新值,直接留存删除前数据。
|
||||
*
|
||||
* @param event 变更事件
|
||||
* @param recordInfo 记录信息
|
||||
* @param oldData 该行的变更前数据
|
||||
* @param newData 该行的变更后数据
|
||||
* @return 摘要JSON
|
||||
*/
|
||||
private String buildRecordResult(DataChangeEvent event, DataRecordInfo recordInfo,
|
||||
Map<String, Object> oldData, Map<String, Object> newData) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>(8);
|
||||
summary.put("tableName", event.getTableName());
|
||||
summary.put("operation", event.getOperation().name());
|
||||
summary.put("statementId", event.getStatementId());
|
||||
summary.put("affectedRows", event.getAffectedRows());
|
||||
Map<String, Object> changedRow = new LinkedHashMap<>();
|
||||
if (recordInfo.getPrimaryKey() != null) {
|
||||
changedRow.put(recordInfo.getPrimaryKey(), String.valueOf(recordInfo.getPrimaryKeyValue()));
|
||||
}
|
||||
if (newData.isEmpty()) {
|
||||
oldData.forEach((property, value) -> changedRow.put(property, String.valueOf(value)));
|
||||
} else {
|
||||
newData.forEach((property, newValue) -> changedRow.put(property, oldData.get(property) + "->" + newValue));
|
||||
}
|
||||
summary.put("changedData", Collections.singletonList(changedRow));
|
||||
if (event.isOverflow()) {
|
||||
summary.put("overflow", Boolean.TRUE);
|
||||
summary.put("overflowNote", "镜像行数超出采集上限,仅记录截断后的数据");
|
||||
}
|
||||
return JsonUtil.toJson(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该记录该字段
|
||||
* <p>
|
||||
* 优先级:全局忽略配置 -> 字段注解及其条件表达式 -> 注解的包含与忽略列表。
|
||||
* 历史配置多以下划线列名书写,与属性名按「去下划线不区分大小写」等价匹配。
|
||||
*
|
||||
* @param property 属性名
|
||||
* @param dataRecord 数据审计配置
|
||||
* @param tableName 表名
|
||||
* @param oldValue 旧值
|
||||
* @param newValue 新值
|
||||
* @return 是否记录
|
||||
*/
|
||||
private boolean shouldRecordField(String property, DataRecord dataRecord, String tableName, Object oldValue, Object newValue) {
|
||||
for (String ignoredField : dataRecordProperties.getIgnoreFields()) {
|
||||
if (fieldNameMatches(ignoredField, property)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
FieldRecord fieldRecord = dataRecordDetector.detectFieldRecord(tableName, property);
|
||||
if (fieldRecord != null) {
|
||||
if (!fieldRecord.value()) {
|
||||
return false;
|
||||
}
|
||||
return DataRecordEvaluator.evaluateFieldCondition(fieldRecord.condition(), oldValue, newValue);
|
||||
}
|
||||
String[] includeFields = dataRecord.includeFields();
|
||||
if (includeFields.length > 0) {
|
||||
return Arrays.stream(includeFields).anyMatch(field -> fieldNameMatches(field, property));
|
||||
}
|
||||
String[] ignoreFields = dataRecord.ignoreFields();
|
||||
if (ignoreFields.length > 0) {
|
||||
return Arrays.stream(ignoreFields).noneMatch(field -> fieldNameMatches(field, property));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段名等价匹配:下划线列名与驼峰属性名视作同一字段
|
||||
*/
|
||||
private boolean fieldNameMatches(String configured, String property) {
|
||||
return configured != null && configured.replace("_", "").equalsIgnoreCase(property.replace("_", ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段标签,优先使用 @FieldRecord 的描述
|
||||
*/
|
||||
private String fieldLabel(String property, String tableName) {
|
||||
FieldRecord fieldRecord = dataRecordDetector.detectFieldRecord(tableName, property);
|
||||
if (fieldRecord != null && StringUtil.isNotBlank(fieldRecord.description())) {
|
||||
return fieldRecord.description();
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
/**
|
||||
* 值等价比较
|
||||
* <p>
|
||||
* 数据库驱动与实体字段的数值装箱类型可能不同(如 Integer 与 Long、BigDecimal 的标度差异),
|
||||
* 数值按数值本身比较;其余类型不可判等时视作发生变更,宁可多记不可漏记。
|
||||
*
|
||||
* @param oldValue 旧值
|
||||
* @param newValue 新值
|
||||
* @return 是否等价
|
||||
*/
|
||||
private boolean valueEquals(Object oldValue, Object newValue) {
|
||||
if (Objects.equals(oldValue, newValue)) {
|
||||
return true;
|
||||
}
|
||||
if (oldValue == null || newValue == null) {
|
||||
return false;
|
||||
}
|
||||
if (oldValue instanceof Number oldNumber && newValue instanceof Number newNumber) {
|
||||
try {
|
||||
return new BigDecimal(oldNumber.toString()).compareTo(new BigDecimal(newNumber.toString())) == 0;
|
||||
} catch (NumberFormatException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.processor;
|
||||
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.annotation.FieldRecord;
|
||||
|
||||
/**
|
||||
* 数据审计检测器接口
|
||||
* <p>
|
||||
* 用于检测表、实体类、字段是否需要进行数据审计
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
public interface DataRecordDetector {
|
||||
|
||||
/**
|
||||
* 检测表是否需要数据审计
|
||||
*
|
||||
* @param tableName 表名
|
||||
* @return DataRecord注解,如果不需要记录则返回null
|
||||
*/
|
||||
DataRecord detectDataRecord(String tableName);
|
||||
|
||||
/**
|
||||
* 检测字段是否需要数据审计
|
||||
*
|
||||
* @param tableName 表名
|
||||
* @param fieldName 字段名
|
||||
* @return FieldRecord注解,如果不需要记录则返回null
|
||||
*/
|
||||
FieldRecord detectFieldRecord(String tableName, String fieldName);
|
||||
|
||||
/**
|
||||
* 检测是否需要进行数据审计
|
||||
*
|
||||
* @param tableName 表名
|
||||
* @return 是否需要记录
|
||||
*/
|
||||
boolean skipRecord(String tableName);
|
||||
|
||||
/**
|
||||
* 获取表的DataRecord配置
|
||||
*
|
||||
* @param tableName 表名
|
||||
* @return DataRecord配置
|
||||
*/
|
||||
DataRecord getTableDataRecord(String tableName);
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
void clearCache();
|
||||
|
||||
/**
|
||||
* 清除指定表的缓存
|
||||
*
|
||||
* @param tableName 表名
|
||||
*/
|
||||
void clearTableCache(String tableName);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.processor;
|
||||
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.model.DataRecordInfo;
|
||||
|
||||
/**
|
||||
* 数据审计处理器接口
|
||||
* <p>
|
||||
* 用于扩展数据审计的处理逻辑,比如:
|
||||
* 1. 保存到审计表
|
||||
* 2. 发送到消息队列
|
||||
* 3. 调用外部审计服务
|
||||
* 4. 发送通知等
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
public interface DataRecordHandler {
|
||||
|
||||
/**
|
||||
* 处理数据审计
|
||||
*
|
||||
* @param recordInfo 记录信息
|
||||
* @param dataRecord 数据审计配置
|
||||
*/
|
||||
void handle(DataRecordInfo recordInfo, DataRecord dataRecord);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.processor;
|
||||
|
||||
import org.springblade.core.datarecord.annotation.DataRecord;
|
||||
import org.springblade.core.datarecord.model.DataChangeEvent;
|
||||
import org.springblade.core.datarecord.model.DataRecordInfo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 数据审计解析器接口
|
||||
* <p>
|
||||
* 将捕获层产出的数据变更事件解析为数据审计记录。
|
||||
* 一次批量语句会命中多行数据,逐行产出一条审计记录。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface DataRecordParser {
|
||||
|
||||
/**
|
||||
* 解析数据变更事件为数据审计记录
|
||||
*
|
||||
* @param event 数据变更事件
|
||||
* @param dataRecord 数据审计配置
|
||||
* @return 数据审计记录列表
|
||||
*/
|
||||
List<DataRecordInfo> parse(DataChangeEvent event, DataRecord dataRecord);
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.core.datarecord.props;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 数据审计配置属性
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "blade.data-record")
|
||||
public class DataRecordProperties {
|
||||
|
||||
/**
|
||||
* 是否启用数据审计,默认为true
|
||||
*/
|
||||
private Boolean enabled = true;
|
||||
|
||||
/**
|
||||
* 全局忽略的表名
|
||||
*/
|
||||
private List<String> ignoreTables = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 全局忽略的字段名
|
||||
*/
|
||||
private List<String> ignoreFields = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 是否记录详细的变更数据,默认为true
|
||||
*/
|
||||
private Boolean recordDetailedChanges = true;
|
||||
|
||||
/**
|
||||
* 是否记录原始数据,默认为true
|
||||
*/
|
||||
private Boolean recordRawData = true;
|
||||
|
||||
/**
|
||||
* 审计事件是否延迟至事务提交后发布,回滚的事务不产生审计记录,默认为true
|
||||
*/
|
||||
private Boolean afterCommit = true;
|
||||
|
||||
/**
|
||||
* 单条语句镜像采集的最大行数,超出部分截断并在记录中标记,默认为1000
|
||||
*/
|
||||
private Integer maxCaptureRows = 1000;
|
||||
|
||||
/**
|
||||
* 异步处理线程数,默认为2
|
||||
*/
|
||||
private Integer asyncPoolSize = 2;
|
||||
|
||||
/**
|
||||
* 异步处理队列容量,队列满时回退由提交线程执行,默认为10000
|
||||
*/
|
||||
private Integer asyncQueueCapacity = 10000;
|
||||
|
||||
/**
|
||||
* 获取全局忽略的表名集合(转换为大写以便比较)
|
||||
*/
|
||||
public List<String> getIgnoreTablesUpperCase() {
|
||||
List<String> upperCaseTables = new ArrayList<>();
|
||||
for (String table : ignoreTables) {
|
||||
if (table != null) {
|
||||
upperCaseTables.add(table.toUpperCase());
|
||||
}
|
||||
}
|
||||
return upperCaseTables;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局忽略的字段名集合(转换为大写以便比较)
|
||||
*/
|
||||
public List<String> getIgnoreFieldsUpperCase() {
|
||||
List<String> upperCaseFields = new ArrayList<>();
|
||||
for (String field : ignoreFields) {
|
||||
if (field != null) {
|
||||
upperCaseFields.add(field.toUpperCase());
|
||||
}
|
||||
}
|
||||
return upperCaseFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查表是否被全局忽略
|
||||
*/
|
||||
public boolean isTableIgnored(String tableName) {
|
||||
if (tableName == null || ignoreTables.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String upperTableName = tableName.toUpperCase();
|
||||
return getIgnoreTablesUpperCase().contains(upperTableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字段是否被全局忽略
|
||||
*/
|
||||
public boolean isFieldIgnored(String fieldName) {
|
||||
if (fieldName == null || ignoreFields.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String upperFieldName = fieldName.toUpperCase();
|
||||
return getIgnoreFieldsUpperCase().contains(upperFieldName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user