feat(B8-B9): 屏端物模型回流 + 设备级 schema 覆盖层(消除死数据)

问题: 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 通过
This commit is contained in:
2995840376 2026-10-27 12:39:23 +08:00
parent a8c2d005a8
commit fdf0e265fc
3 changed files with 420 additions and 2 deletions

View File

@ -9,6 +9,7 @@ import com.iot.device.entity.Device;
import com.iot.device.entity.PanelTerminal; import com.iot.device.entity.PanelTerminal;
import com.iot.device.mapper.DeviceMapper; import com.iot.device.mapper.DeviceMapper;
import com.iot.device.mapper.PanelTerminalMapper; import com.iot.device.mapper.PanelTerminalMapper;
import com.iot.device.service.spec.PanelSpecAdoptService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -28,6 +29,7 @@ public class PanelDeviceService {
private final DeviceMapper deviceMapper; private final DeviceMapper deviceMapper;
private final PanelTerminalMapper panelTerminalMapper; private final PanelTerminalMapper panelTerminalMapper;
private final PanelSpecAdoptService panelSpecAdoptService;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@ -76,6 +78,12 @@ public class PanelDeviceService {
} }
deviceMapper.insert(device); 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={}", log.info("本地设备登记成功: panelId={}, localDeviceId={}, deviceId={}",
panelId, registration.getLocalDeviceId(), device.getDeviceId()); panelId, registration.getLocalDeviceId(), device.getDeviceId());
return toResult(device, 1); return toResult(device, 1);
@ -103,6 +111,11 @@ public class PanelDeviceService {
device.setSchemaJson(toJson(request.getSchema())); device.setSchemaJson(toJson(request.getSchema()));
deviceMapper.updateById(device); deviceMapper.updateById(device);
// 物模型更新后同步回流到产品物模型
if (device.getProductId() != null) {
panelSpecAdoptService.adopt(device.getProductId(), request.getSchema());
}
PanelDeviceDto.SchemaUpdateResult result = new PanelDeviceDto.SchemaUpdateResult(); PanelDeviceDto.SchemaUpdateResult result = new PanelDeviceDto.SchemaUpdateResult();
result.setDeviceId(device.getDeviceId()); result.setDeviceId(device.getDeviceId());
result.setSchemaRevision(revision); result.setSchemaRevision(revision);

View File

@ -16,7 +16,9 @@ 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;
import com.iot.device.mapper.ProductPropertyMapper; import com.iot.device.mapper.ProductPropertyMapper;
import com.iot.device.dto.PanelDeviceDto;
import com.iot.device.service.ControlSchemaService; import com.iot.device.service.ControlSchemaService;
import com.iot.device.service.spec.PanelSpecAdoptService;
import com.iot.device.service.spec.SpecSemanticResolver; import com.iot.device.service.spec.SpecSemanticResolver;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -24,6 +26,7 @@ import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@ -45,6 +48,7 @@ public class ControlSchemaServiceImpl implements ControlSchemaService {
private final DeviceMapper deviceMapper; private final DeviceMapper deviceMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SpecSemanticResolver semanticResolver; private final SpecSemanticResolver semanticResolver;
private final PanelSpecAdoptService panelSpecAdoptService;
@Override @Override
public ControlSchema getProductSchema(Long productId) { public ControlSchema getProductSchema(Long productId) {
@ -89,10 +93,18 @@ public class ControlSchemaServiceImpl implements ControlSchemaService {
empty.setAvailable(false); empty.setAvailable(false);
Device device = deviceMapper.selectOne(new LambdaQueryWrapper<Device>() Device device = deviceMapper.selectOne(new LambdaQueryWrapper<Device>()
.eq(Device::getDeviceId, deviceId)); .eq(Device::getDeviceId, deviceId));
if (device == null || device.getProductId() == null) { if (device == null) {
return empty; 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()) { if (!schema.isAvailable()) {
return schema; return schema;
} }
@ -107,6 +119,143 @@ public class ControlSchemaServiceImpl implements ControlSchemaService {
return schema; 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<ControlItem> writable = new ArrayList<>();
List<ControlItem> 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。
*
* <p>产品基线是权威超集;设备级 schema_json 只补充/覆盖该设备特有的属性:
* 同 code 的属性以设备级为准(屏端更贴近该设备实际能力),设备级独有的 code 追加。</p>
*/
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<ControlItem> writable = schema.getItems() == null ? new ArrayList<>() : new ArrayList<>(schema.getItems());
List<ControlItem> readOnly = schema.getReadOnly() == null ? new ArrayList<>() : new ArrayList<>(schema.getReadOnly());
Map<String, ControlItem> 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<ControlSchema.ControlAction> toActions(Long productId) { private List<ControlSchema.ControlAction> toActions(Long productId) {
List<ProductAction> actions = productActionMapper.selectList( List<ProductAction> actions = productActionMapper.selectList(
new LambdaQueryWrapper<ProductAction>().eq(ProductAction::getProductId, productId)); new LambdaQueryWrapper<ProductAction>().eq(ProductAction::getProductId, productId));

View File

@ -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;
/**
* 屏端物模型回流服务 —— 把中控屏上报的设备级标准控制模型回流到产品物模型。
*
* <p>重构背景:屏端上报的 DeviceSchema 此前只写进 device.schema_json,全仓无人读取,
* 属典型死数据;与此同时,屏端本地设备(zigbee / ble / matter)没有平台产品,
* 在产品级物模型里也查不到任何东西 —— 导致这类设备拿不到 control-schema。</p>
*
* <p>本服务的职责:</p>
* <ol>
* <li>归一化:屏端用 BOOLEAN/INTEGER/READ_WRITE 这类通用协议类型,
* 产品物模型用 bool/int/read,write,需做映射;</li>
* <li>回流:把归一化后的属性 merge 进产品物模型 —— 已存在的补齐/修正,
* 不存在的补建;产品级已有的、屏端没报的属性保留(产品级是超集);</li>
* <li>语义:屏端已上报 semantic 与 channel,直接落库;缺失时由
* SpecSemanticResolver 兜底推导。</li>
* </ol>
*
* <p>回流后,设备级 schema_json 仍保留,但角色降级为设备级差异覆盖层:
* 产品基线之外的本设备特有属性由它承载,由 ControlSchemaServiceImpl merge 后下发客户端。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PanelSpecAdoptService {
private final ProductPropertyMapper productPropertyMapper;
private final SpecSemanticResolver semanticResolver;
private final ObjectMapper objectMapper;
/** 屏端通用协议类型 -> 产品物模型类型 */
private static final Map<String, String> 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<ProductProperty> existing = productPropertyMapper.selectList(
new LambdaQueryWrapper<ProductProperty>().eq(ProductProperty::getProductId, productId));
Map<String, ProductProperty> 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<Object> 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<ProductProperty> toProperties(PanelDeviceDto.DeviceSchema schema) {
List<ProductProperty> 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;
}
}