Commit 8e4d1e709706c502a46cec8c801aa0eaa53bf0d6

Authored by YunaiV
1 parent 2f655a81

fix: 统一事务锁定读并补齐多数据库统计与 JSON 筛选

Showing 19 changed files with 1738 additions and 108 deletions
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;
4 4 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
5 5 import cn.iocoder.yudao.framework.mybatis.core.handler.DefaultDBFieldHandler;
6 6 import cn.iocoder.yudao.framework.mybatis.core.injector.YudaoSqlInjector;
  7 +import cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor;
7 8 import cn.iocoder.yudao.framework.mybatis.core.util.CaseInsensitiveResultMap;
8 9 import com.baomidou.mybatisplus.annotation.DbType;
9 10 import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer;
... ... @@ -57,6 +58,8 @@ public class YudaoMybatisAutoConfiguration {
57 58 public MybatisPlusInterceptor mybatisPlusInterceptor() {
58 59 MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
59 60 mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件
  61 + // 锁定读插件:需要位于末尾,在租户、数据权限、分页等插件改写 SQL 之后,改写 SQL Server 的行锁
  62 + mybatisPlusInterceptor.addInnerInterceptor(new ForUpdateInnerInterceptor());
60 63 // ↓↓↓ 按需开启,可能会影响到 updateBatch 的地方:例如说文件配置管理 ↓↓↓
61 64 // mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 拦截没有指定条件的 update 和 delete 语句
62 65 return mybatisPlusInterceptor;
... ...
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.mybatis.core.injector;
  2 +
  3 +import com.baomidou.mybatisplus.core.injector.AbstractMethod;
  4 +import com.baomidou.mybatisplus.core.metadata.TableInfo;
  5 +import com.baomidou.mybatisplus.core.toolkit.sql.SqlScriptUtils;
  6 +import lombok.AllArgsConstructor;
  7 +import lombok.Getter;
  8 +import org.apache.ibatis.executor.keygen.KeyGenerator;
  9 +import org.apache.ibatis.mapping.BoundSql;
  10 +import org.apache.ibatis.mapping.MappedStatement;
  11 +import org.apache.ibatis.mapping.SqlCommandType;
  12 +import org.apache.ibatis.mapping.SqlSource;
  13 +import org.apache.ibatis.mapping.StatementType;
  14 +
  15 +/**
  16 + * 锁定读使用的内置查询,见 BaseMapperX 的 selectOneForUpdate、selectListForUpdate、selectFirstOneForUpdate 方法
  17 + *
  18 + * 按 {@link Kind} 分为三类,均不在 Mapper 接口上声明,由 BaseMapperX 通过 SqlSession 调用:
  19 + * 1. {@link Kind#LOCK}:按原条件(可选再加主键)加锁查询,末尾为 FOR UPDATE
  20 + * 2. {@link Kind#LOCK_FIRST}:按原条件、原排序加锁查询首行,末尾为 LIMIT 1 FOR UPDATE
  21 + * 3. {@link Kind#FIRST_IDS}:按原条件、原排序(未设置时按主键)查询首行主键,行数限制由分页插件生成,不加锁;
  22 + * 仅用于 Oracle 的首行锁定(Oracle 的 FOR UPDATE 不能与 ROWNUM、FETCH FIRST 一起使用)
  23 + *
  24 + * SQL Server 不支持 FOR UPDATE、LIMIT,由 {@link cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor}
  25 + * 在所有解析 SQL 的插件之后改写;该拦截器只处理 SqlSource 为 {@link LockSqlSource} 的内置查询
  26 + *
  27 + * 1. 条件:Wrapper 的实体条件、普通条件(整体加括号,OR 条件也均受主键限制)、逻辑删除,与 MyBatis Plus 的 selectList 一致;
  28 + * 不使用 Wrapper 的 last、groupBy、having,由调用方校验
  29 + * 2. 列与结果:加锁查询沿用 selectList 的查询列(含 Wrapper 的 select)、resultMap;主键查询的结果为主键类型
  30 + * 3. 缓存:每次执行前清空一级缓存、且不使用二级缓存,避免锁定前后读到缓存的结果
  31 + *
  32 + * @author 芋道源码
  33 + */
  34 +public class SelectForUpdateMethod extends AbstractMethod {
  35 +
  36 + /**
  37 + * 加锁查询的主键参数名
  38 + */
  39 + public static final String PARAM_ID = "id";
  40 +
  41 + private final Kind kind;
  42 +
  43 + public SelectForUpdateMethod(Kind kind) {
  44 + super(kind.getMethod());
  45 + this.kind = kind;
  46 + }
  47 +
  48 + @Override
  49 + public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
  50 + // 没有主键的表,无法查询首行主键
  51 + if (kind == Kind.FIRST_IDS && !tableInfo.havePK()) {
  52 + return null;
  53 + }
  54 + String columns = kind == Kind.FIRST_IDS ? tableInfo.getKeyColumn() : sqlSelectColumns(tableInfo, true);
  55 + String sql = "<script>" + sqlFirst() + " SELECT " + columns + " FROM " + tableInfo.getTableName()
  56 + + NEWLINE + sqlWhere(tableInfo) + NEWLINE + sqlTail(tableInfo) + sqlComment() + NEWLINE + "</script>";
  57 + SqlSource sqlSource = new LockSqlSource(kind, super.createSqlSource(configuration, sql, modelClass));
  58 + if (kind == Kind.FIRST_IDS) {
  59 + return addSelectMappedStatementForOther(mapperClass, methodName, sqlSource, tableInfo.getKeyType());
  60 + }
  61 + return addSelectMappedStatementForTable(mapperClass, methodName, sqlSource, tableInfo);
  62 + }
  63 +
  64 + /**
  65 + * 条件:实体条件 + 普通条件(加括号) + [主键] + 逻辑删除
  66 + *
  67 + * @param tableInfo 表信息
  68 + * @return WHERE 脚本
  69 + */
  70 + private String sqlWhere(TableInfo tableInfo) {
  71 + String entityWhere = SqlScriptUtils.convertIf(tableInfo.getAllSqlWhere(true, true, true, WRAPPER_ENTITY_DOT),
  72 + WRAPPER_ENTITY + " != null", true);
  73 + String normalWhere = SqlScriptUtils.convertIf(" AND (${" + WRAPPER_DOT + "expression.normal.sqlSegment})",
  74 + WRAPPER_DOT + "nonEmptyOfNormal", true);
  75 + String idWhere = kind == Kind.LOCK && tableInfo.havePK() ? NEWLINE + SqlScriptUtils.convertIf(
  76 + " AND " + tableInfo.getKeyColumn() + " = " + SqlScriptUtils.safeParam(PARAM_ID), PARAM_ID + " != null", true) : "";
  77 + return SqlScriptUtils.convertWhere(entityWhere + NEWLINE + normalWhere + idWhere
  78 + + NEWLINE + tableInfo.getLogicDeleteSql(true, true));
  79 + }
  80 +
  81 + /**
  82 + * 排序与锁定:
  83 + * 1. 加锁查询:Wrapper 的排序(未设置时不排序,避免改变数据库的加锁范围) + FOR UPDATE;首行再加 LIMIT 1
  84 + * 2. 主键查询:Wrapper 的排序;未设置时按主键排序,保证锁定前后读取的首行一致
  85 + *
  86 + * @param tableInfo 表信息
  87 + * @return ORDER BY 等脚本
  88 + */
  89 + private String sqlTail(TableInfo tableInfo) {
  90 + String orderBy = " ${" + WRAPPER_DOT + "expression.orderBy.sqlSegment}";
  91 + if (kind == Kind.FIRST_IDS) {
  92 + return SqlScriptUtils.convertChoose(WRAPPER_DOT + "expression.orderBy.isEmpty()",
  93 + " ORDER BY " + tableInfo.getKeyColumn(), orderBy);
  94 + }
  95 + return SqlScriptUtils.convertIf(orderBy, "!" + WRAPPER_DOT + "expression.orderBy.isEmpty()", true)
  96 + + NEWLINE + (kind == Kind.LOCK_FIRST ? "LIMIT 1 FOR UPDATE" : "FOR UPDATE");
  97 + }
  98 +
  99 + @Override
  100 + protected MappedStatement addMappedStatement(Class<?> mapperClass, String id, SqlSource sqlSource,
  101 + SqlCommandType sqlCommandType, Class<?> parameterType,
  102 + String resultMap, Class<?> resultType, KeyGenerator keyGenerator,
  103 + String keyProperty, String keyColumn) {
  104 + String statementName = mapperClass.getName() + DOT + id;
  105 + if (configuration.hasStatement(statementName, false)) {
  106 + 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);
  107 + return null;
  108 + }
  109 + // 与父类相比:flushCache 为 true、useCache 为 false
  110 + return builderAssistant.addMappedStatement(id, sqlSource, StatementType.PREPARED, sqlCommandType,
  111 + null, null, null, parameterType, resultMap, resultType,
  112 + null, true, false, false, keyGenerator, keyProperty, keyColumn,
  113 + configuration.getDatabaseId(), languageDriver, null);
  114 + }
  115 +
  116 + /**
  117 + * 内置查询的类型
  118 + */
  119 + @Getter
  120 + @AllArgsConstructor
  121 + public enum Kind {
  122 +
  123 + /**
  124 + * 加锁查询:按原条件、可选的主键加锁,返回实体
  125 + */
  126 + LOCK("_forUpdateLock"),
  127 + /**
  128 + * 首行加锁查询:按原条件、原排序加锁第 1 条,返回实体
  129 + */
  130 + LOCK_FIRST("_forUpdateLockFirst"),
  131 + /**
  132 + * 首行主键查询:按原条件、原排序查询第 1 条的主键,不加锁,返回主键
  133 + */
  134 + FIRST_IDS("_forUpdateFirstIds");
  135 +
  136 + /**
  137 + * 方法名,即 MappedStatement 编号中 Mapper 之后的部分
  138 + */
  139 + private final String method;
  140 +
  141 + }
  142 +
  143 + /**
  144 + * 内置查询的 SqlSource:标记 MappedStatement 为锁定读的内置查询,SQL 由原 SqlSource 生成
  145 + *
  146 + * MyBatis Plus Join 等插件复制 MappedStatement 时保留同一个 SqlSource,标记仍然有效
  147 + */
  148 + @Getter
  149 + @AllArgsConstructor
  150 + public static class LockSqlSource implements SqlSource {
  151 +
  152 + /**
  153 + * 内置查询的类型
  154 + */
  155 + private final Kind kind;
  156 + /**
  157 + * 原 SqlSource
  158 + */
  159 + private final SqlSource delegate;
  160 +
  161 + @Override
  162 + public BoundSql getBoundSql(Object parameterObject) {
  163 + return delegate.getBoundSql(parameterObject);
  164 + }
  165 +
  166 + }
  167 +
  168 +}
... ...
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;
15 15 * 自定义 SQL 注入器
16 16 *
17 17 * 在 MyBatis Plus Join 的 {@link MPJSqlInjector} 基础上,将 selectMaps 的结果行替换为 {@link CaseInsensitiveResultMap},
18   - * 解决 PostgreSQL、Oracle 等数据库列标签大小写折叠,导致按固定 key 取不到值的问题
  18 + * 解决 PostgreSQL、Oracle 等数据库列标签大小写折叠,导致按固定 key 取不到值的问题;
  19 + * 并增加锁定读使用的内置查询 {@link SelectForUpdateMethod}
