//! 数据库迁移 — 自动建表 use sqlx::MySqlPool; /// 全量建表语句(幂等:全部 `CREATE TABLE IF NOT EXISTS`)。 /// 抽成常量是为了让 [`run`] 保持可读(此前 280 行的函数里有 233 行是这段 SQL)。 const SCHEMA_SQL: &str = r#" CREATE TABLE IF NOT EXISTS organizations ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL COMMENT '组织名称', parent_id BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS projects ( id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, name VARCHAR(100) NOT NULL COMMENT '项目名称', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (organization_id) REFERENCES organizations(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS cabinets ( id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id BIGINT, abstract_id VARCHAR(20) NULL UNIQUE COMMENT '平台抽象ID,如10-00000001', imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI', iccid VARCHAR(20) COMMENT 'SIM卡ICCID', auth_str VARCHAR(8) COMMENT '签名安全码', auth_str_issued BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发', cell TINYINT DEFAULT 6 COMMENT '仓控板数量', work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式', name VARCHAR(100) COMMENT '柜子名称', status TINYINT DEFAULT 0 COMMENT '0离线 1在线', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (project_id) REFERENCES projects(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS sim_change_logs ( id BIGINT AUTO_INCREMENT PRIMARY KEY, imei VARCHAR(15) NOT NULL COMMENT '设备IMEI', old_iccid VARCHAR(20) COMMENT '旧ICCID', new_iccid VARCHAR(20) NOT NULL COMMENT '新ICCID', changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间', INDEX idx_imei (imei) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS batteries ( id BIGINT AUTO_INCREMENT PRIMARY KEY, battery_id VARCHAR(50) NOT NULL UNIQUE COMMENT '电池唯一标识', soc INT DEFAULT 0 COMMENT '电量(‰)', brand VARCHAR(50) COMMENT '品牌(由 brand_code 映射出的名称,代码表未定前为空)', model VARCHAR(50) COMMENT '型号(由 model_code 映射出的名称,代码表未定前为空)', sn VARCHAR(100) COMMENT '序列号', -- 以下为协议 §5.5「电池属性」原值(硬件代码,平台侧负责映射,见交付文档 §10 定稿第 7 条) brand_code VARCHAR(20) COMMENT '厂家代码(原值透传)', model_code VARCHAR(20) COMMENT '电池型号代码(原值透传)', material_code VARCHAR(20) COMMENT '电芯材料代码(原值透传)', cell_count INT COMMENT '电芯节数', rated_voltage BIGINT COMMENT '额定电压(mV)', rated_capacity BIGINT COMMENT '额定容量(mAh)', status TINYINT DEFAULT 0 COMMENT '0空闲 1使用中 2故障 3报废', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS charge_orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, compartment_id BIGINT NOT NULL COMMENT '仓体ID', cabinet_id BIGINT NOT NULL COMMENT '柜子ID', battery_id VARCHAR(50) NOT NULL COMMENT '电池ID', start_time TIMESTAMP NULL COMMENT '充电开始时间', end_time TIMESTAMP NULL COMMENT '充电结束时间', start_soc INT COMMENT '起始电量(‰)', end_soc INT COMMENT '结束电量(‰)', energy INT DEFAULT 0 COMMENT '充电量(Wh)', reason VARCHAR(20) COMMENT '结束原因: normal/user_stop/forced/timeout', status TINYINT DEFAULT 0 COMMENT '0充电中 1已完成 2异常', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (compartment_id) REFERENCES compartments(id), FOREIGN KEY (cabinet_id) REFERENCES cabinets(id), INDEX idx_battery (battery_id), INDEX idx_compartment (compartment_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS cabin_boards ( id BIGINT AUTO_INCREMENT PRIMARY KEY, cabinet_id BIGINT NOT NULL, board_id TINYINT NOT NULL COMMENT '仓控板ID 1-6', status TINYINT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS compartments ( id BIGINT AUTO_INCREMENT PRIMARY KEY, cabin_board_id BIGINT NOT NULL, channel_id TINYINT NOT NULL COMMENT '通道ID 1-6', status TINYINT DEFAULT 0 COMMENT '0空闲 1充电中 2故障 3禁用', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (cabin_board_id) REFERENCES cabin_boards(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号', name VARCHAR(50) COMMENT '姓名', password VARCHAR(255) NOT NULL, role TINYINT DEFAULT 0 COMMENT '0操作员 1企业管理员 2总管理员', organization_id BIGINT, status TINYINT DEFAULT 1 COMMENT '0禁用 1启用', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (organization_id) REFERENCES organizations(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS charge_records ( id BIGINT AUTO_INCREMENT PRIMARY KEY, compartment_id BIGINT NOT NULL, cabinet_id BIGINT NOT NULL, start_time TIMESTAMP NULL COMMENT '充电开始时间', end_time TIMESTAMP NULL COMMENT '充电结束时间', energy BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)', max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)', status TINYINT DEFAULT 0 COMMENT '0正常 1异常 2手动停止', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (compartment_id) REFERENCES compartments(id), FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS device_logs ( id BIGINT AUTO_INCREMENT PRIMARY KEY, cabinet_id BIGINT NOT NULL, log_type TINYINT NOT NULL COMMENT '1状态上报 2故障 3告警 4停电', content TEXT COMMENT '日志内容JSON', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS energy_stats ( id BIGINT AUTO_INCREMENT PRIMARY KEY, cabinet_id BIGINT NOT NULL, stat_date DATE NOT NULL, total_energy BIGINT NOT NULL DEFAULT 0 COMMENT '总能耗(Wh)', charge_count INT DEFAULT 0 COMMENT '充电次数', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (cabinet_id) REFERENCES cabinets(id), UNIQUE KEY uk_cabinet_date (cabinet_id, stat_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS operation_logs ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, action VARCHAR(100) NOT NULL COMMENT '操作动作', target_type VARCHAR(50) COMMENT '操作对象类型', target_id BIGINT COMMENT '操作对象ID', detail TEXT COMMENT '操作详情', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS roles ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称', description VARCHAR(200) COMMENT '角色描述', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS permissions ( id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(50) NOT NULL UNIQUE COMMENT '权限码,如device:view', name VARCHAR(100) NOT NULL COMMENT '权限名称', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS role_permissions ( role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, PRIMARY KEY (role_id, permission_id), FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS user_roles ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS user_scopes ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, scope_type TINYINT NOT NULL COMMENT '1组织 2项目 3设备', scope_id BIGINT NOT NULL COMMENT '对应组织/项目/设备ID', permission_type TINYINT NOT NULL COMMENT '1查看 2操作', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS download_tasks ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, task_type VARCHAR(50) NOT NULL COMMENT 'charge_records/energy_stats/device_logs', status TINYINT DEFAULT 0 COMMENT '0排队 1处理中 2完成 3失败', file_path VARCHAR(500) COMMENT '文件路径', file_name VARCHAR(200) COMMENT '文件名', progress INT DEFAULT 0 COMMENT '进度百分比', error_message TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP NULL COMMENT '完成时间', FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS abstract_bindings ( id BIGINT AUTO_INCREMENT PRIMARY KEY, abstract_id VARCHAR(20) NOT NULL UNIQUE COMMENT '平台抽象ID,如10-00000001', cabinet_id BIGINT NOT NULL UNIQUE COMMENT '柜子ID', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS user_feedback ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '提交用户ID', content TEXT NOT NULL COMMENT '反馈内容', contact VARCHAR(200) COMMENT '联系方式', status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "#; /// 执行数据库迁移,创建所有必要的数据表 pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { // 逐条执行全量建表语句,避免多语句执行问题 for statement in SCHEMA_SQL.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { sqlx::query(statement).execute(pool).await?; } // 兼容已有数据库:将 abstract_id 改为可空 let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#) .execute(pool).await; // 更新 charge_records.max_temperature 注释(0.1°C → K×100) // 只在注释确实过时(老库)时才 ALTER:避免每次启动都对整表做一次 MODIFY ensure_column_comment( pool, "charge_records", "max_temperature", "K×100", r#"ALTER TABLE charge_records MODIFY max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)'"#, ) .await?; // 更新 charge_orders.reason 注释:新增 timeout // (订单超过 10 小时由 order_timeout 定时任务自动结束,落该枚举值) ensure_column_comment( pool, "charge_orders", "reason", "timeout", r#"ALTER TABLE charge_orders MODIFY COLUMN reason VARCHAR(20) NULL COMMENT '结束原因: normal/user_stop/forced/timeout'"#, ) .await?; // 创建关键索引(IF NOT EXISTS 避免重复) create_indexes(pool).await?; // 兼容已有数据库:新增字段(幂等,列已存在则跳过) add_columns_if_not_exists(pool).await?; // P0-2 的 DB 层兜底:**同一仓体最多一笔活跃订单**(`status = 0`)的唯一约束。 // // ⚠️ 这一步的失败**绝不能中断启动**:`run()` 返回 Err 会让 main panic 退出(生产事故), // 而业务侧已经有「按仓体的进程内锁 + 条件插入」两层防护,DB 约束只是第三层兜底。 // 因此这里只记 ERROR 后继续(与上方 `ALTER TABLE cabinets ...` 的容错口径一致)。 if let Err(e) = ensure_active_order_unique(pool).await { tracing::error!( "[migrate] 活跃订单唯一约束迁移失败(服务继续启动;业务侧仍有进程内锁+条件插入兜底)err={}", e ); } // 初始化默认权限码(INSERT IGNORE 避免重复插入) seed_permissions(pool).await?; // 初始化默认角色 seed_roles(pool).await?; tracing::info!("数据库迁移完成"); Ok(()) } /// 创建关键查询索引(幂等,已存在则跳过) async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> { /// 辅助:检查索引是否存在,不存在则创建 async fn ensure_index( pool: &MySqlPool, table: &str, index: &str, columns: &str, ) -> Result<(), sqlx::Error> { let exists: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM information_schema.statistics \ WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", ) .bind(table) .bind(index) .fetch_one(pool) .await?; if exists.0 == 0 { let ddl = format!("CREATE INDEX {} ON {}({})", index, table, columns); sqlx::query(&ddl).execute(pool).await?; } Ok(()) } ensure_index(pool, "cabinets", "idx_cabinets_project", "project_id").await?; ensure_index(pool, "cabin_boards", "idx_cabin_boards_cabinet", "cabinet_id").await?; ensure_index(pool, "compartments", "idx_compartments_board", "cabin_board_id").await?; ensure_index(pool, "charge_records", "idx_charge_records_cabinet", "cabinet_id").await?; ensure_index(pool, "device_logs", "idx_device_logs_cabinet", "cabinet_id").await?; // 订单超时兜底扫描(status = 0 AND start_time < ...)走该联合索引,避免全表扫描 ensure_index(pool, "charge_orders", "idx_charge_orders_status_start", "status, start_time").await?; // device_logs 保留策略按 created_at 分批删除,必须有该列索引,否则每次清理全表扫描 ensure_index(pool, "device_logs", "idx_device_logs_created", "created_at").await?; tracing::info!("数据库索引创建完成"); Ok(()) } /// DB 层兜底:**同一仓体最多只能有一笔活跃订单**(`status = 0`)的唯一约束。 /// /// # 为什么需要(P0-2) /// `workers/report_worker.rs` 的 `ensure_charge_order` 原本是"先查活跃单、再插入": /// 判定与插入之间没有事务、没有锁、也没有唯一键,并发两次建单 /// (H5 双击/重试、两人同时点、设备上报 `on` 与平台下发 `on` 撞在一起) /// 会各插一笔 `status = 0`。后果:结单只收最新一笔、老单**永久卡在 `status = 0`**, /// 还会被 `order_timeout` 扫到后**把正在充电的新单当超时单结束**, /// 一次充电会话产生两条 `charge_records`("今日充电次数"虚增)。 /// /// 代码侧已加「按仓体的进程内锁 + 条件插入」,本迁移是**第三层(DB 层)兜底**: /// 即使将来有人绕过封装直接 `INSERT`,也写不出第二笔活跃订单。 /// /// # 方案:生成列 + 唯一索引(MySQL 不支持条件唯一索引) /// ```sql /// ALTER TABLE charge_orders ADD COLUMN active_flag BIGINT /// GENERATED ALWAYS AS (CASE WHEN status = 0 THEN compartment_id ELSE NULL END) STORED; /// CREATE UNIQUE INDEX uk_charge_orders_active ON charge_orders(active_flag); /// ``` /// `status <> 0`(已完成/异常)的行 `active_flag = NULL`,而 **MySQL 唯一索引不约束 NULL** /// (多行 NULL 可以共存)—— 正是需要的语义:历史订单可以有任意多笔, /// **活跃订单同一仓体只能有一笔**。 /// /// # 幂等与安全(三步各自独立可重入) /// 1. 索引已存在 ⇒ 直接返回(OLD 库/第二次启动都不会重复 DDL,避免每次启动重建整表); /// 2. 加约束前**先预检重复活跃单**:存在重复就**只记 ERROR、放弃本次 DDL** /// (强行加约束会报 1062;脏数据必须先人工清理,不能让迁移失败); /// 3. 生成列已存在但索引缺失(上次"加列成功、建索引失败")⇒ 只补建索引,不重复 `ALTER`。 /// /// 失败一律上抛给 [`run`],由 `run` 记 ERROR 后**继续启动**(绝不让服务起不来)。 async fn ensure_active_order_unique(pool: &MySqlPool) -> Result<(), sqlx::Error> { /// 目标表名(仅用于 information_schema 查询与日志) const TABLE: &str = "charge_orders"; /// 生成列名(活跃订单哨兵) const COLUMN: &str = "active_flag"; /// 唯一索引名(幂等判定依据) const INDEX: &str = "uk_charge_orders_active"; // 1) 索引已存在 ⇒ 幂等跳过 let index_exists: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM information_schema.statistics \ WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", ) .bind(TABLE) .bind(INDEX) .fetch_one(pool) .await?; if index_exists.0 > 0 { return Ok(()); } // 2) 预检:已存在重复活跃单时**不能**加唯一约束,只能记 ERROR 交人工处理 let duplicates: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM ( \ SELECT compartment_id FROM charge_orders WHERE status = 0 \ GROUP BY compartment_id HAVING COUNT(*) > 1 \ ) AS dup", ) .fetch_one(pool) .await?; if duplicates.0 > 0 { tracing::error!( "[migrate] 检测到 {} 个仓体存在多笔活跃订单(charge_orders.status=0),\ 已跳过 {} 唯一约束;请先人工清理重复活跃单(保留最新一笔、其余置为结束态)再重启服务", duplicates.0, INDEX ); return Ok(()); } // 3) 生成列(幂等:列已存在则只补建索引) let column_exists: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM information_schema.COLUMNS \ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", ) .bind(TABLE) .bind(COLUMN) .fetch_one(pool) .await?; if column_exists.0 == 0 { sqlx::query( "ALTER TABLE charge_orders ADD COLUMN active_flag BIGINT \ GENERATED ALWAYS AS (CASE WHEN status = 0 THEN compartment_id ELSE NULL END) STORED \ COMMENT '活跃订单唯一性哨兵:status=0 时=compartment_id,否则 NULL(NULL 不参与唯一性)'", ) .execute(pool) .await?; tracing::info!("[migrate] 新增生成列 {}.{}", TABLE, COLUMN); } sqlx::query("CREATE UNIQUE INDEX uk_charge_orders_active ON charge_orders(active_flag)") .execute(pool) .await?; tracing::info!( "[migrate] 已创建活跃订单唯一约束 {}(同一仓体最多一笔 status=0)", INDEX ); Ok(()) } /// 列级幂等迁移:列不存在时才 ALTER TABLE 补列 /// /// 为什么需要:`CREATE TABLE IF NOT EXISTS` 只保证「表存在」, /// 对**已存在**的表永远不会补后加的列,导致线上 schema 漂移。 /// 安全性:表名/列名/DDL 全部来自本文件内的白名单常量,不含任何外部输入拼接。 async fn ensure_column( pool: &MySqlPool, table: &str, column: &str, ddl: &str, ) -> Result<(), sqlx::Error> { let exists: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM information_schema.COLUMNS \ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", ) .bind(table) .bind(column) .fetch_one(pool) .await?; if exists.0 == 0 { let alter = format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, ddl); sqlx::query(&alter).execute(pool).await?; tracing::info!("[migrate] 新增列 {}.{}", table, column); } Ok(()) } /// 列注释幂等更新:仅当现有注释不包含 `marker` 时才 ALTER /// /// 为什么:老库的列注释不会随 CREATE TABLE IF NOT EXISTS 更新; /// 而 MODIFY 会重建整表,因此必须先比对注释,避免每次启动都白跑一次 DDL。 async fn ensure_column_comment( pool: &MySqlPool, table: &str, column: &str, marker: &str, ddl: &str, ) -> Result<(), sqlx::Error> { // ⚠️ `information_schema.COLUMNS.COLUMN_COMMENT` 在 MySQL 8 上会被 sqlx 报告为 // **VARBINARY**,直接按 `Option` 解码会 panic(实测阿里云 RDS MySQL 8.0.36): // `mismatched types; Rust type String (as SQL type VARCHAR) is not compatible with SQL type VARBINARY` // 必须显式 `CAST(... AS CHAR)`。 let comment: Option = sqlx::query_scalar( "SELECT CAST(COLUMN_COMMENT AS CHAR) FROM information_schema.COLUMNS \ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", ) .bind(table) .bind(column) .fetch_optional(pool) .await?; if let Some(current) = comment { if !current.contains(marker) { sqlx::query(ddl).execute(pool).await?; tracing::info!("[migrate] 更新列注释 {}.{}", table, column); } } Ok(()) } /// 逐列补齐历史版本遗漏的列(幂等,兼容线上已存在的旧表) /// /// 线上 pms 库曾因缺少 `cabinets.auth_str_issued` / `cabinets.cell`, /// 导致设备认证链路直接报 `1054 Unknown column`,只能人工 ALTER 补救; /// 本函数就是把这个人工步骤固化进启动迁移。 async fn add_columns_if_not_exists(pool: &MySqlPool) -> Result<(), sqlx::Error> { // users:头像 + 微信 openid ensure_column(pool, "users", "avatar", "VARCHAR(500) NULL COMMENT '头像URL'").await?; ensure_column(pool, "users", "openid", "VARCHAR(64) NULL UNIQUE COMMENT '微信openid'") .await?; // organizations:父组织(组织树层级)+ 禁用标记(甲方禁用客户组织,其下柜子全部失效) ensure_column(pool, "organizations", "parent_id", "BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)'") .await?; ensure_column( pool, "organizations", "disabled", "TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1禁用'", ) .await?; // projects:地址 + 经纬度 ensure_column(pool, "projects", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?; ensure_column(pool, "projects", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?; ensure_column(pool, "projects", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?; // cabinets:地址 + 经纬度 ensure_column(pool, "cabinets", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?; ensure_column(pool, "cabinets", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?; ensure_column(pool, "cabinets", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?; // cabinets:设备认证 / 拓扑 / 工作模式(线上曾缺失,必须补齐) ensure_column( pool, "cabinets", "auth_str_issued", "BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发'", ) .await?; ensure_column(pool, "cabinets", "cell", "TINYINT DEFAULT 6 COMMENT '仓控板数量'").await?; ensure_column( pool, "cabinets", "work_mode", "TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式'", ) .await?; // batteries:协议 §5.5「电池属性」的原值字段(老库缺失,必须补齐,否则台账只能丢到 device_logs) ensure_column(pool, "batteries", "brand_code", "VARCHAR(20) NULL COMMENT '厂家代码(原值透传)'").await?; ensure_column(pool, "batteries", "model_code", "VARCHAR(20) NULL COMMENT '电池型号代码(原值透传)'").await?; ensure_column(pool, "batteries", "material_code", "VARCHAR(20) NULL COMMENT '电芯材料代码(原值透传)'") .await?; ensure_column(pool, "batteries", "cell_count", "INT NULL COMMENT '电芯节数'").await?; ensure_column(pool, "batteries", "rated_voltage", "BIGINT NULL COMMENT '额定电压(mV)'").await?; ensure_column(pool, "batteries", "rated_capacity", "BIGINT NULL COMMENT '额定容量(mAh)'").await?; // user_feedback:反馈图片(JSON 数组字符串) ensure_column( pool, "user_feedback", "images", "TEXT NULL COMMENT '反馈图片URL列表(JSON数组)'", ) .await?; tracing::info!("数据库列级迁移完成"); Ok(()) } /// 插入默认权限码 async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> { let perms: Vec<(&str, &str)> = vec![ ("device:view", "查看设备"), ("device:operate", "操作设备"), ("device:create", "添加设备"), ("device:edit", "编辑设备"), ("device:delete", "删除设备"), // 安全码重置(仅总管理员,不授予企业管理员/操作员) ("device:auth_str:reset", "重置安全码"), ("charge:start", "开始充电"), ("charge:stop", "停止充电"), ("charge:open_door", "开门"), ("charge_record:view", "查看充电记录"), ("charge_record:export", "导出充电记录"), ("device_log:view", "查看设备日志"), ("energy:view", "查看能耗"), ("energy:export", "导出能耗"), ("user:view", "查看用户"), ("user:create", "创建用户"), ("user:edit", "编辑用户"), ("user:delete", "删除用户"), ("role:view", "查看角色"), ("role:create", "创建角色"), ("role:edit", "编辑角色"), ("role:delete", "删除角色"), ("operation_log:view", "查看操作日志"), // 组织管理 ("org:view", "查看组织"), ("org:create", "创建组织"), ("org:edit", "编辑组织"), ("org:delete", "删除组织"), // 项目管理 ("project:view", "查看项目"), ("project:create", "创建项目"), ("project:edit", "编辑项目"), ("project:delete", "删除项目"), ]; for (code, name) in perms { sqlx::query( "INSERT IGNORE INTO permissions (code, name) VALUES (?, ?)" ) .bind(code) .bind(name) .execute(pool) .await?; } tracing::info!("默认权限码初始化完成"); Ok(()) } /// 创建默认角色并分配权限 async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { insert_default_roles(pool).await?; // 为总管理员分配所有权限 let super_admin_id: Option<(i64,)> = sqlx::query_as( "SELECT id FROM roles WHERE name = 'super_admin'" ) .fetch_optional(pool) .await?; if let Some((role_id,)) = super_admin_id { grant_all_permissions(pool, role_id).await?; } // 为企业管理员分配非系统管理权限 let org_admin_id: Option<(i64,)> = sqlx::query_as( "SELECT id FROM roles WHERE name = 'org_admin'" ) .fetch_optional(pool) .await?; let org_perm_codes = [ "device:view", "device:operate", "device:create", "device:edit", "charge:start", "charge:stop", "charge:open_door", "charge_record:view", "charge_record:export", "device_log:view", "energy:view", "energy:export", "user:view", "user:create", "user:edit", "operation_log:view", // 组织管理 "org:view", "org:create", "org:edit", "org:delete", // 项目管理 "project:view", "project:create", "project:edit", "project:delete", ]; if let Some((role_id,)) = org_admin_id { grant_permissions_by_codes(pool, role_id, &org_perm_codes).await?; } // 为操作员分配查看和操作权限 let normal_id: Option<(i64,)> = sqlx::query_as( "SELECT id FROM roles WHERE name = 'normal_user'" ) .fetch_optional(pool) .await?; let normal_perm_codes = [ "device:view", "device:operate", "charge:start", "charge:stop", "charge:open_door", "charge_record:view", "device_log:view", "energy:view", ]; if let Some((role_id,)) = normal_id { grant_permissions_by_codes(pool, role_id, &normal_perm_codes).await?; } tracing::info!("默认角色及权限分配完成"); Ok(()) } /// 创建三个默认角色(INSERT IGNORE,已存在则跳过) async fn insert_default_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { // 创建总管理员角色 sqlx::query( "INSERT IGNORE INTO roles (name, description) VALUES (?, ?)" ) .bind("super_admin") .bind("总管理员 — 全部权限") .execute(pool) .await?; // 创建企业管理员角色 sqlx::query( "INSERT IGNORE INTO roles (name, description) VALUES (?, ?)" ) .bind("org_admin") .bind("企业管理员 — 管理本组织下项目/设备/人员") .execute(pool) .await?; // 创建操作员角色 sqlx::query( "INSERT IGNORE INTO roles (name, description) VALUES (?, ?)" ) .bind("normal_user") .bind("操作员 — 查看/操作指定范围设备") .execute(pool) .await?; Ok(()) } /// 为指定角色分配全部权限(授权总管理员) async fn grant_all_permissions(pool: &MySqlPool, role_id: i64) -> Result<(), sqlx::Error> { // 查询所有权限ID let perm_ids: Vec<(i64,)> = sqlx::query_as( "SELECT id FROM permissions" ) .fetch_all(pool) .await?; for (perm_id,) in perm_ids { sqlx::query( "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)" ) .bind(role_id) .bind(perm_id) .execute(pool) .await?; } Ok(()) } /// 按权限码逐个为指定角色授权(权限码不存在则跳过) async fn grant_permissions_by_codes( pool: &MySqlPool, role_id: i64, codes: &[&str], ) -> Result<(), sqlx::Error> { for code in codes { let perm: Option<(i64,)> = sqlx::query_as( "SELECT id FROM permissions WHERE code = ?" ) .bind(code) .fetch_optional(pool) .await?; if let Some((perm_id,)) = perm { sqlx::query( "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)" ) .bind(role_id) .bind(perm_id) .execute(pool) .await?; } } Ok(()) }