Commit acdeb6e5c8f032e1c6312c42020963359506ebea
1 parent
84c46ad6
refactor(iot): 移除 IoT 设备影子表及相关 SQL,重构影子协议工具类
- 删除 `iot_device_shadow` 表的 SQL 定义及相关菜单权限插入语句。 - 重构 `IotDeviceShadowController`,使用新的 `IotDeviceShadowProtocolUtils` 处理影子状态的时间戳。 - 更新 Redis DAO 和服务实现,确保影子数据的缓存逻辑与新工具类兼容。 - 增加影子透传编解码逻辑,支持 JSON 格式的设备透传。 - 补充单元测试,确保新逻辑的正确性与稳定性。
Showing
18 changed files
with
318 additions
and
152 deletions
sql/mysql/iot_device_shadow.sql deleted
| 1 | --- IoT 设备影子表 | |
| 2 | -CREATE TABLE IF NOT EXISTS `iot_device_shadow` | |
| 3 | -( | |
| 4 | - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', | |
| 5 | - `device_id` bigint NOT NULL COMMENT '设备编号', | |
| 6 | - `product_id` bigint NOT NULL COMMENT '产品编号', | |
| 7 | - `product_key` varchar(64) NOT NULL COMMENT '产品标识', | |
| 8 | - `device_name` varchar(255) NOT NULL COMMENT '设备名称', | |
| 9 | - `reported` json DEFAULT NULL COMMENT '上报状态', | |
| 10 | - `desired` json DEFAULT NULL COMMENT '期望状态', | |
| 11 | - `metadata` json DEFAULT NULL COMMENT '元数据', | |
| 12 | - `version` bigint NOT NULL DEFAULT 0 COMMENT '版本号', | |
| 13 | - `shadow_time` bigint NOT NULL DEFAULT 0 COMMENT '文档最后更新时间', | |
| 14 | - `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态', | |
| 15 | - `creator` varchar(64) DEFAULT '' COMMENT '创建者', | |
| 16 | - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 17 | - `updater` varchar(64) DEFAULT '' COMMENT '更新者', | |
| 18 | - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', | |
| 19 | - `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', | |
| 20 | - `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', | |
| 21 | - PRIMARY KEY (`id`), | |
| 22 | - UNIQUE KEY `uk_device_id` (`device_id`) | |
| 23 | -) ENGINE = InnoDB | |
| 24 | - DEFAULT CHARSET = utf8mb4 COMMENT ='IoT 设备影子'; | |
| 25 | - | |
| 26 | --- 菜单权限 | |
| 27 | -INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, | |
| 28 | - `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, | |
| 29 | - `updater`, `update_time`, `deleted`) | |
| 30 | -VALUES (4056, '设备影子查询', 'iot:device:shadow-query', 3, 13, 4008, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), | |
| 31 | - '1', NOW(), b'0'); | |
| 32 | -INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, | |
| 33 | - `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, | |
| 34 | - `updater`, `update_time`, `deleted`) | |
| 35 | -VALUES (4057, '设备影子更新', 'iot:device:shadow-update', 3, 14, 4008, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), | |
| 36 | - '1', NOW(), b'0'); |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceShadowController.java
| ... | ... | @@ -3,11 +3,12 @@ package cn.iocoder.yudao.module.iot.controller.admin.device; |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | 4 | import cn.hutool.core.map.MapUtil; |
| 5 | 5 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 6 | +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | |
| 6 | 7 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowPropertyRespVO; |
| 7 | 8 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowRespVO; |
| 8 | 9 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowUpdateDesiredReqVO; |
| 9 | 10 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowUpdateStatusReqVO; |
| 10 | -import cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowMetadatas; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils; | |
| 11 | 12 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; |
| 12 | 13 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO; |
| 13 | 14 | import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; |
| ... | ... | @@ -57,29 +58,26 @@ public class IotDeviceShadowController { |
| 57 | 58 | Map<String, Object> desired = shadow.getDesired() != null ? shadow.getDesired() : MapUtil.empty(); |
| 58 | 59 | Map<String, Object> reported = shadow.getReported() != null ? shadow.getReported() : MapUtil.empty(); |
| 59 | 60 | Map<String, Object> metadata = shadow.getMetadata() != null ? shadow.getMetadata() : MapUtil.empty(); |
| 61 | + // 拼接 VO | |
| 60 | 62 | List<IotDeviceShadowPropertyRespVO> properties = convertList(thingModels, thingModel -> { |
| 61 | 63 | ThingModelProperty prop = thingModel.getProperty(); |
| 62 | 64 | Assert.notNull(prop, "属性不能为空"); |
| 63 | 65 | return new IotDeviceShadowPropertyRespVO() |
| 64 | - .setIdentifier(thingModel.getIdentifier()) | |
| 65 | - .setName(thingModel.getName()) | |
| 66 | - .setDataType(prop.getDataType()) | |
| 67 | - .setDataSpecs(prop.getDataSpecs()) | |
| 68 | - .setDataSpecsList(prop.getDataSpecsList()) | |
| 66 | + .setIdentifier(thingModel.getIdentifier()).setName(thingModel.getName()) | |
| 67 | + .setDataType(prop.getDataType()).setDataSpecs(prop.getDataSpecs()).setDataSpecsList(prop.getDataSpecsList()) | |
| 69 | 68 | .setDesiredValue(desired.get(thingModel.getIdentifier())) |
| 70 | 69 | .setReportedValue(reported.get(thingModel.getIdentifier())) |
| 71 | - .setDesiredTime(IotDeviceShadowMetadatas.getDesiredTimestamp(metadata, thingModel.getIdentifier())) | |
| 72 | - .setReportedTime(IotDeviceShadowMetadatas.getReportedTimestamp(metadata, thingModel.getIdentifier())); | |
| 70 | + .setDesiredTime(IotDeviceShadowProtocolUtils.getDesiredTimestamp(metadata, thingModel.getIdentifier())) | |
| 71 | + .setReportedTime(IotDeviceShadowProtocolUtils.getReportedTimestamp(metadata, thingModel.getIdentifier())); | |
| 73 | 72 | }); |
| 74 | 73 | |
| 75 | - return success(new IotDeviceShadowRespVO() | |
| 76 | - .setVersion(shadow.getVersion()) | |
| 77 | - .setShadowTime(shadow.getShadowTime()) | |
| 78 | - .setStatus(shadow.getStatus()) | |
| 79 | - .setProperties(properties) | |
| 80 | - .setDesired(desired) | |
| 81 | - .setReported(reported) | |
| 82 | - .setMetadata(metadata)); | |
| 74 | + IotDeviceShadowRespVO respVO = BeanUtils.toBean(shadow, IotDeviceShadowRespVO.class); | |
| 75 | + // toBean 会拷贝 DO 中同名标量;Map 字段须用下方已归一化的局部变量覆盖,避免 null 透传前端 | |
| 76 | + respVO.setProperties(properties); | |
| 77 | + respVO.setDesired(desired); | |
| 78 | + respVO.setReported(reported); | |
| 79 | + respVO.setMetadata(metadata); | |
| 80 | + return success(respVO); | |
| 83 | 81 | } |
| 84 | 82 | |
| 85 | 83 | @PutMapping("/update-desired") | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java
| ... | ... | @@ -21,6 +21,7 @@ public interface RedisKeyConstants { |
| 21 | 21 | /** |
| 22 | 22 | * 设备影子数据缓存,采用 STRING 结构 |
| 23 | 23 | * <p> |
| 24 | + * 影子缓存:DB 为准,Redis 仅读缓存(cache-aside),写后失效。 | |
| 24 | 25 | * KEY 格式:device_shadow:{deviceId} |
| 25 | 26 | * VALUE 数据类型:String(JSON) {@link cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO} |
| 26 | 27 | */ | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceShadowRedisDAO.java
| ... | ... | @@ -10,6 +10,10 @@ import static cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants.DEVICE_SHA |
| 10 | 10 | |
| 11 | 11 | /** |
| 12 | 12 | * {@link IotDeviceShadowDO} 的 Redis DAO |
| 13 | + * <p> | |
| 14 | + * 影子缓存:DB 为准,Redis 仅作读缓存(cache-aside),写后失效。 | |
| 15 | + * | |
| 16 | + * @author 芋道源码 | |
| 13 | 17 | */ |
| 14 | 18 | @Repository |
| 15 | 19 | public class DeviceShadowRedisDAO { | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/shadow/IotDeviceShadowServiceImpl.java
| ... | ... | @@ -7,7 +7,7 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 7 | 7 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 8 | 8 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 9 | 9 | import cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowErrorCodeEnum; |
| 10 | -import cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowMetadatas; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils; | |
| 11 | 11 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 12 | 12 | import cn.iocoder.yudao.module.iot.core.topic.shadow.IotDeviceShadowReplyDTO; |
| 13 | 13 | import cn.iocoder.yudao.module.iot.core.topic.shadow.IotDeviceShadowUpdateReqDTO; |
| ... | ... | @@ -32,8 +32,8 @@ import java.util.Map; |
| 32 | 32 | import java.util.function.Function; |
| 33 | 33 | |
| 34 | 34 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 35 | -import static cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowProtocolConstants.INNER_METHOD_GET; | |
| 36 | -import static cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowProtocolConstants.INNER_METHOD_UPDATE; | |
| 35 | +import static cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils.INNER_METHOD_GET; | |
| 36 | +import static cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils.INNER_METHOD_UPDATE; | |
| 37 | 37 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_SHADOW_NOT_ENABLED; |
| 38 | 38 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_SHADOW_VERSION_CONFLICT; |
| 39 | 39 | |
| ... | ... | @@ -54,17 +54,19 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 54 | 54 | private IotDeviceShadowMapper deviceShadowMapper; |
| 55 | 55 | @Resource |
| 56 | 56 | private DeviceShadowRedisDAO deviceShadowRedisDAO; |
| 57 | + | |
| 57 | 58 | @Resource |
| 58 | 59 | private IotDeviceService deviceService; |
| 59 | 60 | @Resource |
| 60 | 61 | private IotDevicePropertyService devicePropertyService; |
| 62 | + // 解决 IotDeviceShadowServiceImpl 与 IotDeviceMessageService 循环依赖 | |
| 61 | 63 | @Resource |
| 62 | 64 | @Lazy |
| 63 | 65 | private IotDeviceMessageService deviceMessageService; |
| 64 | 66 | |
| 65 | 67 | @Override |
| 66 | 68 | public IotDeviceShadowDO getOrCreateShadow(IotDeviceDO device) { |
| 67 | - IotDeviceShadowDO shadow = getShadowFromCacheOrDb(device.getId()); | |
| 69 | + IotDeviceShadowDO shadow = getDeviceShadowFromCacheOrDb(device.getId()); | |
| 68 | 70 | if (shadow != null) { |
| 69 | 71 | return shadow; |
| 70 | 72 | } |
| ... | ... | @@ -204,7 +206,7 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 204 | 206 | @Override |
| 205 | 207 | public void resetShadow(Long deviceId) { |
| 206 | 208 | deviceService.validateDeviceExists(deviceId); |
| 207 | - IotDeviceShadowDO shadow = getShadowFromCacheOrDb(deviceId); | |
| 209 | + IotDeviceShadowDO shadow = getDeviceShadowFromCacheOrDb(deviceId); | |
| 208 | 210 | if (shadow == null) { |
| 209 | 211 | return; |
| 210 | 212 | } |
| ... | ... | @@ -219,7 +221,7 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 219 | 221 | @Transactional(rollbackFor = Exception.class) |
| 220 | 222 | public void updateStatus(Long deviceId, Integer status) { |
| 221 | 223 | IotDeviceDO device = deviceService.validateDeviceExists(deviceId); |
| 222 | - IotDeviceShadowDO shadow = getShadowFromCacheOrDb(deviceId); | |
| 224 | + IotDeviceShadowDO shadow = getDeviceShadowFromCacheOrDb(deviceId); | |
| 223 | 225 | if (shadow == null) { |
| 224 | 226 | // 影子行不存在时直接按目标 status 懒建,避免后续 getOrCreateShadow 始终以 ENABLE 插入 |
| 225 | 227 | IotDeviceShadowDO newShadow = buildEmptyShadow(device).status(status).build(); |
| ... | ... | @@ -251,7 +253,8 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 251 | 253 | // ========== 内部方法 ========== |
| 252 | 254 | |
| 253 | 255 | private IotDeviceShadowDO buildReportedUpdate(IotDeviceShadowDO latest, IotDeviceShadowUpdateReqDTO req) { |
| 254 | - if (req.getVersion() != null && req.getVersion() < latest.getVersion()) { | |
| 256 | + // 乐观锁:带 version 时必须与当前完全一致(null=强制更新、-1=清空已在上层处理) | |
| 257 | + if (req.getVersion() != null && !req.getVersion().equals(latest.getVersion())) { | |
| 255 | 258 | throw new ShadowUpdateRejectedException(latest.getVersion(), IotDeviceShadowErrorCodeEnum.VERSION_CONFLICT); |
| 256 | 259 | } |
| 257 | 260 | Map<String, Object> mergedReported = mergeMap(latest.getReported(), req.getState().getReported()); |
| ... | ... | @@ -260,7 +263,7 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 260 | 263 | } |
| 261 | 264 | long now = DateUtil.currentSeconds(); |
| 262 | 265 | Map<String, Object> metadata = latest.getMetadata() != null ? new HashMap<>(latest.getMetadata()) : new HashMap<>(); |
| 263 | - IotDeviceShadowMetadatas.putReportedTimestamps(metadata, req.getState().getReported().keySet(), now); | |
| 266 | + IotDeviceShadowProtocolUtils.putReportedTimestamps(metadata, req.getState().getReported().keySet(), now); | |
| 264 | 267 | return IotDeviceShadowDO.builder() |
| 265 | 268 | .reported(mergedReported) |
| 266 | 269 | .metadata(metadata) |
| ... | ... | @@ -273,7 +276,7 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 273 | 276 | long now = DateUtil.currentSeconds(); |
| 274 | 277 | Map<String, Object> mergedDesired = mergeMap(latest.getDesired(), desired); |
| 275 | 278 | Map<String, Object> metadata = latest.getMetadata() != null ? new HashMap<>(latest.getMetadata()) : new HashMap<>(); |
| 276 | - IotDeviceShadowMetadatas.putDesiredTimestamps(metadata, desired.keySet(), now); | |
| 279 | + IotDeviceShadowProtocolUtils.putDesiredTimestamps(metadata, desired.keySet(), now); | |
| 277 | 280 | return IotDeviceShadowDO.builder() |
| 278 | 281 | .desired(mergedDesired) |
| 279 | 282 | .metadata(metadata) |
| ... | ... | @@ -302,7 +305,7 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 302 | 305 | for (int i = 0; i < OPTIMISTIC_LOCK_RETRY; i++) { |
| 303 | 306 | // 首次尝试走缓存,重试时直读 DB 获取最新 version |
| 304 | 307 | IotDeviceShadowDO latest = i == 0 |
| 305 | - ? getShadowFromCacheOrDb(deviceId) | |
| 308 | + ? getDeviceShadowFromCacheOrDb(deviceId) | |
| 306 | 309 | : deviceShadowMapper.selectByDeviceId(deviceId); |
| 307 | 310 | if (latest == null) { |
| 308 | 311 | throw ShadowCasExhaustedException.shadowNotFound(); |
| ... | ... | @@ -311,6 +314,7 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 311 | 314 | IotDeviceShadowDO update = updateBuilder.apply(latest); |
| 312 | 315 | int rows = deviceShadowMapper.updateByDeviceIdAndVersion(update, deviceId, latest.getVersion()); |
| 313 | 316 | if (rows > 0) { |
| 317 | + // cache-aside:写后失效 Redis 读缓存 | |
| 314 | 318 | deviceShadowRedisDAO.delete(deviceId); |
| 315 | 319 | enrichCommittedShadow(update, latest); |
| 316 | 320 | return update; |
| ... | ... | @@ -352,7 +356,7 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 352 | 356 | |
| 353 | 357 | private Long resolveShadowVersionSafely(Long deviceId) { |
| 354 | 358 | try { |
| 355 | - IotDeviceShadowDO shadow = getShadowFromCacheOrDb(deviceId); | |
| 359 | + IotDeviceShadowDO shadow = getDeviceShadowFromCacheOrDb(deviceId); | |
| 356 | 360 | return shadow != null ? shadow.getVersion() : 0L; |
| 357 | 361 | } catch (Exception ex) { |
| 358 | 362 | log.warn("[resolveShadowVersionSafely][device({}) 读取 version 失败,使用保底 0]", deviceId, ex); |
| ... | ... | @@ -373,7 +377,10 @@ public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { |
| 373 | 377 | .shadowTime(DateUtil.currentSeconds()); |
| 374 | 378 | } |
| 375 | 379 | |
| 376 | - private IotDeviceShadowDO getShadowFromCacheOrDb(Long deviceId) { | |
| 380 | + /** | |
| 381 | + * cache-aside 读路径:Redis 命中则返回,否则查 DB 并回填缓存 | |
| 382 | + */ | |
| 383 | + private IotDeviceShadowDO getDeviceShadowFromCacheOrDb(Long deviceId) { | |
| 377 | 384 | IotDeviceShadowDO shadow = deviceShadowRedisDAO.get(deviceId); |
| 378 | 385 | if (shadow != null) { |
| 379 | 386 | return shadow; | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/shadow/IotDeviceShadowServiceImplTest.java
| ... | ... | @@ -54,6 +54,20 @@ public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest { |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | 56 | @Test |
| 57 | + public void testGetOrCreateShadow_whenNotExists_insertNewRow() { | |
| 58 | + // 影子不存在时懒插入;并发下 DuplicateKeyException 由 getOrCreateShadow 兜底 | |
| 59 | + IotDeviceDO device = buildDevice(); | |
| 60 | + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null); | |
| 61 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(null); | |
| 62 | + | |
| 63 | + service.getOrCreateShadow(device); | |
| 64 | + | |
| 65 | + ArgumentCaptor<IotDeviceShadowDO> captor = ArgumentCaptor.forClass(IotDeviceShadowDO.class); | |
| 66 | + verify(deviceShadowMapper).insert(captor.capture()); | |
| 67 | + assertEquals(device.getId(), captor.getValue().getDeviceId()); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 57 | 71 | public void testHandleDeviceShadowUpdate_get_replyFullShadow() { |
| 58 | 72 | IotDeviceDO device = buildDevice(); |
| 59 | 73 | IotDeviceShadowDO shadow = buildShadow(device, 2L); |
| ... | ... | @@ -106,6 +120,59 @@ public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest { |
| 106 | 120 | } |
| 107 | 121 | |
| 108 | 122 | @Test |
| 123 | + public void testHandleDeviceShadowUpdate_versionGreaterThanLatest_send409() { | |
| 124 | + IotDeviceDO device = buildDevice(); | |
| 125 | + IotDeviceShadowDO shadow = buildShadow(device, 1L); | |
| 126 | + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null); | |
| 127 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow); | |
| 128 | + | |
| 129 | + Map<String, Object> reported = new HashMap<>(); | |
| 130 | + reported.put("Temperature", 25); | |
| 131 | + Map<String, Object> state = new HashMap<>(); | |
| 132 | + state.put("reported", reported); | |
| 133 | + Map<String, Object> params = new HashMap<>(); | |
| 134 | + params.put("method", "update"); | |
| 135 | + params.put("state", state); | |
| 136 | + params.put("version", 3L); | |
| 137 | + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1", | |
| 138 | + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params); | |
| 139 | + | |
| 140 | + service.handleDeviceShadowUpdate(device, message); | |
| 141 | + | |
| 142 | + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 143 | + verify(deviceMessageService).sendDeviceMessage(captor.capture(), eq(device), eq("server-1")); | |
| 144 | + IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams(); | |
| 145 | + @SuppressWarnings("unchecked") | |
| 146 | + Map<String, Object> payload = (Map<String, Object>) reply.getPayload(); | |
| 147 | + assertEquals(409, payload.get("code")); | |
| 148 | + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); | |
| 149 | + } | |
| 150 | + | |
| 151 | + @Test | |
| 152 | + public void testHandleDeviceShadowUpdate_versionNull_forceUpdateWithoutVersionCheck() { | |
| 153 | + IotDeviceDO device = buildDevice(); | |
| 154 | + IotDeviceShadowDO shadow = buildShadow(device, 5L); | |
| 155 | + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null); | |
| 156 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow); | |
| 157 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(5L))).thenReturn(1); | |
| 158 | + | |
| 159 | + Map<String, Object> reported = new HashMap<>(); | |
| 160 | + reported.put("Temperature", 25); | |
| 161 | + Map<String, Object> state = new HashMap<>(); | |
| 162 | + state.put("reported", reported); | |
| 163 | + Map<String, Object> params = new HashMap<>(); | |
| 164 | + params.put("method", "update"); | |
| 165 | + params.put("state", state); | |
| 166 | + // 不传 version → 强制更新,跳过版本精确匹配 | |
| 167 | + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1", | |
| 168 | + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params); | |
| 169 | + | |
| 170 | + service.handleDeviceShadowUpdate(device, message); | |
| 171 | + | |
| 172 | + verify(deviceShadowMapper).updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(5L)); | |
| 173 | + } | |
| 174 | + | |
| 175 | + @Test | |
| 109 | 176 | public void testHandleDeviceShadowUpdate_versionConflict_send409() { |
| 110 | 177 | IotDeviceDO device = buildDevice(); |
| 111 | 178 | IotDeviceShadowDO shadow = buildShadow(device, 5L); |
| ... | ... | @@ -131,7 +198,7 @@ public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest { |
| 131 | 198 | assertEquals("update_reply", reply.getMethod()); |
| 132 | 199 | @SuppressWarnings("unchecked") |
| 133 | 200 | Map<String, Object> payload = (Map<String, Object>) reply.getPayload(); |
| 134 | - assertEquals("error", payload.get("status")); | |
| 201 | + assertEquals(409, payload.get("code")); | |
| 135 | 202 | verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); |
| 136 | 203 | } |
| 137 | 204 | |
| ... | ... | @@ -244,9 +311,7 @@ public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest { |
| 244 | 311 | IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams(); |
| 245 | 312 | @SuppressWarnings("unchecked") |
| 246 | 313 | Map<String, Object> payload = (Map<String, Object>) reply.getPayload(); |
| 247 | - @SuppressWarnings("unchecked") | |
| 248 | - Map<String, Object> content = (Map<String, Object>) payload.get("content"); | |
| 249 | - assertEquals(401, content.get("errorcode")); | |
| 314 | + assertEquals(401, payload.get("code")); | |
| 250 | 315 | } |
| 251 | 316 | |
| 252 | 317 | @Test |
| ... | ... | @@ -283,7 +348,7 @@ public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest { |
| 283 | 348 | Map<String, Object> params = new HashMap<>(); |
| 284 | 349 | params.put("method", "update"); |
| 285 | 350 | params.put("state", state); |
| 286 | - params.put("version", 2L); | |
| 351 | + // version=null:强制更新,CAS 重试时不受请求 version 约束 | |
| 287 | 352 | IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1", |
| 288 | 353 | IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params); |
| 289 | 354 | |
| ... | ... | @@ -367,9 +432,7 @@ public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest { |
| 367 | 432 | assertEquals(0L, reply.getVersion()); |
| 368 | 433 | @SuppressWarnings("unchecked") |
| 369 | 434 | Map<String, Object> payload = (Map<String, Object>) reply.getPayload(); |
| 370 | - @SuppressWarnings("unchecked") | |
| 371 | - Map<String, Object> content = (Map<String, Object>) payload.get("content"); | |
| 372 | - assertEquals(500, content.get("errorcode")); | |
| 435 | + assertEquals(500, payload.get("code")); | |
| 373 | 436 | } |
| 374 | 437 | |
| 375 | 438 | @Test |
| ... | ... | @@ -397,9 +460,7 @@ public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest { |
| 397 | 460 | IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams(); |
| 398 | 461 | @SuppressWarnings("unchecked") |
| 399 | 462 | Map<String, Object> payload = (Map<String, Object>) reply.getPayload(); |
| 400 | - @SuppressWarnings("unchecked") | |
| 401 | - Map<String, Object> content = (Map<String, Object>) payload.get("content"); | |
| 402 | - assertEquals(500, content.get("errorcode")); | |
| 463 | + assertEquals(500, payload.get("code")); | |
| 403 | 464 | verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); |
| 404 | 465 | } |
| 405 | 466 | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/shadow/IotDeviceShadowErrorCodeEnum.java
| ... | ... | @@ -6,7 +6,8 @@ import lombok.Getter; |
| 6 | 6 | /** |
| 7 | 7 | * IoT 设备影子设备侧错误码枚举 |
| 8 | 8 | * <p> |
| 9 | - * 用于回填 update_reply.payload.content,不走 CommonResult | |
| 9 | + * 设备侧 wire code,仅用于回填 {@code update_reply.payload.code/msg},不走 {@link cn.iocoder.yudao.framework.common.pojo.CommonResult}。 | |
| 10 | + * 服务端内部异常请使用 {@link cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants}(如 {@code DEVICE_SHADOW_NOT_ENABLED})。 | |
| 10 | 11 | * |
| 11 | 12 | * @author 芋道源码 |
| 12 | 13 | */ | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/shadow/IotDeviceShadowProtocolConstants.java deleted
| 1 | -package cn.iocoder.yudao.module.iot.core.enums.shadow; | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * IoT 设备影子协议常量 | |
| 5 | - * <p> | |
| 6 | - * 内层 method、state 键名等与设备侧透传 JSON 对齐 | |
| 7 | - * | |
| 8 | - * @author 芋道源码 | |
| 9 | - */ | |
| 10 | -public final class IotDeviceShadowProtocolConstants { | |
| 11 | - | |
| 12 | - private IotDeviceShadowProtocolConstants() { | |
| 13 | - } | |
| 14 | - | |
| 15 | - // ========== 内层 method ========== | |
| 16 | - | |
| 17 | - public static final String INNER_METHOD_GET = "get"; | |
| 18 | - public static final String INNER_METHOD_UPDATE = "update"; | |
| 19 | - public static final String INNER_METHOD_GET_REPLY = "get_reply"; | |
| 20 | - public static final String INNER_METHOD_UPDATE_REPLY = "update_reply"; | |
| 21 | - | |
| 22 | - // ========== state / metadata 键 ========== | |
| 23 | - | |
| 24 | - public static final String STATE_DESIRED = "desired"; | |
| 25 | - public static final String STATE_REPORTED = "reported"; | |
| 26 | - public static final String METADATA_TIMESTAMP = "timestamp"; | |
| 27 | - | |
| 28 | - // ========== payload 键 ========== | |
| 29 | - | |
| 30 | - public static final String PAYLOAD_STATUS = "status"; | |
| 31 | - public static final String PAYLOAD_STATE = "state"; | |
| 32 | - public static final String PAYLOAD_VERSION = "version"; | |
| 33 | - public static final String PAYLOAD_CONTENT = "content"; | |
| 34 | - public static final String PAYLOAD_ERROR_CODE = "errorcode"; | |
| 35 | - public static final String PAYLOAD_ERROR_MESSAGE = "errormessage"; | |
| 36 | - | |
| 37 | - // ========== payload status 值 ========== | |
| 38 | - | |
| 39 | - public static final String STATUS_SUCCESS = "success"; | |
| 40 | - public static final String STATUS_ERROR = "error"; | |
| 41 | - | |
| 42 | -} |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/shadow/IotDeviceShadowReplyDTO.java
| ... | ... | @@ -8,7 +8,8 @@ import lombok.NoArgsConstructor; |
| 8 | 8 | import java.util.HashMap; |
| 9 | 9 | import java.util.Map; |
| 10 | 10 | |
| 11 | -import static cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowProtocolConstants.*; | |
| 11 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS; | |
| 12 | +import static cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils.*; | |
| 12 | 13 | |
| 13 | 14 | /** |
| 14 | 15 | * IoT 设备影子下行 Reply DTO |
| ... | ... | @@ -28,7 +29,7 @@ public class IotDeviceShadowReplyDTO { |
| 28 | 29 | private String method; |
| 29 | 30 | |
| 30 | 31 | /** |
| 31 | - * 回复载荷 | |
| 32 | + * 回复载荷(对齐 CommonResult:code / msg / data) | |
| 32 | 33 | */ |
| 33 | 34 | private Object payload; |
| 34 | 35 | |
| ... | ... | @@ -47,9 +48,9 @@ public class IotDeviceShadowReplyDTO { |
| 47 | 48 | Map<String, Object> state = new HashMap<>(); |
| 48 | 49 | state.put(STATE_DESIRED, desired != null ? desired : new HashMap<>()); |
| 49 | 50 | state.put(STATE_REPORTED, reported != null ? reported : new HashMap<>()); |
| 50 | - Map<String, Object> payload = new HashMap<>(); | |
| 51 | - payload.put(PAYLOAD_STATUS, STATUS_SUCCESS); | |
| 52 | - payload.put(PAYLOAD_STATE, state); | |
| 51 | + Map<String, Object> data = new HashMap<>(); | |
| 52 | + data.put(PAYLOAD_STATE, state); | |
| 53 | + Map<String, Object> payload = buildSuccessPayload(data); | |
| 53 | 54 | return new IotDeviceShadowReplyDTO() |
| 54 | 55 | .setMethod(INNER_METHOD_GET_REPLY) |
| 55 | 56 | .setPayload(payload) |
| ... | ... | @@ -58,9 +59,9 @@ public class IotDeviceShadowReplyDTO { |
| 58 | 59 | } |
| 59 | 60 | |
| 60 | 61 | public static IotDeviceShadowReplyDTO updateReplyOf(Long version, Long timestamp) { |
| 61 | - Map<String, Object> payload = new HashMap<>(); | |
| 62 | - payload.put(PAYLOAD_STATUS, STATUS_SUCCESS); | |
| 63 | - payload.put(PAYLOAD_VERSION, version); | |
| 62 | + Map<String, Object> data = new HashMap<>(); | |
| 63 | + data.put(PAYLOAD_VERSION, version); | |
| 64 | + Map<String, Object> payload = buildSuccessPayload(data); | |
| 64 | 65 | return new IotDeviceShadowReplyDTO() |
| 65 | 66 | .setMethod(INNER_METHOD_UPDATE_REPLY) |
| 66 | 67 | .setPayload(payload) |
| ... | ... | @@ -68,14 +69,13 @@ public class IotDeviceShadowReplyDTO { |
| 68 | 69 | .setTimestamp(timestamp); |
| 69 | 70 | } |
| 70 | 71 | |
| 71 | - public static IotDeviceShadowReplyDTO errorOf(Long version, Integer errorcode, String errormessage, Long timestamp) { | |
| 72 | - Map<String, Object> content = new HashMap<>(); | |
| 73 | - content.put(PAYLOAD_ERROR_CODE, errorcode); | |
| 74 | - content.put(PAYLOAD_ERROR_MESSAGE, errormessage); | |
| 72 | + public static IotDeviceShadowReplyDTO errorOf(Long version, Integer errorCode, String errorMsg, Long timestamp) { | |
| 73 | + Map<String, Object> data = new HashMap<>(); | |
| 74 | + data.put(PAYLOAD_VERSION, version); | |
| 75 | 75 | Map<String, Object> payload = new HashMap<>(); |
| 76 | - payload.put(PAYLOAD_STATUS, STATUS_ERROR); | |
| 77 | - payload.put(PAYLOAD_VERSION, version); | |
| 78 | - payload.put(PAYLOAD_CONTENT, content); | |
| 76 | + payload.put(PAYLOAD_CODE, errorCode); | |
| 77 | + payload.put(PAYLOAD_MSG, errorMsg); | |
| 78 | + payload.put(PAYLOAD_DATA, data); | |
| 79 | 79 | return new IotDeviceShadowReplyDTO() |
| 80 | 80 | .setMethod(INNER_METHOD_UPDATE_REPLY) |
| 81 | 81 | .setPayload(payload) |
| ... | ... | @@ -83,4 +83,12 @@ public class IotDeviceShadowReplyDTO { |
| 83 | 83 | .setTimestamp(timestamp); |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | + private static Map<String, Object> buildSuccessPayload(Map<String, Object> data) { | |
| 87 | + Map<String, Object> payload = new HashMap<>(); | |
| 88 | + payload.put(PAYLOAD_CODE, SUCCESS.getCode()); | |
| 89 | + payload.put(PAYLOAD_MSG, SUCCESS.getMsg()); | |
| 90 | + payload.put(PAYLOAD_DATA, data); | |
| 91 | + return payload; | |
| 92 | + } | |
| 93 | + | |
| 86 | 94 | } | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/shadow/IotDeviceShadowMetadatas.java renamed to yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceShadowProtocolUtils.java
| 1 | -package cn.iocoder.yudao.module.iot.core.enums.shadow; | |
| 1 | +package cn.iocoder.yudao.module.iot.core.util; | |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.map.MapUtil; |
| 4 | 4 | |
| ... | ... | @@ -6,20 +6,39 @@ import java.util.HashMap; |
| 6 | 6 | import java.util.Map; |
| 7 | 7 | import java.util.Set; |
| 8 | 8 | |
| 9 | -import static cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowProtocolConstants.*; | |
| 10 | - | |
| 11 | 9 | /** |
| 12 | - * IoT 设备影子 metadata 读写工具 | |
| 10 | + * IoT 设备影子协议工具 | |
| 13 | 11 | * <p> |
| 14 | - * 结构:{ desired: { identifier: { timestamp } }, reported: { ... } } | |
| 12 | + * 内层 method、state 键名、payload 字段等与设备侧透传 JSON 对齐;并提供 metadata 读写。 | |
| 15 | 13 | * |
| 16 | 14 | * @author 芋道源码 |
| 17 | 15 | */ |
| 18 | -public final class IotDeviceShadowMetadatas { | |
| 16 | +public final class IotDeviceShadowProtocolUtils { | |
| 19 | 17 | |
| 20 | - private IotDeviceShadowMetadatas() { | |
| 18 | + private IotDeviceShadowProtocolUtils() { | |
| 21 | 19 | } |
| 22 | 20 | |
| 21 | + // ========== 内层 method ========== | |
| 22 | + | |
| 23 | + public static final String INNER_METHOD_GET = "get"; | |
| 24 | + public static final String INNER_METHOD_UPDATE = "update"; | |
| 25 | + public static final String INNER_METHOD_GET_REPLY = "get_reply"; | |
| 26 | + public static final String INNER_METHOD_UPDATE_REPLY = "update_reply"; | |
| 27 | + | |
| 28 | + // ========== state / metadata 键 ========== | |
| 29 | + | |
| 30 | + public static final String STATE_DESIRED = "desired"; | |
| 31 | + public static final String STATE_REPORTED = "reported"; | |
| 32 | + public static final String METADATA_TIMESTAMP = "timestamp"; | |
| 33 | + | |
| 34 | + // ========== payload 键(对齐 CommonResult:code / msg / data)========== | |
| 35 | + | |
| 36 | + public static final String PAYLOAD_CODE = "code"; | |
| 37 | + public static final String PAYLOAD_MSG = "msg"; | |
| 38 | + public static final String PAYLOAD_DATA = "data"; | |
| 39 | + public static final String PAYLOAD_STATE = "state"; | |
| 40 | + public static final String PAYLOAD_VERSION = "version"; | |
| 41 | + | |
| 23 | 42 | /** |
| 24 | 43 | * 获取 desired 下某属性的更新时间戳 |
| 25 | 44 | * | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamHandler.java
| ... | ... | @@ -55,6 +55,11 @@ public class IotEmqxDownstreamHandler { |
| 55 | 55 | // 2.2 构建载荷 |
| 56 | 56 | byte[] payload = deviceMessageService.serializeDeviceMessage(message, deviceInfo.getProductKey(), |
| 57 | 57 | deviceInfo.getDeviceName()); |
| 58 | + if (payload == null) { | |
| 59 | + log.warn("[handle][影子下行跳过,设备 ID: {},method: {},非 JSON 协议设备不支持影子透传]", | |
| 60 | + message.getDeviceId(), message.getMethod()); | |
| 61 | + return; | |
| 62 | + } | |
| 58 | 63 | |
| 59 | 64 | // 3. 发布消息 |
| 60 | 65 | protocol.publishMessage(topic, payload); | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java
| ... | ... | @@ -17,6 +17,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream.IotMqt |
| 17 | 17 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream.IotMqttRegisterHandler; |
| 18 | 18 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream.IotMqttUpstreamHandler; |
| 19 | 19 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; |
| 20 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | |
| 20 | 21 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 21 | 22 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 22 | 23 | import io.netty.handler.codec.mqtt.MqttConnectReturnCode; |
| ... | ... | @@ -101,9 +102,10 @@ public class IotMqttProtocol implements IotProtocol { |
| 101 | 102 | // 初始化 Handler |
| 102 | 103 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| 103 | 104 | IotDeviceCommonApi deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); |
| 105 | + IotDeviceService deviceService = SpringUtil.getBean(IotDeviceService.class); | |
| 104 | 106 | this.authHandler = new IotMqttAuthHandler(connectionManager, deviceMessageService, deviceApi, serverId); |
| 105 | 107 | this.registerHandler = new IotMqttRegisterHandler(connectionManager, deviceMessageService); |
| 106 | - this.upstreamHandler = new IotMqttUpstreamHandler(connectionManager, deviceMessageService, serverId); | |
| 108 | + this.upstreamHandler = new IotMqttUpstreamHandler(connectionManager, deviceMessageService, deviceService, serverId); | |
| 107 | 109 | } |
| 108 | 110 | |
| 109 | 111 | @Override | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandler.java
| ... | ... | @@ -45,7 +45,12 @@ public class IotMqttDownstreamHandler { |
| 45 | 45 | // 2.1 序列化消息 |
| 46 | 46 | byte[] payload = deviceMessageService.serializeDeviceMessage(message, connectionInfo.getProductKey(), |
| 47 | 47 | connectionInfo.getDeviceName()); |
| 48 | - Assert.isTrue(payload != null && payload.length > 0, "消息编码结果不能为空"); | |
| 48 | + if (payload == null) { | |
| 49 | + log.warn("[handle][影子下行跳过,设备 ID: {},method: {},非 JSON 协议设备不支持影子透传]", | |
| 50 | + message.getDeviceId(), message.getMethod()); | |
| 51 | + return; | |
| 52 | + } | |
| 53 | + Assert.isTrue(payload.length > 0, "消息编码结果不能为空"); | |
| 49 | 54 | // 2.2 构建主题 |
| 50 | 55 | Assert.notBlank(message.getMethod(), "消息方法不能为空"); |
| 51 | 56 | boolean isReply = IotDeviceMessageUtils.isReplyMessage(message); | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttAbstractHandler.java
| ... | ... | @@ -72,6 +72,11 @@ public abstract class IotMqttAbstractHandler { |
| 72 | 72 | try { |
| 73 | 73 | // 1.1 序列化消息(根据设备配置的序列化类型) |
| 74 | 74 | byte[] encodedData = deviceMessageService.serializeDeviceMessage(responseMessage, productKey, deviceName); |
| 75 | + if (encodedData == null) { | |
| 76 | + // 影子透传在非 JSON 设备上 encode 返回 null,跳过发布避免 NPE | |
| 77 | + log.warn("[writeResponse][影子下行跳过,客户端 ID: {},method: {}]", endpoint.clientIdentifier(), method); | |
| 78 | + return; | |
| 79 | + } | |
| 75 | 80 | // 1.2 构建响应主题 |
| 76 | 81 | String replyTopic = IotMqttTopicUtils.buildTopicByMethod(method, productKey, deviceName, true); |
| 77 | 82 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java
| ... | ... | @@ -2,10 +2,13 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | 4 | import cn.hutool.core.util.ArrayUtil; |
| 5 | -import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | |
| 6 | 6 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 7 | +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; | |
| 7 | 8 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 8 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.serialize.IotShadowPassthroughCodec; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | |
| 9 | 12 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 10 | 13 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 11 | 14 | import io.vertx.mqtt.MqttEndpoint; |
| ... | ... | @@ -25,11 +28,14 @@ import java.util.stream.Collectors; |
| 25 | 28 | public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { |
| 26 | 29 | |
| 27 | 30 | private final String serverId; |
| 31 | + private final IotDeviceService deviceService; | |
| 28 | 32 | |
| 29 | 33 | public IotMqttUpstreamHandler(IotMqttConnectionManager connectionManager, |
| 30 | 34 | IotDeviceMessageService deviceMessageService, |
| 35 | + IotDeviceService deviceService, | |
| 31 | 36 | String serverId) { |
| 32 | 37 | super(connectionManager, deviceMessageService); |
| 38 | + this.deviceService = deviceService; | |
| 33 | 39 | this.serverId = serverId; |
| 34 | 40 | } |
| 35 | 41 | |
| ... | ... | @@ -69,8 +75,11 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { |
| 69 | 75 | .collect(Collectors.joining(".")); |
| 70 | 76 | IotDeviceMessage message; |
| 71 | 77 | if (IotDeviceMessageMethodEnum.isPassthrough(topicMethod)) { |
| 72 | - // 影子透传:从 topic 解析 yudao method,原始 body 作为 params | |
| 73 | - Map<String, Object> params = JsonUtils.parseObject(payload, Map.class); | |
| 78 | + // 影子透传:按设备 serializeType 解码,原始 body 作为 params | |
| 79 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); | |
| 80 | + IotSerializeTypeEnum serializeType = device != null | |
| 81 | + ? IotSerializeTypeEnum.of(device.getSerializeType()) : null; | |
| 82 | + Map<String, Object> params = IotShadowPassthroughCodec.decode(payload, serializeType); | |
| 74 | 83 | if (params == null) { |
| 75 | 84 | log.warn("[handleBusinessRequest][影子透传 payload 解析失败,客户端 ID: {},主题: {}]", clientId, topic); |
| 76 | 85 | return; | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/IotShadowPassthroughCodec.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.serialize; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; | |
| 5 | +import lombok.extern.slf4j.Slf4j; | |
| 6 | + | |
| 7 | +import java.util.Map; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * IoT 设备影子透传编解码 | |
| 11 | + * <p> | |
| 12 | + * 影子协议本质为 JSON 结构,透传编解码规则: | |
| 13 | + * <ul> | |
| 14 | + * <li>{@code serializeType == null} 或 {@link IotSerializeTypeEnum#JSON}:按 JSON 编解码(未配置时默认 JSON,兼容历史设备)</li> | |
| 15 | + * <li>其它显式类型(如 {@link IotSerializeTypeEnum#BINARY}):warn 并丢弃,返回 {@code null},不静默喂给 JSON 解析器</li> | |
| 16 | + * </ul> | |
| 17 | + * 下行发布方收到 {@code null} 时应跳过发送,避免 NPE;影子功能目前仅支持 JSON 协议设备。 | |
| 18 | + * | |
| 19 | + * @author 芋道源码 | |
| 20 | + */ | |
| 21 | +@Slf4j | |
| 22 | +public final class IotShadowPassthroughCodec { | |
| 23 | + | |
| 24 | + private static final String UNSUPPORTED_MSG = "影子透传暂仅支持 JSON serializeType"; | |
| 25 | + | |
| 26 | + private IotShadowPassthroughCodec() { | |
| 27 | + } | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * 编码影子透传 params | |
| 31 | + * | |
| 32 | + * @param params 透传参数(Map 或 DTO) | |
| 33 | + * @param serializeType 设备序列化类型 | |
| 34 | + * @return 线格式字节;非 JSON 类型返回 null | |
| 35 | + */ | |
| 36 | + public static byte[] encode(Object params, IotSerializeTypeEnum serializeType) { | |
| 37 | + if (isJsonSerializeType(serializeType)) { | |
| 38 | + return JsonUtils.toJsonByte(params); | |
| 39 | + } | |
| 40 | + log.warn("[encode][{},serializeType={},已丢弃]", UNSUPPORTED_MSG, serializeType); | |
| 41 | + return null; | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** | |
| 45 | + * 解码影子透传 payload | |
| 46 | + * | |
| 47 | + * @param bytes 线格式字节 | |
| 48 | + * @param serializeType 设备序列化类型 | |
| 49 | + * @return 透传 params;非 JSON 类型或解析失败返回 null | |
| 50 | + */ | |
| 51 | + @SuppressWarnings("unchecked") | |
| 52 | + public static Map<String, Object> decode(byte[] bytes, IotSerializeTypeEnum serializeType) { | |
| 53 | + if (!isJsonSerializeType(serializeType)) { | |
| 54 | + log.warn("[decode][{},serializeType={},已丢弃]", UNSUPPORTED_MSG, serializeType); | |
| 55 | + return null; | |
| 56 | + } | |
| 57 | + return JsonUtils.parseObject(bytes, Map.class); | |
| 58 | + } | |
| 59 | + | |
| 60 | + private static boolean isJsonSerializeType(IotSerializeTypeEnum serializeType) { | |
| 61 | + // null 视为未配置,默认走 JSON(与产品/设备默认 serializeType 一致) | |
| 62 | + return serializeType == null || IotSerializeTypeEnum.JSON.equals(serializeType); | |
| 63 | + } | |
| 64 | + | |
| 65 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java
| ... | ... | @@ -2,7 +2,6 @@ package cn.iocoder.yudao.module.iot.gateway.service.device.message; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | 4 | import cn.hutool.core.util.StrUtil; |
| 5 | -import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 6 | 5 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 7 | 6 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 8 | 7 | import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; |
| ... | ... | @@ -11,6 +10,7 @@ import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; |
| 11 | 10 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 12 | 11 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 13 | 12 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.serialize.IotShadowPassthroughCodec; | |
| 14 | 14 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 15 | 15 | import jakarta.annotation.Resource; |
| 16 | 16 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -42,9 +42,13 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 42 | 42 | @Override |
| 43 | 43 | public byte[] serializeDeviceMessage(IotDeviceMessage message, |
| 44 | 44 | String productKey, String deviceName) { |
| 45 | - // 影子透传:直接序列化 params 为原始线 JSON,忽略产品 serializeType | |
| 46 | 45 | if (IotDeviceMessageMethodEnum.isPassthrough(message.getMethod())) { |
| 47 | - return JsonUtils.toJsonByte(message.getParams()); | |
| 46 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); | |
| 47 | + if (device == null) { | |
| 48 | + throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); | |
| 49 | + } | |
| 50 | + IotSerializeTypeEnum serializeType = IotSerializeTypeEnum.of(device.getSerializeType()); | |
| 51 | + return encodePassthrough(message, serializeType, productKey, deviceName); | |
| 48 | 52 | } |
| 49 | 53 | // 1.1 获取设备信息 |
| 50 | 54 | IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); |
| ... | ... | @@ -62,9 +66,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 62 | 66 | @Override |
| 63 | 67 | public byte[] serializeDeviceMessage(IotDeviceMessage message, |
| 64 | 68 | IotSerializeTypeEnum serializeType) { |
| 65 | - // 影子透传 | |
| 66 | 69 | if (IotDeviceMessageMethodEnum.isPassthrough(message.getMethod())) { |
| 67 | - return JsonUtils.toJsonByte(message.getParams()); | |
| 70 | + return encodePassthrough(message, serializeType, null, null); | |
| 68 | 71 | } |
| 69 | 72 | // 1. 获取序列化器 |
| 70 | 73 | IotMessageSerializer serializer = messageSerializerManager.get(serializeType); |
| ... | ... | @@ -135,4 +138,20 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 135 | 138 | } |
| 136 | 139 | } |
| 137 | 140 | |
| 141 | + /** | |
| 142 | + * 影子透传编码;非 JSON 设备返回 null,由下行发布方跳过发送 | |
| 143 | + */ | |
| 144 | + private byte[] encodePassthrough(IotDeviceMessage message, IotSerializeTypeEnum serializeType, | |
| 145 | + String productKey, String deviceName) { | |
| 146 | + byte[] bytes = IotShadowPassthroughCodec.encode(message.getParams(), serializeType); | |
| 147 | + if (bytes != null) { | |
| 148 | + return bytes; | |
| 149 | + } | |
| 150 | + if (productKey != null && deviceName != null) { | |
| 151 | + log.warn("[encodePassthrough][影子下行跳过:设备({}/{}) serializeType={} 不支持,目前仅 JSON 协议设备可用]", | |
| 152 | + productKey, deviceName, serializeType); | |
| 153 | + } | |
| 154 | + return null; | |
| 155 | + } | |
| 156 | + | |
| 138 | 157 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceShadowPassthroughTest.java
| ... | ... | @@ -3,8 +3,10 @@ package cn.iocoder.yudao.module.iot.gateway.service.device.message; |
| 3 | 3 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 4 | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; | |
| 6 | 7 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | 8 | import cn.iocoder.yudao.module.iot.core.topic.shadow.IotDeviceShadowReplyDTO; |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.serialize.IotShadowPassthroughCodec; | |
| 8 | 10 | import org.junit.jupiter.api.Test; |
| 9 | 11 | import org.mockito.InjectMocks; |
| 10 | 12 | |
| ... | ... | @@ -12,6 +14,7 @@ import java.util.HashMap; |
| 12 | 14 | import java.util.Map; |
| 13 | 15 | |
| 14 | 16 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 17 | +import static org.junit.jupiter.api.Assertions.assertNotNull; | |
| 15 | 18 | import static org.junit.jupiter.api.Assertions.assertNull; |
| 16 | 19 | |
| 17 | 20 | /** |
| ... | ... | @@ -31,7 +34,7 @@ public class IotDeviceShadowPassthroughTest extends BaseMockitoUnitTest { |
| 31 | 34 | IotDeviceMessage message = IotDeviceMessage.requestOf( |
| 32 | 35 | IotDeviceMessageMethodEnum.SHADOW_GET.getMethod(), payload); |
| 33 | 36 | |
| 34 | - byte[] bytes = deviceMessageService.serializeDeviceMessage(message, null); | |
| 37 | + byte[] bytes = deviceMessageService.serializeDeviceMessage(message, IotSerializeTypeEnum.JSON); | |
| 35 | 38 | |
| 36 | 39 | @SuppressWarnings("unchecked") |
| 37 | 40 | Map<String, Object> result = JsonUtils.parseObject(bytes, Map.class); |
| ... | ... | @@ -60,11 +63,43 @@ public class IotDeviceShadowPassthroughTest extends BaseMockitoUnitTest { |
| 60 | 63 | IotDeviceMessage message = IotDeviceMessage.requestOf( |
| 61 | 64 | IotDeviceMessageMethodEnum.SHADOW_GET.getMethod(), reply); |
| 62 | 65 | |
| 63 | - byte[] bytes = deviceMessageService.serializeDeviceMessage(message, null); | |
| 66 | + byte[] bytes = deviceMessageService.serializeDeviceMessage(message, IotSerializeTypeEnum.JSON); | |
| 64 | 67 | @SuppressWarnings("unchecked") |
| 65 | 68 | Map<String, Object> result = JsonUtils.parseObject(bytes, Map.class); |
| 66 | 69 | assertEquals("update_reply", result.get("method")); |
| 67 | 70 | assertNull(result.get("id")); |
| 68 | 71 | } |
| 69 | 72 | |
| 73 | + @Test | |
| 74 | + public void testPassthroughCodec_jsonSerializeType() { | |
| 75 | + Map<String, Object> params = Map.of("method", "get"); | |
| 76 | + byte[] bytes = IotShadowPassthroughCodec.encode(params, IotSerializeTypeEnum.JSON); | |
| 77 | + Map<String, Object> decoded = IotShadowPassthroughCodec.decode(bytes, IotSerializeTypeEnum.JSON); | |
| 78 | + assertNotNull(decoded); | |
| 79 | + assertEquals("get", decoded.get("method")); | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + public void testPassthroughCodec_nullSerializeTypeAsJson() { | |
| 84 | + Map<String, Object> params = Map.of("method", "get"); | |
| 85 | + byte[] bytes = IotShadowPassthroughCodec.encode(params, null); | |
| 86 | + Map<String, Object> decoded = IotShadowPassthroughCodec.decode(bytes, null); | |
| 87 | + assertNotNull(decoded); | |
| 88 | + assertEquals("get", decoded.get("method")); | |
| 89 | + } | |
| 90 | + | |
| 91 | + @Test | |
| 92 | + public void testSerializeDeviceMessage_passthrough_binaryReturnsNull() { | |
| 93 | + IotDeviceMessage message = IotDeviceMessage.requestOf( | |
| 94 | + IotDeviceMessageMethodEnum.SHADOW_GET.getMethod(), Map.of("method", "get_reply")); | |
| 95 | + assertNull(deviceMessageService.serializeDeviceMessage(message, IotSerializeTypeEnum.BINARY)); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + public void testPassthroughCodec_binaryDrop() { | |
| 100 | + Map<String, Object> params = Map.of("method", "update"); | |
| 101 | + assertNull(IotShadowPassthroughCodec.encode(params, IotSerializeTypeEnum.BINARY)); | |
| 102 | + assertNull(IotShadowPassthroughCodec.decode(new byte[]{0x01}, IotSerializeTypeEnum.BINARY)); | |
| 103 | + } | |
| 104 | + | |
| 70 | 105 | } | ... | ... |