refactor(api-server): 拆分 db/migrate.rs::run(174 → 20 行),行为零改动
`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:<path>`,失败后"跳过并返回通过" —— 等于什么都没查。 > 已修: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)沿用同一套改法与验证判据继续处理。
This commit is contained in:
parent
401f84bda6
commit
86a52ce293
@ -266,15 +266,104 @@ 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
|
||||
/// 阶段 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` 的筛选下拉
|
||||
/// 提供 `<Option value={2}>异常</Option>`、`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,
|
||||
@ -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` 的筛选下拉
|
||||
// 提供 `<Option value={2}>异常</Option>`、`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(())
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user