From 297206368eea385ee41fe3933d31e10bb5b6a7e8 Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Tue, 27 Oct 2026 13:25:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(B11):=20AI=20=E6=8C=87=E4=BB=A4=E7=A0=81?= =?UTF-8?q?=E4=BB=8E=E4=BA=A7=E5=93=81=E7=89=A9=E6=A8=A1=E5=9E=8B=E6=B4=BE?= =?UTF-8?q?=E7=94=9F,=20=E6=B6=88=E9=99=A4=E7=AC=AC=E4=B8=89=E5=A5=97?= =?UTF-8?q?=E6=8C=87=E4=BB=A4=E7=A0=81=E6=A1=A3=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构前: ai_device_profile 的指令码全靠品类猜(默认 switch_1/switch_led/control), 与 product_property 物模型、涂鸦 SDK schema 是三套独立来源, 必然漂移 —— 多开关位/带背光设备会拿到错误指令码。 重构后决策优先级: 1. 人工覆盖(source=manual) —— 用户显式指定, 最高优先 2. 产品物模型派生 —— semantic 标注的权威来源(后端 SpecSemanticResolver 落库) 3. 已存在的档案(探测写入/类型默认) —— 兼容历史数据 4. 品类推断兜底 —— 物模型缺失时 新增 ProductSpecMapper(只读投影): 查询产品物模型功能点(code/name/type/semantic/grp/access/dp_id) fromProductSpec 映射: - power -> powerCode (多开关位取 is_primary 通道) - switch -> powerCode 兜底 (物模型未标 power 时取首个可写开关通道) - brightness -> brightnessCode - color_temp -> colorTempCode - mode -> modeCode - temperature -> temperatureCode 派生结果同时写回 product_id(产品级共享), 不再逐设备重复存储 验证: mvn -pl iot-common,iot-device,iot-rule -am compile BUILD SUCCESS --- .../iot/rule/ai/AiDeviceProfileResolver.java | 154 ++++++++++++++++-- .../iot/rule/mapper/ProductSpecMapper.java | 28 ++++ 2 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 backend/iot-rule/src/main/java/com/iot/rule/mapper/ProductSpecMapper.java diff --git a/backend/iot-rule/src/main/java/com/iot/rule/ai/AiDeviceProfileResolver.java b/backend/iot-rule/src/main/java/com/iot/rule/ai/AiDeviceProfileResolver.java index 321b9e5..4025aa0 100644 --- a/backend/iot-rule/src/main/java/com/iot/rule/ai/AiDeviceProfileResolver.java +++ b/backend/iot-rule/src/main/java/com/iot/rule/ai/AiDeviceProfileResolver.java @@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -28,6 +29,7 @@ public class AiDeviceProfileResolver { private final AiDeviceProfileMapper aiDeviceProfileMapper; private final AiEntityResolver aiEntityResolver; + private final com.iot.rule.mapper.ProductSpecMapper productSpecMapper; /** 涂鸦亮度取值区间 */ private static final int TUYA_BRIGHT_MIN = 10; @@ -40,21 +42,34 @@ public class AiDeviceProfileResolver { * @param ownerId 用户ID */ public AiDeviceProfile resolve(AiDeviceView device, Long ownerId) { - AiDeviceProfile existing = null; - // 优先产品级档案(同类设备共享),否则回退设备级(兼容旧数据) - if (device.getProductId() != null) { - existing = aiDeviceProfileMapper.selectByProductAndOwner(device.getProductId(), ownerId); + // 决策优先级(重构后): + // 1. 人工覆盖(source=manual) —— 用户显式指定,最高优先 + // 2. 产品物模型派生 —— semantic 标注的权威来源 + // 3. 已存在的档案(探测写入/类型默认) —— 兼容历史数据 + // 4. 品类推断兜底 —— 物模型缺失时 + // + // 重构前只有 1/3/4 三档,指令码全靠品类猜(如默认 switch_1), + // 对多开关位/带背光设备会给出错误指令码 —— 这正是「第三套指令码档案」的危害。 + + // 1) 人工覆盖优先 + AiDeviceProfile manual = loadProfile(device, ownerId); + if (manual != null && AiDeviceProfile.SOURCE_MANUAL.equals(manual.getSource()) + && manual.getPowerCode() != null && !manual.getPowerCode().isBlank()) { + return manual; } - if (existing == null) { - existing = aiDeviceProfileMapper.selectOne( - new LambdaQueryWrapper() - .eq(AiDeviceProfile::getDeviceId, device.getDeviceId()) - .eq(AiDeviceProfile::getOwnerId, ownerId) - .last("limit 1")); + + // 2) 从产品物模型派生 + AiDeviceProfile fromSpec = fromProductSpec(device, ownerId); + if (fromSpec != null) { + return fromSpec; } - if (existing != null && existing.getPowerCode() != null && !existing.getPowerCode().isBlank()) { - return existing; + + // 3) 已存在的档案 + if (manual != null && manual.getPowerCode() != null && !manual.getPowerCode().isBlank()) { + return manual; } + + // 4) 品类推断兜底 AiDeviceProfile inferred = infer(device, ownerId); // 落库到产品级(同类设备共享),避免逐设备重复存储 if (device.getProductId() != null) { @@ -69,6 +84,121 @@ public class AiDeviceProfileResolver { return inferred; } + /** 读取已有档案:优先产品级,否则设备级(兼容旧数据) */ + private AiDeviceProfile loadProfile(AiDeviceView device, Long ownerId) { + if (device.getProductId() != null) { + AiDeviceProfile p = aiDeviceProfileMapper.selectByProductAndOwner(device.getProductId(), ownerId); + if (p != null) { + return p; + } + } + return aiDeviceProfileMapper.selectOne( + new LambdaQueryWrapper() + .eq(AiDeviceProfile::getDeviceId, device.getDeviceId()) + .eq(AiDeviceProfile::getOwnerId, ownerId) + .last("limit 1")); + } + + /** + * 从产品物模型派生 AI 指令码。 + * + *

物模型里的 semantic 由后端 SpecSemanticResolver 落库,是功能点语义的唯一权威: + * power → 电源指令码、brightness → 亮度、color_temp → 色温、mode → 模式、 + * temperature → 温度。多开关位设备取 is_primary 的那个通道。

+ * + * @return 派生的档案;物模型缺失或没有可用功能点时返回 null(走品类兜底) + */ + private AiDeviceProfile fromProductSpec(AiDeviceView device, Long ownerId) { + if (device.getProductId() == null) { + return null; + } + List> props; + try { + props = productSpecMapper.selectProperties(device.getProductId()); + } catch (Exception e) { + log.warn("读取产品物模型失败(不影响执行): productId={}, {}", device.getProductId(), e.getMessage()); + return null; + } + if (props == null || props.isEmpty()) { + return null; + } + String powerCode = null; + String brightnessCode = null; + String colorTempCode = null; + String modeCode = null; + String temperatureCode = null; + for (Map p : props) { + String semantic = str(p.get("semantic")); + String code = str(p.get("code")); + if (code.isEmpty()) { + continue; + } + String access = str(p.get("access")); + boolean writable = access.contains("write"); + if (semantic.isEmpty()) { + continue; + } + switch (semantic) { + case "power": + if (powerCode == null || writable) { + powerCode = code; + } + break; + case "switch": + // 开关通道:物模型未标 power 时,用首个可写开关通道兜底 + if (powerCode == null && writable) { + powerCode = code; + } + break; + case "brightness": + if (brightnessCode == null) { + brightnessCode = code; + } + break; + case "color_temp": + if (colorTempCode == null) { + colorTempCode = code; + } + break; + case "mode": + if (modeCode == null) { + modeCode = code; + } + break; + case "temperature": + if (temperatureCode == null) { + temperatureCode = code; + } + break; + default: + break; + } + } + if (powerCode == null && brightnessCode == null && colorTempCode == null) { + return null; + } + AiDeviceProfile profile = new AiDeviceProfile(); + profile.setDeviceId(device.getDeviceId()); + profile.setOwnerId(ownerId); + profile.setProductId(device.getProductId()); + profile.setPowerCode(powerCode); + profile.setBrightnessCode(brightnessCode); + profile.setColorTempCode(colorTempCode); + profile.setModeCode(modeCode); + profile.setTemperatureCode(temperatureCode); + profile.setPowerOnValue("true"); + profile.setPowerOffValue("false"); + profile.setSource(AiDeviceProfile.SOURCE_TYPE_DEFAULT); + profile.setEnabled(Boolean.TRUE); + log.info("AI指令码从产品物模型派生: deviceId={}, productId={}, powerCode={}", + device.getDeviceId(), device.getProductId(), powerCode); + return profile; + } + + private String str(Object v) { + return v == null ? "" : String.valueOf(v).trim(); + } + /** 按品类推断默认指令码 */ private AiDeviceProfile infer(AiDeviceView device, Long ownerId) { String type = aiEntityResolver.resolveDeviceType(device); diff --git a/backend/iot-rule/src/main/java/com/iot/rule/mapper/ProductSpecMapper.java b/backend/iot-rule/src/main/java/com/iot/rule/mapper/ProductSpecMapper.java new file mode 100644 index 0000000..c5d82f0 --- /dev/null +++ b/backend/iot-rule/src/main/java/com/iot/rule/mapper/ProductSpecMapper.java @@ -0,0 +1,28 @@ +package com.iot.rule.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 产品物模型功能点查询(供 AI 指令码从物模型派生)。 + * + *

产品物模型由 iot-device 侧的 SpecSemanticResolver 落库, + * 是「哪个功能点是什么语义」的唯一权威;AI 控制档案不再靠品类猜指令码。

+ */ +@Mapper +public interface ProductSpecMapper { + + /** + * 查询产品的全部功能点(含语义标签)。 + * + * @param productId 产品ID + * @return 每行含 code / name / type / semantic / grp / access + */ + @Select("SELECT code, name, type, semantic, grp, access, dp_id FROM product_property " + + "WHERE product_id = #{productId} AND deleted = 0 ORDER BY sort ASC") + List> selectProperties(@Param("productId") Long productId); +} \ No newline at end of file