fix(E): 修复 iot-device 启动失败 + V22 迁移脚本重写为可执行版本

【生产故障与修复】
iot-device 部署后反复重启失败(status=1/FAILURE, restart counter 3+):
  UnsatisfiedDependencyException: No qualifying bean of type
  'com.iot.device.mapper.ProductActionMapper' available
根因: iot-device 模块【没有 @MapperScan】(仅 iot-rule 有), Mapper 靠
      @Mapper 注解被 mybatis-spring-boot-starter 自动扫描;
      新建的 ProductEventMapper / ProductActionMapper 漏写 @Mapper 注解,
      导致扫描不到 -> ControlSchemaServiceImpl 注入失败 -> 启动失败。
修复: 两个 Mapper 补 @Mapper 注解; 已全量核查 iot-device 31 个 Mapper 均有该注解。

【V22 迁移脚本重写】(实测发现原写法在本环境根本执行不了)
- 原写法使用存储过程 + DELIMITER, 实测三种执行方式(管道/文件重定向/source)
  全部失败: MySQL 客户端在 batch 模式会把 DELIMITER 当普通 SQL 发给服务器。
  生产库现存存储过程数为 0, 佐证历史 V19/V21 等含 DELIMITER 的脚本
  其存储过程部分从未生效(仅 ADD COLUMN 等语句生效)。
- 重写为零存储过程、零 DELIMITER 的普通语句序列:
  加列幂等 -> PREPARE + information_schema 判定(ADD COLUMN 走 PREPARE 实测可用)
  索引     -> 直接 DDL(ADD INDEX 走 PREPARE 实测报 1064, 直接执行可用)
- 库名统一用 DATABASE() 判定, 测试库克隆无需改写脚本
- 清理顺序调整: 唯一索引统一放在软删重复行清理之后, 避免索引创建失败

【生产验证】(R730 192.168.3.3)
- 全库备份: /opt/iotsaas/backup/iot_platform_before_v22.sql
- 克隆库演练: 建克隆库 -> 跑 V22 -> 校验 -> DROP, 生产数据零风险
- 正式执行: platform_type 8行 / product.protocol / product_property.grp,
  is_primary, enum_label / product_action.completion_mode 全部就位
- 唯一索引: uk_platform_product / uk_product_code(x3) / idx_product_id 全部就位
- 数据完整性: product=11 device=13, 与迁移前一致
- 数据清洗: 脏 model/manufacturer=0, 空串 platform_product_id=0, device.miot=0
- tracking 表已标记 V22, 避免 deploy 脚本重复执行

【部署脚本加固】
deploy-all.bat 迁移步骤加 --force: 迁移脚本中'已存在'的 DDL(重复列/索引)
属正常情况, 不应中断整体部署

验证: 4 服务全部 active; 后台首页/SPA路由/9081文档/9083文档 均 200;
产品列表 API 返回 401 未认证(路由与过滤器正常)
This commit is contained in:
2995840376 2026-10-27 13:49:15 +08:00
parent 297206368e
commit 6e3f3ea661
4 changed files with 84 additions and 149 deletions

View File

