From 8e4d1e709706c502a46cec8c801aa0eaa53bf0d6 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 30 Oct 2026 08:28:46 +0800 Subject: [PATCH] fix: 统一事务锁定读并补齐多数据库统计与 JSON 筛选 --- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java | 3 +++ yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java | 6 +++++- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java | 253 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptorTest.java | 318 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java | 273 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/RedisKeyConstants.java | 10 ++++++++++ yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/insurance/HrmInsuranceMonthRecordLockRedisDAO.java | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImpl.java | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------- yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImplTest.java | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java | 4 +--- yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java | 4 ++-- yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java | 4 ++-- yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java | 4 ++-- yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java | 2 +- yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java | 11 ++--------- 19 files changed, 1738 insertions(+), 108 deletions(-) create mode 100644 yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java create mode 100644 yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java create mode 100644 yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java create mode 100644 yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptorTest.java create mode 100644 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/insurance/HrmInsuranceMonthRecordLockRedisDAO.java diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java index f1f5396..eae9464 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.mybatis.core.handler.DefaultDBFieldHandler; import cn.iocoder.yudao.framework.mybatis.core.injector.YudaoSqlInjector; +import cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor; import cn.iocoder.yudao.framework.mybatis.core.util.CaseInsensitiveResultMap; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer; @@ -57,6 +58,8 @@ public class YudaoMybatisAutoConfiguration { public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件 + // 锁定读插件:需要位于末尾,在租户、数据权限、分页等插件改写 SQL 之后,改写 SQL Server 的行锁 + mybatisPlusInterceptor.addInnerInterceptor(new ForUpdateInnerInterceptor()); // ↓↓↓ 按需开启,可能会影响到 updateBatch 的地方:例如说文件配置管理 ↓↓↓ // mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 拦截没有指定条件的 update 和 delete 语句 return mybatisPlusInterceptor; diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java new file mode 100644 index 0000000..454b758 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java @@ -0,0 +1,168 @@ +package cn.iocoder.yudao.framework.mybatis.core.injector; + +import com.baomidou.mybatisplus.core.injector.AbstractMethod; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.toolkit.sql.SqlScriptUtils; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.apache.ibatis.executor.keygen.KeyGenerator; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.mapping.SqlCommandType; +import org.apache.ibatis.mapping.SqlSource; +import org.apache.ibatis.mapping.StatementType; + +/** + * 锁定读使用的内置查询,见 BaseMapperX 的 selectOneForUpdate、selectListForUpdate、selectFirstOneForUpdate 方法 + * + * 按 {@link Kind} 分为三类,均不在 Mapper 接口上声明,由 BaseMapperX 通过 SqlSession 调用: + * 1. {@link Kind#LOCK}:按原条件(可选再加主键)加锁查询,末尾为 FOR UPDATE + * 2. {@link Kind#LOCK_FIRST}:按原条件、原排序加锁查询首行,末尾为 LIMIT 1 FOR UPDATE + * 3. {@link Kind#FIRST_IDS}:按原条件、原排序(未设置时按主键)查询首行主键,行数限制由分页插件生成,不加锁; + * 仅用于 Oracle 的首行锁定(Oracle 的 FOR UPDATE 不能与 ROWNUM、FETCH FIRST 一起使用) + * + * SQL Server 不支持 FOR UPDATE、LIMIT,由 {@link cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor} + * 在所有解析 SQL 的插件之后改写;该拦截器只处理 SqlSource 为 {@link LockSqlSource} 的内置查询 + * + * 1. 条件:Wrapper 的实体条件、普通条件(整体加括号,OR 条件也均受主键限制)、逻辑删除,与 MyBatis Plus 的 selectList 一致; + * 不使用 Wrapper 的 last、groupBy、having,由调用方校验 + * 2. 列与结果:加锁查询沿用 selectList 的查询列(含 Wrapper 的 select)、resultMap;主键查询的结果为主键类型 + * 3. 缓存:每次执行前清空一级缓存、且不使用二级缓存,避免锁定前后读到缓存的结果 + * + * @author 芋道源码 + */ +public class SelectForUpdateMethod extends AbstractMethod { + + /** + * 加锁查询的主键参数名 + */ + public static final String PARAM_ID = "id"; + + private final Kind kind; + + public SelectForUpdateMethod(Kind kind) { + super(kind.getMethod()); + this.kind = kind; + } + + @Override + public MappedStatement injectMappedStatement(Class mapperClass, Class modelClass, TableInfo tableInfo) { + // 没有主键的表,无法查询首行主键 + if (kind == Kind.FIRST_IDS && !tableInfo.havePK()) { + return null; + } + String columns = kind == Kind.FIRST_IDS ? tableInfo.getKeyColumn() : sqlSelectColumns(tableInfo, true); + String sql = ""; + SqlSource sqlSource = new LockSqlSource(kind, super.createSqlSource(configuration, sql, modelClass)); + if (kind == Kind.FIRST_IDS) { + return addSelectMappedStatementForOther(mapperClass, methodName, sqlSource, tableInfo.getKeyType()); + } + return addSelectMappedStatementForTable(mapperClass, methodName, sqlSource, tableInfo); + } + + /** + * 条件:实体条件 + 普通条件(加括号) + [主键] + 逻辑删除 + * + * @param tableInfo 表信息 + * @return WHERE 脚本 + */ + private String sqlWhere(TableInfo tableInfo) { + String entityWhere = SqlScriptUtils.convertIf(tableInfo.getAllSqlWhere(true, true, true, WRAPPER_ENTITY_DOT), + WRAPPER_ENTITY + " != null", true); + String normalWhere = SqlScriptUtils.convertIf(" AND (${" + WRAPPER_DOT + "expression.normal.sqlSegment})", + WRAPPER_DOT + "nonEmptyOfNormal", true); + String idWhere = kind == Kind.LOCK && tableInfo.havePK() ? NEWLINE + SqlScriptUtils.convertIf( + " AND " + tableInfo.getKeyColumn() + " = " + SqlScriptUtils.safeParam(PARAM_ID), PARAM_ID + " != null", true) : ""; + return SqlScriptUtils.convertWhere(entityWhere + NEWLINE + normalWhere + idWhere + + NEWLINE + tableInfo.getLogicDeleteSql(true, true)); + } + + /** + * 排序与锁定: + * 1. 加锁查询:Wrapper 的排序(未设置时不排序,避免改变数据库的加锁范围) + FOR UPDATE;首行再加 LIMIT 1 + * 2. 主键查询:Wrapper 的排序;未设置时按主键排序,保证锁定前后读取的首行一致 + * + * @param tableInfo 表信息 + * @return ORDER BY 等脚本 + */ + private String sqlTail(TableInfo tableInfo) { + String orderBy = " ${" + WRAPPER_DOT + "expression.orderBy.sqlSegment}"; + if (kind == Kind.FIRST_IDS) { + return SqlScriptUtils.convertChoose(WRAPPER_DOT + "expression.orderBy.isEmpty()", + " ORDER BY " + tableInfo.getKeyColumn(), orderBy); + } + return SqlScriptUtils.convertIf(orderBy, "!" + WRAPPER_DOT + "expression.orderBy.isEmpty()", true) + + NEWLINE + (kind == Kind.LOCK_FIRST ? "LIMIT 1 FOR UPDATE" : "FOR UPDATE"); + } + + @Override + protected MappedStatement addMappedStatement(Class mapperClass, String id, SqlSource sqlSource, + SqlCommandType sqlCommandType, Class parameterType, + String resultMap, Class resultType, KeyGenerator keyGenerator, + String keyProperty, String keyColumn) { + String statementName = mapperClass.getName() + DOT + id; + if (configuration.hasStatement(statementName, false)) { + logger.warn(LEFT_SQ_BRACKET + statementName + "] Has been loaded by XML or SqlProvider or Mybatis's Annotation, so ignoring this injection for [" + getClass() + RIGHT_SQ_BRACKET); + return null; + } + // 与父类相比:flushCache 为 true、useCache 为 false + return builderAssistant.addMappedStatement(id, sqlSource, StatementType.PREPARED, sqlCommandType, + null, null, null, parameterType, resultMap, resultType, + null, true, false, false, keyGenerator, keyProperty, keyColumn, + configuration.getDatabaseId(), languageDriver, null); + } + + /** + * 内置查询的类型 + */ + @Getter + @AllArgsConstructor + public enum Kind { + + /** + * 加锁查询:按原条件、可选的主键加锁,返回实体 + */ + LOCK("_forUpdateLock"), + /** + * 首行加锁查询:按原条件、原排序加锁第 1 条,返回实体 + */ + LOCK_FIRST("_forUpdateLockFirst"), + /** + * 首行主键查询:按原条件、原排序查询第 1 条的主键,不加锁,返回主键 + */ + FIRST_IDS("_forUpdateFirstIds"); + + /** + * 方法名,即 MappedStatement 编号中 Mapper 之后的部分 + */ + private final String method; + + } + + /** + * 内置查询的 SqlSource:标记 MappedStatement 为锁定读的内置查询,SQL 由原 SqlSource 生成 + * + * MyBatis Plus Join 等插件复制 MappedStatement 时保留同一个 SqlSource,标记仍然有效 + */ + @Getter + @AllArgsConstructor + public static class LockSqlSource implements SqlSource { + + /** + * 内置查询的类型 + */ + private final Kind kind; + /** + * 原 SqlSource + */ + private final SqlSource delegate; + + @Override + public BoundSql getBoundSql(Object parameterObject) { + return delegate.getBoundSql(parameterObject); + } + + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java index 2fc1f72..d6e271f 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/YudaoSqlInjector.java @@ -15,7 +15,8 @@ import java.util.List; * 自定义 SQL 注入器 * * 在 MyBatis Plus Join 的 {@link MPJSqlInjector} 基础上,将 selectMaps 的结果行替换为 {@link CaseInsensitiveResultMap}, - * 解决 PostgreSQL、Oracle 等数据库列标签大小写折叠,导致按固定 key 取不到值的问题 + * 解决 PostgreSQL、Oracle 等数据库列标签大小写折叠,导致按固定 key 取不到值的问题; + * 并增加锁定读使用的内置查询 {@link SelectForUpdateMethod} * * @author 芋道源码 */ @@ -25,6 +26,9 @@ public class YudaoSqlInjector extends MPJSqlInjector { public List getMethodList(Configuration configuration, Class mapperClass, TableInfo tableInfo) { List methods = super.getMethodList(configuration, mapperClass, tableInfo); methods.replaceAll(method -> method instanceof SelectMaps ? new CaseInsensitiveSelectMaps() : method); + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) { + methods.add(new SelectForUpdateMethod(kind)); + } return methods; } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java new file mode 100644 index 0000000..b4a4efb --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java @@ -0,0 +1,211 @@ +package cn.iocoder.yudao.framework.mybatis.core.interceptor; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum; +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod; +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.toolkit.PluginUtils; +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.parser.CCJSqlParserConstants; +import net.sf.jsqlparser.parser.SimpleNode; +import net.sf.jsqlparser.parser.Token; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.ForMode; +import net.sf.jsqlparser.statement.select.Limit; +import net.sf.jsqlparser.statement.select.PlainSelect; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.SqlSource; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +// TODO DONE @AI:能不能不这么搞,而是 mapperx 根据数据库做封装噢? +// 选择执行路径已在 BaseMapperX 按数据库封装(Oracle 首行锁定走多次查询,其它单条语句); +// SQL Server 的 WITH (UPDLOCK, ROWLOCK) 无法被 JSqlParser 解析,若在 Mapper 中直接生成,租户、数据权限等插件会解析失败, +// 因此只能在所有解析 SQL 的插件之后改写。本拦截器只处理 SelectForUpdateMethod 的内置查询,不再扫描其它 SQL。 +/** + * 锁定读的拦截器,需要位于拦截器链的末尾:在 prepare 阶段处理,此时租户、数据权限、分页等插件已完成 SQL 改写 + * + * 只处理 SqlSource 为 {@link SelectForUpdateMethod.LockSqlSource} 的内置查询,其它 SQL 不做任何处理: + * 1. 在实际执行的连接上校验事务:要求非自动提交;Oracle 首行锁定的主键查询({@link SelectForUpdateMethod.Kind#FIRST_IDS}) + * 还要求隔离级别为 READ COMMITTED,其它隔离级别下,锁定后的重新读取可能读不到其它事务已提交的记录 + * 2. SQL Server 不支持 FOR UPDATE、LIMIT:将末尾的 FOR UPDATE 改写为基表的 WITH (UPDLOCK, ROWLOCK),首行的 LIMIT 1 改写为 TOP (1); + * 只按 SQL 的 token 修改,字符串中的 for update 不会被改写;仅支持单表,不支持时直接报错,不会退化为不加锁的 SQL + * 3. Oracle 不支持 LIMIT 1 FOR UPDATE:首行加锁查询({@link SelectForUpdateMethod.Kind#LOCK_FIRST})直接报错, + * 正常情况下 BaseMapperX 已按数据库类型改为多次查询 + * + * @author 芋道源码 + */ +public class ForUpdateInnerInterceptor implements InnerInterceptor { + + /** + * SQL Server 的行锁提示:UPDLOCK 持有更新锁到事务结束,与 FOR UPDATE 一样阻塞其它事务的加锁读与修改;ROWLOCK 尽量使用行锁 + */ + static final String SQL_SERVER_LOCK_HINTS = " WITH (UPDLOCK, ROWLOCK)"; + /** + * SQL Server 的首行限制,替代 LIMIT 1 + */ + static final String SQL_SERVER_TOP_ONE = " TOP (1)"; + + @Override + public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) { + PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh); + SqlSource sqlSource = mpSh.mappedStatement().getSqlSource(); + if (!(sqlSource instanceof SelectForUpdateMethod.LockSqlSource)) { + return; + } + SelectForUpdateMethod.Kind kind = ((SelectForUpdateMethod.LockSqlSource) sqlSource).getKind(); + // 1. 校验实际连接的事务 + checkTransaction(connection, kind); + // 2. 按实际连接的数据库类型处理:首行主键查询不加锁,无需改写 + if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) { + return; + } + DbType dbType = getDbType(connection); + if (JdbcUtils.isSQLServer(dbType)) { + PluginUtils.MPBoundSql mpBoundSql = mpSh.mPBoundSql(); + mpBoundSql.sql(rewriteForSqlServer(mpBoundSql.sql(), kind == SelectForUpdateMethod.Kind.LOCK_FIRST)); + return; + } + if (kind == SelectForUpdateMethod.Kind.LOCK_FIRST && ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) { + throw new IllegalStateException("Oracle 不支持 LIMIT 1 FOR UPDATE,首行锁定需要使用多次查询"); + } + } + + /** + * 校验连接在事务中;首行主键查询还要求隔离级别为 READ COMMITTED + * + * @param connection 实际执行的连接 + * @param kind 内置查询的类型 + */ + static void checkTransaction(Connection connection, SelectForUpdateMethod.Kind kind) { + try { + if (connection.getAutoCommit()) { + throw new IllegalStateException("锁定读需要在事务中执行,当前连接为自动提交"); + } + if (kind != SelectForUpdateMethod.Kind.FIRST_IDS) { + return; + } + int isolation = connection.getTransactionIsolation(); + if (isolation != Connection.TRANSACTION_READ_COMMITTED) { + throw new IllegalStateException("首行锁定要求事务隔离级别为 READ COMMITTED,当前为:" + isolation); + } + } catch (SQLException e) { + throw new IllegalStateException("获取连接的事务状态失败", e); + } + } + + private static DbType getDbType(Connection connection) { + try { + return DbTypeEnum.find(connection.getMetaData().getDatabaseProductName()); + } catch (SQLException e) { + throw new IllegalStateException("获取数据库类型失败", e); + } + } + + /** + * 将单表 SELECT 末尾的 FOR UPDATE 改写为表的 WITH (UPDLOCK, ROWLOCK);首行时将 LIMIT 1 改写为 TOP (1) + * + * @param sql SQL + * @param first 是否为首行加锁查询 + * @return 改写后的 SQL + */ + static String rewriteForSqlServer(String sql, boolean first) { + Statement statement; + try { + statement = SqlTokenUtils.parse(sql); + } catch (JSQLParserException e) { + throw new IllegalStateException("解析行锁 SQL 失败:" + sql, e); + } + // 校验:仅支持单表、无修饰的 FOR UPDATE + if (!(statement instanceof PlainSelect)) { + throw unsupported("仅支持单个 SELECT", sql); + } + PlainSelect plainSelect = (PlainSelect) statement; + if (plainSelect.getForMode() != ForMode.UPDATE || plainSelect.getForUpdateTable() != null + || plainSelect.getWait() != null || plainSelect.isNoWait() || plainSelect.isSkipLocked()) { + throw unsupported("仅支持 FOR UPDATE,不支持 OF、WAIT、NOWAIT、SKIP LOCKED 等", sql); + } + if (CollUtil.isNotEmpty(plainSelect.getWithItemsList())) { + throw unsupported("不支持 WITH", sql); + } + if (!(plainSelect.getFromItem() instanceof Table) || CollUtil.isNotEmpty(plainSelect.getJoins())) { + throw unsupported("仅支持单表", sql); + } + Table table = (Table) plainSelect.getFromItem(); + if (table.getSqlServerHints() != null || table.getIndexHint() != null || table.getSampleClause() != null + || table.getPivot() != null || table.getUnPivot() != null) { + throw unsupported("表已有提示等修饰", sql); + } + if (plainSelect.getTop() != null || plainSelect.getOffset() != null || plainSelect.getFetch() != null) { + throw unsupported("不支持 TOP、OFFSET、FETCH", sql); + } + Limit limit = plainSelect.getLimit(); + if (first ? !isLimitOne(limit) || plainSelect.getDistinct() != null : limit != null) { + throw unsupported(first ? "首行仅支持 LIMIT 1,且不支持 DISTINCT" : "不支持 LIMIT", sql); + } + SimpleNode tableNode = table.getASTNode(); + if (tableNode == null) { + throw unsupported("无法定位表", sql); + } + + // 末尾的 FOR UPDATE:表之后的 token 中,最后两个(不含分号) + List tokens = new ArrayList<>(); + for (Token token = tableNode.jjtGetLastToken().next; token != null && token.kind != CCJSqlParserConstants.EOF; + token = token.next) { + tokens.add(token); + } + while (!tokens.isEmpty() && tokens.get(tokens.size() - 1).kind == CCJSqlParserConstants.ST_SEMICOLON) { + tokens.remove(tokens.size() - 1); + } + if (tokens.size() < 2 || tokens.get(tokens.size() - 2).kind != CCJSqlParserConstants.K_FOR + || tokens.get(tokens.size() - 1).kind != CCJSqlParserConstants.K_UPDATE) { + throw unsupported("FOR UPDATE 不在末尾", sql); + } + + // 删除 FOR UPDATE,在表(含别名)之后增加提示 + int[] lineStarts = SqlTokenUtils.lineStarts(sql); + Token forToken = tokens.get(tokens.size() - 2); + Token updateToken = tokens.get(tokens.size() - 1); + int tableEnd = SqlTokenUtils.end(sql, lineStarts, tableNode.jjtGetLastToken()); + List patches = new ArrayList<>(); + patches.add(new SqlTokenUtils.Patch(SqlTokenUtils.begin(sql, lineStarts, forToken), + SqlTokenUtils.end(sql, lineStarts, updateToken), "")); + patches.add(new SqlTokenUtils.Patch(tableEnd, tableEnd, SQL_SERVER_LOCK_HINTS)); + // 首行:删除 LIMIT 1,在 SELECT 之后增加 TOP (1) + if (first) { + SimpleNode limitNode = limit.getASTNode(); + SimpleNode selectNode = plainSelect.getASTNode(); + if (limitNode == null || selectNode == null + || selectNode.jjtGetFirstToken().kind != CCJSqlParserConstants.K_SELECT) { + throw unsupported("无法定位 SELECT、LIMIT", sql); + } + int selectEnd = SqlTokenUtils.end(sql, lineStarts, selectNode.jjtGetFirstToken()); + patches.add(new SqlTokenUtils.Patch(selectEnd, selectEnd, SQL_SERVER_TOP_ONE)); + patches.add(new SqlTokenUtils.Patch(SqlTokenUtils.begin(sql, lineStarts, limitNode.jjtGetFirstToken()), + SqlTokenUtils.end(sql, lineStarts, limitNode.jjtGetLastToken()), "")); + } + return SqlTokenUtils.apply(sql, patches); + } + + private static boolean isLimitOne(Limit limit) { + if (limit == null || limit.getOffset() != null) { + return false; + } + Expression rowCount = limit.getRowCount(); + return rowCount instanceof LongValue && ((LongValue) rowCount).getValue() == 1L; + } + + private static IllegalStateException unsupported(String reason, String sql) { + return new IllegalStateException("SQL Server 不支持的行锁查询(" + reason + "):" + sql); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java new file mode 100644 index 0000000..292e4d7 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java @@ -0,0 +1,144 @@ +package cn.iocoder.yudao.framework.mybatis.core.interceptor; + +import com.baomidou.mybatisplus.extension.parser.JsqlParserGlobal; +import lombok.Value; +import lombok.experimental.Accessors; +import lombok.experimental.UtilityClass; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Token; +import net.sf.jsqlparser.statement.Statement; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 按 JSqlParser 的 token 位置修改原 SQL 的工具类 + * + * 只替换识别出的 token,不使用 JSqlParser 重新生成整条 SQL,保留原有的字符串、注释、提示与格式 + * + * @author 芋道源码 + */ +@UtilityClass +class SqlTokenUtils { + + /** + * 解析 SQL,保留 token 位置 + * + * 注意:不使用 {@link JsqlParserGlobal#parse(String)},其缓存返回反序列化后的语句,不包含 token 位置,也不能被修改 + * + * @param sql SQL + * @return 语句 + * @throws JSQLParserException 解析失败 + */ + static Statement parse(String sql) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, JsqlParserGlobal.getExecutorService(), null); + } + + /** + * 计算每行的起始位置,与 JavaCC 的行号规则一致:\r\n、\r、\n 均为换行 + * + * @param sql SQL + * @return 每行的起始位置,下标为行号减 1 + */ + static int[] lineStarts(String sql) { + List starts = new ArrayList<>(); + starts.add(0); + for (int i = 0; i < sql.length(); i++) { + char c = sql.charAt(i); + if (c == '\r' && i + 1 < sql.length() && sql.charAt(i + 1) == '\n') { + i++; + starts.add(i + 1); + } else if (c == '\r' || c == '\n') { + starts.add(i + 1); + } + } + return starts.stream().mapToInt(Integer::intValue).toArray(); + } + + /** + * 获得 token 在 SQL 中的起始位置,并校验原文与 token 一致 + * + * @param sql SQL + * @param lineStarts {@link #lineStarts(String)} 的结果 + * @param token token + * @return 起始位置 + */ + static int begin(String sql, int[] lineStarts, Token token) { + if (token.beginLine < 1 || token.beginLine > lineStarts.length) { + throw new IllegalStateException("无法定位 SQL 中的 token:" + token.image); + } + // JavaCC 的列号从 1 开始,制表符按 1 列计算 + int offset = lineStarts[token.beginLine - 1] + token.beginColumn - 1; + if (offset < 0 || !sql.startsWith(token.image, offset)) { + throw new IllegalStateException("无法定位 SQL 中的 token:" + token.image); + } + return offset; + } + + /** + * 获得 token 在 SQL 中的结束位置(不含) + * + * @param sql SQL + * @param lineStarts {@link #lineStarts(String)} 的结果 + * @param token token + * @return 结束位置 + */ + static int end(String sql, int[] lineStarts, Token token) { + return begin(sql, lineStarts, token) + token.image.length(); + } + + /** + * 按位置替换 SQL;相同位置的相同替换只执行一次,其它重叠的替换视为错误 + * + * @param sql SQL + * @param patches 替换 + * @return 替换后的 SQL + */ + static String apply(String sql, List patches) { + List sorted = new ArrayList<>(patches); + sorted.sort(Comparator.comparingInt(Patch::start).thenComparingInt(Patch::end)); + List distinct = new ArrayList<>(); + for (Patch patch : sorted) { + Patch last = distinct.isEmpty() ? null : distinct.get(distinct.size() - 1); + if (patch.equals(last)) { + continue; + } + if (last != null && patch.start() < last.end()) { + throw new IllegalStateException("SQL 修改位置重叠:" + last + "、" + patch); + } + distinct.add(patch); + } + // 从后向前替换,前面的位置不受影响 + StringBuilder builder = new StringBuilder(sql); + for (int i = distinct.size() - 1; i >= 0; i--) { + Patch patch = distinct.get(i); + builder.replace(patch.start(), patch.end(), patch.replacement()); + } + return builder.toString(); + } + + /** + * 替换:将 [start, end) 替换为 replacement;start 等于 end 时为插入 + */ + @Value + @Accessors(fluent = true) + static class Patch { + + /** + * 起始位置 + */ + int start; + /** + * 结束位置(不含) + */ + int end; + /** + * 替换的文本 + */ + String replacement; + + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java index 2b008bf..18a093b 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java @@ -1,11 +1,15 @@ package cn.iocoder.yudao.framework.mybatis.core.mapper; import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.SortablePageParam; import cn.iocoder.yudao.framework.common.pojo.SortingField; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; import cn.iocoder.yudao.framework.common.util.object.PageUtils; +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod; import cn.iocoder.yudao.framework.mybatis.core.util.CaseInsensitiveResultMap; import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; @@ -13,19 +17,25 @@ import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.segments.MergeSegments; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.MapperProxyMetadata; import com.baomidou.mybatisplus.core.toolkit.Constants; +import com.baomidou.mybatisplus.core.toolkit.MybatisUtils; import com.baomidou.mybatisplus.core.toolkit.support.SFunction; import com.baomidou.mybatisplus.extension.toolkit.Db; import com.github.yulichang.base.MPJBaseMapper; import com.github.yulichang.interfaces.MPJBaseJoin; import com.github.yulichang.wrapper.MPJLambdaWrapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.binding.MapperMethod; +import org.apache.ibatis.exceptions.TooManyResultsException; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Objects; /** * 在 MyBatis Plus 的 BaseMapper 的基础上拓展,提供更多的能力 @@ -141,16 +151,26 @@ public interface BaseMapperX extends MPJBaseMapper { return selectOne(new LambdaQueryWrapper().eq(field1, value1).eq(field2, value2).eq(field3, value3)); } + // TODO DONE @AI:能不能把现有的 selectOneForUpdate、selectFirstOneForUpdate、selectListForFirstLock、 这些,做一个整合。目前感觉有点散。。。不然有点乱;包括 cms 项目里,其实也有一些 lock;/Users/yunai/Java/yudao-cms-all/ruoyi-vue-pro-jdk25/yudao-module-cms;最好有个比较万金油的方案。。。。 + // 已整合为 selectOneForUpdate、selectListForUpdate、selectFirstOneForUpdate 三个锁定读入口(含字段重载),boot、cms 的手写 FOR UPDATE 均改为调用它们; + // 锁定子句由内置查询 SelectForUpdateMethod 生成(不再暴露 selectListForFirstLock 等接口方法),SQL Server 由 ForUpdateInnerInterceptor 改写,Oracle 的首行锁定在本类按数据库类型走多次查询。 + + // ========== 锁定读:需要在事务中调用;Wrapper 不能使用 last、groupBy、having、参数别名 ========== + /** * 获得满足条件的一条记录,并使用 FOR UPDATE 锁定。 * - * 注意:需要在事务中调用,否则锁会立即释放。 + * 注意:需要在事务中调用,否则锁会立即释放;多于 1 条时抛出 {@link TooManyResultsException}(已锁定的记录在事务结束时释放) * * @param queryWrapper 查询条件 * @return 实体 */ default T selectOneForUpdate(LambdaQueryWrapper queryWrapper) { - return selectOne(queryWrapper.last("FOR UPDATE")); + List list = selectListForUpdate(queryWrapper); + if (list.size() > 1) { + throw new TooManyResultsException("Expected one result (or null) to be returned by selectOneForUpdate(), but found: " + list.size()); + } + return CollUtil.getFirst(list); } default T selectOneForUpdate(SFunction field, Object value) { @@ -167,15 +187,112 @@ public interface BaseMapperX extends MPJBaseMapper { } /** + * 获得满足条件的全部记录,并使用 FOR UPDATE 锁定。 + * + * 注意:需要在事务中调用,否则锁会立即释放;返回顺序为 queryWrapper 的排序,未设置时不排序 + * + * @param queryWrapper 查询条件 + * @return 实体列表 + */ + default List selectListForUpdate(LambdaQueryWrapper queryWrapper) { + return selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, null); + } + + default List selectListForUpdate(SFunction field, Object value) { + return selectListForUpdate(new LambdaQueryWrapper().eq(field, value)); + } + + default List selectListForUpdate(SFunction field, Collection values) { + if (CollUtil.isEmpty(values)) { + return CollUtil.newArrayList(); + } + return selectListForUpdate(new LambdaQueryWrapper().in(field, values)); + } + + /** * 获得满足条件的第 1 条记录,并使用 FOR UPDATE 锁定。 * - * 注意:需要在事务中调用,否则锁会立即释放。 + * 注意:需要在事务中调用,否则锁会立即释放;queryWrapper 未设置排序时,为任意一条满足条件的记录(只适合判断是否存在) + * + * Oracle 不支持 LIMIT 1 FOR UPDATE(Oracle 的 FOR UPDATE 也不能与行数限制一起使用),改为多次查询: + * 1. 按原条件、原排序查询首行主键(候选),不加锁;不存在时返回 null + * 2. 按原条件、且主键等于候选主键加锁查询,不限制行数;候选已不满足条件时重试 + * 3. 锁定后重新查询首行主键,与候选相同才返回;不同(例如其它事务已提交了排序更靠前的记录)时重试,最多 + * 5 次后抛出 {@link GlobalErrorCodeConstants#LOCKED} + * 要求实际执行的连接隔离级别为 READ COMMITTED,否则锁定后无法读到其它事务已提交的记录, + * 由 {@link cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor} 校验 + * + * 其它数据库为单条 LIMIT 1 FOR UPDATE(SQL Server 改写为 TOP (1) 与 WITH (UPDLOCK, ROWLOCK)) * * @param queryWrapper 查询条件 * @return 实体 */ default T selectFirstOneForUpdate(LambdaQueryWrapper queryWrapper) { - return selectOne(queryWrapper.last("LIMIT 1 FOR UPDATE")); + DbType dbType = JdbcUtils.getDbType(); + if (!ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) { + return CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK_FIRST, queryWrapper, null)); + } + int maxAttempts = 5; + for (int i = 0; i < maxAttempts; i++) { + List ids = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null); + if (ids.isEmpty()) { + return null; + } + Object id = ids.get(0); + // 是否锁定到候选,以列表是否为空判断:查询列均为 NULL 时,MyBatis 返回的元素为 null + List lockRows = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, id); + if (lockRows.isEmpty()) { + continue; + } + List latestIds = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null); + if (!latestIds.isEmpty() && Objects.equals(id, latestIds.get(0))) { + return lockRows.get(0); + } + } + throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.LOCKED); + } + + /** + * 执行锁定读的内置查询,见 {@link SelectForUpdateMethod} + * + * @param kind 内置查询的类型 + * @param queryWrapper 查询条件 + * @param id 主键;仅 {@link SelectForUpdateMethod.Kind#LOCK} 使用,为 null 时不限制 + * @return 实体或主键列表 + */ + private List selectListByForUpdateMethod(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper queryWrapper, + Object id) { + checkForUpdateWrapper(queryWrapper); + MapperMethod.ParamMap param = new MapperMethod.ParamMap<>(); + param.put(Constants.WRAPPER, queryWrapper); + param.put(SelectForUpdateMethod.PARAM_ID, id); + if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) { + param.put("page", MyBatisUtils.buildPage(0, 1, false)); + } + MapperProxyMetadata mapperProxyMetadata = MybatisUtils.getMapperProxy(this); + return mapperProxyMetadata.getSqlSession().selectList( + mapperProxyMetadata.getMapperInterface().getName() + Constants.DOT + kind.getMethod(), param); + } + + /** + * 校验锁定读的查询条件:内置查询只使用实体条件、普通条件与排序 + * + * @param queryWrapper 查询条件 + */ + private void checkForUpdateWrapper(LambdaQueryWrapper queryWrapper) { + if (queryWrapper == null) { + throw new IllegalArgumentException("锁定读的查询条件不能为空"); + } + MergeSegments expression = queryWrapper.getExpression(); + if (!Objects.equals(queryWrapper.getSqlSegment(), expression.getSqlSegment())) { + throw new IllegalArgumentException("锁定读的查询条件不能使用 last"); + } + if (!expression.getGroupBy().isEmpty() || !expression.getHaving().isEmpty()) { + throw new IllegalArgumentException("锁定读的查询条件不能使用 groupBy、having"); + } + if (!Constants.WRAPPER.equals(queryWrapper.getParamAlias())) { + throw new IllegalArgumentException("锁定读的查询条件不能设置参数别名"); + } } /** diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java index bf0dfd5..5b73003 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java @@ -22,7 +22,9 @@ import java.util.regex.Pattern; * * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库 * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、 - * 达梦、神通、人大金仓、openGauss、GaussDB、瀚高、MogDB 等)保持原有 SQL 片段;完全未识别的数据库抛出异常 + * 达梦、神通、人大金仓、openGauss、GaussDB、瀚高、MogDB 等)保持原有 SQL 片段;完全未识别的数据库抛出异常。 + * 例外:JSON 数组匹配中,瀚高原生驱动、人大金仓使用 PostgreSQL 片段,H2 使用严格的平面 Long 数组片段, + * 见 {@link #jsonArrayContains(String, boolean)} * 3. 参数只能是代码中固定的列名,经过白名单校验;格式均为常量,业务值必须继续使用 #{} 绑定 * * 在 XML 或注解中使用:${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@formatDate('create_time')} @@ -63,6 +65,50 @@ public class SqlDialectUtils { new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); /** + * 数据源与 JSON 方言的缓存;与 {@link #DIALECT_CACHE} 分开,瀚高、人大金仓、H2 仅 JSON 片段使用单独的写法 + */ + private static final Map JSON_DIALECT_CACHE = + new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); + + /** + * 瀚高原生驱动 URL 的前缀 + */ + private static final String HIGHGO_URL_PREFIX = "jdbc:highgo:"; + + /** + * Oracle 平面 Long / null 数组的整体格式,TAB、LF、CR 需先替换为空格: + * 元素为 0、不含前导零的 1~19 位整数或小写 null,元素两侧可有空格;字符串、嵌套数组、对象、小数、指数、空元素均不匹配 + */ + static final String ORACLE_LONG_ARRAY_PATTERN = + "^ *\\[ *((0|-?[1-9][0-9]{0,18}|null) *(, *(0|-?[1-9][0-9]{0,18}|null) *)*)?\\] *$"; + + /** + * H2 平面 Long / null 数组的整体格式,规则同 {@link #ORACLE_LONG_ARRAY_PATTERN} + * + * 注意:H2 的 REGEXP_LIKE 使用 Java 正则,重复的捕获组按递归匹配,长数组(例如 1400 个元素)会栈溢出, + * 因此使用占有量词(*+)与非捕获组;Oracle 的 POSIX 正则不支持该语法,两者不能混用 + */ + static final String H2_LONG_ARRAY_PATTERN = + "^ *+\\[ *+(?:(?:0|-?[1-9][0-9]{0,18}|null) *+(?:, *+(?:0|-?[1-9][0-9]{0,18}|null) *+)*+)?\\] *+$"; + + /** + * 超出 Long 范围的 19 位整数:负数超过 -9223372036854775808,或正数超过 9223372036854775807 + * + * 只使用分组、字符类与固定次数,Oracle 的 POSIX 正则与 H2 的 Java 正则语义一致,两者共用 + */ + static final String LONG_OVERFLOW_PATTERN = "(^|[^0-9])" + greaterDigitsPattern("9223372036854775808") + + "|(^|[^-0-9])9223372036854775808"; + + /** + * SQL Server 校验整个 XML(<r><i>元素</i>...</r>)为平面 Long / null 数组的 XQuery: + * 存在元素,且每个元素去掉两侧空白后非空,并且为 null,或转为 xs:long 再转回文本与原文完全相同(拒绝前导零、溢出等)。 + * 转换失败时 cast as xs:long? 返回空序列,string() 后为空串,与原文不等,因此非法元素会使整体不匹配 + */ + private static final String SQL_SERVER_LONG_ARRAY_XQUERY = "/r[i and not(i[string-length(string(xs:token(string(.)))) = 0" + + " or (string(xs:token(string(.))) != \"null\"" + + " and string(xs:token(string(.))) != string((string(xs:token(string(.))) cast as xs:long?)))])]"; + + /** * 方言 */ enum Dialect { @@ -73,7 +119,11 @@ public class SqlDialectUtils { ORIGINAL, POSTGRESQL, ORACLE, - SQLSERVER + SQLSERVER, + /** + * H2:仅 JSON 片段使用,见 {@link #resolveJsonDialect(DatabaseMetaData)};其它片段的 H2 仍为 {@link #ORIGINAL} + */ + H2 } @@ -355,8 +405,11 @@ public class SqlDialectUtils { * 平均成交周期(天):两个时间之间的完整天数(向零截断)求平均,保留 1 位小数(截断),无数据时为 0 * * 原有片段为 COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, begin, end)), 1), 0)。 - * 注意:仅 PostgreSQL 输出新片段;Oracle、SQL Server 的完整天数表达式依赖实际列类型(DATE / TIMESTAMP / datetime2 / date 等), - * 尚未依据实际 DDL 在真实数据库验收,因此保持原有片段 + * 仅适用于不带时区的日期时间列(LocalDateTime 契约);带时区的 TIMESTAMP WITH TIME ZONE、datetimeoffset 不在适用范围内 + * + * 1. Oracle:两列均升格为 TIMESTAMP(9),DATE、TIMESTAMP(0~9) 均不丢失小数秒,INTERVAL 的 DAY 部分即向零截断的完整天数 + * 2. SQL Server 2012+:DATEDIFF(DAY) 按日期边界计数,按时刻修正为完整天数;先转 datetime2(7) 再取 time(7), + * 兼容 date、datetime、datetime2;AVG 使用 DECIMAL 避免整数平均,ROUND(..., 1, 1) 为截断 * * @param beginColumn 开始时间列名 * @param endColumn 结束时间列名 @@ -367,7 +420,7 @@ public class SqlDialectUtils { } /** - * 按指定方言生成平均完整天数;未验收的数据库继续使用原表达式。 + * 按指定方言生成平均完整天数;原有路径继续使用原表达式。 * * @param dialect 已识别的数据库方言 * @param beginColumn 代码中固定的开始时间列名 @@ -377,11 +430,26 @@ public class SqlDialectUtils { static String customerDealCycle(Dialect dialect, String beginColumn, String endColumn) { checkColumn(beginColumn); checkColumn(endColumn); - if (dialect == Dialect.POSTGRESQL) { - return "COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP) - CAST(" - + beginColumn + " AS TIMESTAMP))) AS NUMERIC)), 1), 0)"; + switch (dialect) { + case POSTGRESQL: + return "COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP) - CAST(" + + beginColumn + " AS TIMESTAMP))) AS NUMERIC)), 1), 0)"; + case ORACLE: + return "COALESCE(TRUNC(AVG(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP(9)) - CAST(" + + beginColumn + " AS TIMESTAMP(9))))), 1), 0)"; + case SQLSERVER: { + String beginTime = "CAST(CAST(" + beginColumn + " AS datetime2(7)) AS time(7))"; + String endTime = "CAST(CAST(" + endColumn + " AS datetime2(7)) AS time(7))"; + String days = "DATEDIFF(DAY, " + beginColumn + ", " + endColumn + ")"; + // 正向时,结束时刻早于开始时刻说明最后一天不完整;反向时同理,均向零修正 1 天 + return "COALESCE(ROUND(AVG(CAST((CASE WHEN " + endColumn + " >= " + beginColumn + + " THEN " + days + " - CASE WHEN " + endTime + " < " + beginTime + " THEN 1 ELSE 0 END" + + " ELSE " + days + " + CASE WHEN " + endTime + " > " + beginTime + " THEN 1 ELSE 0 END END)" + + " AS DECIMAL(19, 4))), 1, 1), 0)"; + } + default: + return "COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, " + beginColumn + ", " + endColumn + ")), 1), 0)"; } - return "COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, " + beginColumn + ", " + endColumn + ")), 1), 0)"; } // ========== 其它 ========== @@ -458,32 +526,130 @@ public class SqlDialectUtils { * * 1. 原有片段:wrapArray 为 false 时为 JSON_CONTAINS(column, {0}),参数为 ID 的数字文本; * 为 true 时为 JSON_CONTAINS(column, JSON_ARRAY({0})),参数为 Long - * 2. PostgreSQL:CAST(column AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT)),兼容 jsonb、json 与 JSON 文本列; - * 按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配 - * 3. Oracle、SQL Server 及其它数据库保持原有片段,是否可用未经验收 + * 2. PostgreSQL、瀚高(原生驱动 jdbc:highgo:)、人大金仓:CAST(column AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT)), + * 兼容 jsonb、json 与 JSON 文本列;按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配 + * 3. Oracle、SQL Server 2012+、H2:仅支持平面 Long / null 数组(应用以 List<Long> 写入的数据),元素两侧允许空白; + * 整个数组含字符串、嵌套数组、对象、小数、空元素、超出 Long 范围等非法元素时整体不匹配,不等价于通用 JSON_CONTAINS。 + * 参数为 null 时匹配数组中的 null 元素,与原有 JSON_CONTAINS(column, JSON_ARRAY(NULL)) 一致;列为 NULL 时不匹配 + * 4. 达梦、openGauss、神通等其它数据库保持原有片段 * * @param column JSON 数组列名,元素为数值 ID * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数 * @return 包含 {0} 占位符的 SQL 条件片段 */ public static String jsonArrayContains(String column, boolean wrapArray) { - return jsonArrayContains(getDialect(), column, wrapArray); + return jsonArrayContains(getJsonDialect(), column, wrapArray); } /** - * 按指定方言生成 JSON 数组包含数值 ID 的条件;仅 PostgreSQL 输出新片段。 + * 按指定方言生成 JSON 数组包含数值 ID 的条件;原有路径保持 JSON_CONTAINS 片段,{0} 在片段中只出现一次。 * - * @param dialect 已识别的数据库方言 + * @param dialect 已识别的 JSON 方言 * @param column 代码中固定的 JSON 数组列名 * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数 * @return 包含 {0} 占位符的 SQL 条件片段 */ static String jsonArrayContains(Dialect dialect, String column, boolean wrapArray) { checkColumn(column); - if (dialect == Dialect.POSTGRESQL) { - return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))"; + switch (dialect) { + case POSTGRESQL: + return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))"; + case ORACLE: + return oracleJsonLongArrayContains(column); + case SQLSERVER: + return sqlServerJsonLongArrayContains(column); + case H2: + return h2JsonLongArrayContains(column); + default: + return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})"; } - return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})"; + } + + /** + * Oracle:先用 REGEXP_LIKE 校验整个值为平面 Long / null 数组,再去掉空白、将首尾括号换为逗号,按 ,元素, 精确匹配。 + * 数值按 TM9 格式与固定小数点字符转为文本,不受 NLS 影响;参数为 null 时匹配 null 元素,避免 Oracle 空串即 NULL 的拼接陷阱 + * + * @param column 代码中固定的 JSON 数组列名 + * @return 包含 {0} 占位符的 SQL 条件片段 + */ + private static String oracleJsonLongArrayContains(String column) { + // 注意:不能使用 TRANSLATE,CLOB 列超过 4000 字节时会报 ORA-22835;REPLACE 对 CLOB 返回 CLOB + String text = "REPLACE(REPLACE(REPLACE(" + column + ", CHR(9), ' '), CHR(10), ' '), CHR(13), ' ')"; + return "(REGEXP_LIKE(" + text + ", '" + ORACLE_LONG_ARRAY_PATTERN + "')" + + " AND NOT REGEXP_LIKE(" + column + ", '" + LONG_OVERFLOW_PATTERN + "')" + + " AND INSTR(REPLACE(REPLACE(REPLACE(" + text + ", ' ', ''), '[', ','), ']', ',')," + + " ',' || COALESCE(TO_CHAR(CAST({0} AS NUMBER(19, 0)), 'TM9', 'NLS_NUMERIC_CHARACTERS = ''.,'''), 'null')" + + " || ',') > 0)"; + } + + /** + * H2:规则同 Oracle,整体格式使用 H2 专用的 {@link #H2_LONG_ARRAY_PATTERN},按 LOCATE 匹配 ,元素, + * + * @param column 代码中固定的 JSON 数组列名 + * @return 包含 {0} 占位符的 SQL 条件片段 + */ + private static String h2JsonLongArrayContains(String column) { + String text = "REPLACE(REPLACE(REPLACE(" + column + ", CHR(9), ' '), CHR(10), ' '), CHR(13), ' ')"; + return "(REGEXP_LIKE(" + text + ", '" + H2_LONG_ARRAY_PATTERN + "')" + + " AND NOT REGEXP_LIKE(" + column + ", '" + LONG_OVERFLOW_PATTERN + "')" + + " AND LOCATE(',' || COALESCE(CAST(CAST({0} AS BIGINT) AS VARCHAR), 'null') || ','," + + " REPLACE(REPLACE(REPLACE(" + text + ", ' ', ''), '[', ','), ']', ',')) > 0)"; + } + + /** + * SQL Server 2012+:不使用 OPENJSON(要求 2016+ 且兼容级别 130+),用 XML 校验元素 + * + * 1. TAB、LF、CR 替换为空格后修剪整个值,要求首尾为 [ ];括号内只允许数字、-、逗号、空格及 null 的小写字母(BIN2 比较), + * 此时不含 XML 元字符,按逗号拆成 <r><i>...</i></r>,其它值拆成空的 <r/> + * 2. 用 .query('.') 物化一份 XML 副本,再对整个文档执行一次 exist 校验(见 {@link #SQL_SERVER_LONG_ARRAY_XQUERY}): + * 直接对内联 CAST 的 XML 执行时可能重复解析,本次验证中 1400 个元素的数组会明显变慢 + * 3. 校验通过后,去掉空格、首尾补逗号,按 ,元素, 用 CHARINDEX 匹配;参数在 SQL 内转为 bigint 再转文本, + * 兼容 String、Long 参数;为 null 时匹配 null 元素 + * + * 注意:最低按 SQL Server 2012 的语法编写,实际只在 SQL Server 2017(兼容级别 110)上验收。 + * 表别名使用 yd_j 前缀,避免遮蔽外层查询的别名 + * + * @param column 代码中固定的 JSON 数组列名 + * @return 包含 {0} 占位符的 SQL 条件片段 + */ + private static String sqlServerJsonLongArrayContains(String column) { + return "EXISTS (SELECT 1" + + " FROM (SELECT LTRIM(RTRIM(REPLACE(REPLACE(REPLACE(CAST(" + column + " AS nvarchar(max))," + + "CHAR(9),N' '),CHAR(10),N' '),CHAR(13),N' '))) AS doc) yd_jd" + + " CROSS APPLY (SELECT CASE WHEN LEFT(yd_jd.doc,1)=N'[' AND RIGHT(yd_jd.doc,1)=N']'" + + " THEN SUBSTRING(yd_jd.doc,2,CASE WHEN LEN(yd_jd.doc)>=2 THEN LEN(yd_jd.doc)-2 ELSE 0 END)" + + " ELSE NULL END AS body) yd_jb" + + " CROSS APPLY (SELECT CAST(CASE WHEN yd_jb.body IS NOT NULL" + + " AND PATINDEX(N'%[^0-9,nul -]%',COALESCE(yd_jb.body,N'') COLLATE Latin1_General_100_BIN2)=0" + + " AND LTRIM(RTRIM(yd_jb.body))<>N''" + + " THEN N''+REPLACE(yd_jb.body,N',',N'')+N'' ELSE N'' END AS XML) AS tokens) yd_jx" + + " CROSS APPLY (SELECT yd_jx.tokens.query('.') AS tokens) yd_jm" + + " WHERE yd_jm.tokens.exist('" + SQL_SERVER_LONG_ARRAY_XQUERY + "') = 1" + + " AND CHARINDEX(N','+COALESCE(CONVERT(nvarchar(20),CAST({0} AS BIGINT)),N'null')+N','," + + "N','+REPLACE(yd_jb.body,N' ',N'')+N',')>0)"; + } + + /** + * 生成与 max 位数相同、数值更大的十进制数字串的正则,例如 max 为 18 时为 (1[9-9]|[2-9][0-9]{1}) + * + * @param max 不含符号的十进制数字串 + * @return 正则片段,不含 {0} 这样会被 Wrapper 当作占位符的文本 + */ + static String greaterDigitsPattern(String max) { + String pattern = null; + for (int i = max.length() - 1; i >= 0; i--) { + char digit = max.charAt(i); + int rest = max.length() - 1 - i; + String greater = digit == '9' ? null + : "[" + (char) (digit + 1) + "-9]" + (rest > 0 ? "[0-9]{" + rest + "}" : ""); + String same = pattern == null ? null : digit + pattern; + if (same == null) { + pattern = greater; + } else { + pattern = greater == null ? same : "(" + same + "|" + greater + ")"; + } + } + return pattern; } // ========== 行数限制 ========== @@ -670,6 +836,57 @@ public class SqlDialectUtils { } /** + * 取得当前执行的 JSON 方言;连接与缓存规则同 {@link #getDialect()},仅 JSON 片段使用 + * + * @return JSON 方言 + */ + static Dialect getJsonDialect() { + Connection connection = JdbcUtils.getTransactionalConnection(); + if (connection != null) { + try { + return resolveJsonDialect(connection.getMetaData()); + } catch (SQLException e) { + throw new IllegalStateException("获取数据库方言失败", e); + } + } + return JSON_DIALECT_CACHE.computeIfAbsent(JdbcUtils.getDataSource(), dataSource -> { + try (Connection conn = dataSource.getConnection()) { + return resolveJsonDialect(conn.getMetaData()); + } catch (SQLException e) { + throw new IllegalStateException("获取数据库方言失败", e); + } + }); + } + + /** + * 识别 JSON 方言,其它与 {@link #resolveDialect(DatabaseMetaData)} 一致;日期等其它片段仍保留原有片段: + * 1. 瀚高原生驱动(jdbc:highgo:)、人大金仓(产品名 KingbaseES):已验收 jsonb 写法、且不支持 JSON_CONTAINS,按 PostgreSQL 处理 + * 2. H2:不支持 JSON_CONTAINS,使用 {@link Dialect#H2} 的严格平面 Long 数组片段 + * + * @param metaData 数据库元数据 + * @return JSON 方言 + * @throws SQLException 读取元数据失败 + */ + static Dialect resolveJsonDialect(DatabaseMetaData metaData) throws SQLException { + Dialect dialect = resolveDialect(metaData); + if (dialect != Dialect.ORIGINAL) { + return dialect; + } + String url = StrUtil.nullToEmpty(metaData.getURL()).toLowerCase(Locale.ROOT); + DbType dbType = DbTypeEnum.find(metaData.getDatabaseProductName()); + if (url.startsWith(HIGHGO_URL_PREFIX) && (dbType == null || dbType == DbType.POSTGRE_SQL)) { + return Dialect.POSTGRESQL; + } + if (dbType == DbType.KINGBASE_ES) { + return Dialect.POSTGRESQL; + } + if (dbType == DbType.H2) { + return Dialect.H2; + } + return dialect; + } + + /** * 根据数据库元数据识别方言 * * 注意:使用 PostgreSQL 官方驱动(jdbc:postgresql:)连接 openGauss、瀚高等兼容 PostgreSQL 的数据库时, diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptorTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptorTest.java new file mode 100644 index 0000000..8f35f4a --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptorTest.java @@ -0,0 +1,318 @@ +package cn.iocoder.yudao.framework.mybatis.core.interceptor; + +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.injector.methods.SelectList; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import lombok.Data; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.executor.statement.RoutingStatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.session.RowBounds; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link ForUpdateInnerInterceptor} 的单元测试 + * + * 使用 {@link SelectForUpdateMethod} 实际注入的 MappedStatement 生成 BoundSql,连接为桩对象 + * + * @author 芋道源码 + */ +public class ForUpdateInnerInterceptorTest { + + private static final String SQL_SERVER = "Microsoft SQL Server"; + private static final String MYSQL = "MySQL"; + private static final String ORACLE = "Oracle"; + + private final ForUpdateInnerInterceptor interceptor = new ForUpdateInnerInterceptor(); + + private MybatisConfiguration configuration; + + @BeforeEach + public void setUp() { + configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace(LockMapper.class.getName()); + TableInfo tableInfo = TableInfoHelper.initTableInfo(assistant, LockDO.class); + new SelectList().inject(assistant, LockMapper.class, LockDO.class, tableInfo); + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) { + new SelectForUpdateMethod(kind).inject(assistant, LockMapper.class, LockDO.class, tableInfo); + } + } + + // ========== 内置查询 ========== + + @Test + public void testMappedStatement() { + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) { + MappedStatement ms = getMappedStatement(kind.getMethod()); + // 断言:SqlSource 标记、缓存 + SelectForUpdateMethod.LockSqlSource sqlSource = assertInstanceOf(SelectForUpdateMethod.LockSqlSource.class, ms.getSqlSource()); + assertSame(kind, sqlSource.getKind()); + assertTrue(ms.isFlushCacheRequired()); + assertFalse(ms.isUseCache()); + // 断言:加锁查询返回实体,主键查询返回主键类型 + Class resultType = ms.getResultMaps().get(0).getType(); + assertSame(kind == SelectForUpdateMethod.Kind.FIRST_IDS ? Long.class : LockDO.class, resultType); + } + } + + @Test + public void testBoundSql() { + // 准备参数:OR 条件 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(LockDO::getName, "a").or().eq(LockDO::getStatus, 1); + + // 调用、断言:加锁查询,OR 条件整体加括号后,再限制主键与逻辑删除;未设置排序时不排序 + String lock = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L).getSql()); + assertTrue(Pattern.compile("WHERE \\(\\s*\\(name = \\? OR status = \\?\\)\\s*\\) AND id = \\? AND deleted\\s*=\\s*0 FOR UPDATE$") + .matcher(lock).find(), lock); + assertFalse(lock.contains("ORDER BY"), lock); + // 调用、断言:加锁查询,不传主键时不限制主键 + String lockWithoutId = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, null).getSql()); + assertFalse(lockWithoutId.contains("id = ?"), lockWithoutId); + // 调用、断言:首行加锁查询,未设置排序时不排序 + String lockFirst = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK_FIRST, wrapper, null).getSql()); + assertTrue(lockFirst.endsWith("AND deleted=0 LIMIT 1 FOR UPDATE"), lockFirst); + assertFalse(lockFirst.contains("ORDER BY"), lockFirst); + // 调用、断言:主键查询,未设置排序时按主键排序,不加锁 + String firstIds = normalize(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql()); + assertTrue(firstIds.startsWith("SELECT id FROM infra_lock_demo"), firstIds); + assertTrue(firstIds.endsWith("ORDER BY id"), firstIds); + // 调用、断言:设置排序时使用 Wrapper 的排序 + wrapper.orderByDesc(LockDO::getStatus); + assertTrue(normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, null).getSql()) + .endsWith("ORDER BY status DESC FOR UPDATE")); + assertTrue(normalize(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql()) + .endsWith("ORDER BY status DESC")); + } + + // ========== 拦截 ========== + + @Test + public void testBeforePrepare_notLockStatement() { + // 准备参数:普通查询,即使 SQL 包含 FOR UPDATE + Map params = new HashMap<>(); + params.put("ew", new LambdaQueryWrapper().eq(LockDO::getName, "for update").last("FOR UPDATE")); + MappedStatement ms = getMappedStatement("selectList"); + BoundSql boundSql = ms.getBoundSql(params); + String sql = boundSql.getSql(); + Connection connection = mock(Connection.class); + + // 调用 + interceptor.beforePrepare(newHandler(ms, params, boundSql), connection, null); + // 断言:SQL 不变,不访问连接 + assertSame(sql, boundSql.getSql()); + verifyNoInteractions(connection); + } + + @Test + public void testBeforePrepare_sqlServerLock() throws SQLException { + // 准备参数 + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK, + new LambdaQueryWrapper().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), 1L, + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_READ_COMMITTED)); + String original = getBoundSql(SelectForUpdateMethod.Kind.LOCK, + new LambdaQueryWrapper().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), 1L).getSql(); + + // 断言:删除 FOR UPDATE,表之后增加提示 + assertEquals(normalize(original.replace("FROM infra_lock_demo", "FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)") + .replace("FOR UPDATE", "")), normalize(boundSql.getSql())); + } + + @Test + public void testBeforePrepare_sqlServerLockFirst() throws SQLException { + // 准备参数 + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK_FIRST, + new LambdaQueryWrapper().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), null, + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_REPEATABLE_READ)); + String original = getBoundSql(SelectForUpdateMethod.Kind.LOCK_FIRST, + new LambdaQueryWrapper().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), null).getSql(); + + // 断言:LIMIT 1 改为 TOP (1),删除 FOR UPDATE,表之后增加提示 + assertEquals(normalize(original.replaceFirst("SELECT", "SELECT TOP (1)") + .replace("FROM infra_lock_demo", "FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)") + .replace("LIMIT 1 FOR UPDATE", "")), normalize(boundSql.getSql())); + } + + @Test + public void testBeforePrepare_sqlServerMarkerAfterCopy() throws SQLException { + // 准备参数:与 MyBatis Plus Join 一样,复制 MappedStatement 时保留同一个 SqlSource + MappedStatement origin = getMappedStatement(SelectForUpdateMethod.Kind.LOCK.getMethod()); + MappedStatement ms = new MappedStatement.Builder(configuration, origin.getId(), origin.getSqlSource(), + origin.getSqlCommandType()).resultMaps(origin.getResultMaps()).build(); + Map params = buildParams(new LambdaQueryWrapper().eq(LockDO::getName, "a"), null); + BoundSql boundSql = ms.getBoundSql(params); + + // 调用 + interceptor.beforePrepare(newHandler(ms, params, boundSql), + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_READ_COMMITTED), null); + // 断言 + assertTrue(boundSql.getSql().contains("FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)"), boundSql.getSql()); + assertFalse(boundSql.getSql().contains("FOR UPDATE"), boundSql.getSql()); + } + + @Test + public void testBeforePrepare_mysql() throws SQLException { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(LockDO::getName, "a"); + for (SelectForUpdateMethod.Kind kind : new SelectForUpdateMethod.Kind[]{ + SelectForUpdateMethod.Kind.LOCK, SelectForUpdateMethod.Kind.LOCK_FIRST}) { + // 准备参数:MySQL 默认的 REPEATABLE READ + Connection connection = mockConnection(MYSQL, false, Connection.TRANSACTION_REPEATABLE_READ); + + // 调用 + BoundSql boundSql = beforePrepare(kind, wrapper, null, connection); + // 断言:SQL 不变,不校验隔离级别 + assertEquals(getBoundSql(kind, wrapper, null).getSql(), boundSql.getSql()); + verify(connection, never()).getTransactionIsolation(); + } + } + + @Test + public void testBeforePrepare_autoCommit() throws SQLException { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(LockDO::getName, "a"); + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) { + // 调用,并断言异常 + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> beforePrepare(kind, wrapper, null, + mockConnection(MYSQL, true, Connection.TRANSACTION_READ_COMMITTED))); + assertTrue(ex.getMessage().contains("自动提交")); + } + } + + @Test + public void testBeforePrepare_firstIds() throws SQLException { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(LockDO::getName, "a"); + + // 调用,并断言异常:非 READ COMMITTED + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> beforePrepare(SelectForUpdateMethod.Kind.FIRST_IDS, + wrapper, null, mockConnection(ORACLE, false, Connection.TRANSACTION_SERIALIZABLE))); + assertTrue(ex.getMessage().contains("READ COMMITTED")); + // 调用:READ COMMITTED + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null, + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED)); + // 断言:SQL 不变 + assertEquals(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql(), boundSql.getSql()); + } + + @Test + public void testBeforePrepare_oracle() throws SQLException { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(LockDO::getName, "a"); + + // 调用:加锁查询,SQL 不变 + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L, + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED)); + assertEquals(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L).getSql(), boundSql.getSql()); + // 调用,并断言异常:首行加锁查询 + assertThrows(IllegalStateException.class, () -> beforePrepare(SelectForUpdateMethod.Kind.LOCK_FIRST, wrapper, null, + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED))); + } + + // ========== SQL Server 改写 ========== + + @Test + public void testRewriteForSqlServer() { + // 字符串中的 for update 不改写 + assertEquals("SELECT id FROM t WITH (UPDLOCK, ROWLOCK) WHERE name = 'for update' ", + ForUpdateInnerInterceptor.rewriteForSqlServer("SELECT id FROM t WHERE name = 'for update' FOR UPDATE", false)); + // 子查询的表不加提示 + assertEquals("SELECT id FROM a WITH (UPDLOCK, ROWLOCK) WHERE (b IN (SELECT id FROM c WHERE site_id = 1)) ORDER BY id ASC ", + ForUpdateInnerInterceptor.rewriteForSqlServer( + "SELECT id FROM a WHERE (b IN (SELECT id FROM c WHERE site_id = 1)) ORDER BY id ASC FOR UPDATE", false)); + // 首行 + assertEquals("SELECT TOP (1) id FROM t WITH (UPDLOCK, ROWLOCK) WHERE (a = ?) ORDER BY id ASC ", + ForUpdateInnerInterceptor.rewriteForSqlServer("SELECT id FROM t WHERE (a = ?) ORDER BY id ASC LIMIT 1 FOR UPDATE", true)); + } + + @Test + public void testRewriteForSqlServer_unsupported() { + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( + "SELECT a.id FROM a JOIN b ON a.id = b.id FOR UPDATE", false)); + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( + "SELECT id FROM t FOR UPDATE NOWAIT", false)); + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( + "SELECT id FROM t", false)); + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( + "SELECT id FROM t LIMIT 1 FOR UPDATE", false)); + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( + "SELECT id FROM t LIMIT 2 FOR UPDATE", true)); + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer( + "SELECT DISTINCT id FROM t LIMIT 1 FOR UPDATE", true)); + } + + // ========== 工具方法 ========== + + private BoundSql beforePrepare(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper wrapper, Object id, + Connection connection) { + MappedStatement ms = getMappedStatement(kind.getMethod()); + Map params = buildParams(wrapper, id); + BoundSql boundSql = ms.getBoundSql(params); + interceptor.beforePrepare(newHandler(ms, params, boundSql), connection, null); + return boundSql; + } + + private BoundSql getBoundSql(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper wrapper, Object id) { + return getMappedStatement(kind.getMethod()).getBoundSql(buildParams(wrapper, id)); + } + + private static Map buildParams(LambdaQueryWrapper wrapper, Object id) { + Map params = new HashMap<>(); + params.put("ew", wrapper); + params.put(SelectForUpdateMethod.PARAM_ID, id); + return params; + } + + private MappedStatement getMappedStatement(String method) { + return configuration.getMappedStatement(LockMapper.class.getName() + "." + method); + } + + private static RoutingStatementHandler newHandler(MappedStatement ms, Object params, BoundSql boundSql) { + return new RoutingStatementHandler(mock(Executor.class), ms, params, RowBounds.DEFAULT, null, boundSql); + } + + private static Connection mockConnection(String productName, boolean autoCommit, int isolation) throws SQLException { + Connection connection = mock(Connection.class, RETURNS_DEEP_STUBS); + when(connection.getMetaData().getDatabaseProductName()).thenReturn(productName); + when(connection.getAutoCommit()).thenReturn(autoCommit); + when(connection.getTransactionIsolation()).thenReturn(isolation); + return connection; + } + + private static String normalize(String sql) { + return sql.replaceAll("\\s+", " ").trim(); + } + + @Data + @TableName("infra_lock_demo") + public static class LockDO { + + @TableId + private Long id; + private String name; + private Integer status; + @TableLogic + private Boolean deleted; + + } + + public interface LockMapper extends BaseMapper { + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java index 7f4a4ab..b308ea0 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java @@ -35,6 +35,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.BiFunction; +import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.*; @@ -147,9 +148,19 @@ public class SqlDialectUtilsTest { assertEquals("COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(contract.order_date AS TIMESTAMP) - " + "CAST(customer.create_time AS TIMESTAMP))) AS NUMERIC)), 1), 0)", SqlDialectUtils.customerDealCycle(Dialect.POSTGRESQL, "customer.create_time", "contract.order_date")); - // Oracle、SQL Server:完整天数表达式依赖实际列类型,尚未验收,保持原有片段 - assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.ORACLE, "customer.create_time", "contract.order_date")); - assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.SQLSERVER, "customer.create_time", "contract.order_date")); + // Oracle:升格为 TIMESTAMP(9),DATE 与 TIMESTAMP(9) 均不丢失精度,INTERVAL 的 DAY 部分即完整天数 + assertEquals("COALESCE(TRUNC(AVG(EXTRACT(DAY FROM (CAST(contract.order_date AS TIMESTAMP(9)) - " + + "CAST(customer.create_time AS TIMESTAMP(9))))), 1), 0)", + SqlDialectUtils.customerDealCycle(Dialect.ORACLE, "customer.create_time", "contract.order_date")); + // SQL Server:DATEDIFF(DAY) 按时刻向零修正,DECIMAL 平均,ROUND(..., 1, 1) 截断 + String beginTime = "CAST(CAST(customer.create_time AS datetime2(7)) AS time(7))"; + String endTime = "CAST(CAST(contract.order_date AS datetime2(7)) AS time(7))"; + String days = "DATEDIFF(DAY, customer.create_time, contract.order_date)"; + assertEquals("COALESCE(ROUND(AVG(CAST((CASE WHEN contract.order_date >= customer.create_time THEN " + + days + " - CASE WHEN " + endTime + " < " + beginTime + " THEN 1 ELSE 0 END ELSE " + + days + " + CASE WHEN " + endTime + " > " + beginTime + " THEN 1 ELSE 0 END END)" + + " AS DECIMAL(19, 4))), 1, 1), 0)", + SqlDialectUtils.customerDealCycle(Dialect.SQLSERVER, "customer.create_time", "contract.order_date")); } @Test @@ -332,18 +343,210 @@ public class SqlDialectUtilsTest { @Test public void testJsonArrayContains() { - // 原有片段、Oracle、SQL Server:保持原有 JSON_CONTAINS - for (Dialect dialect : List.of(Dialect.ORIGINAL, Dialect.ORACLE, Dialect.SQLSERVER)) { - assertEquals("JSON_CONTAINS(booking_user_ids, {0})", - SqlDialectUtils.jsonArrayContains(dialect, "booking_user_ids", false), dialect.name()); - assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))", - SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true), dialect.name()); - } + // 原有片段:保持原有 JSON_CONTAINS + assertEquals("JSON_CONTAINS(booking_user_ids, {0})", + SqlDialectUtils.jsonArrayContains(Dialect.ORIGINAL, "booking_user_ids", false)); + assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))", + SqlDialectUtils.jsonArrayContains(Dialect.ORIGINAL, "t.label_ids", true)); // PostgreSQL:两种原有片段统一为 jsonb 包含,参数按 BIGINT 匹配数字元素 assertEquals("CAST(booking_user_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))", SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "booking_user_ids", false)); assertEquals("CAST(t.label_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))", SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "t.label_ids", true)); + // Oracle、SQL Server、H2:两种原有片段相同,{0} 只出现一次,不使用 JSON 函数 + for (Dialect dialect : List.of(Dialect.ORACLE, Dialect.SQLSERVER, Dialect.H2)) { + String condition = SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true); + assertEquals(condition, SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", false), dialect.name()); + assertEquals(1, condition.split("\\{0}", -1).length - 1, condition); + assertFalse(condition.toUpperCase().contains("JSON"), condition); + } + String oracle = SqlDialectUtils.jsonArrayContains(Dialect.ORACLE, "t.label_ids", true); + // CLOB 超过 4000 字节时 TRANSLATE 报 ORA-22835,空白只能用 REPLACE 替换 + assertTrue(oracle.contains("REGEXP_LIKE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), "), + oracle); + assertFalse(oracle.toUpperCase().contains("TRANSLATE"), oracle); + assertTrue(oracle.contains("'" + SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN + "'"), oracle); + assertFalse(oracle.contains(SqlDialectUtils.H2_LONG_ARRAY_PATTERN), oracle); + assertTrue(oracle.contains("NOT REGEXP_LIKE(t.label_ids, '" + SqlDialectUtils.LONG_OVERFLOW_PATTERN + "')"), oracle); + assertTrue(oracle.contains("COALESCE(TO_CHAR(CAST({0} AS NUMBER(19, 0)), 'TM9', " + + "'NLS_NUMERIC_CHARACTERS = ''.,'''), 'null')"), oracle); + // SQL Server:与原生验收的 SQL 一致;XML 用 .query('.') 物化后整体 exist 校验,再按逗号边界 CHARINDEX 匹配 + String sqlServer = SqlDialectUtils.jsonArrayContains(Dialect.SQLSERVER, "t.label_ids", true); + assertEquals("EXISTS (SELECT 1" + + " FROM (SELECT LTRIM(RTRIM(REPLACE(REPLACE(REPLACE(CAST(t.label_ids AS nvarchar(max)),CHAR(9),N' '),CHAR(10),N' '),CHAR(13),N' '))) AS doc) yd_jd" + + " CROSS APPLY (SELECT CASE WHEN LEFT(yd_jd.doc,1)=N'[' AND RIGHT(yd_jd.doc,1)=N']'" + + " THEN SUBSTRING(yd_jd.doc,2,CASE WHEN LEN(yd_jd.doc)>=2" + + " THEN LEN(yd_jd.doc)-2 ELSE 0 END) ELSE NULL END AS body) yd_jb" + + " CROSS APPLY (SELECT CAST(CASE WHEN yd_jb.body IS NOT NULL" + + " AND PATINDEX(N'%[^0-9,nul -]%',COALESCE(yd_jb.body,N'') COLLATE Latin1_General_100_BIN2)=0" + + " AND LTRIM(RTRIM(yd_jb.body))<>N''" + + " THEN N''+REPLACE(yd_jb.body,N',',N'')+N'' ELSE N'' END AS XML) AS tokens) yd_jx" + + " CROSS APPLY (SELECT yd_jx.tokens.query('.') AS tokens) yd_jm" + + " WHERE yd_jm.tokens.exist('/r[i and not(i[string-length(string(xs:token(string(.)))) = 0" + + " or (string(xs:token(string(.))) != \"null\"" + + " and string(xs:token(string(.))) != string((string(xs:token(string(.))) cast as xs:long?)))])]') = 1" + + " AND CHARINDEX(N','+COALESCE(CONVERT(nvarchar(20),CAST({0} AS BIGINT)),N'null')+N',',N','+REPLACE(yd_jb.body,N' ',N'')+N',')>0)", + sqlServer); + // SQL Server 2012~2016 不支持 OPENJSON,任何分支都不能出现;不逐个遍历节点,XQuery 中不嵌入业务列 + assertFalse(sqlServer.toUpperCase().contains("OPENJSON"), sqlServer); + assertFalse(sqlServer.contains(".nodes("), sqlServer); + assertFalse(sqlServer.contains("sql:column"), sqlServer); + // H2:与原生验收的 SQL 一致,使用 H2 专用的正则(Java 正则的占有量词),不使用 Oracle 的正则 + String h2 = SqlDialectUtils.jsonArrayContains(Dialect.H2, "t.label_ids", true); + assertEquals("^ *+\\[ *+(?:(?:0|-?[1-9][0-9]{0,18}|null) *+(?:, *+(?:0|-?[1-9][0-9]{0,18}|null) *+)*+)?\\] *+$", + SqlDialectUtils.H2_LONG_ARRAY_PATTERN); + assertEquals("(REGEXP_LIKE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), '" + + SqlDialectUtils.H2_LONG_ARRAY_PATTERN + "')" + + " AND NOT REGEXP_LIKE(t.label_ids, '" + SqlDialectUtils.LONG_OVERFLOW_PATTERN + "')" + + " AND LOCATE(',' || COALESCE(CAST(CAST({0} AS BIGINT) AS VARCHAR), 'null') || ','," + + " REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' ')," + + " ' ', ''), '[', ','), ']', ',')) > 0)", + h2); + assertFalse(h2.contains(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN), h2); + } + + /** + * Oracle 片段的匹配语义:按 REGEXP_LIKE、REPLACE、INSTR 的组合在 Java 中复现,校验严格的平面 Long / null 数组契约 + */ + @Test + public void testJsonArrayContains_oracleLongArraySemantics() { + // 普通匹配:相邻 ID、负数、大 ID、Long 边界、空白 + assertTrue(oracleJsonContains("[1,11]", 1L)); + assertTrue(oracleJsonContains("[1,11]", 11L)); + assertFalse(oracleJsonContains("[11,21]", 1L)); + assertTrue(oracleJsonContains("[2115311103682412546,2115311103682412547]", 2115311103682412546L)); + assertFalse(oracleJsonContains("[2115311103682412546]", 211531110368241254L)); + assertTrue(oracleJsonContains("[-1, 1]", -1L)); + assertFalse(oracleJsonContains("[-1]", 1L)); + assertTrue(oracleJsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MIN_VALUE)); + assertTrue(oracleJsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MAX_VALUE)); + assertTrue(oracleJsonContains(" [ 1 ,\t2 ,\r\n 3 ] ", 2L)); + assertTrue(oracleJsonContains("[0]", 0L)); + // null 元素:参数为 null 时匹配 null 元素 + assertTrue(oracleJsonContains("[null]", null)); + assertTrue(oracleJsonContains("[1, null]", null)); + assertTrue(oracleJsonContains("[1, null]", 1L)); + assertFalse(oracleJsonContains("[1]", null)); + assertFalse(oracleJsonContains("[NULL]", null)); + // 非数组、空数组、列为 NULL + assertFalse(oracleJsonContains(null, 1L)); + assertFalse(oracleJsonContains(null, null)); + assertFalse(oracleJsonContains("null", null)); + assertFalse(oracleJsonContains("[]", 1L)); + assertFalse(oracleJsonContains("[ ]", null)); + assertFalse(oracleJsonContains("1", 1L)); + // 字符串、嵌套、对象、小数、指数、前导零、空元素、缺分隔符:整体不匹配 + assertFalse(oracleJsonContains("[\"1\"]", 1L)); + assertFalse(oracleJsonContains("[\"x,1,y\"]", 1L)); + assertFalse(oracleJsonContains("[1,\"x\"]", 1L)); + assertFalse(oracleJsonContains("[[1]]", 1L)); + assertFalse(oracleJsonContains("[1,[2]]", 1L)); + assertFalse(oracleJsonContains("[{\"a\":1}]", 1L)); + assertFalse(oracleJsonContains("[1.0]", 1L)); + assertFalse(oracleJsonContains("[1e3]", 1000L)); + assertFalse(oracleJsonContains("[01]", 1L)); + assertFalse(oracleJsonContains("[-0]", 0L)); + assertFalse(oracleJsonContains("[1,,2]", 1L)); + assertFalse(oracleJsonContains("[,1]", 1L)); + assertFalse(oracleJsonContains("[1,]", 1L)); + assertFalse(oracleJsonContains("[1 2]", 12L)); + assertFalse(oracleJsonContains("[1 2]", 1L)); + assertFalse(oracleJsonContains("[1]]", 1L)); + // 超出 Long 范围:整个数组不匹配 + assertFalse(oracleJsonContains("[9223372036854775808,1]", 1L)); + assertFalse(oracleJsonContains("[-9223372036854775809,1]", 1L)); + assertFalse(oracleJsonContains("[9999999999999999999,1]", 1L)); + assertFalse(oracleJsonContains("[99999999999999999999,1]", 1L)); + assertTrue(oracleJsonContains("[-9223372036854775808,1]", 1L)); + assertTrue(oracleJsonContains("[9223372036854775807,1]", 1L)); + assertTrue(oracleJsonContains("[1000000000000000000,1]", 1L)); + // 长 CLOB 数组由 Oracle 实库验证;Java Pattern 的递归栈不代表 Oracle 的正则执行能力。 + // H2 使用 Java 正则,其长数组边界由 testJsonArrayContains_h2LongArraySemantics 验证。 + } + + /** + * H2 片段的匹配语义:H2 的 REGEXP_LIKE 使用 Java 正则,H2 专用的正则在长数组上不能栈溢出 + * + * 注意:仅校验正则与匹配规则,不代替 H2 实库验收 + */ + @Test + public void testJsonArrayContains_h2LongArraySemantics() { + // 普通匹配、null 元素、空白 + assertTrue(h2JsonContains("[1,11]", 1L)); + assertFalse(h2JsonContains("[11,21]", 1L)); + assertTrue(h2JsonContains("[2115311103682412546,2115311103682412547]", 2115311103682412547L)); + assertTrue(h2JsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MIN_VALUE)); + assertTrue(h2JsonContains(" [ 1 ,\t2 ,\r\n 3 ] ", 2L)); + assertTrue(h2JsonContains("[1, null]", null)); + assertFalse(h2JsonContains("[1]", null)); + // 非数组、字符串、嵌套、小数、前导零、空元素、缺分隔符、超出 Long 范围:整体不匹配 + assertFalse(h2JsonContains(null, 1L)); + assertFalse(h2JsonContains("null", null)); + assertFalse(h2JsonContains("[]", 1L)); + assertFalse(h2JsonContains("[\"1\"]", 1L)); + assertFalse(h2JsonContains("[\"x,1,y\"]", 1L)); + assertFalse(h2JsonContains("[[1]]", 1L)); + assertFalse(h2JsonContains("[1.0]", 1L)); + assertFalse(h2JsonContains("[01]", 1L)); + assertFalse(h2JsonContains("[1,,2]", 1L)); + assertFalse(h2JsonContains("[1 2]", 1L)); + assertFalse(h2JsonContains("[9223372036854775808,1]", 1L)); + assertFalse(h2JsonContains("[-9223372036854775809,1]", 1L)); + // 1400 个元素的长数组:占有量词不回溯、不递归,不能栈溢出;末尾非法元素整体不匹配 + StringBuilder longArray = new StringBuilder("[0"); + for (int i = 1; i < 1400; i++) { + longArray.append(i % 2 == 0 ? ",\n" : " , ").append(2115311103682412546L + i); + } + String longJson = longArray.append(']').toString(); + assertTrue(h2JsonContains(longJson, 2115311103682412546L + 1399)); + assertFalse(h2JsonContains(longJson, 2115311103682412546L + 1400)); + assertFalse(h2JsonContains(longJson.replace("]", ",\"x\"]"), 0L)); + } + + @Test + public void testGreaterDigitsPattern() { + assertEquals("(1[9-9]|[2-9][0-9]{1})", SqlDialectUtils.greaterDigitsPattern("18")); + Pattern pattern = Pattern.compile(SqlDialectUtils.greaterDigitsPattern("9223372036854775808")); + assertTrue(pattern.matcher("9223372036854775809").matches()); + assertTrue(pattern.matcher("9223372036854775810").matches()); + assertTrue(pattern.matcher("9300000000000000000").matches()); + assertTrue(pattern.matcher("9999999999999999999").matches()); + assertFalse(pattern.matcher("9223372036854775808").matches()); + assertFalse(pattern.matcher("9223372036854775807").matches()); + assertFalse(pattern.matcher("8999999999999999999").matches()); + assertFalse(pattern.matcher("1000000000000000000").matches()); + // Oracle 正则最长 512 字节;也不能包含会被 Wrapper 当作占位符的 {0} + assertTrue(SqlDialectUtils.LONG_OVERFLOW_PATTERN.length() < 512); + assertTrue(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN.length() < 512); + assertFalse(SqlDialectUtils.LONG_OVERFLOW_PATTERN.contains("{0}")); + assertFalse(SqlDialectUtils.H2_LONG_ARRAY_PATTERN.contains("{0}")); + } + + @Test + public void testResolveJsonDialect() throws SQLException { + // 瀚高原生驱动:仅 JSON 片段使用 PostgreSQL 写法,其它片段仍保持原有 + assertEquals(Dialect.POSTGRESQL, resolveJson("HighGo", "jdbc:highgo://127.0.0.1:5866/highgo", 6)); + assertEquals(Dialect.POSTGRESQL, resolveJson("PostgreSQL", "jdbc:highgo://127.0.0.1:5866/highgo", 6)); + assertEquals(Dialect.ORIGINAL, resolve("PostgreSQL", "jdbc:highgo://127.0.0.1:5866/highgo", 6)); + // 人大金仓(产品名 KingbaseES):仅 JSON 片段使用 PostgreSQL 写法,其它片段仍保持原有 + assertEquals(Dialect.POSTGRESQL, resolveJson("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8)); + assertEquals(Dialect.ORIGINAL, resolve("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8)); + // H2:仅 JSON 片段使用严格的平面 Long 数组写法,其它片段仍保持原有 + assertEquals(Dialect.H2, resolveJson("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2)); + assertEquals(Dialect.ORIGINAL, resolve("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2)); + // 其它 PostgreSQL 变种(含产品名为 PostgreSQL 的金仓 URL)、达梦、神通、MySQL:保持原有 JSON_CONTAINS + assertEquals(Dialect.ORIGINAL, resolveJson("PostgreSQL", "jdbc:opengauss://127.0.0.1:5432/test", 9)); + assertEquals(Dialect.ORIGINAL, resolveJson("openGauss", "jdbc:opengauss://127.0.0.1:5432/test", 5)); + assertEquals(Dialect.ORIGINAL, resolveJson("PostgreSQL", "jdbc:kingbase8://127.0.0.1:54321/test", 12)); + assertEquals(Dialect.ORIGINAL, resolveJson("DM DBMS", "jdbc:dm://127.0.0.1:5236", 8)); + assertEquals(Dialect.ORIGINAL, resolveJson("OSCAR", "jdbc:oscar://127.0.0.1:2003/osrdb", 7)); + assertEquals(Dialect.ORIGINAL, resolveJson("MySQL", "jdbc:mysql://127.0.0.1:3306/highgo", 8)); + assertEquals(Dialect.ORIGINAL, resolveJson("Microsoft SQL Server 2005", "jdbc:jtds:sqlserver://127.0.0.1:1433", 9)); + // 其它与 resolveDialect 一致 + assertEquals(Dialect.POSTGRESQL, resolveJson("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17)); + assertEquals(Dialect.ORACLE, resolveJson("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21)); + assertEquals(Dialect.SQLSERVER, resolveJson("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 11)); + assertThrows(IllegalStateException.class, () -> resolveJson("SQLite", "jdbc:sqlite:/tmp/test.db", 3)); } @Test @@ -387,15 +590,20 @@ public class SqlDialectUtilsTest { FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21); FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16); FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8); - mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, "mysql", mysql)); + FakeDataSource h2 = new FakeDataSource("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2); + FakeDataSource kingbase = new FakeDataSource("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8); + mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, + "mysql", mysql, "h2", h2, "kingbase", kingbase)); SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(), "", Map.class); - // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL + // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL、H2、人大金仓 Map routes = new LinkedHashMap<>(); routes.put("master", Dialect.POSTGRESQL); routes.put("oracle", Dialect.ORACLE); routes.put("sqlserver", Dialect.SQLSERVER); routes.put("mysql", Dialect.ORIGINAL); + routes.put("h2", Dialect.H2); + routes.put("kingbase", Dialect.POSTGRESQL); for (Map.Entry route : routes.entrySet()) { DynamicDataSourceContextHolder.push(route.getKey()); @@ -408,7 +616,7 @@ public class SqlDialectUtilsTest { String expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "label_ids", true).replace("{0}", "?"); assertTrue(sql.startsWith("SELECT id FROM pms_work_item WHERE"), sql); assertTrue(sql.contains(expected + " OR " + expected), sql); - assertFalse(sql.contains("11"), sql); // 值只通过参数绑定,不拼接到 SQL 中 + assertFalse(sql.replace(expected, "").contains("11"), sql); // 值只通过参数绑定,不拼接到 SQL 中 assertEquals(List.of(100L, 1L, 11L), getParameterValues(boundSql, labelWrapper)); assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); @@ -420,7 +628,7 @@ public class SqlDialectUtilsTest { sql = normalize(boundSql.getSql()); expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "booking_user_ids", false).replace("{0}", "?"); assertTrue(sql.contains(expected), sql); - assertFalse(sql.contains("1024"), sql); + assertFalse(sql.replace(expected, "").contains("1024"), sql); assertEquals(List.of(1, "1024"), getParameterValues(boundSql, bookingWrapper)); assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); } @@ -816,6 +1024,43 @@ public class SqlDialectUtilsTest { // ========== 工具方法 ========== + private static Dialect resolveJson(String productName, String url, int majorVersion) throws SQLException { + return SqlDialectUtils.resolveJsonDialect(new FakeDataSource(productName, url, majorVersion).getMetaData()); + } + + /** + * 在 Java 中复现 Oracle 片段:REPLACE 空白、REGEXP_LIKE 整体格式与溢出、去空白后 INSTR 匹配 ,元素, + * + * @param json 列值,null 表示 SQL NULL + * @param value 绑定参数 + * @return 是否匹配 + */ + private static boolean oracleJsonContains(String json, Long value) { + return jsonContains(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN, json, value); + } + + /** + * 在 Java 中复现 H2 片段,规则同 {@link #oracleJsonContains(String, Long)},整体格式使用 H2 专用的正则 + */ + private static boolean h2JsonContains(String json, Long value) { + return jsonContains(SqlDialectUtils.H2_LONG_ARRAY_PATTERN, json, value); + } + + private static boolean jsonContains(String arrayPattern, String json, Long value) { + if (json == null) { + return false; + } + String text = json.replace('\t', ' ').replace('\n', ' ').replace('\r', ' '); + if (!Pattern.compile(arrayPattern).matcher(text).find()) { + return false; + } + if (Pattern.compile(SqlDialectUtils.LONG_OVERFLOW_PATTERN).matcher(json).find()) { + return false; + } + String elements = text.replace(" ", "").replace('[', ',').replace(']', ','); + return elements.contains("," + (value == null ? "null" : value.toString()) + ","); + } + private static Dialect resolve(String productName, String url, int majorVersion) throws SQLException { return SqlDialectUtils.resolveDialect(new FakeDataSource(productName, url, majorVersion).getMetaData()); } diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/RedisKeyConstants.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/RedisKeyConstants.java index 5f7d159..e223833 100644 --- a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/RedisKeyConstants.java +++ b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/RedisKeyConstants.java @@ -23,4 +23,14 @@ public interface RedisKeyConstants { */ String INSURANCE_STANDARD_PROJECT = "hrm:insurance_standard_project"; + /** + * 月度社保表变更的分布式锁 + * + * 全局唯一、不区分租户:首月创建、次月创建、删除互斥;各租户的数据仍按租户过滤 + * + * KEY 格式:hrm:insurance_month_record_mutation_lock + * VALUE 数据格式:Redisson 分布式锁 + */ + String INSURANCE_MONTH_RECORD_MUTATION_LOCK = "hrm:insurance_month_record_mutation_lock"; + } diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/insurance/HrmInsuranceMonthRecordLockRedisDAO.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/insurance/HrmInsuranceMonthRecordLockRedisDAO.java new file mode 100644 index 0000000..016d34d --- /dev/null +++ b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/insurance/HrmInsuranceMonthRecordLockRedisDAO.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.hrm.dal.redis.insurance; + +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.LOCKED; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.hrm.dal.redis.RedisKeyConstants.INSURANCE_MONTH_RECORD_MUTATION_LOCK; + +/** + * 月度社保表变更的锁 Redis DAO + * + * 首月创建、次月创建、删除共用一把锁,避免没有记录可锁定时并发创建出多个首月社保表。 + * 不设置租期,由 Redisson 看门狗续期;锁在事务提交或回滚后释放,保证下一个获得锁的请求能读到已提交的数据 + * + * @author 芋道源码 + */ +@Repository +@Slf4j +public class HrmInsuranceMonthRecordLockRedisDAO { + + /** + * 等待获得锁的最长时间,单位:秒 + */ + static final long LOCK_WAIT_SECONDS = 10; + + @Resource + private RedissonClient redissonClient; + + /** + * 获得锁并执行逻辑,锁在当前事务完成(提交或回滚)后释放 + * + * 注意:需要在事务中调用;等待超时、或等待时线程被中断,抛出 {@code LOCKED} 业务异常 + * + * @param supplier 加锁执行的逻辑 + * @return 执行结果 + */ + public V lock(Supplier supplier) { + // 1. 校验事务:存在实际事务、且事务同步已激活,才能在事务完成后释放锁 + if (!TransactionSynchronizationManager.isActualTransactionActive() + || !TransactionSynchronizationManager.isSynchronizationActive()) { + throw new IllegalStateException("月度社保表变更锁需要在事务中获得"); + } + + // 2. 获得锁 + RLock lock = redissonClient.getLock(INSURANCE_MONTH_RECORD_MUTATION_LOCK); + boolean locked; + try { + locked = lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw exception(LOCKED); + } + if (!locked) { + throw exception(LOCKED); + } + + // 3. 事务完成后释放;注册失败时立即释放 + try { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCompletion(int status) { + unlock(lock); + } + + }); + } catch (RuntimeException e) { + unlock(lock); + throw e; + } + + // 4. 执行逻辑;抛出异常时,由事务回滚后释放锁 + return supplier.get(); + } + + /** + * 获得锁并执行逻辑,见 {@link #lock(Supplier)} + * + * @param runnable 加锁执行的逻辑 + */ + public void lock(Runnable runnable) { + lock(() -> { + runnable.run(); + return null; + }); + } + + /** + * 释放锁;失败时仅打印日志,不影响事务的结果 + * + * @param lock 锁 + */ + private static void unlock(RLock lock) { + try { + lock.unlock(); + } catch (RuntimeException e) { + log.error("[unlock][月度社保表变更锁({}) 释放失败]", INSURANCE_MONTH_RECORD_MUTATION_LOCK, e); + } + } + +} diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImpl.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImpl.java index e4dced0..ef1d917 100644 --- a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImpl.java +++ b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImpl.java @@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.hrm.controller.admin.insurance.vo.monthrecord.Hrm import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthEmployeeRecordDO; import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthRecordDO; import cn.iocoder.yudao.module.hrm.dal.mysql.insurance.monthrecord.HrmInsuranceMonthRecordMapper; +import cn.iocoder.yudao.module.hrm.dal.redis.insurance.HrmInsuranceMonthRecordLockRedisDAO; import cn.iocoder.yudao.module.hrm.enums.insurance.employee.HrmInsuranceEmployeeStatusEnum; import cn.iocoder.yudao.module.hrm.enums.insurance.monthrecord.HrmInsuranceMonthStatusEnum; import com.mzt.logapi.context.LogRecordContext; @@ -50,24 +51,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco @Resource private HrmInsuranceMonthEmployeeRecordService monthEmployeeRecordService; + @Resource + private HrmInsuranceMonthRecordLockRedisDAO monthRecordLockRedisDAO; + @Override @Transactional(rollbackFor = Exception.class) @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_CREATE_SUB_TYPE, bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_CREATE_SUCCESS) public Long createFirstMonthRecord(HrmInsuranceMonthRecordCreateReqVO reqVO) { - // 1. 校验尚未生成社保表 - if (monthRecordMapper.selectLastForUpdate() != null) { - throw exception(INSURANCE_FIRST_MONTH_RECORD_EXISTS); - } + // 加锁:尚无社保表时没有可锁定的记录,避免并发创建多个首月社保表;锁在事务完成后释放 + return monthRecordLockRedisDAO.lock(() -> { + // 1. 校验尚未生成社保表 + if (monthRecordMapper.selectLastForUpdate() != null) { + throw exception(INSURANCE_FIRST_MONTH_RECORD_EXISTS); + } - // 2.1 创建首月社保表 - HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(reqVO); - // 2.2 生成员工月记录并更新汇总 - computeMonthRecord(monthRecord); + // 2.1 创建首月社保表 + HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(reqVO); + // 2.2 生成员工月记录并更新汇总 + computeMonthRecord(monthRecord); - // 3. 记录操作日志上下文 - LogRecordContext.putVariable("monthRecord", monthRecord); - return monthRecord.getId(); + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("monthRecord", monthRecord); + return monthRecord.getId(); + }); } @Override @@ -75,27 +82,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_CREATE_SUB_TYPE, bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_CREATE_SUCCESS) public Long createNextMonthRecord() { - // 1. 校验最近月度社保表 - HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLastForUpdate(); - if (lastMonthRecord == null) { - throw exception(INSURANCE_MONTH_RECORD_NOT_EXISTS); - } - - // 2. 归档最近月度社保表 - if (Objects.equals(lastMonthRecord.getStatus(), HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())) { - archiveMonthRecord(lastMonthRecord); - } + // 加锁:与首月创建、删除互斥;锁在事务完成后释放 + return monthRecordLockRedisDAO.lock(() -> { + // 1. 校验最近月度社保表 + HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLastForUpdate(); + if (lastMonthRecord == null) { + throw exception(INSURANCE_MONTH_RECORD_NOT_EXISTS); + } - // 3.1 创建次月社保表 - YearMonth nextMonth = YearMonth.of(lastMonthRecord.getYear(), lastMonthRecord.getMonth()).plusMonths(1); - HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(new HrmInsuranceMonthRecordCreateReqVO() - .setYear(nextMonth.getYear()).setMonth(nextMonth.getMonthValue())); - // 3.2 生成员工月记录并更新汇总 - computeMonthRecord(monthRecord); + // 2. 归档最近月度社保表 + if (Objects.equals(lastMonthRecord.getStatus(), HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())) { + archiveMonthRecord(lastMonthRecord); + } - // 4. 记录操作日志上下文 - LogRecordContext.putVariable("monthRecord", monthRecord); - return monthRecord.getId(); + // 3.1 创建次月社保表 + YearMonth nextMonth = YearMonth.of(lastMonthRecord.getYear(), lastMonthRecord.getMonth()).plusMonths(1); + HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(new HrmInsuranceMonthRecordCreateReqVO() + .setYear(nextMonth.getYear()).setMonth(nextMonth.getMonthValue())); + // 3.2 生成员工月记录并更新汇总 + computeMonthRecord(monthRecord); + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("monthRecord", monthRecord); + return monthRecord.getId(); + }); } @Override @@ -103,27 +113,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_DELETE_SUB_TYPE, bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_DELETE_SUCCESS) public void deleteMonthRecord(Long id) { - // 1. 校验仅删除最新社保表,并至少保留一张社保表 - HrmInsuranceMonthRecordDO monthRecord = validateMonthRecordEditableForUpdate(id); - HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLast(); - if (lastMonthRecord == null || ObjectUtil.notEqual(lastMonthRecord.getId(), monthRecord.getId())) { - throw exception(INSURANCE_MONTH_RECORD_NOT_LATEST); - } - if (monthRecordMapper.selectCount() <= 1) { - throw exception(INSURANCE_MONTH_RECORD_CANNOT_DELETE_ONLY); - } - - // 2.1 删除当前月员工记录和月表 - monthEmployeeRecordService.deleteMonthEmployeeRecordListByMonthRecordId(id); - monthRecordMapper.deleteById(id); - // 2.2 将上一月调整为可编辑状态 - HrmInsuranceMonthRecordDO previousMonthRecord = monthRecordMapper.selectLast(); - monthRecordMapper.updateById(new HrmInsuranceMonthRecordDO() - .setId(previousMonthRecord.getId()) - .setStatus(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())); + // 加锁:与首月创建、次月创建互斥;锁在事务完成后释放 + monthRecordLockRedisDAO.lock(() -> { + // 1. 校验仅删除最新社保表,并至少保留一张社保表 + HrmInsuranceMonthRecordDO monthRecord = validateMonthRecordEditableForUpdate(id); + HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLast(); + if (lastMonthRecord == null || ObjectUtil.notEqual(lastMonthRecord.getId(), monthRecord.getId())) { + throw exception(INSURANCE_MONTH_RECORD_NOT_LATEST); + } + if (monthRecordMapper.selectCount() <= 1) { + throw exception(INSURANCE_MONTH_RECORD_CANNOT_DELETE_ONLY); + } - // 3. 记录操作日志上下文 - LogRecordContext.putVariable("monthRecord", monthRecord); + // 2.1 删除当前月员工记录和月表 + monthEmployeeRecordService.deleteMonthEmployeeRecordListByMonthRecordId(id); + monthRecordMapper.deleteById(id); + // 2.2 将上一月调整为可编辑状态 + HrmInsuranceMonthRecordDO previousMonthRecord = monthRecordMapper.selectLast(); + monthRecordMapper.updateById(new HrmInsuranceMonthRecordDO() + .setId(previousMonthRecord.getId()) + .setStatus(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("monthRecord", monthRecord); + }); } @Override diff --git a/yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImplTest.java b/yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImplTest.java index 2cb1d21..134e463 100644 --- a/yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImplTest.java +++ b/yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/insurance/monthrecord/HrmInsuranceMonthRecordServiceImplTest.java @@ -5,16 +5,22 @@ import cn.iocoder.yudao.module.hrm.controller.admin.insurance.vo.monthrecord.Hrm import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthEmployeeRecordDO; import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthRecordDO; import cn.iocoder.yudao.module.hrm.dal.mysql.insurance.monthrecord.HrmInsuranceMonthRecordMapper; +import cn.iocoder.yudao.module.hrm.dal.redis.insurance.HrmInsuranceMonthRecordLockRedisDAO; import cn.iocoder.yudao.module.hrm.enums.insurance.employee.HrmInsuranceEmployeeStatusEnum; import cn.iocoder.yudao.module.hrm.enums.insurance.monthrecord.HrmInsuranceMonthStatusEnum; import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.math.BigDecimal; import java.util.Collections; +import java.util.function.Supplier; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.LOCKED; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.INSURANCE_FIRST_MONTH_RECORD_EXISTS; @@ -25,6 +31,10 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -44,6 +54,19 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest { @MockitoBean private HrmInsuranceMonthEmployeeRecordService monthEmployeeRecordService; + @MockitoBean + private HrmInsuranceMonthRecordLockRedisDAO monthRecordLockRedisDAO; + + @BeforeEach + public void setUp() { + // mock 方法:获得锁后执行逻辑 + when(monthRecordLockRedisDAO.lock(any(Supplier.class))) + .thenAnswer(invocation -> invocation.>getArgument(0).get()); + doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(monthRecordLockRedisDAO).lock(any(Runnable.class)); + } @Test public void testCreateFirstMonthRecord_success() { @@ -63,7 +86,60 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest { assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(), monthRecord.getStatus()); assertEquals(0, monthRecord.getInsuredEmployeeCount()); assertEquals(0, BigDecimal.ZERO.compareTo(monthRecord.getPersonalInsuranceAmount())); - verify(monthEmployeeRecordService).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class)); + InOrder inOrder = inOrder(monthRecordLockRedisDAO, monthEmployeeRecordService); + inOrder.verify(monthRecordLockRedisDAO).lock(any(Supplier.class)); + inOrder.verify(monthEmployeeRecordService).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class)); + } + + @Test + public void testCreateFirstMonthRecord_locked() { + // mock 方法:其它请求持有变更锁 + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Supplier.class)); + // 准备参数 + HrmInsuranceMonthRecordCreateReqVO reqVO = + new HrmInsuranceMonthRecordCreateReqVO().setYear(2026).setMonth(7); + + // 调用,并断言异常 + assertServiceException(() -> monthRecordService.createFirstMonthRecord(reqVO), LOCKED); + // 断言:未创建社保表 + assertEquals(0L, monthRecordMapper.selectCount()); + verify(monthEmployeeRecordService, never()).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class)); + } + + @Test + public void testCreateNextMonthRecord_locked() { + // mock 数据 + HrmInsuranceMonthRecordDO currentMonthRecord = randomMonthRecord(2026, 6, + HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus()); + monthRecordMapper.insert(currentMonthRecord); + // mock 方法:其它请求持有变更锁 + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Supplier.class)); + + // 调用,并断言异常 + assertServiceException(() -> monthRecordService.createNextMonthRecord(), LOCKED); + // 断言:未归档、未创建次月 + assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(), + monthRecordMapper.selectById(currentMonthRecord.getId()).getStatus()); + assertEquals(1L, monthRecordMapper.selectCount()); + } + + @Test + public void testDeleteMonthRecord_locked() { + // mock 数据 + HrmInsuranceMonthRecordDO previousMonthRecord = randomMonthRecord(2026, 6, + HrmInsuranceMonthStatusEnum.ARCHIVED.getStatus()); + monthRecordMapper.insert(previousMonthRecord); + HrmInsuranceMonthRecordDO currentMonthRecord = randomMonthRecord(2026, 7, + HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus()); + monthRecordMapper.insert(currentMonthRecord); + // mock 方法:其它请求持有变更锁 + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Runnable.class)); + + // 调用,并断言异常 + assertServiceException(() -> monthRecordService.deleteMonthRecord(currentMonthRecord.getId()), LOCKED); + // 断言:未删除 + assertNotNull(monthRecordMapper.selectById(currentMonthRecord.getId())); + verify(monthEmployeeRecordService, never()).deleteMonthEmployeeRecordListByMonthRecordId(anyLong()); } @Test @@ -123,6 +199,7 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest { HrmInsuranceMonthRecordDO nextMonthRecord = monthRecordMapper.selectById(nextMonthRecordId); assertEquals(2027, nextMonthRecord.getYear()); assertEquals(1, nextMonthRecord.getMonth()); + verify(monthRecordLockRedisDAO).lock(any(Supplier.class)); } @Test @@ -142,7 +219,9 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest { assertNull(monthRecordMapper.selectById(currentMonthRecord.getId())); assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(), monthRecordMapper.selectById(previousMonthRecord.getId()).getStatus()); - verify(monthEmployeeRecordService) + InOrder inOrder = inOrder(monthRecordLockRedisDAO, monthEmployeeRecordService); + inOrder.verify(monthRecordLockRedisDAO).lock(any(Runnable.class)); + inOrder.verify(monthEmployeeRecordService) .deleteMonthEmployeeRecordListByMonthRecordId(currentMonthRecord.getId()); } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java index 7f56a1a..f061a9b 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java @@ -16,9 +16,7 @@ import org.apache.ibatis.annotations.Mapper; public interface ImGroupMapper extends BaseMapperX { default ImGroupDO selectByIdForUpdate(Long id) { - return selectOne(new LambdaQueryWrapperX() - .eq(ImGroupDO::getId, id) - .last("FOR UPDATE")); + return selectOneForUpdate(ImGroupDO::getId, id); } default PageResult selectPage(ImGroupManagerPageReqVO reqVO) { diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java index eccb83a..725c11b 100644 --- a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java @@ -28,8 +28,8 @@ public interface OaFileNodeMapper extends BaseMapperX { } default List selectListByCreatorAndTypeForUpdate(String creator, Integer type) { - return selectList(new LambdaQueryWrapperX().eq(OaFileNodeDO::getCreator, creator) - .eq(OaFileNodeDO::getType, type).orderByAsc(OaFileNodeDO::getId).last("FOR UPDATE")); + return selectListForUpdate(new LambdaQueryWrapperX().eq(OaFileNodeDO::getCreator, creator) + .eq(OaFileNodeDO::getType, type).orderByAsc(OaFileNodeDO::getId)); } default List selectListByCreatorAndStatus(String creator, Integer status) { diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java index 8900d5e..74a244e 100644 --- a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java @@ -32,8 +32,8 @@ public interface OaMailAccountMapper extends BaseMapperX { } default List selectListByCreatorForUpdate(String creator) { - return selectList(new LambdaQueryWrapperX().eq(OaMailAccountDO::getCreator, creator) - .orderByAsc(OaMailAccountDO::getId).last("FOR UPDATE")); + return selectListForUpdate(new LambdaQueryWrapperX().eq(OaMailAccountDO::getCreator, creator) + .orderByAsc(OaMailAccountDO::getId)); } default Long selectCountByProviderId(Long providerId) { diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java index 0c60e2f..e39b3a4 100644 --- a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java +++ b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java @@ -250,11 +250,11 @@ public class PayWalletServiceTest extends BaseDbAndRedisUnitTest { mockRefundAndWalletTransaction(); List declaredIsolationLevels = new ArrayList<>(); List connectionIsolationLevels = new ArrayList<>(); - when(refundService.getRefundForUpdate(any())).thenAnswer(invocation -> { + doAnswer(invocation -> { declaredIsolationLevels.add(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel()); connectionIsolationLevels.add(DataSourceUtils.getConnection(dataSource).getTransactionIsolation()); return refundMapper.selectOneForUpdate(PayRefundDO::getId, invocation.getArgument(0)); - }); + }).when(refundService).getRefundForUpdate(any()); // 调用:不存在调用方事务,新建事务退款到钱包 PayWalletTransactionDO transaction = walletService.orderRefund(refund.getNo(), 30, "测试退款"); diff --git a/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java b/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java index 68d422e..ee6abd9 100644 --- a/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java +++ b/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java @@ -334,7 +334,7 @@ public interface PmsWorkItemMapper extends BaseMapperX { queryWrapper.and(wrapper -> wrapper.notIn(PmsWorkItemDO::getIterationId, pageReqVO.getExcludedIterationIds()).or().isNull(PmsWorkItemDO::getIterationId)); } - // label_ids 为 JSON 数组列,匹配任一标签;PostgreSQL 使用 jsonb 包含,其它数据库保持 JSON_CONTAINS + // label_ids 为 JSON 数组列,匹配任一标签;数据库差异由公共方言工具处理 if (CollUtil.isNotEmpty(pageReqVO.getLabelIds())) { String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true); queryWrapper.and(labelWrapper -> { diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java index bc9b074..5822bb7 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java @@ -66,9 +66,7 @@ public interface WmsInventoryMapper extends BaseMapperX { } default WmsInventoryDO selectByIdForUpdate(Long id) { - return selectOne(new LambdaQueryWrapperX() - .eq(WmsInventoryDO::getId, id) - .last("FOR UPDATE")); + return selectOneForUpdate(WmsInventoryDO::getId, id); } /** @@ -96,12 +94,7 @@ public interface WmsInventoryMapper extends BaseMapperX { } default List selectListByIdsForUpdate(Collection ids) { - if (CollUtil.isEmpty(ids)) { - return Collections.emptyList(); - } - return selectList(new LambdaQueryWrapperX() - .in(WmsInventoryDO::getId, ids) - .last("FOR UPDATE")); + return selectListForUpdate(WmsInventoryDO::getId, ids); } static void appendDimensionOrder(MPJLambdaWrapperX query, String type) { -- libgit2 0.21.4