Commit 9b12e80291b69b301f7c6a03605dc50afd6eb894

Authored by YunaiV
1 parent 8e4d1e70

fix: 适配 IoT 外部数据库时间函数并整理 SQL 工具

yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/injector/SelectForUpdateMethod.java
... ... @@ -47,14 +47,17 @@ public class SelectForUpdateMethod extends AbstractMethod {
47 47  
48 48 @Override
49 49 public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
50   - // 没有主键的表,无法查询首行主键
  50 + // 1. 没有主键的表,无法查询首行主键
51 51 if (kind == Kind.FIRST_IDS && !tableInfo.havePK()) {
52 52 return null;
53 53 }
  54 + // 2. 拼接 SQL:查询列 + 条件 + 排序与锁定;首行主键查询只查询主键列
54 55 String columns = kind == Kind.FIRST_IDS ? tableInfo.getKeyColumn() : sqlSelectColumns(tableInfo, true);
55 56 String sql = "<script>" + sqlFirst() + " SELECT " + columns + " FROM " + tableInfo.getTableName()
56 57 + NEWLINE + sqlWhere(tableInfo) + NEWLINE + sqlTail(tableInfo) + sqlComment() + NEWLINE + "</script>";
  58 + // 3. 使用 LockSqlSource 标记为锁定读的内置查询,供 ForUpdateInnerInterceptor 识别
57 59 SqlSource sqlSource = new LockSqlSource(kind, super.createSqlSource(configuration, sql, modelClass));
  60 + // 4. 注册 MappedStatement:首行主键查询的结果为主键类型,其它为实体(沿用表的 resultMap)
58 61 if (kind == Kind.FIRST_IDS) {
59 62 return addSelectMappedStatementForOther(mapperClass, methodName, sqlSource, tableInfo.getKeyType());
60 63 }
... ... @@ -68,16 +71,21 @@ public class SelectForUpdateMethod extends AbstractMethod {
68 71 * @return WHERE 脚本
69 72 */
70 73 private String sqlWhere(TableInfo tableInfo) {
  74 + // 实体条件:Wrapper 设置了 entity 时生效
71 75 String entityWhere = SqlScriptUtils.convertIf(tableInfo.getAllSqlWhere(true, true, true, WRAPPER_ENTITY_DOT),
72 76 WRAPPER_ENTITY + " != null", true);
  77 + // 普通条件:整体加括号,避免 OR 条件绕过后面的主键、逻辑删除条件
73 78 String normalWhere = SqlScriptUtils.convertIf(" AND (${" + WRAPPER_DOT + "expression.normal.sqlSegment})",
74 79 WRAPPER_DOT + "nonEmptyOfNormal", true);
  80 + // 主键:仅加锁查询、且表有主键时,按传入的主键限制,用于 Oracle 的首行锁定
75 81 String idWhere = kind == Kind.LOCK && tableInfo.havePK() ? NEWLINE + SqlScriptUtils.convertIf(
76 82 " AND " + tableInfo.getKeyColumn() + " = " + SqlScriptUtils.safeParam(PARAM_ID), PARAM_ID + " != null", true) : "";
  83 + // 拼接逻辑删除条件,由 <where> 去除开头多余的 AND
77 84 return SqlScriptUtils.convertWhere(entityWhere + NEWLINE + normalWhere + idWhere
78 85 + NEWLINE + tableInfo.getLogicDeleteSql(true, true));
79 86 }
80 87  
  88 + // DONE @AI:方法内 注释;(处理:已按主键查询、加锁查询两种情况补充注释)
81 89 /**
82 90 * 排序与锁定:
83 91 * 1. 加锁查询:Wrapper 的排序(未设置时不排序,避免改变数据库的加锁范围) + FOR UPDATE;首行再加 LIMIT 1
... ... @@ -88,19 +96,23 @@ public class SelectForUpdateMethod extends AbstractMethod {
88 96 */
89 97 private String sqlTail(TableInfo tableInfo) {
90 98 String orderBy = " ${" + WRAPPER_DOT + "expression.orderBy.sqlSegment}";
  99 + // 情况一:首行主键查询,不加锁;未设置排序时按主键排序
91 100 if (kind == Kind.FIRST_IDS) {
92 101 return SqlScriptUtils.convertChoose(WRAPPER_DOT + "expression.orderBy.isEmpty()",
93 102 " ORDER BY " + tableInfo.getKeyColumn(), orderBy);
94 103 }
  104 + // 情况二:加锁查询,只使用 Wrapper 的排序,末尾为 FOR UPDATE;首行为 LIMIT 1 FOR UPDATE
95 105 return SqlScriptUtils.convertIf(orderBy, "!" + WRAPPER_DOT + "expression.orderBy.isEmpty()", true)
96 106 + NEWLINE + (kind == Kind.LOCK_FIRST ? "LIMIT 1 FOR UPDATE" : "FOR UPDATE");
97 107 }
98 108  
  109 + // DONE @AI:方法内 注释;(处理:已补充同名 MappedStatement 跳过注入的注释,保留 flushCache、useCache 的说明)
99 110 @Override
100 111 protected MappedStatement addMappedStatement(Class<?> mapperClass, String id, SqlSource sqlSource,
101 112 SqlCommandType sqlCommandType, Class<?> parameterType,
102 113 String resultMap, Class<?> resultType, KeyGenerator keyGenerator,
103 114 String keyProperty, String keyColumn) {
  115 + // 已通过 XML、注解等定义同名的 MappedStatement 时,不重复注入
104 116 String statementName = mapperClass.getName() + DOT + id;
105 117 if (configuration.hasStatement(statementName, false)) {
106 118 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);
... ... @@ -145,6 +157,7 @@ public class SelectForUpdateMethod extends AbstractMethod {
145 157 *
146 158 * MyBatis Plus Join 等插件复制 MappedStatement 时保留同一个 SqlSource,标记仍然有效
147 159 */
  160 + @SuppressWarnings("ClassCanBeRecord")
148 161 @Getter
149 162 @AllArgsConstructor
150 163 public static class LockSqlSource implements SqlSource {
... ...
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.hutool.core.util.ObjectUtil;
4 5 import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
5 6 import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
6 7 import cn.iocoder.yudao.framework.common.pojo.PageParam;
... ... @@ -151,10 +152,6 @@ public interface BaseMapperX&lt;T&gt; extends MPJBaseMapper&lt;T&gt; {
151 152 return selectOne(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2).eq(field3, value3));
152 153 }
153 154  
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 155 // ========== 锁定读:需要在事务中调用;Wrapper 不能使用 last、groupBy、having、参数别名 ==========
159 156  
160 157 /**
... ... @@ -227,31 +224,40 @@ public interface BaseMapperX&lt;T&gt; extends MPJBaseMapper&lt;T&gt; {
227 224 * @param queryWrapper 查询条件
228 225 * @return 实体
229 226 */
  227 + // DONE @AI:方法内注释噢;(处理:已按“情况一 非 Oracle 单条锁定、情况二 Oracle 2.1~2.3 候选、加锁、重读”补充步骤注释)
230 228 default T selectFirstOneForUpdate(LambdaQueryWrapper<T> queryWrapper) {
  229 + // 情况一:非 Oracle,单条 LIMIT 1 FOR UPDATE 锁定首行(SQL Server 由 ForUpdateInnerInterceptor 改写)
231 230 DbType dbType = JdbcUtils.getDbType();
232   - if (!ObjectUtils.equalsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) {
  231 + // DONE @AI:ObjectUtils notequals any;(处理:已改为 ObjectUtils.notEqualsAny,dbType 为 null 时与原取反写法一致,均走单条锁定)
  232 + if (ObjectUtils.notEqualsAny(dbType, DbType.ORACLE, DbType.ORACLE_12C)) {
233 233 return CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK_FIRST, queryWrapper, null));
234 234 }
  235 +
  236 + // 情况二:Oracle,按“查询候选主键、按主键加锁、重新读取首行主键”重试,最多 5 次
235 237 int maxAttempts = 5;
236 238 for (int i = 0; i < maxAttempts; i++) {
237   - List<Object> ids = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null);
238   - if (ids.isEmpty()) {
  239 + // DONE @AI:getFirst 是不是可以简化下;看看其它地方,是不是也有地方;(处理:候选、重读主键与返回的锁定行均改为 CollUtil.getFirst;是否锁定到候选仍按列表是否为空判断)
  240 + // 2.1 查询首行主键作为候选,不加锁;主键不为 null,取不到时即没有满足条件的记录
  241 + Object id = CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null));
  242 + if (id == null) {
239 243 return null;
240 244 }
241   - Object id = ids.get(0);
242   - // 是否锁定到候选,以列表是否为空判断:查询列均为 NULL 时,MyBatis 返回的元素为 null
  245 + // 2.2 按原条件、且主键等于候选加锁;列表为空说明候选已不满足条件,重试
  246 + // 注意:以列表是否为空判断是否锁定到候选,不能以元素判断:查询列均为 NULL 时,MyBatis 返回的元素为 null
243 247 List<T> lockRows = selectListByForUpdateMethod(SelectForUpdateMethod.Kind.LOCK, queryWrapper, id);
244   - if (lockRows.isEmpty()) {
  248 + if (CollUtil.isEmpty(lockRows)) {
245 249 continue;
246 250 }
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);
  251 + // 2.3 锁定后重新读取首行主键,与候选相同才返回锁定的记录(查询列均为 NULL 时为 null);不同时重试
  252 + Object latestId = CollUtil.getFirst(selectListByForUpdateMethod(SelectForUpdateMethod.Kind.FIRST_IDS, queryWrapper, null));
  253 + if (Objects.equals(id, latestId)) {
  254 + return CollUtil.getFirst(lockRows);
250 255 }
251 256 }
252 257 throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.LOCKED);
253 258 }
254 259  
  260 + // DONE @AI:方法内注释噢;(处理:已补充校验、构建参数、执行内置查询三步注释)