@ -3,8 +3,10 @@ package com.iot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.iot.device.entity.ProductAction; import com.iot.device.entity.ProductAction;
import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@Mapper
public interface ProductActionMapper extends BaseMapper<ProductAction> { public interface ProductActionMapper extends BaseMapper<ProductAction> {
/** /**

View File

@ -3,8 +3,10 @@ package com.iot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.iot.device.entity.ProductEvent; import com.iot.device.entity.ProductEvent;
import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@Mapper
public interface ProductEventMapper extends BaseMapper<ProductEvent> { public interface ProductEventMapper extends BaseMapper<ProductEvent> {
/** /**

View File

@ -1,73 +1,25 @@
-- ===================================================================== -- =====================================================================
-- V22 · 产品 / 物模型 / 设备 三层架构重构(地基) -- V22 · 产品 / 物模型 / 设备 三层架构重构(地基)
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- 背景(重构前的实测断点): -- 执行特性(重要,实测结论):
-- 1) platform 取值域四套并存:product(tuya/local/matter) / -- 本脚本【不使用存储过程、不使用 DELIMITER】,全部是普通语句序列。
-- device(tuya/miot/dueros/local/matter) / DevicePlatform 枚举 / -- 原因:MySQL 客户端在 batch 模式(管道 / 文件重定向 / source)下
-- platform_config(tuya/aliyun/xfyun),miot、dueros 设备无处归类; -- 会把 DELIMITER 当作普通 SQL 发给服务器,导致含存储过程的脚本
-- 2) 物模型两套并存:product_property(唯一被消费)与 device.schema_json -- 100% 执行失败(已在生产库克隆上实测复现)。生产库现存存储过程数为 0,
-- (中控屏上报后只写不读,属死数据); -- 即历史 V19/V21 等含 DELIMITER 的脚本其存储过程部分从未生效。
-- 3) product_property 无 (product_id, code) 唯一约束,saveSpec 全量替换 -- 幂等性改由 PREPARE + information_schema 判定实现,语义等价。
-- / saveProperty 增量 / reconcile-spec 物理删三条写路径语义不一; -- 库名一律用 DATABASE() 判定,测试库克隆无需改写脚本。
-- 4) product.model / manufacturer 被涂鸦 productId 污染 -- ---------------------------------------------------------------------
-- (resolveProductId 调用方参数错位),product_key 为随机 UUID 不可读; -- 本次动作(仅结构与数据清洗;业务语义回填由后端 SpecSemanticResolver
-- 5) device.product_id 无索引,而 OTA 按 product_id 物化目标设备 -> 全表扫; -- 单一权威实现,保证规则只有一份):
-- 6) V19 已建 product_property.semantic/channel/completionMode 三列,
-- 但 Entity / DTO 全无对应字段 -> 语义字段整链断裂。
--
-- 本次动作(仅结构与数据清洗,不做业务语义回填;语义回填由后端
-- SpecSemanticResolver 单一权威实现,保证规则只有一份):
-- A. 新建 platform_type 平台字典表(后台可维护,取代硬编码枚举) -- A. 新建 platform_type 平台字典表(后台可维护,取代硬编码枚举)
-- B. product:加 protocol 列;空串 platform_product_id -> NULL; -- B. product:加 protocol 列;platform_product_id 空串清洗为 NULL;
-- 清洗 model/manufacturer 脏数据;加 (platform, platform_product_id) 唯一约束 -- 清洗 model/manufacturer 脏数据;加平台唯一约束
-- C. product_property:加 grp / is_primary / enum_label; -- C. product_property:加 grp / is_primary / enum_label;加功能点唯一约束
-- 加 (product_id, code) 唯一约束 -- D. product_event / product_action:加功能点唯一约束;action 加 completion_mode
-- D. product_event / product_action:加 (product_id, code) 唯一约束;
-- product_action 加 completion_mode
-- E. device:加 product_id 索引;platform 归一(legacy 别名 + 空值兜底) -- 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) 平台字典表 ---------- -- ---------- 1) 平台字典表 ----------
CREATE TABLE IF NOT EXISTS platform_type ( CREATE TABLE IF NOT EXISTS platform_type (
id BIGINT NOT NULL COMMENT '主键', id BIGINT NOT NULL COMMENT '主键',
@ -100,108 +52,86 @@ VALUES
(2260000000000000007, 'alexa', 'Amazon Alexa', 'alexa', 'wifi', 0, 70, '待接入', 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) (2260000000000000008, 'google_home', 'Google Home', 'google_home', 'wifi', 0, 80, '待接入', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
name = VALUES(name), name = VALUES(name), legacy_codes = VALUES(legacy_codes), protocol = VALUES(protocol),
legacy_codes = VALUES(legacy_codes), sort = VALUES(sort), remark = VALUES(remark), update_time = NOW();
protocol = VALUES(protocol),
sort = VALUES(sort),
remark = VALUES(remark),
update_time = NOW();
-- ---------- 2) product ---------- -- ---------- 2) product ----------
-- 协议维度(与 platform 正交):wifi / ble / zigbee / matter / mesh -- 2.1 协议列(与 platform 正交):wifi / ble / zigbee / matter / mesh
CALL v22_add_col('product', 'protocol', SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
'VARCHAR(32) DEFAULT NULL COMMENT ''联网协议(wifi/ble/zigbee/matter/mesh)'''); WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='product' AND COLUMN_NAME='protocol'),
'SELECT 1',
'ALTER TABLE product ADD COLUMN protocol VARCHAR(32) DEFAULT NULL COMMENT \'联网协议(wifi/ble/zigbee/matter/mesh)\''));
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
-- 空串会与唯一索引互撞(多个 '' 视为同值),统一清洗为 NULL -- 2.2 空串会与唯一索引互撞(多个空串视为同值),统一清洗为 NULL
UPDATE product SET platform_product_id = NULL WHERE platform_product_id = ''; UPDATE product SET platform_product_id = NULL WHERE platform_product_id = '';
-- 清洗历史脏数据:model / manufacturer 曾被误写入涂鸦 productId -- 2.3 清洗历史脏数据:model / manufacturer 曾被误写入涂鸦 productId
UPDATE product UPDATE product SET model = NULL, manufacturer = NULL
SET model = NULL, manufacturer = NULL
WHERE manufacturer = 'tuya' WHERE manufacturer = 'tuya'
AND model IS NOT NULL AND model IS NOT NULL AND model = platform_product_id;
AND model = platform_product_id;
-- 守卫:确认无重复后再加唯一约束(有重复时下面 ALTER 会直接报错中止) -- ---------- 3) product_property(物模型属性)----------
-- ---------- 3) product_property(物模型属性) ---------- -- 3.1 功能分组:支撑「多开关位整组联动」与详情页分组渲染
-- grp:功能分组(switch / scene / mode / countdown / display ...), SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
-- 支撑"多开关位一起控制"与详情页分组渲染 WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='product_property' AND COLUMN_NAME='grp'),
CALL v22_add_col('product_property', 'grp', 'SELECT 1',
'VARCHAR(32) DEFAULT NULL COMMENT ''功能分组(switch/scene/mode/countdown/display)'''); 'ALTER TABLE product_property ADD COLUMN grp VARCHAR(32) DEFAULT NULL COMMENT \'功能分组(switch/scene/mode/countdown/display)\''));
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
-- is_primary:主开关标记,取代按名称猜测(开关/电源/switch/power) -- 3.2 主开关标记:取代按名称猜测(开关/电源/switch/power)
CALL v22_add_col('product_property', 'is_primary', SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
'TINYINT NOT NULL DEFAULT 0 COMMENT ''是否主开关(1是,0否)'''); WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='product_property' AND COLUMN_NAME='is_primary'),
'SELECT 1',
'ALTER TABLE product_property ADD COLUMN is_primary TINYINT NOT NULL DEFAULT 0 COMMENT \'是否主开关(1是,0否)\''));
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
-- enum_label:枚举中文名 JSON(如 {"warm":"暖风"}),供 APP 与后台显示 -- 3.3 枚举中文名 JSON(如 {"warm":"暖风"}),供 APP 与后台显示
CALL v22_add_col('product_property', 'enum_label', SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
'TEXT DEFAULT NULL COMMENT ''枚举中文名(JSON对象)'''); WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='product_property' AND COLUMN_NAME='enum_label'),
'SELECT 1',
'ALTER TABLE product_property ADD COLUMN enum_label TEXT DEFAULT NULL COMMENT \'枚举中文名(JSON对象)\''));
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
-- ---------- 4) product_event / product_action ----------
-- 4.1 completion_mode 属 action 维度(sync 同步完成 / ack_only 仅受理)
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='product_action' AND COLUMN_NAME='completion_mode'),
'SELECT 1',
'ALTER TABLE product_action ADD COLUMN completion_mode VARCHAR(16) DEFAULT \'SYNC\' COMMENT \'完成模式(sync/ack_only)\''));
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
-- ---------- 4) product_event ---------- -- ---------- 5) device ----------
-- 5.1 product_id 索引:OTA 按 product_id 物化目标设备,此前为全表扫
ALTER TABLE device ADD INDEX idx_product_id (product_id);
-- ---------- 5) product_action ---------- -- ---------- 6) platform 归一 ----------
-- completion_mode 属 action 维度(sync 同步完成 / ack_only 仅受理) -- 6.1 legacy 别名 -> 规范码
CALL v22_add_col('product_action', 'completion_mode',
'VARCHAR(16) DEFAULT ''SYNC'' COMMENT ''完成模式(sync/ack_only)''');
-- ---------- 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'; UPDATE device SET platform = 'xiaomi' WHERE platform = 'miot';
-- platform 空值兜底为 local(无平台信息的中控屏本地设备) -- 6.2 platform 空值兜底为 local(无平台信息的中控屏本地设备)
UPDATE device SET platform = 'local' WHERE platform IS NULL OR platform = ''; UPDATE device SET platform = 'local' WHERE platform IS NULL OR platform = '';
-- 被误标为 local、但持有云平台产品ID 的设备 -> 归回涂鸦 -- 6.3 被误标为 local、但持有云平台产品ID 的设备 -> 归回涂鸦
UPDATE device SET platform = 'tuya' UPDATE device SET platform = 'tuya'
WHERE platform = 'local' WHERE platform = 'local'
AND platform_product_id IS NOT NULL AND platform_product_id IS NOT NULL AND platform_product_id <> '';
AND platform_product_id <> '';
-- ---------- 9) 唯一索引(统一放在软删重复行清理之后执行,避免创建失败)---------- -- ---------- 7) 唯一索引前的软删重复行清理 ----------
CALL v22_add_uniq('product', 'uk_platform_product', 'platform, platform_product_id, deleted'); -- 唯一索引 (product_id, code, deleted) 对「同 code 多条软删行」同样冲突
CALL v22_add_uniq('product_property', 'uk_product_code', 'product_id, code, deleted'); --(deleted 值相同=1)。物模型是声明式数据,多余软删行无价值,物理清理:
CALL v22_add_uniq('product_event', 'uk_product_code', 'product_id, code, deleted'); DELETE p FROM product_property p JOIN product_property k
CALL v22_add_uniq('product_action', 'uk_product_code', 'product_id, code, deleted'); ON k.product_id = p.product_id AND k.code = p.code AND k.id < p.id WHERE p.deleted = 1;
DELETE e FROM product_event e JOIN product_event k
-- ---------- 7) 清理辅助过程 ---------- ON k.product_id = e.product_id AND k.code = e.code AND k.id < e.id WHERE e.deleted = 1;
DROP PROCEDURE IF EXISTS v22_add_col$$ DELETE a FROM product_action a JOIN product_action k
DROP PROCEDURE IF EXISTS v22_add_idx$$ ON k.product_id = a.product_id AND k.code = a.code AND k.id < a.id WHERE a.deleted = 1;
DROP PROCEDURE IF EXISTS v22_add_uniq$$ DELETE p FROM product p JOIN product k
-- ---------- 8) 唯一索引前的软删重复行清理 ---------- ON k.platform = p.platform AND k.platform_product_id = p.platform_product_id
-- 唯一索引 (product_id, code, deleted) 对"同 code 多条软删行"同样会冲突 AND k.id < p.id WHERE p.deleted = 1 AND p.platform_product_id IS NOT NULL;
-- (deleted 值相同=1)。物模型是声明式数据,多余软删行无价值,此处物理清理:
-- 每个 (product_id, code) 只保留 id 最小的一条,其余物理删除。
DELETE p FROM product_property p
JOIN product_property k
ON k.product_id = p.product_id
AND k.code = p.code
AND k.id < p.id
WHERE p.deleted = 1;
DELETE e FROM product_event e
JOIN product_event k
ON k.product_id = e.product_id
AND k.code = e.code
AND k.id < e.id
WHERE e.deleted = 1;
DELETE a FROM product_action a
JOIN product_action k
ON k.product_id = a.product_id
AND k.code = a.code
AND k.id < a.id
WHERE a.deleted = 1;
DELETE p FROM product p
JOIN product k
ON k.platform = p.platform
AND k.platform_product_id = p.platform_product_id
AND k.id < p.id
WHERE p.deleted = 1
AND p.platform_product_id IS NOT NULL;
-- ---------- 8) 唯一索引(统一放在软删重复行清理之后,避免创建失败)----------
ALTER TABLE product ADD UNIQUE INDEX uk_platform_product (platform, platform_product_id, deleted);
ALTER TABLE product_property ADD UNIQUE INDEX uk_product_code (product_id, code, deleted);
ALTER TABLE product_event ADD UNIQUE INDEX uk_product_code (product_id, code, deleted);
ALTER TABLE product_action ADD UNIQUE INDEX uk_product_code (product_id, code, deleted);

View File

@ -116,7 +116,8 @@ if not "!CHK!"=="0" (
goto :eof goto :eof
) )
echo 应用 %1 ... echo 应用 %1 ...
type "%MIG_DIR%\%1" | %PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform" REM --force: 迁移脚本中"已存在"的 DDL(重复列/索引)属正常情况,不应中断整体部署
type "%MIG_DIR%\%1" | %PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --force -uroot -p'IoT.p2026.Xk9Qm' iot_platform"
if !ERRORLEVEL! NEQ 0 ( echo [错误] 迁移 %1 执行失败。 & pause & exit /b 1 ) if !ERRORLEVEL! NEQ 0 ( echo [错误] 迁移 %1 执行失败。 & pause & exit /b 1 )
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul %PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
goto :eof goto :eof