19 20 *
20 21 * @author 芋道源码
21 22 */
... ... @@ -25,6 +26,9 @@ public class YudaoSqlInjector extends MPJSqlInjector {
25 26 public List<AbstractMethod> getMethodList(Configuration configuration, Class<?> mapperClass, TableInfo tableInfo) {
26 27 List<AbstractMethod> methods = super.getMethodList(configuration, mapperClass, tableInfo);
27 28 methods.replaceAll(method -> method instanceof SelectMaps ? new CaseInsensitiveSelectMaps() : method);
  29 + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) {
  30 + methods.add(new SelectForUpdateMethod(kind));
  31 + }
28 32 return methods;
29 33 }
30 34  
... ...
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptor.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.mybatis.core.interceptor;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
  5 +import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum;
  6 +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod;
  7 +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
  8 +import com.baomidou.mybatisplus.annotation.DbType;
  9 +import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
  10 +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
  11 +import net.sf.jsqlparser.JSQLParserException;
  12 +import net.sf.jsqlparser.expression.Expression;
  13 +import net.sf.jsqlparser.expression.LongValue;
  14 +import net.sf.jsqlparser.parser.CCJSqlParserConstants;
  15 +import net.sf.jsqlparser.parser.SimpleNode;
  16 +import net.sf.jsqlparser.parser.Token;
  17 +import net.sf.jsqlparser.schema.Table;
  18 +import net.sf.jsqlparser.statement.Statement;
  19 +import net.sf.jsqlparser.statement.select.ForMode;
  20 +import net.sf.jsqlparser.statement.select.Limit;
  21 +import net.sf.jsqlparser.statement.select.PlainSelect;
  22 +import org.apache.ibatis.executor.statement.StatementHandler;
  23 +import org.apache.ibatis.mapping.SqlSource;
  24 +
  25 +import java.sql.Connection;
  26 +import java.sql.SQLException;
  27 +import java.util.ArrayList;
  28 +import java.util.List;
  29 +
  30 +// TODO DONE @AI:能不能不这么搞,而是 mapperx 根据数据库做封装噢?
  31 +// 选择执行路径已在 BaseMapperX 按数据库封装(Oracle 首行锁定走多次查询,其它单条语句);
  32 +// SQL Server 的 WITH (UPDLOCK, ROWLOCK) 无法被 JSqlParser 解析,若在 Mapper 中直接生成,租户、数据权限等插件会解析失败,
  33 +// 因此只能在所有解析 SQL 的插件之后改写。本拦截器只处理 SelectForUpdateMethod 的内置查询,不再扫描其它 SQL。
  34 +/**
  35 + * 锁定读的拦截器,需要位于拦截器链的末尾:在 prepare 阶段处理,此时租户、数据权限、分页等插件已完成 SQL 改写
  36 + *
  37 + * 只处理 SqlSource 为 {@link SelectForUpdateMethod.LockSqlSource} 的内置查询,其它 SQL 不做任何处理:
  38 + * 1. 在实际执行的连接上校验事务:要求非自动提交;Oracle 首行锁定的主键查询({@link SelectForUpdateMethod.Kind#FIRST_IDS})
  39 + * 还要求隔离级别为 READ COMMITTED,其它隔离级别下,锁定后的重新读取可能读不到其它事务已提交的记录
  40 + * 2. SQL Server 不支持 FOR UPDATE、LIMIT:将末尾的 FOR UPDATE 改写为基表的 WITH (UPDLOCK, ROWLOCK),首行的 LIMIT 1 改写为 TOP (1);
  41 + * 只按 SQL 的 token 修改,字符串中的 for update 不会被改写;仅支持单表,不支持时直接报错,不会退化为不加锁的 SQL
  42 + * 3. Oracle 不支持 LIMIT 1 FOR UPDATE:首行加锁查询({@link SelectForUpdateMethod.Kind#LOCK_FIRST})直接报错,
  43 + * 正常情况下 BaseMapperX 已按数据库类型改为多次查询
  44 + *
  45 + * @author 芋道源码
  46 + */
  47 +public class ForUpdateInnerInterceptor implements InnerInterceptor {
  48 +
  49 + /**
  50 + * SQL Server 的行锁提示:UPDLOCK 持有更新锁到事务结束,与 FOR UPDATE 一样阻塞其它事务的加锁读与修改;ROWLOCK 尽量使用行锁
  51 + */
  52 + static final String SQL_SERVER_LOCK_HINTS = " WITH (UPDLOCK, ROWLOCK)";
  53 + /**
  54 + * SQL Server 的首行限制,替代 LIMIT 1
  55 + */
  56 + static final String SQL_SERVER_TOP_ONE = " TOP (1)";
  57 +
  58 + @Override
  59 + public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
  60 + PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
  61 + SqlSource sqlSource = mpSh.mappedStatement().getSqlSource();
  62 + if (!(sqlSource instanceof SelectForUpdateMethod.LockSqlSource)) {
  63 + return;
  64 + }
  65 + SelectForUpdateMethod.Kind kind = ((SelectForUpdateMethod.LockSqlSource) sqlSource).getKind();
  66 + // 1. 校验实际连接的事务
  67 + checkTransaction(connection, kind);
  68 + // 2. 按实际连接的数据库类型处理:首行主键查询不加锁,无需改写
  69 + if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) {
  70 + return;
  71 + }
  72 + DbType dbType = getDbType(connection);
  73 + if (JdbcUtils.isSQLServer(dbType)) {
  74 + PluginUtils.MPBoundSql mpBoundSql = mpSh.mPBoundSql();
  75 + mpBoundSql.sql(rewriteForSqlServer(mpBoundSql.sql(), kind == SelectForUpdateMethod.Kind.LOCK_FIRST));
  76 + return;
  77 + }
  78 + if (kind == SelectForUpdateMethod.Kind.LOCK_FIRST && ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) {
  79 + throw new IllegalStateException("Oracle 不支持 LIMIT 1 FOR UPDATE,首行锁定需要使用多次查询");
  80 + }
  81 + }
  82 +
  83 + /**
  84 + * 校验连接在事务中;首行主键查询还要求隔离级别为 READ COMMITTED
  85 + *
  86 + * @param connection 实际执行的连接
  87 + * @param kind 内置查询的类型
  88 + */
  89 + static void checkTransaction(Connection connection, SelectForUpdateMethod.Kind kind) {
  90 + try {
  91 + if (connection.getAutoCommit()) {
  92 + throw new IllegalStateException("锁定读需要在事务中执行,当前连接为自动提交");
  93 + }
  94 + if (kind != SelectForUpdateMethod.Kind.FIRST_IDS) {
  95 + return;
  96 + }
  97 + int isolation = connection.getTransactionIsolation();
  98 + if (isolation != Connection.TRANSACTION_READ_COMMITTED) {
  99 + throw new IllegalStateException("首行锁定要求事务隔离级别为 READ COMMITTED,当前为:" + isolation);
  100 + }
  101 + } catch (SQLException e) {
  102 + throw new IllegalStateException("获取连接的事务状态失败", e);
  103 + }
  104 + }
  105 +
  106 + private static DbType getDbType(Connection connection) {
  107 + try {
  108 + return DbTypeEnum.find(connection.getMetaData().getDatabaseProductName());
  109 + } catch (SQLException e) {
  110 + throw new IllegalStateException("获取数据库类型失败", e);
  111 + }
  112 + }
  113 +
  114 + /**
  115 + * 将单表 SELECT 末尾的 FOR UPDATE 改写为表的 WITH (UPDLOCK, ROWLOCK);首行时将 LIMIT 1 改写为 TOP (1)
  116 + *
  117 + * @param sql SQL
  118 + * @param first 是否为首行加锁查询
  119 + * @return 改写后的 SQL
  120 + */
  121 + static String rewriteForSqlServer(String sql, boolean first) {
  122 + Statement statement;
  123 + try {
  124 + statement = SqlTokenUtils.parse(sql);
  125 + } catch (JSQLParserException e) {
  126 + throw new IllegalStateException("解析行锁 SQL 失败:" + sql, e);
  127 + }
  128 + // 校验:仅支持单表、无修饰的 FOR UPDATE
  129 + if (!(statement instanceof PlainSelect)) {
  130 + throw unsupported("仅支持单个 SELECT", sql);
  131 + }
  132 + PlainSelect plainSelect = (PlainSelect) statement;
  133 + if (plainSelect.getForMode() != ForMode.UPDATE || plainSelect.getForUpdateTable() != null
  134 + || plainSelect.getWait() != null || plainSelect.isNoWait() || plainSelect.isSkipLocked()) {
  135 + throw unsupported("仅支持 FOR UPDATE,不支持 OF、WAIT、NOWAIT、SKIP LOCKED 等", sql);
  136 + }
  137 + if (CollUtil.isNotEmpty(plainSelect.getWithItemsList())) {
  138 + throw unsupported("不支持 WITH", sql);
  139 + }
  140 + if (!(plainSelect.getFromItem() instanceof Table) || CollUtil.isNotEmpty(plainSelect.getJoins())) {
  141 + throw unsupported("仅支持单表", sql);
  142 + }
  143 + Table table = (Table) plainSelect.getFromItem();
  144 + if (table.getSqlServerHints() != null || table.getIndexHint() != null || table.getSampleClause() != null
  145 + || table.getPivot() != null || table.getUnPivot() != null) {
  146 + throw unsupported("表已有提示等修饰", sql);
  147 + }
  148 + if (plainSelect.getTop() != null || plainSelect.getOffset() != null || plainSelect.getFetch() != null) {
  149 + throw unsupported("不支持 TOP、OFFSET、FETCH", sql);
  150 + }
  151 + Limit limit = plainSelect.getLimit();
  152 + if (first ? !isLimitOne(limit) || plainSelect.getDistinct() != null : limit != null) {
  153 + throw unsupported(first ? "首行仅支持 LIMIT 1,且不支持 DISTINCT" : "不支持 LIMIT", sql);
  154 + }
  155 + SimpleNode tableNode = table.getASTNode();
  156 + if (tableNode == null) {
  157 + throw unsupported("无法定位表", sql);
  158 + }
  159 +
  160 + // 末尾的 FOR UPDATE:表之后的 token 中,最后两个(不含分号)
  161 + List<Token> tokens = new ArrayList<>();
  162 + for (Token token = tableNode.jjtGetLastToken().next; token != null && token.kind != CCJSqlParserConstants.EOF;
  163 + token = token.next) {
  164 + tokens.add(token);
  165 + }
  166 + while (!tokens.isEmpty() && tokens.get(tokens.size() - 1).kind == CCJSqlParserConstants.ST_SEMICOLON) {
  167 + tokens.remove(tokens.size() - 1);
  168 + }
  169 + if (tokens.size() < 2 || tokens.get(tokens.size() - 2).kind != CCJSqlParserConstants.K_FOR
  170 + || tokens.get(tokens.size() - 1).kind != CCJSqlParserConstants.K_UPDATE) {
  171 + throw unsupported("FOR UPDATE 不在末尾", sql);
  172 + }
  173 +
  174 + // 删除 FOR UPDATE,在表(含别名)之后增加提示
  175 + int[] lineStarts = SqlTokenUtils.lineStarts(sql);
  176 + Token forToken = tokens.get(tokens.size() - 2);
  177 + Token updateToken = tokens.get(tokens.size() - 1);
  178 + int tableEnd = SqlTokenUtils.end(sql, lineStarts, tableNode.jjtGetLastToken());
  179 + List<SqlTokenUtils.Patch> patches = new ArrayList<>();
  180 + patches.add(new SqlTokenUtils.Patch(SqlTokenUtils.begin(sql, lineStarts, forToken),
  181 + SqlTokenUtils.end(sql, lineStarts, updateToken), ""));
  182 + patches.add(new SqlTokenUtils.Patch(tableEnd, tableEnd, SQL_SERVER_LOCK_HINTS));
  183 + // 首行:删除 LIMIT 1,在 SELECT 之后增加 TOP (1)
  184 + if (first) {
  185 + SimpleNode limitNode = limit.getASTNode();
  186 + SimpleNode selectNode = plainSelect.getASTNode();
  187 + if (limitNode == null || selectNode == null
  188 + || selectNode.jjtGetFirstToken().kind != CCJSqlParserConstants.K_SELECT) {
  189 + throw unsupported("无法定位 SELECT、LIMIT", sql);
  190 + }
  191 + int selectEnd = SqlTokenUtils.end(sql, lineStarts, selectNode.jjtGetFirstToken());
  192 + patches.add(new SqlTokenUtils.Patch(selectEnd, selectEnd, SQL_SERVER_TOP_ONE));
  193 + patches.add(new SqlTokenUtils.Patch(SqlTokenUtils.begin(sql, lineStarts, limitNode.jjtGetFirstToken()),
  194 + SqlTokenUtils.end(sql, lineStarts, limitNode.jjtGetLastToken()), ""));
  195 + }
  196 + return SqlTokenUtils.apply(sql, patches);
  197 + }
  198 +
  199 + private static boolean isLimitOne(Limit limit) {
  200 + if (limit == null || limit.getOffset() != null) {
  201 + return false;
  202 + }
  203 + Expression rowCount = limit.getRowCount();
  204 + return rowCount instanceof LongValue && ((LongValue) rowCount).getValue() == 1L;
  205 + }
  206 +
  207 + private static IllegalStateException unsupported(String reason, String sql) {
  208 + return new IllegalStateException("SQL Server 不支持的行锁查询(" + reason + "):" + sql);
  209 + }
  210 +
  211 +}
... ...
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/SqlTokenUtils.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.mybatis.core.interceptor;
  2 +
  3 +import com.baomidou.mybatisplus.extension.parser.JsqlParserGlobal;
  4 +import lombok.Value;
  5 +import lombok.experimental.Accessors;
  6 +import lombok.experimental.UtilityClass;
  7 +import net.sf.jsqlparser.JSQLParserException;
  8 +import net.sf.jsqlparser.parser.CCJSqlParserUtil;
  9 +import net.sf.jsqlparser.parser.Token;
  10 +import net.sf.jsqlparser.statement.Statement;
  11 +
  12 +import java.util.ArrayList;
  13 +import java.util.Comparator;
  14 +import java.util.List;
  15 +
  16 +/**
  17 + * 按 JSqlParser 的 token 位置修改原 SQL 的工具类
  18 + *
  19 + * 只替换识别出的 token,不使用 JSqlParser 重新生成整条 SQL,保留原有的字符串、注释、提示与格式
  20 + *
  21 + * @author 芋道源码
  22 + */
  23 +@UtilityClass
  24 +class SqlTokenUtils {
  25 +
  26 + /**
  27 + * 解析 SQL,保留 token 位置
  28 + *
  29 + * 注意:不使用 {@link JsqlParserGlobal#parse(String)},其缓存返回反序列化后的语句,不包含 token 位置,也不能被修改
  30 + *
  31 + * @param sql SQL
  32 + * @return 语句
  33 + * @throws JSQLParserException 解析失败
  34 + */
  35 + static Statement parse(String sql) throws JSQLParserException {
  36 + return CCJSqlParserUtil.parse(sql, JsqlParserGlobal.getExecutorService(), null);
  37 + }
  38 +
  39 + /**
  40 + * 计算每行的起始位置,与 JavaCC 的行号规则一致:\r\n、\r、\n 均为换行
  41 + *
  42 + * @param sql SQL
  43 + * @return 每行的起始位置,下标为行号减 1
  44 + */
  45 + static int[] lineStarts(String sql) {
  46 + List<Integer> starts = new ArrayList<>();
  47 + starts.add(0);
  48 + for (int i = 0; i < sql.length(); i++) {
  49 + char c = sql.charAt(i);
  50 + if (c == '\r' && i + 1 < sql.length() && sql.charAt(i + 1) == '\n') {
  51 + i++;
  52 + starts.add(i + 1);
  53 + } else if (c == '\r' || c == '\n') {
  54 + starts.add(i + 1);
  55 + }
  56 + }
  57 + return starts.stream().mapToInt(Integer::intValue).toArray();
  58 + }
  59 +
  60 + /**
  61 + * 获得 token 在 SQL 中的起始位置,并校验原文与 token 一致
  62 + *
  63 + * @param sql SQL
  64 + * @param lineStarts {@link #lineStarts(String)} 的结果
  65 + * @param token token
  66 + * @return 起始位置
  67 + */
  68 + static int begin(String sql, int[] lineStarts, Token token) {
  69 + if (token.beginLine < 1 || token.beginLine > lineStarts.length) {
  70 + throw new IllegalStateException("无法定位 SQL 中的 token:" + token.image);
  71 + }
  72 + // JavaCC 的列号从 1 开始,制表符按 1 列计算
  73 + int offset = lineStarts[token.beginLine - 1] + token.beginColumn - 1;
  74 + if (offset < 0 || !sql.startsWith(token.image, offset)) {
  75 + throw new IllegalStateException("无法定位 SQL 中的 token:" + token.image);
  76 + }
  77 + return offset;
  78 + }
  79 +
  80 + /**
  81 + * 获得 token 在 SQL 中的结束位置(不含)
  82 + *
  83 + * @param sql SQL
  84 + * @param lineStarts {@link #lineStarts(String)} 的结果
  85 + * @param token token
  86 + * @return 结束位置
  87 + */
  88 + static int end(String sql, int[] lineStarts, Token token) {
  89 + return begin(sql, lineStarts, token) + token.image.length();
  90 + }
  91 +
  92 + /**
  93 + * 按位置替换 SQL;相同位置的相同替换只执行一次,其它重叠的替换视为错误
  94 + *
  95 + * @param sql SQL
  96 + * @param patches 替换
  97 + * @return 替换后的 SQL
  98 + */
  99 + static String apply(String sql, List<Patch> patches) {
  100 + List<Patch> sorted = new ArrayList<>(patches);
  101 + sorted.sort(Comparator.comparingInt(Patch::start).thenComparingInt(Patch::end));
  102 + List<Patch> distinct = new ArrayList<>();
  103 + for (Patch patch : sorted) {
  104 + Patch last = distinct.isEmpty() ? null : distinct.get(distinct.size() - 1);
  105 + if (patch.equals(last)) {
  106 + continue;
  107 + }
  108 + if (last != null && patch.start() < last.end()) {
  109 + throw new IllegalStateException("SQL 修改位置重叠:" + last + "、" + patch);
  110 + }
  111 + distinct.add(patch);
  112 + }
  113 + // 从后向前替换,前面的位置不受影响
  114 + StringBuilder builder = new StringBuilder(sql);
  115 + for (int i = distinct.size() - 1; i >= 0; i--) {
  116 + Patch patch = distinct.get(i);
  117 + builder.replace(patch.start(), patch.end(), patch.replacement());
  118 + }
  119 + return builder.toString();
  120 + }
  121 +
  122 + /**
  123 + * 替换:将 [start, end) 替换为 replacement;start 等于 end 时为插入
  124 + */
  125 + @Value
  126 + @Accessors(fluent = true)
  127 + static class Patch {
  128 +
  129 + /**
  130 + * 起始位置
  131 + */
  132 + int start;
  133 + /**
  134 + * 结束位置(不含)
  135 + */
  136 + int end;
  137 + /**
  138 + * 替换的文本
  139 + */
  140 + String replacement;
  141 +
  142 + }
  143 +
  144 +}
... ...
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
1 1 package cn.iocoder.yudao.framework.mybatis.core.mapper;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
  5 +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
4 6 import cn.iocoder.yudao.framework.common.pojo.PageParam;
5 7 import cn.iocoder.yudao.framework.common.pojo.PageResult;
6 8 import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
7 9 import cn.iocoder.yudao.framework.common.pojo.SortingField;
  10 +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
8 11 import cn.iocoder.yudao.framework.common.util.object.PageUtils;
  12 +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod;
9 13 import cn.iocoder.yudao.framework.mybatis.core.util.CaseInsensitiveResultMap;
10 14 import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
11 15 import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
... ... @@ -13,19 +17,25 @@ import com.baomidou.mybatisplus.annotation.DbType;
13 17 import com.baomidou.mybatisplus.core.conditions.Wrapper;
14 18 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
15 19 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  20 +import com.baomidou.mybatisplus.core.conditions.segments.MergeSegments;
16 21 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
17 22 import com.baomidou.mybatisplus.core.metadata.IPage;
  23 +import com.baomidou.mybatisplus.core.metadata.MapperProxyMetadata;
18 24 import com.baomidou.mybatisplus.core.toolkit.Constants;
  25 +import com.baomidou.mybatisplus.core.toolkit.MybatisUtils;
19 26 import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
20 27 import com.baomidou.mybatisplus.extension.toolkit.Db;
21 28 import com.github.yulichang.base.MPJBaseMapper;
22 29 import com.github.yulichang.interfaces.MPJBaseJoin;
23 30 import com.github.yulichang.wrapper.MPJLambdaWrapper;
24 31 import org.apache.ibatis.annotations.Param;
  32 +import org.apache.ibatis.binding.MapperMethod;
  33 +import org.apache.ibatis.exceptions.TooManyResultsException;
25 34  
26 35 import java.util.Collection;
27 36 import java.util.List;
28 37 import java.util.Map;
  38 +import java.util.Objects;
