Commit 8618426f1b75e7d7db609ecc4c392726587302fe
1 parent
86137492
fix(iot): 校验数据库数据目的表名
Showing
5 changed files
with
153 additions
and
0 deletions
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVO.java
| @@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.validation.InEnum; | @@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.validation.InEnum; | ||
| 5 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig; | 5 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig; |
| 6 | import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; | 6 | import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; |
| 7 | import io.swagger.v3.oas.annotations.media.Schema; | 7 | import io.swagger.v3.oas.annotations.media.Schema; |
| 8 | +import jakarta.validation.Valid; | ||
| 8 | import jakarta.validation.constraints.NotEmpty; | 9 | import jakarta.validation.constraints.NotEmpty; |
| 9 | import jakarta.validation.constraints.NotNull; | 10 | import jakarta.validation.constraints.NotNull; |
| 10 | import lombok.Data; | 11 | import lombok.Data; |
| @@ -35,6 +36,7 @@ public class IotDataSinkSaveReqVO { | @@ -35,6 +36,7 @@ public class IotDataSinkSaveReqVO { | ||
| 35 | 36 | ||
| 36 | @Schema(description = "数据目的配置") | 37 | @Schema(description = "数据目的配置") |
| 37 | @NotNull(message = "数据目的配置不能为空") | 38 | @NotNull(message = "数据目的配置不能为空") |
| 39 | + @Valid | ||
| 38 | private IotAbstractDataSinkConfig config; | 40 | private IotAbstractDataSinkConfig config; |
| 39 | 41 | ||
| 40 | } | 42 | } |
| 41 | \ No newline at end of file | 43 | \ No newline at end of file |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkDatabaseConfig.java
| 1 | package cn.iocoder.yudao.module.iot.dal.dataobject.rule.config; | 1 | package cn.iocoder.yudao.module.iot.dal.dataobject.rule.config; |
| 2 | 2 | ||
| 3 | +import jakarta.validation.constraints.NotEmpty; | ||
| 4 | +import jakarta.validation.constraints.Pattern; | ||
| 3 | import lombok.Data; | 5 | import lombok.Data; |
| 4 | 6 | ||
| 5 | /** | 7 | /** |
| @@ -15,6 +17,13 @@ import lombok.Data; | @@ -15,6 +17,13 @@ import lombok.Data; | ||
| 15 | public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig { | 17 | public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig { |
| 16 | 18 | ||
| 17 | /** | 19 | /** |
| 20 | + * 目标表名的格式:只允许字母、数字、下划线,且不以数字开头;支持可选的 schema.table 格式 | ||
| 21 | + * | ||
| 22 | + * 目的:表名会拼接到 INSERT 语句中,保存和执行时都必须校验 | ||
| 23 | + */ | ||
| 24 | + public static final String TABLE_NAME_REGEX = "^[A-Za-z_][A-Za-z0-9_]{0,127}(\\.[A-Za-z_][A-Za-z0-9_]{0,127})?$"; | ||
| 25 | + | ||
| 26 | + /** | ||
| 18 | * JDBC 连接地址 | 27 | * JDBC 连接地址 |
| 19 | * | 28 | * |
| 20 | * 例如:jdbc:mysql://localhost:3306/iot_data | 29 | * 例如:jdbc:mysql://localhost:3306/iot_data |
| @@ -37,6 +46,8 @@ public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig { | @@ -37,6 +46,8 @@ public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig { | ||
| 37 | * | 46 | * |
| 38 | * 设备消息将以固定结构写入该表 | 47 | * 设备消息将以固定结构写入该表 |
| 39 | */ | 48 | */ |
| 49 | + @NotEmpty(message = "目标表名不能为空") | ||
| 50 | + @Pattern(regexp = TABLE_NAME_REGEX, message = "目标表名格式不正确,只允许字母、数字、下划线,或 schema.table 格式") | ||
| 40 | private String tableName; | 51 | private String tableName; |
| 41 | 52 | ||
| 42 | } | 53 | } |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java
| 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action; | 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action; |
| 2 | 2 | ||
| 3 | +import cn.hutool.core.util.ReUtil; | ||
| 3 | import cn.hutool.core.util.StrUtil; | 4 | import cn.hutool.core.util.StrUtil; |
| 4 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | 5 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 5 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| @@ -33,6 +34,10 @@ public class IotDatabaseDataRuleAction extends | @@ -33,6 +34,10 @@ public class IotDatabaseDataRuleAction extends | ||
| 33 | @Override | 34 | @Override |
| 34 | public void execute(IotDeviceMessage message, IotDataSinkDatabaseConfig config) throws Exception { | 35 | public void execute(IotDeviceMessage message, IotDataSinkDatabaseConfig config) throws Exception { |
| 35 | try { | 36 | try { |
| 37 | + // 0. 校验表名:执行时再次校验,避免历史保存的非法表名拼接到 SQL 中 | ||
| 38 | + if (!ReUtil.isMatch(IotDataSinkDatabaseConfig.TABLE_NAME_REGEX, config.getTableName())) { | ||
| 39 | + throw new IllegalArgumentException(StrUtil.format("数据库目的的表名({}) 不合法", config.getTableName())); | ||
| 40 | + } | ||
| 36 | // 1. 获取或创建 JdbcTemplate | 41 | // 1. 获取或创建 JdbcTemplate |
| 37 | JdbcTemplate jdbcTemplate = getProducer(config); | 42 | JdbcTemplate jdbcTemplate = getProducer(config); |
| 38 | 43 |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVOTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | ||
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig; | ||
| 5 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig; | ||
| 6 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkHttpConfig; | ||
| 7 | +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; | ||
| 8 | +import jakarta.validation.ConstraintViolation; | ||
| 9 | +import jakarta.validation.Validation; | ||
| 10 | +import jakarta.validation.Validator; | ||
| 11 | +import org.junit.jupiter.api.Test; | ||
| 12 | + | ||
| 13 | +import java.util.Arrays; | ||
| 14 | +import java.util.Set; | ||
| 15 | + | ||
| 16 | +import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| 17 | +import static org.junit.jupiter.api.Assertions.assertTrue; | ||
| 18 | + | ||
| 19 | +/** | ||
| 20 | + * {@link IotDataSinkSaveReqVO} 的参数校验单元测试 | ||
| 21 | + * | ||
| 22 | + * @author 芋道源码 | ||
| 23 | + */ | ||
| 24 | +public class IotDataSinkSaveReqVOTest { | ||
| 25 | + | ||
| 26 | + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); | ||
| 27 | + | ||
| 28 | + @Test | ||
| 29 | + public void testValidate_databaseTableNameValid() { | ||
| 30 | + for (String tableName : Arrays.asList("iot_device_message", "iot_data.device_message")) { | ||
| 31 | + // 准备参数 | ||
| 32 | + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.DATABASE.getType(), buildDatabaseConfig(tableName)); | ||
| 33 | + | ||
| 34 | + // 调用,并断言 | ||
| 35 | + assertTrue(validator.validate(reqVO).isEmpty(), tableName); | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + @Test | ||
| 40 | + public void testValidate_databaseTableNameInvalid() { | ||
| 41 | + for (String tableName : Arrays.asList(null, "", "1message", "message; DROP TABLE system_users", | ||
| 42 | + "message -- ", "message/**/", "`message`", "a.b.c", "message(id)")) { | ||
| 43 | + // 准备参数 | ||
| 44 | + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.DATABASE.getType(), buildDatabaseConfig(tableName)); | ||
| 45 | + | ||
| 46 | + // 调用 | ||
| 47 | + Set<ConstraintViolation<IotDataSinkSaveReqVO>> violations = validator.validate(reqVO); | ||
| 48 | + // 断言:通过 @Valid 级联校验到配置的表名 | ||
| 49 | + assertTrue(!violations.isEmpty(), String.valueOf(tableName)); | ||
| 50 | + violations.forEach(violation -> assertEquals("config.tableName", violation.getPropertyPath().toString())); | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + public void testValidate_otherConfigNotAffected() { | ||
| 56 | + // 准备参数:其它类型的配置,没有新增的校验约束 | ||
| 57 | + IotDataSinkHttpConfig config = new IotDataSinkHttpConfig(); | ||
| 58 | + config.setUrl("https://www.iocoder.cn"); | ||
| 59 | + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.HTTP.getType(), config); | ||
| 60 | + | ||
| 61 | + // 调用,并断言 | ||
| 62 | + assertTrue(validator.validate(reqVO).isEmpty()); | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + private static IotDataSinkSaveReqVO buildReqVO(Integer type, IotAbstractDataSinkConfig config) { | ||
| 66 | + IotDataSinkSaveReqVO reqVO = new IotDataSinkSaveReqVO(); | ||
| 67 | + reqVO.setName("数据目的"); | ||
| 68 | + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); | ||
| 69 | + reqVO.setType(type); | ||
| 70 | + reqVO.setConfig(config); | ||
| 71 | + return reqVO; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + private static IotDataSinkDatabaseConfig buildDatabaseConfig(String tableName) { | ||
| 75 | + IotDataSinkDatabaseConfig config = new IotDataSinkDatabaseConfig(); | ||
| 76 | + config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iot_data"); | ||
| 77 | + config.setTableName(tableName); | ||
| 78 | + return config; | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | +} |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java
| 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action; | 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action; |
| 2 | 2 | ||
| 3 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | ||
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig; | ||
| 3 | import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; | 5 | import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; |
| 4 | import com.zaxxer.hikari.HikariDataSource; | 6 | import com.zaxxer.hikari.HikariDataSource; |
| 5 | import org.junit.jupiter.api.BeforeEach; | 7 | import org.junit.jupiter.api.BeforeEach; |
| @@ -9,9 +11,12 @@ import org.mockito.MockitoAnnotations; | @@ -9,9 +11,12 @@ import org.mockito.MockitoAnnotations; | ||
| 9 | import org.springframework.jdbc.core.JdbcTemplate; | 11 | import org.springframework.jdbc.core.JdbcTemplate; |
| 10 | 12 | ||
| 11 | import javax.sql.DataSource; | 13 | import javax.sql.DataSource; |
| 14 | +import java.time.LocalDateTime; | ||
| 15 | +import java.util.Arrays; | ||
| 12 | 16 | ||
| 13 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; | 17 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
| 14 | import static org.junit.jupiter.api.Assertions.assertEquals; | 18 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 19 | +import static org.junit.jupiter.api.Assertions.assertThrows; | ||
| 15 | import static org.mockito.Mockito.*; | 20 | import static org.mockito.Mockito.*; |
| 16 | 21 | ||
| 17 | /** | 22 | /** |
| @@ -39,6 +44,41 @@ class IotDatabaseDataRuleActionTest { | @@ -39,6 +44,41 @@ class IotDatabaseDataRuleActionTest { | ||
| 39 | } | 44 | } |
| 40 | 45 | ||
| 41 | @Test | 46 | @Test |
| 47 | + public void testExecute_tableNameValid() throws Exception { | ||
| 48 | + // 准备参数:普通表名,以及 schema.table 格式 | ||
| 49 | + for (String tableName : Arrays.asList("iot_device_message", "_message01", "iot_data.device_message")) { | ||
| 50 | + IotDataSinkDatabaseConfig config = buildConfig(tableName); | ||
| 51 | + IotDatabaseDataRuleAction action = spy(databaseDataRuleAction); | ||
| 52 | + doReturn(jdbcTemplate).when(action).getProducer(same(config)); | ||
| 53 | + | ||
| 54 | + // 调用 | ||
| 55 | + action.execute(buildMessage(), config); | ||
| 56 | + | ||
| 57 | + // 断言:表名原样拼接,值使用参数绑定 | ||
| 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()); | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + @Test | ||
| 65 | + public void testExecute_tableNameInvalid() throws Exception { | ||
| 66 | + // 准备参数:模拟历史已保存的非法表名,执行时也必须拦截 | ||
| 67 | + for (String tableName : Arrays.asList(null, "", "1message", "device message", | ||
| 68 | + "message; DROP TABLE system_users", "message -- ", "message/**/", "`message`", "\"message\"", | ||
| 69 | + "message(id)", "a.b.c", ".message", "message.", "表名")) { | ||
| 70 | + IotDataSinkDatabaseConfig config = buildConfig(tableName); | ||
| 71 | + IotDatabaseDataRuleAction action = spy(databaseDataRuleAction); | ||
| 72 | + | ||
| 73 | + // 调用,并断言异常 | ||
| 74 | + assertThrows(IllegalArgumentException.class, () -> action.execute(buildMessage(), config)); | ||
| 75 | + // 断言:校验在获取连接之前,不会创建连接池、执行 SQL | ||
| 76 | + verify(action, never()).getProducer(any()); | ||
| 77 | + } | ||
| 78 | + verifyNoInteractions(jdbcTemplate); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + @Test | ||
| 42 | public void testCloseProducer_whenHikari() throws Exception { | 82 | public void testCloseProducer_whenHikari() throws Exception { |
| 43 | // 准备:底层是 HikariDataSource | 83 | // 准备:底层是 HikariDataSource |
| 44 | HikariDataSource hikari = mock(HikariDataSource.class); | 84 | HikariDataSource hikari = mock(HikariDataSource.class); |
| @@ -62,4 +102,18 @@ class IotDatabaseDataRuleActionTest { | @@ -62,4 +102,18 @@ class IotDatabaseDataRuleActionTest { | ||
| 62 | verifyNoInteractions(other); | 102 | verifyNoInteractions(other); |
| 63 | } | 103 | } |
| 64 | 104 | ||
| 105 | + private static IotDataSinkDatabaseConfig buildConfig(String tableName) { | ||
| 106 | + IotDataSinkDatabaseConfig config = new IotDataSinkDatabaseConfig(); | ||
| 107 | + config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iot_data"); | ||
| 108 | + config.setUsername("root"); | ||
| 109 | + config.setPassword("123456"); | ||
| 110 | + config.setTableName(tableName); | ||
| 111 | + return config; | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + private static IotDeviceMessage buildMessage() { | ||
| 115 | + return new IotDeviceMessage().setId("message_01").setDeviceId(1L).setTenantId(1L) | ||
| 116 | + .setMethod("thing.property.post").setReportTime(LocalDateTime.now()); | ||
| 117 | + } | ||
| 118 | + | ||
| 65 | } | 119 | } |