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