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:
parent
3120af6464
commit
d6d9a94736
@ -2,8 +2,14 @@ package com.iot.device.mapper;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.iot.device.entity.ProductAction;
|
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> {
|
public interface ProductActionMapper extends BaseMapper<ProductAction> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 物理删除某产品的全部服务(绕过逻辑删除,用于全量替换,避免软删行与唯一索引冲突)
|
||||||
|
*/
|
||||||
|
@Delete("DELETE FROM product_action WHERE product_id = #{productId}")
|
||||||
|
int physicallyDeleteByProductId(@Param("productId") Long productId);
|
||||||
}
|
}
|
||||||
@ -2,8 +2,14 @@ package com.iot.device.mapper;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.iot.device.entity.ProductEvent;
|
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> {
|
public interface ProductEventMapper extends BaseMapper<ProductEvent> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 物理删除某产品的全部事件(绕过逻辑删除,用于全量替换,避免软删行与唯一索引冲突)
|
||||||
|
*/
|
||||||
|
@Delete("DELETE FROM product_event WHERE product_id = #{productId}")
|
||||||
|
int physicallyDeleteByProductId(@Param("productId") Long productId);
|
||||||
}
|
}
|
||||||
@ -185,20 +185,33 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void saveSpec(Long productId, ProductSpec spec) {
|
public void saveSpec(Long productId, ProductSpec spec) {
|
||||||
// 全量替换
|
// 全量替换:改用物理删除。
|
||||||
productPropertyMapper.delete(new LambdaQueryWrapper<ProductProperty>()
|
// 原因:物模型是声明式数据,逻辑删除会留下同 (product_id, code) 的软删行,
|
||||||
.eq(ProductProperty::getProductId, productId));
|
// 与 V22 新增的 (product_id, code, deleted) 唯一索引冲突 —— 连续保存两次即报错。
|
||||||
productEventMapper.delete(new LambdaQueryWrapper<ProductEvent>()
|
productPropertyMapper.physicallyDeleteByProductId(productId);
|
||||||
.eq(ProductEvent::getProductId, productId));
|
productEventMapper.physicallyDeleteByProductId(productId);
|
||||||
productActionMapper.delete(new LambdaQueryWrapper<ProductAction>()
|
productActionMapper.physicallyDeleteByProductId(productId);
|
||||||
.eq(ProductAction::getProductId, productId));
|
|
||||||
if (spec.getProperties() != null) {
|
if (spec.getProperties() != null) {
|
||||||
for (ProductProperty p : spec.getProperties()) {
|
for (ProductProperty p : spec.getProperties()) {
|
||||||
p.setId(null);
|
p.setId(null);
|
||||||
p.setProductId(productId);
|
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);
|
productPropertyMapper.insert(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 重新计算主开关(人工编辑后以人工标记优先,未标记时按语义规则补齐)
|
||||||
|
applyPrimary(productId);
|
||||||
if (spec.getEvents() != null) {
|
if (spec.getEvents() != null) {
|
||||||
for (ProductEvent e : spec.getEvents()) {
|
for (ProductEvent e : spec.getEvents()) {
|
||||||
e.setId(null);
|
e.setId(null);
|
||||||
@ -210,6 +223,9 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
for (ProductAction a : spec.getActions()) {
|
for (ProductAction a : spec.getActions()) {
|
||||||
a.setId(null);
|
a.setId(null);
|
||||||
a.setProductId(productId);
|
a.setProductId(productId);
|
||||||
|
if (!StringUtils.hasText(a.getCompletionMode())) {
|
||||||
|
a.setCompletionMode("SYNC");
|
||||||
|
}
|
||||||
productActionMapper.insert(a);
|
productActionMapper.insert(a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -457,6 +473,9 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 依据语义规则选出产品的主开关功能点,并回填 is_primary。
|
* 依据语义规则选出产品的主开关功能点,并回填 is_primary。
|
||||||
|
*
|
||||||
|
* <p><b>人工优先</b>:若产品内已存在人工标记的主开关(后台物模型编辑器勾选),
|
||||||
|
* 则保留人工选择不再自动改写;仅在无人工标记时才按语义规则自动补齐。</p>
|
||||||
*/
|
*/
|
||||||
private void applyPrimary(Long productId) {
|
private void applyPrimary(Long productId) {
|
||||||
List<ProductProperty> props = productPropertyMapper.selectList(
|
List<ProductProperty> props = productPropertyMapper.selectList(
|
||||||
@ -464,6 +483,12 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
if (props.isEmpty()) {
|
if (props.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 已有人工标记 -> 尊重人工选择
|
||||||
|
boolean manualMarked = props.stream()
|
||||||
|
.anyMatch(p -> p.getIsPrimary() != null && p.getIsPrimary() == 1);
|
||||||
|
if (manualMarked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
List<SpecSemanticResolver.ProductPropertyView> views = new ArrayList<>();
|
List<SpecSemanticResolver.ProductPropertyView> views = new ArrayList<>();
|
||||||
for (ProductProperty pp : props) {
|
for (ProductProperty pp : props) {
|
||||||
SpecSemanticResolver.Semantic sem = semanticResolver.resolve(
|
SpecSemanticResolver.Semantic sem = semanticResolver.resolve(
|
||||||
@ -475,12 +500,17 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
sem.isWritable(), pp.getSort()));
|
sem.isWritable(), pp.getSort()));
|
||||||
}
|
}
|
||||||
String primary = semanticResolver.pickPrimary(views);
|
String primary = semanticResolver.pickPrimary(views);
|
||||||
|
if (primary == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (ProductProperty pp : props) {
|
for (ProductProperty pp : props) {
|
||||||
int want = (primary != null && primary.equals(pp.getCode())) ? 1 : 0;
|
if (!primary.equals(pp.getCode())) {
|
||||||
if (pp.getIsPrimary() == null || pp.getIsPrimary() != want) {
|
continue;
|
||||||
|
}
|
||||||
|
if (pp.getIsPrimary() == null || pp.getIsPrimary() != 1) {
|
||||||
ProductProperty upd = new ProductProperty();
|
ProductProperty upd = new ProductProperty();
|
||||||
upd.setId(pp.getId());
|
upd.setId(pp.getId());
|
||||||
upd.setIsPrimary(want);
|
upd.setIsPrimary(1);
|
||||||
productPropertyMapper.updateById(upd);
|
productPropertyMapper.updateById(upd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,7 +123,6 @@ UPDATE product
|
|||||||
AND model = platform_product_id;
|
AND model = platform_product_id;
|
||||||
|
|
||||||
-- 守卫:确认无重复后再加唯一约束(有重复时下面 ALTER 会直接报错中止)
|
-- 守卫:确认无重复后再加唯一约束(有重复时下面 ALTER 会直接报错中止)
|
||||||
CALL v22_add_uniq('product', 'uk_platform_product', 'platform, platform_product_id, deleted');
|
|
||||||
-- ---------- 3) product_property(物模型属性) ----------
|
-- ---------- 3) product_property(物模型属性) ----------
|
||||||
-- grp:功能分组(switch / scene / mode / countdown / display ...),
|
-- 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',
|
CALL v22_add_col('product_property', 'enum_label',
|
||||||
'TEXT DEFAULT NULL COMMENT ''枚举中文名(JSON对象)''');
|
'TEXT DEFAULT NULL COMMENT ''枚举中文名(JSON对象)''');
|
||||||
|
|
||||||
CALL v22_add_uniq('product_property', 'uk_product_code', 'product_id, code, deleted');
|
|
||||||
|
|
||||||
-- ---------- 4) product_event ----------
|
-- ---------- 4) product_event ----------
|
||||||
CALL v22_add_uniq('product_event', 'uk_product_code', 'product_id, code, deleted');
|
|
||||||
|
|
||||||
-- ---------- 5) product_action ----------
|
-- ---------- 5) product_action ----------
|
||||||
-- completion_mode 属 action 维度(sync 同步完成 / ack_only 仅受理)
|
-- completion_mode 属 action 维度(sync 同步完成 / ack_only 仅受理)
|
||||||
CALL v22_add_col('product_action', 'completion_mode',
|
CALL v22_add_col('product_action', 'completion_mode',
|
||||||
'VARCHAR(16) DEFAULT ''SYNC'' COMMENT ''完成模式(sync/ack_only)''');
|
'VARCHAR(16) DEFAULT ''SYNC'' COMMENT ''完成模式(sync/ack_only)''');
|
||||||
|
|
||||||
CALL v22_add_uniq('product_action', 'uk_product_code', 'product_id, code, deleted');
|
|
||||||
|
|
||||||
-- ---------- 6) device ----------
|
-- ---------- 6) device ----------
|
||||||
-- product_id 索引:OTA 按 product_id 物化目标设备,此前为全表扫
|
-- 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 IS NOT NULL
|
||||||
AND platform_product_id <> '';
|
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) 清理辅助过程 ----------
|
-- ---------- 7) 清理辅助过程 ----------
|
||||||
DROP PROCEDURE IF EXISTS v22_add_col$$
|
DROP PROCEDURE IF EXISTS v22_add_col$$
|
||||||
DROP PROCEDURE IF EXISTS v22_add_idx$$
|
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;
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,20 @@
|
|||||||
<el-table-column prop="type" label="类型" width="70" />
|
<el-table-column prop="type" label="类型" width="70" />
|
||||||
<el-table-column prop="access" label="访问" width="110" />
|
<el-table-column prop="access" label="访问" width="110" />
|
||||||
<el-table-column prop="dpId" label="DP映射" width="80" />
|
<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">
|
<el-table-column label="范围" width="130">
|
||||||
<template #default="{ row }">{{ row.min ?? '' }} ~ {{ row.max ?? '' }} {{ row.unit ?? '' }}</template>
|
<template #default="{ row }">{{ row.min ?? '' }} ~ {{ row.max ?? '' }} {{ row.unit ?? '' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -120,6 +134,22 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="DP映射"><el-input v-model="propForm.dpId" placeholder="如涂鸦 DP 13" /></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.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.max" /></el-form-item>
|
||||||
<el-form-item label="步长"><el-input v-model="propForm.step" /></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 propIndex = ref(-1)
|
||||||
const propForm = reactive<ProductProperty>({ code: '', name: '' })
|
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) => {
|
const editProp = (index?: number) => {
|
||||||
propIndex.value = index === undefined ? -1 : index
|
propIndex.value = index === undefined ? -1 : index
|
||||||
Object.assign(propForm, index === undefined
|
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] })
|
: { ...spec.properties[index] })
|
||||||
propVisible.value = true
|
propVisible.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user