//! 设备管理 API — CRUD + 详情 + 级联下拉 //! //! 提供柜子的增删改查、详情查询、安全码重新生成, //! 以及柜子/仓板/仓体的级联下拉选项。 use axum::extract::{Path, Query, State}; use axum::Json; use redis; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sqlx::MySqlPool; use super::organizations::{ generate_auth_str, CabinBoardRow, CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody, }; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; use super::scope_guard; /// 基础柜子行(只包含数据库字段,用于简单查询) #[derive(Debug, Clone, Serialize, sqlx::FromRow)] struct CabinetBasicRow { id: i64, project_id: Option, abstract_id: Option, imei: String, iccid: Option, name: Option, address: Option, latitude: Option, longitude: Option, status: i8, /// 充电工作模式:0=平台指令模式 1=自动充电工作模式 work_mode: Option, } impl From for super::organizations::CabinetRow { fn from(row: CabinetBasicRow) -> Self { super::organizations::CabinetRow { id: row.id, project_id: row.project_id, abstract_id: row.abstract_id, imei: row.imei, iccid: row.iccid, name: row.name, address: row.address, status: row.status, board_count: None, channel_count: None, idle_count: None, charging_count: None, full_count: None, fault_count: None, rssi: None, pow_fail_dc: None, pow_fail_ac: None, is_online: None, org_disabled: None, project_address: None, } } } // --------------------------------------------------------------------------- // 项目访问权限验证 // --------------------------------------------------------------------------- /// 验证用户是否有权访问指定项目 /// /// 总管理员(role_level >= 2)可访问所有项目; /// 企业管理员只能访问本组织下的项目。 async fn check_project_access( user: &CurrentUser, pool: &MySqlPool, project_id: i64, ) -> Result<(), AppError> { // 总管理员可访问所有项目 if user.role_level >= 2 { return Ok(()); } if let Some(org_id) = user.organization_id { // 验证项目是否属于用户的组织 let project: Option<(i64,)> = sqlx::query_as( "SELECT id FROM projects WHERE id = ? AND organization_id = ?", ) .bind(project_id) .bind(org_id) .fetch_optional(pool) .await?; if project.is_none() { return Err(AppError::Forbidden("无权访问该项目".into())); } } else { return Err(AppError::Forbidden("无组织关联".into())); } Ok(()) } // --------------------------------------------------------------------------- // 设备 CRUD // --------------------------------------------------------------------------- /// GET /api/projects/:project_id/cabinets — 按项目查询柜子列表 pub async fn list_cabinets( user: CurrentUser, State(state): State, Path(project_id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; check_project_access(&user, &state.mysql, project_id).await?; let db = &state.mysql; let sql_rows = sqlx::query_as::<_, CabinetBasicRow>( r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status, work_mode FROM cabinets WHERE project_id = ? ORDER BY id"#, ) .bind(project_id) .fetch_all(db) .await?; let rows: Vec = sql_rows.into_iter().map(Into::into).collect(); Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } /// POST /api/cabinets — 批量添加柜子 /// /// 每个 IMEI 创建一个柜子,同时生成默认仓控板和 6 个仓体。 pub async fn create_cabinet( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:create")?; scope_guard::verify_project_ownership(&state.mysql, &user, body.project_id).await?; let db = &state.mysql; if body.imeis.is_empty() { return Err(AppError::BadRequest("IMEI 列表不能为空".into())); } let mut unique_imeis: Vec = Vec::new(); for imei in &body.imeis { let trimmed = imei.trim().to_string(); if trimmed.len() != 15 { return Err(AppError::BadRequest( format!("IMEI 必须为15位数字: {}", trimmed), )); } if !unique_imeis.contains(&trimmed) { unique_imeis.push(trimmed); } } let mut created_ids: Vec = Vec::new(); for imei in &unique_imeis { let auth_str = generate_auth_str(); let mut tx = db.begin().await.map_err(AppError::Database)?; let result = sqlx::query( r#"INSERT INTO cabinets (project_id, imei, auth_str) VALUES (?, ?, ?)"#, ) .bind(body.project_id) .bind(imei) .bind(&auth_str) .execute(&mut *tx) .await?; let cabinet_id = result.last_insert_id() as i64; let board_result = sqlx::query( r#"INSERT INTO cabin_boards (cabinet_id, board_id) VALUES (?, 1)"#, ) .bind(cabinet_id) .execute(&mut *tx) .await?; let board_pk = board_result.last_insert_id() as i64; for ch in 1..=6 { sqlx::query( r#"INSERT INTO compartments (cabin_board_id, channel_id) VALUES (?, ?)"#, ) .bind(board_pk) .bind(ch) .execute(&mut *tx) .await?; } tx.commit().await.map_err(AppError::Database)?; created_ids.push(cabinet_id); tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei); } Ok(Json(json!({ "code": 0, "data": { "ids": created_ids }, "message": "ok" }))) } /// PUT /api/cabinets/:id — 更新柜子(划拨项目等) pub async fn update_cabinet( user: CurrentUser, State(state): State, Path(id): Path, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; let rows = sqlx::query( r#"UPDATE cabinets SET project_id = COALESCE(?, project_id), name = COALESCE(?, name), abstract_id = COALESCE(?, abstract_id) WHERE id = ?"#, ) .bind(body.project_id) .bind(&body.name) .bind(&body.abstract_id) .bind(id) .execute(db) .await? .rows_affected(); if rows == 0 { return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); } tracing::info!("更新柜子 id={}", id); Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } /// 柜子状态更新请求体 #[derive(Debug, Deserialize)] pub struct SetCabinetStatusBody { pub status: i8, } /// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用) /// /// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。 pub async fn set_cabinet_status( user: CurrentUser, State(state): State, Path(id): Path, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; // 禁/启用操作仅总管理员可执行 if user.role_level < 2 { return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into())); } let db = &state.mysql; let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?") .bind(body.status) .bind(id) .execute(db) .await? .rows_affected(); if rows == 0 { return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); } let action = if body.status == 2 { "禁用" } else { "启用" }; tracing::info!("{}柜子 id={}", action, id); Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } /// 充电工作模式更新请求体(前端契约:字符串 "0" / "1") #[derive(Debug, Deserialize)] pub struct UpdateWorkModeBody { pub work_mode: String, } /// PUT /api/cabinets/:id/work-mode — 设置充电工作模式 /// /// 0=平台指令模式(默认,需平台下发指令才充电) /// 1=自动充电工作模式(设备自主充电) /// 权限:device:edit(编辑设备),并校验柜子存在 + 组织归属。 pub async fn update_work_mode( user: CurrentUser, State(state): State, Path(id): Path, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; // 组织隔离:非总管理员只能改本组织项目的柜子 scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; // 白名单取值,拒绝一切其它输入(含 "2"、"true" 等) let mode: i8 = match body.work_mode.trim() { "0" => 0, "1" => 1, other => { return Err(AppError::BadRequest(format!( "work_mode 只能为 \"0\"(平台指令模式) 或 \"1\"(自动充电模式),收到: {}", other ))) } }; let rows = sqlx::query("UPDATE cabinets SET work_mode = ? WHERE id = ?") .bind(mode) .bind(id) .execute(&state.mysql) .await? .rows_affected(); // 总管理员路径下 verify_cabinet_ownership 直接放行,这里兜底不存在的情况 if rows == 0 { return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); } tracing::info!("设置充电工作模式 id={} work_mode={}", id, mode); Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "ok" }))) } /// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体) pub async fn delete_cabinet( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:delete")?; scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; let mut tx = db.begin().await.map_err(AppError::Database)?; sqlx::query( r#"DELETE compartments FROM compartments INNER JOIN cabin_boards ON compartments.cabin_board_id = cabin_boards.id WHERE cabin_boards.cabinet_id = ?"#, ) .bind(id) .execute(&mut *tx) .await?; sqlx::query(r#"DELETE FROM cabin_boards WHERE cabinet_id = ?"#) .bind(id) .execute(&mut *tx) .await?; let rows = sqlx::query(r#"DELETE FROM cabinets WHERE id = ?"#) .bind(id) .execute(&mut *tx) .await? .rows_affected(); tx.commit().await.map_err(AppError::Database)?; if rows == 0 { return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); } tracing::info!("删除柜子 id={}", id); Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } /// POST /api/cabinets/:id/bind — 绑定平台抽象ID pub async fn bind_cabinet( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; // 检查柜子是否存在且未绑定 let bound: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM cabinets WHERE id = ? AND abstract_id IS NULL" ) .bind(id) .fetch_one(db) .await?; if bound.0 == 0 { return Err(AppError::BadRequest("柜子不存在或已绑定".into())); } // 事务:插入绑定记录 + 回写设备表 let mut tx = db.begin().await.map_err(AppError::Database)?; let result = sqlx::query( "INSERT INTO abstract_bindings (cabinet_id) VALUES (?)" ) .bind(id) .execute(&mut *tx) .await?; let binding_id = result.last_insert_id() as u64; let abstract_id = format!("10-{:08X}", binding_id); sqlx::query( "UPDATE cabinets SET abstract_id = ? WHERE id = ?" ) .bind(&abstract_id) .bind(id) .execute(&mut *tx) .await?; tx.commit().await.map_err(AppError::Database)?; tracing::info!("绑定柜子 id={} abstract_id={}", id, abstract_id); Ok(Json(json!({ "code": 0, "data": { "abstract_id": abstract_id }, "message": "ok" }))) } /// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组) pub async fn replace_imei( user: CurrentUser, State(state): State, Path(id): Path, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let imei = body.imei.trim().to_string(); if imei.len() != 15 || !imei.chars().all(|c| c.is_ascii_digit()) { return Err(AppError::BadRequest("IMEI 必须为15位数字".into())); } let db = &state.mysql; // 检查柜子是否存在 let exists: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cabinets WHERE id = ?") .bind(id) .fetch_one(db) .await?; if exists.0 == 0 { return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); } // 检查新IMEI是否已被其他柜子使用 let conflict: Vec<(i64, Option)> = sqlx::query_as( "SELECT id, name FROM cabinets WHERE imei = ? AND id != ?" ) .bind(&imei) .bind(id) .fetch_all(db) .await?; if let Some(row) = conflict.first() { let name = row.1.as_deref().unwrap_or("未知"); return Err(AppError::Conflict( format!("IMEI {} 已被柜子 {} ({}) 使用", imei, row.0, name) )); } // 更新IMEI sqlx::query("UPDATE cabinets SET imei = ? WHERE id = ?") .bind(&imei) .bind(id) .execute(db) .await?; tracing::info!("更换IMEI id={} new_imei={}", id, imei); Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } /// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码 pub async fn regenerate_auth( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { // 仅总管理员可重置安全码:device:auth_str:reset 不在企业管理员/操作员的权限码表中, // 且 check_permission 对 role_level >= 2 直接放行,因此等价于「仅总管理员可用」 auth::check_permission(&user, "device:auth_str:reset")?; scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; let auth_str = generate_auth_str(); let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ?, auth_str_issued = 0 WHERE id = ?"#) .bind(&auth_str) .bind(id) .execute(db) .await? .rows_affected(); // 同步 Redis: // 1. 用新码覆盖 auth_str:{imei} —— 旧安全码【立即失效】,设备继续用旧码 login 会收到「签名错误」, // 从而触发设备重新调用 auth_str 申领新码,形成自愈闭环 // 2. 清除 auth_str:issued:{imei} 标记 —— 允许设备重新申领 let imei: Option = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") .bind(id) .fetch_optional(db) .await?; if let Some(imei) = imei { let code_key = format!("auth_str:{}", imei); let issued_key = format!("auth_str:issued:{}", imei); let mut conn = state.redis.clone(); if let Err(e) = redis::cmd("SET") .arg(&code_key) .arg(&auth_str) .query_async::<()>(&mut conn) .await { tracing::error!("[regenerate_auth] SET {} 失败: {}", code_key, e); } let _: Result<(), _> = redis::cmd("DEL") .arg(&issued_key) .query_async(&mut conn) .await; } if rows == 0 { return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); } tracing::info!("重新生成 auth_str id={}", id); Ok(Json(json!({ "code": 0, "data": { "auth_str": auth_str }, "message": "ok" }))) } // --------------------------------------------------------------------------- // 柜子详情(含仓控板 + 仓体) // --------------------------------------------------------------------------- /// GET /api/cabinets/:id — 柜子详情,包含仓控板和仓体 pub async fn get_cabinet_detail( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let db = &state.mysql; let cabinet = sqlx::query_as::<_, CabinetBasicRow>( r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude, status, work_mode FROM cabinets WHERE id = ?"#, ) .bind(id) .fetch_optional(db) .await? .ok_or_else(|| AppError::NotFound(format!("柜子 {} 不存在", id)))?; // 验证用户是否有权访问该柜子所属的项目 if let Some(pid) = cabinet.project_id { check_project_access(&user, db, pid).await?; } // 仓控板 + 仓体(含实时字段占位),并收集通道 Redis key 所需的映射 let (mut cabin_boards, comp_ids, comp_to_board) = load_boards_with_compartments(db, id).await?; // 一次 pipeline 读取柜级 + 通道级实时状态;Redis 异常一律降级,不让详情接口整体失败 let realtime = fetch_cabinet_realtime(&state.redis_url, &cabinet.imei, &comp_ids, &comp_to_board).await; apply_realtime(&mut cabin_boards, &realtime); // 查询项目名称 let project_name: Option = if let Some(pid) = cabinet.project_id { sqlx::query_scalar("SELECT name FROM projects WHERE id = ?") .bind(pid) .fetch_optional(db) .await .ok() .flatten() } else { None }; Ok(Json(json!({ "code": 0, "data": { "id": cabinet.id, "project_id": cabinet.project_id, "project_name": project_name, "abstract_id": cabinet.abstract_id, "imei": cabinet.imei, "iccid": cabinet.iccid, "name": cabinet.name, "status": cabinet.status, // 充电工作模式:字符串 "0"/"1"(前端契约),无值时为 null "work_mode": cabinet.work_mode.map(|m| m.to_string()), "rssi": realtime.rssi, "pow_fail_dc": realtime.pow_fail_dc, "pow_fail_ac": realtime.pow_fail_ac, // 柜温:Redis `device:{imei}.temp`,语义 K×100(与柜级 temp 口径一致); // 取不到为 null(前端显示 `--°C`),不落 0 "temp": realtime.temp, "address": cabinet.address, "latitude": cabinet.latitude, "longitude": cabinet.longitude, "cabin_boards": cabin_boards, }, "message": "ok" }))) } // --------------------------------------------------------------------------- // 设备详情 — Redis 实时状态(柜级 + 通道级) // --------------------------------------------------------------------------- /// 详情页实时状态快照;Redis 不可用时整体为默认空值 /// /// 所有字段一律 `Option`:Redis 里取不到就保持 `None`(响应 JSON 为 `null`), /// **绝不落成 0/空串** —— 0 是"真实读数为 0",与"无数据"必须区分。 #[derive(Default)] struct CabinetRealtime { rssi: Option, pow_fail_dc: Option, pow_fail_ac: Option, /// 柜温,平台单位 K×100(Redis `device:{imei}` 的 `temp`) temp: Option, /// 仓体 id -> (SOC, 电池ID) soc_map: std::collections::HashMap, Option)>, /// 仓体 id -> 通道级实时状态 ch_map: std::collections::HashMap, } /// 单通道实时状态(与 `report_worker::write_channel_state` 写入的 Hash 字段一一对应) /// /// 单位口径与 `docs/API.md` 一致:`soc` ‰ / `voltage` mV / `current` mA / `temp` K×100, /// `batt_status` / `pow_status` 为 2 位 hex 字符串。 #[derive(Default, Clone, Debug)] struct ChannelRealtime { /// 电芯最高温度,单位 K×100 temp: Option, /// 电池状态(2 位 hex) batt_status: Option, /// 电源状态(2 位 hex) pow_status: Option, /// 充电电压,单位 mV voltage: Option, /// 充电电流,单位 mA current: Option, } /// pipeline 中柜级(`device:{imei}`)HGET 的字段个数:rssi / pow_fail_dc / pow_fail_ac / temp。 /// 与 [`fetch_cabinet_realtime`] 下发的命令顺序、[`parse_cabinet_realtime`] 的下标解析 /// **三者必须同步修改**,故抽成常量。 const CABINET_REALTIME_FIELDS: usize = 4; /// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current const CHANNEL_REALTIME_FIELDS: usize = 5; /// 查询仓控板与仓体,构建带实时字段占位的 JSON /// /// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`, /// 后两者供 Redis 批量查询通道级状态使用(板号/通道号为十进制不补零)。 async fn load_boards_with_compartments( db: &MySqlPool, cabinet_id: i64, ) -> Result<(Vec, Vec, Vec<(i64, (i8, i8))>), AppError> { let boards: Vec = sqlx::query_as( r#"SELECT id, cabinet_id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"#, ) .bind(cabinet_id) .fetch_all(db) .await?; let mut cabin_boards: Vec = Vec::new(); let mut comp_ids: Vec = Vec::new(); let mut comp_to_board: Vec<(i64, (i8, i8))> = Vec::new(); for board in boards { let compartments: Vec = sqlx::query_as( r#"SELECT id, cabin_board_id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"#, ) .bind(board.id) .fetch_all(db) .await?; for c in &compartments { comp_ids.push(c.id); comp_to_board.push((c.id, (board.board_id, c.channel_id))); } // batt_status / pow_status 为 2 位 hex 字符串,Redis 缺失时保持 null let comp_values: Vec = compartments .into_iter() .map(|c| { json!({ "id": c.id, "channel_id": c.channel_id, "status": c.status, "batt_status": Value::Null, "pow_status": Value::Null, // 通道级实时字段占位:Redis 缺失时保持 null(**不落 0**, // 前端 `c.voltage ?? '-'` 据此显示"-"而不是"-mV") "voltage": Value::Null, "current": Value::Null, "temp": Value::Null, }) }) .collect(); cabin_boards.push(json!({ "id": board.id, "board_id": board.board_id, "status": board.status, "compartments": comp_values, })); } Ok((cabin_boards, comp_ids, comp_to_board)) } /// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态 /// /// 为什么放一起:详情接口要求低延迟,柜级 4 个字段 + 每通道 5 个字段共用一次网络往返。 /// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 /// /// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段: /// 设备上报(`report_worker::write_device_state` / `write_channel_state`)早就写进了 Redis, /// 只是接口没取,导致 PC 端柜温恒为 `--°C`、电压电流恒为 `-mV -mA`。 async fn fetch_cabinet_realtime( redis_url: &str, imei: &str, comp_ids: &[i64], comp_to_board: &[(i64, (i8, i8))], ) -> CabinetRealtime { let client = match redis::Client::open(redis_url) { Ok(c) => c, Err(e) => { tracing::warn!("[cabinet_detail] Redis 客户端创建失败,实时状态降级: {}", e); return CabinetRealtime::default(); } }; // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection let mut conn = match client.get_multiplexed_async_connection().await { Ok(c) => c, Err(e) => { tracing::warn!("[cabinet_detail] Redis 连接失败,实时状态降级: {}", e); return CabinetRealtime::default(); } }; let key = format!("device:{}", imei); let mut pipe = redis::pipe(); pipe.cmd("HGET").arg(&key).arg("rssi") .cmd("HGET").arg(&key).arg("pow_fail_dc") .cmd("HGET").arg(&key).arg("pow_fail_ac") .cmd("HGET").arg(&key).arg("temp"); for cid in comp_ids { pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); } // 通道级:板号/通道号为十进制不补零,与设备上报写入的 key 保持一致 for (_, (board_id, channel_id)) in comp_to_board { let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id); pipe.cmd("HGET").arg(&rkey).arg("temp") .cmd("HGET").arg(&rkey).arg("batt_status") .cmd("HGET").arg(&rkey).arg("pow_status") .cmd("HGET").arg(&rkey).arg("voltage") .cmd("HGET").arg(&rkey).arg("current"); } // 用类型标注而非 turbofish:query_async 的泛型首参是连接类型 let queried: Result>, redis::RedisError> = pipe.query_async(&mut conn).await; match queried { Ok(vals) => parse_cabinet_realtime(&vals, comp_ids, comp_to_board), Err(e) => { tracing::warn!("[cabinet_detail] Redis pipeline 读取失败,实时状态降级 imei={} err={}", imei, e); CabinetRealtime::default() } } } /// 解析 pipeline 结果:柜级 [`CABINET_REALTIME_FIELDS`] 项 + 每仓体 1 项(SOC JSON) /// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 项(temp/batt_status/pow_status/voltage/current) /// /// 任何一项缺失或解析失败 → 对应字段保持 `None`(**不落 0**)。 fn parse_cabinet_realtime( vals: &[Option], comp_ids: &[i64], comp_to_board: &[(i64, (i8, i8))], ) -> CabinetRealtime { /// 取出第 i 项的非空字符串 fn pick(vals: &[Option], i: usize) -> Option { vals.get(i).and_then(|v| v.as_ref()).cloned() } /// 取出第 i 项并解析为 i64(空值/非数字 → None) fn pick_i64(vals: &[Option], i: usize) -> Option { pick(vals, i).and_then(|v| v.trim().parse::().ok()) } let mut rt = CabinetRealtime { rssi: pick(vals, 0).and_then(|v| v.parse().ok()), pow_fail_dc: pick(vals, 1).map(|v| v == "1"), pow_fail_ac: pick(vals, 2).map(|v| v == "1"), temp: pick_i64(vals, 3), ..Default::default() }; for (i, cid) in comp_ids.iter().enumerate() { if let Some(data) = pick(vals, CABINET_REALTIME_FIELDS + i) { if let Ok(v) = serde_json::from_str::(&data) { let soc = v.get("soc").and_then(|s| s.as_i64()); let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string); rt.soc_map.insert(*cid, (soc, bid)); } } } // 通道级字段紧随 SOC 段之后,每通道 CHANNEL_REALTIME_FIELDS 项 let base = CABINET_REALTIME_FIELDS + comp_ids.len(); for (i, (cid, _)) in comp_to_board.iter().enumerate() { let off = base + i * CHANNEL_REALTIME_FIELDS; rt.ch_map.insert( *cid, ChannelRealtime { temp: pick_i64(vals, off), batt_status: pick(vals, off + 1), pow_status: pick(vals, off + 2), voltage: pick_i64(vals, off + 3), current: pick_i64(vals, off + 4), }, ); } rt } /// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串,也绝不落 0 fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) { for board in cabin_boards.iter_mut() { let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else { continue; }; for comp in comps.iter_mut() { let Some(cid) = comp.get("id").and_then(|i| i.as_i64()) else { continue; }; if let Some((soc, bid)) = rt.soc_map.get(&cid) { if let Some(s) = soc { comp["soc"] = json!(s); } if let Some(b) = bid { comp["battery_id"] = json!(b); } } if let Some(ch) = rt.ch_map.get(&cid) { if let Some(t) = ch.temp { comp["temp"] = json!(t); } if let Some(bs) = &ch.batt_status { comp["batt_status"] = json!(bs); } if let Some(ps) = &ch.pow_status { comp["pow_status"] = json!(ps); } if let Some(v) = ch.voltage { comp["voltage"] = json!(v); } if let Some(c) = ch.current { comp["current"] = json!(c); } } } } } // --------------------------------------------------------------------------- // 级联下拉选项 // --------------------------------------------------------------------------- /// 柜子下拉查询参数 #[derive(Debug, Deserialize)] pub struct CabinetListQuery { pub project_id: Option, } /// 仓板下拉查询参数 #[derive(Debug, Deserialize)] pub struct CabinBoardListQuery { pub cabinet_id: i64, } /// 仓体下拉查询参数 #[derive(Debug, Deserialize)] pub struct CompartmentListQuery { pub cabin_board_id: i64, } /// 柜子下拉选项 #[derive(Debug, Serialize, sqlx::FromRow)] pub struct CabinetOption { pub id: i64, pub name: Option, pub abstract_id: String, pub imei: Option, pub iccid: Option, pub address: Option, pub status: i8, } /// 仓板下拉选项 #[derive(Debug, Serialize, sqlx::FromRow)] pub struct CabinBoardOption { pub id: i64, pub board_id: i8, } /// 仓体下拉选项 #[derive(Debug, Serialize, sqlx::FromRow)] pub struct CompartmentOption { pub id: i64, pub channel_id: i8, } /// GET /api/cabinets/options — 柜子下拉列表 pub async fn list_cabinet_options( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let rows = if let Some(pid) = params.project_id { sqlx::query_as::<_, CabinetOption>( "SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets WHERE project_id = ? ORDER BY id" ) .bind(pid) .fetch_all(&state.mysql) .await? } else { sqlx::query_as::<_, CabinetOption>( "SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets ORDER BY id" ) .fetch_all(&state.mysql) .await? }; Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } /// GET /api/cabin-boards/options — 仓板下拉列表 pub async fn list_cabin_board_options( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let rows = sqlx::query_as::<_, CabinBoardOption>( "SELECT id, board_id FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id" ) .bind(params.cabinet_id) .fetch_all(&state.mysql) .await?; Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } /// GET /api/compartments/options — 仓体下拉列表 pub async fn list_compartment_options( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let rows = sqlx::query_as::<_, CompartmentOption>( "SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id" ) .bind(params.cabin_board_id) .fetch_all(&state.mysql) .await?; Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } // --------------------------------------------------------------------------- // 单元测试 // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// pipeline 结果解析:柜级 4 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局, /// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 Redis 但接口不取) #[test] fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() { let comp_ids = vec![11_i64, 12_i64]; let comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))]; let vals: Vec> = vec![ // 柜级:rssi / pow_fail_dc / pow_fail_ac / temp Some("85".into()), Some("1".into()), Some("0".into()), Some("29815".into()), // 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None) None, None, // 通道 11:temp / batt_status / pow_status / voltage / current Some("29815".into()), Some("82".into()), Some("03".into()), Some("53200".into()), Some("1500".into()), // 通道 12 Some("30015".into()), Some("02".into()), Some("01".into()), Some("48000".into()), Some("0".into()), ]; let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); assert_eq!(rt.rssi, Some(85)); assert_eq!(rt.pow_fail_dc, Some(true)); assert_eq!(rt.pow_fail_ac, Some(false)); assert_eq!(rt.temp, Some(29815), "柜温应被取出"); let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态"); assert_eq!(ch11.voltage, Some(53200), "电压应被取出"); assert_eq!(ch11.current, Some(1500), "电流应被取出"); assert_eq!(ch11.temp, Some(29815)); assert_eq!(ch11.batt_status.as_deref(), Some("82")); assert_eq!(ch11.pow_status.as_deref(), Some("03")); let ch12 = rt.ch_map.get(&12).expect("通道 12 应有实时状态"); // 0 是**真实读数**,必须原样保留(不能与"无数据"混为一谈) assert_eq!(ch12.current, Some(0)); assert_eq!(ch12.voltage, Some(48000)); } /// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0) #[test] fn test_parse_cabinet_realtime_missing_fields_stay_none() { let comp_ids = vec![7_i64]; let comp_to_board = vec![(7_i64, (1_i8, 1_i8))]; // 柜级 4 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失 let vals: Vec> = vec![ None, None, None, None, None, Some("29815".into()), Some("82".into()), Some("03".into()), Some("abc".into()), None, ]; let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); assert_eq!(rt.temp, None); assert_eq!(rt.rssi, None); let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)"); assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0"); assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0"); assert_eq!(ch.temp, Some(29815)); } /// apply_realtime:只覆盖取到的值,取不到的字段保持占位 null #[test] fn test_apply_realtime_keeps_null_when_missing() { let mut wrap = vec![json!({ "compartments": [{ "id": 11, "channel_id": 1, "status": 0, "batt_status": Value::Null, "pow_status": Value::Null, "voltage": Value::Null, "current": Value::Null, "temp": Value::Null, }] })]; let mut ch_map = std::collections::HashMap::new(); ch_map.insert( 11_i64, ChannelRealtime { temp: None, batt_status: Some("82".into()), pow_status: None, voltage: Some(53200), current: None, }, ); let rt = CabinetRealtime { ch_map, ..Default::default() }; apply_realtime(&mut wrap, &rt); let comp = &wrap[0]["compartments"][0]; assert_eq!(comp["batt_status"], json!("82")); assert_eq!(comp["voltage"], json!(53200)); assert_eq!(comp["current"], Value::Null, "缺失电流必须保持 null"); assert_eq!(comp["temp"], Value::Null, "缺失温度必须保持 null"); assert_eq!(comp["pow_status"], Value::Null); } }