255 261 /**
256 262 * 执行锁定读的内置查询,见 {@link SelectForUpdateMethod}
257 263 *
... ... @@ -262,18 +268,23 @@ public interface BaseMapperX&lt;T&gt; extends MPJBaseMapper&lt;T&gt; {
262 268 */
263 269 private <E> List<E> selectListByForUpdateMethod(SelectForUpdateMethod.Kind kind, LambdaQueryWrapper<T> queryWrapper,
264 270 Object id) {
  271 + // 1. 校验查询条件
265 272 checkForUpdateWrapper(queryWrapper);
  273 + // 2. 构建参数:查询条件、主键;首行主键查询由分页插件限制为 1 条,不查询总数
266 274 MapperMethod.ParamMap<Object> param = new MapperMethod.ParamMap<>();
267 275 param.put(Constants.WRAPPER, queryWrapper);
268 276 param.put(SelectForUpdateMethod.PARAM_ID, id);
269 277 if (kind == SelectForUpdateMethod.Kind.FIRST_IDS) {
270 278 param.put("page", MyBatisUtils.buildPage(0, 1, false));
271 279 }
  280 + // 3. 通过 SqlSession 执行内置查询:未在 Mapper 接口声明,MappedStatement 编号为 Mapper 接口名 + 方法名
272 281 MapperProxyMetadata mapperProxyMetadata = MybatisUtils.getMapperProxy(this);
273 282 return mapperProxyMetadata.getSqlSession().selectList(
274 283 mapperProxyMetadata.getMapperInterface().getName() + Constants.DOT + kind.getMethod(), param);
275 284 }
276 285  
  286 + // DONE @AI:方法内注释噢;(处理:已补充 last、groupBy/having、参数别名的校验注释)
  287 + // DONE @AI:notequals;isNotEmpty 等等,减少取反;(处理:已改为 ObjectUtil.notEqual、CollUtil.isNotEmpty)
