feat(C2-C4): 物模型编辑器支持语义编辑 + 修复全量替换撞唯一索引

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 已更新)
This commit is contained in:
2995840376 2026-10-27 13:19:08 +08:00
parent 3120af6464
commit d6d9a94736
5 changed files with 160 additions and 22 deletions

View File

@ -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<ProductAction> {
}
/**
* 物理删除某产品的全部服务(绕过逻辑删除,用于全量替换,避免软删行与唯一索引冲突)
*/
@Delete("DELETE FROM product_action WHERE product_id = #{productId}")
int physicallyDeleteByProductId(@Param("productId") Long productId);
}

View File

@ -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<ProductEvent> {
}
/**
* 物理删除某产品的全部事件(绕过逻辑删除,用于全量替换,避免软删行与唯一索引冲突)
*/
@Delete("DELETE FROM product_event WHERE product_id = #{productId}")
int physicallyDeleteByProductId(@Param("productId") Long productId);
}

View File

@ -185,20 +185,33 @@ public class ProductServiceImpl implements ProductService {
@Override
@Transactional
public void saveSpec(Long productId, ProductSpec spec) {
// 全量替换
productPropertyMapper.delete(new LambdaQueryWrapper<ProductProperty>()
.eq(ProductProperty::getProductId, productId));
productEventMapper.delete(new LambdaQueryWrapper<ProductEvent>()
.eq(ProductEvent::getProductId, productId));
productActionMapper.delete(new LambdaQueryWrapper<ProductAction>()
.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。
*
* <p><b>人工优先</b>:若产品内已存在人工标记的主开关(后台物模型编辑器勾选),
* 则保留人工选择不再自动改写;仅在无人工标记时才按语义规则自动补齐。</p>
*/
private void applyPrimary(Long productId) {
List<ProductProperty> 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<SpecSemanticResolver.ProductPropertyView> 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);
}
}

View File

@ -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$$
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;

View File

@ -15,6 +15,20 @@
<el-table-column prop="type" label="类型" width="70" />
<el-table-column prop="access" label="访问" width="110" />
<el-table-column prop="dpId" label="DP映射" width="80" />
<el-table-column label="语义" width="110">
<template #default="{ row }">
<el-tag v-if="row.semantic" size="small" :type="row.semantic === 'power' ? 'danger' : 'info'">
{{ row.semantic }}
</el-tag>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column prop="grp" label="分组" width="90" />
<el-table-column label="主开关" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.isPrimary === 1" size="small" type="danger">主</el-tag>
</template>
</el-table-column>
<el-table-column label="范围" width="130">
<template #default="{ row }">{{ row.min ?? '' }} ~ {{ row.max ?? '' }} {{ row.unit ?? '' }}</template>
</el-table-column>
@ -120,6 +134,22 @@
</el-select>
</el-form-item>
<el-form-item label="DP映射"><el-input v-model="propForm.dpId" placeholder="如涂鸦 DP 13" /></el-form-item>
<el-form-item label="功能语义">
<el-select v-model="propForm.semantic" placeholder="留空则自动推导" clearable filterable style="width: 100%">
<el-option v-for="s in SEMANTIC_OPTIONS" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<div class="muted">决定客户端如何渲染与能否作为主开关,取代按名称猜测</div>
</el-form-item>
<el-form-item label="功能分组">
<el-select v-model="propForm.grp" placeholder="留空则自动推导" clearable filterable style="width: 100%">
<el-option v-for="g in GROUP_OPTIONS" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
<div class="muted">switch 组的开关位支持整组联动(如 switch_1~4 一起开关)</div>
</el-form-item>
<el-form-item label="主开关">
<el-switch v-model="propForm.isPrimary" :active-value="1" :inactive-value="0" />
<span class="muted ml8">详情页大圆钮使用;多开关位设备通常取首个通道</span>
</el-form-item>
<el-form-item label="最小值"><el-input v-model="propForm.min" /></el-form-item>
<el-form-item label="最大值"><el-input v-model="propForm.max" /></el-form-item>
<el-form-item label="步长"><el-input v-model="propForm.step" /></el-form-item>
@ -231,10 +261,41 @@ const propVisible = ref(false)
const propIndex = ref(-1)
const propForm = reactive<ProductProperty>({ 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
}