refactor(A): 产品/物模型/设备三层架构重构 - 地基批次

新增:
- iot-common/PlatformType: 平台维度单一真相源(枚举)
  统一 tuya/matter/local/xiaomi/huawei/dueros/alexa/google_home,
  提供 legacy 别名归一(miot|mi -> xiaomi)与已知性判定
- backend/sql/V22__product_device_arch_refactor.sql: 幂等迁移(153行)
  A. platform_type 平台字典表 + 种子数据(后台可维护)
  B. product 加 protocol 列; platform_product_id 空串清洗为 NULL;
     清洗 model/manufacturer 被涂鸦 productId 污染的历史脏数据;
     加 (platform, platform_product_id, deleted) 唯一约束
  C. product_property 加 grp/is_primary/enum_label + (product_id,code) 唯一约束
  D. product_event/product_action 加 (product_id,code) 唯一约束;
     product_action 加 completion_mode
  E. device 加 product_id 索引(OTA 物化目标设备此前全表扫); platform 归一

改造:
- DeviceAdapter 新增 platform() 显式声明平台
- DeviceAdapterManager 改为按 platform() 精确匹配,
  移除'类名是否包含平台字符串'的脆弱约定(类名与平台码不同名会静默降级)
- DevicePlatform 委托 PlatformType, 消除第四套平台取值域

验证: mvn -pl iot-common,iot-device -am compile 通过
This commit is contained in:
2995840376 2026-10-27 12:08:44 +08:00
parent 0ce191c448
commit a932633c50
7 changed files with 334 additions and 23 deletions

View File

@ -0,0 +1,100 @@
package com.iot.common.constants;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 平台标识(单一真相源)。
*
* <p>历史问题:platform 的取值域在四张表/四份代码里各写一套,互不兼容:</p>
* <ul>
* <li>product.platform —— tuya / local / matter</li>
* <li>device.platform —— tuya / miot / dueros / local / matter</li>
* <li>DevicePlatform 枚举 —— tuya / mi / huawei / matter / local</li>
* <li>platform_config —— tuya / aliyun / xfyun</li>
* </ul>
* <p>本枚举是 product / device / platform_type 三处的统一口径来源;
* platform_config.platform_code 表示"第三方集成配置"(涂鸦 OpenAPI / 讯飞 / 阿里云),
* 与设备接入平台不是同一维度,不在此枚举内。</p>
*
* <p>另:zigbee / ble / bluetooth_mesh / wifi 是协议,不是平台,
* 存放于 device.protocol 与 product.connect_type,与 platform 正交。</p>
*/
public enum PlatformType {
TUYA("tuya", "涂鸦智能"),
MATTER("matter", "Matter"),
LOCAL("local", "本地协议"),
XIAOMI("xiaomi", "小米 IoT"),
HUAWEI("huawei", "华为 HiLink"),
DUEROS("dueros", "百度 DuerOS"),
ALEXA("alexa", "Amazon Alexa"),
GOOGLE_HOME("google_home", "Google Home");
/** 历史别名 → 规范码(只做同义改名映射) */
private static final Map<String, String> LEGACY_ALIAS;
static {
Map<String, String> m = new LinkedHashMap<>();
m.put("miot", "xiaomi");
m.put("mi", "xiaomi");
m.put("xiaomi_miot", "xiaomi");
m.put("matter_bridge", "matter");
m.put("tuya_cloud", "tuya");
LEGACY_ALIAS = Map.copyOf(m);
}
private final String code;
private final String label;
PlatformType(String code, String label) {
this.code = code;
this.label = label;
}
public String code() {
return code;
}
/**
* 平台显示名(注意:Enum.name() 是 final 的,不能用作显示名,故用 label)。
*/
public String label() {
return label;
}
/** 按编码取枚举(不归一 legacy 别名,未知返回 empty) */
public static Optional<PlatformType> of(String code) {
if (code == null || code.isBlank()) {
return Optional.empty();
}
String c = code.trim().toLowerCase();
return Arrays.stream(values()).filter(p -> p.code.equals(c)).findFirst();
}
/**
* 归一化任意平台字符串:去空格小写 → legacy 别名映射。
* 未知编码原样返回(不丢数据),空值归为 LOCAL。
*/
public static String normalize(String raw) {
if (raw == null || raw.isBlank()) {
return LOCAL.code;
}
String c = raw.trim().toLowerCase();
String mapped = LEGACY_ALIAS.get(c);
return mapped != null ? mapped : c;
}
/** 全部平台枚举(供后台下拉与字典表初始化使用) */
public static List<PlatformType> all() {
return List.of(values());
}
/** 判断是否为已知平台编码 */
public static boolean isKnown(String code) {
return of(code).isPresent();
}
}

