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,6 +19,14 @@ public interface RedisKeyConstants {
19 String DEVICE_PROPERTY = "iot:device_property:%d"; 19 String DEVICE_PROPERTY = "iot:device_property:%d";
20 20
21 /** 21 /**
  22 + * 设备影子数据缓存,采用 STRING 结构
  23 + * <p>
  24 + * 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 * 设备的最后上报时间,采用 ZSET 结构 30 * 设备的最后上报时间,采用 ZSET 结构
23 * 31 *
24 * KEY 格式:{deviceId} 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,6 +62,10 @@ public interface ErrorCodeConstants {
62 ErrorCode DEVICE_MODBUS_POINT_NOT_EXISTS = new ErrorCode(1_050_007_000, "设备 Modbus 点位配置不存在"); 62 ErrorCode DEVICE_MODBUS_POINT_NOT_EXISTS = new ErrorCode(1_050_007_000, "设备 Modbus 点位配置不存在");
63 ErrorCode DEVICE_MODBUS_POINT_EXISTS = new ErrorCode(1_050_007_001, "设备 Modbus 点位配置已存在"); 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 // ========== OTA 固件相关 1-050-008-000 ========== 69 // ========== OTA 固件相关 1-050-008-000 ==========
66 70
67 ErrorCode OTA_FIRMWARE_NOT_EXISTS = new ErrorCode(1_050_008_000, "固件信息不存在"); 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,6 +37,7 @@ import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceMapper;
37 import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; 37 import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants;
38 import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; 38 import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
39 import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; 39 import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
  40 +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService;
40 import cn.iocoder.yudao.module.iot.service.product.IotProductService; 41 import cn.iocoder.yudao.module.iot.service.product.IotProductService;
41 import jakarta.annotation.Resource; 42 import jakarta.annotation.Resource;
42 import jakarta.validation.ConstraintViolationException; 43 import jakarta.validation.ConstraintViolationException;
@@ -81,6 +82,9 @@ public class IotDeviceServiceImpl implements IotDeviceService { @@ -81,6 +82,9 @@ public class IotDeviceServiceImpl implements IotDeviceService {
81 @Resource 82 @Resource
82 @Lazy // 延迟加载,解决循环依赖 83 @Lazy // 延迟加载,解决循环依赖
83 private IotDeviceMessageService deviceMessageService; 84 private IotDeviceMessageService deviceMessageService;
  85 + @Resource
  86 + @Lazy // 延迟加载,解决循环依赖
  87 + private IotDeviceShadowService deviceShadowService;
84 88
85 private IotDeviceServiceImpl getSelf() { 89 private IotDeviceServiceImpl getSelf() {
86 return SpringUtil.getBean(getClass()); 90 return SpringUtil.getBean(getClass());
@@ -209,7 +213,10 @@ public class IotDeviceServiceImpl implements IotDeviceService { @@ -209,7 +213,10 @@ public class IotDeviceServiceImpl implements IotDeviceService {
209 // 2. 删除设备 213 // 2. 删除设备
210 deviceMapper.deleteById(id); 214 deviceMapper.deleteById(id);
211 215
212 - // 3. 清空对应缓存 216 + // 3. 级联删除影子
  217 + deviceShadowService.deleteByDeviceId(id);
  218 +
  219 + // 4. 清空对应缓存
213 deleteDeviceCache(device); 220 deleteDeviceCache(device);
214 } 221 }
215 222
@@ -235,7 +242,10 @@ public class IotDeviceServiceImpl implements IotDeviceService { @@ -235,7 +242,10 @@ public class IotDeviceServiceImpl implements IotDeviceService {
235 // 2. 删除设备 242 // 2. 删除设备
236 deviceMapper.deleteByIds(ids); 243 deviceMapper.deleteByIds(ids);
237 244
238 - // 3. 清空对应缓存 245 + // 3. 级联删除影子
  246 + ids.forEach(deviceShadowService::deleteByDeviceId);
  247 +
  248 + // 4. 清空对应缓存
239 deleteDeviceCache(devices); 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,6 +37,16 @@ public interface IotDeviceMessageService {
37 IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device); 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 * @param message 消息(“codec(编解码)字段” 部分字段) 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,6 +28,7 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO;
28 import cn.iocoder.yudao.module.iot.dal.tdengine.IotDeviceMessageMapper; 28 import cn.iocoder.yudao.module.iot.dal.tdengine.IotDeviceMessageMapper;
29 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; 29 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
30 import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; 30 import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
  31 +import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService;
31 import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; 32 import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService;
32 import com.baomidou.mybatisplus.core.metadata.IPage; 33 import com.baomidou.mybatisplus.core.metadata.IPage;
33 import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 34 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -65,6 +66,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @@ -65,6 +66,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
65 @Resource 66 @Resource
66 @Lazy // 延迟加载,避免循环依赖 67 @Lazy // 延迟加载,避免循环依赖
67 private IotOtaTaskRecordService otaTaskRecordService; 68 private IotOtaTaskRecordService otaTaskRecordService;
  69 + @Resource
  70 + @Lazy // 延迟加载,避免循环依赖
  71 + private IotDeviceShadowService deviceShadowService;
68 72
69 @Resource 73 @Resource
70 private IotDeviceMessageMapper deviceMessageMapper; 74 private IotDeviceMessageMapper deviceMessageMapper;
@@ -120,7 +124,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @@ -120,7 +124,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
120 return sendDeviceMessage(message, device, null); 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 // 1. 补充信息 129 // 1. 补充信息
125 appendDeviceMessage(message, device); 130 appendDeviceMessage(message, device);
126 131
@@ -229,6 +234,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @@ -229,6 +234,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
229 return null; 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 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())) { 244 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())) {
234 return deviceService.handleTopoAddMessage(message, device); 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 DELETE FROM "iot_scene_rule"; 1 DELETE FROM "iot_scene_rule";
2 DELETE FROM "iot_product"; 2 DELETE FROM "iot_product";
3 DELETE FROM "iot_device"; 3 DELETE FROM "iot_device";
  4 +DELETE FROM "iot_device_shadow";
4 DELETE FROM "iot_thing_model"; 5 DELETE FROM "iot_thing_model";
5 DELETE FROM "iot_device_data"; 6 DELETE FROM "iot_device_data";
6 DELETE FROM "iot_alert_config"; 7 DELETE FROM "iot_alert_config";
yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql
@@ -181,6 +181,29 @@ CREATE TABLE IF NOT EXISTS &quot;iot_ota_record&quot; ( @@ -181,6 +181,29 @@ CREATE TABLE IF NOT EXISTS &quot;iot_ota_record&quot; (
181 PRIMARY KEY ("id") 181 PRIMARY KEY ("id")
182 ) COMMENT 'IoT OTA 升级记录表'; 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 CREATE TABLE IF NOT EXISTS "iot_data_rule" ( 207 CREATE TABLE IF NOT EXISTS "iot_data_rule" (
185 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, 208 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
186 "name" varchar(128) NOT NULL, 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,6 +67,11 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; {
67 OTA_UPGRADE("thing.ota.upgrade", "OTA 固件信息推送", false), 67 OTA_UPGRADE("thing.ota.upgrade", "OTA 固件信息推送", false),
68 OTA_PROGRESS("thing.ota.progress", "OTA 升级进度上报", true), 68 OTA_PROGRESS("thing.ota.progress", "OTA 升级进度上报", true),
69 69
  70 + // ========== 设备影子 ==========
  71 +
  72 + SHADOW_UPDATE("thing.shadow.update", "设备影子更新", true),
  73 + SHADOW_GET("thing.shadow.get", "设备影子获取", false),
  74 +
70 ; 75 ;
71 76
72 public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod) 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,7 +82,16 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; {
77 */ 82 */
78 public static final Set<String> REPLY_DISABLED = SetUtils.asSet( 83 public static final Set<String> REPLY_DISABLED = SetUtils.asSet(
79 STATE_UPDATE.getMethod(), 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 private final String method; 97 private final String method;
@@ -100,4 +114,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; { @@ -100,4 +114,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable&lt;String&gt; {
100 return REPLY_DISABLED.contains(method); 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,6 +2,8 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream;
2 2
3 import cn.hutool.core.lang.Assert; 3 import cn.hutool.core.lang.Assert;
4 import cn.hutool.core.util.ArrayUtil; 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 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; 7 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; 8 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager;
7 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; 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,6 +11,10 @@ import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
9 import io.vertx.mqtt.MqttEndpoint; 11 import io.vertx.mqtt.MqttEndpoint;
10 import lombok.extern.slf4j.Slf4j; 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 * IoT 网关 MQTT 上行消息处理器:处理业务消息(属性上报、事件上报等) 20 * IoT 网关 MQTT 上行消息处理器:处理业务消息(属性上报、事件上报等)
@@ -59,13 +65,27 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { @@ -59,13 +65,27 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler {
59 } 65 }
60 66
61 // 2.1 反序列化消息 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 // 3. 处理业务消息 90 // 3. 处理业务消息
71 deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); 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,7 +2,9 @@ package cn.iocoder.yudao.module.iot.gateway.service.device.message;
2 2
3 import cn.hutool.core.lang.Assert; 3 import cn.hutool.core.lang.Assert;
4 import cn.hutool.core.util.StrUtil; 4 import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
5 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; 6 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
  7 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
6 import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; 8 import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum;
7 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; 9 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
8 import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; 10 import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
@@ -40,6 +42,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @@ -40,6 +42,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
40 @Override 42 @Override
41 public byte[] serializeDeviceMessage(IotDeviceMessage message, 43 public byte[] serializeDeviceMessage(IotDeviceMessage message,
42 String productKey, String deviceName) { 44 String productKey, String deviceName) {
  45 + // 影子透传:直接序列化 params 为原始线 JSON,忽略产品 serializeType
  46 + if (IotDeviceMessageMethodEnum.isPassthrough(message.getMethod())) {
  47 + return JsonUtils.toJsonByte(message.getParams());
  48 + }
43 // 1.1 获取设备信息 49 // 1.1 获取设备信息
44 IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); 50 IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName);
45 if (device == null) { 51 if (device == null) {
@@ -56,6 +62,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @@ -56,6 +62,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
56 @Override 62 @Override
57 public byte[] serializeDeviceMessage(IotDeviceMessage message, 63 public byte[] serializeDeviceMessage(IotDeviceMessage message,
58 IotSerializeTypeEnum serializeType) { 64 IotSerializeTypeEnum serializeType) {
  65 + // 影子透传
  66 + if (IotDeviceMessageMethodEnum.isPassthrough(message.getMethod())) {
  67 + return JsonUtils.toJsonByte(message.getParams());
  68 + }
59 // 1. 获取序列化器 69 // 1. 获取序列化器
60 IotMessageSerializer serializer = messageSerializerManager.get(serializeType); 70 IotMessageSerializer serializer = messageSerializerManager.get(serializeType);
61 if (serializer == null) { 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 +}