From fdf0e265fcecfec2d08c63cbab07240aac8d0717 Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Tue, 27 Oct 2026 12:39:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(B8-B9):=20=E5=B1=8F=E7=AB=AF=E7=89=A9?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=9B=9E=E6=B5=81=20+=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=BA=A7=20schema=20=E8=A6=86=E7=9B=96=E5=B1=82(=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E6=AD=BB=E6=95=B0=E6=8D=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: device.schema_json 是中控屏上报物模型的唯一落点, 但全仓只写不读, 属死数据; 同时屏端本地设备(zigbee/ble/matter)无平台产品, 产品级物模型里 也查不到东西, 导致这类设备拿不到 control-schema。 新增 PanelSpecAdoptService: - 归一化: 屏端通用协议类型(BOOLEAN/INTEGER/NUMBER/ENUM/STRING) -> 产品物模型类型(bool/int/float/enum/string); access(READ/WRITE/READ_WRITE) -> (read/write/read,write) - 回流: 已存在的属性只补齐/修正屏端明确提供的字段(不做破坏性覆盖), 不存在的补建; 产品级已有而屏端未报的属性保留(产品级是超集) - 语义: 屏端已上报 semantic/channel 直接落库, 缺失时由 SpecSemanticResolver 兜底推导 - toProperties(): 供无产品设备直接以设备级 schema 作为模型来源 PanelDeviceService: register / updateSchema 均触发回流 ControlSchemaServiceImpl.getDeviceSchema 改造: - 无 product_id 的设备: 用设备级 schema 构建控制模型(不再直接返回空) - 有 product_id: 产品基线 + 设备级覆盖层 merge 同 code 以设备级为准, 设备级独有的 code 追加 - 新增 mergeItem(): 仅覆盖屏端明确提供的字段 验证: mvn -pl iot-common,iot-device -am compile 通过 --- .../device/service/PanelDeviceService.java | 13 + .../impl/ControlSchemaServiceImpl.java | 153 ++++++++++- .../service/spec/PanelSpecAdoptService.java | 256 ++++++++++++++++++ 3 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 backend/iot-device/src/main/java/com/iot/device/service/spec/PanelSpecAdoptService.java diff --git a/backend/iot-device/src/main/java/com/iot/device/service/PanelDeviceService.java b/backend/iot-device/src/main/java/com/iot/device/service/PanelDeviceService.java index 15c5799..8ac4580 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/PanelDeviceService.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/PanelDeviceService.java @@ -9,6 +9,7 @@ import com.iot.device.entity.Device; import com.iot.device.entity.PanelTerminal; import com.iot.device.mapper.DeviceMapper; import com.iot.device.mapper.PanelTerminalMapper; +import com.iot.device.service.spec.PanelSpecAdoptService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -28,6 +29,7 @@ public class PanelDeviceService { private final DeviceMapper deviceMapper; private final PanelTerminalMapper panelTerminalMapper; + private final PanelSpecAdoptService panelSpecAdoptService; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -76,6 +78,12 @@ public class PanelDeviceService { } deviceMapper.insert(device); + // 屏端物模型回流到产品物模型:否则 device.schema_json 只是无人读取的死数据, + // 且屏端本地设备(zigbee/ble/matter)没有平台产品也拿不到 control-schema。 + if (registration.getProductId() != null) { + panelSpecAdoptService.adopt(registration.getProductId(), registration.getSchema()); + } + log.info("本地设备登记成功: panelId={}, localDeviceId={}, deviceId={}", panelId, registration.getLocalDeviceId(), device.getDeviceId()); return toResult(device, 1); @@ -103,6 +111,11 @@ public class PanelDeviceService { device.setSchemaJson(toJson(request.getSchema())); deviceMapper.updateById(device); + // 物模型更新后同步回流到产品物模型 + if (device.getProductId() != null) { + panelSpecAdoptService.adopt(device.getProductId(), request.getSchema()); + } + PanelDeviceDto.SchemaUpdateResult result = new PanelDeviceDto.SchemaUpdateResult(); result.setDeviceId(device.getDeviceId()); result.setSchemaRevision(revision); diff --git a/backend/iot-device/src/main/java/com/iot/device/service/impl/ControlSchemaServiceImpl.java b/backend/iot-device/src/main/java/com/iot/device/service/impl/ControlSchemaServiceImpl.java index bd30a94..ed47ef2 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/impl/ControlSchemaServiceImpl.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/impl/ControlSchemaServiceImpl.java @@ -16,7 +16,9 @@ import com.iot.device.mapper.ProductActionMapper; import com.iot.device.mapper.ProductEventMapper; import com.iot.device.mapper.ProductMapper; import com.iot.device.mapper.ProductPropertyMapper; +import com.iot.device.dto.PanelDeviceDto; import com.iot.device.service.ControlSchemaService; +import com.iot.device.service.spec.PanelSpecAdoptService; import com.iot.device.service.spec.SpecSemanticResolver; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,6 +26,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -45,6 +48,7 @@ public class ControlSchemaServiceImpl implements ControlSchemaService { private final DeviceMapper deviceMapper; private final ObjectMapper objectMapper; private final SpecSemanticResolver semanticResolver; + private final PanelSpecAdoptService panelSpecAdoptService; @Override public ControlSchema getProductSchema(Long productId) { @@ -89,10 +93,18 @@ public class ControlSchemaServiceImpl implements ControlSchemaService { empty.setAvailable(false); Device device = deviceMapper.selectOne(new LambdaQueryWrapper() .eq(Device::getDeviceId, deviceId)); - if (device == null || device.getProductId() == null) { + if (device == null) { return empty; } - ControlSchema schema = getProductSchema(device.getProductId()); + ControlSchema schema; + if (device.getProductId() != null) { + schema = getProductSchema(device.getProductId()); + } else { + // 无产品归属的设备(如中控屏本地设备)→ 用设备级 schema 兜底 + schema = buildFromDeviceSchema(device); + } + // 设备级覆盖层 merge:产品基线之外,本设备特有的属性由 device.schema_json 承载 + schema = mergeDeviceOverrides(schema, device); if (!schema.isAvailable()) { return schema; } @@ -107,6 +119,143 @@ public class ControlSchemaServiceImpl implements ControlSchemaService { return schema; } + /** + * 无产品归属设备:直接以设备级 schema 构建控制模型。 + */ + private ControlSchema buildFromDeviceSchema(Device device) { + ControlSchema schema = new ControlSchema(); + schema.setProductId(null); + schema.setPlatform(device.getPlatform()); + schema.setSchemaRevision(device.getSchemaRevision()); + PanelDeviceDto.DeviceSchema ds = parseDeviceSchema(device.getSchemaJson()); + if (ds == null) { + schema.setAvailable(false); + return schema; + } + List writable = new ArrayList<>(); + List readOnly = new ArrayList<>(); + for (ProductProperty p : panelSpecAdoptService.toProperties(ds)) { + ControlItem item = toItem(p); + if (item.isWritable()) { + writable.add(item); + } else { + readOnly.add(item); + } + } + schema.setItems(writable); + schema.setReadOnly(readOnly); + schema.setMainSwitchCode(pickMainSwitch(writable)); + schema.setAvailable(!writable.isEmpty() || !readOnly.isEmpty()); + return schema; + } + + /** + * 设备级覆盖层 merge。 + * + *

产品基线是权威超集;设备级 schema_json 只补充/覆盖该设备特有的属性: + * 同 code 的属性以设备级为准(屏端更贴近该设备实际能力),设备级独有的 code 追加。

+ */ + private ControlSchema mergeDeviceOverrides(ControlSchema schema, Device device) { + if (device.getSchemaJson() == null || device.getSchemaJson().isBlank()) { + return schema; + } + PanelDeviceDto.DeviceSchema ds = parseDeviceSchema(device.getSchemaJson()); + if (ds == null) { + return schema; + } + List writable = schema.getItems() == null ? new ArrayList<>() : new ArrayList<>(schema.getItems()); + List readOnly = schema.getReadOnly() == null ? new ArrayList<>() : new ArrayList<>(schema.getReadOnly()); + Map byCode = new HashMap<>(); + for (ControlItem i : writable) { + byCode.put(i.getCode(), i); + } + for (ControlItem i : readOnly) { + byCode.put(i.getCode(), i); + } + for (ProductProperty p : panelSpecAdoptService.toProperties(ds)) { + ControlItem cur = byCode.get(p.getCode()); + if (cur == null) { + ControlItem item = toItem(p); + if (item.isWritable()) { + writable.add(item); + } else { + readOnly.add(item); + } + continue; + } + // 设备级覆盖:仅覆盖屏端明确提供的字段 + ControlItem upd = new ControlItem(); + upd.setCode(cur.getCode()); + boolean changed = false; + if (StringUtils.hasText(p.getName()) && !p.getName().equals(cur.getName())) { + upd.setName(p.getName()); + changed = true; + } + if (StringUtils.hasText(p.getSemantic()) && !p.getSemantic().equals(cur.getSemantic())) { + upd.setSemantic(p.getSemantic()); + changed = true; + } + if (StringUtils.hasText(p.getGrp()) && !p.getGrp().equals(cur.getGrp())) { + upd.setGrp(p.getGrp()); + changed = true; + } + if (changed) { + ControlItem merged = mergeItem(cur, upd); + if (merged.isWritable()) { + writable.remove(cur); + writable.add(merged); + } else { + readOnly.remove(cur); + readOnly.add(merged); + } + byCode.put(merged.getCode(), merged); + } + } + schema.setItems(writable); + schema.setReadOnly(readOnly); + schema.setMainSwitchCode(pickMainSwitch(writable)); + schema.setAvailable(!writable.isEmpty() || !readOnly.isEmpty() + || (schema.getActions() != null && !schema.getActions().isEmpty())); + return schema; + } + + /** 把覆盖字段合并到基线项上(null 表示不覆盖)。 */ + private ControlItem mergeItem(ControlItem base, ControlItem over) { + ControlItem r = new ControlItem(); + r.setCode(base.getCode()); + r.setName(over.getName() != null ? over.getName() : base.getName()); + r.setType(base.getType()); + r.setDpId(base.getDpId()); + r.setUnit(base.getUnit()); + r.setSort(base.getSort()); + r.setDefaultValue(base.getDefaultValue()); + r.setMin(base.getMin()); + r.setMax(base.getMax()); + r.setStep(base.getStep()); + r.setEnumValues(base.getEnumValues()); + r.setWritable(base.isWritable()); + r.setValue(base.getValue()); + r.setSemantic(over.getSemantic() != null ? over.getSemantic() : base.getSemantic()); + r.setGrp(over.getGrp() != null ? over.getGrp() : base.getGrp()); + r.setIsPrimary(base.getIsPrimary()); + r.setEnumLabels(base.getEnumLabels()); + r.setWidget(base.getWidget()); + return r; + } + + /** 解析设备级 schema_json。 */ + private PanelDeviceDto.DeviceSchema parseDeviceSchema(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readValue(json, PanelDeviceDto.DeviceSchema.class); + } catch (Exception e) { + log.warn("解析设备级 schema 失败: deviceSchema parse error: {}", e.getMessage()); + return null; + } + } + private List toActions(Long productId) { List actions = productActionMapper.selectList( new LambdaQueryWrapper().eq(ProductAction::getProductId, productId)); diff --git a/backend/iot-device/src/main/java/com/iot/device/service/spec/PanelSpecAdoptService.java b/backend/iot-device/src/main/java/com/iot/device/service/spec/PanelSpecAdoptService.java new file mode 100644 index 0000000..6b4f027 --- /dev/null +++ b/backend/iot-device/src/main/java/com/iot/device/service/spec/PanelSpecAdoptService.java @@ -0,0 +1,256 @@ +package com.iot.device.service.spec; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iot.device.dto.PanelDeviceDto; +import com.iot.device.entity.ProductProperty; +import com.iot.device.mapper.ProductPropertyMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 屏端物模型回流服务 —— 把中控屏上报的设备级标准控制模型回流到产品物模型。 + * + *

重构背景:屏端上报的 DeviceSchema 此前只写进 device.schema_json,全仓无人读取, + * 属典型死数据;与此同时,屏端本地设备(zigbee / ble / matter)没有平台产品, + * 在产品级物模型里也查不到任何东西 —— 导致这类设备拿不到 control-schema。

+ * + *

本服务的职责:

+ *
    + *
  1. 归一化:屏端用 BOOLEAN/INTEGER/READ_WRITE 这类通用协议类型, + * 产品物模型用 bool/int/read,write,需做映射;
  2. + *
  3. 回流:把归一化后的属性 merge 进产品物模型 —— 已存在的补齐/修正, + * 不存在的补建;产品级已有的、屏端没报的属性保留(产品级是超集);
  4. + *
  5. 语义:屏端已上报 semantic 与 channel,直接落库;缺失时由 + * SpecSemanticResolver 兜底推导。
  6. + *
+ * + *

回流后,设备级 schema_json 仍保留,但角色降级为设备级差异覆盖层: + * 产品基线之外的本设备特有属性由它承载,由 ControlSchemaServiceImpl merge 后下发客户端。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PanelSpecAdoptService { + + private final ProductPropertyMapper productPropertyMapper; + private final SpecSemanticResolver semanticResolver; + private final ObjectMapper objectMapper; + + /** 屏端通用协议类型 -> 产品物模型类型 */ + private static final Map TYPE_MAP = Map.of( + "boolean", "bool", + "integer", "int", + "number", "float", + "enum", "enum", + "string", "string", + "bool", "bool", + "int", "int", + "float", "float"); + + /** + * 将屏端上报的 schema 回流到产品物模型。 + * + * @param productId 目标产品ID(为空则跳过回流,仅保留设备级 schema) + * @param schema 屏端上报的设备级控制模型 + * @return 新建的属性数量 + */ + @Transactional + public int adopt(Long productId, PanelDeviceDto.DeviceSchema schema) { + if (productId == null || schema == null || schema.getProperties() == null + || schema.getProperties().isEmpty()) { + return 0; + } + List existing = productPropertyMapper.selectList( + new LambdaQueryWrapper().eq(ProductProperty::getProductId, productId)); + Map byCode = new HashMap<>(); + for (ProductProperty p : existing) { + byCode.put(p.getCode(), p); + } + + int created = 0; + int sort = existing.size(); + for (PanelDeviceDto.PropertySpec ps : schema.getProperties()) { + if (ps == null || !StringUtils.hasText(ps.getCode())) { + continue; + } + String code = ps.getCode().trim(); + String type = mapType(ps.getType()); + String access = mapAccess(ps); + String semantic = StringUtils.hasText(ps.getSemantic()) + ? ps.getSemantic().trim().toLowerCase(Locale.ROOT) + : null; + String grp = null; + + // 屏端未上报 semantic 时由统一规则推导 + if (!StringUtils.hasText(semantic)) { + SpecSemanticResolver.Semantic s = semanticResolver.resolve(code, ps.getName(), type, access); + semantic = s.getSemantic(); + grp = s.getGrp(); + access = s.isWritable() ? "write" : "read"; + } + + ProductProperty cur = byCode.get(code); + if (cur == null) { + ProductProperty np = new ProductProperty(); + np.setProductId(productId); + np.setCode(code); + np.setName(ps.getName()); + np.setType(type); + np.setAccess(access); + np.setSemantic(semantic); + np.setGrp(grp); + np.setChannel(ps.getChannel()); + np.setMin(numText(ps.getMin())); + np.setMax(numText(ps.getMax())); + np.setStep(numText(ps.getStep())); + np.setUnit(ps.getUnit()); + np.setDefaultValue(ps.getDefaultValue() == null ? null : String.valueOf(ps.getDefaultValue())); + np.setEnumList(enumToJson(ps.getEnumList())); + np.setSort(++sort); + productPropertyMapper.insert(np); + byCode.put(code, np); + created++; + } else { + // 已存在:只补齐/修正屏端明确提供的字段,不做破坏性覆盖 + ProductProperty upd = new ProductProperty(); + upd.setId(cur.getId()); + boolean changed = false; + if (StringUtils.hasText(ps.getName()) && !ps.getName().equals(cur.getName())) { + upd.setName(ps.getName()); + changed = true; + } + if (!StringUtils.hasText(cur.getSemantic()) && StringUtils.hasText(semantic)) { + upd.setSemantic(semantic); + changed = true; + } + if (!StringUtils.hasText(cur.getGrp()) && StringUtils.hasText(grp)) { + upd.setGrp(grp); + changed = true; + } + if (!StringUtils.hasText(cur.getChannel()) && StringUtils.hasText(ps.getChannel())) { + upd.setChannel(ps.getChannel()); + changed = true; + } + if (!StringUtils.hasText(cur.getEnumList()) && ps.getEnumList() != null) { + upd.setEnumList(enumToJson(ps.getEnumList())); + changed = true; + } + if (!StringUtils.hasText(cur.getMin()) && ps.getMin() != null) { + upd.setMin(numText(ps.getMin())); + changed = true; + } + if (!StringUtils.hasText(cur.getMax()) && ps.getMax() != null) { + upd.setMax(numText(ps.getMax())); + changed = true; + } + if (changed) { + productPropertyMapper.updateById(upd); + } + } + } + log.info("屏端物模型回流完成: productId={}, 新建={}, 屏端属性数={}", + productId, created, schema.getProperties().size()); + return created; + } + /** + * 屏端 access(READ/WRITE/READ_WRITE)-> 产品物模型 access(read/write/read,write)。 + */ + private String mapAccess(PanelDeviceDto.PropertySpec ps) { + String acc = ps.getAccess() == null ? null : ps.getAccess().trim().toUpperCase(Locale.ROOT); + if (acc == null || acc.isEmpty()) { + boolean r = ps.getReadable() == null || ps.getReadable(); + boolean w = ps.getWritable() != null && ps.getWritable(); + return accessOf(r, w); + } + return switch (acc) { + case "READ" -> "read"; + case "WRITE" -> "write"; + case "READ_WRITE" -> "read,write"; + default -> acc.toLowerCase(Locale.ROOT); + }; + } + + private String accessOf(boolean readable, boolean writable) { + if (readable && writable) { + return "read,write"; + } + return writable ? "write" : "read"; + } + + private String mapType(String t) { + if (t == null || t.isBlank()) { + return "bool"; + } + return TYPE_MAP.getOrDefault(t.trim().toLowerCase(Locale.ROOT), "string"); + } + + private String numText(BigDecimal v) { + return v == null ? null : v.stripTrailingZeros().toPlainString(); + } + + private String enumToJson(List enumList) { + if (enumList == null || enumList.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(enumList); + } catch (Exception e) { + log.warn("枚举序列化失败: {}", e.getMessage()); + return null; + } + } + + /** + * 把屏端设备级 schema 转成与产品物模型同构的属性列表。 + * 用于「设备没有产品」的场景,此时直接以设备级 schema 作为唯一模型来源。 + */ + public List toProperties(PanelDeviceDto.DeviceSchema schema) { + List list = new ArrayList<>(); + if (schema == null || schema.getProperties() == null) { + return list; + } + int sort = 1; + for (PanelDeviceDto.PropertySpec ps : schema.getProperties()) { + if (ps == null || !StringUtils.hasText(ps.getCode())) { + continue; + } + ProductProperty pp = new ProductProperty(); + pp.setCode(ps.getCode().trim()); + pp.setName(ps.getName()); + pp.setType(mapType(ps.getType())); + pp.setAccess(mapAccess(ps)); + pp.setSemantic(ps.getSemantic()); + pp.setChannel(ps.getChannel()); + pp.setMin(numText(ps.getMin())); + pp.setMax(numText(ps.getMax())); + pp.setStep(numText(ps.getStep())); + pp.setUnit(ps.getUnit()); + pp.setEnumList(enumToJson(ps.getEnumList())); + pp.setSort(sort++); + SpecSemanticResolver.Semantic s = semanticResolver.resolve( + pp.getCode(), pp.getName(), pp.getType(), pp.getAccess()); + if (!StringUtils.hasText(pp.getSemantic())) { + pp.setSemantic(s.getSemantic()); + } + if (!StringUtils.hasText(pp.getGrp())) { + pp.setGrp(s.getGrp()); + } + if (semanticResolver.isReadOnly(pp.getSemantic(), pp.getGrp(), pp.getCode())) { + pp.setAccess("read"); + } + list.add(pp); + } + return list; + } +} \ No newline at end of file