From 86a52ce293851e4b11f37faa616632bdc9d67532 Mon Sep 17 00:00:00 2001 From: 12451 <12451@example.com> Date: Sun, 20 Sep 2026 05:13:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor(api-server):=20=E6=8B=86=E5=88=86=20db?= =?UTF-8?q?/migrate.rs::run=EF=BC=88174=20=E2=86=92=2020=20=E8=A1=8C?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E8=A1=8C=E4=B8=BA=E9=9B=B6=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run` 在 2026-09 被拆过一次(280 → 46 行,把建表 SQL 抽成 `const SCHEMA_SQL`), 但后续每一轮迁移改动都往它里面追加(`LEGACY_COLUMN_TARGETS` 列对齐、 `ensure_active_order_unique`、各处列注释对齐……),**它又长回 174 行**,重新成为全仓最长函数。 ## 改法:按"迁移阶段"切五个私有函数,顺序与容错口径逐条保留 | 阶段 | 内容 | 容错口径 | |---|---|---| | `apply_schema` | 执行 `SCHEMA_SQL` 建表 | ⛔ 上抛(表不在,后续无从谈起) | | `align_column_metadata` | `abstract_id` 可空化 + 5 处列注释对齐 | 混合:`abstract_id`/`cabinets.status` 只记 ERROR,其余 4 条上抛(**逐条保留原口径**) | | `create_indexes_and_columns` | `create_indexes` / `ensure_energy_report_indexes` / `add_columns_if_not_exists` | 第 1、3 条上抛,第 2 条(纯性能优化)只记 ERROR | | `align_legacy_structures` | `align_legacy_columns` / `ensure_active_order_unique` | 均只记 ERROR(业务侧已有兜底) | | `seed_defaults` | `seed_permissions` / `seed_roles` | ⛔ 上抛(没权限码平台等于不可用) | `run` 本体只剩 5 行调用 + 收尾日志。**顺序即依赖顺序**,与拆分前逐条一致。 ## 大段解释性注释:上移为 `///` 文档,不是删除 `align_column_metadata` 一度仍有 123 行 —— 但**里面全是解释性注释**(为什么 `compartments.status` 旧注释是错的、`cabinets.status` 为什么不是"在线"、 `charge_orders.status` 的 `2` 为何保留不用)。 这些说明**逐字上移到该函数的 `///` 文档**(rustdoc 同样渲染、读者同样能看到), 函数体因此降到 80 行以内 —— **零信息损失**。 ## 验证(三条机械判据,全部可复跑) 新增验证器 `.git/verify_refactor.py`(本地工具,不入库),对任意文件证明"只搬代码、未改语义": 1. **被移除的非注释代码行 = 0** —— 即所有原可执行语句都还在(本次实测 0 种); 2. 新增的非注释代码行**只有脚手架** —— 实测仅 5 个 `async fn` 签名 + 5 个 `}` + 5 个 `Ok(())` + 5 条调用语句; 3. 文件内 `r#"..."#` **SQL 字面量逐条与 HEAD 相同** —— 实测 7 条全同。 > ⚠️ 这个验证器**先被我自己的自证抓出过一个假绿**:早期版本用反斜杠路径调 > `git show HEAD:`,失败后"跳过并返回通过" —— 等于什么都没查。 > 已修:git 路径强制正斜杠;已跟踪文件取不到 HEAD 内容时**判为"验证器失效/本项未验证"并 exit 1**, > 绝不当成通过。并实测:植入 `VARCHAR(20)→VARCHAR(30)` 后 **exit 1 且列出被移除的代码行**。 - `cargo check --all-targets`:零 error / 零 warning; - `cargo test --bin pms-api`:**182 passed**(与基线一致,未减少); - 长函数总数:**8 → 7**(`migrate.rs::run` 已消除)。 其余 7 个(`hooks.rs::login` 95、`report_worker.rs::handle_auth_str` 88、 `middleware/auth.rs::auth_middleware` 87、`h5.rs::get_compartment_detail` 87、 `report_worker.rs::settle_finished_order` 85、`charge_orders.rs::list_charge_orders` 84、 `cabinets.rs::get_cabinet_detail` 83)沿用同一套改法与验证判据继续处理。 --- software/api-server/src/db/migrate.rs | 182 +++++++++++++++++--------- 1 file changed, 123 insertions(+), 59 deletions(-) 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(()) }