29 39  
30 40 /**
31 41 * 在 MyBatis Plus 的 BaseMapper 的基础上拓展,提供更多的能力
... ... @@ -141,16 +151,26 @@ public interface BaseMapperX&lt;T&gt; extends MPJBaseMapper&lt;T&gt; {
141 151 return selectOne(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2).eq(field3, value3));
142 152 }
143 153  
  154 + // TODO DONE @AI:能不能把现有的 selectOneForUpdate、selectFirstOneForUpdate、selectListForFirstLock、 这些,做一个整合。目前感觉有点散。。。不然有点乱;包括 cms 项目里,其实也有一些 lock;/Users/yunai/Java/yudao-cms-all/ruoyi-vue-pro-jdk25/yudao-module-cms;最好有个比较万金油的方案。。。。
  155 + // 已整合为 selectOneForUpdate、selectListForUpdate、selectFirstOneForUpdate 三个锁定读入口(含字段重载),boot、cms 的手写 FOR UPDATE 均改为调用它们;
  156 + // 锁定子句由内置查询 SelectForUpdateMethod 生成(不再暴露 selectListForFirstLock 等接口方法),SQL Server 由 ForUpdateInnerInterceptor 改写,Oracle 的首行锁定在本类按数据库类型走多次查询。
  157 +
  158 + // ========== 锁定读:需要在事务中调用;Wrapper 不能使用 last、groupBy、having、参数别名 ==========
  159 +
144 160 /**
145 161 * 获得满足条件的一条记录,并使用 FOR UPDATE 锁定。
146 162 *
147   - * 注意:需要在事务中调用,否则锁会立即释放。
  163 + * 注意:需要在事务中调用,否则锁会立即释放;多于 1 条时抛出 {@link TooManyResultsException}(已锁定的记录在事务结束时释放)
148 164 *
149 165 * @param queryWrapper 查询条件
150 166 * @return 实体
151 167 */
152 168 default T selectOneForUpdate(LambdaQueryWrapper<T> queryWrapper) {
153   - return selectOne(queryWrapper.last("FOR UPDATE"));
  169 + List<T> list = selectListForUpdate(queryWrapper);
  170 + if (list.size() > 1) {
  171 + throw new TooManyResultsException("Expected one result (or null) to be returned by selectOneForUpdate(), but found: " + list.size());
  172 + }
  173 + return CollUtil.getFirst(list);
154 174 }
155 175  
156 176 default T selectOneForUpdate(SFunction<T, ?> field, Object value) {
... ... @@ -167,15 +187,112 @@ public interface BaseMapperX&lt;T&gt; extends MPJBaseMapper&lt;T&gt; {
167 187 }
168 188  
169 189 /**
  190 + * 获得满足条件的全部记录,并使用 FOR UPDATE 锁定。
  191 + *
  192 + * 注意:需要在事务中调用,否则锁会立即释放;返回顺序为 queryWrapper 的排序,未设置时不排序
  193 + *
  194 + * @param queryWrapper 查询条件
  195 + * @return 实体列表
  196 + */
  197 + default List<T> selectListForUpdate(LambdaQueryWrapper<T> queryWrapper) {
  198 + return selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, null);
  199 + }
  200 +
  201 + default List<T> selectListForUpdate(SFunction<T, ?> field, Object value) {
  202 + return selectListForUpdate(new LambdaQueryWrapper<T>().eq(field, value));
  203 + }
  204 +
  205 + default List<T> selectListForUpdate(SFunction<T, ?> field, Collection<?> values) {
  206 + if (CollUtil.isEmpty(values)) {
  207 + return CollUtil.newArrayList();
  208 + }
  209 + return selectListForUpdate(new LambdaQueryWrapper<T>().in(field, values));
  210 + }
  211 +
  212 + /**
170 213 * 获得满足条件的第 1 条记录,并使用 FOR UPDATE 锁定。
171 214 *
172   - * 注意:需要在事务中调用,否则锁会立即释放。
  215 + * 注意:需要在事务中调用,否则锁会立即释放;queryWrapper 未设置排序时,为任意一条满足条件的记录(只适合判断是否存在)
  216 + *
  217 + * Oracle 不支持 LIMIT 1 FOR UPDATE(Oracle 的 FOR UPDATE 也不能与行数限制一起使用),改为多次查询:
  218 + * 1. 按原条件、原排序查询首行主键(候选),不加锁;不存在时返回 null
  219 + * 2. 按原条件、且主键等于候选主键加锁查询,不限制行数;候选已不满足条件时重试
  220 + * 3. 锁定后重新查询首行主键,与候选相同才返回;不同(例如其它事务已提交了排序更靠前的记录)时重试,最多
  221 + * 5 次后抛出 {@link GlobalErrorCodeConstants#LOCKED}
  222 + * 要求实际执行的连接隔离级别为 READ COMMITTED,否则锁定后无法读到其它事务已提交的记录,
  223 + * 由 {@link cn.iocoder.yudao.framework.mybatis.core.interceptor.ForUpdateInnerInterceptor} 校验
  224 + *
  225 + * 其它数据库为单条 LIMIT 1 FOR UPDATE(SQL Server 改写为 TOP (1) 与 WITH (UPDLOCK, ROWLOCK))
173 226 *
174 227 * @param queryWrapper 查询条件
175 228 * @return 实体
176 229 */
177 230 default T selectFirstOneForUpdate(LambdaQueryWrapper<T> queryWrapper) {
178   - return selectOne(queryWrapper.last("LIMIT 1 FOR UPDATE"));
  231 + DbType dbType = JdbcUtils.getDbType();
  232 + if (!ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) {
  233 + return CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK_FIRST, queryWrapper, null));
  234 + }
  235 + int maxAttempts = 5;
  236 + for (int i = 0; i < maxAttempts; i++) {
  237 + List<Object> ids = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null);
  238 + if (ids.isEmpty()) {
  239 + return null;
  240 + }
  241 + Object id = ids.get(0);
  242 + // 是否锁定到候选,以列表是否为空判断:查询列均为 NULL 时,MyBatis 返回的元素为 null
  243 + List<T> lockRows = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, id);
  244 + if (lockRows.isEmpty()) {
  245 + continue;
  246 + }
  247 + List<Object> latestIds = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null);
  248 + if (!latestIds.isEmpty() && Objects.equals(id, latestIds.get(0))) {
  249 + return lockRows.get(0);
  250 + }
  251 + }
  252 + throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.LOCKED);
  253 + }
  254 +
  255 + /**
  256 + * 执行锁定读的内置查询,见 {@link SelectForUpdateMethod}
  257 + *
  258 + * @param kind 内置查询的类型
  259 + * @param queryWrapper 查询条件
  260 + * @param id 主键;仅 {@link SelectForUpdateMethod.Kind#LOCK} 使用,为 null 时不限制
  261 + * @return 实体或主键列表
  262 + */
  263 + private <E> List<E> selectListByForUpdateMethod(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper<T> queryWrapper,
  264 + Object id) {
  265 + checkForUpdateWrapper(queryWrapper);
  266 + MapperMethod.ParamMap<Object> param = new MapperMethod.ParamMap<>();
  267 + param.put(Constants.WRAPPER, queryWrapper);
  268 + param.put(SelectForUpdateMethod.PARAM_ID, id);
  269 + if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) {
  270 + param.put("page", MyBatisUtils.buildPage(0, 1, false));
  271 + }
  272 + MapperProxyMetadata mapperProxyMetadata = MybatisUtils.getMapperProxy(this);
  273 + return mapperProxyMetadata.getSqlSession().selectList(
  274 + mapperProxyMetadata.getMapperInterface().getName() + Constants.DOT + kind.getMethod(), param);
  275 + }
  276 +
  277 + /**
  278 + * 校验锁定读的查询条件:内置查询只使用实体条件、普通条件与排序
  279 + *
  280 + * @param queryWrapper 查询条件
  281 + */
  282 + private void checkForUpdateWrapper(LambdaQueryWrapper<T> queryWrapper) {
  283 + if (queryWrapper == null) {
  284 + throw new IllegalArgumentException("锁定读的查询条件不能为空");
  285 + }
  286 + MergeSegments expression = queryWrapper.getExpression();
  287 + if (!Objects.equals(queryWrapper.getSqlSegment(), expression.getSqlSegment())) {
  288 + throw new IllegalArgumentException("锁定读的查询条件不能使用 last");
  289 + }
  290 + if (!expression.getGroupBy().isEmpty() || !expression.getHaving().isEmpty()) {
  291 + throw new IllegalArgumentException("锁定读的查询条件不能使用 groupBy、having");
  292 + }
  293 + if (!Constants.WRAPPER.equals(queryWrapper.getParamAlias())) {
  294 + throw new IllegalArgumentException("锁定读的查询条件不能设置参数别名");
  295 + }
179 296 }
180 297  
181 298 /**
... ...
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;
22 22 *
23 23 * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库
24 24 * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、
25   - * 达梦、神通、人大金仓、openGauss、GaussDB、瀚高、MogDB 等)保持原有 SQL 片段;完全未识别的数据库抛出异常
  25 + * 达梦、神通、人大金仓、openGauss、GaussDB、瀚高、MogDB 等)保持原有 SQL 片段;完全未识别的数据库抛出异常。
  26 + * 例外:JSON 数组匹配中,瀚高原生驱动、人大金仓使用 PostgreSQL 片段,H2 使用严格的平面 Long 数组片段,
  27 + * 见 {@link #jsonArrayContains(String, boolean)}
26 28 * 3. 参数只能是代码中固定的列名,经过白名单校验;格式均为常量,业务值必须继续使用 #{} 绑定
27 29 *
28 30 * 在 XML 或注解中使用:${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@formatDate('create_time')}
... ... @@ -63,6 +65,50 @@ public class SqlDialectUtils {
63 65 new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK);
64 66  
65 67 /**
  68 + * 数据源与 JSON 方言的缓存;与 {@link #DIALECT_CACHE} 分开,瀚高、人大金仓、H2 仅 JSON 片段使用单独的写法
  69 + */
  70 + private static final Map<DataSource, Dialect> JSON_DIALECT_CACHE =
  71 + new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK);
  72 +
  73 + /**
  74 + * 瀚高原生驱动 URL 的前缀
  75 + */
  76 + private static final String HIGHGO_URL_PREFIX = "jdbc:highgo:";
  77 +
  78 + /**
  79 + * Oracle 平面 Long / null 数组的整体格式,TAB、LF、CR 需先替换为空格:
  80 + * 元素为 0、不含前导零的 1~19 位整数或小写 null,元素两侧可有空格;字符串、嵌套数组、对象、小数、指数、空元素均不匹配
  81 + */
  82 + static final String ORACLE_LONG_ARRAY_PATTERN =
  83 + "^ *\\[ *((0|-?[1-9][0-9]{0,18}|null) *(, *(0|-?[1-9][0-9]{0,18}|null) *)*)?\\] *$";
  84 +
  85 + /**
  86 + * H2 平面 Long / null 数组的整体格式,规则同 {@link #ORACLE_LONG_ARRAY_PATTERN}
  87 + *
  88 + * 注意:H2 的 REGEXP_LIKE 使用 Java 正则,重复的捕获组按递归匹配,长数组(例如 1400 个元素)会栈溢出,
  89 + * 因此使用占有量词(*+)与非捕获组;Oracle 的 POSIX 正则不支持该语法,两者不能混用
  90 + */
  91 + static final String H2_LONG_ARRAY_PATTERN =
  92 + "^ *+\\[ *+(?:(?:0|-?[1-9][0-9]{0,18}|null) *+(?:, *+(?:0|-?[1-9][0-9]{0,18}|null) *+)*+)?\\] *+$";
  93 +
  94 + /**
  95 + * 超出 Long 范围的 19 位整数:负数超过 -9223372036854775808,或正数超过 9223372036854775807
  96 + *
  97 + * 只使用分组、字符类与固定次数,Oracle 的 POSIX 正则与 H2 的 Java 正则语义一致,两者共用
  98 + */
  99 + static final String LONG_OVERFLOW_PATTERN = "(^|[^0-9])" + greaterDigitsPattern("9223372036854775808")
  100 + + "|(^|[^-0-9])9223372036854775808";
  101 +
  102 + /**
  103 + * SQL Server 校验整个 XML(&lt;r&gt;&lt;i&gt;元素&lt;/i&gt;...&lt;/r&gt;)为平面 Long / null 数组的 XQuery:
  104 + * 存在元素,且每个元素去掉两侧空白后非空,并且为 null,或转为 xs:long 再转回文本与原文完全相同(拒绝前导零、溢出等)。
  105 + * 转换失败时 cast as xs:long? 返回空序列,string() 后为空串,与原文不等,因此非法元素会使整体不匹配
  106 + */
  107 + private static final String SQL_SERVER_LONG_ARRAY_XQUERY = "/r[i and not(i[string-length(string(xs:token(string(.)))) = 0"
  108 + + " or (string(xs:token(string(.))) != \"null\""
  109 + + " and string(xs:token(string(.))) != string((string(xs:token(string(.))) cast as xs:long?)))])]";
  110 +
  111 + /**
66 112 * 方言
67 113 */
68 114 enum Dialect {
... ... @@ -73,7 +119,11 @@ public class SqlDialectUtils {
73 119 ORIGINAL,
74 120 POSTGRESQL,
75 121 ORACLE,
76   - SQLSERVER
  122 + SQLSERVER,
  123 + /**
  124 + * H2:仅 JSON 片段使用,见 {@link #resolveJsonDialect(DatabaseMetaData)};其它片段的 H2 仍为 {@link #ORIGINAL}
  125 + */
  126 + H2
77 127  
78 128 }
79 129  
... ... @@ -355,8 +405,11 @@ public class SqlDialectUtils {
355 405 * 平均成交周期(天):两个时间之间的完整天数(向零截断)求平均,保留 1 位小数(截断),无数据时为 0
356 406 *
357 407 * 原有片段为 COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, begin, end)), 1), 0)。
358   - * 注意:仅 PostgreSQL 输出新片段;Oracle、SQL Server 的完整天数表达式依赖实际列类型(DATE / TIMESTAMP / datetime2 / date 等),
359   - * 尚未依据实际 DDL 在真实数据库验收,因此保持原有片段
  408 + * 仅适用于不带时区的日期时间列(LocalDateTime 契约);带时区的 TIMESTAMP WITH TIME ZONE、datetimeoffset 不在适用范围内
  409 + *
  410 + * 1. Oracle:两列均升格为 TIMESTAMP(9),DATE、TIMESTAMP(0~9) 均不丢失小数秒,INTERVAL 的 DAY 部分即向零截断的完整天数
  411 + * 2. SQL Server 2012+:DATEDIFF(DAY) 按日期边界计数,按时刻修正为完整天数;先转 datetime2(7) 再取 time(7),
  412 + * 兼容 date、datetime、datetime2;AVG 使用 DECIMAL 避免整数平均,ROUND(..., 1, 1) 为截断
