Commit 42fffd2a5142a5e430aa3383f7748ca8d0337716

Authored by YunaiV
1 parent 87c8d7e2

【修复】IoT 数据流转规则:属性上报场景下多规则命中同一数据目的会重复推送

yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java
... ... @@ -10,6 +10,7 @@ import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
10 10 import cn.iocoder.yudao.framework.common.util.spring.SpringUtils;
11 11 import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRulePageReqVO;
12 12 import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRuleSaveReqVO;
  13 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
13 14 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
14 15 import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
15 16 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
... ... @@ -212,34 +213,76 @@ public class IotDataRuleServiceImpl implements IotDataRuleService {
212 213 @Override
213 214 public void executeDataRule(IotDeviceMessage message) {
214 215 try {
215   - // 1. 获取匹配的数据流转规则
216 216 Long deviceId = message.getDeviceId();
217 217 String method = message.getMethod();
218   - String identifier = IotDeviceMessageUtils.getIdentifier(message);
219   - List<IotDataRuleDO> rules = getSelf().getDataRuleListByConditionFromCache(deviceId, method, identifier);
220   - if (CollUtil.isEmpty(rules)) {
  218 + // 1. 匹配命中的规则
  219 + List<IotDataRuleDO> matchedRules;
  220 + Object identifierForLog;
  221 + if (IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod().equals(method)) {
  222 + // 属性上报:params 含多个属性 key,每个 key 都可能命中规则
  223 + Set<String> identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message);
  224 + matchedRules = matchPropertyPostDataRules(deviceId, method, identifiers);
  225 + identifierForLog = identifiers;
  226 + } else {
  227 + // 其他消息(事件 / 服务调用 / 状态):单一 identifier
  228 + String identifier = IotDeviceMessageUtils.getIdentifier(message);
  229 + matchedRules = getSelf().getDataRuleListByConditionFromCache(deviceId, method, identifier);
  230 + identifierForLog = identifier;
  231 + }
  232 + if (CollUtil.isEmpty(matchedRules)) {
221 233 log.debug("[executeDataRule][设备({}) 方法({}) 标识符({}) 没有匹配的数据流转规则]",
222   - deviceId, method, identifier);
  234 + deviceId, method, identifierForLog);
223 235 return;
224 236 }
225 237 log.info("[executeDataRule][设备({}) 方法({}) 标识符({}) 匹配到 {} 条数据流转规则]",
226   - deviceId, method, identifier, rules.size());
  238 + deviceId, method, identifierForLog, matchedRules.size());
227 239  
228   - // 2. 遍历规则,执行数据流转
229   - rules.forEach(rule -> executeDataRule(message, rule));
  240 + // 2. 跨规则去重 sink,避免多条规则命中同一数据目的时重复推送
  241 + Set<Long> processedSinkIds = new HashSet<>();
  242 + matchedRules.forEach(rule -> executeDataRule(message, rule, processedSinkIds));
230 243 } catch (Exception e) {
231 244 log.error("[executeDataRule][消息({}) 执行数据流转规则异常]", message, e);
232 245 }
233 246 }
234 247  
235 248 /**
  249 + * 匹配属性上报场景下命中的数据流转规则
  250 + *
  251 + * 同一规则可能同时匹配「任意属性」与具体 identifier,按 ruleId 去重后返回
  252 + *
  253 + * @param deviceId 设备编号
  254 + * @param method 消息方法
  255 + * @param identifiers 上报消息中包含的属性标识符集合
  256 + * @return 命中的数据流转规则列表
  257 + */
  258 + private List<IotDataRuleDO> matchPropertyPostDataRules(Long deviceId, String method, Set<String> identifiers) {
  259 + LinkedHashMap<Long, IotDataRuleDO> matchedRuleMap = new LinkedHashMap<>();
  260 + // 情况一:先匹配未填 identifier 的「任意属性」规则,默认就匹配
  261 + collectMatchedRules(matchedRuleMap, deviceId, method, null);
  262 + // 情况二:再针对每个上报的属性标识符匹配限定具体 identifier 的规则
  263 + identifiers.forEach(identifier -> collectMatchedRules(matchedRuleMap, deviceId, method, identifier));
  264 + return new ArrayList<>(matchedRuleMap.values());
  265 + }
  266 +
  267 + private void collectMatchedRules(Map<Long, IotDataRuleDO> matchedRuleMap,
  268 + Long deviceId, String method, String identifier) {
  269 + getSelf().getDataRuleListByConditionFromCache(deviceId, method, identifier)
  270 + .forEach(rule -> matchedRuleMap.putIfAbsent(rule.getId(), rule));
  271 + }
  272 +
  273 + /**
236 274 * 为指定规则的所有数据目的执行数据流转
237 275 *
238   - * @param message 设备消息
239   - * @param rule 数据流转规则
  276 + * @param message 设备消息
  277 + * @param rule 数据流转规则
  278 + * @param processedSinkIds 已处理的数据目的编号集合,跨规则去重,避免同一数据目的被重复推送
240 279 */
241   - private void executeDataRule(IotDeviceMessage message, IotDataRuleDO rule) {
  280 + private void executeDataRule(IotDeviceMessage message, IotDataRuleDO rule, Set<Long> processedSinkIds) {
242 281 rule.getSinkIds().forEach(sinkId -> {
  282 + // 同一消息下,多条规则命中同一数据目的时只推送一次
  283 + if (!processedSinkIds.add(sinkId)) {
  284 + return;
  285 + }
243 286 try {
244 287 // 获取数据目的配置
245 288 IotDataSinkDO dataSink = dataSinkService.getDataSinkFromCache(sinkId);
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.rule.data;
  2 +
  3 +import cn.hutool.core.map.MapUtil;
  4 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  7 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  8 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataRuleDO;
  9 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO;
  10 +import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotDataRuleMapper;
  11 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  12 +import cn.iocoder.yudao.module.iot.service.product.IotProductService;
  13 +import cn.iocoder.yudao.module.iot.service.rule.data.action.IotDataRuleAction;
  14 +import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
  15 +import jakarta.annotation.Resource;
  16 +import org.junit.jupiter.api.Test;
  17 +import org.springframework.context.annotation.Import;
  18 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
  19 +
  20 +import java.util.Map;
  21 +
  22 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
  23 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
  24 +import static java.util.Collections.singletonList;
  25 +import static org.mockito.ArgumentMatchers.any;
  26 +import static org.mockito.ArgumentMatchers.eq;
  27 +import static org.mockito.Mockito.never;
  28 +import static org.mockito.Mockito.verify;
  29 +import static org.mockito.Mockito.when;
  30 +
  31 +/**
  32 + * {@link IotDataRuleServiceImpl} 的单元测试类
  33 + *
  34 + * @author 芋道源码
  35 + */
  36 +@Import(IotDataRuleServiceImpl.class)
  37 +class IotDataRuleServiceImplTest extends BaseDbUnitTest {
  38 +
  39 + @Resource
  40 + private IotDataRuleServiceImpl dataRuleService;
  41 +
  42 + @Resource
  43 + private IotDataRuleMapper dataRuleMapper;
  44 +
  45 + @MockitoBean
  46 + private IotDataSinkService dataSinkService;
  47 + @MockitoBean
  48 + private IotDataRuleAction dataRuleAction;
  49 + @MockitoBean
  50 + private IotProductService productService;
  51 + @MockitoBean
  52 + private IotDeviceService deviceService;
  53 + @MockitoBean
  54 + private IotThingModelService thingModelService;
  55 +
  56 + @Test
  57 + public void testExecuteDataRule_propertyPost_singleIdentifierMatched() {
  58 + // 准备参数
  59 + Long deviceId = randomLongId();
  60 + String identifier = "temperature";
  61 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  62 + MapUtil.<String, Object>builder().put(identifier, 25.5).build());
  63 + // mock 数据:插入一条限定 identifier=temperature 的规则
  64 + Long sinkId = randomLongId();
  65 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), identifier, sinkId);
  66 + // mock 方法
  67 + IotDataSinkDO sink = mockEnabledSink(sinkId);
  68 +
  69 + // 调用
  70 + dataRuleService.executeDataRule(message);
  71 +
  72 + // 断言:sink action 被调用一次
  73 + verify(dataRuleAction).execute(eq(message), eq(sink));
  74 + }
  75 +
  76 + @Test
  77 + public void testExecuteDataRule_propertyPost_multiIdentifierOneMatched() {
  78 + // 准备参数:上报 {temperature, humidity},规则只限定 humidity
  79 + Long deviceId = randomLongId();
  80 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  81 + MapUtil.<String, Object>builder().put("temperature", 25.5).put("humidity", 60).build());
  82 + // mock 数据
  83 + Long sinkId = randomLongId();
  84 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), "humidity", sinkId);
  85 + // mock 方法
  86 + IotDataSinkDO sink = mockEnabledSink(sinkId);
  87 +
  88 + // 调用
  89 + dataRuleService.executeDataRule(message);
  90 +
  91 + // 断言
  92 + verify(dataRuleAction).execute(eq(message), eq(sink));
  93 + }
  94 +
  95 + @Test
  96 + public void testExecuteDataRule_propertyPost_multiIdentifierDeduped() {
  97 + // 准备参数:上报 {temperature, humidity},规则 identifier=null 不限定属性
  98 + Long deviceId = randomLongId();
  99 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  100 + MapUtil.<String, Object>builder().put("temperature", 25.5).put("humidity", 60).build());
  101 + // mock 数据:identifier=null 时两个属性 key 都会命中同一条规则,需在 sink 调用前去重
  102 + Long sinkId = randomLongId();
  103 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), null, sinkId);
  104 + // mock 方法
  105 + IotDataSinkDO sink = mockEnabledSink(sinkId);
  106 +
  107 + // 调用
  108 + dataRuleService.executeDataRule(message);
  109 +
  110 + // 断言:去重后只触发一次,而不是 2 次
  111 + verify(dataRuleAction).execute(eq(message), eq(sink));
  112 + }
  113 +
  114 + @Test
  115 + public void testExecuteDataRule_propertyPost_multiRuleSameSinkDeduped() {
  116 + // 准备参数:上报 {temperature, humidity},两条规则分别命中不同 identifier,但都指向同一 sink
  117 + Long deviceId = randomLongId();
  118 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  119 + MapUtil.<String, Object>builder().put("temperature", 25.5).put("humidity", 60).build());
  120 + // mock 数据:插入两条规则,identifier 分别为 temperature 与 humidity,sinkId 相同
  121 + Long sinkId = randomLongId();
  122 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), "temperature", sinkId);
  123 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), "humidity", sinkId);
  124 + // mock 方法
  125 + IotDataSinkDO sink = mockEnabledSink(sinkId);
  126 +
  127 + // 调用
  128 + dataRuleService.executeDataRule(message);
  129 +
  130 + // 断言:跨规则去重后,sink action 只触发一次,而不是 2 次
  131 + verify(dataRuleAction).execute(eq(message), eq(sink));
  132 + }
  133 +
  134 + @Test
  135 + public void testExecuteDataRule_propertyPost_emptyParamsMatchesWildcardRule() {
  136 + // 准备参数:上报空属性,规则 identifier=null 不限定属性,按"任意 property report 都同步"语义应命中
  137 + Long deviceId = randomLongId();
  138 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  139 + MapUtil.<String, Object>builder().build());
  140 + // mock 数据
  141 + Long sinkId = randomLongId();
  142 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), null, sinkId);
  143 + // mock 方法
  144 + IotDataSinkDO sink = mockEnabledSink(sinkId);
  145 +
  146 + // 调用
  147 + dataRuleService.executeDataRule(message);
  148 +
  149 + // 断言
  150 + verify(dataRuleAction).execute(eq(message), eq(sink));
  151 + }
  152 +
  153 + @Test
  154 + public void testExecuteDataRule_propertyPost_noIdentifierMatched() {
  155 + // 准备参数:上报 {temperature},规则限定 humidity
  156 + Long deviceId = randomLongId();
  157 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  158 + MapUtil.<String, Object>builder().put("temperature", 25.5).build());
  159 + // mock 数据
  160 + Long sinkId = randomLongId();
  161 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), "humidity", sinkId);
  162 +
  163 + // 调用
  164 + dataRuleService.executeDataRule(message);
  165 +
  166 + // 断言:sink action 不应被调用
  167 + verify(dataRuleAction, never()).execute(any(), any());
  168 + }
  169 +
  170 + @Test
  171 + public void testExecuteDataRule_eventPost_singleIdentifierMatched() {
  172 + // 准备参数:事件触发器走单 identifier 路径(与改动前行为保持一致)
  173 + Long deviceId = randomLongId();
  174 + String identifier = "alarm";
  175 + IotDeviceMessage message = randomPojo(IotDeviceMessage.class, o -> {
  176 + o.setDeviceId(deviceId);
  177 + o.setMethod(IotDeviceMessageMethodEnum.EVENT_POST.getMethod());
  178 + o.setParams(MapUtil.<String, Object>builder()
  179 + .put("identifier", identifier).put("value", "fired").build());
  180 + });
  181 + // mock 数据
  182 + Long sinkId = randomLongId();
  183 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), identifier, sinkId);
  184 + // mock 方法
  185 + IotDataSinkDO sink = mockEnabledSink(sinkId);
  186 +
  187 + // 调用
  188 + dataRuleService.executeDataRule(message);
  189 +
  190 + // 断言
  191 + verify(dataRuleAction).execute(eq(message), eq(sink));
  192 + }
  193 +
  194 + // ========== 辅助方法 ==========
  195 +
  196 + private IotDeviceMessage createPropertyPostMessage(Long deviceId, Map<String, Object> params) {
  197 + return randomPojo(IotDeviceMessage.class, o -> {
  198 + o.setDeviceId(deviceId);
  199 + o.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod());
  200 + o.setParams(params);
  201 + });
  202 + }
  203 +
  204 + /**
  205 + * 向 H2 中插入一条启用状态的数据流转规则,命中后会路由到 {@code sinkId}
  206 + */
  207 + private void insertEnabledRule(Long deviceId, String method, String identifier, Long sinkId) {
  208 + IotDataRuleDO.SourceConfig config = randomPojo(IotDataRuleDO.SourceConfig.class, o -> {
  209 + o.setDeviceId(deviceId);
  210 + o.setMethod(method);
  211 + o.setIdentifier(identifier);
  212 + });
  213 + IotDataRuleDO rule = randomPojo(IotDataRuleDO.class, o -> {
  214 + o.setId(null);
  215 + o.setStatus(CommonStatusEnum.ENABLE.getStatus());
  216 + o.setSourceConfigs(singletonList(config));
  217 + o.setSinkIds(singletonList(sinkId));
  218 + });
  219 + dataRuleMapper.insert(rule);
  220 + }
  221 +
  222 + /**
  223 + * 构造一个启用状态的数据流转目的并塞入对应 mock;返回 sink 用于断言
  224 + */
  225 + private IotDataSinkDO mockEnabledSink(Long sinkId) {
  226 + IotDataSinkDO sink = randomPojo(IotDataSinkDO.class, o -> {
  227 + o.setId(sinkId);
  228 + o.setStatus(CommonStatusEnum.ENABLE.getStatus());
  229 + });
  230 + when(dataSinkService.getDataSinkFromCache(sinkId)).thenReturn(sink);
  231 + when(dataRuleAction.getType()).thenReturn(sink.getType());
  232 + return sink;
  233 + }
  234 +
  235 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/clean.sql
