Commit 84c46ad6df2021c2564084d67ab2078191db7193

Authored by haohao
1 parent d97b85bb

feat(iot): 新增设备影子能力,支持 MQTT 透传与后台管理

实现 desired/reported 状态存储与同步,提供查询、期望下发、开关控制与重置接口;
网关支持 thing.shadow.update/get 透传协议,并补充单元测试与菜单权限 SQL。
Showing 28 changed files with 1864 additions and 10 deletions
sql/mysql/iot_device_shadow.sql 0 → 100644
  1 +-- IoT 设备影子表
  2 +CREATE TABLE IF NOT EXISTS `iot_device_shadow`
  3 +(
  4 + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
  5 + `device_id` bigint NOT NULL COMMENT '设备编号',
  6 + `product_id` bigint NOT NULL COMMENT '产品编号',
  7 + `product_key` varchar(64) NOT NULL COMMENT '产品标识',
  8 + `device_name` varchar(255) NOT NULL COMMENT '设备名称',
  9 + `reported` json DEFAULT NULL COMMENT '上报状态',
  10 + `desired` json DEFAULT NULL COMMENT '期望状态',
  11 + `metadata` json DEFAULT NULL COMMENT '元数据',
  12 + `version` bigint NOT NULL DEFAULT 0 COMMENT '版本号',
  13 + `shadow_time` bigint NOT NULL DEFAULT 0 COMMENT '文档最后更新时间',
  14 + `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态',
  15 + `creator` varchar(64) DEFAULT '' COMMENT '创建者',
  16 + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  17 + `updater` varchar(64) DEFAULT '' COMMENT '更新者',
  18 + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  19 + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  20 + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
  21 + PRIMARY KEY (`id`),
  22 + UNIQUE KEY `uk_device_id` (`device_id`)
  23 +) ENGINE = InnoDB
  24 + DEFAULT CHARSET = utf8mb4 COMMENT ='IoT 设备影子';
  25 +
  26 +-- 菜单权限
  27 +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`,
  28 + `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`,
  29 + `updater`, `update_time`, `deleted`)
  30 +VALUES (4056, '设备影子查询', 'iot:device:shadow-query', 3, 13, 4008, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(),
  31 + '1', NOW(), b'0');
  32 +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`,
  33 + `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`,
  34 + `updater`, `update_time`, `deleted`)
  35 +VALUES (4057, '设备影子更新', 'iot:device:shadow-update', 3, 14, 4008, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(),
  36 + '1', NOW(), b'0');
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceShadowController.java 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.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowPropertyRespVO;
  7 +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowRespVO;
  8 +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowUpdateDesiredReqVO;
  9 +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.shadow.IotDeviceShadowUpdateStatusReqVO;
  10 +import cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowMetadatas;
  11 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  12 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO;
  13 +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
  14 +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty;
  15 +import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum;
  16 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  17 +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService;
  18 +import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
  19 +import io.swagger.v3.oas.annotations.Operation;
  20 +import io.swagger.v3.oas.annotations.Parameter;
  21 +import io.swagger.v3.oas.annotations.tags.Tag;
  22 +import jakarta.annotation.Resource;
  23 +import jakarta.validation.Valid;
  24 +import org.springframework.security.access.prepost.PreAuthorize;
  25 +import org.springframework.validation.annotation.Validated;
  26 +import org.springframework.web.bind.annotation.*;
  27 +
  28 +import java.util.List;
  29 +import java.util.Map;
  30 +
  31 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  32 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  33 +
  34 +@Tag(name = "管理后台 - IoT 设备影子")
  35 +@RestController
  36 +@RequestMapping("/iot/device/shadow")
  37 +@Validated
  38 +public class IotDeviceShadowController {
  39 +
  40 + @Resource
  41 + private IotDeviceShadowService deviceShadowService;
  42 + @Resource
  43 + private IotDeviceService deviceService;
  44 + @Resource
  45 + private IotThingModelService thingModelService;
  46 +
  47 + @GetMapping("/get")
  48 + @Operation(summary = "获取设备影子")
  49 + @Parameter(name = "deviceId", description = "设备编号", required = true)
  50 + @PreAuthorize("@ss.hasPermission('iot:device:shadow-query')")
  51 + public CommonResult<IotDeviceShadowRespVO> getDeviceShadow(@RequestParam("deviceId") Long deviceId) {
  52 + IotDeviceDO device = deviceService.validateDeviceExists(deviceId);
  53 + IotDeviceShadowDO shadow = deviceShadowService.getOrCreateShadow(device);
  54 +
  55 + List<IotThingModelDO> thingModels = thingModelService.getThingModelListByProductIdAndType(
  56 + device.getProductId(), IotThingModelTypeEnum.PROPERTY.getType());
  57 + Map<String, Object> desired = shadow.getDesired() != null ? shadow.getDesired() : MapUtil.empty();
  58 + Map<String, Object> reported = shadow.getReported() != null ? shadow.getReported() : MapUtil.empty();
  59 + Map<String, Object> metadata = shadow.getMetadata() != null ? shadow.getMetadata() : MapUtil.empty();
  60 + List<IotDeviceShadowPropertyRespVO> properties = convertList(thingModels, thingModel -> {
  61 + ThingModelProperty prop = thingModel.getProperty();
  62 + Assert.notNull(prop, "属性不能为空");
  63 + return new IotDeviceShadowPropertyRespVO()
  64 + .setIdentifier(thingModel.getIdentifier())
  65 + .setName(thingModel.getName())
  66 + .setDataType(prop.getDataType())
  67 + .setDataSpecs(prop.getDataSpecs())
  68 + .setDataSpecsList(prop.getDataSpecsList())
  69 + .setDesiredValue(desired.get(thingModel.getIdentifier()))
  70 + .setReportedValue(reported.get(thingModel.getIdentifier()))
  71 + .setDesiredTime(IotDeviceShadowMetadatas.getDesiredTimestamp(metadata, thingModel.getIdentifier()))
  72 + .setReportedTime(IotDeviceShadowMetadatas.getReportedTimestamp(metadata, thingModel.getIdentifier()));
  73 + });
  74 +
  75 + return success(new IotDeviceShadowRespVO()
  76 + .setVersion(shadow.getVersion())
  77 + .setShadowTime(shadow.getShadowTime())
  78 + .setStatus(shadow.getStatus())
  79 + .setProperties(properties)
  80 + .setDesired(desired)
  81 + .setReported(reported)
  82 + .setMetadata(metadata));
  83 + }
  84 +
  85 + @PutMapping("/update-desired")
  86 + @Operation(summary = "更新设备影子期望状态")
  87 + @PreAuthorize("@ss.hasPermission('iot:device:shadow-update')")
  88 + public CommonResult<Boolean> updateDesiredState(@Valid @RequestBody IotDeviceShadowUpdateDesiredReqVO reqVO) {
  89 + deviceShadowService.updateDesiredState(reqVO.getDeviceId(), reqVO.getDesired());
  90 + return success(true);
  91 + }
  92 +
  93 + @PutMapping("/update-status")
  94 + @Operation(summary = "更新设备影子开关状态")
  95 + @PreAuthorize("@ss.hasPermission('iot:device:shadow-update')")
  96 + public CommonResult<Boolean> updateShadowStatus(@Valid @RequestBody IotDeviceShadowUpdateStatusReqVO reqVO) {
  97 + deviceShadowService.updateStatus(reqVO.getDeviceId(), reqVO.getStatus());
  98 + return success(true);
  99 + }
  100 +
  101 + @DeleteMapping("/reset")
  102 + @Operation(summary = "重置设备影子")
  103 + @Parameter(name = "deviceId", description = "设备编号", required = true)
  104 + @PreAuthorize("@ss.hasPermission('iot:device:shadow-update')")
  105 + public CommonResult<Boolean> resetShadow(@RequestParam("deviceId") Long deviceId) {
  106 + deviceShadowService.resetShadow(deviceId);
  107 + return success(true);
  108 + }
  109 +
  110 +}
... ...
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 +@Schema(description = "管理后台 - IoT 设备影子属性对比 Response VO")
  10 +@Data
  11 +public class IotDeviceShadowPropertyRespVO {
  12 +
  13 + @Schema(description = "标识符", example = "Temperature")
  14 + private String identifier;
  15 +
  16 + @Schema(description = "属性名称", example = "温度")
  17 + private String name;
  18 +
  19 + @Schema(description = "数据类型", example = "int")
  20 + private String dataType;
  21 +
  22 + @Schema(description = "数据定义")
  23 + private ThingModelDataSpecs dataSpecs;
  24 +
  25 + @Schema(description = "数据定义列表")
  26 + private List<ThingModelDataSpecs> dataSpecsList;
  27 +
  28 + @Schema(description = "期望值")
  29 + private Object desiredValue;
  30 +
  31 + @Schema(description = "上报值")
  32 + private Object reportedValue;
  33 +
  34 + @Schema(description = "期望更新时间(epoch 秒)")
  35 + private Long desiredTime;
  36 +
  37 + @Schema(description = "上报更新时间(epoch 秒)")
  38 + private Long reportedTime;
  39 +
  40 +}
... ...
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 +@Schema(description = "管理后台 - IoT 设备影子 Response VO")
  10 +@Data
  11 +public class IotDeviceShadowRespVO {
  12 +
  13 + @Schema(description = "版本号", example = "3")
  14 + private Long version;
  15 +
  16 + @Schema(description = "最后更新时间(epoch 秒)", example = "1469564576")
  17 + private Long shadowTime;
  18 +
  19 + @Schema(description = "是否启用", example = "0")
  20 + private Integer status;
  21 +
  22 + @Schema(description = "属性对比列表")
  23 + private List<IotDeviceShadowPropertyRespVO> properties;
  24 +
  25 + @Schema(description = "期望状态")
  26 + private Map<String, Object> desired;
  27 +
  28 + @Schema(description = "上报状态")
  29 + private Map<String, Object> reported;
  30 +
  31 + @Schema(description = "元数据")
  32 + private Map<String, Object> metadata;
  33 +
  34 +}
... ...
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 +@Schema(description = "管理后台 - IoT 设备影子更新期望状态 Request VO")
  10 +@Data
  11 +public class IotDeviceShadowUpdateDesiredReqVO {
  12 +
  13 + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  14 + @NotNull(message = "设备编号不能为空")
  15 + private Long deviceId;
  16 +
  17 + @Schema(description = "期望状态", requiredMode = Schema.RequiredMode.REQUIRED)
  18 + @NotNull(message = "期望状态不能为空")
  19 + private Map<String, Object> desired;
  20 +
  21 +}
... ...
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 +@Schema(description = "管理后台 - IoT 设备影子更新状态 Request VO")
  10 +@Data
  11 +public class IotDeviceShadowUpdateStatusReqVO {
  12 +
  13 + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  14 + @NotNull(message = "设备编号不能为空")
  15 + private Long deviceId;
  16 +
  17 + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  18 + @NotNull(message = "状态不能为空")
  19 + @InEnum(CommonStatusEnum.class)
  20 + private Integer status;
  21 +
  22 +}
... ...
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 updateByDeviceIdAndVersion(IotDeviceShadowDO update, Long deviceId, Long expectVersion) {
  21 + return update(update, new LambdaUpdateWrapper<IotDeviceShadowDO>()
  22 + .eq(IotDeviceShadowDO::getDeviceId, deviceId)
  23 + .eq(IotDeviceShadowDO::getVersion, expectVersion));
  24 + }
  25 +
  26 + default int updateStatusByDeviceId(Long deviceId, Integer status) {
  27 + return update(new LambdaUpdateWrapper<IotDeviceShadowDO>()
  28 + .set(IotDeviceShadowDO::getStatus, status)
  29 + .eq(IotDeviceShadowDO::getDeviceId, deviceId));
  30 + }
  31 +
  32 + default int deleteByDeviceId(Long deviceId) {
  33 + return delete(IotDeviceShadowDO::getDeviceId, deviceId);
  34 + }
  35 +
  36 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java
... ... @@ -19,6 +19,14 @@ public interface RedisKeyConstants {
19 19 String DEVICE_PROPERTY = "iot:device_property:%d";
20 20  
21 21 /**
  22 + * 设备影子数据缓存,采用 STRING 结构
  23 + * <p>
  24 + * KEY 格式:device_shadow:{deviceId}
  25 + * VALUE 数据类型:String(JSON) {@link cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO}
  26 + */
  27 + String DEVICE_SHADOW = "iot:device_shadow:%d";
  28 +
  29 + /**
22 30 * 设备的最后上报时间,采用 ZSET 结构
23 31 *
24 32 * 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 static cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants.DEVICE_SHADOW;
  10 +
  11 +/**
  12 + * {@link IotDeviceShadowDO} 的 Redis DAO
  13 + */
  14 +@Repository
  15 +public class DeviceShadowRedisDAO {
  16 +
  17 + @Resource
  18 + private StringRedisTemplate stringRedisTemplate;
  19 +
  20 + public IotDeviceShadowDO get(Long deviceId) {
  21 + String redisKey = formatKey(deviceId);
  22 + String json = stringRedisTemplate.opsForValue().get(redisKey);
  23 + return JsonUtils.parseObject(json, IotDeviceShadowDO.class);
  24 + }
  25 +
  26 + public void set(IotDeviceShadowDO shadow) {
  27 + String redisKey = formatKey(shadow.getDeviceId());
  28 + stringRedisTemplate.opsForValue().set(redisKey, JsonUtils.toJsonString(shadow));
  29 + }
  30 +
  31 + public void delete(Long deviceId) {
  32 + stringRedisTemplate.delete(formatKey(deviceId));
  33 + }
  34 +
  35 + private static String formatKey(Long deviceId) {
  36 + return String.format(DEVICE_SHADOW, deviceId);
  37 + }
  38 +
  39 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java
... ... @@ -62,6 +62,10 @@ public interface ErrorCodeConstants {
62 62 ErrorCode DEVICE_MODBUS_POINT_NOT_EXISTS = new ErrorCode(1_050_007_000, "设备 Modbus 点位配置不存在");
63 63 ErrorCode DEVICE_MODBUS_POINT_EXISTS = new ErrorCode(1_050_007_001, "设备 Modbus 点位配置已存在");
64 64  
  65 + // ========== 设备影子 1-050-007-100 ==========
  66 + ErrorCode DEVICE_SHADOW_NOT_ENABLED = new ErrorCode(1_050_007_100, "设备影子未启用");
  67 + ErrorCode DEVICE_SHADOW_VERSION_CONFLICT = new ErrorCode(1_050_007_101, "设备影子版本冲突");
  68 +
65 69 // ========== OTA 固件相关 1-050-008-000 ==========
66 70  
67 71 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());
... ... @@ -209,7 +213,10 @@ public class IotDeviceServiceImpl implements IotDeviceService {
209 213 // 2. 删除设备
210 214 deviceMapper.deleteById(id);
211 215  
212   - // 3. 清空对应缓存
  216 + // 3. 级联删除影子
  217 + deviceShadowService.deleteByDeviceId(id);
  218 +
  219 + // 4. 清空对应缓存
213 220 deleteDeviceCache(device);
214 221 }
215 222  
... ... @@ -235,7 +242,10 @@ public class IotDeviceServiceImpl implements IotDeviceService {
235 242 // 2. 删除设备
236 243 deviceMapper.deleteByIds(ids);
237 244  
238   - // 3. 清空对应缓存
  245 + // 3. 级联删除影子
  246 + ids.forEach(deviceShadowService::deleteByDeviceId);
  247 +
  248 + // 4. 清空对应缓存
239 249 deleteDeviceCache(devices);
240 250 }
241 251  
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java
... ... @@ -37,6 +37,16 @@ public interface IotDeviceMessageService {
37 37 IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device);
38 38  
39 39 /**
  40 + * 发送设备消息(指定 serverId,用于上行回复等场景)
  41 + *
  42 + * @param message 消息
  43 + * @param device 设备
  44 + * @param serverId 网关 serverId
  45 + * @return 设备消息
  46 + */
  47 + IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId);
  48 +
  49 + /**
40 50 * 发送设备消息
41 51 *
42 52 * @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.tdengine.IotDeviceMessageMapper;
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.baomidou.mybatisplus.core.metadata.IPage;
33 34 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
... ... @@ -65,6 +66,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
65 66 @Resource
66 67 @Lazy // 延迟加载,避免循环依赖
67 68 private IotOtaTaskRecordService otaTaskRecordService;
  69 + @Resource
  70 + @Lazy // 延迟加载,避免循环依赖
  71 + private IotDeviceShadowService deviceShadowService;
68 72  
69 73 @Resource
70 74 private IotDeviceMessageMapper deviceMessageMapper;
... ... @@ -120,7 +124,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
120 124 return sendDeviceMessage(message, device, null);
121 125 }
122 126  
123   - private IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) {
  127 + @Override
  128 + public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) {
124 129 // 1. 补充信息
125 130 appendDeviceMessage(message, device);
126 131  
... ... @@ -229,6 +234,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
229 234 return null;
230 235 }
231 236  
  237 + // 设备影子更新
  238 + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod())) {
  239 + deviceShadowService.handleDeviceShadowUpdate(device, message);
  240 + return null;
  241 + }
  242 +
232 243 // 添加拓扑关系
233 244 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())) {
234 245 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 + *
  19 + * @param device 设备
  20 + * @return 设备影子
  21 + */
  22 + IotDeviceShadowDO getOrCreateShadow(IotDeviceDO device);
  23 +
  24 + /**
  25 + * 处理设备影子上行更新
  26 + *
  27 + * @param device 设备
  28 + * @param message 上行消息
  29 + */
  30 + void handleDeviceShadowUpdate(IotDeviceDO device, IotDeviceMessage message);
  31 +
  32 + /**
  33 + * 更新期望状态
  34 + *
  35 + * @param deviceId 设备编号
  36 + * @param desired 期望状态
  37 + */
  38 + void updateDesiredState(Long deviceId, Map<String, Object> desired);
  39 +
  40 + /**
  41 + * 重置影子
  42 + *
  43 + * @param deviceId 设备编号
  44 + */
  45 + void resetShadow(Long deviceId);
  46 +
  47 + /**
  48 + * 更新影子开关状态
  49 + *
  50 + * @param deviceId 设备编号
  51 + * @param status 状态
  52 + */
  53 + void updateStatus(Long deviceId, Integer status);
  54 +
  55 + /**
  56 + * 删除设备影子(设备删除时级联)
  57 + *
  58 + * @param deviceId 设备编号
  59 + */
  60 + void deleteByDeviceId(Long deviceId);
  61 +
  62 +}
... ...
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.util.StrUtil;
  6 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  7 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  8 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  9 +import cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowErrorCodeEnum;
  10 +import cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowMetadatas;
  11 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  12 +import cn.iocoder.yudao.module.iot.core.topic.shadow.IotDeviceShadowReplyDTO;
  13 +import cn.iocoder.yudao.module.iot.core.topic.shadow.IotDeviceShadowUpdateReqDTO;
  14 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  15 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO;
  16 +import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceShadowMapper;
  17 +import cn.iocoder.yudao.module.iot.dal.redis.device.DeviceShadowRedisDAO;
  18 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  19 +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
  20 +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
  21 +import jakarta.annotation.Resource;
  22 +import lombok.Getter;
  23 +import lombok.extern.slf4j.Slf4j;
  24 +import org.springframework.context.annotation.Lazy;
  25 +import org.springframework.dao.DuplicateKeyException;
  26 +import org.springframework.stereotype.Service;
  27 +import org.springframework.transaction.annotation.Transactional;
  28 +import org.springframework.validation.annotation.Validated;
  29 +
  30 +import java.util.HashMap;
  31 +import java.util.Map;
  32 +import java.util.function.Function;
  33 +
  34 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  35 +import static cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowProtocolConstants.INNER_METHOD_GET;
  36 +import static cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowProtocolConstants.INNER_METHOD_UPDATE;
  37 +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_SHADOW_NOT_ENABLED;
  38 +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_SHADOW_VERSION_CONFLICT;
  39 +
  40 +/**
  41 + * IoT 设备影子 Service 实现类
  42 + *
  43 + * @author 芋道源码
  44 + */
  45 +@Service
  46 +@Validated
  47 +@Slf4j
  48 +public class IotDeviceShadowServiceImpl implements IotDeviceShadowService {
  49 +
  50 + private static final int MAX_PROPERTY_COUNT = 128;
  51 + private static final int OPTIMISTIC_LOCK_RETRY = 3;
  52 +
  53 + @Resource
  54 + private IotDeviceShadowMapper deviceShadowMapper;
  55 + @Resource
  56 + private DeviceShadowRedisDAO deviceShadowRedisDAO;
  57 + @Resource
  58 + private IotDeviceService deviceService;
  59 + @Resource
  60 + private IotDevicePropertyService devicePropertyService;
  61 + @Resource
  62 + @Lazy
  63 + private IotDeviceMessageService deviceMessageService;
  64 +
  65 + @Override
  66 + public IotDeviceShadowDO getOrCreateShadow(IotDeviceDO device) {
  67 + IotDeviceShadowDO shadow = getShadowFromCacheOrDb(device.getId());
  68 + if (shadow != null) {
  69 + return shadow;
  70 + }
  71 + // 懒插入空文档
  72 + IotDeviceShadowDO newShadow = buildEmptyShadow(device)
  73 + .status(CommonStatusEnum.ENABLE.getStatus())
  74 + .build();
  75 + try {
  76 + deviceShadowMapper.insert(newShadow);
  77 + return newShadow;
  78 + } catch (DuplicateKeyException ex) {
  79 + return deviceShadowMapper.selectByDeviceId(device.getId());
  80 + }
  81 + }
  82 +
  83 + @Override
  84 + public void handleDeviceShadowUpdate(IotDeviceDO device, IotDeviceMessage message) {
  85 + try {
  86 + handleDeviceShadowUpdate0(device, message);
  87 + } catch (Exception ex) {
  88 + log.error("[handleDeviceShadowUpdate][device({}) message({}) 处理异常]", device.getId(), message, ex);
  89 + Long version = resolveShadowVersionSafely(device.getId());
  90 + sendShadowReplySafely(device, message.getServerId(), IotDeviceShadowReplyDTO.errorOf(version,
  91 + IotDeviceShadowErrorCodeEnum.SERVER_ERROR.getCode(),
  92 + IotDeviceShadowErrorCodeEnum.SERVER_ERROR.getMessage(), DateUtil.currentSeconds()));
  93 + }
  94 + }
  95 +
  96 + private void handleDeviceShadowUpdate0(IotDeviceDO device, IotDeviceMessage message) {
  97 + // 1. 解析请求
  98 + IotDeviceShadowUpdateReqDTO req = JsonUtils.convertObject(message.getParams(), IotDeviceShadowUpdateReqDTO.class);
  99 + if (req == null) {
  100 + sendShadowReplySafely(device, message.getServerId(), IotDeviceShadowReplyDTO.errorOf(0L,
  101 + IotDeviceShadowErrorCodeEnum.JSON_FORMAT_ERROR.getCode(),
  102 + IotDeviceShadowErrorCodeEnum.JSON_FORMAT_ERROR.getMessage(), DateUtil.currentSeconds()));
  103 + return;
  104 + }
  105 + if (StrUtil.isEmpty(req.getMethod())) {
  106 + sendShadowReplySafely(device, message.getServerId(), IotDeviceShadowReplyDTO.errorOf(resolveShadowVersionSafely(device.getId()),
  107 + IotDeviceShadowErrorCodeEnum.METHOD_MISSING.getCode(),
  108 + IotDeviceShadowErrorCodeEnum.METHOD_MISSING.getMessage(), DateUtil.currentSeconds()));
  109 + return;
  110 + }
  111 +
  112 + // 2. 获取影子并校验开关
  113 + IotDeviceShadowDO shadow = getOrCreateShadow(device);
  114 + if (CommonStatusEnum.isDisable(shadow.getStatus())) {
  115 + log.info("[handleDeviceShadowUpdate0][device({}) 影子未启用,忽略上行]", device.getId());
  116 + return;
  117 + }
  118 +
  119 + // 3. 内层 method 分发
  120 + if (INNER_METHOD_GET.equals(req.getMethod())) {
  121 + sendShadowReplySafely(device, message.getServerId(), IotDeviceShadowReplyDTO.getReplyOf(
  122 + shadow.getDesired(), shadow.getReported(), shadow.getVersion(), shadow.getShadowTime()));
  123 + return;
  124 + }
  125 + if (INNER_METHOD_UPDATE.equals(req.getMethod())) {
  126 + handleReportedUpdate(device, message.getServerId(), req);
  127 + return;
  128 + }
  129 + sendShadowReplySafely(device, message.getServerId(), IotDeviceShadowReplyDTO.errorOf(shadow.getVersion(),
  130 + IotDeviceShadowErrorCodeEnum.INVALID_METHOD.getCode(),
  131 + IotDeviceShadowErrorCodeEnum.INVALID_METHOD.getMessage(), DateUtil.currentSeconds()));
  132 + }
  133 +
  134 + private void handleReportedUpdate(IotDeviceDO device, String serverId, IotDeviceShadowUpdateReqDTO req) {
  135 + Long deviceId = device.getId();
  136 +
  137 + if (req.getState() == null) {
  138 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.errorOf(resolveShadowVersionSafely(deviceId),
  139 + IotDeviceShadowErrorCodeEnum.STATE_MISSING.getCode(),
  140 + IotDeviceShadowErrorCodeEnum.STATE_MISSING.getMessage(), DateUtil.currentSeconds()));
  141 + return;
  142 + }
  143 + if (req.getState().getReported() == null) {
  144 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.errorOf(resolveShadowVersionSafely(deviceId),
  145 + IotDeviceShadowErrorCodeEnum.REPORTED_MISSING.getCode(),
  146 + IotDeviceShadowErrorCodeEnum.REPORTED_MISSING.getMessage(), DateUtil.currentSeconds()));
  147 + return;
  148 + }
  149 + if (CollUtil.isEmpty(req.getState().getReported())) {
  150 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.errorOf(resolveShadowVersionSafely(deviceId),
  151 + IotDeviceShadowErrorCodeEnum.REPORTED_EMPTY.getCode(),
  152 + IotDeviceShadowErrorCodeEnum.REPORTED_EMPTY.getMessage(), DateUtil.currentSeconds()));
  153 + return;
  154 + }
  155 +
  156 + // version == -1:清空影子
  157 + if (req.getVersion() != null && req.getVersion() == -1L) {
  158 + try {
  159 + resetShadowWithRetry(deviceId);
  160 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.updateReplyOf(0L, DateUtil.currentSeconds()));
  161 + } catch (ShadowCasExhaustedException ex) {
  162 + handleCasExhaustedForDevice(device, serverId, ex);
  163 + }
  164 + return;
  165 + }
  166 +
  167 + try {
  168 + IotDeviceShadowDO committed = casUpdateShadow(deviceId, latest -> buildReportedUpdate(latest, req));
  169 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.updateReplyOf(committed.getVersion(),
  170 + committed.getShadowTime()));
  171 + } catch (ShadowUpdateRejectedException ex) {
  172 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.errorOf(ex.getVersion(),
  173 + ex.getErrorCode().getCode(), ex.getErrorCode().getMessage(), DateUtil.currentSeconds()));
  174 + } catch (ShadowCasExhaustedException ex) {
  175 + handleCasExhaustedForDevice(device, serverId, ex);
  176 + }
  177 + }
  178 +
  179 + @Override
  180 + public void updateDesiredState(Long deviceId, Map<String, Object> desired) {
  181 + IotDeviceDO device = deviceService.validateDeviceExists(deviceId);
  182 + IotDeviceShadowDO shadow = getOrCreateShadow(device);
  183 + if (CommonStatusEnum.isDisable(shadow.getStatus())) {
  184 + throw exception(DEVICE_SHADOW_NOT_ENABLED);
  185 + }
  186 + if (CollUtil.isEmpty(desired)) {
  187 + return;
  188 + }
  189 +
  190 + try {
  191 + IotDeviceShadowDO committed = casUpdateShadow(deviceId, latest -> buildDesiredUpdate(latest, desired));
  192 + // 设备在线则主动推送全量影子(复用 CAS 返回值,避免二次查库)
  193 + String serverId = devicePropertyService.getDeviceServerId(deviceId);
  194 + if (StrUtil.isNotEmpty(serverId)) {
  195 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.getReplyOf(
  196 + committed.getDesired(), committed.getReported(),
  197 + committed.getVersion(), committed.getShadowTime()));
  198 + }
  199 + } catch (ShadowCasExhaustedException ex) {
  200 + throw exception(DEVICE_SHADOW_VERSION_CONFLICT);
  201 + }
  202 + }
  203 +
  204 + @Override
  205 + public void resetShadow(Long deviceId) {
  206 + deviceService.validateDeviceExists(deviceId);
  207 + IotDeviceShadowDO shadow = getShadowFromCacheOrDb(deviceId);
  208 + if (shadow == null) {
  209 + return;
  210 + }
  211 + try {
  212 + resetShadowWithRetry(deviceId);
  213 + } catch (ShadowCasExhaustedException ex) {
  214 + throw exception(DEVICE_SHADOW_VERSION_CONFLICT);
  215 + }
  216 + }
  217 +
  218 + @Override
  219 + @Transactional(rollbackFor = Exception.class)
  220 + public void updateStatus(Long deviceId, Integer status) {
  221 + IotDeviceDO device = deviceService.validateDeviceExists(deviceId);
  222 + IotDeviceShadowDO shadow = getShadowFromCacheOrDb(deviceId);
  223 + if (shadow == null) {
  224 + // 影子行不存在时直接按目标 status 懒建,避免后续 getOrCreateShadow 始终以 ENABLE 插入
  225 + IotDeviceShadowDO newShadow = buildEmptyShadow(device).status(status).build();
  226 + try {
  227 + deviceShadowMapper.insert(newShadow);
  228 + } catch (DuplicateKeyException ex) {
  229 + int rows = deviceShadowMapper.updateStatusByDeviceId(deviceId, status);
  230 + if (rows == 0) {
  231 + throw exception(DEVICE_SHADOW_VERSION_CONFLICT);
  232 + }
  233 + deviceShadowRedisDAO.delete(deviceId);
  234 + }
  235 + } else {
  236 + int rows = deviceShadowMapper.updateStatusByDeviceId(deviceId, status);
  237 + if (rows == 0) {
  238 + throw exception(DEVICE_SHADOW_VERSION_CONFLICT);
  239 + }
  240 + deviceShadowRedisDAO.delete(deviceId);
  241 + }
  242 + }
  243 +
  244 + @Override
  245 + @Transactional(rollbackFor = Exception.class)
  246 + public void deleteByDeviceId(Long deviceId) {
  247 + deviceShadowMapper.deleteByDeviceId(deviceId);
  248 + deviceShadowRedisDAO.delete(deviceId);
  249 + }
  250 +
  251 + // ========== 内部方法 ==========
  252 +
  253 + private IotDeviceShadowDO buildReportedUpdate(IotDeviceShadowDO latest, IotDeviceShadowUpdateReqDTO req) {
  254 + if (req.getVersion() != null && req.getVersion() < latest.getVersion()) {
  255 + throw new ShadowUpdateRejectedException(latest.getVersion(), IotDeviceShadowErrorCodeEnum.VERSION_CONFLICT);
  256 + }
  257 + Map<String, Object> mergedReported = mergeMap(latest.getReported(), req.getState().getReported());
  258 + if (mergedReported.size() > MAX_PROPERTY_COUNT) {
  259 + throw new ShadowUpdateRejectedException(latest.getVersion(), IotDeviceShadowErrorCodeEnum.PROPERTY_COUNT_EXCEEDED);
  260 + }
  261 + long now = DateUtil.currentSeconds();
  262 + Map<String, Object> metadata = latest.getMetadata() != null ? new HashMap<>(latest.getMetadata()) : new HashMap<>();
  263 + IotDeviceShadowMetadatas.putReportedTimestamps(metadata, req.getState().getReported().keySet(), now);
  264 + return IotDeviceShadowDO.builder()
  265 + .reported(mergedReported)
  266 + .metadata(metadata)
  267 + .version(latest.getVersion() + 1)
  268 + .shadowTime(now)
  269 + .build();
  270 + }
  271 +
  272 + private IotDeviceShadowDO buildDesiredUpdate(IotDeviceShadowDO latest, Map<String, Object> desired) {
  273 + long now = DateUtil.currentSeconds();
  274 + Map<String, Object> mergedDesired = mergeMap(latest.getDesired(), desired);
  275 + Map<String, Object> metadata = latest.getMetadata() != null ? new HashMap<>(latest.getMetadata()) : new HashMap<>();
  276 + IotDeviceShadowMetadatas.putDesiredTimestamps(metadata, desired.keySet(), now);
  277 + return IotDeviceShadowDO.builder()
  278 + .desired(mergedDesired)
  279 + .metadata(metadata)
  280 + .version(latest.getVersion() + 1)
  281 + .shadowTime(now)
  282 + .build();
  283 + }
  284 +
  285 + private void resetShadowWithRetry(Long deviceId) {
  286 + casUpdateShadow(deviceId, latest -> IotDeviceShadowDO.builder()
  287 + .desired(new HashMap<>())
  288 + .reported(new HashMap<>())
  289 + .metadata(new HashMap<>())
  290 + .version(0L)
  291 + .shadowTime(DateUtil.currentSeconds())
  292 + .build());
  293 + }
  294 +
  295 + /**
  296 + * 乐观锁 CAS 更新:每次重试基于最新影子重新构建 update,避免 lost-update
  297 + *
  298 + * @return 写入成功后的 update DO(回填未更新字段,便于构建全量回复)
  299 + */
  300 + private IotDeviceShadowDO casUpdateShadow(Long deviceId, Function<IotDeviceShadowDO, IotDeviceShadowDO> updateBuilder) {
  301 + Long lastKnownVersion = 0L;
  302 + for (int i = 0; i < OPTIMISTIC_LOCK_RETRY; i++) {
  303 + // 首次尝试走缓存,重试时直读 DB 获取最新 version
  304 + IotDeviceShadowDO latest = i == 0
  305 + ? getShadowFromCacheOrDb(deviceId)
  306 + : deviceShadowMapper.selectByDeviceId(deviceId);
  307 + if (latest == null) {
  308 + throw ShadowCasExhaustedException.shadowNotFound();
  309 + }
  310 + lastKnownVersion = latest.getVersion();
  311 + IotDeviceShadowDO update = updateBuilder.apply(latest);
  312 + int rows = deviceShadowMapper.updateByDeviceIdAndVersion(update, deviceId, latest.getVersion());
  313 + if (rows > 0) {
  314 + deviceShadowRedisDAO.delete(deviceId);
  315 + enrichCommittedShadow(update, latest);
  316 + return update;
  317 + }
  318 + }
  319 + throw ShadowCasExhaustedException.versionConflict(lastKnownVersion);
  320 + }
  321 +
  322 + /**
  323 + * 将 CAS 成功写入的局部字段与 latest 快照合并,供全量 get_reply 使用
  324 + */
  325 + private static void enrichCommittedShadow(IotDeviceShadowDO committed, IotDeviceShadowDO latest) {
  326 + if (committed.getDesired() == null) {
  327 + committed.setDesired(latest.getDesired());
  328 + }
  329 + if (committed.getReported() == null) {
  330 + committed.setReported(latest.getReported());
  331 + }
  332 + if (committed.getMetadata() == null) {
  333 + committed.setMetadata(latest.getMetadata());
  334 + }
  335 + }
  336 +
  337 + private void handleCasExhaustedForDevice(IotDeviceDO device, String serverId, ShadowCasExhaustedException ex) {
  338 + if (ex.isShadowNotFound()) {
  339 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.errorOf(0L,
  340 + IotDeviceShadowErrorCodeEnum.SERVER_ERROR.getCode(),
  341 + IotDeviceShadowErrorCodeEnum.SERVER_ERROR.getMessage(), DateUtil.currentSeconds()));
  342 + return;
  343 + }
  344 + sendVersionConflictReply(device, serverId, ex.getLastKnownVersion());
  345 + }
  346 +
  347 + private void sendVersionConflictReply(IotDeviceDO device, String serverId, Long version) {
  348 + sendShadowReplySafely(device, serverId, IotDeviceShadowReplyDTO.errorOf(version,
  349 + IotDeviceShadowErrorCodeEnum.VERSION_CONFLICT.getCode(),
  350 + IotDeviceShadowErrorCodeEnum.VERSION_CONFLICT.getMessage(), DateUtil.currentSeconds()));
  351 + }
  352 +
  353 + private Long resolveShadowVersionSafely(Long deviceId) {
  354 + try {
  355 + IotDeviceShadowDO shadow = getShadowFromCacheOrDb(deviceId);
  356 + return shadow != null ? shadow.getVersion() : 0L;
  357 + } catch (Exception ex) {
  358 + log.warn("[resolveShadowVersionSafely][device({}) 读取 version 失败,使用保底 0]", deviceId, ex);
  359 + return 0L;
  360 + }
  361 + }
  362 +
  363 + private IotDeviceShadowDO.IotDeviceShadowDOBuilder buildEmptyShadow(IotDeviceDO device) {
  364 + return IotDeviceShadowDO.builder()
  365 + .deviceId(device.getId())
  366 + .productId(device.getProductId())
  367 + .productKey(device.getProductKey())
  368 + .deviceName(device.getDeviceName())
  369 + .reported(new HashMap<>())
  370 + .desired(new HashMap<>())
  371 + .metadata(new HashMap<>())
  372 + .version(0L)
  373 + .shadowTime(DateUtil.currentSeconds());
  374 + }
  375 +
  376 + private IotDeviceShadowDO getShadowFromCacheOrDb(Long deviceId) {
  377 + IotDeviceShadowDO shadow = deviceShadowRedisDAO.get(deviceId);
  378 + if (shadow != null) {
  379 + return shadow;
  380 + }
  381 + shadow = deviceShadowMapper.selectByDeviceId(deviceId);
  382 + if (shadow != null) {
  383 + deviceShadowRedisDAO.set(shadow);
  384 + }
  385 + return shadow;
  386 + }
  387 +
  388 + /**
  389 + * 下发影子回复,内部吞掉异常,保证上行处理链路不向上抛
  390 + */
  391 + private void sendShadowReplySafely(IotDeviceDO device, String serverId, IotDeviceShadowReplyDTO replyDTO) {
  392 + IotDeviceMessage shadowGetMsg = IotDeviceMessage.requestOf(
  393 + device.getId(), device.getTenantId(), serverId,
  394 + IotDeviceMessageMethodEnum.SHADOW_GET.getMethod(), replyDTO);
  395 + try {
  396 + deviceMessageService.sendDeviceMessage(shadowGetMsg, device, serverId);
  397 + } catch (Exception ex) {
  398 + log.error("[sendShadowReply][device({}) 下发影子回复失败]", device.getId(), ex);
  399 + }
  400 + }
  401 +
  402 + private static Map<String, Object> mergeMap(Map<String, Object> oldMap, Map<String, Object> newMap) {
  403 + Map<String, Object> result = oldMap != null ? new HashMap<>(oldMap) : new HashMap<>();
  404 + if (CollUtil.isNotEmpty(newMap)) {
  405 + result.putAll(newMap);
  406 + }
  407 + return result;
  408 + }
  409 +
  410 + @Getter
  411 + private static class ShadowUpdateRejectedException extends RuntimeException {
  412 +
  413 + private final Long version;
  414 + private final IotDeviceShadowErrorCodeEnum errorCode;
  415 +
  416 + private ShadowUpdateRejectedException(Long version, IotDeviceShadowErrorCodeEnum errorCode) {
  417 + super(errorCode.getMessage());
  418 + this.version = version;
  419 + this.errorCode = errorCode;
  420 + }
  421 +
  422 + }
  423 +
  424 + @Getter
  425 + private static class ShadowCasExhaustedException extends RuntimeException {
  426 +
  427 + private final Long lastKnownVersion;
  428 + private final boolean shadowNotFound;
  429 +
  430 + private ShadowCasExhaustedException(Long lastKnownVersion, boolean shadowNotFound) {
  431 + this.lastKnownVersion = lastKnownVersion != null ? lastKnownVersion : 0L;
  432 + this.shadowNotFound = shadowNotFound;
  433 + }
  434 +
  435 + static ShadowCasExhaustedException shadowNotFound() {
  436 + return new ShadowCasExhaustedException(0L, true);
  437 + }
  438 +
  439 + static ShadowCasExhaustedException versionConflict(Long lastKnownVersion) {
  440 + return new ShadowCasExhaustedException(lastKnownVersion, false);
  441 + }
  442 +
  443 + }
  444 +
  445 +}
... ...
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.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  7 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  8 +import cn.iocoder.yudao.module.iot.core.topic.shadow.IotDeviceShadowReplyDTO;
  9 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  10 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceShadowDO;
  11 +import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceShadowMapper;
  12 +import cn.iocoder.yudao.module.iot.dal.redis.device.DeviceShadowRedisDAO;
  13 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  14 +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
  15 +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
  16 +import org.junit.jupiter.api.BeforeEach;
  17 +import org.junit.jupiter.api.Test;
  18 +import org.mockito.ArgumentCaptor;
  19 +import org.mockito.InjectMocks;
  20 +import org.mockito.Mock;
  21 +import org.springframework.dao.DuplicateKeyException;
  22 +
  23 +import java.util.HashMap;
  24 +import java.util.Map;
  25 +import java.util.concurrent.atomic.AtomicInteger;
  26 +
  27 +import static org.junit.jupiter.api.Assertions.*;
  28 +import static org.mockito.ArgumentMatchers.*;
  29 +import static org.mockito.Mockito.*;
  30 +
  31 +/**
  32 + * {@link IotDeviceShadowServiceImpl} 的单元测试
  33 + */
  34 +public class IotDeviceShadowServiceImplTest extends BaseMockitoUnitTest {
  35 +
  36 + @InjectMocks
  37 + private IotDeviceShadowServiceImpl service;
  38 +
  39 + @Mock
  40 + private IotDeviceShadowMapper deviceShadowMapper;
  41 + @Mock
  42 + private DeviceShadowRedisDAO deviceShadowRedisDAO;
  43 + @Mock
  44 + private IotDeviceService deviceService;
  45 + @Mock
  46 + private IotDevicePropertyService devicePropertyService;
  47 + @Mock
  48 + private IotDeviceMessageService deviceMessageService;
  49 +
  50 + @BeforeEach
  51 + public void setUp() {
  52 + reset(deviceShadowMapper, deviceShadowRedisDAO, deviceService,
  53 + devicePropertyService, deviceMessageService);
  54 + }
  55 +
  56 + @Test
  57 + public void testHandleDeviceShadowUpdate_get_replyFullShadow() {
  58 + IotDeviceDO device = buildDevice();
  59 + IotDeviceShadowDO shadow = buildShadow(device, 2L);
  60 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  61 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow);
  62 +
  63 + Map<String, Object> params = new HashMap<>();
  64 + params.put("method", "get");
  65 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  66 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  67 +
  68 + service.handleDeviceShadowUpdate(device, message);
  69 +
  70 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  71 + verify(deviceMessageService).sendDeviceMessage(captor.capture(), eq(device), eq("server-1"));
  72 + IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams();
  73 + assertEquals("get_reply", reply.getMethod());
  74 + assertEquals(2L, reply.getVersion());
  75 + }
  76 +
  77 + @Test
  78 + public void testHandleDeviceShadowUpdate_update_mergeReportedAndVersionIncrement() {
  79 + IotDeviceDO device = buildDevice();
  80 + IotDeviceShadowDO shadow = buildShadow(device, 1L);
  81 + shadow.getReported().put("Temperature", 20);
  82 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  83 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow);
  84 + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(1L))).thenReturn(1);
  85 +
  86 + Map<String, Object> reported = new HashMap<>();
  87 + reported.put("Temperature", 25);
  88 + reported.put("Humidity", 60);
  89 + Map<String, Object> state = new HashMap<>();
  90 + state.put("reported", reported);
  91 + Map<String, Object> params = new HashMap<>();
  92 + params.put("method", "update");
  93 + params.put("state", state);
  94 + params.put("version", 1L);
  95 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  96 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  97 +
  98 + service.handleDeviceShadowUpdate(device, message);
  99 +
  100 + ArgumentCaptor<IotDeviceShadowDO> updateCaptor = ArgumentCaptor.forClass(IotDeviceShadowDO.class);
  101 + verify(deviceShadowMapper).updateByDeviceIdAndVersion(updateCaptor.capture(), eq(device.getId()), eq(1L));
  102 + assertEquals(25, updateCaptor.getValue().getReported().get("Temperature"));
  103 + assertEquals(60, updateCaptor.getValue().getReported().get("Humidity"));
  104 + assertEquals(2L, updateCaptor.getValue().getVersion());
  105 + verify(deviceShadowRedisDAO).delete(device.getId());
  106 + }
  107 +
  108 + @Test
  109 + public void testHandleDeviceShadowUpdate_versionConflict_send409() {
  110 + IotDeviceDO device = buildDevice();
  111 + IotDeviceShadowDO shadow = buildShadow(device, 5L);
  112 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  113 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow);
  114 +
  115 + Map<String, Object> reported = new HashMap<>();
  116 + reported.put("Temperature", 25);
  117 + Map<String, Object> state = new HashMap<>();
  118 + state.put("reported", reported);
  119 + Map<String, Object> params = new HashMap<>();
  120 + params.put("method", "update");
  121 + params.put("state", state);
  122 + params.put("version", 3L);
  123 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  124 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  125 +
  126 + service.handleDeviceShadowUpdate(device, message);
  127 +
  128 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  129 + verify(deviceMessageService).sendDeviceMessage(captor.capture(), eq(device), eq("server-1"));
  130 + IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams();
  131 + assertEquals("update_reply", reply.getMethod());
  132 + @SuppressWarnings("unchecked")
  133 + Map<String, Object> payload = (Map<String, Object>) reply.getPayload();
  134 + assertEquals("error", payload.get("status"));
  135 + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any());
  136 + }
  137 +
  138 + @Test
  139 + public void testHandleDeviceShadowUpdate_versionMinusOne_resetShadow() {
  140 + IotDeviceDO device = buildDevice();
  141 + IotDeviceShadowDO shadow = buildShadow(device, 3L);
  142 + shadow.getDesired().put("PowerSwitch", 1);
  143 + shadow.getReported().put("Temperature", 20);
  144 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  145 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow);
  146 + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), eq(3L))).thenReturn(1);
  147 +
  148 + Map<String, Object> reported = new HashMap<>();
  149 + reported.put("Temperature", 20);
  150 + Map<String, Object> state = new HashMap<>();
  151 + state.put("reported", reported);
  152 + Map<String, Object> params = new HashMap<>();
  153 + params.put("method", "update");
  154 + params.put("state", state);
  155 + params.put("version", -1L);
  156 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  157 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  158 +
  159 + service.handleDeviceShadowUpdate(device, message);
  160 +
  161 + ArgumentCaptor<IotDeviceShadowDO> updateCaptor = ArgumentCaptor.forClass(IotDeviceShadowDO.class);
  162 + verify(deviceShadowMapper).updateByDeviceIdAndVersion(updateCaptor.capture(), eq(device.getId()), eq(3L));
  163 + assertTrue(updateCaptor.getValue().getDesired().isEmpty());
  164 + assertTrue(updateCaptor.getValue().getReported().isEmpty());
  165 + assertEquals(0L, updateCaptor.getValue().getVersion());
  166 + }
  167 +
  168 + @Test
  169 + public void testHandleDeviceShadowUpdate_disabled_ignore() {
  170 + IotDeviceDO device = buildDevice();
  171 + IotDeviceShadowDO shadow = buildShadow(device, 0L, CommonStatusEnum.DISABLE.getStatus());
  172 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  173 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow);
  174 +
  175 + Map<String, Object> params = new HashMap<>();
  176 + params.put("method", "get");
  177 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  178 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  179 +
  180 + service.handleDeviceShadowUpdate(device, message);
  181 +
  182 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any());
  183 + }
  184 +
  185 + @Test
  186 + public void testUpdateDesiredState_online_pushShadow() {
  187 + Long deviceId = 2L;
  188 + IotDeviceDO device = buildDevice();
  189 + IotDeviceShadowDO shadow = buildShadow(device, 1L);
  190 + when(deviceService.validateDeviceExists(deviceId)).thenReturn(device);
  191 + when(deviceShadowRedisDAO.get(deviceId)).thenReturn(null);
  192 + when(deviceShadowMapper.selectByDeviceId(deviceId)).thenReturn(shadow);
  193 + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(deviceId), eq(1L))).thenReturn(1);
  194 + when(devicePropertyService.getDeviceServerId(deviceId)).thenReturn("server-1");
  195 +
  196 + Map<String, Object> desired = new HashMap<>();
  197 + desired.put("PowerSwitch", 1);
  198 + service.updateDesiredState(deviceId, desired);
  199 +
  200 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  201 + verify(deviceMessageService).sendDeviceMessage(captor.capture(), eq(device), eq("server-1"));
  202 + IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams();
  203 + assertEquals("get_reply", reply.getMethod());
  204 + assertEquals(2L, reply.getVersion());
  205 + // getOrCreateShadow + CAS 各一次 SELECT,推送不再额外查库
  206 + verify(deviceShadowMapper, times(2)).selectByDeviceId(deviceId);
  207 + verify(deviceShadowRedisDAO).delete(deviceId);
  208 + }
  209 +
  210 + @Test
  211 + public void testUpdateDesiredState_offline_noPush() {
  212 + Long deviceId = 2L;
  213 + IotDeviceDO device = buildDevice();
  214 + IotDeviceShadowDO shadow = buildShadow(device, 0L);
  215 + when(deviceService.validateDeviceExists(deviceId)).thenReturn(device);
  216 + when(deviceShadowRedisDAO.get(deviceId)).thenReturn(null);
  217 + when(deviceShadowMapper.selectByDeviceId(deviceId)).thenReturn(shadow);
  218 + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(deviceId), eq(0L))).thenReturn(1);
  219 + when(devicePropertyService.getDeviceServerId(deviceId)).thenReturn(null);
  220 +
  221 + Map<String, Object> desired = new HashMap<>();
  222 + desired.put("PowerSwitch", 1);
  223 + service.updateDesiredState(deviceId, desired);
  224 +
  225 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any());
  226 + }
  227 +
  228 + @Test
  229 + public void testHandleDeviceShadowUpdate_methodMissing_send401() {
  230 + IotDeviceDO device = buildDevice();
  231 + IotDeviceShadowDO shadow = buildShadow(device, 3L);
  232 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  233 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow);
  234 +
  235 + Map<String, Object> params = new HashMap<>();
  236 + params.put("state", Map.of("reported", Map.of("Temperature", 25)));
  237 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  238 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  239 +
  240 + service.handleDeviceShadowUpdate(device, message);
  241 +
  242 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  243 + verify(deviceMessageService).sendDeviceMessage(captor.capture(), eq(device), eq("server-1"));
  244 + IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams();
  245 + @SuppressWarnings("unchecked")
  246 + Map<String, Object> payload = (Map<String, Object>) reply.getPayload();
  247 + @SuppressWarnings("unchecked")
  248 + Map<String, Object> content = (Map<String, Object>) payload.get("content");
  249 + assertEquals(401, content.get("errorcode"));
  250 + }
  251 +
  252 + @Test
  253 + public void testHandleDeviceShadowUpdate_update_retryReMergeFromLatest() {
  254 + IotDeviceDO device = buildDevice();
  255 + IotDeviceShadowDO staleShadow = buildShadow(device, 1L);
  256 + staleShadow.getReported().put("Temperature", 20);
  257 +
  258 + IotDeviceShadowDO latestShadow = buildShadow(device, 2L);
  259 + latestShadow.getReported().put("Temperature", 30);
  260 + latestShadow.getReported().put("Humidity", 50);
  261 +
  262 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  263 + AtomicInteger selectCount = new AtomicInteger(0);
  264 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenAnswer(invocation -> {
  265 + // 前两次 select 返回 v=1 的快照,之后返回 v=2 的最新快照
  266 + return selectCount.incrementAndGet() <= 2 ? staleShadow : latestShadow;
  267 + });
  268 + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(device.getId()), anyLong())).thenAnswer(invocation -> {
  269 + Long expectVersion = invocation.getArgument(2);
  270 + if (expectVersion == 1L) {
  271 + return 0;
  272 + }
  273 + if (expectVersion == 2L) {
  274 + return 1;
  275 + }
  276 + return 0;
  277 + });
  278 +
  279 + Map<String, Object> reported = new HashMap<>();
  280 + reported.put("Temperature", 25);
  281 + Map<String, Object> state = new HashMap<>();
  282 + state.put("reported", reported);
  283 + Map<String, Object> params = new HashMap<>();
  284 + params.put("method", "update");
  285 + params.put("state", state);
  286 + params.put("version", 2L);
  287 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  288 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  289 +
  290 + service.handleDeviceShadowUpdate(device, message);
  291 +
  292 + ArgumentCaptor<IotDeviceShadowDO> updateCaptor = ArgumentCaptor.forClass(IotDeviceShadowDO.class);
  293 + verify(deviceShadowMapper, atLeastOnce()).updateByDeviceIdAndVersion(updateCaptor.capture(), eq(device.getId()), any());
  294 + IotDeviceShadowDO finalUpdate = updateCaptor.getAllValues().get(updateCaptor.getAllValues().size() - 1);
  295 + // 重试时应基于 v=2 的最新 reported 合并,保留 Humidity=50
  296 + assertEquals(25, finalUpdate.getReported().get("Temperature"));
  297 + assertEquals(50, finalUpdate.getReported().get("Humidity"));
  298 + assertEquals(3L, finalUpdate.getVersion());
  299 +
  300 + ArgumentCaptor<IotDeviceMessage> replyCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  301 + verify(deviceMessageService).sendDeviceMessage(replyCaptor.capture(), eq(device), eq("server-1"));
  302 + IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) replyCaptor.getValue().getParams();
  303 + assertEquals(3L, reply.getVersion());
  304 + }
  305 +
  306 + @Test
  307 + public void testUpdateStatus_whenShadowMissing_insertWithTargetStatus() {
  308 + Long deviceId = 2L;
  309 + IotDeviceDO device = buildDevice();
  310 + when(deviceService.validateDeviceExists(deviceId)).thenReturn(device);
  311 + when(deviceShadowRedisDAO.get(deviceId)).thenReturn(null);
  312 + when(deviceShadowMapper.selectByDeviceId(deviceId)).thenReturn(null);
  313 +
  314 + service.updateStatus(deviceId, CommonStatusEnum.DISABLE.getStatus());
  315 +
  316 + ArgumentCaptor<IotDeviceShadowDO> captor = ArgumentCaptor.forClass(IotDeviceShadowDO.class);
  317 + verify(deviceShadowMapper).insert(captor.capture());
  318 + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus());
  319 + verify(deviceShadowRedisDAO, never()).delete(deviceId);
  320 + }
  321 +
  322 + @Test
  323 + public void testUpdateStatus_whenShadowMissing_duplicateKey_fallbackUpdateAndEvictCache() {
  324 + Long deviceId = 2L;
  325 + IotDeviceDO device = buildDevice();
  326 + when(deviceService.validateDeviceExists(deviceId)).thenReturn(device);
  327 + when(deviceShadowRedisDAO.get(deviceId)).thenReturn(null);
  328 + when(deviceShadowMapper.selectByDeviceId(deviceId)).thenReturn(null);
  329 + when(deviceShadowMapper.insert(any(IotDeviceShadowDO.class))).thenThrow(new DuplicateKeyException("duplicate"));
  330 + when(deviceShadowMapper.updateStatusByDeviceId(deviceId, CommonStatusEnum.DISABLE.getStatus())).thenReturn(1);
  331 +
  332 + service.updateStatus(deviceId, CommonStatusEnum.DISABLE.getStatus());
  333 +
  334 + verify(deviceShadowMapper).updateStatusByDeviceId(deviceId, CommonStatusEnum.DISABLE.getStatus());
  335 + verify(deviceShadowRedisDAO).delete(deviceId);
  336 + }
  337 +
  338 + @Test
  339 + public void testResetShadow_casConflict_throwException() {
  340 + Long deviceId = 2L;
  341 + IotDeviceDO device = buildDevice();
  342 + IotDeviceShadowDO shadow = buildShadow(device, 2L);
  343 + when(deviceService.validateDeviceExists(deviceId)).thenReturn(device);
  344 + when(deviceShadowRedisDAO.get(deviceId)).thenReturn(null);
  345 + when(deviceShadowMapper.selectByDeviceId(deviceId)).thenReturn(shadow);
  346 + when(deviceShadowMapper.updateByDeviceIdAndVersion(any(), eq(deviceId), eq(2L))).thenReturn(0);
  347 +
  348 + assertThrows(ServiceException.class, () -> service.resetShadow(deviceId));
  349 + }
  350 +
  351 + @Test
  352 + public void testHandleDeviceShadowUpdate_catchBlock_dbFailureStillSendsReply() {
  353 + IotDeviceDO device = buildDevice();
  354 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  355 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenThrow(new RuntimeException("DB down"));
  356 +
  357 + Map<String, Object> params = new HashMap<>();
  358 + params.put("method", "get");
  359 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  360 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  361 +
  362 + assertDoesNotThrow(() -> service.handleDeviceShadowUpdate(device, message));
  363 +
  364 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  365 + verify(deviceMessageService).sendDeviceMessage(captor.capture(), eq(device), eq("server-1"));
  366 + IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams();
  367 + assertEquals(0L, reply.getVersion());
  368 + @SuppressWarnings("unchecked")
  369 + Map<String, Object> payload = (Map<String, Object>) reply.getPayload();
  370 + @SuppressWarnings("unchecked")
  371 + Map<String, Object> content = (Map<String, Object>) payload.get("content");
  372 + assertEquals(500, content.get("errorcode"));
  373 + }
  374 +
  375 + @Test
  376 + public void testHandleDeviceShadowUpdate_casShadowNotFound_send500() {
  377 + IotDeviceDO device = buildDevice();
  378 + IotDeviceShadowDO shadow = buildShadow(device, 2L);
  379 + when(deviceShadowRedisDAO.get(device.getId())).thenReturn(null);
  380 + when(deviceShadowMapper.selectByDeviceId(device.getId())).thenReturn(shadow, (IotDeviceShadowDO) null);
  381 +
  382 + Map<String, Object> reported = new HashMap<>();
  383 + reported.put("Temperature", 25);
  384 + Map<String, Object> state = new HashMap<>();
  385 + state.put("reported", reported);
  386 + Map<String, Object> params = new HashMap<>();
  387 + params.put("method", "update");
  388 + params.put("state", state);
  389 + params.put("version", 2L);
  390 + IotDeviceMessage message = IotDeviceMessage.requestOf(device.getId(), 1L, "server-1",
  391 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), params);
  392 +
  393 + service.handleDeviceShadowUpdate(device, message);
  394 +
  395 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  396 + verify(deviceMessageService).sendDeviceMessage(captor.capture(), eq(device), eq("server-1"));
  397 + IotDeviceShadowReplyDTO reply = (IotDeviceShadowReplyDTO) captor.getValue().getParams();
  398 + @SuppressWarnings("unchecked")
  399 + Map<String, Object> payload = (Map<String, Object>) reply.getPayload();
  400 + @SuppressWarnings("unchecked")
  401 + Map<String, Object> content = (Map<String, Object>) payload.get("content");
  402 + assertEquals(500, content.get("errorcode"));
  403 + verify(deviceShadowMapper, never()).updateByDeviceIdAndVersion(any(), any(), any());
  404 + }
  405 +
  406 + private IotDeviceDO buildDevice() {
  407 + return IotDeviceDO.builder()
  408 + .id(2L)
  409 + .productId(10L)
  410 + .productKey("testProduct")
  411 + .deviceName("testDevice")
  412 + .build();
  413 + }
  414 +
  415 + private IotDeviceShadowDO buildShadow(IotDeviceDO device, Long version) {
  416 + return buildShadow(device, version, CommonStatusEnum.ENABLE.getStatus());
  417 + }
  418 +
  419 + private IotDeviceShadowDO buildShadow(IotDeviceDO device, Long version, Integer status) {
  420 + return IotDeviceShadowDO.builder()
  421 + .id(100L)
  422 + .deviceId(device.getId())
  423 + .productId(device.getProductId())
  424 + .productKey(device.getProductKey())
  425 + .deviceName(device.getDeviceName())
  426 + .desired(new HashMap<>())
  427 + .reported(new HashMap<>())
  428 + .metadata(new HashMap<>())
  429 + .version(version)
  430 + .shadowTime(1000L)
  431 + .status(status)
  432 + .build();
  433 + }
  434 +
  435 +}
... ...
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
... ... @@ -181,6 +181,29 @@ CREATE TABLE IF NOT EXISTS &quot;iot_ota_record&quot; (
181 181 PRIMARY KEY ("id")
182 182 ) COMMENT 'IoT OTA 升级记录表';
183 183  
  184 +CREATE TABLE IF NOT EXISTS "iot_device_shadow"
  185 +(
  186 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  187 + "device_id" bigint NOT NULL,
  188 + "product_id" bigint NOT NULL,
  189 + "product_key" varchar(64) NOT NULL DEFAULT '',
  190 + "device_name" varchar(255) NOT NULL DEFAULT '',
  191 + "reported" varchar(10000) DEFAULT NULL,
  192 + "desired" varchar(10000) DEFAULT NULL,
  193 + "metadata" varchar(10000) DEFAULT NULL,
  194 + "version" bigint NOT NULL DEFAULT 0,
  195 + "shadow_time" bigint NOT NULL DEFAULT 0,
  196 + "status" tinyint NOT NULL DEFAULT 0,
  197 + "creator" varchar(64) DEFAULT '',
  198 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  199 + "updater" varchar(64) DEFAULT '',
  200 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  201 + "deleted" bit NOT NULL DEFAULT FALSE,
  202 + "tenant_id" bigint NOT NULL DEFAULT '0',
  203 + PRIMARY KEY ("id"),
  204 + UNIQUE ("device_id")
  205 +) COMMENT 'IoT 设备影子表';
  206 +
184 207 CREATE TABLE IF NOT EXISTS "iot_data_rule" (
185 208 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
186 209 "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,11 @@ 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 + SHADOW_GET("thing.shadow.get", "设备影子获取", false),
  74 +
70 75 ;
71 76  
72 77 public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod)
... ... @@ -77,7 +82,16 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; {
77 82 */
78 83 public static final Set<String> REPLY_DISABLED = SetUtils.asSet(
79 84 STATE_UPDATE.getMethod(),
80   - OTA_PROGRESS.getMethod() // 参考阿里云,OTA 升级进度上报,不进行回复
  85 + OTA_PROGRESS.getMethod(), // 参考阿里云,OTA 升级进度上报,不进行回复
  86 + SHADOW_UPDATE.getMethod() // 设备影子由影子 Service 主动下发 thing.shadow.get
  87 + );
  88 +
  89 + /**
  90 + * 透传方法集合:设备侧使用原始线格式,不套 IotDeviceMessage 封套
  91 + */
  92 + public static final Set<String> PASSTHROUGH_METHODS = SetUtils.asSet(
  93 + SHADOW_UPDATE.getMethod(),
  94 + SHADOW_GET.getMethod()
81 95 );
82 96  
83 97 private final String method;
... ... @@ -100,4 +114,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; {
100 114 return REPLY_DISABLED.contains(method);
101 115 }
102 116  
  117 + public static boolean isPassthrough(String method) {
  118 + return PASSTHROUGH_METHODS.contains(method);
  119 + }
  120 +
103 121 }
... ...
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 + * 用于回填 update_reply.payload.content,不走 CommonResult
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Getter
  14 +@AllArgsConstructor
  15 +public enum IotDeviceShadowErrorCodeEnum {
  16 +
  17 + JSON_FORMAT_ERROR(400, "JSON 格式错误"),
  18 + METHOD_MISSING(401, "缺少 method"),
  19 + STATE_MISSING(402, "缺少 state"),
  20 + REPORTED_MISSING(404, "缺少 reported"),
  21 + REPORTED_EMPTY(405, "reported 为空"),
  22 + INVALID_METHOD(406, "无效内层 method"),
  23 + SHADOW_EMPTY(407, "影子内容为空"),
  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/enums/shadow/IotDeviceShadowMetadatas.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.core.enums.shadow;
  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 +import static cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowProtocolConstants.*;
  10 +
  11 +/**
  12 + * IoT 设备影子 metadata 读写工具
  13 + * <p>
  14 + * 结构:{ desired: { identifier: { timestamp } }, reported: { ... } }
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +public final class IotDeviceShadowMetadatas {
  19 +
  20 + private IotDeviceShadowMetadatas() {
  21 + }
  22 +
  23 + /**
  24 + * 获取 desired 下某属性的更新时间戳
  25 + *
  26 + * @param metadata 元数据
  27 + * @param identifier 属性标识符
  28 + * @return 时间戳(epoch 秒),不存在返回 null
  29 + */
  30 + public static Long getDesiredTimestamp(Map<String, Object> metadata, String identifier) {
  31 + return getTimestamp(metadata, STATE_DESIRED, identifier);
  32 + }
  33 +
  34 + /**
  35 + * 获取 reported 下某属性的更新时间戳
  36 + *
  37 + * @param metadata 元数据
  38 + * @param identifier 属性标识符
  39 + * @return 时间戳(epoch 秒),不存在返回 null
  40 + */
  41 + public static Long getReportedTimestamp(Map<String, Object> metadata, String identifier) {
  42 + return getTimestamp(metadata, STATE_REPORTED, identifier);
  43 + }
  44 +
  45 + /**
  46 + * 写入 desired 下多个属性的更新时间戳(原地修改 metadata)
  47 + *
  48 + * @param metadata 元数据
  49 + * @param keys 属性标识符集合
  50 + * @param timestamp 时间戳(epoch 秒)
  51 + */
  52 + public static void putDesiredTimestamps(Map<String, Object> metadata, Set<String> keys, long timestamp) {
  53 + putTimestamps(metadata, STATE_DESIRED, keys, timestamp);
  54 + }
  55 +
  56 + /**
  57 + * 写入 reported 下多个属性的更新时间戳(原地修改 metadata)
  58 + *
  59 + * @param metadata 元数据
  60 + * @param keys 属性标识符集合
  61 + * @param timestamp 时间戳(epoch 秒)
  62 + */
  63 + public static void putReportedTimestamps(Map<String, Object> metadata, Set<String> keys, long timestamp) {
  64 + putTimestamps(metadata, STATE_REPORTED, keys, timestamp);
  65 + }
  66 +
  67 + @SuppressWarnings("unchecked")
  68 + private static Long getTimestamp(Map<String, Object> metadata, String type, String identifier) {
  69 + if (metadata == null) {
  70 + return null;
  71 + }
  72 + Object typeMeta = metadata.get(type);
  73 + if (!(typeMeta instanceof Map)) {
  74 + return null;
  75 + }
  76 + Object keyMeta = ((Map<String, Object>) typeMeta).get(identifier);
  77 + if (!(keyMeta instanceof Map)) {
  78 + return null;
  79 + }
  80 + Object timestamp = ((Map<String, Object>) keyMeta).get(METADATA_TIMESTAMP);
  81 + return timestamp instanceof Number ? ((Number) timestamp).longValue() : null;
  82 + }
  83 +
  84 + @SuppressWarnings("unchecked")
  85 + private static void putTimestamps(Map<String, Object> metadata, String type, Set<String> keys, long timestamp) {
  86 + Map<String, Object> typeMeta = (Map<String, Object>) metadata.computeIfAbsent(type, k -> new HashMap<>());
  87 + for (String key : keys) {
  88 + typeMeta.put(key, MapUtil.of(METADATA_TIMESTAMP, timestamp));
  89 + }
  90 + }
  91 +
  92 +}
... ...
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/shadow/IotDeviceShadowProtocolConstants.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.core.enums.shadow;
  2 +
  3 +/**
  4 + * IoT 设备影子协议常量
  5 + * <p>
  6 + * 内层 method、state 键名等与设备侧透传 JSON 对齐
  7 + *
  8 + * @author 芋道源码
  9 + */
  10 +public final class IotDeviceShadowProtocolConstants {
  11 +
  12 + private IotDeviceShadowProtocolConstants() {
  13 + }
  14 +
  15 + // ========== 内层 method ==========
  16 +
  17 + public static final String INNER_METHOD_GET = "get";
  18 + public static final String INNER_METHOD_UPDATE = "update";
  19 + public static final String INNER_METHOD_GET_REPLY = "get_reply";
  20 + public static final String INNER_METHOD_UPDATE_REPLY = "update_reply";
  21 +
  22 + // ========== state / metadata 键 ==========
  23 +
  24 + public static final String STATE_DESIRED = "desired";
  25 + public static final String STATE_REPORTED = "reported";
  26 + public static final String METADATA_TIMESTAMP = "timestamp";
  27 +
  28 + // ========== payload 键 ==========
  29 +
  30 + public static final String PAYLOAD_STATUS = "status";
  31 + public static final String PAYLOAD_STATE = "state";
  32 + public static final String PAYLOAD_VERSION = "version";
  33 + public static final String PAYLOAD_CONTENT = "content";
  34 + public static final String PAYLOAD_ERROR_CODE = "errorcode";
  35 + public static final String PAYLOAD_ERROR_MESSAGE = "errormessage";
  36 +
  37 + // ========== payload status 值 ==========
  38 +
  39 + public static final String STATUS_SUCCESS = "success";
  40 + public static final String STATUS_ERROR = "error";
  41 +
  42 +}
... ...
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/shadow/IotDeviceShadowReplyDTO.java 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.HashMap;
  9 +import java.util.Map;
  10 +
  11 +import static cn.iocoder.yudao.module.iot.core.enums.shadow.IotDeviceShadowProtocolConstants.*;
  12 +
  13 +/**
  14 + * IoT 设备影子下行 Reply DTO
  15 + * <p>
  16 + * 用于 {@link IotDeviceMessageMethodEnum#SHADOW_GET} 消息的 params 参数(透传原始线 body)
  17 + *
  18 + * @author 芋道源码
  19 + */
  20 +@Data
  21 +@NoArgsConstructor
  22 +@AllArgsConstructor
  23 +public class IotDeviceShadowReplyDTO {
  24 +
  25 + /**
  26 + * 内层方法:get_reply / update_reply
  27 + */
  28 + private String method;
  29 +
  30 + /**
  31 + * 回复载荷
  32 + */
  33 + private Object payload;
  34 +
  35 + /**
  36 + * 影子版本号
  37 + */
  38 + private Long version;
  39 +
  40 + /**
  41 + * 时间戳(epoch 秒)
  42 + */
  43 + private Long timestamp;
  44 +
  45 + public static IotDeviceShadowReplyDTO getReplyOf(Map<String, Object> desired, Map<String, Object> reported,
  46 + Long version, Long timestamp) {
  47 + Map<String, Object> state = new HashMap<>();
  48 + state.put(STATE_DESIRED, desired != null ? desired : new HashMap<>());
  49 + state.put(STATE_REPORTED, reported != null ? reported : new HashMap<>());
  50 + Map<String, Object> payload = new HashMap<>();
  51 + payload.put(PAYLOAD_STATUS, STATUS_SUCCESS);
  52 + payload.put(PAYLOAD_STATE, state);
  53 + return new IotDeviceShadowReplyDTO()
  54 + .setMethod(INNER_METHOD_GET_REPLY)
  55 + .setPayload(payload)
  56 + .setVersion(version)
  57 + .setTimestamp(timestamp);
  58 + }
  59 +
  60 + public static IotDeviceShadowReplyDTO updateReplyOf(Long version, Long timestamp) {
  61 + Map<String, Object> payload = new HashMap<>();
  62 + payload.put(PAYLOAD_STATUS, STATUS_SUCCESS);
  63 + payload.put(PAYLOAD_VERSION, version);
  64 + return new IotDeviceShadowReplyDTO()
  65 + .setMethod(INNER_METHOD_UPDATE_REPLY)
  66 + .setPayload(payload)
  67 + .setVersion(version)
  68 + .setTimestamp(timestamp);
  69 + }
  70 +
  71 + public static IotDeviceShadowReplyDTO errorOf(Long version, Integer errorcode, String errormessage, Long timestamp) {
  72 + Map<String, Object> content = new HashMap<>();
  73 + content.put(PAYLOAD_ERROR_CODE, errorcode);
  74 + content.put(PAYLOAD_ERROR_MESSAGE, errormessage);
  75 + Map<String, Object> payload = new HashMap<>();
  76 + payload.put(PAYLOAD_STATUS, STATUS_ERROR);
  77 + payload.put(PAYLOAD_VERSION, version);
  78 + payload.put(PAYLOAD_CONTENT, content);
  79 + return new IotDeviceShadowReplyDTO()
  80 + .setMethod(INNER_METHOD_UPDATE_REPLY)
  81 + .setPayload(payload)
  82 + .setVersion(version)
  83 + .setTimestamp(timestamp);
  84 + }
  85 +
  86 +}
... ...
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 参数(透传原始线 body)
  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 + */
  50 + private Map<String, Object> desired;
  51 +
  52 + }
  53 +
  54 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java
... ... @@ -2,6 +2,8 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream;
2 2  
3 3 import cn.hutool.core.lang.Assert;
4 4 import cn.hutool.core.util.ArrayUtil;
  5 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
5 7 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6 8 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager;
7 9 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
... ... @@ -9,6 +11,10 @@ import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
9 11 import io.vertx.mqtt.MqttEndpoint;
10 12 import lombok.extern.slf4j.Slf4j;
11 13  
  14 +import java.util.Arrays;
  15 +import java.util.Map;
  16 +import java.util.stream.Collectors;
  17 +
12 18  
13 19 /**
14 20 * IoT 网关 MQTT 上行消息处理器:处理业务消息(属性上报、事件上报等)
... ... @@ -59,13 +65,27 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler {
59 65 }
60 66  
61 67 // 2.1 反序列化消息
62   - IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName);
63   - if (message == null) {
64   - log.warn("[handleBusinessRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic);
65   - return;
  68 + String topicMethod = Arrays.stream(topicParts, 4, topicParts.length)
  69 + .collect(Collectors.joining("."));
  70 + IotDeviceMessage message;
  71 + if (IotDeviceMessageMethodEnum.isPassthrough(topicMethod)) {
  72 + // 影子透传:从 topic 解析 yudao method,原始 body 作为 params
  73 + Map<String, Object> params = JsonUtils.parseObject(payload, Map.class);
  74 + if (params == null) {
  75 + log.warn("[handleBusinessRequest][影子透传 payload 解析失败,客户端 ID: {},主题: {}]", clientId, topic);
  76 + return;
  77 + }
  78 + String requestId = (String) params.get("id");
  79 + message = IotDeviceMessage.requestOf(requestId, topicMethod, params);
  80 + } else {
  81 + message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName);
  82 + if (message == null) {
  83 + log.warn("[handleBusinessRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic);
  84 + return;
  85 + }
  86 + // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀)
  87 + IotMqttTopicUtils.normalizeReplyMethod(message);
66 88 }
67   - // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀)
68   - IotMqttTopicUtils.normalizeReplyMethod(message);
69 89  
70 90 // 3. 处理业务消息
71 91 deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId);
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java
... ... @@ -2,7 +2,9 @@ package cn.iocoder.yudao.module.iot.gateway.service.device.message;
2 2  
3 3 import cn.hutool.core.lang.Assert;
4 4 import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
5 6 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
  7 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
6 8 import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum;
7 9 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
8 10 import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
... ... @@ -40,6 +42,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
40 42 @Override
41 43 public byte[] serializeDeviceMessage(IotDeviceMessage message,
42 44 String productKey, String deviceName) {
  45 + // 影子透传:直接序列化 params 为原始线 JSON,忽略产品 serializeType
  46 + if (IotDeviceMessageMethodEnum.isPassthrough(message.getMethod())) {
  47 + return JsonUtils.toJsonByte(message.getParams());
  48 + }
43 49 // 1.1 获取设备信息
44 50 IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName);
45 51 if (device == null) {
... ... @@ -56,6 +62,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
56 62 @Override
57 63 public byte[] serializeDeviceMessage(IotDeviceMessage message,
58 64 IotSerializeTypeEnum serializeType) {
  65 + // 影子透传
  66 + if (IotDeviceMessageMethodEnum.isPassthrough(message.getMethod())) {
  67 + return JsonUtils.toJsonByte(message.getParams());
  68 + }
59 69 // 1. 获取序列化器
60 70 IotMessageSerializer serializer = messageSerializerManager.get(serializeType);
61 71 if (serializer == null) {
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceShadowPassthroughTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.service.device.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  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.core.topic.shadow.IotDeviceShadowReplyDTO;
  8 +import org.junit.jupiter.api.Test;
  9 +import org.mockito.InjectMocks;
  10 +
  11 +import java.util.HashMap;
  12 +import java.util.Map;
  13 +
  14 +import static org.junit.jupiter.api.Assertions.assertEquals;
  15 +import static org.junit.jupiter.api.Assertions.assertNull;
  16 +
  17 +/**
  18 + * 设备影子网关透传序列化单元测试
  19 + */
  20 +public class IotDeviceShadowPassthroughTest extends BaseMockitoUnitTest {
  21 +
  22 + @InjectMocks
  23 + private IotDeviceMessageServiceImpl deviceMessageService;
  24 +
  25 + @Test
  26 + public void testSerializeDeviceMessage_passthrough_noEnvelope() {
  27 + Map<String, Object> payload = new HashMap<>();
  28 + payload.put("method", "get_reply");
  29 + payload.put("version", 2L);
  30 + payload.put("timestamp", 1469564576L);
  31 + IotDeviceMessage message = IotDeviceMessage.requestOf(
  32 + IotDeviceMessageMethodEnum.SHADOW_GET.getMethod(), payload);
  33 +
  34 + byte[] bytes = deviceMessageService.serializeDeviceMessage(message, null);
  35 +
  36 + @SuppressWarnings("unchecked")
  37 + Map<String, Object> result = JsonUtils.parseObject(bytes, Map.class);
  38 + assertEquals("get_reply", result.get("method"));
  39 + assertEquals(2, ((Number) result.get("version")).intValue());
  40 + assertNull(result.get("id"));
  41 + assertNull(result.get("code"));
  42 + assertNull(result.get("requestId"));
  43 + }
  44 +
  45 + @Test
  46 + public void testDeserializeUpstreamShadow_fromTopicMethod() {
  47 + // 模拟上行透传:原始 body 不含 thing.shadow.update 封套 method
  48 + Map<String, Object> body = new HashMap<>();
  49 + body.put("method", "get");
  50 + IotDeviceMessage message = IotDeviceMessage.requestOf("req-1",
  51 + IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), body);
  52 +
  53 + assertEquals(IotDeviceMessageMethodEnum.SHADOW_UPDATE.getMethod(), message.getMethod());
  54 + assertEquals("get", ((Map<?, ?>) message.getParams()).get("method"));
  55 + }
  56 +
  57 + @Test
  58 + public void testSerializeUpdateReply_passthrough() {
  59 + IotDeviceShadowReplyDTO reply = IotDeviceShadowReplyDTO.updateReplyOf(3L, 1469564576L);
  60 + IotDeviceMessage message = IotDeviceMessage.requestOf(
  61 + IotDeviceMessageMethodEnum.SHADOW_GET.getMethod(), reply);
  62 +
  63 + byte[] bytes = deviceMessageService.serializeDeviceMessage(message, null);
  64 + @SuppressWarnings("unchecked")
  65 + Map<String, Object> result = JsonUtils.parseObject(bytes, Map.class);
  66 + assertEquals("update_reply", result.get("method"));
  67 + assertNull(result.get("id"));
  68 + }
  69 +
  70 +}
... ...