360 413 *
361 414 * @param beginColumn 开始时间列名
362 415 * @param endColumn 结束时间列名
... ... @@ -367,7 +420,7 @@ public class SqlDialectUtils {
367 420 }
368 421  
369 422 /**
370   - * 按指定方言生成平均完整天数;未验收的数据库继续使用原表达式。
  423 + * 按指定方言生成平均完整天数;原有路径继续使用原表达式。
371 424 *
372 425 * @param dialect 已识别的数据库方言
373 426 * @param beginColumn 代码中固定的开始时间列名
... ... @@ -377,11 +430,26 @@ public class SqlDialectUtils {
377 430 static String customerDealCycle(Dialect dialect, String beginColumn, String endColumn) {
378 431 checkColumn(beginColumn);
379 432 checkColumn(endColumn);
380   - if (dialect == Dialect.POSTGRESQL) {
381   - return "COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP) - CAST("
382   - + beginColumn + " AS TIMESTAMP))) AS NUMERIC)), 1), 0)";
  433 + switch (dialect) {
  434 + case POSTGRESQL:
  435 + return "COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP) - CAST("
  436 + + beginColumn + " AS TIMESTAMP))) AS NUMERIC)), 1), 0)";
  437 + case ORACLE:
  438 + return "COALESCE(TRUNC(AVG(EXTRACT(DAY FROM (CAST(" + endColumn + " AS TIMESTAMP(9)) - CAST("
  439 + + beginColumn + " AS TIMESTAMP(9))))), 1), 0)";
  440 + case SQLSERVER: {
  441 + String beginTime = "CAST(CAST(" + beginColumn + " AS datetime2(7)) AS time(7))";
  442 + String endTime = "CAST(CAST(" + endColumn + " AS datetime2(7)) AS time(7))";
  443 + String days = "DATEDIFF(DAY, " + beginColumn + ", " + endColumn + ")";
  444 + // 正向时,结束时刻早于开始时刻说明最后一天不完整;反向时同理,均向零修正 1 天
  445 + return "COALESCE(ROUND(AVG(CAST((CASE WHEN " + endColumn + " >= " + beginColumn
  446 + + " THEN " + days + " - CASE WHEN " + endTime + " < " + beginTime + " THEN 1 ELSE 0 END"
  447 + + " ELSE " + days + " + CASE WHEN " + endTime + " > " + beginTime + " THEN 1 ELSE 0 END END)"
  448 + + " AS DECIMAL(19, 4))), 1, 1), 0)";
  449 + }
  450 + default:
  451 + return "COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, " + beginColumn + ", " + endColumn + ")), 1), 0)";
383 452 }
384   - return "COALESCE(TRUNCATE(AVG(TIMESTAMPDIFF(DAY, " + beginColumn + ", " + endColumn + ")), 1), 0)";
385 453 }
386 454  
387 455 // ========== 其它 ==========
... ... @@ -458,32 +526,130 @@ public class SqlDialectUtils {
458 526 *
459 527 * 1. 原有片段:wrapArray 为 false 时为 JSON_CONTAINS(column, {0}),参数为 ID 的数字文本;
460 528 * 为 true 时为 JSON_CONTAINS(column, JSON_ARRAY({0})),参数为 Long
461   - * 2. PostgreSQL:CAST(column AS jsonb) @&gt; jsonb_build_array(CAST({0} AS BIGINT)),兼容 jsonb、json 与 JSON 文本列;
462   - * 按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配
463   - * 3. Oracle、SQL Server 及其它数据库保持原有片段,是否可用未经验收
  529 + * 2. PostgreSQL、瀚高(原生驱动 jdbc:highgo:)、人大金仓:CAST(column AS jsonb) @&gt; jsonb_build_array(CAST({0} AS BIGINT)),
  530 + * 兼容 jsonb、json 与 JSON 文本列;按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配
  531 + * 3. Oracle、SQL Server 2012+、H2:仅支持平面 Long / null 数组(应用以 List&lt;Long&gt; 写入的数据),元素两侧允许空白;
  532 + * 整个数组含字符串、嵌套数组、对象、小数、空元素、超出 Long 范围等非法元素时整体不匹配,不等价于通用 JSON_CONTAINS。
  533 + * 参数为 null 时匹配数组中的 null 元素,与原有 JSON_CONTAINS(column, JSON_ARRAY(NULL)) 一致;列为 NULL 时不匹配
  534 + * 4. 达梦、openGauss、神通等其它数据库保持原有片段
464 535 *
465 536 * @param column JSON 数组列名,元素为数值 ID
466 537 * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数
467 538 * @return 包含 {0} 占位符的 SQL 条件片段
468 539 */
469 540 public static String jsonArrayContains(String column, boolean wrapArray) {
470   - return jsonArrayContains(getDialect(), column, wrapArray);
  541 + return jsonArrayContains(getJsonDialect(), column, wrapArray);
471 542 }
472 543  
473 544 /**
474   - * 按指定方言生成 JSON 数组包含数值 ID 的条件;仅 PostgreSQL 输出新片段。
  545 + * 按指定方言生成 JSON 数组包含数值 ID 的条件;原有路径保持 JSON_CONTAINS 片段,{0} 在片段中只出现一次。
475 546 *
476   - * @param dialect 已识别的数据库方言
  547 + * @param dialect 已识别的 JSON 方言
477 548 * @param column 代码中固定的 JSON 数组列名
478 549 * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数
479 550 * @return 包含 {0} 占位符的 SQL 条件片段
480 551 */
481 552 static String jsonArrayContains(Dialect dialect, String column, boolean wrapArray) {
482 553 checkColumn(column);
483   - if (dialect == Dialect.POSTGRESQL) {
484   - return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))";
  554 + switch (dialect) {
  555 + case POSTGRESQL:
  556 + return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))";
  557 + case ORACLE:
  558 + return oracleJsonLongArrayContains(column);
  559 + case SQLSERVER:
  560 + return sqlServerJsonLongArrayContains(column);
  561 + case H2:
  562 + return h2JsonLongArrayContains(column);
  563 + default:
  564 + return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})";