... ... @@ -8,3 +8,4 @@ DELETE FROM &quot;iot_alert_record&quot;;
8 8 DELETE FROM "iot_ota_firmware";
9 9 DELETE FROM "iot_ota_task";
10 10 DELETE FROM "iot_ota_record";
  11 +DELETE FROM "iot_data_rule";
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql
... ... @@ -180,3 +180,19 @@ CREATE TABLE IF NOT EXISTS &quot;iot_ota_record&quot; (
180 180 "tenant_id" bigint NOT NULL DEFAULT '0',
181 181 PRIMARY KEY ("id")
182 182 ) COMMENT 'IoT OTA 升级记录表';
  183 +
  184 +CREATE TABLE IF NOT EXISTS "iot_data_rule" (
  185 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  186 + "name" varchar(128) NOT NULL,
  187 + "description" varchar(256) DEFAULT '',
  188 + "status" int NOT NULL,
  189 + "source_configs" varchar(10000) NOT NULL,
  190 + "sink_ids" varchar(512) NOT NULL,
  191 + "creator" varchar(64) DEFAULT '',
  192 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  193 + "updater" varchar(64) DEFAULT '',
  194 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  195 + "deleted" bit NOT NULL DEFAULT FALSE,
  196 + "tenant_id" bigint NOT NULL DEFAULT '0',
  197 + PRIMARY KEY ("id")
  198 +) COMMENT 'IoT 数据流转规则';
