feat(B11): AI 指令码从产品物模型派生, 消除第三套指令码档案

重构前: 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
This commit is contained in:
2995840376 2026-10-27 13:25:32 +08:00
parent 10344e0b05
commit 297206368e
2 changed files with 170 additions and 12 deletions

View File

@ -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<AiDeviceProfile>()
.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<AiDeviceProfile>()
.eq(AiDeviceProfile::getDeviceId, device.getDeviceId())
.eq(AiDeviceProfile::getOwnerId, ownerId)
.last("limit 1"));
}
/**
* 从产品物模型派生 AI 指令码。
*
* <p>物模型里的 semantic 由后端 SpecSemanticResolver 落库,是功能点语义的唯一权威:
* power → 电源指令码、brightness → 亮度、color_temp → 色温、mode → 模式、
* temperature → 温度。多开关位设备取 is_primary 的那个通道。</p>
*
* @return 派生的档案;物模型缺失或没有可用功能点时返回 null(走品类兜底)
*/
private AiDeviceProfile fromProductSpec(AiDeviceView device, Long ownerId) {
if (device.getProductId() == null) {
return null;
}
List<Map<String, Object>> 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<String, Object> 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);

View File

@ -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 指令码从物模型派生)。
*
* <p>产品物模型由 iot-device 侧的 SpecSemanticResolver 落库,
* 是「哪个功能点是什么语义」的唯一权威;AI 控制档案不再靠品类猜指令码。</p>
*/
@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<Map<String, Object>> selectProperties(@Param("productId") Long productId);
}