diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs
index f56156c..049e3da 100644
--- a/software/api-server/src/db/migrate.rs
+++ b/software/api-server/src/db/migrate.rs
@@ -266,16 +266,105 @@ const CABINETS_STATUS_COMMENT_ALIGN: (&str, &str) = (
r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '启用/禁用配置态(不表示在线):1启用 2禁用(拦截指令链路) 0=建表默认值产生的取值,语义等同启用'"#,
);
-/// 执行数据库迁移,创建所有必要的数据表
+/// 执行数据库迁移。
+///
+/// **本函数只做编排**:五个阶段各自封装在下方私有函数里,**顺序即依赖顺序,不得调整**
+/// (历史上改错过一次顺序:`SCHEMA_SQL` 前向引用导致新库初始化报 MySQL 1824 无限重启)。
+///
+/// 每个阶段的"失败是否中断启动"口径**各不相同且是刻意设计的**,见各阶段函数的文档说明:
+/// 结构性前提(建表/索引/种子数据)失败必须中断;纯元信息/纯优化/业务侧已有兜底的失败
+/// 一律只记 `ERROR` 后继续 —— 因为 `run()` 返回 Err 会让 `main.rs` 的
+/// `.expect("数据库迁移失败")` panic 成 systemd 崩溃循环。
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
+ apply_schema(pool).await?;
+ align_column_metadata(pool).await?;
+ create_indexes_and_columns(pool).await?;
+ align_legacy_structures(pool).await?;
+ seed_defaults(pool).await?;
+
+ tracing::info!("数据库迁移完成");
+ Ok(())
+}
+
+/// 阶段 A:执行全量建表语句(`SCHEMA_SQL`)。
+///
+/// ⛔ **失败必须中断启动**:表都不在,后续任何查询都无从谈起。
+///
+/// ⚠️ `SCHEMA_SQL` 里建表语句的**书写顺序即依赖顺序**(被引用表必须先建),
+/// 否则新库初始化会报 MySQL **1824**(外键找不到被引用表),
+/// 叠加 `Restart=always` = **无限重启**。改 `SCHEMA_SQL` 时必须同步
+/// `LEGACY_COLUMN_TARGETS`(有单测守门)。
+async fn apply_schema(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?;
}
+ Ok(())
+}
- // 兼容已有数据库:将 abstract_id 改为可空
- // 只在列当前确实为 NOT NULL(老库)时才 ALTER:避免每次重启都对 cabinets 做一次 DDL
- // 容错口径与修改前一致(原先为 `let _ =`):失败只记 ERROR,绝不中断启动
+/// 阶段 B:对齐列的**可空性与注释**(老库与 `SCHEMA_SQL` 的元信息口径)。
+///
+/// 顺序即原实现顺序,且**每一步的容错口径单独保留**。
+///
+/// # 口径:纯元信息失败只记 ERROR,绝不阻断启动
+///
+/// 注释只是元信息,不配享有"迁移失败 → main panic → systemd 崩溃循环"的杀伤力
+/// (`run()` 返回 Err 会让 `main.rs` 的 `.expect("数据库迁移失败")` panic)。
+/// 例外是 `charge_records.max_temperature` / `charge_orders.reason` /
+/// `compartments.status` / `charge_orders.status` 四条 —— 它们从最初就是**上抛**口径,保持不变。
+///
+/// # 各列为什么要改(逐条复核结论,原文保留)
+///
+/// ## `charge_records.max_temperature`:`0.1°C` → `K×100`
+/// 只在该列注释确实过时(老库)时才 ALTER,避免每次启动都对整表做一次 MODIFY。
+///
+/// ## `charge_orders.reason`:新增 `timeout` 枚举值
+/// 订单超过 10 小时由 `order_timeout` 定时任务自动结束,落该枚举值。
+///
+/// ## `compartments.status`:**旧注释是错的**
+/// 老库写的是 `0空闲 1充电中 2故障 3禁用`,但全仓没有任何地方会写 2/3 ——
+/// 平台只写 0/1(`workers/report_worker.rs::mark_compartment_status` 的调用点只有 0 与 1);
+/// 而"禁用"是设备/组织级状态,仓体级并不存在。
+/// 本列语义从此**只表达占用**:"充满 / 故障"由 Redis 通道实时状态派生
+/// (`routes/cabinet_tree.rs::derive_channel_flags`),不落库。
+///
+/// ## `cabinets.status`:**本列是"启用/禁用"配置态,不是"在线"**
+/// 全仓复核结论(grep 实测):
+/// - **唯一写入方** `routes/cabinets.rs::set_cabinet_status`:
+/// `UPDATE cabinets SET status = ? WHERE id = ?`,语义是 `2` = 禁用 / 其它值 = 启用;
+/// - **没有任何地方写 0**(0 只可能由建表默认值 `DEFAULT 0` 或该接口传入 0 时产生);
+/// - 消费方只有"禁用"一档:`commands.rs::CABINET_DISABLED_SQL`
+/// (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)与
+/// `routes/h5.rs::ensure_cabinet_enabled` 据此拦截开合闸 / 开门 / 充电。
+///
+/// 旧注释 `0离线 1在线 2禁用` 里的"**在线**"是**语义错配**:
+/// 生产实测 68 台柜子 `status` 全 = 1,而当时**一台设备都没连** ——
+/// 按它统计在线(`energy_stats` 的 `online_count`、H5 首页 `online_cabinets`)
+/// 会恒定显示"全部在线"。在线已全部改为 **Redis 判据**
+/// (`routes/cabinet_tree.rs::device_online_flag` / `count_online_devices`,
+/// 与设备列表 `is_online` 同源),本列不再参与任何在线判定。
+/// 只改注释,**不动类型与默认值**(仍是 `TINYINT DEFAULT 0`)。
+///
+/// ## `charge_orders.status`:**旧注释把从未写入的 `2` 写成了既成事实**
+/// 实测(全仓 grep):**生产代码只有 0 与 1 两个写入值** ——
+/// 建单 `report_worker.rs::try_create_order` 固定写 `0`;所有结束路径
+/// (`off` / `bat_out` / 换电池 / 10 小时超时 / 无电池对账)都走
+/// `finalize_order*`,`order_status` 恒为 `ORDER_STATUS_DONE = 1`。
+/// 唯一的 `2` 出现在 `test/reset_fixture.py`(一次性夹具脚本,非交付物)。
+///
+/// 但 `2` **有消费方**:PC 订单页 `web/src/pages/charge-orders/index.tsx` 的筛选下拉
+/// 提供 ``、`STATUS_COLOR[2] = 'red'`,
+/// 且 `routes/charge_orders.rs::status_text` 把 `2` 映射为"异常" ——
+/// 即"异常"曾被设计成一个**状态**,而现实现把它降级成 `reason` 里的**结束原因**。
+/// 这是**产品语义缺口,不是笔误**,故**不删除 `2`**,只在注释里注明"保留未使用",
+/// 并作为待产品决策项上报(见 docs/tech/API.md 的 `GET /api/charge-orders` 一节)。
+///
+/// # marker 机制
+/// 各条 `marker` 取**新注释里的独有片段**:老注释不含它 ⇒ 只 ALTER 一次,
+/// 之后每次启动不再重复 DDL(幂等,见 `ensure_column_comment`)。
+async fn align_column_metadata(pool: &MySqlPool) -> Result<(), sqlx::Error> {
+ // ① cabinets.abstract_id 可空化(老库为 NOT NULL 时才 ALTER)
+ // 容错口径与修改前一致(原先为 `let _ =`):失败只记 ERROR,绝不中断启动
if let Err(e) = ensure_column_nullable(
pool,
"cabinets",
@@ -290,8 +379,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
);
}
- // 更新 charge_records.max_temperature 注释(0.1°C → K×100)
- // 只在注释确实过时(老库)时才 ALTER:避免每次启动都对整表做一次 MODIFY
+ // ② charge_records.max_temperature 注释(0.1°C → K×100)—— 上抛口径
ensure_column_comment(
pool,
"charge_records",
@@ -301,8 +389,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
)
.await?;
- // 更新 charge_orders.reason 注释:新增 timeout
- // (订单超过 10 小时由 order_timeout 定时任务自动结束,落该枚举值)
+ // ③ charge_orders.reason 注释:新增 timeout —— 上抛口径
ensure_column_comment(
pool,
"charge_orders",
@@ -312,13 +399,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
)
.await?;
- // 更新 compartments.status 注释(**旧的注释是错的**:老库写的是
- // `0空闲 1充电中 2故障 3禁用`,但全仓没有任何地方会写 2/3 —— 平台只写 0/1,
- // `routes/workers/report_worker.rs::mark_compartment_status` 的调用点只有 0 与 1;
- // 而"禁用"是设备/组织级状态,仓体级并不存在)。
- // 本列语义从此**只表达占用**:"充满 / 故障"由 Redis 通道实时状态派生
- // (`routes/cabinet_tree.rs::derive_channel_flags`),不落库。
- // marker 取注释里的独有片段:老注释不含它 → 只 ALTER 一次,之后启动不再重复 DDL。
+ // ④ compartments.status 注释:语义收敛为"只表达占用" —— 上抛口径
ensure_column_comment(
pool,
"compartments",
@@ -328,33 +409,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
)
.await?;
- // 更新 cabinets.status 注释(**本列是"启用/禁用"配置态,不是"在线"**)。
- //
- // 全仓复核结论(grep 实测):
- // - **唯一写入方** `routes/cabinets.rs::set_cabinet_status`:
- // `UPDATE cabinets SET status = ? WHERE id = ?`,语义是 `2` = 禁用 / 其它值 = 启用;
- // - **没有任何地方写 0**(0 只可能由建表默认值 `DEFAULT 0` 或该接口传入 0 时产生);
- // - 消费方只有"禁用"一档:`commands.rs::CABINET_DISABLED_SQL`
- // (`SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2`)与
- // `routes/h5.rs::ensure_cabinet_enabled` 据此拦截开合闸 / 开门 / 充电。
- //
- // 旧注释 `0离线 1在线 2禁用` 里的"**在线**"是**语义错配**:
- // 生产实测 68 台柜子 `status` 全 = 1,而当时**一台设备都没连** ——
- // 按它统计在线(`energy_stats` 的 `online_count`、H5 首页 `online_cabinets`)
- // 会恒定显示"全部在线"。在线已全部改为 **Redis 判据**
- // (`routes/cabinet_tree.rs::device_online_flag` / `count_online_devices`,
- // 与设备列表 `is_online` 同源),本列不再参与任何在线判定。
- //
- // 只改注释,**不动类型与默认值**(仍是 `TINYINT DEFAULT 0`);
- // `0` 是建表默认值产生的取值(`create_cabinet` 的 INSERT 未显式给 status),
- // 语义等同"启用"(除 `2` 外一律按启用处理),代码不显式写它。
- //
- // marker 取新注释里的独有片段(旧注释 `0离线 1在线 2禁用(禁用后指令链路拦截)` 不含它)
- // → 只 ALTER 一次,之后启动不再重复 DDL。
- //
- // ⚠️ 失败只记 ERROR、**绝不阻断启动**:注释只是元信息,
- // 不配享有"迁移失败 → main panic → systemd 崩溃循环"的杀伤力
- // (与上面 `cabinets.abstract_id` 可空化、下面 `align_legacy_columns` 同口径)。
+ // ⑤ cabinets.status 注释:"启用/禁用配置态,不是在线" —— **容错:只记 ERROR**
if let Err(e) = ensure_column_comment(
pool,
"cabinets",
@@ -370,20 +425,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
);
}
- // 更新 charge_orders.status 注释(**旧注释把从未写入的 `2` 写成了既成事实**)。
- //
- // 实测(全仓 grep):**生产代码只有 0 与 1 两个写入值** ——
- // 建单 `report_worker.rs::try_create_order` 固定写 `0`;所有结束路径
- // (`off` / `bat_out` / 换电池 / 10 小时超时 / 无电池对账)都走
- // `finalize_order*`,`order_status` 恒为 `ORDER_STATUS_DONE = 1`。
- // 唯一的 `2` 出现在 `test/reset_fixture.py`(一次性夹具脚本,非交付物)。
- //
- // 但 `2` **有消费方**:PC 订单页 `web/src/pages/charge-orders/index.tsx` 的筛选下拉
- // 提供 ``、`STATUS_COLOR[2] = 'red'`,
- // 且 `routes/charge_orders.rs::status_text` 把 `2` 映射为"异常" ——
- // 即"异常"曾被设计成一个**状态**,而现实现把它降级成 `reason` 里的**结束原因**。
- // 这是**产品语义缺口,不是笔误**,故**不删除 `2`**,只在注释里注明"保留未使用",
- // 并作为待产品决策项上报(见 docs/tech/API.md 的 `GET /api/charge-orders` 一节)。
+ // ⑥ charge_orders.status 注释:标注 `2` 保留未使用 —— 上抛口径
ensure_column_comment(
pool,
"charge_orders",
@@ -393,6 +435,15 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
)
.await?;
+ Ok(())
+}
+
+/// 阶段 C:关键索引 + 老库缺失列的补齐。
+///
+/// 容错口径**两条不同**,逐一保留:
+/// - [`create_indexes`] **上抛** ⇒ 经 `run` 中断启动(索引是可用性前提);
+/// - [`ensure_energy_report_indexes`] 只记 `ERROR` ⇒ 纯性能优化,报表查询退化为全表扫描也不能阻断启动。
+async fn create_indexes_and_columns(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 创建关键索引(IF NOT EXISTS 避免重复)
create_indexes(pool).await?;
@@ -408,6 +459,13 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 兼容已有数据库:新增字段(幂等,列已存在则跳过)
add_columns_if_not_exists(pool).await?;
+ Ok(())
+}
+
+/// 阶段 D:历史库的**结构漂移对齐**与 DB 层约束兜底。
+///
+/// ⚠️ 三步的**顺序不可换**(见下),容错口径:第 1 步上抛,后两步只记 `ERROR`。
+async fn align_legacy_structures(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 历史库「列在、但类型/可空性/默认值错了」的漂移对齐 + organizations.parent_id 索引/外键。
//
// ⚠️ 必须放在 `add_columns_if_not_exists` **之后**:它要建索引的
@@ -425,7 +483,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
//
// ⚠️ 这一步的失败**绝不能中断启动**:`run()` 返回 Err 会让 main panic 退出(生产事故),
// 而业务侧已经有「按仓体的进程内锁 + 条件插入」两层防护,DB 约束只是第三层兜底。
- // 因此这里只记 ERROR 后继续(与上方 `ALTER TABLE cabinets ...` 的容错口径一致)。
+ // 因此这里只记 ERROR 后继续(与上方各 `ALTER TABLE ...` 的容错口径一致)。
if let Err(e) = ensure_active_order_unique(pool).await {
tracing::error!(
"[migrate] 活跃订单唯一约束迁移失败(服务继续启动;业务侧仍有进程内锁+条件插入兜底)err={}",
@@ -433,12 +491,18 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
);
}
+ Ok(())
+}
+
+/// 阶段 E:种子数据(默认权限码 + 默认角色)。
+///
+/// ⛔ **失败必须中断启动**:没有权限码,全部 RBAC 校验都会拒绝,平台等于不可用。
+/// 两条语句都用 `INSERT IGNORE` / 幂等写法,重复执行无副作用。
+async fn seed_defaults(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 初始化默认权限码(INSERT IGNORE 避免重复插入)
seed_permissions(pool).await?;
// 初始化默认角色
seed_roles(pool).await?;
-
- tracing::info!("数据库迁移完成");
Ok(())
}