From d6d9a94736c5156c0f7be36084be2ded08f49490 Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Tue, 27 Oct 2026 13:19:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(C2-C4):=20=E7=89=A9=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E6=94=AF=E6=8C=81=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E7=BC=96=E8=BE=91=20+=20=E4=BF=AE=E5=A4=8D=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E6=92=9E=E5=94=AF=E4=B8=80=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpecEditor.vue: - 属性表新增 语义/分组/主开关 三列 - 属性编辑表单新增 semantic 下拉(17种标签, 与后端 SpecSemanticResolver 一一对应)、 grp 下拉(8种分组)、is_primary 开关 - 分组说明写明: switch 组的开关位支持整组联动(switch_1~4 一起开关) - 语义说明写明: 决定客户端如何渲染与能否作为主开关, 取代按名称猜测 后端 saveSpec 修复: - 全量替换由逻辑删除改为物理删除 原因: 逻辑删除留下同 (product_id, code) 的软删行, 与 V22 的 (product_id, code, deleted) 唯一索引冲突 —— 连续保存两次即报错 - 新增 ProductEventMapper/ProductActionMapper.physicallyDeleteByProductId - 语义缺失时自动推导(与 SpecSemanticResolver 规则一致) - 状态量被误标 write 时强制只读 - 保存后调用 applyPrimary 重算主开关 - action 缺省 completionMode=SYNC applyPrimary 语义修正: - 人工优先: 已存在人工标记(is_primary=1)则保留人工选择, 不自动改写 - 仅在无人工标记时按语义规则自动补齐 (reconcile 场景重建后无标记 -> 自动选; 后台编辑场景有人工标记 -> 尊重) V22 迁移顺序修正: - 唯一索引统一移到'软删重复行清理'之后执行, 避免索引创建即失败 - 新增软删重复行物理清理(同 (product_id, code) 只保留 id 最小的一条) - 索引语句去重 验证: mvn BUILD SUCCESS; vite build 通过(ProductDetail chunk 已更新) --- .../device/mapper/ProductActionMapper.java | 12 +++- .../iot/device/mapper/ProductEventMapper.java | 12 +++- .../service/impl/ProductServiceImpl.java | 50 ++++++++++++--- .../sql/V22__product_device_arch_refactor.sql | 45 +++++++++++-- .../views/Product/components/SpecEditor.vue | 63 ++++++++++++++++++- 5 files changed, 160 insertions(+), 22 deletions(-) diff --git a/backend/iot-device/src/main/java/com/iot/device/mapper/ProductActionMapper.java b/backend/iot-device/src/main/java/com/iot/device/mapper/ProductActionMapper.java index a5d2a74..5bf1bda 100644 --- a/backend/iot-device/src/main/java/com/iot/device/mapper/ProductActionMapper.java +++ b/backend/iot-device/src/main/java/com/iot/device/mapper/ProductActionMapper.java @@ -2,8 +2,14 @@ package com.iot.device.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.iot.device.entity.ProductAction; -import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; -@Mapper public interface ProductActionMapper extends BaseMapper { -} + + /** + * 物理删除某产品的全部服务(绕过逻辑删除,用于全量替换,避免软删行与唯一索引冲突) + */ + @Delete("DELETE FROM product_action WHERE product_id = #{productId}") + int physicallyDeleteByProductId(@Param("productId") Long productId); +} \ No newline at end of file diff --git a/backend/iot-device/src/main/java/com/iot/device/mapper/ProductEventMapper.java b/backend/iot-device/src/main/java/com/iot/device/mapper/ProductEventMapper.java index 46a8e26..47baeb2 100644 --- a/backend/iot-device/src/main/java/com/iot/device/mapper/ProductEventMapper.java +++ b/backend/iot-device/src/main/java/com/iot/device/mapper/ProductEventMapper.java @@ -2,8 +2,14 @@ package com.iot.device.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.iot.device.entity.ProductEvent; -import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; -@Mapper public interface ProductEventMapper extends BaseMapper { -} + + /** + * 物理删除某产品的全部事件(绕过逻辑删除,用于全量替换,避免软删行与唯一索引冲突) + */ + @Delete("DELETE FROM product_event WHERE product_id = #{productId}") + int physicallyDeleteByProductId(@Param("productId") Long productId); +} \ No newline at end of file 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 11bd441..bb23ca1 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 @@ -185,20 +185,33 @@ public class ProductServiceImpl implements ProductService { @Override @Transactional public void saveSpec(Long productId, ProductSpec spec) { - // 全量替换 - productPropertyMapper.delete(new LambdaQueryWrapper() - .eq(ProductProperty::getProductId, productId)); - productEventMapper.delete(new LambdaQueryWrapper() - .eq(ProductEvent::getProductId, productId)); - productActionMapper.delete(new LambdaQueryWrapper() - .eq(ProductAction::getProductId, productId)); + // 全量替换:改用物理删除。 + // 原因:物模型是声明式数据,逻辑删除会留下同 (product_id, code) 的软删行, + // 与 V22 新增的 (product_id, code, deleted) 唯一索引冲突 —— 连续保存两次即报错。 + productPropertyMapper.physicallyDeleteByProductId(productId); + productEventMapper.physicallyDeleteByProductId(productId); + productActionMapper.physicallyDeleteByProductId(productId); if (spec.getProperties() != null) { for (ProductProperty p : spec.getProperties()) { p.setId(null); p.setProductId(productId); + // 语义缺失时自动推导,保证与 SpecSemanticResolver 的规则一致 + SpecSemanticResolver.Semantic sem = semanticResolver.resolve( + p.getCode(), p.getName(), p.getType(), p.getAccess()); + if (!StringUtils.hasText(p.getSemantic())) { + p.setSemantic(sem.getSemantic()); + } + if (!StringUtils.hasText(p.getGrp())) { + p.setGrp(sem.getGrp()); + } + if (semanticResolver.isReadOnly(p.getSemantic(), p.getGrp(), p.getCode())) { + p.setAccess("read"); + } productPropertyMapper.insert(p); } } + // 重新计算主开关(人工编辑后以人工标记优先,未标记时按语义规则补齐) + applyPrimary(productId); if (spec.getEvents() != null) { for (ProductEvent e : spec.getEvents()) { e.setId(null); @@ -210,6 +223,9 @@ public class ProductServiceImpl implements ProductService { for (ProductAction a : spec.getActions()) { a.setId(null); a.setProductId(productId); + if (!StringUtils.hasText(a.getCompletionMode())) { + a.setCompletionMode("SYNC"); + } productActionMapper.insert(a); } } @@ -457,6 +473,9 @@ public class ProductServiceImpl implements ProductService { /** * 依据语义规则选出产品的主开关功能点,并回填 is_primary。 + * + *

人工优先:若产品内已存在人工标记的主开关(后台物模型编辑器勾选), + * 则保留人工选择不再自动改写;仅在无人工标记时才按语义规则自动补齐。

*/ private void applyPrimary(Long productId) { List props = productPropertyMapper.selectList( @@ -464,6 +483,12 @@ public class ProductServiceImpl implements ProductService { if (props.isEmpty()) { return; } + // 已有人工标记 -> 尊重人工选择 + boolean manualMarked = props.stream() + .anyMatch(p -> p.getIsPrimary() != null && p.getIsPrimary() == 1); + if (manualMarked) { + return; + } List views = new ArrayList<>(); for (ProductProperty pp : props) { SpecSemanticResolver.Semantic sem = semanticResolver.resolve( @@ -475,12 +500,17 @@ public class ProductServiceImpl implements ProductService { sem.isWritable(), pp.getSort())); } String primary = semanticResolver.pickPrimary(views); + if (primary == null) { + return; + } for (ProductProperty pp : props) { - int want = (primary != null && primary.equals(pp.getCode())) ? 1 : 0; - if (pp.getIsPrimary() == null || pp.getIsPrimary() != want) { + if (!primary.equals(pp.getCode())) { + continue; + } + if (pp.getIsPrimary() == null || pp.getIsPrimary() != 1) { ProductProperty upd = new ProductProperty(); upd.setId(pp.getId()); - upd.setIsPrimary(want); + upd.setIsPrimary(1); productPropertyMapper.updateById(upd); } } diff --git a/backend/sql/V22__product_device_arch_refactor.sql b/backend/sql/V22__product_device_arch_refactor.sql index d89b583..3e589e2 100644 --- a/backend/sql/V22__product_device_arch_refactor.sql +++ b/backend/sql/V22__product_device_arch_refactor.sql @@ -123,7 +123,6 @@ UPDATE product AND model = platform_product_id; -- 守卫:确认无重复后再加唯一约束(有重复时下面 ALTER 会直接报错中止) -CALL v22_add_uniq('product', 'uk_platform_product', 'platform, platform_product_id, deleted'); -- ---------- 3) product_property(物模型属性) ---------- -- grp:功能分组(switch / scene / mode / countdown / display ...), -- 支撑"多开关位一起控制"与详情页分组渲染 @@ -138,17 +137,14 @@ CALL v22_add_col('product_property', 'is_primary', CALL v22_add_col('product_property', 'enum_label', 'TEXT DEFAULT NULL COMMENT ''枚举中文名(JSON对象)'''); -CALL v22_add_uniq('product_property', 'uk_product_code', 'product_id, code, deleted'); -- ---------- 4) product_event ---------- -CALL v22_add_uniq('product_event', 'uk_product_code', 'product_id, code, deleted'); -- ---------- 5) product_action ---------- -- completion_mode 属 action 维度(sync 同步完成 / ack_only 仅受理) CALL v22_add_col('product_action', 'completion_mode', 'VARCHAR(16) DEFAULT ''SYNC'' COMMENT ''完成模式(sync/ack_only)'''); -CALL v22_add_uniq('product_action', 'uk_product_code', 'product_id, code, deleted'); -- ---------- 6) device ---------- -- product_id 索引:OTA 按 product_id 物化目标设备,此前为全表扫 @@ -166,7 +162,46 @@ UPDATE device SET platform = 'tuya' AND platform_product_id IS NOT NULL AND platform_product_id <> ''; +-- ---------- 9) 唯一索引(统一放在软删重复行清理之后执行,避免创建失败)---------- +CALL v22_add_uniq('product', 'uk_platform_product', 'platform, platform_product_id, deleted'); +CALL v22_add_uniq('product_property', 'uk_product_code', 'product_id, code, deleted'); +CALL v22_add_uniq('product_event', 'uk_product_code', 'product_id, code, deleted'); +CALL v22_add_uniq('product_action', 'uk_product_code', 'product_id, code, deleted'); + -- ---------- 7) 清理辅助过程 ---------- DROP PROCEDURE IF EXISTS v22_add_col$$ DROP PROCEDURE IF EXISTS v22_add_idx$$ -DROP PROCEDURE IF EXISTS v22_add_uniq$$ \ No newline at end of file +DROP PROCEDURE IF EXISTS v22_add_uniq$$ +-- ---------- 8) 唯一索引前的软删重复行清理 ---------- +-- 唯一索引 (product_id, code, deleted) 对"同 code 多条软删行"同样会冲突 +-- (deleted 值相同=1)。物模型是声明式数据,多余软删行无价值,此处物理清理: +-- 每个 (product_id, code) 只保留 id 最小的一条,其余物理删除。 +DELETE p FROM product_property p + JOIN product_property k + ON k.product_id = p.product_id + AND k.code = p.code + AND k.id < p.id + WHERE p.deleted = 1; + +DELETE e FROM product_event e + JOIN product_event k + ON k.product_id = e.product_id + AND k.code = e.code + AND k.id < e.id + WHERE e.deleted = 1; + +DELETE a FROM product_action a + JOIN product_action k + ON k.product_id = a.product_id + AND k.code = a.code + AND k.id < a.id + WHERE a.deleted = 1; + +DELETE p FROM product p + JOIN product k + ON k.platform = p.platform + AND k.platform_product_id = p.platform_product_id + AND k.id < p.id + WHERE p.deleted = 1 + AND p.platform_product_id IS NOT NULL; + diff --git a/frontend/admin/src/views/Product/components/SpecEditor.vue b/frontend/admin/src/views/Product/components/SpecEditor.vue index c9ad74f..d499d24 100644 --- a/frontend/admin/src/views/Product/components/SpecEditor.vue +++ b/frontend/admin/src/views/Product/components/SpecEditor.vue @@ -15,6 +15,20 @@ + + + + + + + @@ -120,6 +134,22 @@ + + + + +
决定客户端如何渲染与能否作为主开关,取代按名称猜测
+
+ + + + +
switch 组的开关位支持整组联动(如 switch_1~4 一起开关)
+
+ + + 详情页大圆钮使用;多开关位设备通常取首个通道 + @@ -231,10 +261,41 @@ const propVisible = ref(false) const propIndex = ref(-1) const propForm = reactive({ code: '', name: '' }) +// 语义选项(与后端 SpecSemanticResolver 的标签体系一一对应) +const SEMANTIC_OPTIONS = [ + { value: 'power', label: '主电源开关 (power)' }, + { value: 'switch', label: '开关通道 (switch)' }, + { value: 'backlight', label: '背光 (backlight)' }, + { value: 'indicator', label: '指示灯/氛围灯 (indicator)' }, + { value: 'brightness', label: '亮度 (brightness)' }, + { value: 'color_temp', label: '色温 (color_temp)' }, + { value: 'mode', label: '模式 (mode)' }, + { value: 'scene', label: '场景 (scene)' }, + { value: 'countdown', label: '倒计时/定时 (countdown)' }, + { value: 'child_lock', label: '童锁 (child_lock)' }, + { value: 'temperature', label: '温度 (temperature)' }, + { value: 'humidity', label: '湿度 (humidity)' }, + { value: 'battery', label: '电量 (battery)' }, + { value: 'motion', label: '人体/移动 (motion)' }, + { value: 'contact', label: '门磁 (contact)' }, + { value: 'fault', label: '故障/报警 (fault)' }, + { value: 'raw', label: '其他/未识别 (raw)' } +] +const GROUP_OPTIONS = [ + { value: 'switch', label: '开关通道组 (switch)' }, + { value: 'scene', label: '场景 (scene)' }, + { value: 'mode', label: '模式 (mode)' }, + { value: 'countdown', label: '倒计时 (countdown)' }, + { value: 'display', label: '显示/背光 (display)' }, + { value: 'config', label: '配置 (config)' }, + { value: 'measure', label: '测量值 (measure)' }, + { value: 'status', label: '状态量 (status)' } +] + const editProp = (index?: number) => { propIndex.value = index === undefined ? -1 : index Object.assign(propForm, index === undefined - ? { code: '', name: '', type: 'bool', access: 'read/write', sort: spec.properties.length } + ? { code: '', name: '', type: 'bool', access: 'read/write', sort: spec.properties.length, isPrimary: 0 } : { ...spec.properties[index] }) propVisible.value = true }