Commit 6e3e7776051fa306ec6b5096db0a20926b1d182e

Authored by YunaiV
2 parents 39df7c49 8865481b

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,7 +2799,7 @@ CREATE TABLE `system_menu` (
2799 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', 2799 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
2800 `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', 2800 `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
2801 PRIMARY KEY (`id`) USING BTREE 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 -- Records of system_menu 2805 -- Records of system_menu
@@ -5349,6 +5349,8 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i @@ -5349,6 +5349,8 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i
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'); 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 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'); 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 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'); 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 COMMIT; 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,6 +19,15 @@ public interface RedisKeyConstants {
19 String DEVICE_PROPERTY = "iot:device_property:%d"; 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 * 设备的最后上报时间,采用 ZSET 结构 31 * 设备的最后上报时间,采用 ZSET 结构
23 * 32 *
24 * KEY 格式:{deviceId} 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,6 +67,11 @@ public interface ErrorCodeConstants {
67 ErrorCode DEVICE_MODBUS_POINT_NOT_EXISTS = new ErrorCode(1_050_007_000, "设备 Modbus 点位配置不存在"); 67 ErrorCode DEVICE_MODBUS_POINT_NOT_EXISTS = new ErrorCode(1_050_007_000, "设备 Modbus 点位配置不存在");
68 ErrorCode DEVICE_MODBUS_POINT_EXISTS = new ErrorCode(1_050_007_001, "设备 Modbus 点位配置已存在"); 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 // ========== OTA 固件相关 1-050-008-000 ========== 75 // ========== OTA 固件相关 1-050-008-000 ==========
71 76
72 ErrorCode OTA_FIRMWARE_NOT_EXISTS = new ErrorCode(1_050_008_000, "固件信息不存在"); 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,6 +37,7 @@ import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceMapper;
37 import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; 37 import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants;
38 import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; 38 import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
39 import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; 39 import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
  40 +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService;
40 import cn.iocoder.yudao.module.iot.service.product.IotProductService; 41 import cn.iocoder.yudao.module.iot.service.product.IotProductService;
41 import jakarta.annotation.Resource; 42 import jakarta.annotation.Resource;
42 import jakarta.validation.ConstraintViolationException; 43 import jakarta.validation.ConstraintViolationException;
@@ -81,6 +82,9 @@ public class IotDeviceServiceImpl implements IotDeviceService { @@ -81,6 +82,9 @@ public class IotDeviceServiceImpl implements IotDeviceService {
81 @Resource 82 @Resource
82 @Lazy // 延迟加载,解决循环依赖 83 @Lazy // 延迟加载,解决循环依赖
83 private IotDeviceMessageService deviceMessageService; 84 private IotDeviceMessageService deviceMessageService;
  85 + @Resource
  86 + @Lazy // 延迟加载,解决循环依赖
  87 + private IotDeviceShadowService deviceShadowService;
84 88
85 private IotDeviceServiceImpl getSelf() { 89 private IotDeviceServiceImpl getSelf() {
86 return SpringUtil.getBean(getClass()); 90 return SpringUtil.getBean(getClass());
@@ -197,6 +201,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { @@ -197,6 +201,7 @@ public class IotDeviceServiceImpl implements IotDeviceService {
197 } 201 }
198 202
199 @Override 203 @Override
  204 + @Transactional(rollbackFor = Exception.class)
200 public void deleteDevice(Long id) { 205 public void deleteDevice(Long id) {
201 // 1.1 校验存在 206 // 1.1 校验存在
202 IotDeviceDO device = validateDeviceExists(id); 207 IotDeviceDO device = validateDeviceExists(id);
@@ -209,7 +214,10 @@ public class IotDeviceServiceImpl implements IotDeviceService { @@ -209,7 +214,10 @@ public class IotDeviceServiceImpl implements IotDeviceService {
209 // 2. 删除设备 214 // 2. 删除设备
210 deviceMapper.deleteById(id); 215 deviceMapper.deleteById(id);
211 216
212 - // 3. 清空对应缓存 217 + // 3. 级联删除影子
  218 + deviceShadowService.deleteByDeviceId(id);
  219 +
  220 + // 4. 清空对应缓存(事务提交后生效)
213 deleteDeviceCache(device); 221 deleteDeviceCache(device);
214 } 222 }
215 223
@@ -235,7 +243,10 @@ public class IotDeviceServiceImpl implements IotDeviceService { @@ -235,7 +243,10 @@ public class IotDeviceServiceImpl implements IotDeviceService {
235 // 2. 删除设备 243 // 2. 删除设备
236 deviceMapper.deleteByIds(ids); 244 deviceMapper.deleteByIds(ids);
237 245
238 - // 3. 清空对应缓存 246 + // 3. 级联删除影子
  247 + ids.forEach(deviceShadowService::deleteByDeviceId);
  248 +
  249 + // 4. 清空对应缓存(事务提交后生效)
239 deleteDeviceCache(devices); 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,6 +30,16 @@ public interface IotDeviceMessageService {
30 IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device); 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 * @param message 消息(“codec(编解码)字段” 部分字段) 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,6 +28,7 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO;
28 import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; 28 import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO;
29 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; 29 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
30 import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; 30 import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
  31 +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService;
31 import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; 32 import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService;
32 import com.google.common.base.Objects; 33 import com.google.common.base.Objects;
33 import jakarta.annotation.Resource; 34 import jakarta.annotation.Resource;
@@ -63,6 +64,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @@ -63,6 +64,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
63 @Resource 64 @Resource
64 @Lazy // 延迟加载,避免循环依赖 65 @Lazy // 延迟加载,避免循环依赖
65 private IotOtaTaskRecordService otaTaskRecordService; 66 private IotOtaTaskRecordService otaTaskRecordService;
  67 + @Resource
  68 + @Lazy // 延迟加载,避免循环依赖
  69 + private IotDeviceShadowService deviceShadowService;
66 70
67 @Resource 71 @Resource
68 private IotDeviceMessageDAO deviceMessageDAO; 72 private IotDeviceMessageDAO deviceMessageDAO;
@@ -110,7 +114,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @@ -110,7 +114,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
110 return sendDeviceMessage(message, device, null); 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 // 1. 补充信息 119 // 1. 补充信息
115 appendDeviceMessage(message, device); 120 appendDeviceMessage(message, device);
116 121
@@ -219,6 +224,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @@ -219,6 +224,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
219 return null; 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 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())) { 234 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())) {
224 return deviceService.handleTopoAddMessage(message, device); 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
1 DELETE FROM "iot_scene_rule"; 1 DELETE FROM "iot_scene_rule";
2 DELETE FROM "iot_product"; 2 DELETE FROM "iot_product";
3 DELETE FROM "iot_device"; 3 DELETE FROM "iot_device";
  4 +DELETE FROM "iot_device_shadow";
4 DELETE FROM "iot_thing_model"; 5 DELETE FROM "iot_thing_model";
5 DELETE FROM "iot_device_data"; 6 DELETE FROM "iot_device_data";
6 DELETE FROM "iot_alert_config"; 7 DELETE FROM "iot_alert_config";
yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql
@@ -189,6 +189,29 @@ CREATE TABLE IF NOT EXISTS &quot;iot_ota_record&quot; ( @@ -189,6 +189,29 @@ CREATE TABLE IF NOT EXISTS &quot;iot_ota_record&quot; (
189 PRIMARY KEY ("id") 189 PRIMARY KEY ("id")
190 ) COMMENT 'IoT OTA 升级记录表'; 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 CREATE TABLE IF NOT EXISTS "iot_data_rule" ( 215 CREATE TABLE IF NOT EXISTS "iot_data_rule" (
193 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, 216 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
194 "name" varchar(128) NOT NULL, 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&lt;String&gt; { @@ -67,6 +67,10 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; {
67 OTA_UPGRADE("thing.ota.upgrade", "OTA 固件信息推送", false), 67 OTA_UPGRADE("thing.ota.upgrade", "OTA 固件信息推送", false),
68 OTA_PROGRESS("thing.ota.progress", "OTA 升级进度上报", true), 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 public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod) 76 public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod)
@@ -77,7 +81,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; { @@ -77,7 +81,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; {
77 */ 81 */
78 public static final Set<String> REPLY_DISABLED = SetUtils.asSet( 82 public static final Set<String> REPLY_DISABLED = SetUtils.asSet(
79 STATE_UPDATE.getMethod(), 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 private final String method; 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 +}