View File

@ -10,6 +10,17 @@ import java.util.List;
*/ */
public interface DeviceAdapter { public interface DeviceAdapter {
/**
* 本适配器负责的平台编码(与 {@code device.platform} / {@code product.platform} 同源)。
*
* <p>适配器必须显式声明平台,适配器管理器按此字段精确匹配,
* 不再依赖"类名是否包含平台字符串"这种脆弱约定
* (类名与平台码一旦不同名就会静默降级到 Local 适配器)。</p>
*
* @return 平台编码,如 {@code tuya} / {@code local}
*/
String platform();
/** /**
* 设备连接 * 设备连接
* @param deviceId 设备ID * @param deviceId 设备ID

View File

@ -1,5 +1,6 @@
package com.iot.device.adapter; package com.iot.device.adapter;
import com.iot.common.constants.PlatformType;
import com.iot.common.exception.BusinessException; import com.iot.common.exception.BusinessException;
import com.iot.common.result.ResultCode; import com.iot.common.result.ResultCode;
import com.iot.device.entity.Device; import com.iot.device.entity.Device;
@ -27,23 +28,26 @@ public class DeviceAdapterManager {
private final Map<String, DeviceAdapter> adapterCache = new ConcurrentHashMap<>(); private final Map<String, DeviceAdapter> adapterCache = new ConcurrentHashMap<>();
/** /**
* 根据平台类型获取适配器 * 根据平台类型获取适配器。
*
* <p>匹配规则:适配器显式声明的 {@link DeviceAdapter#platform()} 精确等于平台码;
* 平台码先经 {@link PlatformType#normalize(String)} 归一(miot→xiaomi 等 legacy 别名)。
* 未命中时降级到 LOCAL 适配器(本地设备适配器是唯一兜底实现)。</p>
*/ */
public DeviceAdapter getAdapter(String platform) { public DeviceAdapter getAdapter(String platform) {
return adapterCache.computeIfAbsent(platform, key -> { String key = PlatformType.normalize(platform);
// 先精确匹配平台对应的适配器 return adapterCache.computeIfAbsent(key, k -> {
DeviceAdapter matched = adapters.stream() DeviceAdapter matched = adapters.stream()
.filter(adapter -> adapter.getClass().getSimpleName() .filter(adapter -> k.equals(adapter.platform()))
.toLowerCase().contains(key.toLowerCase()))
.findFirst() .findFirst()
.orElse(null); .orElse(null);
if (matched != null) { if (matched != null) {
return matched; return matched;
} }
// 未找到时降级到 LocalDeviceAdapter 作为默认适配器 // 未找到时降级到 LocalDeviceAdapter 作为默认适配器
log.warn("未找到平台[{}]的专用适配器,降级使用 LocalDeviceAdapter", key); log.warn("未找到平台[{}]的专用适配器,降级使用 LocalDeviceAdapter", k);
return adapters.stream() return adapters.stream()
.filter(a -> a.getClass().getSimpleName().contains("Local")) .filter(a -> PlatformType.LOCAL.code().equals(a.platform()))
.findFirst() .findFirst()
.orElseThrow(() -> new BusinessException(ResultCode.PLATFORM_NOT_SUPPORTED)); .orElseThrow(() -> new BusinessException(ResultCode.PLATFORM_NOT_SUPPORTED));
}); });
@ -69,14 +73,12 @@ public class DeviceAdapterManager {
} }
/** /**
* 获取所有支持的平台 * 获取所有支持的平台(由适配器显式声明)。
*/ */
public List<String> getSupportedPlatforms() { public List<String> getSupportedPlatforms() {
return adapters.stream() return adapters.stream()
.map(adapter -> { .map(DeviceAdapter::platform)
String name = adapter.getClass().getSimpleName(); .distinct()
return name.replace("DeviceAdapter", "").toLowerCase();
})
.toList(); .toList();
} }
} }

