12451 ee92102f3c feat(scope)+fix(db)+feat(security): 数据范围(项目级)生效、库列类型对齐、安全加固、状态位分层展示、能耗实时聚合
- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
  总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
  前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
  GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
  DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
  恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
  新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
  服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
  仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
  每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
2026-09-20 02:44:51 +08:00

1285 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 设备管理 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<i64>,
abstract_id: Option<String>,
imei: String,
iccid: Option<String>,
name: Option<String>,
address: Option<String>,
latitude: Option<String>,
longitude: Option<String>,
status: i8,
/// 充电工作模式:0=平台指令模式 1=自动充电工作模式
work_mode: Option<i8>,
}
impl From<CabinetBasicRow> 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<super::AppState>,
Path(project_id): Path<i64>,
) -> Result<Json<Value>, 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<super::organizations::CabinetRow> = 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<super::AppState>,
Json(body): Json<CreateCabinetBody>,
) -> Result<Json<Value>, 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<String> = 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<i64> = 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<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<UpdateCabinetBody>,
) -> Result<Json<Value>, 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<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<SetCabinetStatusBody>,
) -> Result<Json<Value>, 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<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<UpdateWorkModeBody>,
) -> Result<Json<Value>, 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<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, 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<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, 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<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<ReplaceImeiBody>,
) -> Result<Json<Value>, 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<String>)> = 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<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, 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<String> = 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<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, 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<String> = 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<i32>,
pow_fail_dc: Option<bool>,
pow_fail_ac: Option<bool>,
/// 柜温,平台单位 K×100(Redis `device:{imei}` 的 `temp`)
temp: Option<i64>,
/// **柜级**柜体状态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<String>,
/// **柜级**柜体状态2(协议 §3.8,Redis `device:{imei}` 的 `cabinet_status2`)
///
/// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。
/// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。
status2: Option<String>,
/// 仓体 id -> (SOC, 电池ID)
soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>,
/// 仓体 id -> 通道级实时状态
ch_map: std::collections::HashMap<i64, ChannelRealtime>,
}
/// 单通道实时状态(与 `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<i64>,
/// 电池状态(2 位 hex)
batt_status: Option<String>,
/// 电源状态(2 位 hex)
pow_status: Option<String>,
/// 充电电压,单位 mV
voltage: Option<i64>,
/// 充电电流,单位 mA
current: Option<i64>,
}
/// 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<Value>, Vec<i64>, Vec<(i64, (i8, i8))>), AppError> {
let boards: Vec<CabinBoardRow> = 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<Value> = Vec::new();
let mut comp_ids: Vec<i64> = Vec::new();
let mut comp_to_board: Vec<(i64, (i8, i8))> = Vec::new();
for board in boards {
let compartments: Vec<CompartmentRow> = 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<Value> = 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<Vec<Option<String>>, 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<String>],
comp_ids: &[i64],
comp_to_board: &[(i64, (i8, i8))],
) -> CabinetRealtime {
/// 取出第 i 项的非空字符串
fn pick(vals: &[Option<String>], i: usize) -> Option<String> {
vals.get(i).and_then(|v| v.as_ref()).cloned()
}
/// 取出第 i 项并解析为 i64(空值/非数字 → None)
fn pick_i64(vals: &[Option<String>], i: usize) -> Option<i64> {
pick(vals, i).and_then(|v| v.trim().parse::<i64>().ok())
}
/// 取出第 i 项的非空字符串:`None` 或**纯空白**都视为"无数据"(Redis 里不该有空值)
fn pick_non_empty(vals: &[Option<String>], i: usize) -> Option<String> {
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::<Value>(&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<i64>,
}
/// 仓板下拉查询参数
#[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<String>,
pub abstract_id: String,
pub imei: Option<String>,
pub iccid: Option<String>,
pub address: Option<String>,
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 — 柜子下拉列表
///
/// **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。
pub async fn list_cabinet_options(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
// 动态拼 WHERE:项目筛选在前、数据范围收窄在后,绑定顺序与占位符顺序严格一致
let (scope_cond, scope_binds) = user.effective_scope().project_condition("project_id");
let mut where_clause = String::new();
let mut binds: Vec<String> = Vec::new();
if let Some(pid) = params.project_id {
where_clause.push_str("project_id = ?");
binds.push(pid.to_string());
}
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 — 仓板下拉列表
pub async fn list_cabin_board_options(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<CabinBoardListQuery>,
) -> Result<Json<Value>, 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<super::AppState>,
Query(params): Query<CompartmentListQuery>,
) -> Result<Json<Value>, 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 结果解析:柜级 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<Option<String>> = 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<i64> = 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<i64> = 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<Option<String>> = 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);
}
}