From a932633c506fa58cb5b4abef52baa5fd6993003d Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Tue, 27 Oct 2026 12:08:44 +0800 Subject: [PATCH] =?UTF-8?q?refactor(A):=20=E4=BA=A7=E5=93=81/=E7=89=A9?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B/=E8=AE=BE=E5=A4=87=E4=B8=89=E5=B1=82?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E9=87=8D=E6=9E=84=20-=20=E5=9C=B0=E5=9F=BA?= =?UTF-8?q?=E6=89=B9=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - 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 通过 --- .../iot/common/constants/PlatformType.java | 100 ++++++++++ .../com/iot/device/adapter/DeviceAdapter.java | 11 ++ .../device/adapter/DeviceAdapterManager.java | 26 +-- .../adapter/impl/LocalDeviceAdapter.java | 5 + .../adapter/impl/TuyaDeviceAdapter.java | 5 + .../iot/device/collector/DevicePlatform.java | 38 ++-- .../sql/V22__product_device_arch_refactor.sql | 172 ++++++++++++++++++ 7 files changed, 334 insertions(+), 23 deletions(-) create mode 100644 backend/iot-common/src/main/java/com/iot/common/constants/PlatformType.java create mode 100644 backend/sql/V22__product_device_arch_refactor.sql diff --git a/backend/iot-common/src/main/java/com/iot/common/constants/PlatformType.java b/backend/iot-common/src/main/java/com/iot/common/constants/PlatformType.java new file mode 100644 index 0000000..71c9cef --- /dev/null +++ b/backend/iot-common/src/main/java/com/iot/common/constants/PlatformType.java @@ -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; + +/** + * 平台标识(单一真相源)。 + * + *

历史问题:platform 的取值域在四张表/四份代码里各写一套,互不兼容:

+ * + *

本枚举是 product / device / platform_type 三处的统一口径来源; + * platform_config.platform_code 表示"第三方集成配置"(涂鸦 OpenAPI / 讯飞 / 阿里云), + * 与设备接入平台不是同一维度,不在此枚举内。

+ * + *

另:zigbee / ble / bluetooth_mesh / wifi 是协议,不是平台, + * 存放于 device.protocol 与 product.connect_type,与 platform 正交。

+ */ +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 LEGACY_ALIAS; + + static { + Map 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 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 all() { + return List.of(values()); + } + + /** 判断是否为已知平台编码 */ + public static boolean isKnown(String code) { + return of(code).isPresent(); + } +} diff --git a/backend/iot-device/src/main/java/com/iot/device/adapter/DeviceAdapter.java b/backend/iot-device/src/main/java/com/iot/device/adapter/DeviceAdapter.java index 764e5ce..c9eef55 100644 --- a/backend/iot-device/src/main/java/com/iot/device/adapter/DeviceAdapter.java +++ b/backend/iot-device/src/main/java/com/iot/device/adapter/DeviceAdapter.java @@ -10,6 +10,17 @@ import java.util.List; */ public interface DeviceAdapter { + /** + * 本适配器负责的平台编码(与 {@code device.platform} / {@code product.platform} 同源)。 + * + *

适配器必须显式声明平台,适配器管理器按此字段精确匹配, + * 不再依赖"类名是否包含平台字符串"这种脆弱约定 + * (类名与平台码一旦不同名就会静默降级到 Local 适配器)。

+ * + * @return 平台编码,如 {@code tuya} / {@code local} + */ + String platform(); + /** * 设备连接 * @param deviceId 设备ID diff --git a/backend/iot-device/src/main/java/com/iot/device/adapter/DeviceAdapterManager.java b/backend/iot-device/src/main/java/com/iot/device/adapter/DeviceAdapterManager.java index be74ddb..439539a 100644 --- a/backend/iot-device/src/main/java/com/iot/device/adapter/DeviceAdapterManager.java +++ b/backend/iot-device/src/main/java/com/iot/device/adapter/DeviceAdapterManager.java @@ -1,5 +1,6 @@ package com.iot.device.adapter; +import com.iot.common.constants.PlatformType; import com.iot.common.exception.BusinessException; import com.iot.common.result.ResultCode; import com.iot.device.entity.Device; @@ -27,23 +28,26 @@ public class DeviceAdapterManager { private final Map adapterCache = new ConcurrentHashMap<>(); /** - * 根据平台类型获取适配器 + * 根据平台类型获取适配器。 + * + *

匹配规则:适配器显式声明的 {@link DeviceAdapter#platform()} 精确等于平台码; + * 平台码先经 {@link PlatformType#normalize(String)} 归一(miot→xiaomi 等 legacy 别名)。 + * 未命中时降级到 LOCAL 适配器(本地设备适配器是唯一兜底实现)。

*/ public DeviceAdapter getAdapter(String platform) { - return adapterCache.computeIfAbsent(platform, key -> { - // 先精确匹配平台对应的适配器 + String key = PlatformType.normalize(platform); + return adapterCache.computeIfAbsent(key, k -> { DeviceAdapter matched = adapters.stream() - .filter(adapter -> adapter.getClass().getSimpleName() - .toLowerCase().contains(key.toLowerCase())) + .filter(adapter -> k.equals(adapter.platform())) .findFirst() .orElse(null); if (matched != null) { return matched; } // 未找到时降级到 LocalDeviceAdapter 作为默认适配器 - log.warn("未找到平台[{}]的专用适配器,降级使用 LocalDeviceAdapter", key); + log.warn("未找到平台[{}]的专用适配器,降级使用 LocalDeviceAdapter", k); return adapters.stream() - .filter(a -> a.getClass().getSimpleName().contains("Local")) + .filter(a -> PlatformType.LOCAL.code().equals(a.platform())) .findFirst() .orElseThrow(() -> new BusinessException(ResultCode.PLATFORM_NOT_SUPPORTED)); }); @@ -69,14 +73,12 @@ public class DeviceAdapterManager { } /** - * 获取所有支持的平台 + * 获取所有支持的平台(由适配器显式声明)。 */ public List getSupportedPlatforms() { return adapters.stream() - .map(adapter -> { - String name = adapter.getClass().getSimpleName(); - return name.replace("DeviceAdapter", "").toLowerCase(); - }) + .map(DeviceAdapter::platform) + .distinct() .toList(); } } diff --git a/backend/iot-device/src/main/java/com/iot/device/adapter/impl/LocalDeviceAdapter.java b/backend/iot-device/src/main/java/com/iot/device/adapter/impl/LocalDeviceAdapter.java index e95d363..5e60b3a 100644 --- a/backend/iot-device/src/main/java/com/iot/device/adapter/impl/LocalDeviceAdapter.java +++ b/backend/iot-device/src/main/java/com/iot/device/adapter/impl/LocalDeviceAdapter.java @@ -21,6 +21,11 @@ public class LocalDeviceAdapter implements DeviceAdapter { private final Map deviceCredentials = new HashMap<>(); + @Override + public String platform() { + return com.iot.common.constants.PlatformType.LOCAL.code(); + } + @Override public void connect(String deviceId, DeviceConnectRequest.Credentials credentials) { log.info("连接本地设备: deviceId={}", deviceId); diff --git a/backend/iot-device/src/main/java/com/iot/device/adapter/impl/TuyaDeviceAdapter.java b/backend/iot-device/src/main/java/com/iot/device/adapter/impl/TuyaDeviceAdapter.java index f43761a..1ab92f3 100644 --- a/backend/iot-device/src/main/java/com/iot/device/adapter/impl/TuyaDeviceAdapter.java +++ b/backend/iot-device/src/main/java/com/iot/device/adapter/impl/TuyaDeviceAdapter.java @@ -31,6 +31,11 @@ public class TuyaDeviceAdapter implements DeviceAdapter { private final TuyaCloudService tuyaCloudService; private final ProductMapper productMapper; + @Override + public String platform() { + return com.iot.common.constants.PlatformType.TUYA.code(); + } + @Override public void connect(String deviceId, DeviceConnectRequest.Credentials credentials) { log.info("涂鸦设备连接(无状态, 走云端): deviceId={}", deviceId); diff --git a/backend/iot-device/src/main/java/com/iot/device/collector/DevicePlatform.java b/backend/iot-device/src/main/java/com/iot/device/collector/DevicePlatform.java index a832c9c..14b65f5 100644 --- a/backend/iot-device/src/main/java/com/iot/device/collector/DevicePlatform.java +++ b/backend/iot-device/src/main/java/com/iot/device/collector/DevicePlatform.java @@ -1,23 +1,39 @@ package com.iot.device.collector; +import com.iot.common.constants.PlatformType; + /** - * 设备平台标识(平台无关)。 - * 动态模型不绑定任何平台,平台差异只体现在「采集适配」层。 + * 采集适配层的平台标识。 + * + *

已委托给 {@link PlatformType}(平台维度的单一真相源), + * 避免采集层再维护一份与 device / product / platform_type 不一致的取值域。 + * 历史上本枚举的 MI 编码为 "mi",现已统一为规范码 "xiaomi" + * (对应 legacy 别名 miot / mi)。

*/ public enum DevicePlatform { - TUYA("tuya"), - MI("mi"), - HUAWEI("huawei"), - MATTER("matter"), - LOCAL("local"); + TUYA(PlatformType.TUYA), + MI(PlatformType.XIAOMI), + HUAWEI(PlatformType.HUAWEI), + MATTER(PlatformType.MATTER), + LOCAL(PlatformType.LOCAL); - private final String code; + private final PlatformType delegate; - DevicePlatform(String code) { - this.code = code; + DevicePlatform(PlatformType delegate) { + this.delegate = delegate; } + /** + * 规范平台编码(与 device.platform / product.platform 同源)。 + */ public String code() { - return code; + return delegate.code(); + } + + /** + * 平台显示名。 + */ + public String label() { + return delegate.label(); } } diff --git a/backend/sql/V22__product_device_arch_refactor.sql b/backend/sql/V22__product_device_arch_refactor.sql new file mode 100644 index 0000000..d89b583 --- /dev/null +++ b/backend/sql/V22__product_device_arch_refactor.sql @@ -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$$ \ No newline at end of file