diff --git a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java index c050537..0923be5 100644 --- a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java +++ b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java @@ -354,12 +354,10 @@ public class TuyaController { update.setIcon(tuyaDevice.getIcon() != null ? tuyaDevice.getIcon() : existing.getIcon()); update.setRemark("涂鸦设备"); - if (existing.getProductId() == null) { - // model / manufacturer 传 null:涂鸦 OpenAPI 未提供真实型号与厂商, - // 历史误把 productId 填进 model 位,污染了 product 表的型号字段。 - update.setProductId(productService.resolveProductId("tuya", tuyaDevice.getProductId(), - null, null, tuyaDevice.getCategory(), tuyaDevice.getDeviceName())); - } + // 每次同步都校验产品关联(自愈):产品被删/错挂时自动纠正, + // 不再是「仅当 product_id 为空才解析」——后者会让孤儿设备永远无法回收。 + update.setProductId(productService.ensureDeviceProduct( + existing.getProductId(), "tuya", tuyaDevice.getProductId(), tuyaDevice.getCategory())); deviceMapper.updateById(update); count++; continue; @@ -383,8 +381,8 @@ public class TuyaController { localDevice.setPlatformProductId(tuyaDevice.getProductId()); localDevice.setIcon(tuyaDevice.getIcon()); localDevice.setRemark("涂鸦设备"); - localDevice.setProductId(productService.resolveProductId("tuya", tuyaDevice.getProductId(), - null, null, tuyaDevice.getCategory(), tuyaDevice.getDeviceName())); + localDevice.setProductId(productService.ensureDeviceProduct( + localDevice.getProductId(), "tuya", tuyaDevice.getProductId(), tuyaDevice.getCategory())); deviceMapper.insert(localDevice); count++; } diff --git a/backend/iot-device/src/main/java/com/iot/device/service/ProductService.java b/backend/iot-device/src/main/java/com/iot/device/service/ProductService.java index 8126e25..9ea3a9c 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/ProductService.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/ProductService.java @@ -43,6 +43,18 @@ public interface ProductService { Long resolveProductId(String platform, String platformProductId, String model, String manufacturer, String category, String name); + /** + * 确保设备关联到正确的产品(同步自愈)。 + * + *

重构前,涂鸦同步只在 {@code device.product_id IS NULL} 时才解析产品, + * 导致产品被删除、设备错挂等情况永远无法自愈。现每次同步都校验:

+ * + */ + Long ensureDeviceProduct(Long currentProductId, String platform, String platformProductId, String deviceType); + /** * 存量设备回填:按 model+platform 自动归类建产品并关联 device.product_id */ diff --git a/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductServiceImpl.java b/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductServiceImpl.java index c93b53f..12549d1 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductServiceImpl.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductServiceImpl.java @@ -5,13 +5,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.iot.common.constants.PlatformType; import java.util.ArrayList; +import com.iot.common.exception.BusinessException; +import com.iot.common.result.ResultCode; import com.iot.device.dto.ProductSpec; import com.iot.device.entity.Device; +import com.iot.device.entity.Firmware; +import com.iot.device.entity.OtaTask; import com.iot.device.entity.Product; import com.iot.device.entity.ProductAction; import com.iot.device.entity.ProductEvent; import com.iot.device.entity.ProductProperty; import com.iot.device.mapper.DeviceMapper; +import com.iot.device.mapper.FirmwareMapper; +import com.iot.device.mapper.OtaTaskMapper; import com.iot.device.mapper.ProductActionMapper; import com.iot.device.mapper.ProductEventMapper; import com.iot.device.mapper.ProductMapper; @@ -42,6 +48,8 @@ public class ProductServiceImpl implements ProductService { private final DeviceMapper deviceMapper; private final TuyaCloudService tuyaCloudService; private final SpecSemanticResolver semanticResolver; + private final FirmwareMapper firmwareMapper; + private final OtaTaskMapper otaTaskMapper; @Override @Transactional @@ -90,6 +98,26 @@ public class ProductServiceImpl implements ProductService { @Override @Transactional public void deleteProduct(Long id) { + // 重构:原实现直接删除,会留下 product_id 指向已删产品的孤儿设备。 + // 现做引用校验:设备/固件/OTA 任务任一引用该产品即拒绝删除。 + Long devCount = deviceMapper.selectCount( + new LambdaQueryWrapper().eq(Device::getProductId, id)); + if (devCount != null && devCount > 0) { + throw new BusinessException(ResultCode.FAILED, + "该产品下仍有 " + devCount + " 台设备,请先解除设备关联后再删除"); + } + Long fwCount = firmwareMapper.selectCount( + new LambdaQueryWrapper().eq(Firmware::getProductId, id)); + if (fwCount != null && fwCount > 0) { + throw new BusinessException(ResultCode.FAILED, + "该产品下仍有 " + fwCount + " 个固件版本,请先删除固件后再删除产品"); + } + Long otaCount = otaTaskMapper.selectCount( + new LambdaQueryWrapper().eq(OtaTask::getProductId, id)); + if (otaCount != null && otaCount > 0) { + throw new BusinessException(ResultCode.FAILED, + "该产品下仍有 " + otaCount + " 个 OTA 升级任务,请先处理后再删除产品"); + } productMapper.deleteById(id); } @@ -241,6 +269,24 @@ public class ProductServiceImpl implements ProductService { return np.getId(); } + /** + * 确保设备关联到正确的产品(同步自愈)。 + * + *

产品不存在(已被逻辑删除)或平台产品ID不匹配时重新解析, + * 使"产品被删 → 设备成孤儿 → 永不回收"的死结可自愈。

+ */ + @Override + @Transactional + public Long ensureDeviceProduct(Long currentProductId, String platform, String platformProductId, String deviceType) { + if (currentProductId != null && StringUtils.hasText(platformProductId)) { + Product p = productMapper.selectById(currentProductId); + if (p != null && platformProductId.trim().equals(p.getPlatformProductId())) { + return currentProductId; + } + } + return resolveProductId(platform, platformProductId, null, null, deviceType, null); + } + /** * 清洗型号/厂商入参:与平台产品ID相同、或等于平台编码的,判定为历史错位传入,置空。 */ @@ -261,19 +307,30 @@ public class ProductServiceImpl implements ProductService { @Override @Transactional public int reconcileDevices() { + // 重构:原先只处理 product_id IS NULL 的设备,导致两类问题无法自愈 —— + // 1) 产品被删除后,挂在该产品下的设备 product_id 成了孤儿引用,永远不会被修复; + // 2) 设备被错误关联到别的产品后,不会被纠正。 + // 现改为「全量对齐」:对每台有平台产品ID的设备,重新按 (platform, platform_product_id) + // 解析产品并校正 product_id;无平台产品ID的设备(纯本地手工设备)跳过。 List devices = deviceMapper.selectList( - new LambdaQueryWrapper().isNull(Device::getProductId)); + new LambdaQueryWrapper().isNotNull(Device::getPlatformProductId)); Map cache = new HashMap<>(); int cnt = 0; for (Device d : devices) { - String key = (d.getPlatform() == null ? "" : d.getPlatform()) + "|" - + (d.getPlatformProductId() == null ? "" : d.getPlatformProductId()); + if (!StringUtils.hasText(d.getPlatformProductId())) { + continue; + } + String key = PlatformType.normalize(d.getPlatform()) + "|" + + d.getPlatformProductId().trim(); Long pid = cache.get(key); if (pid == null) { pid = resolveProductId(d.getPlatform(), d.getPlatformProductId(), null, null, d.getDeviceType(), null); cache.put(key, pid); } + if (pid.equals(d.getProductId())) { + continue; + } d.setProductId(pid); deviceMapper.updateById(d); cnt++;