485 565 }
486   - return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})";
  566 + }
  567 +
  568 + /**
  569 + * Oracle:先用 REGEXP_LIKE 校验整个值为平面 Long / null 数组,再去掉空白、将首尾括号换为逗号,按 ,元素, 精确匹配。
  570 + * 数值按 TM9 格式与固定小数点字符转为文本,不受 NLS 影响;参数为 null 时匹配 null 元素,避免 Oracle 空串即 NULL 的拼接陷阱
  571 + *
  572 + * @param column 代码中固定的 JSON 数组列名
  573 + * @return 包含 {0} 占位符的 SQL 条件片段
  574 + */
  575 + private static String oracleJsonLongArrayContains(String column) {
  576 + // 注意:不能使用 TRANSLATE,CLOB 列超过 4000 字节时会报 ORA-22835;REPLACE 对 CLOB 返回 CLOB
  577 + String text = "REPLACE(REPLACE(REPLACE(" + column + ", CHR(9), ' '), CHR(10), ' '), CHR(13), ' ')";
  578 + return "(REGEXP_LIKE(" + text + ", '" + ORACLE_LONG_ARRAY_PATTERN + "')"
  579 + + " AND NOT REGEXP_LIKE(" + column + ", '" + LONG_OVERFLOW_PATTERN + "')"
  580 + + " AND INSTR(REPLACE(REPLACE(REPLACE(" + text + ", ' ', ''), '[', ','), ']', ','),"
  581 + + " ',' || COALESCE(TO_CHAR(CAST({0} AS NUMBER(19, 0)), 'TM9', 'NLS_NUMERIC_CHARACTERS = ''.,'''), 'null')"
  582 + + " || ',') > 0)";
  583 + }
  584 +
  585 + /**
  586 + * H2:规则同 Oracle,整体格式使用 H2 专用的 {@link #H2_LONG_ARRAY_PATTERN},按 LOCATE 匹配 ,元素,
  587 + *
  588 + * @param column 代码中固定的 JSON 数组列名
  589 + * @return 包含 {0} 占位符的 SQL 条件片段
  590 + */
  591 + private static String h2JsonLongArrayContains(String column) {
  592 + String text = "REPLACE(REPLACE(REPLACE(" + column + ", CHR(9), ' '), CHR(10), ' '), CHR(13), ' ')";
  593 + return "(REGEXP_LIKE(" + text + ", '" + H2_LONG_ARRAY_PATTERN + "')"
  594 + + " AND NOT REGEXP_LIKE(" + column + ", '" + LONG_OVERFLOW_PATTERN + "')"
  595 + + " AND LOCATE(',' || COALESCE(CAST(CAST({0} AS BIGINT) AS VARCHAR), 'null') || ',',"
  596 + + " REPLACE(REPLACE(REPLACE(" + text + ", ' ', ''), '[', ','), ']', ',')) > 0)";
  597 + }
  598 +
  599 + /**
  600 + * SQL Server 2012+:不使用 OPENJSON(要求 2016+ 且兼容级别 130+),用 XML 校验元素
  601 + *
  602 + * 1. TAB、LF、CR 替换为空格后修剪整个值,要求首尾为 [ ];括号内只允许数字、-、逗号、空格及 null 的小写字母(BIN2 比较),
  603 + * 此时不含 XML 元字符,按逗号拆成 &lt;r&gt;&lt;i&gt;...&lt;/i&gt;&lt;/r&gt;,其它值拆成空的 &lt;r/&gt;
  604 + * 2. 用 .query('.') 物化一份 XML 副本,再对整个文档执行一次 exist 校验(见 {@link #SQL_SERVER_LONG_ARRAY_XQUERY}):
  605 + * 直接对内联 CAST 的 XML 执行时可能重复解析,本次验证中 1400 个元素的数组会明显变慢
  606 + * 3. 校验通过后,去掉空格、首尾补逗号,按 ,元素, 用 CHARINDEX 匹配;参数在 SQL 内转为 bigint 再转文本,
  607 + * 兼容 String、Long 参数;为 null 时匹配 null 元素
  608 + *
  609 + * 注意:最低按 SQL Server 2012 的语法编写,实际只在 SQL Server 2017(兼容级别 110)上验收。
  610 + * 表别名使用 yd_j 前缀,避免遮蔽外层查询的别名
  611 + *
  612 + * @param column 代码中固定的 JSON 数组列名
  613 + * @return 包含 {0} 占位符的 SQL 条件片段
  614 + */
  615 + private static String sqlServerJsonLongArrayContains(String column) {
  616 + return "EXISTS (SELECT 1"
  617 + + " FROM (SELECT LTRIM(RTRIM(REPLACE(REPLACE(REPLACE(CAST(" + column + " AS nvarchar(max)),"
  618 + + "CHAR(9),N' '),CHAR(10),N' '),CHAR(13),N' '))) AS doc) yd_jd"
  619 + + " CROSS APPLY (SELECT CASE WHEN LEFT(yd_jd.doc,1)=N'[' AND RIGHT(yd_jd.doc,1)=N']'"
  620 + + " THEN SUBSTRING(yd_jd.doc,2,CASE WHEN LEN(yd_jd.doc)>=2 THEN LEN(yd_jd.doc)-2 ELSE 0 END)"
  621 + + " ELSE NULL END AS body) yd_jb"
  622 + + " CROSS APPLY (SELECT CAST(CASE WHEN yd_jb.body IS NOT NULL"
  623 + + " AND PATINDEX(N'%[^0-9,nul -]%',COALESCE(yd_jb.body,N'') COLLATE Latin1_General_100_BIN2)=0"
  624 + + " AND LTRIM(RTRIM(yd_jb.body))<>N''"
  625 + + " THEN N'<r><i>'+REPLACE(yd_jb.body,N',',N'</i><i>')+N'</i></r>' ELSE N'<r/>' END AS XML) AS tokens) yd_jx"
  626 + + " CROSS APPLY (SELECT yd_jx.tokens.query('.') AS tokens) yd_jm"
  627 + + " WHERE yd_jm.tokens.exist('" + SQL_SERVER_LONG_ARRAY_XQUERY + "') = 1"
  628 + + " AND CHARINDEX(N','+COALESCE(CONVERT(nvarchar(20),CAST({0} AS BIGINT)),N'null')+N',',"
  629 + + "N','+REPLACE(yd_jb.body,N' ',N'')+N',')>0)";
  630 + }
  631 +
  632 + /**
  633 + * 生成与 max 位数相同、数值更大的十进制数字串的正则,例如 max 为 18 时为 (1[9-9]|[2-9][0-9]{1})
  634 + *
  635 + * @param max 不含符号的十进制数字串
  636 + * @return 正则片段,不含 {0} 这样会被 Wrapper 当作占位符的文本
  637 + */
  638 + static String greaterDigitsPattern(String max) {
  639 + String pattern = null;
  640 + for (int i = max.length() - 1; i >= 0; i--) {
  641 + char digit = max.charAt(i);
  642 + int rest = max.length() - 1 - i;
  643 + String greater = digit == '9' ? null
  644 + : "[" + (char) (digit + 1) + "-9]" + (rest > 0 ? "[0-9]{" + rest + "}" : "");
  645 + String same = pattern == null ? null : digit + pattern;
  646 + if (same == null) {
  647 + pattern = greater;
  648 + } else {
  649 + pattern = greater == null ? same : "(" + same + "|" + greater + ")";
  650 + }
  651 + }
  652 + return pattern;
487 653 }
488 654  
489 655 // ========== 行数限制 ==========
... ... @@ -670,6 +836,57 @@ public class SqlDialectUtils {
670 836 }
671 837  
672 838 /**
  839 + * 取得当前执行的 JSON 方言;连接与缓存规则同 {@link #getDialect()},仅 JSON 片段使用
  840 + *
  841 + * @return JSON 方言
  842 + */
  843 + static Dialect getJsonDialect() {
  844 + Connection connection = JdbcUtils.getTransactionalConnection();
  845 + if (connection != null) {
  846 + try {
  847 + return resolveJsonDialect(connection.getMetaData());
  848 + } catch (SQLException e) {
  849 + throw new IllegalStateException("获取数据库方言失败", e);
  850 + }
  851 + }
  852 + return JSON_DIALECT_CACHE.computeIfAbsent(JdbcUtils.getDataSource(), dataSource -> {
  853 + try (Connection conn = dataSource.getConnection()) {
  854 + return resolveJsonDialect(conn.getMetaData());
  855 + } catch (SQLException e) {
  856 + throw new IllegalStateException("获取数据库方言失败", e);
  857 + }
  858 + });
  859 + }
  860 +
  861 + /**
  862 + * 识别 JSON 方言,其它与 {@link #resolveDialect(DatabaseMetaData)} 一致;日期等其它片段仍保留原有片段:
  863 + * 1. 瀚高原生驱动(jdbc:highgo:)、人大金仓(产品名 KingbaseES):已验收 jsonb 写法、且不支持 JSON_CONTAINS,按 PostgreSQL 处理
  864 + * 2. H2:不支持 JSON_CONTAINS,使用 {@link Dialect#H2} 的严格平面 Long 数组片段
  865 + *
  866 + * @param metaData 数据库元数据
  867 + * @return JSON 方言
  868 + * @throws SQLException 读取元数据失败
  869 + */
  870 + static Dialect resolveJsonDialect(DatabaseMetaData metaData) throws SQLException {
  871 + Dialect dialect = resolveDialect(metaData);
  872 + if (dialect != Dialect.ORIGINAL) {
  873 + return dialect;
  874 + }
  875 + String url = StrUtil.nullToEmpty(metaData.getURL()).toLowerCase(Locale.ROOT);
  876 + DbType dbType = DbTypeEnum.find(metaData.getDatabaseProductName());
  877 + if (url.startsWith(HIGHGO_URL_PREFIX) && (dbType == null || dbType == DbType.POSTGRE_SQL)) {
  878 + return Dialect.POSTGRESQL;
  879 + }
  880 + if (dbType == DbType.KINGBASE_ES) {
  881 + return Dialect.POSTGRESQL;
  882 + }
  883 + if (dbType == DbType.H2) {
  884 + return Dialect.H2;
  885 + }
  886 + return dialect;
  887 + }
  888 +
  889 + /**
673 890 * 根据数据库元数据识别方言
674 891 *
675 892 * 注意:使用 PostgreSQL 官方驱动(jdbc:postgresql:)连接 openGauss、瀚高等兼容 PostgreSQL 的数据库时,
... ...
yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/interceptor/ForUpdateInnerInterceptorTest.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.mybatis.core.interceptor;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.injector.SelectForUpdateMethod;
  4 +import com.baomidou.mybatisplus.annotation.TableId;
  5 +import com.baomidou.mybatisplus.annotation.TableLogic;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import com.baomidou.mybatisplus.core.MybatisConfiguration;
  8 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  9 +import com.baomidou.mybatisplus.core.injector.methods.SelectList;
  10 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  11 +import com.baomidou.mybatisplus.core.metadata.TableInfo;
  12 +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
  13 +import lombok.Data;
  14 +import org.apache.ibatis.builder.MapperBuilderAssistant;
  15 +import org.apache.ibatis.executor.Executor;
  16 +import org.apache.ibatis.executor.statement.RoutingStatementHandler;
  17 +import org.apache.ibatis.mapping.BoundSql;
  18 +import org.apache.ibatis.mapping.MappedStatement;
  19 +import org.apache.ibatis.session.RowBounds;
  20 +import org.junit.jupiter.api.BeforeEach;
  21 +import org.junit.jupiter.api.Test;
  22 +
  23 +import java.sql.Connection;
  24 +import java.sql.SQLException;
  25 +import java.util.HashMap;
  26 +import java.util.Map;
  27 +import java.util.regex.Pattern;
  28 +
  29 +import static org.junit.jupiter.api.Assertions.*;
  30 +import static org.mockito.Mockito.*;
  31 +
  32 +/**
  33 + * {@link ForUpdateInnerInterceptor} 的单元测试
  34 + *
  35 + * 使用 {@link SelectForUpdateMethod} 实际注入的 MappedStatement 生成 BoundSql,连接为桩对象
  36 + *
  37 + * @author 芋道源码
  38 + */
  39 +public class ForUpdateInnerInterceptorTest {
  40 +
  41 + private static final String SQL_SERVER = "Microsoft SQL Server";
  42 + private static final String MYSQL = "MySQL";
  43 + private static final String ORACLE = "Oracle";
  44 +
  45 + private final ForUpdateInnerInterceptor interceptor = new ForUpdateInnerInterceptor();
  46 +
  47 + private MybatisConfiguration configuration;
  48 +
  49 + @BeforeEach
  50 + public void setUp() {
  51 + configuration = new MybatisConfiguration();
  52 + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
  53 + assistant.setCurrentNamespace(LockMapper.class.getName());
  54 + TableInfo tableInfo = TableInfoHelper.initTableInfo(assistant, LockDO.class);
  55 + new SelectList().inject(assistant, LockMapper.class, LockDO.class, tableInfo);
  56 + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) {
  57 + new SelectForUpdateMethod(kind).inject(assistant, LockMapper.class, LockDO.class, tableInfo);
  58 + }
  59 + }
  60 +
  61 + // ========== 内置查询 ==========
  62 +
  63 + @Test
  64 + public void testMappedStatement() {
  65 + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) {
  66 + MappedStatement ms = getMappedStatement(kind.getMethod());
  67 + // 断言:SqlSource 标记、缓存
  68 + SelectForUpdateMethod.LockSqlSource sqlSource = assertInstanceOf(SelectForUpdateMethod.LockSqlSource.class, ms.getSqlSource());
  69 + assertSame(kind, sqlSource.getKind());
  70 + assertTrue(ms.isFlushCacheRequired());
  71 + assertFalse(ms.isUseCache());
  72 + // 断言:加锁查询返回实体,主键查询返回主键类型
  73 + Class<?> resultType = ms.getResultMaps().get(0).getType();
  74 + assertSame(kind == SelectForUpdateMethod.Kind.FIRST_IDS ? Long.class : LockDO.class, resultType);
  75 + }
  76 + }
  77 +
  78 + @Test
  79 + public void testBoundSql() {
  80 + // 准备参数:OR 条件
  81 + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>()
  82 + .eq(LockDO::getName, "a").or().eq(LockDO::getStatus, 1);
  83 +
  84 + // 调用、断言:加锁查询,OR 条件整体加括号后,再限制主键与逻辑删除;未设置排序时不排序
  85 + String lock = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L).getSql());
  86 + assertTrue(Pattern.compile("WHERE \\(\\s*\\(name = \\? OR status = \\?\\)\\s*\\) AND id = \\? AND deleted\\s*=\\s*0 FOR UPDATE$")
  87 + .matcher(lock).find(), lock);
  88 + assertFalse(lock.contains("ORDER BY"), lock);
  89 + // 调用、断言:加锁查询,不传主键时不限制主键
  90 + String lockWithoutId = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, null).getSql());
  91 + assertFalse(lockWithoutId.contains("id = ?"), lockWithoutId);
  92 + // 调用、断言:首行加锁查询,未设置排序时不排序
  93 + String lockFirst = normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK_FIRST, wrapper, null).getSql());
  94 + assertTrue(lockFirst.endsWith("AND deleted=0 LIMIT 1 FOR UPDATE"), lockFirst);
  95 + assertFalse(lockFirst.contains("ORDER BY"), lockFirst);
  96 + // 调用、断言:主键查询,未设置排序时按主键排序,不加锁
  97 + String firstIds = normalize(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql());
  98 + assertTrue(firstIds.startsWith("SELECT id FROM infra_lock_demo"), firstIds);
  99 + assertTrue(firstIds.endsWith("ORDER BY id"), firstIds);
  100 + // 调用、断言:设置排序时使用 Wrapper 的排序
  101 + wrapper.orderByDesc(LockDO::getStatus);
  102 + assertTrue(normalize(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, null).getSql())
  103 + .endsWith("ORDER BY status DESC FOR UPDATE"));
  104 + assertTrue(normalize(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql())
  105 + .endsWith("ORDER BY status DESC"));
  106 + }
  107 +
  108 + // ========== 拦截 ==========
  109 +
  110 + @Test
  111 + public void testBeforePrepare_notLockStatement() {
  112 + // 准备参数:普通查询,即使 SQL 包含 FOR UPDATE
  113 + Map<String, Object> params = new HashMap<>();
  114 + params.put("ew", new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "for update").last("FOR UPDATE"));
  115 + MappedStatement ms = getMappedStatement("selectList");
  116 + BoundSql boundSql = ms.getBoundSql(params);
  117 + String sql = boundSql.getSql();
  118 + Connection connection = mock(Connection.class);
  119 +
  120 + // 调用
  121 + interceptor.beforePrepare(newHandler(ms, params, boundSql), connection, null);
  122 + // 断言:SQL 不变,不访问连接
  123 + assertSame(sql, boundSql.getSql());
  124 + verifyNoInteractions(connection);
  125 + }
  126 +
  127 + @Test
  128 + public void testBeforePrepare_sqlServerLock() throws SQLException {
  129 + // 准备参数
  130 + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK,
  131 + new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), 1L,
  132 + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_READ_COMMITTED));
  133 + String original = getBoundSql(SelectForUpdateMethod.Kind.LOCK,
  134 + new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), 1L).getSql();
  135 +
  136 + // 断言:删除 FOR UPDATE,表之后增加提示
  137 + assertEquals(normalize(original.replace("FROM infra_lock_demo", "FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)")
  138 + .replace("FOR UPDATE", "")), normalize(boundSql.getSql()));
  139 + }
  140 +
  141 + @Test
  142 + public void testBeforePrepare_sqlServerLockFirst() throws SQLException {
  143 + // 准备参数
  144 + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK_FIRST,
  145 + new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), null,
  146 + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_REPEATABLE_READ));
  147 + String original = getBoundSql(SelectForUpdateMethod.Kind.LOCK_FIRST,
  148 + new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a").orderByAsc(LockDO::getId), null).getSql();
  149 +
  150 + // 断言:LIMIT 1 改为 TOP (1),删除 FOR UPDATE,表之后增加提示
  151 + assertEquals(normalize(original.replaceFirst("SELECT", "SELECT TOP (1)")
  152 + .replace("FROM infra_lock_demo", "FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)")
  153 + .replace("LIMIT 1 FOR UPDATE", "")), normalize(boundSql.getSql()));
  154 + }
  155 +
  156 + @Test
  157 + public void testBeforePrepare_sqlServerMarkerAfterCopy() throws SQLException {
  158 + // 准备参数:与 MyBatis Plus Join 一样,复制 MappedStatement 时保留同一个 SqlSource
  159 + MappedStatement origin = getMappedStatement(SelectForUpdateMethod.Kind.LOCK.getMethod());
  160 + MappedStatement ms = new MappedStatement.Builder(configuration, origin.getId(), origin.getSqlSource(),
  161 + origin.getSqlCommandType()).resultMaps(origin.getResultMaps()).build();
  162 + Map<String, Object> params = buildParams(new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a"), null);
  163 + BoundSql boundSql = ms.getBoundSql(params);
  164 +
  165 + // 调用
  166 + interceptor.beforePrepare(newHandler(ms, params, boundSql),
  167 + mockConnection(SQL_SERVER, false, Connection.TRANSACTION_READ_COMMITTED), null);
  168 + // 断言
  169 + assertTrue(boundSql.getSql().contains("FROM infra_lock_demo WITH (UPDLOCK, ROWLOCK)"), boundSql.getSql());
  170 + assertFalse(boundSql.getSql().contains("FOR UPDATE"), boundSql.getSql());
  171 + }
  172 +
  173 + @Test
  174 + public void testBeforePrepare_mysql() throws SQLException {
  175 + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a");
  176 + for (SelectForUpdateMethod.Kind kind : new SelectForUpdateMethod.Kind[]{
  177 + SelectForUpdateMethod.Kind.LOCK, SelectForUpdateMethod.Kind.LOCK_FIRST}) {
  178 + // 准备参数:MySQL 默认的 REPEATABLE READ
  179 + Connection connection = mockConnection(MYSQL, false, Connection.TRANSACTION_REPEATABLE_READ);
  180 +
  181 + // 调用
  182 + BoundSql boundSql = beforePrepare(kind, wrapper, null, connection);
  183 + // 断言:SQL 不变,不校验隔离级别
  184 + assertEquals(getBoundSql(kind, wrapper, null).getSql(), boundSql.getSql());
  185 + verify(connection, never()).getTransactionIsolation();
  186 + }
  187 + }
  188 +
  189 + @Test
  190 + public void testBeforePrepare_autoCommit() throws SQLException {
  191 + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a");
  192 + for (SelectForUpdateMethod.Kind kind : SelectForUpdateMethod.Kind.values()) {
  193 + // 调用,并断言异常
  194 + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> beforePrepare(kind, wrapper, null,
  195 + mockConnection(MYSQL, true, Connection.TRANSACTION_READ_COMMITTED)));
  196 + assertTrue(ex.getMessage().contains("自动提交"));
  197 + }
  198 + }
  199 +
  200 + @Test
  201 + public void testBeforePrepare_firstIds() throws SQLException {
  202 + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a");
  203 +
  204 + // 调用,并断言异常:非 READ COMMITTED
  205 + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> beforePrepare(SelectForUpdateMethod.Kind.FIRST_IDS,
  206 + wrapper, null, mockConnection(ORACLE, false, Connection.TRANSACTION_SERIALIZABLE)));
  207 + assertTrue(ex.getMessage().contains("READ COMMITTED"));
  208 + // 调用:READ COMMITTED
  209 + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null,
  210 + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED));
  211 + // 断言:SQL 不变
  212 + assertEquals(getBoundSql(SelectForUpdateMethod.Kind.FIRST_IDS, wrapper, null).getSql(), boundSql.getSql());
  213 + }
  214 +
  215 + @Test
  216 + public void testBeforePrepare_oracle() throws SQLException {
  217 + LambdaQueryWrapper<LockDO> wrapper = new LambdaQueryWrapper<LockDO>().eq(LockDO::getName, "a");
  218 +
  219 + // 调用:加锁查询,SQL 不变
  220 + BoundSql boundSql = beforePrepare(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L,
  221 + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED));
  222 + assertEquals(getBoundSql(SelectForUpdateMethod.Kind.LOCK, wrapper, 1L).getSql(), boundSql.getSql());
  223 + // 调用,并断言异常:首行加锁查询
  224 + assertThrows(IllegalStateException.class, () -> beforePrepare(SelectForUpdateMethod.Kind.LOCK_FIRST, wrapper, null,
  225 + mockConnection(ORACLE, false, Connection.TRANSACTION_READ_COMMITTED)));
  226 + }
  227 +
  228 + // ========== SQL Server 改写 ==========
  229 +
  230 + @Test
  231 + public void testRewriteForSqlServer() {
  232 + // 字符串中的 for update 不改写
  233 + assertEquals("SELECT id FROM t WITH (UPDLOCK, ROWLOCK) WHERE name = 'for update' ",
  234 + ForUpdateInnerInterceptor.rewriteForSqlServer("SELECT id FROM t WHERE name = 'for update' FOR UPDATE", false));
  235 + // 子查询的表不加提示
  236 + assertEquals("SELECT id FROM a WITH (UPDLOCK, ROWLOCK) WHERE (b IN (SELECT id FROM c WHERE site_id = 1)) ORDER BY id ASC ",
  237 + ForUpdateInnerInterceptor.rewriteForSqlServer(
  238 + "SELECT id FROM a WHERE (b IN (SELECT id FROM c WHERE site_id = 1)) ORDER BY id ASC FOR UPDATE", false));
  239 + // 首行
  240 + assertEquals("SELECT TOP (1) id FROM t WITH (UPDLOCK, ROWLOCK) WHERE (a = ?) ORDER BY id ASC ",
  241 + ForUpdateInnerInterceptor.rewriteForSqlServer("SELECT id FROM t WHERE (a = ?) ORDER BY id ASC LIMIT 1 FOR UPDATE", true));
  242 + }
  243 +
  244 + @Test
  245 + public void testRewriteForSqlServer_unsupported() {
  246 + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer(
  247 + "SELECT a.id FROM a JOIN b ON a.id = b.id FOR UPDATE", false));
  248 + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer(
  249 + "SELECT id FROM t FOR UPDATE NOWAIT", false));
  250 + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer(
  251 + "SELECT id FROM t", false));
  252 + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer(
  253 + "SELECT id FROM t LIMIT 1 FOR UPDATE", false));
  254 + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer(
  255 + "SELECT id FROM t LIMIT 2 FOR UPDATE", true));
  256 + assertThrows(IllegalStateException.class, () -> ForUpdateInnerInterceptor.rewriteForSqlServer(
  257 + "SELECT DISTINCT id FROM t LIMIT 1 FOR UPDATE", true));
  258 + }
  259 +
  260 + // ========== 工具方法 ==========
  261 +
  262 + private BoundSql beforePrepare(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper<LockDO> wrapper, Object id,
  263 + Connection connection) {
  264 + MappedStatement ms = getMappedStatement(kind.getMethod());
  265 + Map<String, Object> params = buildParams(wrapper, id);
  266 + BoundSql boundSql = ms.getBoundSql(params);
  267 + interceptor.beforePrepare(newHandler(ms, params, boundSql), connection, null);
  268 + return boundSql;
  269 + }
  270 +
  271 + private BoundSql getBoundSql(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper<LockDO> wrapper, Object id) {
  272 + return getMappedStatement(kind.getMethod()).getBoundSql(buildParams(wrapper, id));
  273 + }
  274 +
  275 + private static Map<String, Object> buildParams(LambdaQueryWrapper<LockDO> wrapper, Object id) {
  276 + Map<String, Object> params = new HashMap<>();
  277 + params.put("ew", wrapper);
  278 + params.put(SelectForUpdateMethod.PARAM_ID, id);
  279 + return params;
  280 + }
  281 +
  282 + private MappedStatement getMappedStatement(String method) {
  283 + return configuration.getMappedStatement(LockMapper.class.getName() + "." + method);
  284 + }
  285 +
  286 + private static RoutingStatementHandler newHandler(MappedStatement ms, Object params, BoundSql boundSql) {
  287 + return new RoutingStatementHandler(mock(Executor.class), ms, params, RowBounds.DEFAULT, null, boundSql);
  288 + }
  289 +
  290 + private static Connection mockConnection(String productName, boolean autoCommit, int isolation) throws SQLException {
  291 + Connection connection = mock(Connection.class, RETURNS_DEEP_STUBS);
  292 + when(connection.getMetaData().getDatabaseProductName()).thenReturn(productName);
  293 + when(connection.getAutoCommit()).thenReturn(autoCommit);
  294 + when(connection.getTransactionIsolation()).thenReturn(isolation);
  295 + return connection;
  296 + }
  297 +
  298 + private static String normalize(String sql) {
  299 + return sql.replaceAll("\\s+", " ").trim();
  300 + }
  301 +
  302 + @Data
  303 + @TableName("infra_lock_demo")
  304 + public static class LockDO {
  305 +
  306 + @TableId
  307 + private Long id;
  308 + private String name;
  309 + private Integer status;
  310 + @TableLogic
  311 + private Boolean deleted;
  312 +
  313 + }
  314 +
  315 + public interface LockMapper extends BaseMapper<LockDO> {
  316 + }
  317 +
  318 +}
... ...
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;
35 35 import java.util.List;
36 36 import java.util.Map;
37 37 import java.util.function.BiFunction;
  38 +import java.util.regex.Pattern;
38 39  
39 40 import static org.junit.jupiter.api.Assertions.*;
40 41  
... ... @@ -147,9 +148,19 @@ public class SqlDialectUtilsTest {
147 148 assertEquals("COALESCE(TRUNC(AVG(CAST(EXTRACT(DAY FROM (CAST(contract.order_date AS TIMESTAMP) - "
148 149 + "CAST(customer.create_time AS TIMESTAMP))) AS NUMERIC)), 1), 0)",
149 150 SqlDialectUtils.customerDealCycle(Dialect.POSTGRESQL, "customer.create_time", "contract.order_date"));
150   - // Oracle、SQL Server:完整天数表达式依赖实际列类型,尚未验收,保持原有片段
151   - assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.ORACLE, "customer.create_time", "contract.order_date"));
152   - assertEquals(original, SqlDialectUtils.customerDealCycle(Dialect.SQLSERVER, "customer.create_time", "contract.order_date"));
  151 + // Oracle:升格为 TIMESTAMP(9),DATE 与 TIMESTAMP(9) 均不丢失精度,INTERVAL 的 DAY 部分即完整天数
  152 + assertEquals("COALESCE(TRUNC(AVG(EXTRACT(DAY FROM (CAST(contract.order_date AS TIMESTAMP(9)) - "
  153 + + "CAST(customer.create_time AS TIMESTAMP(9))))), 1), 0)",
  154 + SqlDialectUtils.customerDealCycle(Dialect.ORACLE, "customer.create_time", "contract.order_date"));
  155 + // SQL Server:DATEDIFF(DAY) 按时刻向零修正,DECIMAL 平均,ROUND(..., 1, 1) 截断
  156 + String beginTime = "CAST(CAST(customer.create_time AS datetime2(7)) AS time(7))";
  157 + String endTime = "CAST(CAST(contract.order_date AS datetime2(7)) AS time(7))";
  158 + String days = "DATEDIFF(DAY, customer.create_time, contract.order_date)";
  159 + assertEquals("COALESCE(ROUND(AVG(CAST((CASE WHEN contract.order_date >= customer.create_time THEN "
  160 + + days + " - CASE WHEN " + endTime + " < " + beginTime + " THEN 1 ELSE 0 END ELSE "
  161 + + days + " + CASE WHEN " + endTime + " > " + beginTime + " THEN 1 ELSE 0 END END)"
  162 + + " AS DECIMAL(19, 4))), 1, 1), 0)",
  163 + SqlDialectUtils.customerDealCycle(Dialect.SQLSERVER, "customer.create_time", "contract.order_date"));