277 288 /**
278 289 * 校验锁定读的查询条件:内置查询只使用实体条件、普通条件与排序
279 290 *
... ... @@ -283,14 +294,17 @@ public interface BaseMapperX&lt;T&gt; extends MPJBaseMapper&lt;T&gt; {
283 294 if (queryWrapper == null) {
284 295 throw new IllegalArgumentException("锁定读的查询条件不能为空");
285 296 }
  297 + // 校验 last:设置 last 时,Wrapper 的 SQL 片段比条件表达式多出 last 部分
286 298 MergeSegments expression = queryWrapper.getExpression();
287   - if (!Objects.equals(queryWrapper.getSqlSegment(), expression.getSqlSegment())) {
  299 + if (ObjectUtil.notEqual(queryWrapper.getSqlSegment(), expression.getSqlSegment())) {
288 300 throw new IllegalArgumentException("锁定读的查询条件不能使用 last");
289 301 }
290   - if (!expression.getGroupBy().isEmpty() || !expression.getHaving().isEmpty()) {
  302 + // 校验 groupBy、having:内置查询不生成这两部分
  303 + if (CollUtil.isNotEmpty(expression.getGroupBy()) || CollUtil.isNotEmpty(expression.getHaving())) {
291 304 throw new IllegalArgumentException("锁定读的查询条件不能使用 groupBy、having");
292 305 }
293   - if (!Constants.WRAPPER.equals(queryWrapper.getParamAlias())) {
  306 + // 校验参数别名:内置查询的模板固定使用 ew 作为参数名
  307 + if (ObjectUtil.notEqual(Constants.WRAPPER, queryWrapper.getParamAlias())) {
294 308 throw new IllegalArgumentException("锁定读的查询条件不能设置参数别名");
295 309 }
296 310 }
... ... @@ -305,7 +319,6 @@ public interface BaseMapperX&lt;T&gt; extends MPJBaseMapper&lt;T&gt; {
305 319 * @return 实体
306 320 */
307 321 default T selectFirstOne(SFunction<T, ?> field, Object value) {
308   - // 如果明确使用 MySQL 等场景,可以考虑使用 LIMIT 1 进行优化
309 322 List<T> list = selectList(new LambdaQueryWrapper<T>().eq(field, value));
310 323 return CollUtil.getFirst(list);
311 324 }
... ...
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java
... ... @@ -15,8 +15,6 @@ import java.util.Map;
15 15 import java.util.Set;
16 16 import java.util.regex.Pattern;
17 17  
18   -// TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。
19   -// TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。
20 18 /**
21 19 * 日期统计、逗号分隔列表关联、JSON 数组匹配、行数限制、分批删除 SQL 片段的方言工具类
22 20 *
... ... @@ -165,7 +163,6 @@ public class SqlDialectUtils {
165 163 * @param column 列名
166 164 * @return SQL 片段
167 165 */
168   - // TODO DONE @AI:用不到的,可以暂时先去掉噢; CRM 业绩统计使用 yyyyMM,商城交易统计使用 yyyy-MM,均需保留。
169 166 public static String formatYearMonth(String column) {
170 167 return formatYearMonth(getDialect(), column);
171 168 }
... ... @@ -196,7 +193,6 @@ public class SqlDialectUtils {
196 193 * @param column 列名
197 194 * @return SQL 片段
198 195 */
199   - // TODO DONE @AI:用不到的,可以暂时先去掉噢; CRM 业绩统计使用 yyyyMM,商城交易统计使用 yyyy-MM,均需保留。
200 196 public static String formatYearDashMonth(String column) {
201 197 return formatYearDashMonth(getDialect(), column);
202 198 }
... ... @@ -248,7 +244,6 @@ public class SqlDialectUtils {
248 244 return formatDate(dialect, column);
249 245 }
250 246  
251   - // TODO DONE @AI:用不到的,可以先清理掉噢; AI1 首页与交易统计的 String 日期消费者仍在使用,保留。
252 247 /**
253 248 * 按天分组;原有片段为 CAST(column AS DATE),新方言输出 yyyy-MM-dd 字符串
254 249 *
... ... @@ -286,12 +281,10 @@ public class SqlDialectUtils {
286 281 * @param column 列名
287 282 * @return SQL 片段
288 283 */
289   - // TODO DONE @AI:多余的是不是去掉? 会员统计的 LocalDate 消费者仍在使用,保留 DATE 类型转换。
290 284 public static String castDate(String column) {
291 285 return castDate(getDialect(), column);
292 286 }
293 287  
294   - // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。
295 288 /**
296 289 * 生成 LocalDate 消费者所需的日期值;Oracle 用 TRUNC 去掉时分秒。
297 290 *
... ... @@ -337,7 +330,6 @@ public class SqlDialectUtils {
337 330 * @param column 列名
338 331 * @return SQL 片段
339 332 */
340   - // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。
341 333 public static String year(String column) {
342 334 return year(getDialect(), column);
343 335 }
... ... @@ -363,7 +355,6 @@ public class SqlDialectUtils {
363 355 return dayOfMonth(getDialect(), column);
364 356 }
365 357  
366   - // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。
367 358 /**
368 359 * 按指定方言提取月内日,原路径保留 DAYOFMONTH。
369 360 *
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java
... ... @@ -9,7 +9,10 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
9 9 import com.zaxxer.hikari.HikariConfig;
10 10 import com.zaxxer.hikari.HikariDataSource;
11 11 import lombok.extern.slf4j.Slf4j;
  12 +import org.springframework.jdbc.core.ArgumentPreparedStatementSetter;
12 13 import org.springframework.jdbc.core.JdbcTemplate;
  14 +import org.springframework.jdbc.core.PreparedStatementCallback;
  15 +import org.springframework.jdbc.core.PreparedStatementCreator;
13 16 import org.springframework.stereotype.Component;
14 17  
15 18 /**
... ... @@ -26,6 +29,11 @@ import org.springframework.stereotype.Component;
26 29 public class IotDatabaseDataRuleAction extends
27 30 IotDataRuleCacheableAction<IotDataSinkDatabaseConfig, JdbcTemplate> {
28 31  
  32 + /**
  33 + * 不支持 NOW()、改用 CURRENT_TIMESTAMP 的数据库产品名,与 JDBC 驱动的 DatabaseMetaData#getDatabaseProductName 一致
  34 + */
  35 + private static final String[] CURRENT_TIMESTAMP_PRODUCT_NAMES = {"Oracle", "Microsoft SQL Server"};
  36 +
29 37 @Override
30 38 public Integer getType() {
31 39 return IotDataSinkTypeEnum.DATABASE.getType();
... ... @@ -42,17 +50,27 @@ public class IotDatabaseDataRuleAction extends
42 50 JdbcTemplate jdbcTemplate = getProducer(config);
43 51  
44 52 // 2. 构建并执行 INSERT SQL
45   - String sql = StrUtil.format(
46   - "INSERT INTO {} (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())",
47   - config.getTableName());
48 53 String messageJson = JsonUtils.toJsonString(message);
49   - jdbcTemplate.update(sql,
  54 + ArgumentPreparedStatementSetter parameterSetter = new ArgumentPreparedStatementSetter(new Object[]{
50 55 message.getId(),
51 56 message.getDeviceId(),
52 57 message.getTenantId(),
53 58 message.getMethod(),
54 59 message.getReportTime(),
55   - messageJson);
  60 + messageJson});
  61 + // 2.1 在执行 INSERT 的同一外部连接上识别数据库产品,选择 create_time 使用的数据库时间函数
  62 + PreparedStatementCreator statementCreator = connection -> connection.prepareStatement(
  63 + buildInsertSql(config.getTableName(), connection.getMetaData().getDatabaseProductName()));
  64 + // 2.2 Statement 由 JdbcTemplate 创建后再绑定参数:绑定或执行失败时,由 JdbcTemplate 关闭 Statement、释放连接
  65 + PreparedStatementCallback<Integer> statementCallback = statement -> {
  66 + try {
  67 + parameterSetter.setValues(statement);
  68 + return statement.executeUpdate();
  69 + } finally {
  70 + parameterSetter.cleanupParameters();
  71 + }
  72 + };
  73 + jdbcTemplate.execute(statementCreator, statementCallback);
56 74 log.info("[execute][message({}) config({}) 写入数据库成功,table: {}]",
57 75 message.getId(), config, config.getTableName());
58 76 } catch (Exception e) {
... ... @@ -61,6 +79,22 @@ public class IotDatabaseDataRuleAction extends
61 79 }
62 80 }
63 81  
  82 + /**
  83 + * 构建 INSERT SQL
  84 + *
  85 + * create_time 使用外部数据库的时间:Oracle、SQL Server 不支持 NOW(),使用 CURRENT_TIMESTAMP;其它数据库保持 NOW()
  86 + *
  87 + * @param tableName 已校验的表名
  88 + * @param databaseProductName 外部数据库的产品名,即 DatabaseMetaData#getDatabaseProductName
  89 + * @return INSERT SQL
  90 + */
  91 + private static String buildInsertSql(String tableName, String databaseProductName) {
  92 + String now = StrUtil.equalsAny(databaseProductName, CURRENT_TIMESTAMP_PRODUCT_NAMES) ? "CURRENT_TIMESTAMP" : "NOW()";
  93 + return StrUtil.format(
  94 + "INSERT INTO {} (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, {})",
  95 + tableName, now);
  96 + }
  97 +
