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} 时才解析产品,
+ * 导致产品被删除、设备错挂等情况永远无法自愈。现每次同步都校验:
+ *
+ * - 产品存在且其 platform_product_id 与设备一致 → 保持原关联;
+ * - 产品不存在(含已被逻辑删除)或平台产品ID不匹配 → 重新解析/创建产品并返回新ID。
+ *
+ */
+ 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++;