... ...
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java
1 1 package cn.iocoder.yudao.module.iot.core.util;
2 2  
3 3 import cn.hutool.core.lang.Assert;
4   -import cn.hutool.core.map.MapUtil;
5 4 import cn.hutool.core.util.IdUtil;
6 5 import cn.hutool.core.util.ReflectUtil;
7 6 import cn.hutool.core.util.StrUtil;
... ... @@ -10,7 +9,9 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
10 9 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
11 10 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
12 11  
  12 +import java.util.LinkedHashSet;
13 13 import java.util.Map;
  14 +import java.util.Set;
14 15  
15 16 /**
16 17 * IoT 设备【消息】的工具类
... ... @@ -90,6 +91,25 @@ public class IotDeviceMessageUtils {
90 91 }
91 92  
92 93 /**
  94 + * 获取属性上报消息中包含的所有属性标识符
  95 + *
  96 + * 仅支持扁平结构:{ temperature: 25.5, humidity: 60 },顶层 key 即属性标识符
  97 + *
  98 + * @param message 设备消息
  99 + * @return 属性标识符集合,不为 null
  100 + */
  101 + public static Set<String> getPropertyIdentifiers(IotDeviceMessage message) {
  102 + if (message == null) {
  103 + return new LinkedHashSet<>();
  104 + }
  105 + Map<String, Object> params = parseParamsToMap(message.getParams());
  106 + if (params == null) {
  107 + return new LinkedHashSet<>();
  108 + }
  109 + return new LinkedHashSet<>(params.keySet());
  110 + }
  111 +
  112 + /**
93 113 * 判断消息中是否包含指定的标识符
94 114 * <p>
95 115 * 对于不同消息类型的处理:
... ...
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java
... ... @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test;
7 7  
8 8 import java.util.HashMap;
9 9 import java.util.Map;
  10 +import java.util.Set;
10 11  
11 12 import static org.junit.jupiter.api.Assertions.*;
12 13  
... ... @@ -289,4 +290,62 @@ public class IotDeviceMessageUtilsTest {
289 290 assertTrue(notContainsResult);
290 291 assertEquals(!containsResult, notContainsResult);
291 292 }
  293 +
  294 + // ========== getPropertyIdentifiers 测试 ==========
  295 +
  296 + @Test
  297 + public void testGetPropertyIdentifiers_flatStructure() {
  298 + // 扁平结构:顶层 key 即标识符
  299 + IotDeviceMessage message = new IotDeviceMessage();
  300 + Map<String, Object> params = new HashMap<>();
  301 + params.put("temperature", 25.5);
  302 + params.put("humidity", 60);
  303 + message.setParams(params);
  304 +
  305 + Set<String> identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message);
  306 + assertEquals(2, identifiers.size());
  307 + assertTrue(identifiers.contains("temperature"));
  308 + assertTrue(identifiers.contains("humidity"));
  309 + }
  310 +
  311 + @Test
  312 + public void testGetPropertyIdentifiers_nullMessage() {
  313 + // 入参为 null:返回空集合
  314 + Set<String> identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(null);
  315 + assertNotNull(identifiers);
  316 + assertTrue(identifiers.isEmpty());
  317 + }
  318 +
  319 + @Test
  320 + public void testGetPropertyIdentifiers_nullParams() {
  321 + // params 为 null:返回空集合
  322 + IotDeviceMessage message = new IotDeviceMessage();
  323 + message.setParams(null);
  324 +
  325 + Set<String> identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message);
  326 + assertTrue(identifiers.isEmpty());
  327 + }
  328 +
  329 + @Test
  330 + public void testGetPropertyIdentifiers_emptyParams() {
  331 + // params 为空 Map:返回空集合
  332 + IotDeviceMessage message = new IotDeviceMessage();
  333 + message.setParams(new HashMap<>());
  334 +
  335 + Set<String> identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message);
  336 + assertTrue(identifiers.isEmpty());
  337 + }
  338 +
  339 + @Test
  340 + public void testGetPropertyIdentifiers_jsonStringParams() {
  341 + // params 为 JSON 字符串:parseParamsToMap 解析后正常提取顶层标识符
  342 + IotDeviceMessage message = new IotDeviceMessage();
  343 + message.setParams("{\"temperature\":25.5,\"humidity\":60}");
  344 +
  345 + Set<String> identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message);
  346 + assertEquals(2, identifiers.size());
  347 + assertTrue(identifiers.contains("temperature"));
  348 + assertTrue(identifiers.contains("humidity"));
  349 + }
  350 +
292 351 }
... ...