//! 设备管理 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::cabinet_tree::derive_channel_flags; 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, // 列表(按项目查柜子)不读 Redis,柜级位域如实为 null status1: None, status2: 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 (scope_cond, scope_binds) = user.effective_scope().project_condition("id"); let sql = format!( "SELECT id FROM projects WHERE id = ? AND organization_id = ?{}", scope_cond ); let mut query = sqlx::query_as::<_, (i64,)>(&sql).bind(project_id).bind(org_id); for bind in &scope_binds { query = query.bind(bind); } let project: Option<(i64,)> = query.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; // ⚠️ 字段必须与 `CabinetBasicRow` 一一对应(含 `latitude`/`longitude`): // 少了经纬度这一列,`FromRow` 解码会直接报 `no column found for name: latitude` // 并以 `DATABASE_ERROR` 500 掉整个列表接口(历史缺陷,本次随数据范围改造一并修掉)。 // 经纬度与详情接口同口径:`CAST(... AS CHAR)`,规避 sqlx 对 DECIMAL 的解码问题。 let sql_rows = 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 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?; } // 数据范围兜底:**未划拨到项目的柜子**(`project_id` 为空)走不到上面的项目校验, // 配了范围的用户一律不可见(`allows_project(None)` 在有限制时为 false)。 if !user.effective_scope().allows_project(cabinet.project_id) { return Err(AppError::Forbidden("无权访问该设备".into())); } // 仓控板 + 仓体(含实时字段占位),并收集通道 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, // **柜级**状态位(协议 §3.8):Redis `device:{imei}` 的 `cabinet_status1/2` // 原样透出 2 位小写 hex 字符串(**不在后端解析成布尔**,前端两端有统一纯函数); // 取不到为 null —— 与 `"00"`("全部正常"的有效值)严格区分,绝不瞎填 "00" "status1": realtime.status1, "status2": realtime.status2, "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, /// **柜级**柜体状态1(协议 §3.8,Redis `device:{imei}` 的 `cabinet_status1`) /// /// 2 位小写 hex 字符串**原样透出**(如 `"10"` = 柜门打开、`"82"` = 喷淋 + 烟雾), /// **不在 Rust 里解析成布尔** —— 它是位域,前端两端有统一的 hex 解析纯函数 /// (`web/src/utils/deviceStatus.ts` / `h5-uniapp/src/utils/deviceStatus.ts` 的 /// `parseCabinetStatus1`),与既有 `batt_status` / `pow_status` 的做法完全一致。 /// /// ⚠️ `None`(响应 `null`)= **无数据**,与 `Some("00")`(**全正常**的有效值)语义不同, /// 绝不能把缺失落成 `"00"`。 status1: Option, /// **柜级**柜体状态2(协议 §3.8,Redis `device:{imei}` 的 `cabinet_status2`) /// /// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。 /// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。 status2: 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` / `cabinet_status1` / `cabinet_status2`。 /// /// ⚠️ **命令顺序与下标一一对应**:本常量同时是 [`fetch_cabinet_realtime`] 下发 `HGET` 的 /// 顺序(下标 0..[`CABINET_REALTIME_FIELDS`])与 [`parse_cabinet_realtime`] 的柜级下标解析依据, /// 还决定了 SOC 段(`CABINET_REALTIME_FIELDS + i`)与通道段(`+ comp_ids.len()`)的起始偏移。 /// **新增柜级字段必须同步改这一处,且只能追加在末尾**(改动即破坏下标对齐)。 const CABINET_REALTIME_FIELDS: usize = 6; /// 柜级 hex 位域字段在 pipeline 结果中的下标(协议 §3.8,**柜级**:`cabinet.status1` / `status2`) const CABINET_STATUS1_IDX: usize = 4; const CABINET_STATUS2_IDX: usize = 5; /// 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}) 实时状态 /// /// 为什么放一起:详情接口要求低延迟,柜级 6 个字段 + 每通道 5 个字段共用一次网络往返。 /// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 /// /// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段: /// 设备上报(`report_worker::write_device_state` / `write_channel_state`)早就写进了 Redis, /// 只是接口没取,导致 PC 端柜温恒为 `--°C`、电压电流恒为 `-mV -mA`。 /// /// 柜级第 5、6 个字段(`cabinet_status1` / `cabinet_status2`,协议 §3.8)同样是**补取**: /// `report_worker::handle_status_post` 一直在写,但此前全仓无人读 —— 本次透出为 /// 响应根字段 `status1` / `status2`(**柜级**语义,前端按层级展示在"柜子"层面)。 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); // ⚠️ 命令顺序必须与 CABINET_REALTIME_FIELDS / CABINET_STATUS*_IDX 及 // parse_cabinet_realtime 的下标解析**完全一致**(新增柜级字段只能追加在末尾) 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") // 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析 .cmd("HGET").arg(&key).arg("cabinet_status1") .cmd("HGET").arg(&key).arg("cabinet_status2"); 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()) } /// 取出第 i 项的非空字符串:`None` 或**纯空白**都视为"无数据"(Redis 里不该有空值) fn pick_non_empty(vals: &[Option], i: usize) -> Option { pick(vals, i).filter(|v| !v.trim().is_empty()) } 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), // 柜级位域:**原样透出 hex**(不解析成布尔 —— 位域不是单标志); // 缺数据时保持 None → 响应 null,**绝不落成 "00"**("00" 是"全部正常"的有效值) status1: pick_non_empty(vals, CABINET_STATUS1_IDX), status2: pick_non_empty(vals, CABINET_STATUS2_IDX), ..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 /// /// 另外把每个仓体的 `status` 从「DB 占用值(0/1)」改写成**派生展示值** /// (有通道数据时 `0空闲 / 1充电中 / 2充满 / 3故障`;无数据时保持 DB 值), /// 因为 `compartments.status` 只表达占用,DB 里永远看不到"充满/故障"。 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) { // 展示状态改为**派生值**:有通道实时数据时按 // fault > full > charging > idle → `3故障 / 2充满 / 1充电中 / 0空闲` // (与 `cabinet_tree::derive_channel_flags` 唯一权威规则同源, // 前端 `STATUS_MAP` 只按 0/1/2/3 渲染,**没有"禁用"档**); // 拿不到数据时**保留上面写入的 DB 占用值(0/1)** —— 离线无法判定,不猜测。 let flags = derive_channel_flags(ch.batt_status.as_deref(), ch.pow_status.as_deref()); if flags.has_data { comp["status"] = json!(flags.status_code()); } 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, } /// 柜子下拉列表的固定列清单(列表/详情共用同一组字段,避免两处漂移) const CABINET_OPTION_COLUMNS: &str = "id, name, abstract_id, imei, iccid, address, status"; /// GET /api/cabinets/options — 柜子下拉列表 /// /// **两道收窄(取交集)**: /// 1. **组织隔离**([`auth::org_condition_for_project`])—— 无条件叠加: /// 总管理员全量、客户侧仅本组织、无组织空集(**绝不退化成全量**); /// 2. **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。 /// /// 历史缺陷:本接口只叠加了数据范围、**没有组织隔离** ⇒ 未配置 scope 的客户账号 /// 不带 `project_id` 调用时能看到**全平台所有柜子**(含 IMEI/ICCID/地址)。 /// `project_id` 入参只是纯筛选,不构成归属边界。 pub async fn list_cabinet_options( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; // 动态拼 WHERE:项目筛选 → 组织隔离 → 数据范围收窄, // 书写顺序 = 占位符顺序 = 绑定顺序,三者严格一致 let mut where_clause = String::new(); let mut binds: Vec = Vec::new(); if let Some(pid) = params.project_id { where_clause.push_str("project_id = ?"); binds.push(pid.to_string()); } let (org_cond, org_binds) = auth::org_condition_for_project(&user, "project_id"); auth::append_condition(&mut where_clause, &org_cond); binds.extend(org_binds); let (scope_cond, scope_binds) = user.effective_scope().project_condition("project_id"); auth::append_condition(&mut where_clause, &scope_cond); binds.extend(scope_binds); let mut sql = format!("SELECT {} FROM cabinets", CABINET_OPTION_COLUMNS); if !where_clause.is_empty() { sql.push_str(" WHERE "); sql.push_str(&where_clause); } sql.push_str(" ORDER BY id"); let mut query = sqlx::query_as::<_, CabinetOption>(&sql); for bind in &binds { query = query.bind(bind); } let rows = query.fetch_all(&state.mysql).await?; Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } /// GET /api/cabin-boards/options — 仓板下拉列表 /// /// **归属校验**:`cabinet_id` 是外部输入,必须先过 [`scope_guard::verify_cabinet_ownership`] /// (组织归属 + 数据范围,总管理员豁免)——历史缺陷:只按 `cabinet_id` 直查, /// 传别人的柜子 id 就能拿到它的仓板列表。归属不符直接 403(与 `/api/cabinets/{id}` 同一口径)。 pub async fn list_cabin_board_options( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; scope_guard::verify_cabinet_ownership(&state.mysql, &user, params.cabinet_id).await?; 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 — 仓体下拉列表 /// /// **归属校验**:`cabin_board_id` → `cabin_boards.cabinet_id` → 柜子归属校验 /// (组织归属 + 数据范围)。历史缺陷:只按 `cabin_board_id` 直查, /// 传别人的仓板 id 就能拿到它的仓体列表。仓板不存在 → 404;归属不符 → 403。 pub async fn list_compartment_options( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; // 仓板 → 柜子:拿不到说明仓板不存在(外部输入的 id 不保证有效) let cabinet_id: Option = sqlx::query_scalar("SELECT cabinet_id FROM cabin_boards WHERE id = ?") .bind(params.cabin_board_id) .fetch_optional(&state.mysql) .await?; let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?; scope_guard::verify_cabinet_ownership(&state.mysql, &user, cabinet_id).await?; 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 结果解析:柜级 6 项 + 每仓体 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 / cabinet_status1 / cabinet_status2 Some("85".into()), Some("1".into()), Some("0".into()), Some("29815".into()), Some("10".into()), Some("01".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), "柜温应被取出"); assert_eq!(rt.status1.as_deref(), Some("10"), "柜级 status1 应原样透出 hex"); assert_eq!(rt.status2.as_deref(), Some("01"), "柜级 status2 应原样透出 hex"); 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)); } /// 柜级位域解析口径(协议 §3.8 / §4.2): /// `"00"` 是"全部正常"的**有效值**,`"10"` 是柜门打开,`"82"` 是喷淋 + 烟雾, /// `"01"`(status2) 是声光报警 —— 一律**原样透出**,不作任何改写、不解析成布尔。 #[test] fn test_parse_cabinet_realtime_cabinet_status_bits_raw_hex() { let comp_ids: Vec = vec![]; let comp_to_board: Vec<(i64, (i8, i8))> = vec![]; // "00" = 全 0:全正常(**不是**"无数据") let vals = vec![ None, None, None, None, Some("00".into()), Some("00".into()), ]; let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); assert_eq!(rt.status1.as_deref(), Some("00"), "全正常必须是 Some(\"00\") 而不是 None"); assert_eq!(rt.status2.as_deref(), Some("00")); // "82" = bit7 喷淋 + bit1 烟雾;"10" = 柜门打开 let vals = vec![ None, None, None, None, Some("82".into()), Some("10".into()), ]; let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); assert_eq!(rt.status1.as_deref(), Some("82"), "喷淋+烟雾应原样透出"); assert_eq!(rt.status2.as_deref(), Some("10"), "status2 也必须原样透出(不在后端做位判定)"); } /// 柜级位域**取不到**时必须返回 `None`(响应 `null`)—— 绝不瞎填 `"00"`: /// `"00"` 是"全部正常"的有效值,与"没数据"必须区分得开。 #[test] fn test_parse_cabinet_realtime_cabinet_status_missing_is_null_not_00() { let comp_ids: Vec = vec![]; let comp_to_board: Vec<(i64, (i8, i8))> = vec![]; // HGET 未命中(None)与空串/纯空白都算"无数据" for raw in [None, Some(String::new()), Some(" ".into())] { let vals = vec![ Some("85".into()), None, None, None, raw.clone(), raw, ]; let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); assert_eq!(rt.status1, None, "缺失/空值必须为 None,不能落成 \"00\""); assert_eq!(rt.status2, None, "缺失/空值必须为 None,不能落成 \"00\""); } // 长度不足(Redis 里只有柜级前 4 项)同样安全降级为 None,不能 panic let short = vec![Some("85".into()), None, None, Some("29815".into())]; let rt = parse_cabinet_realtime(&short, &comp_ids, &comp_to_board); assert_eq!(rt.status1, None); assert_eq!(rt.status2, None); // 空切片(Redis 整体不可用)也不能 panic let rt = parse_cabinet_realtime(&[], &comp_ids, &comp_to_board); assert_eq!(rt.status1, None); assert_eq!(rt.status2, None); } /// 柜级下标常量与 [`CABINET_REALTIME_FIELDS`] 必须同步 /// (命令顺序与下标一一对应;新增柜级字段只能追加在末尾) #[test] fn test_cabinet_realtime_field_indices_are_in_sync() { assert_eq!( CABINET_REALTIME_FIELDS, 6, "柜级 HGET 字段数变了:必须同步 fetch_cabinet_realtime 的命令顺序与本文件的下标解析" ); assert_eq!(CABINET_STATUS1_IDX, CABINET_REALTIME_FIELDS - 2); assert_eq!(CABINET_STATUS2_IDX, CABINET_REALTIME_FIELDS - 1); } /// 字段缺失/非数字时必须保持 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))]; // 柜级 6 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失 let vals: Vec> = vec![ None, None, 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); } }