View File

@ -21,6 +21,11 @@ public class LocalDeviceAdapter implements DeviceAdapter {
private final Map<String, DeviceConnectRequest.Credentials> deviceCredentials = new HashMap<>(); private final Map<String, DeviceConnectRequest.Credentials> deviceCredentials = new HashMap<>();
@Override
public String platform() {
return com.iot.common.constants.PlatformType.LOCAL.code();
}
@Override @Override
public void connect(String deviceId, DeviceConnectRequest.Credentials credentials) { public void connect(String deviceId, DeviceConnectRequest.Credentials credentials) {
log.info("连接本地设备: deviceId={}", deviceId); log.info("连接本地设备: deviceId={}", deviceId);

View File

@ -31,6 +31,11 @@ public class TuyaDeviceAdapter implements DeviceAdapter {
private final TuyaCloudService tuyaCloudService; private final TuyaCloudService tuyaCloudService;
private final ProductMapper productMapper; private final ProductMapper productMapper;
@Override
public String platform() {
return com.iot.common.constants.PlatformType.TUYA.code();
}
@Override @Override
public void connect(String deviceId, DeviceConnectRequest.Credentials credentials) { public void connect(String deviceId, DeviceConnectRequest.Credentials credentials) {
log.info("涂鸦设备连接(无状态, 走云端): deviceId={}", deviceId); log.info("涂鸦设备连接(无状态, 走云端): deviceId={}", deviceId);

View File

@ -1,23 +1,39 @@
package com.iot.device.collector; package com.iot.device.collector;
import com.iot.common.constants.PlatformType;
/** /**
* 设备平台标识(平台无关)。 * 采集适配层的平台标识。
* 动态模型不绑定任何平台,平台差异只体现在「采集适配」层。 *
* <p>已委托给 {@link PlatformType}(平台维度的单一真相源),
* 避免采集层再维护一份与 device / product / platform_type 不一致的取值域。
* 历史上本枚举的 MI 编码为 "mi",现已统一为规范码 "xiaomi"
* (对应 legacy 别名 miot / mi)。</p>
*/ */
public enum DevicePlatform { public enum DevicePlatform {
TUYA("tuya"), TUYA(PlatformType.TUYA),
MI("mi"), MI(PlatformType.XIAOMI),
HUAWEI("huawei"), HUAWEI(PlatformType.HUAWEI),
MATTER("matter"), MATTER(PlatformType.MATTER),
LOCAL("local"); LOCAL(PlatformType.LOCAL);
private final String code; private final PlatformType delegate;
DevicePlatform(String code) { DevicePlatform(PlatformType delegate) {
this.code = code; this.delegate = delegate;
} }
/**
* 规范平台编码(与 device.platform / product.platform 同源)。
*/
public String code() { public String code() {
return code; return delegate.code();
}
/**
* 平台显示名。
*/
public String label() {
return delegate.label();
} }
} }

View File

@ -0,0 +1,172 @@
-- =====================================================================
-- V22 · 产品 / 物模型 / 设备 三层架构重构(地基)
-- ---------------------------------------------------------------------
-- 背景(重构前的实测断点):
-- 1) platform 取值域四套并存:product(tuya/local/matter) /
-- device(tuya/miot/dueros/local/matter) / DevicePlatform 枚举 /
-- platform_config(tuya/aliyun/xfyun),miot、dueros 设备无处归类;
-- 2) 物模型两套并存:product_property(唯一被消费)与 device.schema_json
-- (中控屏上报后只写不读,属死数据);
-- 3) product_property 无 (product_id, code) 唯一约束,saveSpec 全量替换
-- / saveProperty 增量 / reconcile-spec 物理删三条写路径语义不一;
-- 4) product.model / manufacturer 被涂鸦 productId 污染
-- (resolveProductId 调用方参数错位),product_key 为随机 UUID 不可读;
-- 5) device.product_id 无索引,而 OTA 按 product_id 物化目标设备 -> 全表扫;
-- 6) V19 已建 product_property.semantic/channel/completionMode 三列,
-- 但 Entity / DTO 全无对应字段 -> 语义字段整链断裂。
--
-- 本次动作(仅结构与数据清洗,不做业务语义回填;语义回填由后端
-- SpecSemanticResolver 单一权威实现,保证规则只有一份):
-- A. 新建 platform_type 平台字典表(后台可维护,取代硬编码枚举)
-- B. product:加 protocol 列;空串 platform_product_id -> NULL;
-- 清洗 model/manufacturer 脏数据;加 (platform, platform_product_id) 唯一约束
-- C. product_property:加 grp / is_primary / enum_label;
-- 加 (product_id, code) 唯一约束
-- D. product_event / product_action:加 (product_id, code) 唯一约束;
-- product_action 加 completion_mode
-- E. device:加 product_id 索引;platform 归一(legacy 别名 + 空值兜底)
--
-- 幂等说明:本脚本可重复执行(加列/加索引均由存储过程判存在性)。
-- 唯一约束前有重复检测,有重复时 ALTER 会直接报错中止,交人工合并
-- (不静默合并,避免设备错挂到错误产品)。
-- 执行方式:手工 mysql 客户端执行(本环境 Flyway 未启用,与 V3~V21 一致)。
-- =====================================================================
-- ---------- 0) 幂等辅助过程 ----------
DROP PROCEDURE IF EXISTS v22_add_col$$
CREATE PROCEDURE v22_add_col(IN tbl VARCHAR(64), IN col VARCHAR(64), IN def TEXT)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'iot_platform' AND TABLE_NAME = tbl AND COLUMN_NAME = col
) THEN
SET @s = CONCAT('ALTER TABLE ', tbl, ' ADD COLUMN ', col, ' ', def);
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
END IF;
END$$
DROP PROCEDURE IF EXISTS v22_add_idx$$
CREATE PROCEDURE v22_add_idx(IN tbl VARCHAR(64), IN idx VARCHAR(64), IN cols VARCHAR(255))
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'iot_platform' AND TABLE_NAME = tbl AND INDEX_NAME = idx
) THEN
SET @s = CONCAT('ALTER TABLE ', tbl, ' ADD INDEX ', idx, ' (', cols, ')');
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
END IF;
END$$
DROP PROCEDURE IF EXISTS v22_add_uniq$$
CREATE PROCEDURE v22_add_uniq(IN tbl VARCHAR(64), IN idx VARCHAR(64), IN cols VARCHAR(255))
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'iot_platform' AND TABLE_NAME = tbl AND INDEX_NAME = idx
) THEN
SET @s = CONCAT('ALTER TABLE ', tbl, ' ADD UNIQUE INDEX ', idx, ' (', cols, ')');
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
END IF;
END$$
-- ---------- 1) 平台字典表 ----------
CREATE TABLE IF NOT EXISTS platform_type (
id BIGINT NOT NULL COMMENT '主键',
code VARCHAR(32) NOT NULL COMMENT '平台编码(tuya/matter/local/xiaomi/huawei...)',
name VARCHAR(64) NOT NULL COMMENT '平台名称',
legacy_codes VARCHAR(255) DEFAULT NULL COMMENT '历史别名(逗号分隔)',
protocol VARCHAR(32) DEFAULT NULL COMMENT '默认协议(wifi/ble/zigbee/matter)',
enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
sort INT NOT NULL DEFAULT 0 COMMENT '排序',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
tenant_id BIGINT DEFAULT NULL,
create_time DATETIME DEFAULT NULL,
update_time DATETIME DEFAULT NULL,
deleted INT DEFAULT 0,
create_by BIGINT DEFAULT NULL,
update_by BIGINT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_platform_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台字典表';
INSERT INTO platform_type
(id, code, name, legacy_codes, protocol, enabled, sort, remark, create_time, update_time, deleted)
VALUES
(2260000000000000001, 'tuya', '涂鸦智能', 'tuya_cloud', 'wifi', 1, 10, '涂鸦 OpenAPI 接入', NOW(), NOW(), 0),
(2260000000000000002, 'matter', 'Matter', 'matter_bridge', 'matter', 1, 20, 'Matter 协议设备', NOW(), NOW(), 0),
(2260000000000000003, 'local', '本地协议', 'local', 'zigbee', 1, 30, '中控屏/网关本地执行', NOW(), NOW(), 0),
(2260000000000000004, 'xiaomi', '小米 IoT', 'miot,mi', 'wifi', 1, 40, '米家设备', NOW(), NOW(), 0),
(2260000000000000005, 'huawei', '华为 HiLink', 'huawei,harmony', 'wifi', 1, 50, '鸿蒙智联设备', NOW(), NOW(), 0),
(2260000000000000006, 'dueros', '百度 DuerOS', 'dueros', 'wifi', 0, 60, '待接入', NOW(), NOW(), 0),
(2260000000000000007, 'alexa', 'Amazon Alexa', 'alexa', 'wifi', 0, 70, '待接入', NOW(), NOW(), 0),
(2260000000000000008, 'google_home', 'Google Home', 'google_home', 'wifi', 0, 80, '待接入', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
legacy_codes = VALUES(legacy_codes),
protocol = VALUES(protocol),
sort = VALUES(sort),
remark = VALUES(remark),
update_time = NOW();
-- ---------- 2) product ----------
-- 协议维度(与 platform 正交):wifi / ble / zigbee / matter / mesh
CALL v22_add_col('product', 'protocol',
'VARCHAR(32) DEFAULT NULL COMMENT ''联网协议(wifi/ble/zigbee/matter/mesh)''');
-- 空串会与唯一索引互撞(多个 '' 视为同值),统一清洗为 NULL
UPDATE product SET platform_product_id = NULL WHERE platform_product_id = '';
-- 清洗历史脏数据:model / manufacturer 曾被误写入涂鸦 productId
UPDATE product
SET model = NULL, manufacturer = NULL
WHERE manufacturer = 'tuya'
AND model IS NOT NULL
AND model = platform_product_id;
-- 守卫:确认无重复后再加唯一约束(有重复时下面 ALTER 会直接报错中止)
CALL v22_add_uniq('product', 'uk_platform_product', 'platform, platform_product_id, deleted');
-- ---------- 3) product_property(物模型属性) ----------
-- grp:功能分组(switch / scene / mode / countdown / display ...),
-- 支撑"多开关位一起控制"与详情页分组渲染
CALL v22_add_col('product_property', 'grp',
'VARCHAR(32) DEFAULT NULL COMMENT ''功能分组(switch/scene/mode/countdown/display)''');
-- is_primary:主开关标记,取代按名称猜测(开关/电源/switch/power)
CALL v22_add_col('product_property', 'is_primary',
'TINYINT NOT NULL DEFAULT 0 COMMENT ''是否主开关(1是,0否)''');
-- enum_label:枚举中文名 JSON(如 {"warm":"暖风"}),供 APP 与后台显示
CALL v22_add_col('product_property', 'enum_label',
'TEXT DEFAULT NULL COMMENT ''枚举中文名(JSON对象)''');
CALL v22_add_uniq('product_property', 'uk_product_code', 'product_id, code, deleted');
-- ---------- 4) product_event ----------
CALL v22_add_uniq('product_event', 'uk_product_code', 'product_id, code, deleted');
-- ---------- 5) product_action ----------
-- completion_mode 属 action 维度(sync 同步完成 / ack_only 仅受理)
CALL v22_add_col('product_action', 'completion_mode',
'VARCHAR(16) DEFAULT ''SYNC'' COMMENT ''完成模式(sync/ack_only)''');
CALL v22_add_uniq('product_action', 'uk_product_code', 'product_id, code, deleted');
-- ---------- 6) device ----------
-- product_id 索引:OTA 按 product_id 物化目标设备,此前为全表扫
CALL v22_add_idx('device', 'idx_product_id', 'product_id');
-- platform 归一:legacy 别名 -> 规范码
UPDATE device SET platform = 'xiaomi' WHERE platform = 'miot';
-- platform 空值兜底为 local(无平台信息的中控屏本地设备)
UPDATE device SET platform = 'local' WHERE platform IS NULL OR platform = '';
-- 被误标为 local、但持有云平台产品ID 的设备 -> 归回涂鸦
UPDATE device SET platform = 'tuya'
WHERE platform = 'local'
AND platform_product_id IS NOT NULL
AND platform_product_id <> '';
-- ---------- 7) 清理辅助过程 ----------
DROP PROCEDURE IF EXISTS v22_add_col$$
DROP PROCEDURE IF EXISTS v22_add_idx$$
DROP PROCEDURE IF EXISTS v22_add_uniq$$