feat(B2-B4): 产品生命周期闭环 - 删除防孤儿 / reconcile 幂等全量 / 同步自愈
B2 deleteProduct 引用校验: - 原实现直接 deleteById, 会留下 product_id 指向已删产品的孤儿设备, 且 reconcile 只补 NULL 的设备 => 孤儿永远无法回收 - 现校验 device / firmware / ota_task 三类引用, 任一存在即拒绝删除并提示数量 (此前前端却提示'已关联设备的产品不可删除', 后端实际毫无校验) B3 reconcileDevices 幂等全量对齐: - 原: 仅处理 product_id IS NULL 的设备 - 现: 对所有有 platform_product_id 的设备按 (platform, platform_product_id) 重新解析产品并校正 product_id, 已对齐的跳过(不产生无谓写) - 无平台产品ID的纯本地手工设备跳过 B4 同步自愈 (新增 ProductService.ensureDeviceProduct): - TuyaController 同步路径由「仅当 product_id IS NULL 才解析」改为每次校验 - 产品不存在(含已逻辑删除)/平台产品ID不匹配 => 自动重新解析或创建产品 - 配合 B2 引用校验, 产品删除不再产生不可回收的孤儿 验证: mvn -pl iot-common,iot-device -am compile 通过
This commit is contained in:
parent
6461acecd1
commit
d7e34efb58
@ -354,12 +354,10 @@ public class TuyaController {
|
|||||||
update.setIcon(tuyaDevice.getIcon() != null
|
update.setIcon(tuyaDevice.getIcon() != null
|
||||||
? tuyaDevice.getIcon() : existing.getIcon());
|
? tuyaDevice.getIcon() : existing.getIcon());
|
||||||
update.setRemark("涂鸦设备");
|
update.setRemark("涂鸦设备");
|
||||||
if (existing.getProductId() == null) {
|
// 每次同步都校验产品关联(自愈):产品被删/错挂时自动纠正,
|
||||||
// model / manufacturer 传 null:涂鸦 OpenAPI 未提供真实型号与厂商,
|
// 不再是「仅当 product_id 为空才解析」——后者会让孤儿设备永远无法回收。
|
||||||
// 历史误把 productId 填进 model 位,污染了 product 表的型号字段。
|
update.setProductId(productService.ensureDeviceProduct(
|
||||||
update.setProductId(productService.resolveProductId("tuya", tuyaDevice.getProductId(),
|
existing.getProductId(), "tuya", tuyaDevice.getProductId(), tuyaDevice.getCategory()));
|
||||||
null, null, tuyaDevice.getCategory(), tuyaDevice.getDeviceName()));
|
|
||||||
}
|
|
||||||
deviceMapper.updateById(update);
|
deviceMapper.updateById(update);
|
||||||
count++;
|
count++;
|
||||||
continue;
|
continue;
|
||||||
@ -383,8 +381,8 @@ public class TuyaController {
|
|||||||
localDevice.setPlatformProductId(tuyaDevice.getProductId());
|
localDevice.setPlatformProductId(tuyaDevice.getProductId());
|
||||||
localDevice.setIcon(tuyaDevice.getIcon());
|
localDevice.setIcon(tuyaDevice.getIcon());
|
||||||
localDevice.setRemark("涂鸦设备");
|
localDevice.setRemark("涂鸦设备");
|
||||||
localDevice.setProductId(productService.resolveProductId("tuya", tuyaDevice.getProductId(),
|
localDevice.setProductId(productService.ensureDeviceProduct(
|
||||||
null, null, tuyaDevice.getCategory(), tuyaDevice.getDeviceName()));
|
localDevice.getProductId(), "tuya", tuyaDevice.getProductId(), tuyaDevice.getCategory()));
|
||||||
deviceMapper.insert(localDevice);
|
deviceMapper.insert(localDevice);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,18 @@ public interface ProductService {
|
|||||||
Long resolveProductId(String platform, String platformProductId, String model,
|
Long resolveProductId(String platform, String platformProductId, String model,
|
||||||
String manufacturer, String category, String name);
|
String manufacturer, String category, String name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保设备关联到正确的产品(同步自愈)。
|
||||||
|
*
|
||||||
|
* <p>重构前,涂鸦同步只在 {@code device.product_id IS NULL} 时才解析产品,
|
||||||
|
* 导致产品被删除、设备错挂等情况永远无法自愈。现每次同步都校验:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>产品存在且其 platform_product_id 与设备一致 → 保持原关联;</li>
|
||||||
|
* <li>产品不存在(含已被逻辑删除)或平台产品ID不匹配 → 重新解析/创建产品并返回新ID。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
Long ensureDeviceProduct(Long currentProductId, String platform, String platformProductId, String deviceType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存量设备回填:按 model+platform 自动归类建产品并关联 device.product_id
|
* 存量设备回填:按 model+platform 自动归类建产品并关联 device.product_id
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -5,13 +5,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.iot.common.constants.PlatformType;
|
import com.iot.common.constants.PlatformType;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
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.dto.ProductSpec;
|
||||||
import com.iot.device.entity.Device;
|
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.Product;
|
||||||
import com.iot.device.entity.ProductAction;
|
import com.iot.device.entity.ProductAction;
|
||||||
import com.iot.device.entity.ProductEvent;
|
import com.iot.device.entity.ProductEvent;
|
||||||
import com.iot.device.entity.ProductProperty;
|
import com.iot.device.entity.ProductProperty;
|
||||||
import com.iot.device.mapper.DeviceMapper;
|
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.ProductActionMapper;
|
||||||
import com.iot.device.mapper.ProductEventMapper;
|
import com.iot.device.mapper.ProductEventMapper;
|
||||||
import com.iot.device.mapper.ProductMapper;
|
import com.iot.device.mapper.ProductMapper;
|
||||||
@ -42,6 +48,8 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
private final DeviceMapper deviceMapper;
|
private final DeviceMapper deviceMapper;
|
||||||
private final TuyaCloudService tuyaCloudService;
|
private final TuyaCloudService tuyaCloudService;
|
||||||
private final SpecSemanticResolver semanticResolver;
|
private final SpecSemanticResolver semanticResolver;
|
||||||
|
private final FirmwareMapper firmwareMapper;
|
||||||
|
private final OtaTaskMapper otaTaskMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@ -90,6 +98,26 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteProduct(Long id) {
|
public void deleteProduct(Long id) {
|
||||||
|
// 重构:原实现直接删除,会留下 product_id 指向已删产品的孤儿设备。
|
||||||
|
// 现做引用校验:设备/固件/OTA 任务任一引用该产品即拒绝删除。
|
||||||
|
Long devCount = deviceMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<Device>().eq(Device::getProductId, id));
|
||||||
|
if (devCount != null && devCount > 0) {
|
||||||
|
throw new BusinessException(ResultCode.FAILED,
|
||||||
|
"该产品下仍有 " + devCount + " 台设备,请先解除设备关联后再删除");
|
||||||
|
}
|
||||||
|
Long fwCount = firmwareMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<Firmware>().eq(Firmware::getProductId, id));
|
||||||
|
if (fwCount != null && fwCount > 0) {
|
||||||
|
throw new BusinessException(ResultCode.FAILED,
|
||||||
|
"该产品下仍有 " + fwCount + " 个固件版本,请先删除固件后再删除产品");
|
||||||
|
}
|
||||||
|
Long otaCount = otaTaskMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<OtaTask>().eq(OtaTask::getProductId, id));
|
||||||
|
if (otaCount != null && otaCount > 0) {
|
||||||
|
throw new BusinessException(ResultCode.FAILED,
|
||||||
|
"该产品下仍有 " + otaCount + " 个 OTA 升级任务,请先处理后再删除产品");
|
||||||
|
}
|
||||||
productMapper.deleteById(id);
|
productMapper.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,6 +269,24 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
return np.getId();
|
return np.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保设备关联到正确的产品(同步自愈)。
|
||||||
|
*
|
||||||
|
* <p>产品不存在(已被逻辑删除)或平台产品ID不匹配时重新解析,
|
||||||
|
* 使"产品被删 → 设备成孤儿 → 永不回收"的死结可自愈。</p>
|
||||||
|
*/
|
||||||
|
@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相同、或等于平台编码的,判定为历史错位传入,置空。
|
* 清洗型号/厂商入参:与平台产品ID相同、或等于平台编码的,判定为历史错位传入,置空。
|
||||||
*/
|
*/
|
||||||
@ -261,19 +307,30 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int reconcileDevices() {
|
public int reconcileDevices() {
|
||||||
|
// 重构:原先只处理 product_id IS NULL 的设备,导致两类问题无法自愈 ——
|
||||||
|
// 1) 产品被删除后,挂在该产品下的设备 product_id 成了孤儿引用,永远不会被修复;
|
||||||
|
// 2) 设备被错误关联到别的产品后,不会被纠正。
|
||||||
|
// 现改为「全量对齐」:对每台有平台产品ID的设备,重新按 (platform, platform_product_id)
|
||||||
|
// 解析产品并校正 product_id;无平台产品ID的设备(纯本地手工设备)跳过。
|
||||||
List<Device> devices = deviceMapper.selectList(
|
List<Device> devices = deviceMapper.selectList(
|
||||||
new LambdaQueryWrapper<Device>().isNull(Device::getProductId));
|
new LambdaQueryWrapper<Device>().isNotNull(Device::getPlatformProductId));
|
||||||
Map<String, Long> cache = new HashMap<>();
|
Map<String, Long> cache = new HashMap<>();
|
||||||
int cnt = 0;
|
int cnt = 0;
|
||||||
for (Device d : devices) {
|
for (Device d : devices) {
|
||||||
String key = (d.getPlatform() == null ? "" : d.getPlatform()) + "|"
|
if (!StringUtils.hasText(d.getPlatformProductId())) {
|
||||||
+ (d.getPlatformProductId() == null ? "" : d.getPlatformProductId());
|
continue;
|
||||||
|
}
|
||||||
|
String key = PlatformType.normalize(d.getPlatform()) + "|"
|
||||||
|
+ d.getPlatformProductId().trim();
|
||||||
Long pid = cache.get(key);
|
Long pid = cache.get(key);
|
||||||
if (pid == null) {
|
if (pid == null) {
|
||||||
pid = resolveProductId(d.getPlatform(), d.getPlatformProductId(),
|
pid = resolveProductId(d.getPlatform(), d.getPlatformProductId(),
|
||||||
null, null, d.getDeviceType(), null);
|
null, null, d.getDeviceType(), null);
|
||||||
cache.put(key, pid);
|
cache.put(key, pid);
|
||||||
}
|
}
|
||||||
|
if (pid.equals(d.getProductId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
d.setProductId(pid);
|
d.setProductId(pid);
|
||||||
deviceMapper.updateById(d);
|
deviceMapper.updateById(d);
|
||||||
cnt++;
|
cnt++;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user