Commit 6e3e7776051fa306ec6b5096db0a20926b1d182e
feat(iot): 合入设备影子能力并完善状态同步
本地合入 Gitee !1561,沿用数据库 version CAS 与三次重试。 修正影子缓存、删除事务、状态开关及清空校验,补充必要单测。 新增影子权限种子,业务表已通过仓库外升级 SQL 在本地 MySQL 补建。
Showing
25 changed files
with
1986 additions
and
5 deletions
sql/mysql/ruoyi-vue-pro.sql
| ... | ... | @@ -2799,7 +2799,7 @@ CREATE TABLE `system_menu` ( |
| 2799 | 2799 | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', |
| 2800 | 2800 | `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', |
| 2801 | 2801 | PRIMARY KEY (`id`) USING BTREE |
| 2802 | -) ENGINE = InnoDB AUTO_INCREMENT = 13478 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '菜单权限表'; | |
| 2802 | +) ENGINE = InnoDB AUTO_INCREMENT = 13480 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '菜单权限表'; | |
| 2803 | 2803 | |
| 2804 | 2804 | -- ---------------------------- |
| 2805 | 2805 | -- Records of system_menu |
| ... | ... | @@ -5349,6 +5349,8 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i |
| 5349 | 5349 | INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (13475, '维修备件领用', 'eam:repair-order-act:maintenance', 3, 5, 13249, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2026-09-26 20:50:45', '', '2026-09-26 20:50:45', b'0'); |
| 5350 | 5350 | INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (13476, '维修执行单取消', 'eam:repair-order-act:cancel', 3, 6, 13249, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2026-09-26 20:50:45', '', '2026-09-26 20:50:45', b'0'); |
| 5351 | 5351 | INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (13477, '维修执行单修改', 'eam:repair-order-act:update', 3, 7, 13249, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2026-09-26 20:50:45', '', '2026-09-26 20:50:45', b'0'); |
| 5352 | +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (13478, '设备影子查询', 'iot:device:shadow-query', 3, 13, 868, '', '', '', '', 0, b'1', b'1', b'1', '1', '2026-10-26 00:00:00', '1', '2026-10-26 00:00:00', b'0'); | |
| 5353 | +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (13479, '设备影子更新', 'iot:device:shadow-update', 3, 14, 868, '', '', '', '', 0, b'1', b'1', b'1', '1', '2026-10-26 00:00:00', '1', '2026-10-26 00:00:00', b'0'); | |
| 5352 | 5354 | COMMIT; |
| 5353 | 5355 | |
| 5354 | 5356 | -- ---------------------------- | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceShadowController.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.device; | |
| 2 | + | |
| 3 | +import cn.hutool.core.lang.Assert; | |
| 4 | +import cn.hutool.core.map.MapUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.pojo.CommonResult; | |
| 6 | +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | |
| 7 | +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowPropertyRespVO; | |
| 8 | +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowRespVO; | |
| 9 | +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowUpdateDesiredReqVO; | |
| 10 | +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowUpdateStatusReqVO; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils; | |
| 12 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | |
| 13 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO; | |
| 14 | +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; | |
| 15 | +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; | |
| 16 | +import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum; | |
| 17 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; | |
| 18 | +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService; | |
| 19 | +import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; | |
| 20 | +import io.swagger.v3.oas.annotations.Operation; | |
| 21 | +import io.swagger.v3.oas.annotations.Parameter; | |
| 22 | +import io.swagger.v3.oas.annotations.tags.Tag; | |
| 23 | +import jakarta.annotation.Resource; | |
| 24 | +import jakarta.validation.Valid; | |
| 25 | +import org.springframework.security.access.prepost.PreAuthorize; | |
| 26 | +import org.springframework.validation.annotation.Validated; | |
| 27 | +import org.springframework.web.bind.annotation.*; | |
| 28 | + | |
| 29 | +import java.util.List; | |
| 30 | +import java.util.Map; | |
| 31 | + | |
| 32 | +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; | |
| 33 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | |
| 34 | + | |
| 35 | +/** | |
| 36 | + * 管理后台 - IoT 设备影子 Controller | |
| 37 | + * | |
| 38 | + * @author 芋道源码 | |
| 39 | + */ | |
| 40 | +@Tag(name = "管理后台 - IoT 设备影子") | |
| 41 | +@RestController | |
| 42 | +@RequestMapping("/iot/device/shadow") | |
| 43 | +@Validated | |
| 44 | +public class IotDeviceShadowController { | |
| 45 | + | |
| 46 | + @Resource | |
| 47 | + private IotDeviceShadowService deviceShadowService; | |
| 48 | + @Resource | |
| 49 | + private IotDeviceService deviceService; | |
| 50 | + @Resource | |
| 51 | + private IotThingModelService thingModelService; | |
| 52 | + | |
| 53 | + @GetMapping("/get") | |
| 54 | + @Operation(summary = "获取设备影子") | |
| 55 | + @Parameter(name = "deviceId", description = "设备编号", required = true) | |
| 56 | + @PreAuthorize("@ss.hasPermission('iot:device:shadow-query')") | |
| 57 | + public CommonResult<IotDeviceShadowRespVO> getDeviceShadow(@RequestParam("deviceId") Long deviceId) { | |
| 58 | + IotDeviceDO device = deviceService.validateDeviceExists(deviceId); | |
| 59 | + IotDeviceShadowDO shadow = deviceShadowService.getOrCreateShadow(device); | |
| 60 | + | |
| 61 | + List<IotThingModelDO> thingModels = thingModelService.getThingModelListByProductIdAndType( | |
| 62 | + device.getProductId(), IotThingModelTypeEnum.PROPERTY.getType()); | |
| 63 | + Map<String, Object> desired = shadow.getDesired() != null ? shadow.getDesired() : MapUtil.empty(); | |
| 64 | + Map<String, Object> reported = shadow.getReported() != null ? shadow.getReported() : MapUtil.empty(); | |
| 65 | + Map<String, Object> metadata = shadow.getMetadata() != null ? shadow.getMetadata() : MapUtil.empty(); | |
| 66 | + // 拼接 VO | |
| 67 | + List<IotDeviceShadowPropertyRespVO> properties = convertList(thingModels, thingModel -> { | |
| 68 | + ThingModelProperty prop = thingModel.getProperty(); | |
| 69 | + Assert.notNull(prop, "属性不能为空"); | |
| 70 | + return new IotDeviceShadowPropertyRespVO() | |
| 71 | + .setIdentifier(thingModel.getIdentifier()).setName(thingModel.getName()) | |
| 72 | + .setDataType(prop.getDataType()).setDataSpecs(prop.getDataSpecs()) | |
| 73 | + .setDataSpecsList(prop.getDataSpecsList()) | |
| 74 | + .setDesiredValue(desired.get(thingModel.getIdentifier())) | |
| 75 | + .setReportedValue(reported.get(thingModel.getIdentifier())) | |
| 76 | + .setDesiredTime(IotDeviceShadowProtocolUtils.getDesiredTimestamp(metadata, thingModel.getIdentifier())) | |
| 77 | + .setReportedTime(IotDeviceShadowProtocolUtils.getReportedTimestamp(metadata, thingModel.getIdentifier())); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + IotDeviceShadowRespVO respVO = BeanUtils.toBean(shadow, IotDeviceShadowRespVO.class) | |
| 81 | + .setProperties(properties) | |
| 82 | + .setDesired(desired) | |
| 83 | + .setReported(reported) | |
| 84 | + .setMetadata(metadata); | |
| 85 | + return success(respVO); | |
| 86 | + } | |
| 87 | + | |
| 88 | + @PutMapping("/update-desired") | |
| 89 | + @Operation(summary = "更新设备影子期望状态") | |
| 90 | + @PreAuthorize("@ss.hasPermission('iot:device:shadow-update')") | |
| 91 | + public CommonResult<Boolean> updateDesiredState(@Valid @RequestBody IotDeviceShadowUpdateDesiredReqVO reqVO) { | |
| 92 | + deviceShadowService.updateDesiredState(reqVO.getDeviceId(), reqVO.getDesired()); | |
| 93 | + return success(true); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @PutMapping("/update-status") | |
| 97 | + @Operation(summary = "更新设备影子开关状态") | |
| 98 | + @PreAuthorize("@ss.hasPermission('iot:device:shadow-update')") | |
| 99 | + public CommonResult<Boolean> updateShadowStatus(@Valid @RequestBody IotDeviceShadowUpdateStatusReqVO reqVO) { | |
| 100 | + deviceShadowService.updateStatus(reqVO.getDeviceId(), reqVO.getStatus()); | |
| 101 | + return success(true); | |
| 102 | + } | |
| 103 | + | |
| 104 | + @DeleteMapping("/reset") | |
| 105 | + @Operation(summary = "重置设备影子") | |
| 106 | + @Parameter(name = "deviceId", description = "设备编号", required = true) | |
| 107 | + @PreAuthorize("@ss.hasPermission('iot:device:shadow-update')") | |
| 108 | + public CommonResult<Boolean> resetShadow(@RequestParam("deviceId") Long deviceId) { | |
| 109 | + deviceShadowService.resetShadow(deviceId); | |
| 110 | + return success(true); | |
| 111 | + } | |
| 112 | + | |
| 113 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/shadow/IotDeviceShadowPropertyRespVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDataSpecs; | |
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +import java.util.List; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 管理后台 - IoT 设备影子属性对比 Response VO | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Schema(description = "管理后台 - IoT 设备影子属性对比 Response VO") | |
| 15 | +@Data | |
| 16 | +public class IotDeviceShadowPropertyRespVO { | |
| 17 | + | |
| 18 | + @Schema(description = "标识符", example = "Temperature") | |
| 19 | + private String identifier; | |
| 20 | + | |
| 21 | + @Schema(description = "属性名称", example = "温度") | |
| 22 | + private String name; | |
| 23 | + | |
| 24 | + @Schema(description = "数据类型", example = "int") | |
| 25 | + private String dataType; | |
| 26 | + | |
| 27 | + @Schema(description = "数据定义") | |
| 28 | + private ThingModelDataSpecs dataSpecs; | |
| 29 | + | |
| 30 | + @Schema(description = "数据定义列表") | |
| 31 | + private List<ThingModelDataSpecs> dataSpecsList; | |
| 32 | + | |
| 33 | + @Schema(description = "期望值") | |
| 34 | + private Object desiredValue; | |
| 35 | + | |
| 36 | + @Schema(description = "上报值") | |
| 37 | + private Object reportedValue; | |
| 38 | + | |
| 39 | + @Schema(description = "期望更新时间(epoch 秒)") | |
| 40 | + private Long desiredTime; | |
| 41 | + | |
| 42 | + @Schema(description = "上报更新时间(epoch 秒)") | |
| 43 | + private Long reportedTime; | |
| 44 | + | |
| 45 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/shadow/IotDeviceShadowRespVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | +import java.util.Map; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 管理后台 - IoT 设备影子 Response VO | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Schema(description = "管理后台 - IoT 设备影子 Response VO") | |
| 15 | +@Data | |
| 16 | +public class IotDeviceShadowRespVO { | |
| 17 | + | |
| 18 | + @Schema(description = "版本号", example = "3") | |
| 19 | + private Long version; | |
| 20 | + | |
| 21 | + @Schema(description = "最后更新时间(epoch 秒)", example = "1469564576") | |
| 22 | + private Long shadowTime; | |
| 23 | + | |
| 24 | + @Schema(description = "是否启用", example = "0") | |
| 25 | + private Integer status; | |
| 26 | + | |
| 27 | + @Schema(description = "属性对比列表") | |
| 28 | + private List<IotDeviceShadowPropertyRespVO> properties; | |
| 29 | + | |
| 30 | + @Schema(description = "期望状态") | |
| 31 | + private Map<String, Object> desired; | |
| 32 | + | |
| 33 | + @Schema(description = "上报状态") | |
| 34 | + private Map<String, Object> reported; | |
| 35 | + | |
| 36 | + @Schema(description = "元数据") | |
| 37 | + private Map<String, Object> metadata; | |
| 38 | + | |
| 39 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/shadow/IotDeviceShadowUpdateDesiredReqVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 4 | +import jakarta.validation.constraints.NotNull; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +import java.util.Map; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 管理后台 - IoT 设备影子更新期望状态 Request VO | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Schema(description = "管理后台 - IoT 设备影子更新期望状态 Request VO") | |
| 15 | +@Data | |
| 16 | +public class IotDeviceShadowUpdateDesiredReqVO { | |
| 17 | + | |
| 18 | + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") | |
| 19 | + @NotNull(message = "设备编号不能为空") | |
| 20 | + private Long deviceId; | |
| 21 | + | |
| 22 | + @Schema(description = "期望状态", requiredMode = Schema.RequiredMode.REQUIRED) | |
| 23 | + @NotNull(message = "期望状态不能为空") | |
| 24 | + private Map<String, Object> desired; | |
| 25 | + | |
| 26 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/shadow/IotDeviceShadowUpdateStatusReqVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 4 | +import cn.iocoder.yudao.framework.common.validation.InEnum; | |
| 5 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 6 | +import jakarta.validation.constraints.NotNull; | |
| 7 | +import lombok.Data; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 管理后台 - IoT 设备影子更新状态 Request VO | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Schema(description = "管理后台 - IoT 设备影子更新状态 Request VO") | |
| 15 | +@Data | |
| 16 | +public class IotDeviceShadowUpdateStatusReqVO { | |
| 17 | + | |
| 18 | + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") | |
| 19 | + @NotNull(message = "设备编号不能为空") | |
| 20 | + private Long deviceId; | |
| 21 | + | |
| 22 | + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") | |
| 23 | + @NotNull(message = "状态不能为空") | |
| 24 | + @InEnum(CommonStatusEnum.class) | |
| 25 | + private Integer status; | |
| 26 | + | |
| 27 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceShadowDO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.dal.dataobject.device; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; | |
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; | |
| 5 | +import com.baomidou.mybatisplus.annotation.KeySequence; | |
| 6 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 7 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 8 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 9 | +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; | |
| 10 | +import lombok.AllArgsConstructor; | |
| 11 | +import lombok.Builder; | |
| 12 | +import lombok.Data; | |
| 13 | +import lombok.NoArgsConstructor; | |
| 14 | + | |
| 15 | +import java.util.Map; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * IoT 设备影子 DO | |
| 19 | + * | |
| 20 | + * @author 芋道源码 | |
| 21 | + */ | |
| 22 | +@TableName(value = "iot_device_shadow", autoResultMap = true) | |
| 23 | +@KeySequence("iot_device_shadow_seq") | |
| 24 | +@Data | |
| 25 | +@Builder | |
| 26 | +@NoArgsConstructor | |
| 27 | +@AllArgsConstructor | |
| 28 | +public class IotDeviceShadowDO extends TenantBaseDO { | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * 主键 | |
| 32 | + */ | |
| 33 | + @TableId | |
| 34 | + private Long id; | |
| 35 | + /** | |
| 36 | + * 设备编号 | |
| 37 | + */ | |
| 38 | + private Long deviceId; | |
| 39 | + /** | |
| 40 | + * 产品编号 | |
| 41 | + * <p> | |
| 42 | + * 关联 {@link IotProductDO#getId()} | |
| 43 | + */ | |
| 44 | + private Long productId; | |
| 45 | + /** | |
| 46 | + * 产品标识 | |
| 47 | + */ | |
| 48 | + private String productKey; | |
| 49 | + /** | |
| 50 | + * 设备名称 | |
| 51 | + */ | |
| 52 | + private String deviceName; | |
| 53 | + /** | |
| 54 | + * 上报状态 | |
| 55 | + */ | |
| 56 | + @TableField(typeHandler = JacksonTypeHandler.class) | |
| 57 | + private Map<String, Object> reported; | |
| 58 | + /** | |
| 59 | + * 期望状态 | |
| 60 | + */ | |
| 61 | + @TableField(typeHandler = JacksonTypeHandler.class) | |
| 62 | + private Map<String, Object> desired; | |
| 63 | + /** | |
| 64 | + * 元数据:{ desired: { key: { timestamp } }, reported: { ... } } | |
| 65 | + */ | |
| 66 | + @TableField(typeHandler = JacksonTypeHandler.class) | |
| 67 | + private Map<String, Object> metadata; | |
| 68 | + /** | |
| 69 | + * 版本号(乐观锁) | |
| 70 | + */ | |
| 71 | + private Long version; | |
| 72 | + /** | |
| 73 | + * 文档最后更新时间(epoch 秒) | |
| 74 | + */ | |
| 75 | + private Long shadowTime; | |
| 76 | + /** | |
| 77 | + * 状态 | |
| 78 | + * <p> | |
| 79 | + * 枚举 {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum} | |
| 80 | + */ | |
| 81 | + private Integer status; | |
| 82 | + | |
| 83 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceShadowMapper.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.dal.mysql.device; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; | |
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO; | |
| 5 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | |
| 6 | +import org.apache.ibatis.annotations.Mapper; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * IoT 设备影子 Mapper | |
| 10 | + * | |
| 11 | + * @author 芋道源码 | |
| 12 | + */ | |
| 13 | +@Mapper | |
| 14 | +public interface IotDeviceShadowMapper extends BaseMapperX<IotDeviceShadowDO> { | |
| 15 | + | |
| 16 | + default IotDeviceShadowDO selectByDeviceId(Long deviceId) { | |
| 17 | + return selectOne(IotDeviceShadowDO::getDeviceId, deviceId); | |
| 18 | + } | |
| 19 | + | |
| 20 | + default int updateByDeviceId(IotDeviceShadowDO update, Long deviceId) { | |
| 21 | + return update(update, new LambdaUpdateWrapper<IotDeviceShadowDO>() | |
| 22 | + .eq(IotDeviceShadowDO::getDeviceId, deviceId)); | |
| 23 | + } | |
| 24 | + | |
| 25 | + default int updateByDeviceIdAndVersion(IotDeviceShadowDO update, Long deviceId, Long expectVersion) { | |
| 26 | + return update(update, new LambdaUpdateWrapper<IotDeviceShadowDO>() | |
| 27 | + .eq(IotDeviceShadowDO::getDeviceId, deviceId) | |
| 28 | + .eq(IotDeviceShadowDO::getVersion, expectVersion)); | |
| 29 | + } | |
| 30 | + | |
| 31 | + default int deleteByDeviceId(Long deviceId) { | |
| 32 | + return delete(IotDeviceShadowDO::getDeviceId, deviceId); | |
| 33 | + } | |
| 34 | + | |
| 35 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java
| ... | ... | @@ -19,6 +19,15 @@ public interface RedisKeyConstants { |
| 19 | 19 | String DEVICE_PROPERTY = "iot:device_property:%d"; |
| 20 | 20 | |
| 21 | 21 | /** |
| 22 | + * 设备影子数据缓存,采用 STRING 结构 | |
| 23 | + * <p> | |
| 24 | + * 影子缓存:DB 为准,Redis 仅作读缓存(cache-aside),写后失效,过期时间 60 秒。 | |
| 25 | + * KEY 格式:iot:device_shadow:{deviceId} | |
| 26 | + * VALUE 数据类型:String(JSON) {@link cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO} | |
| 27 | + */ | |
| 28 | + String DEVICE_SHADOW = "iot:device_shadow:%d"; | |
| 29 | + | |
| 30 | + /** | |
| 22 | 31 | * 设备的最后上报时间,采用 ZSET 结构 |
| 23 | 32 | * |
| 24 | 33 | * KEY 格式:{deviceId} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceShadowRedisDAO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.dal.redis.device; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO; | |
| 5 | +import jakarta.annotation.Resource; | |
| 6 | +import org.springframework.data.redis.core.StringRedisTemplate; | |
| 7 | +import org.springframework.stereotype.Repository; | |
| 8 | + | |
| 9 | +import java.time.Duration; | |
| 10 | + | |
| 11 | +import static cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants.DEVICE_SHADOW; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * {@link IotDeviceShadowDO} 的 Redis DAO | |
| 15 | + * <p> | |
| 16 | + * 影子缓存:DB 为准,Redis 仅作读缓存(cache-aside),写后失效,并设置较短的过期时间兜底。 | |
| 17 | + * | |
| 18 | + * @author 芋道源码 | |
| 19 | + */ | |
| 20 | +@Repository | |
| 21 | +public class DeviceShadowRedisDAO { | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * 缓存过期时间:并发回填旧数据时,最多在该时间内读到旧影子 | |
| 25 | + */ | |
| 26 | + private static final Duration TIMEOUT = Duration.ofSeconds(60); | |
| 27 | + | |
| 28 | + @Resource | |
| 29 | + private StringRedisTemplate stringRedisTemplate; | |
| 30 | + | |
| 31 | + public IotDeviceShadowDO get(Long deviceId) { | |
| 32 | + String redisKey = formatKey(deviceId); | |
| 33 | + String json = stringRedisTemplate.opsForValue().get(redisKey); | |
| 34 | + return JsonUtils.parseObject(json, IotDeviceShadowDO.class); | |
| 35 | + } | |
| 36 | + | |
| 37 | + public void set(IotDeviceShadowDO shadow) { | |
| 38 | + String redisKey = formatKey(shadow.getDeviceId()); | |
| 39 | + stringRedisTemplate.opsForValue().set(redisKey, JsonUtils.toJsonString(shadow), TIMEOUT); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public void delete(Long deviceId) { | |
| 43 | + stringRedisTemplate.delete(formatKey(deviceId)); | |
| 44 | + } | |
| 45 | + | |
| 46 | + private static String formatKey(Long deviceId) { | |
| 47 | + return String.format(DEVICE_SHADOW, deviceId); | |
| 48 | + } | |
| 49 | + | |
| 50 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java
| ... | ... | @@ -67,6 +67,11 @@ public interface ErrorCodeConstants { |
| 67 | 67 | ErrorCode DEVICE_MODBUS_POINT_NOT_EXISTS = new ErrorCode(1_050_007_000, "设备 Modbus 点位配置不存在"); |
| 68 | 68 | ErrorCode DEVICE_MODBUS_POINT_EXISTS = new ErrorCode(1_050_007_001, "设备 Modbus 点位配置已存在"); |
| 69 | 69 | |
| 70 | + // ========== 设备影子 1-050-007-100 ========== | |
| 71 | + ErrorCode DEVICE_SHADOW_NOT_ENABLED = new ErrorCode(1_050_007_100, "设备影子未启用"); | |
| 72 | + ErrorCode DEVICE_SHADOW_VERSION_CONFLICT = new ErrorCode(1_050_007_101, "设备影子版本冲突"); | |
| 73 | + ErrorCode DEVICE_SHADOW_PROPERTY_COUNT_EXCEEDED = new ErrorCode(1_050_007_102, "设备影子属性数超过限制"); | |
| 74 | + | |
| 70 | 75 | // ========== OTA 固件相关 1-050-008-000 ========== |
| 71 | 76 | |
| 72 | 77 | ErrorCode OTA_FIRMWARE_NOT_EXISTS = new ErrorCode(1_050_008_000, "固件信息不存在"); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java
| ... | ... | @@ -37,6 +37,7 @@ import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceMapper; |
| 37 | 37 | import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; |
| 38 | 38 | import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; |
| 39 | 39 | import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; |
| 40 | +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService; | |
| 40 | 41 | import cn.iocoder.yudao.module.iot.service.product.IotProductService; |
| 41 | 42 | import jakarta.annotation.Resource; |
| 42 | 43 | import jakarta.validation.ConstraintViolationException; |
| ... | ... | @@ -81,6 +82,9 @@ public class IotDeviceServiceImpl implements IotDeviceService { |
| 81 | 82 | @Resource |
| 82 | 83 | @Lazy // 延迟加载,解决循环依赖 |
| 83 | 84 | private IotDeviceMessageService deviceMessageService; |
| 85 | + @Resource | |
| 86 | + @Lazy // 延迟加载,解决循环依赖 | |
| 87 | + private IotDeviceShadowService deviceShadowService; | |
| 84 | 88 | |
| 85 | 89 | private IotDeviceServiceImpl getSelf() { |
| 86 | 90 | return SpringUtil.getBean(getClass()); |
| ... | ... | @@ -197,6 +201,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { |
| 197 | 201 | } |
| 198 | 202 | |
| 199 | 203 | @Override |
| 204 | + @Transactional(rollbackFor = Exception.class) | |
| 200 | 205 | public void deleteDevice(Long id) { |
| 201 | 206 | // 1.1 校验存在 |
| 202 | 207 | IotDeviceDO device = validateDeviceExists(id); |
| ... | ... | @@ -209,7 +214,10 @@ public class IotDeviceServiceImpl implements IotDeviceService { |
| 209 | 214 | // 2. 删除设备 |
| 210 | 215 | deviceMapper.deleteById(id); |
| 211 | 216 | |
| 212 | - // 3. 清空对应缓存 | |
| 217 | + // 3. 级联删除影子 | |
| 218 | + deviceShadowService.deleteByDeviceId(id); | |
| 219 | + | |
| 220 | + // 4. 清空对应缓存(事务提交后生效) | |
| 213 | 221 | deleteDeviceCache(device); |
| 214 | 222 | } |
| 215 | 223 | |
| ... | ... | @@ -235,7 +243,10 @@ public class IotDeviceServiceImpl implements IotDeviceService { |
| 235 | 243 | // 2. 删除设备 |
| 236 | 244 | deviceMapper.deleteByIds(ids); |
| 237 | 245 | |
| 238 | - // 3. 清空对应缓存 | |
| 246 | + // 3. 级联删除影子 | |
| 247 | + ids.forEach(deviceShadowService::deleteByDeviceId); | |
| 248 | + | |
| 249 | + // 4. 清空对应缓存(事务提交后生效) | |
| 239 | 250 | deleteDeviceCache(devices); |
| 240 | 251 | } |
| 241 | 252 | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java
| ... | ... | @@ -30,6 +30,16 @@ public interface IotDeviceMessageService { |
| 30 | 30 | IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device); |
| 31 | 31 | |
| 32 | 32 | /** |
| 33 | + * 发送设备消息(指定 serverId,用于上行回复等场景) | |
| 34 | + * | |
| 35 | + * @param message 消息 | |
| 36 | + * @param device 设备 | |
| 37 | + * @param serverId 网关 serverId | |
| 38 | + * @return 设备消息 | |
| 39 | + */ | |
| 40 | + IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId); | |
| 41 | + | |
| 42 | + /** | |
| 33 | 43 | * 发送设备消息 |
| 34 | 44 | * |
| 35 | 45 | * @param message 消息(“codec(编解码)字段” 部分字段) | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java
| ... | ... | @@ -28,6 +28,7 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; |
| 28 | 28 | import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; |
| 29 | 29 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 30 | 30 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 31 | +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService; | |
| 31 | 32 | import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; |
| 32 | 33 | import com.google.common.base.Objects; |
| 33 | 34 | import jakarta.annotation.Resource; |
| ... | ... | @@ -63,6 +64,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 63 | 64 | @Resource |
| 64 | 65 | @Lazy // 延迟加载,避免循环依赖 |
| 65 | 66 | private IotOtaTaskRecordService otaTaskRecordService; |
| 67 | + @Resource | |
| 68 | + @Lazy // 延迟加载,避免循环依赖 | |
| 69 | + private IotDeviceShadowService deviceShadowService; | |
| 66 | 70 | |
| 67 | 71 | @Resource |
| 68 | 72 | private IotDeviceMessageDAO deviceMessageDAO; |
| ... | ... | @@ -110,7 +114,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 110 | 114 | return sendDeviceMessage(message, device, null); |
| 111 | 115 | } |
| 112 | 116 | |
| 113 | - private IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) { | |
| 117 | + @Override | |
| 118 | + public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) { | |
| 114 | 119 | // 1. 补充信息 |
| 115 | 120 | appendDeviceMessage(message, device); |
| 116 | 121 | |
| ... | ... | @@ -219,6 +224,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 219 | 224 | return null; |
| 220 | 225 | } |
| 221 | 226 | |
| 227 | + // 设备影子更新 | |
| 228 | + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod())) { | |
| 229 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 230 | + return null; | |
| 231 | + } | |
| 232 | + | |
| 222 | 233 | // 添加拓扑关系 |
| 223 | 234 | if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())) { |
| 224 | 235 | return deviceService.handleTopoAddMessage(message, device); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/shadow/IotDeviceShadowService.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.device.shadow; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | |
| 5 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO; | |
| 6 | + | |
| 7 | +import java.util.Map; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * IoT 设备影子 Service 接口 | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +public interface IotDeviceShadowService { | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * 获取或创建设备影子 | |
| 18 | + * <p> | |
| 19 | + * 优先读缓存,仅用于展示;写入、版本校验等,需以 DB 为准 | |
| 20 | + * | |
| 21 | + * @param device 设备 | |
| 22 | + * @return 设备影子 | |
| 23 | + */ | |
| 24 | + IotDeviceShadowDO getOrCreateShadow(IotDeviceDO device); | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * 处理设备影子上行更新 | |
| 28 | + * | |
| 29 | + * @param device 设备 | |
| 30 | + * @param message 上行消息 | |
| 31 | + */ | |
| 32 | + void handleDeviceShadowUpdate(IotDeviceDO device, IotDeviceMessage message); | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * 更新期望状态 | |
| 36 | + * | |
| 37 | + * @param deviceId 设备编号 | |
| 38 | + * @param desired 期望状态 | |
| 39 | + */ | |
| 40 | + void updateDesiredState(Long deviceId, Map<String, Object> desired); | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * 重置影子 | |
| 44 | + * | |
| 45 | + * @param deviceId 设备编号 | |
| 46 | + */ | |
| 47 | + void resetShadow(Long deviceId); | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * 更新影子开关状态 | |
| 51 | + * | |
| 52 | + * @param deviceId 设备编号 | |
| 53 | + * @param status 状态 | |
| 54 | + */ | |
| 55 | + void updateStatus(Long deviceId, Integer status); | |
| 56 | + | |
| 57 | + /** | |
| 58 | + * 删除设备影子(设备删除时级联) | |
| 59 | + * | |
| 60 | + * @param deviceId 设备编号 | |
| 61 | + */ | |
| 62 | + void deleteByDeviceId(Long deviceId); | |
| 63 | + | |
| 64 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/shadow/IotDeviceShadowServiceImpl.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.device.shadow; | |
| 2 | + | |
| 3 | +import cn.hutool.core.collection.CollUtil; | |
| 4 | +import cn.hutool.core.date.DateUtil; | |
| 5 | +import cn.hutool.core.lang.Assert; | |
| 6 | +import cn.hutool.core.util.ObjUtil; | |
| 7 | +import cn.hutool.core.util.StrUtil; | |
| 8 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 9 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 10 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 12 | +import cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowErrorCodeEnum; | |
| 13 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 14 | +import cn.iocoder.yudao.module.iot.core.topic.shadow.IotDeviceShadowUpdateReqDTO; | |
| 15 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils; | |
| 16 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | |
| 17 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO; | |
| 18 | +import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceShadowMapper; | |
| 19 | +import cn.iocoder.yudao.module.iot.dal.redis.device.DeviceShadowRedisDAO; | |
| 20 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; | |
| 21 | +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; | |
| 22 | +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; | |
| 23 | +import jakarta.annotation.Resource; | |
| 24 | +import lombok.extern.slf4j.Slf4j; | |
| 25 | +import org.springframework.context.annotation.Lazy; | |
| 26 | +import org.springframework.dao.DuplicateKeyException; | |
| 27 | +import org.springframework.stereotype.Service; | |
| 28 | +import org.springframework.transaction.support.TransactionSynchronization; | |
| 29 | +import org.springframework.transaction.support.TransactionSynchronizationManager; | |
| 30 | +import org.springframework.validation.annotation.Validated; | |
| 31 | + | |
| 32 | +import java.util.HashMap; | |
| 33 | +import java.util.Map; | |
| 34 | +import java.util.Objects; | |
| 35 | + | |
| 36 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS; | |
| 37 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 38 | +import static cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils.INNER_METHOD_GET; | |
| 39 | +import static cn.iocoder.yudao.module.iot.core.util.IotDeviceShadowProtocolUtils.INNER_METHOD_UPDATE; | |
| 40 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * IoT 设备影子 Service 实现类 | |
| 44 | + * <p> | |
| 45 | + * DB 为准:写入、版本校验、开关判断都读 DB;Redis 仅用于管理后台查询的读加速 | |
| 46 | + * | |
| 47 | + * @author 芋道源码 | |
| 48 | + */ | |
| 49 | +@Service | |
| 50 | +@Validated | |
| 51 | +@Slf4j | |
| 52 | +public class IotDeviceShadowServiceImpl implements IotDeviceShadowService { | |
| 53 | + | |
| 54 | + /** | |
| 55 | + * desired、reported 各自的最大属性数 | |
| 56 | + */ | |
| 57 | + private static final int MAX_PROPERTY_COUNT = 128; | |
| 58 | + /** | |
| 59 | + * 乐观锁冲突时的最大尝试次数 | |
| 60 | + */ | |
| 61 | + private static final int OPTIMISTIC_LOCK_RETRY = 3; | |
| 62 | + | |
| 63 | + @Resource | |
| 64 | + private IotDeviceShadowMapper deviceShadowMapper; | |
| 65 | + @Resource | |
| 66 | + private DeviceShadowRedisDAO deviceShadowRedisDAO; | |
| 67 | + | |
| 68 | + @Resource | |
| 69 | + private IotDeviceService deviceService; | |
| 70 | + @Resource | |
| 71 | + private IotDevicePropertyService devicePropertyService; | |
| 72 | + @Resource | |
| 73 | + @Lazy // 延迟加载,解决循环依赖 | |
| 74 | + private IotDeviceMessageService deviceMessageService; | |
| 75 | + | |
| 76 | + @Override | |
| 77 | + public IotDeviceShadowDO getOrCreateShadow(IotDeviceDO device) { | |
| 78 | + // 1. 优先读缓存:仅用于展示,允许在缓存过期前读到旧影子 | |
| 79 | + IotDeviceShadowDO shadow = deviceShadowRedisDAO.get(device.getId()); | |
| 80 | + if (shadow != null) { | |
| 81 | + return shadow; | |
| 82 | + } | |
| 83 | + // 2. 读 DB(不存在则创建),并回填缓存 | |
| 84 | + shadow = getOrCreateDeviceShadowFromDB(device, CommonStatusEnum.ENABLE.getStatus()); | |
| 85 | + deviceShadowRedisDAO.set(shadow); | |
| 86 | + return shadow; | |
| 87 | + } | |
| 88 | + | |
| 89 | + @Override | |
| 90 | + public void handleDeviceShadowUpdate(IotDeviceDO device, IotDeviceMessage message) { | |
| 91 | + try { | |
| 92 | + handleDeviceShadowUpdate0(device, message); | |
| 93 | + } catch (Exception ex) { | |
| 94 | + log.error("[handleDeviceShadowUpdate][device({}) message({}) 处理异常]", device.getId(), message, ex); | |
| 95 | + sendShadowErrorReply(device, message, IotDeviceShadowErrorCodeEnum.SERVER_ERROR, 0L); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + private void handleDeviceShadowUpdate0(IotDeviceDO device, IotDeviceMessage message) { | |
| 100 | + // 1.1 解析请求:params 结构为 { method, state: { reported }, version } | |
| 101 | + IotDeviceShadowUpdateReqDTO reqDTO = JsonUtils.convertObject(message.getParams(), | |
| 102 | + IotDeviceShadowUpdateReqDTO.class); | |
| 103 | + if (reqDTO == null) { | |
| 104 | + sendShadowErrorReply(device, message, IotDeviceShadowErrorCodeEnum.JSON_FORMAT_ERROR, 0L); | |
| 105 | + return; | |
| 106 | + } | |
| 107 | + // 1.2 校验影子已启用:读 DB,避免缓存中的旧开关 | |
| 108 | + IotDeviceShadowDO shadow = getOrCreateDeviceShadowFromDB(device, CommonStatusEnum.ENABLE.getStatus()); | |
| 109 | + if (CommonStatusEnum.isDisable(shadow.getStatus())) { | |
| 110 | + log.info("[handleDeviceShadowUpdate0][device({}) 影子未启用,忽略上行]", device.getId()); | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + | |
| 114 | + // 2. 内层 method 分发 | |
| 115 | + if (INNER_METHOD_GET.equals(reqDTO.getMethod())) { | |
| 116 | + sendShadowGetReply(device, message.getServerId(), message.getRequestId(), shadow); | |
| 117 | + return; | |
| 118 | + } | |
| 119 | + if (INNER_METHOD_UPDATE.equals(reqDTO.getMethod())) { | |
| 120 | + handleReportedUpdate(device, message, reqDTO, shadow); | |
| 121 | + return; | |
| 122 | + } | |
| 123 | + sendShadowErrorReply(device, message, StrUtil.isEmpty(reqDTO.getMethod()) | |
| 124 | + ? IotDeviceShadowErrorCodeEnum.METHOD_MISSING : IotDeviceShadowErrorCodeEnum.INVALID_METHOD, | |
| 125 | + shadow.getVersion()); | |
| 126 | + } | |
| 127 | + | |
| 128 | + private void handleReportedUpdate(IotDeviceDO device, IotDeviceMessage message, | |
| 129 | + IotDeviceShadowUpdateReqDTO reqDTO, IotDeviceShadowDO shadow) { | |
| 130 | + // 1. version 为 -1 时,清空影子,无需 state | |
| 131 | + if (Objects.equals(reqDTO.getVersion(), -1L)) { | |
| 132 | + if (!resetDeviceShadow0(device.getId())) { | |
| 133 | + sendShadowVersionConflictReply(device, message); | |
| 134 | + return; | |
| 135 | + } | |
| 136 | + sendShadowReply(device, message.getServerId(), message.getRequestId(), SUCCESS.getCode(), SUCCESS.getMsg(), | |
| 137 | + IotDeviceShadowProtocolUtils.buildUpdateData(0L, DateUtil.currentSeconds())); | |
| 138 | + return; | |
| 139 | + } | |
| 140 | + | |
| 141 | + // 2. 校验 reported | |
| 142 | + IotDeviceShadowUpdateReqDTO.ShadowState state = reqDTO.getState(); | |
| 143 | + if (state == null) { | |
| 144 | + sendShadowErrorReply(device, message, IotDeviceShadowErrorCodeEnum.STATE_MISSING, shadow.getVersion()); | |
| 145 | + return; | |
| 146 | + } | |
| 147 | + if (state.getReported() == null) { | |
| 148 | + sendShadowErrorReply(device, message, IotDeviceShadowErrorCodeEnum.REPORTED_MISSING, shadow.getVersion()); | |
| 149 | + return; | |
| 150 | + } | |
| 151 | + if (state.getReported().isEmpty()) { | |
| 152 | + sendShadowErrorReply(device, message, IotDeviceShadowErrorCodeEnum.REPORTED_EMPTY, shadow.getVersion()); | |
| 153 | + return; | |
| 154 | + } | |
| 155 | + | |
| 156 | + // 3. 合并更新 reported | |
| 157 | + IotDeviceShadowDO newShadow; | |
| 158 | + try { | |
| 159 | + newShadow = updateDeviceShadowState(device.getId(), false, state.getReported(), reqDTO.getVersion()); | |
| 160 | + } catch (ServiceException ex) { // 合并后的属性数超过限制 | |
| 161 | + sendShadowErrorReply(device, message, IotDeviceShadowErrorCodeEnum.PROPERTY_COUNT_EXCEEDED, | |
| 162 | + shadow.getVersion()); | |
| 163 | + return; | |
| 164 | + } | |
| 165 | + if (newShadow == null) { | |
| 166 | + sendShadowVersionConflictReply(device, message); | |
| 167 | + return; | |
| 168 | + } | |
| 169 | + sendShadowReply(device, message.getServerId(), message.getRequestId(), SUCCESS.getCode(), SUCCESS.getMsg(), | |
| 170 | + IotDeviceShadowProtocolUtils.buildUpdateData(newShadow.getVersion(), newShadow.getShadowTime())); | |
| 171 | + } | |
| 172 | + | |
| 173 | + @Override | |
| 174 | + public void updateDesiredState(Long deviceId, Map<String, Object> desired) { | |
| 175 | + // 1. 校验影子已启用:读 DB,避免缓存中的旧开关 | |
| 176 | + IotDeviceDO device = deviceService.validateDeviceExists(deviceId); | |
| 177 | + IotDeviceShadowDO shadow = getOrCreateDeviceShadowFromDB(device, CommonStatusEnum.ENABLE.getStatus()); | |
| 178 | + if (CommonStatusEnum.isDisable(shadow.getStatus())) { | |
| 179 | + throw exception(DEVICE_SHADOW_NOT_ENABLED); | |
| 180 | + } | |
| 181 | + if (CollUtil.isEmpty(desired)) { | |
| 182 | + return; | |
| 183 | + } | |
| 184 | + | |
| 185 | + // 2. 合并更新 desired | |
| 186 | + IotDeviceShadowDO newShadow = updateDeviceShadowState(deviceId, true, desired, null); | |
| 187 | + if (newShadow == null) { | |
| 188 | + throw exception(DEVICE_SHADOW_VERSION_CONFLICT); | |
| 189 | + } | |
| 190 | + | |
| 191 | + // 3. 设备在线时,主动推送全量影子 | |
| 192 | + String serverId = devicePropertyService.getDeviceServerId(deviceId); | |
| 193 | + if (StrUtil.isNotEmpty(serverId)) { | |
| 194 | + sendShadowGetReply(device, serverId, null, newShadow); | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + @Override | |
| 199 | + public void resetShadow(Long deviceId) { | |
| 200 | + deviceService.validateDeviceExists(deviceId); | |
| 201 | + if (!resetDeviceShadow0(deviceId)) { | |
| 202 | + throw exception(DEVICE_SHADOW_VERSION_CONFLICT); | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + @Override | |
| 207 | + public void updateStatus(Long deviceId, Integer status) { | |
| 208 | + // 1. 获得影子:不存在时,直接按目标状态创建 | |
| 209 | + IotDeviceDO device = deviceService.validateDeviceExists(deviceId); | |
| 210 | + IotDeviceShadowDO shadow = getOrCreateDeviceShadowFromDB(device, status); | |
| 211 | + | |
| 212 | + // 2. 更新状态 | |
| 213 | + if (ObjUtil.notEqual(shadow.getStatus(), status)) { | |
| 214 | + deviceShadowMapper.updateByDeviceId(new IotDeviceShadowDO().setStatus(status), deviceId); | |
| 215 | + } | |
| 216 | + deleteDeviceShadowCache(deviceId); | |
| 217 | + } | |
| 218 | + | |
| 219 | + @Override | |
| 220 | + public void deleteByDeviceId(Long deviceId) { | |
| 221 | + deviceShadowMapper.deleteByDeviceId(deviceId); | |
| 222 | + deleteDeviceShadowCache(deviceId); | |
| 223 | + } | |
| 224 | + | |
| 225 | + /** | |
| 226 | + * 从 DB 获得影子,不存在时创建 | |
| 227 | + * | |
| 228 | + * @param device 设备 | |
| 229 | + * @param status 创建时的状态 | |
| 230 | + * @return 影子 | |
| 231 | + */ | |
| 232 | + private IotDeviceShadowDO getOrCreateDeviceShadowFromDB(IotDeviceDO device, Integer status) { | |
| 233 | + IotDeviceShadowDO shadow = deviceShadowMapper.selectByDeviceId(device.getId()); | |
| 234 | + if (shadow != null) { | |
| 235 | + return shadow; | |
| 236 | + } | |
| 237 | + // lazy 创建:首次访问时,才插入空影子;并发插入时,以先插入的为准 | |
| 238 | + shadow = new IotDeviceShadowDO().setDeviceId(device.getId()).setProductId(device.getProductId()) | |
| 239 | + .setProductKey(device.getProductKey()).setDeviceName(device.getDeviceName()) | |
| 240 | + .setDesired(new HashMap<>()).setReported(new HashMap<>()).setMetadata(new HashMap<>()) | |
| 241 | + .setVersion(0L).setShadowTime(DateUtil.currentSeconds()).setStatus(status); | |
| 242 | + try { | |
| 243 | + deviceShadowMapper.insert(shadow); | |
| 244 | + return shadow; | |
| 245 | + } catch (DuplicateKeyException ex) { | |
| 246 | + return deviceShadowMapper.selectByDeviceId(device.getId()); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + | |
| 250 | + /** | |
| 251 | + * 乐观锁合并更新 desired 或 reported | |
| 252 | + * <p> | |
| 253 | + * 每次尝试都读 DB 最新影子重新合并,避免丢失并发更新 | |
| 254 | + * | |
| 255 | + * @param deviceId 设备编号 | |
| 256 | + * @param desired true 更新 desired;false 更新 reported | |
| 257 | + * @param values 待合并的属性 | |
| 258 | + * @param expectVersion 期望的当前版本;null 表示不校验 | |
| 259 | + * @return 更新后的影子;版本冲突时,返回 null | |
| 260 | + */ | |
| 261 | + private IotDeviceShadowDO updateDeviceShadowState(Long deviceId, boolean desired, Map<String, Object> values, | |
| 262 | + Long expectVersion) { | |
| 263 | + for (int i = 0; i < OPTIMISTIC_LOCK_RETRY; i++) { | |
| 264 | + // 1. 校验版本 | |
| 265 | + IotDeviceShadowDO latest = deviceShadowMapper.selectByDeviceId(deviceId); | |
| 266 | + Assert.notNull(latest, "设备影子({})不存在", deviceId); | |
| 267 | + if (expectVersion != null && ObjUtil.notEqual(expectVersion, latest.getVersion())) { | |
| 268 | + return null; | |
| 269 | + } | |
| 270 | + | |
| 271 | + // 2.1 合并属性,并校验属性数 | |
| 272 | + Map<String, Object> state = mergeMap(desired ? latest.getDesired() : latest.getReported(), values); | |
| 273 | + if (state.size() > MAX_PROPERTY_COUNT) { | |
| 274 | + throw exception(DEVICE_SHADOW_PROPERTY_COUNT_EXCEEDED); | |
| 275 | + } | |
| 276 | + // 2.2 记录属性的更新时间 | |
| 277 | + long now = DateUtil.currentSeconds(); | |
| 278 | + Map<String, Object> metadata = mergeMap(latest.getMetadata(), null); | |
| 279 | + if (desired) { | |
| 280 | + IotDeviceShadowProtocolUtils.putDesiredTimestamps(metadata, values.keySet(), now); | |
| 281 | + } else { | |
| 282 | + IotDeviceShadowProtocolUtils.putReportedTimestamps(metadata, values.keySet(), now); | |
| 283 | + } | |
| 284 | + | |
| 285 | + // 3. 按 version 更新:成功则返回;失败则重试 | |
| 286 | + IotDeviceShadowDO update = new IotDeviceShadowDO() | |
| 287 | + .setDesired(desired ? state : latest.getDesired()) | |
| 288 | + .setReported(desired ? latest.getReported() : state) | |
| 289 | + .setMetadata(metadata).setVersion(latest.getVersion() + 1).setShadowTime(now); | |
| 290 | + if (deviceShadowMapper.updateByDeviceIdAndVersion(update, deviceId, latest.getVersion()) > 0) { | |
| 291 | + deleteDeviceShadowCache(deviceId); | |
| 292 | + return update; | |
| 293 | + } | |
| 294 | + } | |
| 295 | + return null; | |
| 296 | + } | |
| 297 | + | |
| 298 | + /** | |
| 299 | + * 乐观锁清空影子:desired、reported、metadata 置空,version 归零 | |
| 300 | + * | |
| 301 | + * @param deviceId 设备编号 | |
| 302 | + * @return 是否成功;影子不存在时,无需清空,也返回 true;版本冲突重试耗尽时,返回 false | |
| 303 | + */ | |
| 304 | + private boolean resetDeviceShadow0(Long deviceId) { | |
| 305 | + for (int i = 0; i < OPTIMISTIC_LOCK_RETRY; i++) { | |
| 306 | + IotDeviceShadowDO latest = deviceShadowMapper.selectByDeviceId(deviceId); | |
| 307 | + if (latest == null) { | |
| 308 | + return true; | |
| 309 | + } | |
| 310 | + IotDeviceShadowDO update = new IotDeviceShadowDO() | |
| 311 | + .setDesired(new HashMap<>()).setReported(new HashMap<>()).setMetadata(new HashMap<>()) | |
| 312 | + .setVersion(0L).setShadowTime(DateUtil.currentSeconds()); | |
| 313 | + if (deviceShadowMapper.updateByDeviceIdAndVersion(update, deviceId, latest.getVersion()) > 0) { | |
| 314 | + deleteDeviceShadowCache(deviceId); | |
| 315 | + return true; | |
| 316 | + } | |
| 317 | + } | |
| 318 | + return false; | |
| 319 | + } | |
| 320 | + | |
| 321 | + /** | |
| 322 | + * 删除影子缓存:存在事务时,在提交后删除,避免并发查询回填提交前的旧影子 | |
| 323 | + * | |
| 324 | + * @param deviceId 设备编号 | |
| 325 | + */ | |
| 326 | + private void deleteDeviceShadowCache(Long deviceId) { | |
| 327 | + if (!TransactionSynchronizationManager.isSynchronizationActive()) { | |
| 328 | + deviceShadowRedisDAO.delete(deviceId); | |
| 329 | + return; | |
| 330 | + } | |
| 331 | + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { | |
| 332 | + | |
| 333 | + @Override | |
| 334 | + public void afterCommit() { | |
| 335 | + deviceShadowRedisDAO.delete(deviceId); | |
| 336 | + } | |
| 337 | + | |
| 338 | + }); | |
| 339 | + } | |
| 340 | + | |
| 341 | + private void sendShadowGetReply(IotDeviceDO device, String serverId, String requestId, IotDeviceShadowDO shadow) { | |
| 342 | + sendShadowReply(device, serverId, requestId, SUCCESS.getCode(), SUCCESS.getMsg(), | |
| 343 | + IotDeviceShadowProtocolUtils.buildGetData(shadow.getDesired(), shadow.getReported(), | |
| 344 | + shadow.getVersion(), shadow.getShadowTime())); | |
| 345 | + } | |
| 346 | + | |
| 347 | + /** | |
| 348 | + * 回复版本冲突:带上 DB 当前版本,便于设备重新同步 | |
| 349 | + */ | |
| 350 | + private void sendShadowVersionConflictReply(IotDeviceDO device, IotDeviceMessage message) { | |
| 351 | + IotDeviceShadowDO latest = deviceShadowMapper.selectByDeviceId(device.getId()); | |
| 352 | + sendShadowErrorReply(device, message, IotDeviceShadowErrorCodeEnum.VERSION_CONFLICT, | |
| 353 | + latest != null ? latest.getVersion() : 0L); | |
| 354 | + } | |
| 355 | + | |
| 356 | + /** | |
| 357 | + * 回复错误:data 仍带 version,便于设备重新同步 | |
| 358 | + */ | |
| 359 | + private void sendShadowErrorReply(IotDeviceDO device, IotDeviceMessage message, | |
| 360 | + IotDeviceShadowErrorCodeEnum errorCode, Long version) { | |
| 361 | + sendShadowReply(device, message.getServerId(), message.getRequestId(), | |
| 362 | + errorCode.getCode(), errorCode.getMessage(), | |
| 363 | + IotDeviceShadowProtocolUtils.buildUpdateData(version, DateUtil.currentSeconds())); | |
| 364 | + } | |
| 365 | + | |
| 366 | + /** | |
| 367 | + * 下发影子回复(标准 IotDeviceMessage 封套) | |
| 368 | + * <p> | |
| 369 | + * method 使用 {@link IotDeviceMessageMethodEnum#SHADOW_UPDATE},带 code 即为回复, | |
| 370 | + * 网关下发到 thing/shadow/update_reply Topic;发送失败只打印日志,不影响上行处理 | |
| 371 | + */ | |
| 372 | + private void sendShadowReply(IotDeviceDO device, String serverId, String requestId, | |
| 373 | + Integer code, String msg, Map<String, Object> data) { | |
| 374 | + IotDeviceMessage reply = IotDeviceMessage.replyOf(requestId, | |
| 375 | + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), data, code, msg); | |
| 376 | + reply.setDeviceId(device.getId()).setTenantId(device.getTenantId()).setServerId(serverId); | |
| 377 | + try { | |
| 378 | + deviceMessageService.sendDeviceMessage(reply, device, serverId); | |
| 379 | + } catch (Exception ex) { | |
| 380 | + log.error("[sendShadowReply][device({}) 下发影子回复失败]", device.getId(), ex); | |
| 381 | + } | |
| 382 | + } | |
| 383 | + | |
| 384 | + private static Map<String, Object> mergeMap(Map<String, Object> oldMap, Map<String, Object> newMap) { | |
| 385 | + Map<String, Object> result = oldMap != null ? new HashMap<>(oldMap) : new HashMap<>(); | |
| 386 | + if (CollUtil.isNotEmpty(newMap)) { | |
| 387 | + result.putAll(newMap); | |
| 388 | + } | |
| 389 | + return result; | |
| 390 | + } | |
| 391 | + | |
| 392 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImplTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.device; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; | |
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | |
| 5 | +import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceMapper; | |
| 6 | +import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; | |
| 8 | +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService; | |
| 9 | +import cn.iocoder.yudao.module.iot.service.product.IotProductService; | |
| 10 | +import jakarta.annotation.Resource; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | +import org.springframework.context.annotation.Import; | |
| 13 | +import org.springframework.test.context.bean.override.mockito.MockitoBean; | |
| 14 | + | |
| 15 | +import static org.junit.jupiter.api.Assertions.*; | |
| 16 | +import static org.mockito.Mockito.doThrow; | |
| 17 | +import static org.mockito.Mockito.verify; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * {@link IotDeviceServiceImpl} 的单元测试 | |
| 21 | + * | |
| 22 | + * @author 芋道源码 | |
| 23 | + */ | |
| 24 | +@Import(IotDeviceServiceImpl.class) | |
| 25 | +public class IotDeviceServiceImplTest extends BaseDbUnitTest { | |
| 26 | + | |
| 27 | + @Resource | |
| 28 | + private IotDeviceServiceImpl deviceService; | |
| 29 | + | |
| 30 | + @Resource | |
| 31 | + private IotDeviceMapper deviceMapper; | |
| 32 | + | |
| 33 | + @MockitoBean | |
| 34 | + private IotProductService productService; | |
| 35 | + @MockitoBean | |
| 36 | + private IotDeviceGroupService deviceGroupService; | |
| 37 | + @MockitoBean | |
| 38 | + private IotDeviceMessageService deviceMessageService; | |
| 39 | + @MockitoBean | |
| 40 | + private IotDeviceShadowService deviceShadowService; | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + public void testDeleteDevice_success() { | |
| 44 | + // mock 数据 | |
| 45 | + IotDeviceDO dbDevice = buildDevice(); | |
| 46 | + deviceMapper.insert(dbDevice); | |
| 47 | + // 准备参数 | |
| 48 | + Long id = dbDevice.getId(); | |
| 49 | + | |
| 50 | + // 调用 | |
| 51 | + deviceService.deleteDevice(id); | |
| 52 | + // 断言 | |
| 53 | + assertNull(deviceMapper.selectById(id)); | |
| 54 | + verify(deviceShadowService).deleteByDeviceId(id); | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + public void testDeleteDevice_shadowDeleteFailed() { | |
| 59 | + // mock 数据 | |
| 60 | + IotDeviceDO dbDevice = buildDevice(); | |
| 61 | + deviceMapper.insert(dbDevice); | |
| 62 | + // 准备参数 | |
| 63 | + Long id = dbDevice.getId(); | |
| 64 | + // mock 影子删除失败 | |
| 65 | + RuntimeException failure = new RuntimeException("shadow delete failed"); | |
| 66 | + doThrow(failure).when(deviceShadowService).deleteByDeviceId(id); | |
| 67 | + | |
| 68 | + // 调用,并断言异常 | |
| 69 | + assertSame(failure, assertThrows(RuntimeException.class, () -> deviceService.deleteDevice(id))); | |
| 70 | + // 断言:设备删除已回滚 | |
| 71 | + assertNotNull(deviceMapper.selectById(id)); | |
| 72 | + } | |
| 73 | + | |
| 74 | + private static IotDeviceDO buildDevice() { | |
| 75 | + return IotDeviceDO.builder().deviceName("testDevice").productId(10L).productKey("testProduct") | |
| 76 | + .deviceType(IotProductDeviceTypeEnum.DIRECT.getType()).build(); | |
| 77 | + } | |
| 78 | + | |
| 79 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/shadow/IotDeviceShadowServiceImplTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.device.shadow; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 4 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 7 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | |
| 8 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO; | |
| 9 | +import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceShadowMapper; | |
| 10 | +import cn.iocoder.yudao.module.iot.dal.redis.device.DeviceShadowRedisDAO; | |
| 11 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; | |
| 12 | +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; | |
| 13 | +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; | |
| 14 | +import org.junit.jupiter.api.Test; | |
| 15 | +import org.mockito.ArgumentCaptor; | |
| 16 | +import org.mockito.InjectMocks; | |
| 17 | +import org.mockito.Mock; | |
| 18 | +import org.springframework.dao.DuplicateKeyException; | |
| 19 | +import org.springframework.transaction.support.TransactionSynchronization; | |
| 20 | +import org.springframework.transaction.support.TransactionSynchronizationManager; | |
| 21 | + | |
| 22 | +import java.util.HashMap; | |
| 23 | +import java.util.List; | |
| 24 | +import java.util.Map; | |
| 25 | + | |
| 26 | +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | |
| 27 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; | |
| 28 | +import static org.junit.jupiter.api.Assertions.*; | |
| 29 | +import static org.mockito.ArgumentMatchers.*; | |
| 30 | +import static org.mockito.Mockito.*; | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * {@link IotDeviceShadowServiceImpl} 的单元测试 | |
| 34 | + * | |
| 35 | + * @author 芋道源码 | |
| 36 | + */ | |
| 37 | +public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest { | |
| 38 | + | |
| 39 | + @InjectMocks | |
| 40 | + private IotDeviceShadowServiceImpl deviceShadowService; | |
| 41 | + | |
| 42 | + @Mock | |
| 43 | + private IotDeviceShadowMapper deviceShadowMapper; | |
| 44 | + @Mock | |
| 45 | + private DeviceShadowRedisDAO deviceShadowRedisDAO; | |
| 46 | + @Mock | |
| 47 | + private IotDeviceService deviceService; | |
| 48 | + @Mock | |
| 49 | + private IotDevicePropertyService devicePropertyService; | |
| 50 | + @Mock | |
| 51 | + private IotDeviceMessageService deviceMessageService; | |
| 52 | + | |
| 53 | + // ========== getOrCreateShadow ========== | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + public void testGetOrCreateShadow_cacheHit() { | |
| 57 | + // mock 数据 | |
| 58 | + IotDeviceDO device = buildDevice(); | |
| 59 | + IotDeviceShadowDO cacheShadow = buildShadow(device, 2L); | |
| 60 | + // mock 方法 | |
| 61 | + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(cacheShadow); | |
| 62 | + | |
| 63 | + // 调用 | |
| 64 | + IotDeviceShadowDO shadow = deviceShadowService.getOrCreateShadow(device); | |
| 65 | + // 断言 | |
| 66 | + assertSame(cacheShadow, shadow); | |
| 67 | + verifyNoInteractions(deviceShadowMapper); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + public void testGetOrCreateShadow_notExists() { | |
| 72 | + // 准备参数 | |
| 73 | + IotDeviceDO device = buildDevice(); | |
| 74 | + // mock 方法 | |
| 75 | + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null); | |
| 76 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(null); | |
| 77 | + | |
| 78 | + // 调用 | |
| 79 | + IotDeviceShadowDO shadow = deviceShadowService.getOrCreateShadow(device); | |
| 80 | + // 断言:插入启用的空影子,并回填缓存 | |
| 81 | + verify(deviceShadowMapper).insert(shadow); | |
| 82 | + assertEquals(device.getId(), shadow.getDeviceId()); | |
| 83 | + assertEquals(CommonStatusEnum.ENABLE.getStatus(), shadow.getStatus()); | |
| 84 | + assertEquals(0L, shadow.getVersion()); | |
| 85 | + assertTrue(shadow.getReported().isEmpty()); | |
| 86 | + verify(deviceShadowRedisDAO).set(shadow); | |
| 87 | + } | |
| 88 | + | |
| 89 | + // ========== handleDeviceShadowUpdate:get ========== | |
| 90 | + | |
| 91 | + @Test | |
| 92 | + public void testHandleDeviceShadowUpdate_get() { | |
| 93 | + // mock 数据 | |
| 94 | + IotDeviceDO device = buildDevice(); | |
| 95 | + IotDeviceShadowDO dbShadow = buildShadow(device, 2L); | |
| 96 | + dbShadow.getDesired().put("PowerSwitch", 1); | |
| 97 | + // mock 方法 | |
| 98 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(dbShadow); | |
| 99 | + // 准备参数 | |
| 100 | + IotDeviceMessage message = buildMessage(device, Map.of("method", "get")); | |
| 101 | + | |
| 102 | + // 调用 | |
| 103 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 104 | + // 断言:读 DB 回复全量影子 | |
| 105 | + IotDeviceMessage reply = captureReply(device); | |
| 106 | + assertEquals(IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), reply.getMethod()); | |
| 107 | + assertEquals("req-1", reply.getRequestId()); | |
| 108 | + assertEquals(0, reply.getCode()); | |
| 109 | + assertEquals(2L, getReplyVersion(reply)); | |
| 110 | + assertEquals(Map.of("PowerSwitch", 1), getReplyState(reply).get("desired")); | |
| 111 | + verify(deviceShadowRedisDAO, never()).get(any()); | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Test | |
| 115 | + public void testHandleDeviceShadowUpdate_disabledInDb() { | |
| 116 | + // mock 数据:缓存为启用的旧影子,DB 已关闭 | |
| 117 | + IotDeviceDO device = buildDevice(); | |
| 118 | + lenient().when(deviceShadowRedisDAO.get(device.getId())).thenReturn(buildShadow(device, 0L)); | |
| 119 | + when(deviceShadowMapper.selectByDeviceId(device.getId())) | |
| 120 | + .thenReturn(buildShadow(device, 0L, CommonStatusEnum.DISABLE.getStatus())); | |
| 121 | + // 准备参数 | |
| 122 | + IotDeviceMessage message = buildMessage(device, buildUpdateParams(Map.of("Temperature", 25), null)); | |
| 123 | + | |
| 124 | + // 调用 | |
| 125 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 126 | + // 断言:以 DB 为准,忽略上行 | |
| 127 | + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); | |
| 128 | + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any()); | |
| 129 | + } | |
| 130 | + | |
| 131 | + // ========== handleDeviceShadowUpdate:update ========== | |
| 132 | + | |
| 133 | + @Test | |
| 134 | + public void testHandleDeviceShadowUpdate_update_success() { | |
| 135 | + // mock 数据 | |
| 136 | + IotDeviceDO device = buildDevice(); | |
| 137 | + IotDeviceShadowDO dbShadow = buildShadow(device, 1L); | |
| 138 | + dbShadow.getReported().put("Temperature", 20); | |
| 139 | + dbShadow.getDesired().put("PowerSwitch", 1); | |
| 140 | + // mock 方法 | |
| 141 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(dbShadow); | |
| 142 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(1L))).thenReturn(1); | |
| 143 | + // 准备参数 | |
| 144 | + IotDeviceMessage message = buildMessage(device, | |
| 145 | + buildUpdateParams(Map.of("Temperature", 25, "Humidity", 60), 1L)); | |
| 146 | + | |
| 147 | + // 调用 | |
| 148 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 149 | + // 断言:合并 reported,保留 desired,version + 1 | |
| 150 | + IotDeviceShadowDO update = captureUpdate(device, 1L); | |
| 151 | + assertEquals(Map.of("Temperature", 25, "Humidity", 60), update.getReported()); | |
| 152 | + assertEquals(Map.of("PowerSwitch", 1), update.getDesired()); | |
| 153 | + assertEquals(2L, update.getVersion()); | |
| 154 | + verify(deviceShadowRedisDAO).delete(device.getId()); | |
| 155 | + // 断言:回复新版本 | |
| 156 | + IotDeviceMessage reply = captureReply(device); | |
| 157 | + assertEquals(0, reply.getCode()); | |
| 158 | + assertEquals(2L, getReplyVersion(reply)); | |
| 159 | + } | |
| 160 | + | |
| 161 | + @Test | |
| 162 | + public void testHandleDeviceShadowUpdate_update_staleCacheUseDbVersion() { | |
| 163 | + // mock 数据:缓存为 v1 的旧影子,DB 已是 v2 | |
| 164 | + IotDeviceDO device = buildDevice(); | |
| 165 | + lenient().when(deviceShadowRedisDAO.get(device.getId())).thenReturn(buildShadow(device, 1L)); | |
| 166 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 2L)); | |
| 167 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(2L))).thenReturn(1); | |
| 168 | + // 准备参数:设备携带 DB 当前版本 v2 | |
| 169 | + IotDeviceMessage message = buildMessage(device, buildUpdateParams(Map.of("Temperature", 25), 2L)); | |
| 170 | + | |
| 171 | + // 调用 | |
| 172 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 173 | + // 断言:按 DB 版本写入成功 | |
| 174 | + assertEquals(3L, captureUpdate(device, 2L).getVersion()); | |
| 175 | + assertEquals(0, captureReply(device).getCode()); | |
| 176 | + verify(deviceShadowRedisDAO, never()).get(any()); | |
| 177 | + } | |
| 178 | + | |
| 179 | + @Test | |
| 180 | + public void testHandleDeviceShadowUpdate_update_versionConflict() { | |
| 181 | + // mock 数据 | |
| 182 | + IotDeviceDO device = buildDevice(); | |
| 183 | + // mock 方法 | |
| 184 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 5L)); | |
| 185 | + // 准备参数:设备携带旧版本 v3 | |
| 186 | + IotDeviceMessage message = buildMessage(device, buildUpdateParams(Map.of("Temperature", 25), 3L)); | |
| 187 | + | |
| 188 | + // 调用 | |
| 189 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 190 | + // 断言:不写入,回复 409 及 DB 当前版本 | |
| 191 | + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); | |
| 192 | + IotDeviceMessage reply = captureReply(device); | |
| 193 | + assertEquals(409, reply.getCode()); | |
| 194 | + assertEquals(5L, getReplyVersion(reply)); | |
| 195 | + } | |
| 196 | + | |
| 197 | + @Test | |
| 198 | + public void testHandleDeviceShadowUpdate_update_versionNull() { | |
| 199 | + // mock 数据 | |
| 200 | + IotDeviceDO device = buildDevice(); | |
| 201 | + // mock 方法 | |
| 202 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 5L)); | |
| 203 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(5L))).thenReturn(1); | |
| 204 | + // 准备参数:不传 version,强制更新 | |
| 205 | + IotDeviceMessage message = buildMessage(device, buildUpdateParams(Map.of("Temperature", 25), null)); | |
| 206 | + | |
| 207 | + // 调用 | |
| 208 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 209 | + // 断言 | |
| 210 | + assertEquals(6L, captureUpdate(device, 5L).getVersion()); | |
| 211 | + } | |
| 212 | + | |
| 213 | + @Test | |
| 214 | + public void testHandleDeviceShadowUpdate_update_retryFromLatest() { | |
| 215 | + // mock 数据:前两次读到 v1,之后读到 v2 | |
| 216 | + IotDeviceDO device = buildDevice(); | |
| 217 | + IotDeviceShadowDO staleShadow = buildShadow(device, 1L); | |
| 218 | + staleShadow.getReported().put("Temperature", 20); | |
| 219 | + IotDeviceShadowDO latestShadow = buildShadow(device, 2L); | |
| 220 | + latestShadow.getReported().put("Temperature", 30); | |
| 221 | + latestShadow.getReported().put("Humidity", 50); | |
| 222 | + // mock 方法:v1 写入冲突,v2 写入成功 | |
| 223 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(staleShadow, staleShadow, latestShadow); | |
| 224 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(1L))).thenReturn(0); | |
| 225 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(2L))).thenReturn(1); | |
| 226 | + // 准备参数 | |
| 227 | + IotDeviceMessage message = buildMessage(device, buildUpdateParams(Map.of("Temperature", 25), null)); | |
| 228 | + | |
| 229 | + // 调用 | |
| 230 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 231 | + // 断言:重试时基于 v2 合并,保留 Humidity | |
| 232 | + IotDeviceShadowDO update = captureUpdate(device, 2L); | |
| 233 | + assertEquals(Map.of("Temperature", 25, "Humidity", 50), update.getReported()); | |
| 234 | + assertEquals(3L, update.getVersion()); | |
| 235 | + assertEquals(3L, getReplyVersion(captureReply(device))); | |
| 236 | + } | |
| 237 | + | |
| 238 | + @Test | |
| 239 | + public void testHandleDeviceShadowUpdate_update_retryExhausted() { | |
| 240 | + // mock 数据 | |
| 241 | + IotDeviceDO device = buildDevice(); | |
| 242 | + // mock 方法:每次写入都冲突 | |
| 243 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 4L)); | |
| 244 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(4L))).thenReturn(0); | |
| 245 | + // 准备参数 | |
| 246 | + IotDeviceMessage message = buildMessage(device, buildUpdateParams(Map.of("Temperature", 25), null)); | |
| 247 | + | |
| 248 | + // 调用 | |
| 249 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 250 | + // 断言:重试 3 次后,回复 409 | |
| 251 | + verify(deviceShadowMapper, times(3)).updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(4L)); | |
| 252 | + verify(deviceShadowRedisDAO, never()).delete(any()); | |
| 253 | + IotDeviceMessage reply = captureReply(device); | |
| 254 | + assertEquals(409, reply.getCode()); | |
| 255 | + assertEquals(4L, getReplyVersion(reply)); | |
| 256 | + } | |
| 257 | + | |
| 258 | + @Test | |
| 259 | + public void testHandleDeviceShadowUpdate_update_propertyCountExceeded() { | |
| 260 | + // mock 数据:已有 128 个属性 | |
| 261 | + IotDeviceDO device = buildDevice(); | |
| 262 | + IotDeviceShadowDO dbShadow = buildShadow(device, 1L); | |
| 263 | + for (int i = 0; i < 128; i++) { | |
| 264 | + dbShadow.getReported().put("p" + i, i); | |
| 265 | + } | |
| 266 | + // mock 方法 | |
| 267 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(dbShadow); | |
| 268 | + // 准备参数 | |
| 269 | + IotDeviceMessage message = buildMessage(device, buildUpdateParams(Map.of("Temperature", 25), 1L)); | |
| 270 | + | |
| 271 | + // 调用 | |
| 272 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 273 | + // 断言:不写入,回复 408 | |
| 274 | + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); | |
| 275 | + IotDeviceMessage reply = captureReply(device); | |
| 276 | + assertEquals(408, reply.getCode()); | |
| 277 | + assertEquals(1L, getReplyVersion(reply)); | |
| 278 | + } | |
| 279 | + | |
| 280 | + @Test | |
| 281 | + public void testHandleDeviceShadowUpdate_resetWithoutState() { | |
| 282 | + // mock 数据 | |
| 283 | + IotDeviceDO device = buildDevice(); | |
| 284 | + // mock 方法 | |
| 285 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 3L)); | |
| 286 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(3L))).thenReturn(1); | |
| 287 | + // 准备参数:version 为 -1,且不传 state | |
| 288 | + IotDeviceMessage message = buildMessage(device, buildResetParams()); | |
| 289 | + | |
| 290 | + // 调用 | |
| 291 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 292 | + // 断言:按 DB 版本清空影子,version 归零 | |
| 293 | + IotDeviceShadowDO update = captureUpdate(device, 3L); | |
| 294 | + assertTrue(update.getDesired().isEmpty()); | |
| 295 | + assertTrue(update.getReported().isEmpty()); | |
| 296 | + assertTrue(update.getMetadata().isEmpty()); | |
| 297 | + assertEquals(0L, update.getVersion()); | |
| 298 | + verify(deviceShadowRedisDAO).delete(device.getId()); | |
| 299 | + // 断言:回复成功 | |
| 300 | + IotDeviceMessage reply = captureReply(device); | |
| 301 | + assertEquals(0, reply.getCode()); | |
| 302 | + assertEquals(0L, getReplyVersion(reply)); | |
| 303 | + } | |
| 304 | + | |
| 305 | + @Test | |
| 306 | + public void testHandleDeviceShadowUpdate_resetRetryExhausted() { | |
| 307 | + // mock 数据 | |
| 308 | + IotDeviceDO device = buildDevice(); | |
| 309 | + // mock 方法:每次清空都冲突 | |
| 310 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 3L)); | |
| 311 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(3L))).thenReturn(0); | |
| 312 | + // 准备参数 | |
| 313 | + IotDeviceMessage message = buildMessage(device, buildResetParams()); | |
| 314 | + | |
| 315 | + // 调用 | |
| 316 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 317 | + // 断言:重试 3 次后,回复 409 及 DB 当前版本 | |
| 318 | + verify(deviceShadowMapper, times(3)).updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(3L)); | |
| 319 | + verify(deviceShadowRedisDAO, never()).delete(any()); | |
| 320 | + IotDeviceMessage reply = captureReply(device); | |
| 321 | + assertEquals(409, reply.getCode()); | |
| 322 | + assertEquals(3L, getReplyVersion(reply)); | |
| 323 | + } | |
| 324 | + | |
| 325 | + @Test | |
| 326 | + public void testHandleDeviceShadowUpdate_stateMissing() { | |
| 327 | + // mock 数据 | |
| 328 | + IotDeviceDO device = buildDevice(); | |
| 329 | + // mock 方法 | |
| 330 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 3L)); | |
| 331 | + // 准备参数 | |
| 332 | + IotDeviceMessage message = buildMessage(device, Map.of("method", "update", "version", 3L)); | |
| 333 | + | |
| 334 | + // 调用 | |
| 335 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 336 | + // 断言 | |
| 337 | + IotDeviceMessage reply = captureReply(device); | |
| 338 | + assertEquals(402, reply.getCode()); | |
| 339 | + assertEquals(3L, getReplyVersion(reply)); | |
| 340 | + } | |
| 341 | + | |
| 342 | + @Test | |
| 343 | + public void testHandleDeviceShadowUpdate_invalidParams() { | |
| 344 | + // 准备参数 | |
| 345 | + IotDeviceDO device = buildDevice(); | |
| 346 | + IotDeviceMessage message = buildMessage(device, null); | |
| 347 | + | |
| 348 | + // 调用 | |
| 349 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 350 | + // 断言 | |
| 351 | + assertEquals(400, captureReply(device).getCode()); | |
| 352 | + } | |
| 353 | + | |
| 354 | + @Test | |
| 355 | + public void testHandleDeviceShadowUpdate_methodMissing() { | |
| 356 | + // mock 数据 | |
| 357 | + IotDeviceDO device = buildDevice(); | |
| 358 | + // mock 方法 | |
| 359 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 3L)); | |
| 360 | + // 准备参数 | |
| 361 | + IotDeviceMessage message = buildMessage(device, Map.of("state", Map.of("reported", Map.of("Temperature", 25)))); | |
| 362 | + | |
| 363 | + // 调用 | |
| 364 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 365 | + // 断言 | |
| 366 | + assertEquals(401, captureReply(device).getCode()); | |
| 367 | + } | |
| 368 | + | |
| 369 | + @Test | |
| 370 | + public void testHandleDeviceShadowUpdate_dbFailure() { | |
| 371 | + // mock 数据 | |
| 372 | + IotDeviceDO device = buildDevice(); | |
| 373 | + // mock 方法 | |
| 374 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenThrow(new RuntimeException("DB down")); | |
| 375 | + // 准备参数 | |
| 376 | + IotDeviceMessage message = buildMessage(device, Map.of("method", "get")); | |
| 377 | + | |
| 378 | + // 调用 | |
| 379 | + assertDoesNotThrow(() -> deviceShadowService.handleDeviceShadowUpdate(device, message)); | |
| 380 | + // 断言:仍回复 500 | |
| 381 | + IotDeviceMessage reply = captureReply(device); | |
| 382 | + assertEquals(500, reply.getCode()); | |
| 383 | + assertEquals(0L, getReplyVersion(reply)); | |
| 384 | + } | |
| 385 | + | |
| 386 | + @Test | |
| 387 | + public void testHandleDeviceShadowUpdate_shadowDeletedDuringUpdate() { | |
| 388 | + // mock 数据 | |
| 389 | + IotDeviceDO device = buildDevice(); | |
| 390 | + // mock 方法:校验时存在,更新时已被删除 | |
| 391 | + when(deviceShadowMapper.selectByDeviceId(device.getId())) | |
| 392 | + .thenReturn(buildShadow(device, 2L), (IotDeviceShadowDO) null); | |
| 393 | + // 准备参数 | |
| 394 | + IotDeviceMessage message = buildMessage(device, buildUpdateParams(Map.of("Temperature", 25), 2L)); | |
| 395 | + | |
| 396 | + // 调用 | |
| 397 | + deviceShadowService.handleDeviceShadowUpdate(device, message); | |
| 398 | + // 断言 | |
| 399 | + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); | |
| 400 | + assertEquals(500, captureReply(device).getCode()); | |
| 401 | + } | |
| 402 | + | |
| 403 | + // ========== updateDesiredState ========== | |
| 404 | + | |
| 405 | + @Test | |
| 406 | + public void testUpdateDesiredState_online() { | |
| 407 | + // mock 数据:缓存为关闭的旧影子,DB 已启用 | |
| 408 | + IotDeviceDO device = buildDevice(); | |
| 409 | + IotDeviceShadowDO dbShadow = buildShadow(device, 1L); | |
| 410 | + dbShadow.getReported().put("Temperature", 20); | |
| 411 | + lenient().when(deviceShadowRedisDAO.get(device.getId())) | |
| 412 | + .thenReturn(buildShadow(device, 1L, CommonStatusEnum.DISABLE.getStatus())); | |
| 413 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 414 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(dbShadow); | |
| 415 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(1L))).thenReturn(1); | |
| 416 | + when(devicePropertyService.getDeviceServerId(device.getId())).thenReturn("server-1"); | |
| 417 | + // 准备参数:数值、结构体、数组保持原类型 | |
| 418 | + Map<String, Object> desired = Map.of("Temperature", 25, "Location", Map.of("lat", 31.2), | |
| 419 | + "Modes", List.of(1, 2)); | |
| 420 | + | |
| 421 | + // 调用 | |
| 422 | + deviceShadowService.updateDesiredState(device.getId(), desired); | |
| 423 | + // 断言:合并 desired,保留 reported | |
| 424 | + IotDeviceShadowDO update = captureUpdate(device, 1L); | |
| 425 | + assertEquals(desired, update.getDesired()); | |
| 426 | + assertEquals(Map.of("Temperature", 20), update.getReported()); | |
| 427 | + assertEquals(2L, update.getVersion()); | |
| 428 | + verify(deviceShadowRedisDAO).delete(device.getId()); | |
| 429 | + // 断言:推送全量影子,desired 类型不变 | |
| 430 | + IotDeviceMessage push = captureReply(device); | |
| 431 | + assertEquals(IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), push.getMethod()); | |
| 432 | + assertEquals(0, push.getCode()); | |
| 433 | + assertEquals(2L, getReplyVersion(push)); | |
| 434 | + assertEquals(desired, getReplyState(push).get("desired")); | |
| 435 | + assertEquals(Map.of("Temperature", 20), getReplyState(push).get("reported")); | |
| 436 | + } | |
| 437 | + | |
| 438 | + @Test | |
| 439 | + public void testUpdateDesiredState_offline() { | |
| 440 | + // mock 数据 | |
| 441 | + IotDeviceDO device = buildDevice(); | |
| 442 | + // mock 方法 | |
| 443 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 444 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 0L)); | |
| 445 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(0L))).thenReturn(1); | |
| 446 | + when(devicePropertyService.getDeviceServerId(device.getId())).thenReturn(null); | |
| 447 | + | |
| 448 | + // 调用 | |
| 449 | + deviceShadowService.updateDesiredState(device.getId(), Map.of("PowerSwitch", 1)); | |
| 450 | + // 断言 | |
| 451 | + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any()); | |
| 452 | + } | |
| 453 | + | |
| 454 | + @Test | |
| 455 | + public void testUpdateDesiredState_disabledInDb() { | |
| 456 | + // mock 数据:缓存为启用的旧影子,DB 已关闭 | |
| 457 | + IotDeviceDO device = buildDevice(); | |
| 458 | + lenient().when(deviceShadowRedisDAO.get(device.getId())).thenReturn(buildShadow(device, 0L)); | |
| 459 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 460 | + when(deviceShadowMapper.selectByDeviceId(device.getId())) | |
| 461 | + .thenReturn(buildShadow(device, 0L, CommonStatusEnum.DISABLE.getStatus())); | |
| 462 | + | |
| 463 | + // 调用,并断言异常 | |
| 464 | + assertServiceException(() -> deviceShadowService.updateDesiredState(device.getId(), Map.of("PowerSwitch", 1)), | |
| 465 | + DEVICE_SHADOW_NOT_ENABLED); | |
| 466 | + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); | |
| 467 | + } | |
| 468 | + | |
| 469 | + @Test | |
| 470 | + public void testUpdateDesiredState_propertyCountExceeded() { | |
| 471 | + // mock 数据:已有 128 个属性 | |
| 472 | + IotDeviceDO device = buildDevice(); | |
| 473 | + IotDeviceShadowDO dbShadow = buildShadow(device, 1L); | |
| 474 | + for (int i = 0; i < 128; i++) { | |
| 475 | + dbShadow.getDesired().put("p" + i, i); | |
| 476 | + } | |
| 477 | + // mock 方法 | |
| 478 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 479 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(dbShadow); | |
| 480 | + | |
| 481 | + // 调用,并断言异常 | |
| 482 | + assertServiceException(() -> deviceShadowService.updateDesiredState(device.getId(), Map.of("PowerSwitch", 1)), | |
| 483 | + DEVICE_SHADOW_PROPERTY_COUNT_EXCEEDED); | |
| 484 | + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); | |
| 485 | + } | |
| 486 | + | |
| 487 | + @Test | |
| 488 | + public void testUpdateDesiredState_retryExhausted() { | |
| 489 | + // mock 数据 | |
| 490 | + IotDeviceDO device = buildDevice(); | |
| 491 | + // mock 方法:每次写入都冲突 | |
| 492 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 493 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 1L)); | |
| 494 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(1L))).thenReturn(0); | |
| 495 | + | |
| 496 | + // 调用,并断言异常 | |
| 497 | + assertServiceException(() -> deviceShadowService.updateDesiredState(device.getId(), Map.of("PowerSwitch", 1)), | |
| 498 | + DEVICE_SHADOW_VERSION_CONFLICT); | |
| 499 | + verify(deviceShadowMapper, times(3)).updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(1L)); | |
| 500 | + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any()); | |
| 501 | + } | |
| 502 | + | |
| 503 | + // ========== resetShadow ========== | |
| 504 | + | |
| 505 | + @Test | |
| 506 | + public void testResetShadow() { | |
| 507 | + // mock 数据 | |
| 508 | + IotDeviceDO device = buildDevice(); | |
| 509 | + // mock 方法 | |
| 510 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 511 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 2L)); | |
| 512 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(2L))).thenReturn(1); | |
| 513 | + | |
| 514 | + // 调用 | |
| 515 | + deviceShadowService.resetShadow(device.getId()); | |
| 516 | + // 断言 | |
| 517 | + IotDeviceShadowDO update = captureUpdate(device, 2L); | |
| 518 | + assertTrue(update.getDesired().isEmpty()); | |
| 519 | + assertTrue(update.getReported().isEmpty()); | |
| 520 | + assertEquals(0L, update.getVersion()); | |
| 521 | + verify(deviceShadowRedisDAO).delete(device.getId()); | |
| 522 | + } | |
| 523 | + | |
| 524 | + @Test | |
| 525 | + public void testResetShadow_notExists() { | |
| 526 | + // mock 数据 | |
| 527 | + IotDeviceDO device = buildDevice(); | |
| 528 | + // mock 方法 | |
| 529 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 530 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(null); | |
| 531 | + | |
| 532 | + // 调用 | |
| 533 | + deviceShadowService.resetShadow(device.getId()); | |
| 534 | + // 断言:影子不存在,无需清空 | |
| 535 | + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any()); | |
| 536 | + verify(deviceShadowRedisDAO, never()).delete(any()); | |
| 537 | + } | |
| 538 | + | |
| 539 | + @Test | |
| 540 | + public void testResetShadow_retryExhausted() { | |
| 541 | + // mock 数据 | |
| 542 | + IotDeviceDO device = buildDevice(); | |
| 543 | + // mock 方法:每次清空都冲突 | |
| 544 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 545 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 2L)); | |
| 546 | + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(2L))).thenReturn(0); | |
| 547 | + | |
| 548 | + // 调用,并断言异常 | |
| 549 | + assertServiceException(() -> deviceShadowService.resetShadow(device.getId()), DEVICE_SHADOW_VERSION_CONFLICT); | |
| 550 | + verify(deviceShadowMapper, times(3)).updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(2L)); | |
| 551 | + verify(deviceShadowRedisDAO, never()).delete(any()); | |
| 552 | + } | |
| 553 | + | |
| 554 | + // ========== updateStatus ========== | |
| 555 | + | |
| 556 | + @Test | |
| 557 | + public void testUpdateStatus_shadowMissing() { | |
| 558 | + // mock 数据 | |
| 559 | + IotDeviceDO device = buildDevice(); | |
| 560 | + // mock 方法 | |
| 561 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 562 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(null); | |
| 563 | + | |
| 564 | + // 调用 | |
| 565 | + deviceShadowService.updateStatus(device.getId(), CommonStatusEnum.DISABLE.getStatus()); | |
| 566 | + // 断言:直接按目标状态创建 | |
| 567 | + ArgumentCaptor<IotDeviceShadowDO> insertCaptor = ArgumentCaptor.forClass(IotDeviceShadowDO.class); | |
| 568 | + verify(deviceShadowMapper).insert(insertCaptor.capture()); | |
| 569 | + assertEquals(CommonStatusEnum.DISABLE.getStatus(), insertCaptor.getValue().getStatus()); | |
| 570 | + verify(deviceShadowMapper, never()).updateByDeviceId(any(), any()); | |
| 571 | + } | |
| 572 | + | |
| 573 | + @Test | |
| 574 | + public void testUpdateStatus_duplicateKey() { | |
| 575 | + // mock 数据 | |
| 576 | + IotDeviceDO device = buildDevice(); | |
| 577 | + // mock 方法:并发创建,插入冲突后读到已启用的影子 | |
| 578 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 579 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(null, buildShadow(device, 0L)); | |
| 580 | + when(deviceShadowMapper.insert(any(IotDeviceShadowDO.class))).thenThrow(new DuplicateKeyException("duplicate")); | |
| 581 | + | |
| 582 | + // 调用 | |
| 583 | + deviceShadowService.updateStatus(device.getId(), CommonStatusEnum.DISABLE.getStatus()); | |
| 584 | + // 断言:改为更新状态,并删除缓存 | |
| 585 | + ArgumentCaptor<IotDeviceShadowDO> updateCaptor = ArgumentCaptor.forClass(IotDeviceShadowDO.class); | |
| 586 | + verify(deviceShadowMapper).updateByDeviceId(updateCaptor.capture(), eq(device.getId())); | |
| 587 | + assertEquals(CommonStatusEnum.DISABLE.getStatus(), updateCaptor.getValue().getStatus()); | |
| 588 | + assertNull(updateCaptor.getValue().getVersion()); | |
| 589 | + verify(deviceShadowRedisDAO).delete(device.getId()); | |
| 590 | + } | |
| 591 | + | |
| 592 | + @Test | |
| 593 | + public void testUpdateStatus_inTransaction() { | |
| 594 | + // mock 数据 | |
| 595 | + IotDeviceDO device = buildDevice(); | |
| 596 | + // mock 方法 | |
| 597 | + when(deviceService.validateDeviceExists(device.getId())).thenReturn(device); | |
| 598 | + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(buildShadow(device, 0L)); | |
| 599 | + // mock 事务 | |
| 600 | + TransactionSynchronizationManager.initSynchronization(); | |
| 601 | + try { | |
| 602 | + // 调用 | |
| 603 | + deviceShadowService.updateStatus(device.getId(), CommonStatusEnum.DISABLE.getStatus()); | |
| 604 | + // 断言:提交前不删除缓存 | |
| 605 | + verify(deviceShadowMapper).updateByDeviceId(any(), eq(device.getId())); | |
| 606 | + verify(deviceShadowRedisDAO, never()).delete(any()); | |
| 607 | + // 断言:提交后删除缓存 | |
| 608 | + TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit); | |
| 609 | + verify(deviceShadowRedisDAO).delete(device.getId()); | |
| 610 | + } finally { | |
| 611 | + TransactionSynchronizationManager.clearSynchronization(); | |
| 612 | + } | |
| 613 | + } | |
| 614 | + | |
| 615 | + // ========== deleteByDeviceId ========== | |
| 616 | + | |
| 617 | + @Test | |
| 618 | + public void testDeleteByDeviceId_inTransaction() { | |
| 619 | + // 准备参数 | |
| 620 | + Long deviceId = 2L; | |
| 621 | + // mock 事务 | |
| 622 | + TransactionSynchronizationManager.initSynchronization(); | |
| 623 | + try { | |
| 624 | + // 调用 | |
| 625 | + deviceShadowService.deleteByDeviceId(deviceId); | |
| 626 | + // 断言:提交前不删除缓存 | |
| 627 | + verify(deviceShadowMapper).deleteByDeviceId(deviceId); | |
| 628 | + verify(deviceShadowRedisDAO, never()).delete(any()); | |
| 629 | + // 断言:回滚时不删除缓存 | |
| 630 | + TransactionSynchronizationManager.getSynchronizations() | |
| 631 | + .forEach(sync -> sync.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); | |
| 632 | + verify(deviceShadowRedisDAO, never()).delete(any()); | |
| 633 | + // 断言:提交后删除缓存 | |
| 634 | + TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit); | |
| 635 | + verify(deviceShadowRedisDAO).delete(deviceId); | |
| 636 | + } finally { | |
| 637 | + TransactionSynchronizationManager.clearSynchronization(); | |
| 638 | + } | |
| 639 | + } | |
| 640 | + | |
| 641 | + // ========== 私有方法 ========== | |
| 642 | + | |
| 643 | + private IotDeviceDO buildDevice() { | |
| 644 | + return IotDeviceDO.builder().id(2L).productId(10L).productKey("testProduct").deviceName("testDevice").build(); | |
| 645 | + } | |
| 646 | + | |
| 647 | + private IotDeviceShadowDO buildShadow(IotDeviceDO device, Long version) { | |
| 648 | + return buildShadow(device, version, CommonStatusEnum.ENABLE.getStatus()); | |
| 649 | + } | |
| 650 | + | |
| 651 | + private IotDeviceShadowDO buildShadow(IotDeviceDO device, Long version, Integer status) { | |
| 652 | + return new IotDeviceShadowDO().setId(100L).setDeviceId(device.getId()).setProductId(device.getProductId()) | |
| 653 | + .setProductKey(device.getProductKey()).setDeviceName(device.getDeviceName()) | |
| 654 | + .setDesired(new HashMap<>()).setReported(new HashMap<>()).setMetadata(new HashMap<>()) | |
| 655 | + .setVersion(version).setShadowTime(1000L).setStatus(status); | |
| 656 | + } | |
| 657 | + | |
| 658 | + private static Map<String, Object> buildUpdateParams(Map<String, Object> reported, Long version) { | |
| 659 | + Map<String, Object> params = new HashMap<>(); | |
| 660 | + params.put("method", "update"); | |
| 661 | + params.put("state", Map.of("reported", reported)); | |
| 662 | + params.put("version", version); | |
| 663 | + return params; | |
| 664 | + } | |
| 665 | + | |
| 666 | + private static Map<String, Object> buildResetParams() { | |
| 667 | + Map<String, Object> params = new HashMap<>(); | |
| 668 | + params.put("method", "update"); | |
| 669 | + params.put("version", -1L); | |
| 670 | + return params; | |
| 671 | + } | |
| 672 | + | |
| 673 | + private static IotDeviceMessage buildMessage(IotDeviceDO device, Object params) { | |
| 674 | + return IotDeviceMessage.requestOf(device.getId(), 1L, "server-1", | |
| 675 | + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params).setRequestId("req-1"); | |
| 676 | + } | |
| 677 | + | |
| 678 | + private IotDeviceShadowDO captureUpdate(IotDeviceDO device, Long expectVersion) { | |
| 679 | + ArgumentCaptor<IotDeviceShadowDO> updateCaptor = ArgumentCaptor.forClass(IotDeviceShadowDO.class); | |
| 680 | + verify(deviceShadowMapper).updateByDeviceIdAndVersion(updateCaptor.capture(), eq(device.getId()), | |
| 681 | + eq(expectVersion)); | |
| 682 | + return updateCaptor.getValue(); | |
| 683 | + } | |
| 684 | + | |
| 685 | + private IotDeviceMessage captureReply(IotDeviceDO device) { | |
| 686 | + ArgumentCaptor<IotDeviceMessage> replyCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 687 | + verify(deviceMessageService).sendDeviceMessage(replyCaptor.capture(), eq(device), eq("server-1")); | |
| 688 | + return replyCaptor.getValue(); | |
| 689 | + } | |
| 690 | + | |
| 691 | + @SuppressWarnings("unchecked") | |
| 692 | + private static Long getReplyVersion(IotDeviceMessage reply) { | |
| 693 | + Object version = ((Map<String, Object>) reply.getData()).get("version"); | |
| 694 | + return version instanceof Number ? ((Number) version).longValue() : null; | |
| 695 | + } | |
| 696 | + | |
| 697 | + @SuppressWarnings("unchecked") | |
| 698 | + private static Map<String, Object> getReplyState(IotDeviceMessage reply) { | |
| 699 | + return (Map<String, Object>) ((Map<String, Object>) reply.getData()).get("state"); | |
| 700 | + } | |
| 701 | + | |
| 702 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/clean.sql
yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql
| ... | ... | @@ -189,6 +189,29 @@ CREATE TABLE IF NOT EXISTS "iot_ota_record" ( |
| 189 | 189 | PRIMARY KEY ("id") |
| 190 | 190 | ) COMMENT 'IoT OTA 升级记录表'; |
| 191 | 191 | |
| 192 | +CREATE TABLE IF NOT EXISTS "iot_device_shadow" | |
| 193 | +( | |
| 194 | + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, | |
| 195 | + "device_id" bigint NOT NULL, | |
| 196 | + "product_id" bigint NOT NULL, | |
| 197 | + "product_key" varchar(64) NOT NULL DEFAULT '', | |
| 198 | + "device_name" varchar(255) NOT NULL DEFAULT '', | |
| 199 | + "reported" varchar(10000) DEFAULT NULL, | |
| 200 | + "desired" varchar(10000) DEFAULT NULL, | |
| 201 | + "metadata" varchar(10000) DEFAULT NULL, | |
| 202 | + "version" bigint NOT NULL DEFAULT 0, | |
| 203 | + "shadow_time" bigint NOT NULL DEFAULT 0, | |
| 204 | + "status" tinyint NOT NULL DEFAULT 0, | |
| 205 | + "creator" varchar(64) DEFAULT '', | |
| 206 | + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 207 | + "updater" varchar(64) DEFAULT '', | |
| 208 | + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 209 | + "deleted" bit NOT NULL DEFAULT FALSE, | |
| 210 | + "tenant_id" bigint NOT NULL DEFAULT '0', | |
| 211 | + PRIMARY KEY ("id"), | |
| 212 | + UNIQUE ("device_id") | |
| 213 | +) COMMENT 'IoT 设备影子表'; | |
| 214 | + | |
| 192 | 215 | CREATE TABLE IF NOT EXISTS "iot_data_rule" ( |
| 193 | 216 | "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, |
| 194 | 217 | "name" varchar(128) NOT NULL, | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java
| ... | ... | @@ -67,6 +67,10 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable<String> { |
| 67 | 67 | OTA_UPGRADE("thing.ota.upgrade", "OTA 固件信息推送", false), |
| 68 | 68 | OTA_PROGRESS("thing.ota.progress", "OTA 升级进度上报", true), |
| 69 | 69 | |
| 70 | + // ========== 设备影子 ========== | |
| 71 | + | |
| 72 | + SHADOW_UPDATE("thing.shadow.update", "设备影子更新", true), | |
| 73 | + | |
| 70 | 74 | ; |
| 71 | 75 | |
| 72 | 76 | public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod) |
| ... | ... | @@ -77,7 +81,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable<String> { |
| 77 | 81 | */ |
| 78 | 82 | public static final Set<String> REPLY_DISABLED = SetUtils.asSet( |
| 79 | 83 | STATE_UPDATE.getMethod(), |
| 80 | - OTA_PROGRESS.getMethod() // 参考阿里云,OTA 升级进度上报,不进行回复 | |
| 84 | + OTA_PROGRESS.getMethod(), // 参考阿里云,OTA 升级进度上报,不进行回复 | |
| 85 | + SHADOW_UPDATE.getMethod() // 设备影子由影子 Service 主动下发回复(thing/shadow/update_reply) | |
| 81 | 86 | ); |
| 82 | 87 | |
| 83 | 88 | private final String method; | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/shadow/IotDeviceShadowErrorCodeEnum.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.enums.shadow; | |
| 2 | + | |
| 3 | +import lombok.AllArgsConstructor; | |
| 4 | +import lombok.Getter; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * IoT 设备影子回复的错误码枚举 | |
| 8 | + * <p> | |
| 9 | + * 用于设备影子回复(Topic:thing/shadow/update_reply)的 code、msg; | |
| 10 | + * 管理后台接口的业务异常,使用 biz 模块的 ErrorCodeConstants | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Getter | |
| 15 | +@AllArgsConstructor | |
| 16 | +public enum IotDeviceShadowErrorCodeEnum { | |
| 17 | + | |
| 18 | + JSON_FORMAT_ERROR(400, "JSON 格式错误"), | |
| 19 | + METHOD_MISSING(401, "缺少 method"), | |
| 20 | + STATE_MISSING(402, "缺少 state"), | |
| 21 | + REPORTED_MISSING(404, "缺少 reported"), | |
| 22 | + REPORTED_EMPTY(405, "reported 为空"), | |
| 23 | + INVALID_METHOD(406, "无效内层 method"), | |
| 24 | + PROPERTY_COUNT_EXCEEDED(408, "影子属性数超过限制"), | |
| 25 | + VERSION_CONFLICT(409, "影子版本冲突"), | |
| 26 | + SERVER_ERROR(500, "服务端异常"); | |
| 27 | + | |
| 28 | + private final Integer code; | |
| 29 | + | |
| 30 | + private final String message; | |
| 31 | + | |
| 32 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/shadow/IotDeviceShadowUpdateReqDTO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.topic.shadow; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 4 | +import lombok.AllArgsConstructor; | |
| 5 | +import lombok.Data; | |
| 6 | +import lombok.NoArgsConstructor; | |
| 7 | + | |
| 8 | +import java.util.Map; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * IoT 设备影子上行 Request DTO | |
| 12 | + * <p> | |
| 13 | + * 用于 {@link IotDeviceMessageMethodEnum#SHADOW_UPDATE} 标准封套的 params 参数 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +@Data | |
| 18 | +@NoArgsConstructor | |
| 19 | +@AllArgsConstructor | |
| 20 | +public class IotDeviceShadowUpdateReqDTO { | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * 内层方法:get / update | |
| 24 | + */ | |
| 25 | + private String method; | |
| 26 | + | |
| 27 | + /** | |
| 28 | + * 影子状态 | |
| 29 | + */ | |
| 30 | + private ShadowState state; | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * 设备已知版本号,可缺省;-1 表示清空影子 | |
| 34 | + */ | |
| 35 | + private Long version; | |
| 36 | + | |
| 37 | + @Data | |
| 38 | + @NoArgsConstructor | |
| 39 | + @AllArgsConstructor | |
| 40 | + public static class ShadowState { | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * 上报状态 | |
| 44 | + */ | |
| 45 | + private Map<String, Object> reported; | |
| 46 | + | |
| 47 | + } | |
| 48 | + | |
| 49 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceShadowProtocolUtils.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.util; | |
| 2 | + | |
| 3 | +import cn.hutool.core.map.MapUtil; | |
| 4 | + | |
| 5 | +import java.util.HashMap; | |
| 6 | +import java.util.Map; | |
| 7 | +import java.util.Set; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * IoT 设备影子协议工具 | |
| 11 | + * <p> | |
| 12 | + * 内层 method、state 键名、reply data 字段等与设备侧标准封套 params/data 对齐;并提供 metadata 读写。 | |
| 13 | + * | |
| 14 | + * @author 芋道源码 | |
| 15 | + */ | |
| 16 | +public final class IotDeviceShadowProtocolUtils { | |
| 17 | + | |
| 18 | + private IotDeviceShadowProtocolUtils() { | |
| 19 | + } | |
| 20 | + | |
| 21 | + // ========== 内层 method(上行 params.method) ========== | |
| 22 | + | |
| 23 | + public static final String INNER_METHOD_GET = "get"; | |
| 24 | + public static final String INNER_METHOD_UPDATE = "update"; | |
| 25 | + | |
| 26 | + // ========== state / metadata 键 ========== | |
| 27 | + | |
| 28 | + public static final String STATE_DESIRED = "desired"; | |
| 29 | + public static final String STATE_REPORTED = "reported"; | |
| 30 | + public static final String METADATA_TIMESTAMP = "timestamp"; | |
| 31 | + | |
| 32 | + // ========== reply data 键 ========== | |
| 33 | + | |
| 34 | + public static final String PAYLOAD_STATE = "state"; | |
| 35 | + public static final String PAYLOAD_VERSION = "version"; | |
| 36 | + | |
| 37 | + /** | |
| 38 | + * 构建 get 响应 data:{ state:{desired,reported}, version, timestamp } | |
| 39 | + */ | |
| 40 | + public static Map<String, Object> buildGetData(Map<String, Object> desired, Map<String, Object> reported, | |
| 41 | + Long version, Long timestamp) { | |
| 42 | + Map<String, Object> state = new HashMap<>(); | |
| 43 | + state.put(STATE_DESIRED, desired != null ? desired : new HashMap<>()); | |
| 44 | + state.put(STATE_REPORTED, reported != null ? reported : new HashMap<>()); | |
| 45 | + Map<String, Object> data = new HashMap<>(); | |
| 46 | + data.put(PAYLOAD_STATE, state); | |
| 47 | + data.put(PAYLOAD_VERSION, version); | |
| 48 | + data.put(METADATA_TIMESTAMP, timestamp); | |
| 49 | + return data; | |
| 50 | + } | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * 构建 update 响应 / 错误 data:{ version, timestamp } | |
| 54 | + */ | |
| 55 | + public static Map<String, Object> buildUpdateData(Long version, Long timestamp) { | |
| 56 | + Map<String, Object> data = new HashMap<>(); | |
| 57 | + data.put(PAYLOAD_VERSION, version); | |
| 58 | + data.put(METADATA_TIMESTAMP, timestamp); | |
| 59 | + return data; | |
| 60 | + } | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * 获取 desired 下某属性的更新时间戳 | |
| 64 | + * | |
| 65 | + * @param metadata 元数据 | |
| 66 | + * @param identifier 属性标识符 | |
| 67 | + * @return 时间戳(epoch 秒),不存在返回 null | |
| 68 | + */ | |
| 69 | + public static Long getDesiredTimestamp(Map<String, Object> metadata, String identifier) { | |
| 70 | + return getTimestamp(metadata, STATE_DESIRED, identifier); | |
| 71 | + } | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * 获取 reported 下某属性的更新时间戳 | |
| 75 | + * | |
| 76 | + * @param metadata 元数据 | |
| 77 | + * @param identifier 属性标识符 | |
| 78 | + * @return 时间戳(epoch 秒),不存在返回 null | |
| 79 | + */ | |
| 80 | + public static Long getReportedTimestamp(Map<String, Object> metadata, String identifier) { | |
| 81 | + return getTimestamp(metadata, STATE_REPORTED, identifier); | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * 写入 desired 下多个属性的更新时间戳(原地修改 metadata) | |
| 86 | + * | |
| 87 | + * @param metadata 元数据 | |
| 88 | + * @param keys 属性标识符集合 | |
| 89 | + * @param timestamp 时间戳(epoch 秒) | |
| 90 | + */ | |
| 91 | + public static void putDesiredTimestamps(Map<String, Object> metadata, Set<String> keys, long timestamp) { | |
| 92 | + putTimestamps(metadata, STATE_DESIRED, keys, timestamp); | |
| 93 | + } | |
| 94 | + | |
| 95 | + /** | |
| 96 | + * 写入 reported 下多个属性的更新时间戳(原地修改 metadata) | |
| 97 | + * | |
| 98 | + * @param metadata 元数据 | |
| 99 | + * @param keys 属性标识符集合 | |
| 100 | + * @param timestamp 时间戳(epoch 秒) | |
| 101 | + */ | |
| 102 | + public static void putReportedTimestamps(Map<String, Object> metadata, Set<String> keys, long timestamp) { | |
| 103 | + putTimestamps(metadata, STATE_REPORTED, keys, timestamp); | |
| 104 | + } | |
| 105 | + | |
| 106 | + @SuppressWarnings("unchecked") | |
| 107 | + private static Long getTimestamp(Map<String, Object> metadata, String type, String identifier) { | |
| 108 | + if (metadata == null) { | |
| 109 | + return null; | |
| 110 | + } | |
| 111 | + Object typeMeta = metadata.get(type); | |
| 112 | + if (!(typeMeta instanceof Map)) { | |
| 113 | + return null; | |
| 114 | + } | |
| 115 | + Object keyMeta = ((Map<String, Object>) typeMeta).get(identifier); | |
| 116 | + if (!(keyMeta instanceof Map)) { | |
| 117 | + return null; | |
| 118 | + } | |
| 119 | + Object timestamp = ((Map<String, Object>) keyMeta).get(METADATA_TIMESTAMP); | |
| 120 | + return timestamp instanceof Number ? ((Number) timestamp).longValue() : null; | |
| 121 | + } | |
| 122 | + | |
| 123 | + @SuppressWarnings("unchecked") | |
| 124 | + private static void putTimestamps(Map<String, Object> metadata, String type, Set<String> keys, long timestamp) { | |
| 125 | + Map<String, Object> typeMeta = (Map<String, Object>) metadata.computeIfAbsent(type, k -> new HashMap<>()); | |
| 126 | + for (String key : keys) { | |
| 127 | + typeMeta.put(key, MapUtil.of(METADATA_TIMESTAMP, timestamp)); | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtilsTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.util; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 4 | +import org.junit.jupiter.api.Test; | |
| 5 | + | |
| 6 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * {@link IotMqttTopicUtils} 的单元测试 | |
| 10 | + * | |
| 11 | + * @author 芋道源码 | |
| 12 | + */ | |
| 13 | +public class IotMqttTopicUtilsTest { | |
| 14 | + | |
| 15 | + @Test | |
| 16 | + public void testBuildTopicByMethod_shadowUpdateReply() { | |
| 17 | + // 准备参数 | |
| 18 | + String method = IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(); | |
| 19 | + | |
| 20 | + // 调用 | |
| 21 | + String topic = IotMqttTopicUtils.buildTopicByMethod(method, "pk", "dn", true); | |
| 22 | + // 断言 | |
| 23 | + assertEquals("/sys/pk/dn/thing/shadow/update_reply", topic); | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + public void testBuildTopicByMethod_serviceInvokeReply() { | |
| 28 | + // 准备参数 | |
| 29 | + String method = IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod(); | |
| 30 | + | |
| 31 | + // 调用 | |
| 32 | + String topic = IotMqttTopicUtils.buildTopicByMethod(method, "pk", "dn", true); | |
| 33 | + // 断言 | |
| 34 | + assertEquals("/sys/pk/dn/thing/service/invoke_reply", topic); | |
| 35 | + } | |
| 36 | + | |
| 37 | +} | ... | ... |