153 164 }
154 165  
155 166 @Test
... ... @@ -332,18 +343,210 @@ public class SqlDialectUtilsTest {
332 343  
333 344 @Test
334 345 public void testJsonArrayContains() {
335   - // 原有片段、Oracle、SQL Server:保持原有 JSON_CONTAINS
336   - for (Dialect dialect : List.of(Dialect.ORIGINAL, Dialect.ORACLE, Dialect.SQLSERVER)) {
337   - assertEquals("JSON_CONTAINS(booking_user_ids, {0})",
338   - SqlDialectUtils.jsonArrayContains(dialect, "booking_user_ids", false), dialect.name());
339   - assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))",
340   - SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true), dialect.name());
341   - }
  346 + // 原有片段:保持原有 JSON_CONTAINS
  347 + assertEquals("JSON_CONTAINS(booking_user_ids, {0})",
  348 + SqlDialectUtils.jsonArrayContains(Dialect.ORIGINAL, "booking_user_ids", false));
  349 + assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))",
  350 + SqlDialectUtils.jsonArrayContains(Dialect.ORIGINAL, "t.label_ids", true));
342 351 // PostgreSQL:两种原有片段统一为 jsonb 包含,参数按 BIGINT 匹配数字元素
343 352 assertEquals("CAST(booking_user_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))",
344 353 SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "booking_user_ids", false));
345 354 assertEquals("CAST(t.label_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))",
346 355 SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "t.label_ids", true));
  356 + // Oracle、SQL Server、H2:两种原有片段相同,{0} 只出现一次,不使用 JSON 函数
  357 + for (Dialect dialect : List.of(Dialect.ORACLE, Dialect.SQLSERVER, Dialect.H2)) {
  358 + String condition = SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true);
  359 + assertEquals(condition, SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", false), dialect.name());
  360 + assertEquals(1, condition.split("\\{0}", -1).length - 1, condition);
  361 + assertFalse(condition.toUpperCase().contains("JSON"), condition);
  362 + }
  363 + String oracle = SqlDialectUtils.jsonArrayContains(Dialect.ORACLE, "t.label_ids", true);
  364 + // CLOB 超过 4000 字节时 TRANSLATE 报 ORA-22835,空白只能用 REPLACE 替换
  365 + assertTrue(oracle.contains("REGEXP_LIKE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), "),
  366 + oracle);
  367 + assertFalse(oracle.toUpperCase().contains("TRANSLATE"), oracle);
  368 + assertTrue(oracle.contains("'" + SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN + "'"), oracle);
  369 + assertFalse(oracle.contains(SqlDialectUtils.H2_LONG_ARRAY_PATTERN), oracle);
  370 + assertTrue(oracle.contains("NOT REGEXP_LIKE(t.label_ids, '" + SqlDialectUtils.LONG_OVERFLOW_PATTERN + "')"), oracle);
  371 + assertTrue(oracle.contains("COALESCE(TO_CHAR(CAST({0} AS NUMBER(19, 0)), 'TM9', "
  372 + + "'NLS_NUMERIC_CHARACTERS = ''.,'''), 'null')"), oracle);
  373 + // SQL Server:与原生验收的 SQL 一致;XML 用 .query('.') 物化后整体 exist 校验,再按逗号边界 CHARINDEX 匹配
  374 + String sqlServer = SqlDialectUtils.jsonArrayContains(Dialect.SQLSERVER, "t.label_ids", true);
  375 + assertEquals("EXISTS (SELECT 1"
  376 + + " 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"
  377 + + " CROSS APPLY (SELECT CASE WHEN LEFT(yd_jd.doc,1)=N'[' AND RIGHT(yd_jd.doc,1)=N']'"
  378 + + " THEN SUBSTRING(yd_jd.doc,2,CASE WHEN LEN(yd_jd.doc)>=2"
  379 + + " THEN LEN(yd_jd.doc)-2 ELSE 0 END) ELSE NULL END AS body) yd_jb"
  380 + + " CROSS APPLY (SELECT CAST(CASE WHEN yd_jb.body IS NOT NULL"
  381 + + " AND PATINDEX(N'%[^0-9,nul -]%',COALESCE(yd_jb.body,N'') COLLATE Latin1_General_100_BIN2)=0"
  382 + + " AND LTRIM(RTRIM(yd_jb.body))<>N''"
  383 + + " THEN N'<r><i>'+REPLACE(yd_jb.body,N',',N'</i><i>')+N'</i></r>' ELSE N'<r/>' END AS XML) AS tokens) yd_jx"
  384 + + " CROSS APPLY (SELECT yd_jx.tokens.query('.') AS tokens) yd_jm"
  385 + + " WHERE yd_jm.tokens.exist('/r[i and not(i[string-length(string(xs:token(string(.)))) = 0"
  386 + + " or (string(xs:token(string(.))) != \"null\""
  387 + + " and string(xs:token(string(.))) != string((string(xs:token(string(.))) cast as xs:long?)))])]') = 1"
  388 + + " AND CHARINDEX(N','+COALESCE(CONVERT(nvarchar(20),CAST({0} AS BIGINT)),N'null')+N',',N','+REPLACE(yd_jb.body,N' ',N'')+N',')>0)",
  389 + sqlServer);
  390 + // SQL Server 2012~2016 不支持 OPENJSON,任何分支都不能出现;不逐个遍历节点,XQuery 中不嵌入业务列
  391 + assertFalse(sqlServer.toUpperCase().contains("OPENJSON"), sqlServer);
  392 + assertFalse(sqlServer.contains(".nodes("), sqlServer);
  393 + assertFalse(sqlServer.contains("sql:column"), sqlServer);
  394 + // H2:与原生验收的 SQL 一致,使用 H2 专用的正则(Java 正则的占有量词),不使用 Oracle 的正则
  395 + String h2 = SqlDialectUtils.jsonArrayContains(Dialect.H2, "t.label_ids", true);
  396 + assertEquals("^ *+\\[ *+(?:(?:0|-?[1-9][0-9]{0,18}|null) *+(?:, *+(?:0|-?[1-9][0-9]{0,18}|null) *+)*+)?\\] *+$",
  397 + SqlDialectUtils.H2_LONG_ARRAY_PATTERN);
  398 + assertEquals("(REGEXP_LIKE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), '"
  399 + + SqlDialectUtils.H2_LONG_ARRAY_PATTERN + "')"
  400 + + " AND NOT REGEXP_LIKE(t.label_ids, '" + SqlDialectUtils.LONG_OVERFLOW_PATTERN + "')"
  401 + + " AND LOCATE(',' || COALESCE(CAST(CAST({0} AS BIGINT) AS VARCHAR), 'null') || ',',"
  402 + + " REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(t.label_ids, CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),"
  403 + + " ' ', ''), '[', ','), ']', ',')) > 0)",
  404 + h2);
  405 + assertFalse(h2.contains(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN), h2);
  406 + }
  407 +
  408 + /**
  409 + * Oracle 片段的匹配语义:按 REGEXP_LIKE、REPLACE、INSTR 的组合在 Java 中复现,校验严格的平面 Long / null 数组契约
  410 + */
  411 + @Test
  412 + public void testJsonArrayContains_oracleLongArraySemantics() {
  413 + // 普通匹配:相邻 ID、负数、大 ID、Long 边界、空白
  414 + assertTrue(oracleJsonContains("[1,11]", 1L));
  415 + assertTrue(oracleJsonContains("[1,11]", 11L));
  416 + assertFalse(oracleJsonContains("[11,21]", 1L));
  417 + assertTrue(oracleJsonContains("[2115311103682412546,2115311103682412547]", 2115311103682412546L));
  418 + assertFalse(oracleJsonContains("[2115311103682412546]", 211531110368241254L));
  419 + assertTrue(oracleJsonContains("[-1, 1]", -1L));
  420 + assertFalse(oracleJsonContains("[-1]", 1L));
  421 + assertTrue(oracleJsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MIN_VALUE));
  422 + assertTrue(oracleJsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MAX_VALUE));
  423 + assertTrue(oracleJsonContains(" [ 1 ,\t2 ,\r\n 3 ] ", 2L));
  424 + assertTrue(oracleJsonContains("[0]", 0L));
  425 + // null 元素:参数为 null 时匹配 null 元素
  426 + assertTrue(oracleJsonContains("[null]", null));
  427 + assertTrue(oracleJsonContains("[1, null]", null));
  428 + assertTrue(oracleJsonContains("[1, null]", 1L));
  429 + assertFalse(oracleJsonContains("[1]", null));
  430 + assertFalse(oracleJsonContains("[NULL]", null));
  431 + // 非数组、空数组、列为 NULL
  432 + assertFalse(oracleJsonContains(null, 1L));
  433 + assertFalse(oracleJsonContains(null, null));
  434 + assertFalse(oracleJsonContains("null", null));
  435 + assertFalse(oracleJsonContains("[]", 1L));
  436 + assertFalse(oracleJsonContains("[ ]", null));
  437 + assertFalse(oracleJsonContains("1", 1L));
  438 + // 字符串、嵌套、对象、小数、指数、前导零、空元素、缺分隔符:整体不匹配
  439 + assertFalse(oracleJsonContains("[\"1\"]", 1L));
  440 + assertFalse(oracleJsonContains("[\"x,1,y\"]", 1L));
  441 + assertFalse(oracleJsonContains("[1,\"x\"]", 1L));
  442 + assertFalse(oracleJsonContains("[[1]]", 1L));
  443 + assertFalse(oracleJsonContains("[1,[2]]", 1L));
  444 + assertFalse(oracleJsonContains("[{\"a\":1}]", 1L));
  445 + assertFalse(oracleJsonContains("[1.0]", 1L));
  446 + assertFalse(oracleJsonContains("[1e3]", 1000L));
  447 + assertFalse(oracleJsonContains("[01]", 1L));
  448 + assertFalse(oracleJsonContains("[-0]", 0L));
  449 + assertFalse(oracleJsonContains("[1,,2]", 1L));
  450 + assertFalse(oracleJsonContains("[,1]", 1L));
  451 + assertFalse(oracleJsonContains("[1,]", 1L));
  452 + assertFalse(oracleJsonContains("[1 2]", 12L));
  453 + assertFalse(oracleJsonContains("[1 2]", 1L));
  454 + assertFalse(oracleJsonContains("[1]]", 1L));
  455 + // 超出 Long 范围:整个数组不匹配
  456 + assertFalse(oracleJsonContains("[9223372036854775808,1]", 1L));
  457 + assertFalse(oracleJsonContains("[-9223372036854775809,1]", 1L));
  458 + assertFalse(oracleJsonContains("[9999999999999999999,1]", 1L));
  459 + assertFalse(oracleJsonContains("[99999999999999999999,1]", 1L));
  460 + assertTrue(oracleJsonContains("[-9223372036854775808,1]", 1L));
  461 + assertTrue(oracleJsonContains("[9223372036854775807,1]", 1L));
  462 + assertTrue(oracleJsonContains("[1000000000000000000,1]", 1L));
  463 + // 长 CLOB 数组由 Oracle 实库验证;Java Pattern 的递归栈不代表 Oracle 的正则执行能力。
  464 + // H2 使用 Java 正则,其长数组边界由 testJsonArrayContains_h2LongArraySemantics 验证。
  465 + }
  466 +
  467 + /**
  468 + * H2 片段的匹配语义:H2 的 REGEXP_LIKE 使用 Java 正则,H2 专用的正则在长数组上不能栈溢出
  469 + *
  470 + * 注意:仅校验正则与匹配规则,不代替 H2 实库验收
  471 + */
  472 + @Test
  473 + public void testJsonArrayContains_h2LongArraySemantics() {
  474 + // 普通匹配、null 元素、空白
  475 + assertTrue(h2JsonContains("[1,11]", 1L));
  476 + assertFalse(h2JsonContains("[11,21]", 1L));
  477 + assertTrue(h2JsonContains("[2115311103682412546,2115311103682412547]", 2115311103682412547L));
  478 + assertTrue(h2JsonContains("[" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "]", Long.MIN_VALUE));
  479 + assertTrue(h2JsonContains(" [ 1 ,\t2 ,\r\n 3 ] ", 2L));
  480 + assertTrue(h2JsonContains("[1, null]", null));
  481 + assertFalse(h2JsonContains("[1]", null));
  482 + // 非数组、字符串、嵌套、小数、前导零、空元素、缺分隔符、超出 Long 范围:整体不匹配
  483 + assertFalse(h2JsonContains(null, 1L));
  484 + assertFalse(h2JsonContains("null", null));
  485 + assertFalse(h2JsonContains("[]", 1L));
  486 + assertFalse(h2JsonContains("[\"1\"]", 1L));
  487 + assertFalse(h2JsonContains("[\"x,1,y\"]", 1L));
  488 + assertFalse(h2JsonContains("[[1]]", 1L));
  489 + assertFalse(h2JsonContains("[1.0]", 1L));
  490 + assertFalse(h2JsonContains("[01]", 1L));
  491 + assertFalse(h2JsonContains("[1,,2]", 1L));
  492 + assertFalse(h2JsonContains("[1 2]", 1L));
  493 + assertFalse(h2JsonContains("[9223372036854775808,1]", 1L));
  494 + assertFalse(h2JsonContains("[-9223372036854775809,1]", 1L));
  495 + // 1400 个元素的长数组:占有量词不回溯、不递归,不能栈溢出;末尾非法元素整体不匹配
  496 + StringBuilder longArray = new StringBuilder("[0");
  497 + for (int i = 1; i < 1400; i++) {
  498 + longArray.append(i % 2 == 0 ? ",\n" : " , ").append(2115311103682412546L + i);
  499 + }
  500 + String longJson = longArray.append(']').toString();
  501 + assertTrue(h2JsonContains(longJson, 2115311103682412546L + 1399));
  502 + assertFalse(h2JsonContains(longJson, 2115311103682412546L + 1400));
  503 + assertFalse(h2JsonContains(longJson.replace("]", ",\"x\"]"), 0L));
  504 + }
  505 +
  506 + @Test
  507 + public void testGreaterDigitsPattern() {
  508 + assertEquals("(1[9-9]|[2-9][0-9]{1})", SqlDialectUtils.greaterDigitsPattern("18"));
  509 + Pattern pattern = Pattern.compile(SqlDialectUtils.greaterDigitsPattern("9223372036854775808"));
  510 + assertTrue(pattern.matcher("9223372036854775809").matches());
  511 + assertTrue(pattern.matcher("9223372036854775810").matches());
  512 + assertTrue(pattern.matcher("9300000000000000000").matches());
  513 + assertTrue(pattern.matcher("9999999999999999999").matches());
  514 + assertFalse(pattern.matcher("9223372036854775808").matches());
  515 + assertFalse(pattern.matcher("9223372036854775807").matches());
  516 + assertFalse(pattern.matcher("8999999999999999999").matches());
  517 + assertFalse(pattern.matcher("1000000000000000000").matches());
  518 + // Oracle 正则最长 512 字节;也不能包含会被 Wrapper 当作占位符的 {0}
  519 + assertTrue(SqlDialectUtils.LONG_OVERFLOW_PATTERN.length() < 512);
  520 + assertTrue(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN.length() < 512);
  521 + assertFalse(SqlDialectUtils.LONG_OVERFLOW_PATTERN.contains("{0}"));
  522 + assertFalse(SqlDialectUtils.H2_LONG_ARRAY_PATTERN.contains("{0}"));
  523 + }
  524 +
  525 + @Test
  526 + public void testResolveJsonDialect() throws SQLException {
  527 + // 瀚高原生驱动:仅 JSON 片段使用 PostgreSQL 写法,其它片段仍保持原有
  528 + assertEquals(Dialect.POSTGRESQL, resolveJson("HighGo", "jdbc:highgo://127.0.0.1:5866/highgo", 6));
  529 + assertEquals(Dialect.POSTGRESQL, resolveJson("PostgreSQL", "jdbc:highgo://127.0.0.1:5866/highgo", 6));
  530 + assertEquals(Dialect.ORIGINAL, resolve("PostgreSQL", "jdbc:highgo://127.0.0.1:5866/highgo", 6));
  531 + // 人大金仓(产品名 KingbaseES):仅 JSON 片段使用 PostgreSQL 写法,其它片段仍保持原有
  532 + assertEquals(Dialect.POSTGRESQL, resolveJson("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8));
  533 + assertEquals(Dialect.ORIGINAL, resolve("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8));
  534 + // H2:仅 JSON 片段使用严格的平面 Long 数组写法,其它片段仍保持原有
  535 + assertEquals(Dialect.H2, resolveJson("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2));
  536 + assertEquals(Dialect.ORIGINAL, resolve("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2));
  537 + // 其它 PostgreSQL 变种(含产品名为 PostgreSQL 的金仓 URL)、达梦、神通、MySQL:保持原有 JSON_CONTAINS
  538 + assertEquals(Dialect.ORIGINAL, resolveJson("PostgreSQL", "jdbc:opengauss://127.0.0.1:5432/test", 9));
  539 + assertEquals(Dialect.ORIGINAL, resolveJson("openGauss", "jdbc:opengauss://127.0.0.1:5432/test", 5));
  540 + assertEquals(Dialect.ORIGINAL, resolveJson("PostgreSQL", "jdbc:kingbase8://127.0.0.1:54321/test", 12));
  541 + assertEquals(Dialect.ORIGINAL, resolveJson("DM DBMS", "jdbc:dm://127.0.0.1:5236", 8));
  542 + assertEquals(Dialect.ORIGINAL, resolveJson("OSCAR", "jdbc:oscar://127.0.0.1:2003/osrdb", 7));
  543 + assertEquals(Dialect.ORIGINAL, resolveJson("MySQL", "jdbc:mysql://127.0.0.1:3306/highgo", 8));
  544 + assertEquals(Dialect.ORIGINAL, resolveJson("Microsoft SQL Server 2005", "jdbc:jtds:sqlserver://127.0.0.1:1433", 9));
  545 + // 其它与 resolveDialect 一致
  546 + assertEquals(Dialect.POSTGRESQL, resolveJson("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17));
  547 + assertEquals(Dialect.ORACLE, resolveJson("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21));
  548 + assertEquals(Dialect.SQLSERVER, resolveJson("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 11));
  549 + assertThrows(IllegalStateException.class, () -> resolveJson("SQLite", "jdbc:sqlite:/tmp/test.db", 3));
347 550 }
348 551  
349 552 @Test
... ... @@ -387,15 +590,20 @@ public class SqlDialectUtilsTest {
387 590 FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21);
388 591 FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16);
389 592 FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8);
390   - mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, "mysql", mysql));
  593 + FakeDataSource h2 = new FakeDataSource("H2", "jdbc:h2:mem:testdb;MODE=MYSQL", 2);
  594 + FakeDataSource kingbase = new FakeDataSource("KingbaseES", "jdbc:kingbase8://127.0.0.1:54321/test", 8);
  595 + mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer,
  596 + "mysql", mysql, "h2", h2, "kingbase", kingbase));
391 597 SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(),
392 598 "<script>SELECT id FROM pms_work_item ${ew.customSqlSegment}</script>", Map.class);
393   - // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL
  599 + // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL、H2、人大金仓
394 600 Map<String, Dialect> routes = new LinkedHashMap<>();
395 601 routes.put("master", Dialect.POSTGRESQL);
396 602 routes.put("oracle", Dialect.ORACLE);
397 603 routes.put("sqlserver", Dialect.SQLSERVER);
398 604 routes.put("mysql", Dialect.ORIGINAL);
  605 + routes.put("h2", Dialect.H2);
  606 + routes.put("kingbase", Dialect.POSTGRESQL);
399 607  
400 608 for (Map.Entry<String, Dialect> route : routes.entrySet()) {
401 609 DynamicDataSourceContextHolder.push(route.getKey());
... ... @@ -408,7 +616,7 @@ public class SqlDialectUtilsTest {
408 616 String expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "label_ids", true).replace("{0}", "?");
409 617 assertTrue(sql.startsWith("SELECT id FROM pms_work_item WHERE"), sql);
410 618 assertTrue(sql.contains(expected + " OR " + expected), sql);
411   - assertFalse(sql.contains("11"), sql); // 值只通过参数绑定,不拼接到 SQL 中
  619 + assertFalse(sql.replace(expected, "").contains("11"), sql); // 值只通过参数绑定,不拼接到 SQL 中
412 620 assertEquals(List.of(100L, 1L, 11L), getParameterValues(boundSql, labelWrapper));
413 621 assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql);
414 622  
... ... @@ -420,7 +628,7 @@ public class SqlDialectUtilsTest {
420 628 sql = normalize(boundSql.getSql());
421 629 expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "booking_user_ids", false).replace("{0}", "?");
422 630 assertTrue(sql.contains(expected), sql);
423   - assertFalse(sql.contains("1024"), sql);
  631 + assertFalse(sql.replace(expected, "").contains("1024"), sql);
424 632 assertEquals(List.of(1, "1024"), getParameterValues(boundSql, bookingWrapper));
425 633 assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql);
426 634 }
... ... @@ -816,6 +1024,43 @@ public class SqlDialectUtilsTest {
816 1024  
817 1025 // ========== 工具方法 ==========
818 1026  
  1027 + private static Dialect resolveJson(String productName, String url, int majorVersion) throws SQLException {
  1028 + return SqlDialectUtils.resolveJsonDialect(new FakeDataSource(productName, url, majorVersion).getMetaData());
  1029 + }
  1030 +
  1031 + /**
  1032 + * 在 Java 中复现 Oracle 片段:REPLACE 空白、REGEXP_LIKE 整体格式与溢出、去空白后 INSTR 匹配 ,元素,
  1033 + *
  1034 + * @param json 列值,null 表示 SQL NULL
  1035 + * @param value 绑定参数
  1036 + * @return 是否匹配
  1037 + */
  1038 + private static boolean oracleJsonContains(String json, Long value) {
  1039 + return jsonContains(SqlDialectUtils.ORACLE_LONG_ARRAY_PATTERN, json, value);
  1040 + }
  1041 +
  1042 + /**
  1043 + * 在 Java 中复现 H2 片段,规则同 {@link #oracleJsonContains(String, Long)},整体格式使用 H2 专用的正则
  1044 + */
  1045 + private static boolean h2JsonContains(String json, Long value) {
  1046 + return jsonContains(SqlDialectUtils.H2_LONG_ARRAY_PATTERN, json, value);
  1047 + }
  1048 +
  1049 + private static boolean jsonContains(String arrayPattern, String json, Long value) {
  1050 + if (json == null) {
  1051 + return false;
  1052 + }
  1053 + String text = json.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ');
  1054 + if (!Pattern.compile(arrayPattern).matcher(text).find()) {
  1055 + return false;
  1056 + }
  1057 + if (Pattern.compile(SqlDialectUtils.LONG_OVERFLOW_PATTERN).matcher(json).find()) {
  1058 + return false;
  1059 + }
  1060 + String elements = text.replace(" ", "").replace('[', ',').replace(']', ',');
  1061 + return elements.contains("," + (value == null ? "null" : value.toString()) + ",");
  1062 + }
  1063 +
819 1064 private static Dialect resolve(String productName, String url, int majorVersion) throws SQLException {
820 1065 return SqlDialectUtils.resolveDialect(new FakeDataSource(productName, url, majorVersion).getMetaData());
821 1066 }
... ...
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/RedisKeyConstants.java
... ... @@ -23,4 +23,14 @@ public interface RedisKeyConstants {
23 23 */
24 24 String INSURANCE_STANDARD_PROJECT = "hrm:insurance_standard_project";
25 25  
  26 + /**
  27 + * 月度社保表变更的分布式锁
  28 + *
  29 + * 全局唯一、不区分租户:首月创建、次月创建、删除互斥;各租户的数据仍按租户过滤
  30 + *
  31 + * KEY 格式:hrm:insurance_month_record_mutation_lock
  32 + * VALUE 数据格式:Redisson 分布式锁
  33 + */
  34 + String INSURANCE_MONTH_RECORD_MUTATION_LOCK = "hrm:insurance_month_record_mutation_lock";
  35 +
26 36 }
... ...
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/insurance/HrmInsuranceMonthRecordLockRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.hrm.dal.redis.insurance;
  2 +
  3 +import jakarta.annotation.Resource;
  4 +import lombok.extern.slf4j.Slf4j;
  5 +import org.redisson.api.RLock;
  6 +import org.redisson.api.RedissonClient;
  7 +import org.springframework.stereotype.Repository;
  8 +import org.springframework.transaction.support.TransactionSynchronization;
  9 +import org.springframework.transaction.support.TransactionSynchronizationManager;
  10 +
  11 +import java.util.concurrent.TimeUnit;
  12 +import java.util.function.Supplier;
  13 +
  14 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.LOCKED;
  15 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  16 +import static cn.iocoder.yudao.module.hrm.dal.redis.RedisKeyConstants.INSURANCE_MONTH_RECORD_MUTATION_LOCK;
  17 +
  18 +/**
  19 + * 月度社保表变更的锁 Redis DAO
  20 + *
  21 + * 首月创建、次月创建、删除共用一把锁,避免没有记录可锁定时并发创建出多个首月社保表。
  22 + * 不设置租期,由 Redisson 看门狗续期;锁在事务提交或回滚后释放,保证下一个获得锁的请求能读到已提交的数据
  23 + *
  24 + * @author 芋道源码
  25 + */
  26 +@Repository
  27 +@Slf4j
  28 +public class HrmInsuranceMonthRecordLockRedisDAO {
  29 +
  30 + /**
  31 + * 等待获得锁的最长时间,单位:秒
  32 + */
  33 + static final long LOCK_WAIT_SECONDS = 10;
  34 +
  35 + @Resource
  36 + private RedissonClient redissonClient;
  37 +
  38 + /**
  39 + * 获得锁并执行逻辑,锁在当前事务完成(提交或回滚)后释放
  40 + *
  41 + * 注意:需要在事务中调用;等待超时、或等待时线程被中断,抛出 {@code LOCKED} 业务异常
  42 + *
  43 + * @param supplier 加锁执行的逻辑
  44 + * @return 执行结果
  45 + */
  46 + public <V> V lock(Supplier<V> supplier) {
  47 + // 1. 校验事务:存在实际事务、且事务同步已激活,才能在事务完成后释放锁
  48 + if (!TransactionSynchronizationManager.isActualTransactionActive()
  49 + || !TransactionSynchronizationManager.isSynchronizationActive()) {
  50 + throw new IllegalStateException("月度社保表变更锁需要在事务中获得");
  51 + }
  52 +
  53 + // 2. 获得锁
  54 + RLock lock = redissonClient.getLock(INSURANCE_MONTH_RECORD_MUTATION_LOCK);
  55 + boolean locked;
  56 + try {
  57 + locked = lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
  58 + } catch (InterruptedException e) {
  59 + Thread.currentThread().interrupt();
  60 + throw exception(LOCKED);
  61 + }
  62 + if (!locked) {
  63 + throw exception(LOCKED);
  64 + }
  65 +
  66 + // 3. 事务完成后释放;注册失败时立即释放
  67 + try {
  68 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  69 +
  70 + @Override
  71 + public void afterCompletion(int status) {
  72 + unlock(lock);
  73 + }
  74 +
  75 + });
  76 + } catch (RuntimeException e) {
  77 + unlock(lock);
  78 + throw e;
  79 + }
  80 +
  81 + // 4. 执行逻辑;抛出异常时,由事务回滚后释放锁
  82 + return supplier.get();
  83 + }
  84 +
  85 + /**
  86 + * 获得锁并执行逻辑,见 {@link #lock(Supplier)}
  87 + *
  88 + * @param runnable 加锁执行的逻辑
  89 + */
  90 + public void lock(Runnable runnable) {
  91 + lock(() -> {
  92 + runnable.run();
  93 + return null;
  94 + });
  95 + }
  96 +
  97 + /**
  98 + * 释放锁;失败时仅打印日志,不影响事务的结果
  99 + *
  100 + * @param lock 锁
  101 + */
  102 + private static void unlock(RLock lock) {
  103 + try {
  104 + lock.unlock();
  105 + } catch (RuntimeException e) {
  106 + log.error("[unlock][月度社保表变更锁({}) 释放失败]", INSURANCE_MONTH_RECORD_MUTATION_LOCK, e);
  107 + }
  108 + }
  109 +
  110 +}
... ...
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
6 6 import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthEmployeeRecordDO;
7 7 import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthRecordDO;
8 8 import cn.iocoder.yudao.module.hrm.dal.mysql.insurance.monthrecord.HrmInsuranceMonthRecordMapper;
  9 +import cn.iocoder.yudao.module.hrm.dal.redis.insurance.HrmInsuranceMonthRecordLockRedisDAO;
9 10 import cn.iocoder.yudao.module.hrm.enums.insurance.employee.HrmInsuranceEmployeeStatusEnum;
10 11 import cn.iocoder.yudao.module.hrm.enums.insurance.monthrecord.HrmInsuranceMonthStatusEnum;
11 12 import com.mzt.logapi.context.LogRecordContext;
... ... @@ -50,24 +51,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco
50 51 @Resource
51 52 private HrmInsuranceMonthEmployeeRecordService monthEmployeeRecordService;
52 53  
  54 + @Resource
  55 + private HrmInsuranceMonthRecordLockRedisDAO monthRecordLockRedisDAO;
  56 +
53 57 @Override
54 58 @Transactional(rollbackFor = Exception.class)
55 59 @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_CREATE_SUB_TYPE,
56 60 bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_CREATE_SUCCESS)
57 61 public Long createFirstMonthRecord(HrmInsuranceMonthRecordCreateReqVO reqVO) {
58   - // 1. 校验尚未生成社保表
59   - if (monthRecordMapper.selectLastForUpdate() != null) {
60   - throw exception(INSURANCE_FIRST_MONTH_RECORD_EXISTS);
61   - }
  62 + // 加锁:尚无社保表时没有可锁定的记录,避免并发创建多个首月社保表;锁在事务完成后释放
  63 + return monthRecordLockRedisDAO.lock(() -> {
  64 + // 1. 校验尚未生成社保表
  65 + if (monthRecordMapper.selectLastForUpdate() != null) {
  66 + throw exception(INSURANCE_FIRST_MONTH_RECORD_EXISTS);
  67 + }
62 68  
63   - // 2.1 创建首月社保表
64   - HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(reqVO);
65   - // 2.2 生成员工月记录并更新汇总
66   - computeMonthRecord(monthRecord);
  69 + // 2.1 创建首月社保表
  70 + HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(reqVO);
  71 + // 2.2 生成员工月记录并更新汇总
  72 + computeMonthRecord(monthRecord);
67 73  
68   - // 3. 记录操作日志上下文
69   - LogRecordContext.putVariable("monthRecord", monthRecord);
70   - return monthRecord.getId();
  74 + // 3. 记录操作日志上下文
  75 + LogRecordContext.putVariable("monthRecord", monthRecord);
  76 + return monthRecord.getId();
  77 + });
71 78 }
72 79  
73 80 @Override
... ... @@ -75,27 +82,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco
75 82 @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_CREATE_SUB_TYPE,
76 83 bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_CREATE_SUCCESS)
77 84 public Long createNextMonthRecord() {
78   - // 1. 校验最近月度社保表
79   - HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLastForUpdate();
80   - if (lastMonthRecord == null) {
81   - throw exception(INSURANCE_MONTH_RECORD_NOT_EXISTS);
82   - }
83   -
84   - // 2. 归档最近月度社保表
85   - if (Objects.equals(lastMonthRecord.getStatus(), HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())) {
86   - archiveMonthRecord(lastMonthRecord);
87   - }
  85 + // 加锁:与首月创建、删除互斥;锁在事务完成后释放
  86 + return monthRecordLockRedisDAO.lock(() -> {
  87 + // 1. 校验最近月度社保表
  88 + HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLastForUpdate();
  89 + if (lastMonthRecord == null) {
  90 + throw exception(INSURANCE_MONTH_RECORD_NOT_EXISTS);
  91 + }
88 92  
89   - // 3.1 创建次月社保表
90   - YearMonth nextMonth = YearMonth.of(lastMonthRecord.getYear(), lastMonthRecord.getMonth()).plusMonths(1);
91   - HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(new HrmInsuranceMonthRecordCreateReqVO()
92   - .setYear(nextMonth.getYear()).setMonth(nextMonth.getMonthValue()));
93   - // 3.2 生成员工月记录并更新汇总
94   - computeMonthRecord(monthRecord);
  93 + // 2. 归档最近月度社保表
  94 + if (Objects.equals(lastMonthRecord.getStatus(), HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus())) {
  95 + archiveMonthRecord(lastMonthRecord);
  96 + }
95 97  
96   - // 4. 记录操作日志上下文
97   - LogRecordContext.putVariable("monthRecord", monthRecord);
98   - return monthRecord.getId();
  98 + // 3.1 创建次月社保表
  99 + YearMonth nextMonth = YearMonth.of(lastMonthRecord.getYear(), lastMonthRecord.getMonth()).plusMonths(1);
  100 + HrmInsuranceMonthRecordDO monthRecord = createMonthRecord(new HrmInsuranceMonthRecordCreateReqVO()
  101 + .setYear(nextMonth.getYear()).setMonth(nextMonth.getMonthValue()));
  102 + // 3.2 生成员工月记录并更新汇总
  103 + computeMonthRecord(monthRecord);
  104 +
  105 + // 4. 记录操作日志上下文
  106 + LogRecordContext.putVariable("monthRecord", monthRecord);
  107 + return monthRecord.getId();
  108 + });
99 109 }
100 110  
101 111 @Override
... ... @@ -103,27 +113,30 @@ public class HrmInsuranceMonthRecordServiceImpl implements HrmInsuranceMonthReco
103 113 @LogRecord(type = HRM_INSURANCE_MONTH_TYPE, subType = HRM_INSURANCE_MONTH_DELETE_SUB_TYPE,
104 114 bizNo = "{{#monthRecord.id}}", success = HRM_INSURANCE_MONTH_DELETE_SUCCESS)
105 115 public void deleteMonthRecord(Long id) {
106   - // 1. 校验仅删除最新社保表,并至少保留一张社保表
107   - HrmInsuranceMonthRecordDO monthRecord = validateMonthRecordEditableForUpdate(id);
108   - HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLast();
109   - if (lastMonthRecord == null || ObjectUtil.notEqual(lastMonthRecord.getId(), monthRecord.getId())) {
110   - throw exception(INSURANCE_MONTH_RECORD_NOT_LATEST);
111   - }
112   - if (monthRecordMapper.selectCount() <= 1) {
113   - throw exception(INSURANCE_MONTH_RECORD_CANNOT_DELETE_ONLY);
114   - }
115   -
116   - // 2.1 删除当前月员工记录和月表
117   - monthEmployeeRecordService.deleteMonthEmployeeRecordListByMonthRecordId(id);
118   - monthRecordMapper.deleteById(id);
119   - // 2.2 将上一月调整为可编辑状态
120   - HrmInsuranceMonthRecordDO previousMonthRecord = monthRecordMapper.selectLast();
121   - monthRecordMapper.updateById(new HrmInsuranceMonthRecordDO()
122   - .setId(previousMonthRecord.getId())
123   - .setStatus(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus()));
  116 + // 加锁:与首月创建、次月创建互斥;锁在事务完成后释放
  117 + monthRecordLockRedisDAO.lock(() -> {
  118 + // 1. 校验仅删除最新社保表,并至少保留一张社保表
  119 + HrmInsuranceMonthRecordDO monthRecord = validateMonthRecordEditableForUpdate(id);
  120 + HrmInsuranceMonthRecordDO lastMonthRecord = monthRecordMapper.selectLast();
  121 + if (lastMonthRecord == null || ObjectUtil.notEqual(lastMonthRecord.getId(), monthRecord.getId())) {
  122 + throw exception(INSURANCE_MONTH_RECORD_NOT_LATEST);
  123 + }
  124 + if (monthRecordMapper.selectCount() <= 1) {
  125 + throw exception(INSURANCE_MONTH_RECORD_CANNOT_DELETE_ONLY);
  126 + }
124 127  
125   - // 3. 记录操作日志上下文
126   - LogRecordContext.putVariable("monthRecord", monthRecord);
  128 + // 2.1 删除当前月员工记录和月表
  129 + monthEmployeeRecordService.deleteMonthEmployeeRecordListByMonthRecordId(id);
  130 + monthRecordMapper.deleteById(id);
  131 + // 2.2 将上一月调整为可编辑状态
  132 + HrmInsuranceMonthRecordDO previousMonthRecord = monthRecordMapper.selectLast();
  133 + monthRecordMapper.updateById(new HrmInsuranceMonthRecordDO()
  134 + .setId(previousMonthRecord.getId())
  135 + .setStatus(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus()));
  136 +
  137 + // 3. 记录操作日志上下文
  138 + LogRecordContext.putVariable("monthRecord", monthRecord);
  139 + });
127 140 }
128 141  
129 142 @Override
... ...
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
5 5 import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthEmployeeRecordDO;
6 6 import cn.iocoder.yudao.module.hrm.dal.dataobject.insurance.monthrecord.HrmInsuranceMonthRecordDO;
7 7 import cn.iocoder.yudao.module.hrm.dal.mysql.insurance.monthrecord.HrmInsuranceMonthRecordMapper;
  8 +import cn.iocoder.yudao.module.hrm.dal.redis.insurance.HrmInsuranceMonthRecordLockRedisDAO;
8 9 import cn.iocoder.yudao.module.hrm.enums.insurance.employee.HrmInsuranceEmployeeStatusEnum;
9 10 import cn.iocoder.yudao.module.hrm.enums.insurance.monthrecord.HrmInsuranceMonthStatusEnum;
10 11 import jakarta.annotation.Resource;
  12 +import org.junit.jupiter.api.BeforeEach;
11 13 import org.junit.jupiter.api.Test;
  14 +import org.mockito.InOrder;
12 15 import org.springframework.context.annotation.Import;
13 16 import org.springframework.test.context.bean.override.mockito.MockitoBean;
14 17  
15 18 import java.math.BigDecimal;
16 19 import java.util.Collections;
  20 +import java.util.function.Supplier;
17 21  
  22 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.LOCKED;
  23 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
18 24 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
19 25 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
20 26 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;
25 31 import static org.junit.jupiter.api.Assertions.assertNull;
26 32 import static org.mockito.ArgumentMatchers.any;
27 33 import static org.mockito.ArgumentMatchers.anyLong;
  34 +import static org.mockito.Mockito.doAnswer;
  35 +import static org.mockito.Mockito.doThrow;
  36 +import static org.mockito.Mockito.inOrder;
  37 +import static org.mockito.Mockito.never;
28 38 import static org.mockito.Mockito.verify;
29 39 import static org.mockito.Mockito.when;
30 40  
... ... @@ -44,6 +54,19 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest {
44 54  
45 55 @MockitoBean
46 56 private HrmInsuranceMonthEmployeeRecordService monthEmployeeRecordService;
  57 + @MockitoBean
  58 + private HrmInsuranceMonthRecordLockRedisDAO monthRecordLockRedisDAO;
  59 +
  60 + @BeforeEach
  61 + public void setUp() {
  62 + // mock 方法:获得锁后执行逻辑
  63 + when(monthRecordLockRedisDAO.lock(any(Supplier.class)))
  64 + .thenAnswer(invocation -> invocation.<Supplier<?>>getArgument(0).get());
  65 + doAnswer(invocation -> {
  66 + invocation.<Runnable>getArgument(0).run();
  67 + return null;
  68 + }).when(monthRecordLockRedisDAO).lock(any(Runnable.class));
  69 + }
47 70  
48 71 @Test
49 72 public void testCreateFirstMonthRecord_success() {
... ... @@ -63,7 +86,60 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest {
63 86 assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(), monthRecord.getStatus());
64 87 assertEquals(0, monthRecord.getInsuredEmployeeCount());
65 88 assertEquals(0, BigDecimal.ZERO.compareTo(monthRecord.getPersonalInsuranceAmount()));
66   - verify(monthEmployeeRecordService).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class));
  89 + InOrder inOrder = inOrder(monthRecordLockRedisDAO, monthEmployeeRecordService);
  90 + inOrder.verify(monthRecordLockRedisDAO).lock(any(Supplier.class));
  91 + inOrder.verify(monthEmployeeRecordService).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class));
  92 + }
  93 +
  94 + @Test
  95 + public void testCreateFirstMonthRecord_locked() {
  96 + // mock 方法:其它请求持有变更锁
  97 + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Supplier.class));
  98 + // 准备参数
  99 + HrmInsuranceMonthRecordCreateReqVO reqVO =
  100 + new HrmInsuranceMonthRecordCreateReqVO().setYear(2026).setMonth(7);
  101 +
  102 + // 调用,并断言异常
  103 + assertServiceException(() -> monthRecordService.createFirstMonthRecord(reqVO), LOCKED);
  104 + // 断言:未创建社保表
  105 + assertEquals(0L, monthRecordMapper.selectCount());
  106 + verify(monthEmployeeRecordService, never()).createMonthEmployeeRecordList(any(HrmInsuranceMonthRecordDO.class));
  107 + }
  108 +
  109 + @Test
  110 + public void testCreateNextMonthRecord_locked() {
  111 + // mock 数据
  112 + HrmInsuranceMonthRecordDO currentMonthRecord = randomMonthRecord(2026, 6,
  113 + HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus());
  114 + monthRecordMapper.insert(currentMonthRecord);
  115 + // mock 方法:其它请求持有变更锁
  116 + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Supplier.class));
  117 +
  118 + // 调用,并断言异常
  119 + assertServiceException(() -> monthRecordService.createNextMonthRecord(), LOCKED);
  120 + // 断言:未归档、未创建次月
  121 + assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(),
  122 + monthRecordMapper.selectById(currentMonthRecord.getId()).getStatus());
  123 + assertEquals(1L, monthRecordMapper.selectCount());
  124 + }
  125 +
  126 + @Test
  127 + public void testDeleteMonthRecord_locked() {
  128 + // mock 数据
  129 + HrmInsuranceMonthRecordDO previousMonthRecord = randomMonthRecord(2026, 6,
  130 + HrmInsuranceMonthStatusEnum.ARCHIVED.getStatus());
  131 + monthRecordMapper.insert(previousMonthRecord);
  132 + HrmInsuranceMonthRecordDO currentMonthRecord = randomMonthRecord(2026, 7,
  133 + HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus());
  134 + monthRecordMapper.insert(currentMonthRecord);
  135 + // mock 方法:其它请求持有变更锁
  136 + doThrow(exception(LOCKED)).when(monthRecordLockRedisDAO).lock(any(Runnable.class));
  137 +
  138 + // 调用,并断言异常
  139 + assertServiceException(() -> monthRecordService.deleteMonthRecord(currentMonthRecord.getId()), LOCKED);
  140 + // 断言:未删除
  141 + assertNotNull(monthRecordMapper.selectById(currentMonthRecord.getId()));
  142 + verify(monthEmployeeRecordService, never()).deleteMonthEmployeeRecordListByMonthRecordId(anyLong());
67 143 }
68 144  
69 145 @Test
... ... @@ -123,6 +199,7 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest {
123 199 HrmInsuranceMonthRecordDO nextMonthRecord = monthRecordMapper.selectById(nextMonthRecordId);
124 200 assertEquals(2027, nextMonthRecord.getYear());
125 201 assertEquals(1, nextMonthRecord.getMonth());
  202 + verify(monthRecordLockRedisDAO).lock(any(Supplier.class));
126 203 }
127 204  
128 205 @Test
... ... @@ -142,7 +219,9 @@ public class HrmInsuranceMonthRecordServiceImplTest extends BaseDbUnitTest {
142 219 assertNull(monthRecordMapper.selectById(currentMonthRecord.getId()));
143 220 assertEquals(HrmInsuranceMonthStatusEnum.UNARCHIVED.getStatus(),
144 221 monthRecordMapper.selectById(previousMonthRecord.getId()).getStatus());
145   - verify(monthEmployeeRecordService)
  222 + InOrder inOrder = inOrder(monthRecordLockRedisDAO, monthEmployeeRecordService);
  223 + inOrder.verify(monthRecordLockRedisDAO).lock(any(Runnable.class));
  224 + inOrder.verify(monthEmployeeRecordService)
146 225 .deleteMonthEmployeeRecordListByMonthRecordId(currentMonthRecord.getId());
147 226 }
148 227  
... ...
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;
16 16 public interface ImGroupMapper extends BaseMapperX<ImGroupDO> {
17 17  
18 18 default ImGroupDO selectByIdForUpdate(Long id) {
19   - return selectOne(new LambdaQueryWrapperX<ImGroupDO>()
20   - .eq(ImGroupDO::getId, id)
21   - .last("FOR UPDATE"));
  19 + return selectOneForUpdate(ImGroupDO::getId, id);
22 20 }
23 21  
24 22 default PageResult<ImGroupDO> selectPage(ImGroupManagerPageReqVO reqVO) {
... ...
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&lt;OaFileNodeDO&gt; {
28 28 }
29 29  
30 30 default List<OaFileNodeDO> selectListByCreatorAndTypeForUpdate(String creator, Integer type) {
31   - return selectList(new LambdaQueryWrapperX<OaFileNodeDO>().eq(OaFileNodeDO::getCreator, creator)
32   - .eq(OaFileNodeDO::getType, type).orderByAsc(OaFileNodeDO::getId).last("FOR UPDATE"));
  31 + return selectListForUpdate(new LambdaQueryWrapperX<OaFileNodeDO>().eq(OaFileNodeDO::getCreator, creator)
  32 + .eq(OaFileNodeDO::getType, type).orderByAsc(OaFileNodeDO::getId));
33 33 }
34 34  
35 35 default List<OaFileNodeDO> selectListByCreatorAndStatus(String creator, Integer status) {
... ...
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&lt;OaMailAccountDO&gt; {
32 32 }
33 33  
34 34 default List<OaMailAccountDO> selectListByCreatorForUpdate(String creator) {
35   - return selectList(new LambdaQueryWrapperX<OaMailAccountDO>().eq(OaMailAccountDO::getCreator, creator)
36   - .orderByAsc(OaMailAccountDO::getId).last("FOR UPDATE"));
  35 + return selectListForUpdate(new LambdaQueryWrapperX<OaMailAccountDO>().eq(OaMailAccountDO::getCreator, creator)
  36 + .orderByAsc(OaMailAccountDO::getId));
37 37 }
38 38  
39 39 default Long selectCountByProviderId(Long providerId) {
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java
... ... @@ -250,11 +250,11 @@ public class PayWalletServiceTest extends BaseDbAndRedisUnitTest {
250 250 mockRefundAndWalletTransaction();
251 251 List<Integer> declaredIsolationLevels = new ArrayList<>();
252 252 List<Integer> connectionIsolationLevels = new ArrayList<>();
253   - when(refundService.getRefundForUpdate(any())).thenAnswer(invocation -> {
  253 + doAnswer(invocation -> {
254 254 declaredIsolationLevels.add(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel());
255 255 connectionIsolationLevels.add(DataSourceUtils.getConnection(dataSource).getTransactionIsolation());
256 256 return refundMapper.selectOneForUpdate(PayRefundDO::getId, invocation.getArgument(0));
257   - });
  257 + }).when(refundService).getRefundForUpdate(any());
258 258  
259 259 // 调用:不存在调用方事务,新建事务退款到钱包
260 260 PayWalletTransactionDO transaction = walletService.orderRefund(refund.getNo(), 30, "测试退款");
... ...
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&lt;PmsWorkItemDO&gt; {
334 334 queryWrapper.and(wrapper -> wrapper.notIn(PmsWorkItemDO::getIterationId,
335 335 pageReqVO.getExcludedIterationIds()).or().isNull(PmsWorkItemDO::getIterationId));
336 336 }
337   - // label_ids 为 JSON 数组列,匹配任一标签;PostgreSQL 使用 jsonb 包含,其它数据库保持 JSON_CONTAINS
  337 + // label_ids 为 JSON 数组列,匹配任一标签;数据库差异由公共方言工具处理
338 338 if (CollUtil.isNotEmpty(pageReqVO.getLabelIds())) {
339 339 String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true);
340 340 queryWrapper.and(labelWrapper -> {
... ...
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&lt;WmsInventoryDO&gt; {
66 66 }
67 67  
68 68 default WmsInventoryDO selectByIdForUpdate(Long id) {
69   - return selectOne(new LambdaQueryWrapperX<WmsInventoryDO>()
70   - .eq(WmsInventoryDO::getId, id)
71   - .last("FOR UPDATE"));
  69 + return selectOneForUpdate(WmsInventoryDO::getId, id);
72 70 }
73 71  
74 72 /**
... ... @@ -96,12 +94,7 @@ public interface WmsInventoryMapper extends BaseMapperX&lt;WmsInventoryDO&gt; {
96 94 }
97 95  
98 96 default List<WmsInventoryDO> selectListByIdsForUpdate(Collection<Long> ids) {
99   - if (CollUtil.isEmpty(ids)) {
100   - return Collections.emptyList();
101   - }
102   - return selectList(new LambdaQueryWrapperX<WmsInventoryDO>()
103   - .in(WmsInventoryDO::getId, ids)
104   - .last("FOR UPDATE"));
  97 + return selectListForUpdate(WmsInventoryDO::getId, ids);
105 98 }
106 99  
107 100 static void appendDimensionOrder(MPJLambdaWrapperX<WmsInventoryDO> query, String type) {
... ...