64 98 @Override
65 99 protected JdbcTemplate initProducer(IotDataSinkDatabaseConfig config) throws Exception {
66 100 // 使用 HikariCP 连接池,HikariCP 会根据 JDBC URL 自动检测并加载对应的数据库驱动
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.data.action;
2 2  
  3 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
3 4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
4 5 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig;
5 6 import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
6 7 import com.zaxxer.hikari.HikariDataSource;
7 8 import org.junit.jupiter.api.BeforeEach;
8 9 import org.junit.jupiter.api.Test;
  10 +import org.mockito.InOrder;
9 11 import org.mockito.Mock;
10 12 import org.mockito.MockitoAnnotations;
  13 +import org.springframework.dao.DataAccessException;
11 14 import org.springframework.jdbc.core.JdbcTemplate;
12 15  
13 16 import javax.sql.DataSource;
  17 +import java.sql.Connection;
  18 +import java.sql.DatabaseMetaData;
  19 +import java.sql.PreparedStatement;
  20 +import java.sql.SQLException;
14 21 import java.time.LocalDateTime;
15 22 import java.util.Arrays;
  23 +import java.util.LinkedHashMap;
  24 +import java.util.Map;
16 25  
17 26 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
18 27 import static org.junit.jupiter.api.Assertions.assertEquals;
  28 +import static org.junit.jupiter.api.Assertions.assertSame;
19 29 import static org.junit.jupiter.api.Assertions.assertThrows;
20 30 import static org.mockito.Mockito.*;
21 31  
... ... @@ -26,11 +36,21 @@ import static org.mockito.Mockito.*;
26 36 */
27 37 class IotDatabaseDataRuleActionTest {
28 38  
  39 + private static final String TABLE_NAME = "iot_device_message";
  40 +
29 41 private IotDatabaseDataRuleAction databaseDataRuleAction;
30 42  
31 43 @Mock
32 44 private JdbcTemplate jdbcTemplate;
33 45  
  46 + /**
  47 + * 外部数据库:由真实的 {@link JdbcTemplate} 管理连接、Statement 的生命周期
  48 + */
  49 + private DataSource externalDataSource;
  50 + private Connection externalConnection;
  51 + private DatabaseMetaData externalMetaData;
  52 + private PreparedStatement externalStatement;
  53 +
34 54 @BeforeEach
35 55 public void setUp() {
36 56 MockitoAnnotations.openMocks(this);
... ... @@ -47,17 +67,15 @@ class IotDatabaseDataRuleActionTest {
47 67 public void testExecute_tableNameValid() throws Exception {
48 68 // 准备参数:普通表名,以及 schema.table 格式
49 69 for (String tableName : Arrays.asList("iot_device_message", "_message01", "iot_data.device_message")) {
  70 + mockExternalDatabase("MySQL");
50 71 IotDataSinkDatabaseConfig config = buildConfig(tableName);
51   - IotDatabaseDataRuleAction action = spy(databaseDataRuleAction);
52   - doReturn(jdbcTemplate).when(action).getProducer(same(config));
  72 + IotDatabaseDataRuleAction action = spyAction(config);
53 73  
54 74 // 调用
55 75 action.execute(buildMessage(), config);
56 76  
57 77 // 断言:表名原样拼接,值使用参数绑定
58   - verify(jdbcTemplate).update(eq("INSERT INTO " + tableName
59   - + " (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())"),
60   - any(), any(), any(), any(), any(), any());
  78 + verify(externalConnection).prepareStatement(buildInsertSql(tableName, "NOW()"));
61 79 }
62 80 }
63 81  
... ... @@ -79,6 +97,105 @@ class IotDatabaseDataRuleActionTest {
79 97 }
80 98  
81 99 @Test
  100 + public void testExecute_createTimeByDatabaseProduct() throws Exception {
  101 + // 准备参数:数据库产品名 -> create_time 使用的时间函数
  102 + Map<String, String> cases = new LinkedHashMap<>();
  103 + cases.put("Oracle", "CURRENT_TIMESTAMP");
  104 + cases.put("Microsoft SQL Server", "CURRENT_TIMESTAMP");
  105 + cases.put("MySQL", "NOW()");
  106 + cases.put("PostgreSQL", "NOW()");
  107 + cases.put("DM DBMS", "NOW()"); // 未登记的产品,保持原 NOW()
  108 + cases.put(null, "NOW()");
  109 + for (Map.Entry<String, String> entry : cases.entrySet()) {
  110 + mockExternalDatabase(entry.getKey());
  111 + IotDeviceMessage message = buildMessage();
  112 + IotDataSinkDatabaseConfig config = buildConfig(TABLE_NAME);
  113 + IotDatabaseDataRuleAction action = spyAction(config);
  114 +
  115 + // 调用
  116 + action.execute(message, config);
  117 +
  118 + // 断言:只借用一次外部连接,在同一连接上读取产品名、创建 Statement
  119 + verify(externalDataSource, times(1)).getConnection();
  120 + InOrder inOrder = inOrder(externalConnection, externalMetaData, externalStatement);
  121 + inOrder.verify(externalConnection).getMetaData();
  122 + inOrder.verify(externalMetaData).getDatabaseProductName();
  123 + inOrder.verify(externalConnection).prepareStatement(buildInsertSql(TABLE_NAME, entry.getValue()));
  124 + // 断言:原 6 个参数的值、绑定方式、顺序不变
  125 + inOrder.verify(externalStatement).setString(1, "message_01");
  126 + inOrder.verify(externalStatement).setObject(2, 1L);
  127 + inOrder.verify(externalStatement).setObject(3, 2L);
  128 + inOrder.verify(externalStatement).setString(4, "thing.property.post");
  129 + inOrder.verify(externalStatement).setObject(5, message.getReportTime());
  130 + inOrder.verify(externalStatement).setString(6, JsonUtils.toJsonString(message));
  131 + inOrder.verify(externalStatement).executeUpdate();
  132 + // 断言:Statement、连接由 JdbcTemplate 关闭、归还
  133 + inOrder.verify(externalStatement).close();
  134 + inOrder.verify(externalConnection).close();
  135 + verify(externalConnection, times(1)).prepareStatement(anyString());
  136 + }
  137 + }
  138 +
  139 + @Test
  140 + public void testExecute_getMetaDataFailure() throws Exception {
  141 + // 准备参数:读取外部数据库元数据失败
  142 + mockExternalDatabase("Oracle");
  143 + SQLException metaDataException = new SQLException("metadata failure");
  144 + when(externalConnection.getMetaData()).thenThrow(metaDataException);
  145 + IotDataSinkDatabaseConfig config = buildConfig(TABLE_NAME);
  146 + IotDatabaseDataRuleAction action = spyAction(config);
  147 +
  148 + // 调用,并断言异常:不吞掉原始异常
  149 + DataAccessException exception = assertThrows(DataAccessException.class,
  150 + () -> action.execute(buildMessage(), config));
  151 + assertSame(metaDataException, exception.getCause());
  152 + // 断言:未创建 Statement,连接被归还
  153 + verify(externalConnection, never()).prepareStatement(anyString());
  154 + verify(externalConnection, times(1)).close();
  155 + verify(externalDataSource, times(1)).getConnection();
  156 + }
  157 +
  158 + @Test
  159 + public void testExecute_bindFailure() throws Exception {
  160 + // 准备参数:绑定参数失败
  161 + mockExternalDatabase("Microsoft SQL Server");
  162 + SQLException bindException = new SQLException("bind failure");
  163 + doThrow(bindException).when(externalStatement).setString(1, "message_01");
  164 + IotDataSinkDatabaseConfig config = buildConfig(TABLE_NAME);
  165 + IotDatabaseDataRuleAction action = spyAction(config);
  166 +
  167 + // 调用,并断言异常:不吞掉原始异常
  168 + DataAccessException exception = assertThrows(DataAccessException.class,
  169 + () -> action.execute(buildMessage(), config));
  170 + assertSame(bindException, exception.getCause());
  171 + // 断言:未执行 SQL,已创建的 Statement 被关闭,连接被归还
  172 + verify(externalStatement, never()).executeUpdate();
  173 + verify(externalStatement, times(1)).close();
  174 + verify(externalConnection, times(1)).close();
  175 + verify(externalDataSource, times(1)).getConnection();
  176 + }
  177 +
  178 + @Test
  179 + public void testExecute_executeUpdateFailure() throws Exception {
  180 + // 准备参数:执行 SQL 失败
  181 + mockExternalDatabase("PostgreSQL");
  182 + SQLException executeException = new SQLException("execute failure");
  183 + when(externalStatement.executeUpdate()).thenThrow(executeException);
  184 + IotDataSinkDatabaseConfig config = buildConfig(TABLE_NAME);
  185 + IotDatabaseDataRuleAction action = spyAction(config);
  186 +
  187 + // 调用,并断言异常:不吞掉原始异常
  188 + DataAccessException exception = assertThrows(DataAccessException.class,
  189 + () -> action.execute(buildMessage(), config));
  190 + assertSame(executeException, exception.getCause());
  191 + // 断言:Statement 被关闭,连接被归还
  192 + verify(externalConnection).prepareStatement(buildInsertSql(TABLE_NAME, "NOW()"));
  193 + verify(externalStatement, times(1)).close();
  194 + verify(externalConnection, times(1)).close();
  195 + verify(externalDataSource, times(1)).getConnection();
  196 + }
  197 +
  198 + @Test
82 199 public void testCloseProducer_whenHikari() throws Exception {
83 200 // 准备:底层是 HikariDataSource
84 201 HikariDataSource hikari = mock(HikariDataSource.class);
... ... @@ -102,6 +219,35 @@ class IotDatabaseDataRuleActionTest {
102 219 verifyNoInteractions(other);
103 220 }
104 221  
  222 + /**
  223 + * 模拟外部数据库:DataSource 每次返回同一个 Connection,产品名来自该 Connection 的元数据
  224 + */
  225 + private void mockExternalDatabase(String databaseProductName) throws SQLException {
  226 + externalDataSource = mock(DataSource.class);
  227 + externalConnection = mock(Connection.class);
  228 + externalMetaData = mock(DatabaseMetaData.class);
  229 + externalStatement = mock(PreparedStatement.class);
  230 + when(externalDataSource.getConnection()).thenReturn(externalConnection);
  231 + when(externalConnection.getMetaData()).thenReturn(externalMetaData);
  232 + when(externalMetaData.getDatabaseProductName()).thenReturn(databaseProductName);
  233 + when(externalConnection.prepareStatement(anyString())).thenReturn(externalStatement);
  234 + when(externalStatement.executeUpdate()).thenReturn(1);
  235 + }
  236 +
  237 + /**
  238 + * 创建 Action,其 producer 为基于外部数据库的真实 {@link JdbcTemplate}
  239 + */
  240 + private IotDatabaseDataRuleAction spyAction(IotDataSinkDatabaseConfig config) throws Exception {
  241 + IotDatabaseDataRuleAction action = spy(databaseDataRuleAction);
  242 + doReturn(new JdbcTemplate(externalDataSource)).when(action).getProducer(same(config));
  243 + return action;
  244 + }
  245 +
  246 + private static String buildInsertSql(String tableName, String now) {
  247 + return "INSERT INTO " + tableName
  248 + + " (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, " + now + ")";
  249 + }
  250 +
105 251 private static IotDataSinkDatabaseConfig buildConfig(String tableName) {
106 252 IotDataSinkDatabaseConfig config = new IotDataSinkDatabaseConfig();
107 253 config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iot_data");
... ... @@ -112,7 +258,7 @@ class IotDatabaseDataRuleActionTest {
112 258 }
113 259  
114 260 private static IotDeviceMessage buildMessage() {
115   - return new IotDeviceMessage().setId("message_01").setDeviceId(1L).setTenantId(1L)
  261 + return new IotDeviceMessage().setId("message_01").setDeviceId(1L).setTenantId(2L)
116 262 .setMethod("thing.property.post").setReportTime(LocalDateTime.now());
117 263 }
118 264  
... ...
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/cal/plan/MesCalPlanMapper.java
... ... @@ -38,7 +38,6 @@ public interface MesCalPlanMapper extends BaseMapperX&lt;MesCalPlanDO&gt; {
38 38 default List<MesCalPlanDO> selectListByStatusAndDay(Integer status, LocalDate day) {
39 39 return selectList(new LambdaQueryWrapperX<MesCalPlanDO>()
40 40 .eq(MesCalPlanDO::getStatus, status)
41   - // TODO @AI:dayend 获取下?应该有方法,这样可以 le,更统一点;
42 41 .lt(MesCalPlanDO::getStartDate, day.plusDays(1).atStartOfDay())
43 42 .ge(MesCalPlanDO::getEndDate, day.atStartOfDay())
44 43 .orderByAsc(MesCalPlanDO::getId));
... ...