2026-07-02 05:38:01 +08:00
//! 设备管理 API — CRUD + 详情 + 级联下拉
//!
//! 提供柜子的增删改查、详情查询、安全码重新生成,
//! 以及柜子/仓板/仓体的级联下拉选项。
use axum ::extract ::{ Path , Query , State } ;
use axum ::Json ;
2026-07-04 04:03:02 +08:00
use redis ;
2026-07-02 05:38:01 +08:00
use serde ::{ Deserialize , Serialize } ;
use serde_json ::{ json , Value } ;
use sqlx ::MySqlPool ;
use super ::organizations ::{
2026-07-03 02:45:00 +08:00
generate_auth_str ,
2026-09-19 23:40:46 +08:00
CabinBoardRow , CompartmentRow , CreateCabinetBody , ReplaceImeiBody , UpdateCabinetBody ,
2026-07-02 05:38:01 +08:00
} ;
use crate ::error ::AppError ;
use crate ::middleware ::auth ::{ self , CurrentUser } ;
2026-09-20 02:44:51 +08:00
use super ::cabinet_tree ::derive_channel_flags ;
2026-09-19 23:40:46 +08:00
use super ::scope_guard ;
2026-07-02 05:38:01 +08:00
2026-07-03 02:45:00 +08:00
/// 基础柜子行(只包含数据库字段,用于简单查询)
#[ 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 > ,
2026-09-19 23:40:46 +08:00
latitude : Option < String > ,
longitude : Option < String > ,
2026-07-03 02:45:00 +08:00
status : i8 ,
2026-09-19 23:40:46 +08:00
/// 充电工作模式: 0=平台指令模式 1=自动充电工作模式
work_mode : Option < i8 > ,
2026-07-03 02:45:00 +08:00
}
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 ,
2026-09-20 02:44:51 +08:00
// 列表(按项目查柜子)不读 Redis, 柜级位域如实为 null
status1 : None ,
status2 : None ,
2026-07-03 02:45:00 +08:00
is_online : None ,
2026-09-19 23:40:46 +08:00
org_disabled : None ,
project_address : None ,
2026-07-03 02:45:00 +08:00
}
}
}
2026-07-02 05:38:01 +08:00
// ---------------------------------------------------------------------------
// 项目访问权限验证
// ---------------------------------------------------------------------------
/// 验证用户是否有权访问指定项目
///
/// 总管理员( role_level >= 2) 可访问所有项目;
2026-09-20 02:44:51 +08:00
/// 企业管理员只能访问本组织下的项目,**且该项目必须在其数据范围内**(未配置范围 = 不限制)。
/// 这是「按项目查设备列表」与「设备详情」共用的唯一入口 —— 一处收窄,两处生效。
2026-07-02 05:38:01 +08:00
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 {
2026-09-20 02:44:51 +08:00
// 组织归属 + 数据范围(项目级)一次查完,避免两次往返
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 ? ;
2026-07-02 05:38:01 +08:00
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 ;
2026-09-20 02:44:51 +08:00
// ⚠️ 字段必须与 `CabinetBasicRow` 一一对应(含 `latitude`/`longitude`) :
// 少了经纬度这一列,`FromRow` 解码会直接报 `no column found for name: latitude`
// 并以 `DATABASE_ERROR` 500 掉整个列表接口(历史缺陷,本次随数据范围改造一并修掉)。
// 经纬度与详情接口同口径:`CAST(... AS CHAR)`,规避 sqlx 对 DECIMAL 的解码问题。
2026-07-03 02:45:00 +08:00
let sql_rows = sqlx ::query_as ::< _ , CabinetBasicRow > (
2026-09-20 02:44:51 +08:00
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
2026-07-02 05:38:01 +08:00
FROM cabinets WHERE project_id = ? ORDER BY id " #,
)
. bind ( project_id )
. fetch_all ( db )
. await ? ;
2026-07-03 02:45:00 +08:00
let rows : Vec < super ::organizations ::CabinetRow > = sql_rows . into_iter ( ) . map ( Into ::into ) . collect ( ) ;
2026-09-19 23:40:46 +08:00
Ok ( Json ( json! ( { " code " : 0 , " data " : rows , " message " : " ok " } ) ) )
2026-07-02 05:38:01 +08:00
}
/// 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 " ) ? ;
2026-09-19 23:40:46 +08:00
scope_guard ::verify_project_ownership ( & state . mysql , & user , body . project_id ) . await ? ;
2026-07-02 05:38:01 +08:00
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 (
2026-07-03 02:45:00 +08:00
r #" INSERT INTO cabinets (project_id, imei, auth_str)
VALUES ( ? , ? , ? ) " #,
2026-07-02 05:38:01 +08:00
)
. 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 ) ;
2026-07-03 02:45:00 +08:00
tracing ::info! ( " 创建柜子 id={} imei={} " , cabinet_id , imei ) ;
2026-07-02 05:38:01 +08:00
}
2026-09-19 23:40:46 +08:00
Ok ( Json ( json! ( { " code " : 0 , " data " : { " ids " : created_ids } , " message " : " ok " } ) ) )
2026-07-02 05:38:01 +08:00
}
/// 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 " ) ? ;
2026-09-19 23:40:46 +08:00
scope_guard ::verify_cabinet_ownership ( & state . mysql , & user , id ) . await ? ;
2026-07-02 05:38:01 +08:00
let db = & state . mysql ;
let rows = sqlx ::query (
r #" UPDATE cabinets SET
project_id = COALESCE ( ? , project_id ) ,
2026-07-03 02:45:00 +08:00
name = COALESCE ( ? , name ) ,
abstract_id = COALESCE ( ? , abstract_id )
2026-07-02 05:38:01 +08:00
WHERE id = ? " #,
)
. bind ( body . project_id )
. bind ( & body . name )
2026-07-03 02:45:00 +08:00
. bind ( & body . abstract_id )
2026-07-02 05:38:01 +08:00
. bind ( id )
. execute ( db )
. await ?
. rows_affected ( ) ;
if rows = = 0 {
return Err ( AppError ::NotFound ( format! ( " 柜子 {} 不存在 " , id ) ) ) ;
}
tracing ::info! ( " 更新柜子 id={} " , id ) ;
2026-09-19 23:40:46 +08:00
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 " } ) ) )
2026-07-02 05:38:01 +08:00
}
/// 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 " ) ? ;
2026-09-19 23:40:46 +08:00
scope_guard ::verify_cabinet_ownership ( & state . mysql , & user , id ) . await ? ;
2026-07-02 05:38:01 +08:00
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 ) ;
2026-09-19 23:40:46 +08:00
Ok ( Json ( json! ( { " code " : 0 , " data " : null , " message " : " ok " } ) ) )
2026-07-02 05:38:01 +08:00
}
2026-07-03 02:45:00 +08:00
/// 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 " ) ? ;
2026-09-19 23:40:46 +08:00
scope_guard ::verify_cabinet_ownership ( & state . mysql , & user , id ) . await ? ;
2026-07-03 02:45:00 +08:00
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 ) ;
2026-09-19 23:40:46 +08:00
Ok ( Json ( json! ( { " code " : 0 , " data " : { " abstract_id " : abstract_id } , " message " : " ok " } ) ) )
2026-07-03 02:45:00 +08:00
}
/// 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 " ) ? ;
2026-09-19 23:40:46 +08:00
scope_guard ::verify_cabinet_ownership ( & state . mysql , & user , id ) . await ? ;
2026-07-03 02:45:00 +08:00
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 ) ;
2026-09-19 23:40:46 +08:00
Ok ( Json ( json! ( { " code " : 0 , " data " : null , " message " : " ok " } ) ) )
2026-07-03 02:45:00 +08:00
}
2026-07-02 05:38:01 +08:00
/// 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 > {
2026-09-19 23:40:46 +08:00
// 仅总管理员可重置安全码: 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 ? ;
2026-07-02 05:38:01 +08:00
let db = & state . mysql ;
let auth_str = generate_auth_str ( ) ;
2026-09-19 23:40:46 +08:00
let rows = sqlx ::query ( r # "UPDATE cabinets SET auth_str = ?, auth_str_issued = 0 WHERE id = ?"# )
2026-07-02 05:38:01 +08:00
. bind ( & auth_str )
. bind ( id )
. execute ( db )
. await ?
. rows_affected ( ) ;
2026-09-19 23:40:46 +08:00
// 同步 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 ;
}
2026-07-02 05:38:01 +08:00
if rows = = 0 {
return Err ( AppError ::NotFound ( format! ( " 柜子 {} 不存在 " , id ) ) ) ;
}
tracing ::info! ( " 重新生成 auth_str id={} " , id ) ;
2026-09-19 23:40:46 +08:00
Ok ( Json ( json! ( { " code " : 0 , " data " : { " auth_str " : auth_str } , " message " : " ok " } ) ) )
2026-07-02 05:38:01 +08:00
}
// ---------------------------------------------------------------------------
// 柜子详情(含仓控板 + 仓体)
// ---------------------------------------------------------------------------
/// GET /api/cabinets/:id — 柜子详情,包含仓控板和仓体
pub async fn get_cabinet_detail (
user : CurrentUser ,
State ( state ) : State < super ::AppState > ,
Path ( id ) : Path < i64 > ,
2026-07-04 04:03:02 +08:00
) -> Result < Json < Value > , AppError > {
2026-07-02 05:38:01 +08:00
auth ::check_permission ( & user , " device:view " ) ? ;
let db = & state . mysql ;
2026-07-03 02:45:00 +08:00
let cabinet = sqlx ::query_as ::< _ , CabinetBasicRow > (
2026-09-19 23:40:46 +08:00
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
2026-07-02 05:38:01 +08:00
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 ? ;
}
2026-09-20 02:44:51 +08:00
// 数据范围兜底:**未划拨到项目的柜子**( `project_id` 为空)走不到上面的项目校验,
// 配了范围的用户一律不可见(`allows_project(None)` 在有限制时为 false) 。
if ! user . effective_scope ( ) . allows_project ( cabinet . project_id ) {
return Err ( AppError ::Forbidden ( " 无权访问该设备 " . into ( ) ) ) ;
}
2026-09-19 23:40:46 +08:00
// 仓控板 + 仓体(含实时字段占位),并收集通道 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 ,
2026-09-20 00:36:37 +08:00
// 柜温: Redis `device:{imei}.temp`,语义 K× 100( 与柜级 temp 口径一致);
// 取不到为 null( 前端显示 `--°C`),不落 0
" temp " : realtime . temp ,
2026-09-20 02:44:51 +08:00
// **柜级**状态位(协议 §3.8) : Redis `device:{imei}` 的 `cabinet_status1/2`
// 原样透出 2 位小写 hex 字符串(**不在后端解析成布尔**,前端两端有统一纯函数);
// 取不到为 null —— 与 `"00"`( "全部正常"的有效值)严格区分,绝不瞎填 "00"
" status1 " : realtime . status1 ,
" status2 " : realtime . status2 ,
2026-09-19 23:40:46 +08:00
" address " : cabinet . address ,
" latitude " : cabinet . latitude ,
" longitude " : cabinet . longitude ,
" cabin_boards " : cabin_boards ,
} ,
" message " : " ok "
} ) ) )
}
// ---------------------------------------------------------------------------
// 设备详情 — Redis 实时状态(柜级 + 通道级)
// ---------------------------------------------------------------------------
/// 详情页实时状态快照; Redis 不可用时整体为默认空值
2026-09-20 00:36:37 +08:00
///
/// 所有字段一律 `Option`: Redis 里取不到就保持 `None`(响应 JSON 为 `null`) ,
/// **绝不落成 0/空串** —— 0 是"真实读数为 0",与"无数据"必须区分。
2026-09-19 23:40:46 +08:00
#[ derive(Default) ]
struct CabinetRealtime {
rssi : Option < i32 > ,
pow_fail_dc : Option < bool > ,
pow_fail_ac : Option < bool > ,
2026-09-20 00:36:37 +08:00
/// 柜温,平台单位 K× 100( Redis `device:{imei}` 的 `temp`)
temp : Option < i64 > ,
2026-09-20 02:44:51 +08:00
/// **柜级**柜体状态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 > ,
2026-09-19 23:40:46 +08:00
/// 仓体 id -> (SOC, 电池ID)
soc_map : std ::collections ::HashMap < i64 , ( Option < i64 > , Option < String > ) > ,
2026-09-20 00:36:37 +08:00
/// 仓体 id -> 通道级实时状态
ch_map : std ::collections ::HashMap < i64 , ChannelRealtime > ,
2026-09-19 23:40:46 +08:00
}
2026-09-20 00:36:37 +08:00
/// 单通道实时状态(与 `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 > ,
}
2026-09-20 02:44:51 +08:00
/// 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 ;
2026-09-20 00:36:37 +08:00
/// pipeline 中每个通道 HGET 的字段个数: temp / batt_status / pow_status / voltage / current
const CHANNEL_REALTIME_FIELDS : usize = 5 ;
2026-09-19 23:40:46 +08:00
/// 查询仓控板与仓体,构建带实时字段占位的 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 > {
2026-07-02 05:38:01 +08:00
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 " #,
)
2026-09-19 23:40:46 +08:00
. bind ( cabinet_id )
2026-07-02 05:38:01 +08:00
. fetch_all ( db )
. await ? ;
2026-07-04 04:03:02 +08:00
let mut cabin_boards : Vec < Value > = Vec ::new ( ) ;
let mut comp_ids : Vec < i64 > = Vec ::new ( ) ;
2026-09-19 23:40:46 +08:00
let mut comp_to_board : Vec < ( i64 , ( i8 , i8 ) ) > = Vec ::new ( ) ;
2026-07-02 05:38:01 +08:00
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 ? ;
2026-07-04 04:03:02 +08:00
for c in & compartments {
comp_ids . push ( c . id ) ;
2026-09-19 23:40:46 +08:00
comp_to_board . push ( ( c . id , ( board . board_id , c . channel_id ) ) ) ;
2026-07-04 04:03:02 +08:00
}
2026-09-19 23:40:46 +08:00
// 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 ,
2026-09-20 00:36:37 +08:00
// 通道级实时字段占位: Redis 缺失时保持 null( **不落 0**,
// 前端 `c.voltage ?? '-'` 据此显示"-"而不是"-mV")
" voltage " : Value ::Null ,
" current " : Value ::Null ,
" temp " : Value ::Null ,
2026-09-19 23:40:46 +08:00
} )
2026-07-04 04:03:02 +08:00
} )
2026-09-19 23:40:46 +08:00
. collect ( ) ;
2026-07-04 04:03:02 +08:00
cabin_boards . push ( json! ( {
" id " : board . id ,
" board_id " : board . board_id ,
" status " : board . status ,
" compartments " : comp_values ,
} ) ) ;
}
2026-09-19 23:40:46 +08:00
Ok ( ( cabin_boards , comp_ids , comp_to_board ) )
}
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
///
2026-09-20 02:44:51 +08:00
/// 为什么放一起:详情接口要求低延迟,柜级 6 个字段 + 每通道 5 个字段共用一次网络往返。
2026-09-19 23:40:46 +08:00
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL, 不能因缓存故障整体失败。
2026-09-20 00:36:37 +08:00
///
/// 柜级第 4 个字段 `temp`( 柜温, K× 100) 与通道级 `voltage`/`current` 是**补取**字段:
/// 设备上报(`report_worker::write_device_state` / `write_channel_state`)早就写进了 Redis,
/// 只是接口没取,导致 PC 端柜温恒为 `--°C`、电压电流恒为 `-mV -mA`。
2026-09-20 02:44:51 +08:00
///
/// 柜级第 5、6 个字段(`cabinet_status1` / `cabinet_status2`,协议 §3.8)同样是**补取**:
/// `report_worker::handle_status_post` 一直在写,但此前全仓无人读 —— 本次透出为
/// 响应根字段 `status1` / `status2`( **柜级**语义,前端按层级展示在"柜子"层面)。
2026-09-19 23:40:46 +08:00
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 ) ;
2026-09-20 02:44:51 +08:00
// ⚠️ 命令顺序必须与 CABINET_REALTIME_FIELDS / CABINET_STATUS*_IDX 及
// parse_cabinet_realtime 的下标解析**完全一致**(新增柜级字段只能追加在末尾)
2026-09-19 23:40:46 +08:00
let mut pipe = redis ::pipe ( ) ;
pipe . cmd ( " HGET " ) . arg ( & key ) . arg ( " rssi " )
. cmd ( " HGET " ) . arg ( & key ) . arg ( " pow_fail_dc " )
2026-09-20 00:36:37 +08:00
. cmd ( " HGET " ) . arg ( & key ) . arg ( " pow_fail_ac " )
2026-09-20 02:44:51 +08:00
. cmd ( " HGET " ) . arg ( & key ) . arg ( " temp " )
// 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析
. cmd ( " HGET " ) . arg ( & key ) . arg ( " cabinet_status1 " )
. cmd ( " HGET " ) . arg ( & key ) . arg ( " cabinet_status2 " ) ;
2026-09-19 23:40:46 +08:00
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 " )
2026-09-20 00:36:37 +08:00
. cmd ( " HGET " ) . arg ( & rkey ) . arg ( " pow_status " )
. cmd ( " HGET " ) . arg ( & rkey ) . arg ( " voltage " )
. cmd ( " HGET " ) . arg ( & rkey ) . arg ( " current " ) ;
2026-09-19 23:40:46 +08:00
}
// 用类型标注而非 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 ( )
}
}
}
2026-09-20 00:36:37 +08:00
/// 解析 pipeline 结果:柜级 [`CABINET_REALTIME_FIELDS`] 项 + 每仓体 1 项(SOC JSON)
/// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 项(temp/batt_status/pow_status/voltage/current)
///
/// 任何一项缺失或解析失败 → 对应字段保持 `None`( **不落 0**)。
2026-09-19 23:40:46 +08:00
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 ( )
}
2026-09-20 00:36:37 +08:00
/// 取出第 i 项并解析为 i64( 空值/非数字 → None)
fn pick_i64 ( vals : & [ Option < String > ] , i : usize ) -> Option < i64 > {
pick ( vals , i ) . and_then ( | v | v . trim ( ) . parse ::< i64 > ( ) . ok ( ) )
}
2026-09-20 02:44:51 +08:00
/// 取出第 i 项的非空字符串:`None` 或**纯空白**都视为"无数据"( Redis 里不该有空值)
fn pick_non_empty ( vals : & [ Option < String > ] , i : usize ) -> Option < String > {
pick ( vals , i ) . filter ( | v | ! v . trim ( ) . is_empty ( ) )
}
2026-09-19 23:40:46 +08:00
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 " ) ,
2026-09-20 00:36:37 +08:00
temp : pick_i64 ( vals , 3 ) ,
2026-09-20 02:44:51 +08:00
// 柜级位域:**原样透出 hex**(不解析成布尔 —— 位域不是单标志);
// 缺数据时保持 None → 响应 null, **绝不落成 "00"**( "00" 是"全部正常"的有效值)
status1 : pick_non_empty ( vals , CABINET_STATUS1_IDX ) ,
status2 : pick_non_empty ( vals , CABINET_STATUS2_IDX ) ,
2026-09-19 23:40:46 +08:00
.. Default ::default ( )
} ;
for ( i , cid ) in comp_ids . iter ( ) . enumerate ( ) {
2026-09-20 00:36:37 +08:00
if let Some ( data ) = pick ( vals , CABINET_REALTIME_FIELDS + i ) {
2026-09-19 23:40:46 +08:00
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 ) ) ;
2026-07-04 04:03:02 +08:00
}
2026-09-19 23:40:46 +08:00
}
}
2026-09-20 00:36:37 +08:00
// 通道级字段紧随 SOC 段之后,每通道 CHANNEL_REALTIME_FIELDS 项
let base = CABINET_REALTIME_FIELDS + comp_ids . len ( ) ;
2026-09-19 23:40:46 +08:00
for ( i , ( cid , _ ) ) in comp_to_board . iter ( ) . enumerate ( ) {
2026-09-20 00:36:37 +08:00
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 ) ,
} ,
) ;
2026-09-19 23:40:46 +08:00
}
rt
}
2026-09-20 00:36:37 +08:00
/// 把实时状态注入仓体 JSON: 缺失字段保持 null / 原值,绝不写空字符串,也绝不落 0
2026-09-20 02:44:51 +08:00
///
/// 另外把每个仓体的 `status` 从「DB 占用值( 0/1) 」改写成**派生展示值**
/// (有通道数据时 `0空闲 / 1充电中 / 2充满 / 3故障`;无数据时保持 DB 值),
/// 因为 `compartments.status` 只表达占用, DB 里永远看不到"充满/故障"。
2026-09-19 23:40:46 +08:00
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 ) ;
2026-07-04 04:03:02 +08:00
}
}
2026-09-20 00:36:37 +08:00
if let Some ( ch ) = rt . ch_map . get ( & cid ) {
2026-09-20 02:44:51 +08:00
// 展示状态改为**派生值**:有通道实时数据时按
// 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 ( ) ) ;
}
2026-09-20 00:36:37 +08:00
if let Some ( t ) = ch . temp {
2026-09-19 23:40:46 +08:00
comp [ " temp " ] = json! ( t ) ;
}
2026-09-20 00:36:37 +08:00
if let Some ( bs ) = & ch . batt_status {
2026-09-19 23:40:46 +08:00
comp [ " batt_status " ] = json! ( bs ) ;
}
2026-09-20 00:36:37 +08:00
if let Some ( ps ) = & ch . pow_status {
2026-09-19 23:40:46 +08:00
comp [ " pow_status " ] = json! ( ps ) ;
2026-07-04 04:03:02 +08:00
}
2026-09-20 00:36:37 +08:00
if let Some ( v ) = ch . voltage {
comp [ " voltage " ] = json! ( v ) ;
}
if let Some ( c ) = ch . current {
comp [ " current " ] = json! ( c ) ;
}
2026-07-04 04:03:02 +08:00
}
}
2026-07-02 05:38:01 +08:00
}
}
// ---------------------------------------------------------------------------
// 级联下拉选项
// ---------------------------------------------------------------------------
/// 柜子下拉查询参数
#[ 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 ,
2026-07-02 20:54:37 +08:00
pub imei : Option < String > ,
pub iccid : Option < String > ,
pub address : Option < String > ,
pub status : i8 ,
2026-07-02 05:38:01 +08:00
}
/// 仓板下拉选项
#[ 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 ,
}
2026-09-20 02:44:51 +08:00
/// 柜子下拉列表的固定列清单(列表/详情共用同一组字段,避免两处漂移)
const CABINET_OPTION_COLUMNS : & str =
" id, name, abstract_id, imei, iccid, address, status " ;
2026-07-02 05:38:01 +08:00
/// GET /api/cabinets/options — 柜子下拉列表
2026-09-20 02:44:51 +08:00
///
2026-09-20 03:23:00 +08:00
/// **两道收窄(取交集)**:
/// 1. **组织隔离**( [`auth::org_condition_for_project`])—— 无条件叠加:
/// 总管理员全量、客户侧仅本组织、无组织空集(**绝不退化成全量**) ;
/// 2. **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。
///
/// 历史缺陷:本接口只叠加了数据范围、**没有组织隔离** ⇒ 未配置 scope 的客户账号
/// 不带 `project_id` 调用时能看到**全平台所有柜子**(含 IMEI/ICCID/地址)。
/// `project_id` 入参只是纯筛选,不构成归属边界。
2026-07-02 05:38:01 +08:00
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 " ) ? ;
2026-09-20 03:23:00 +08:00
// 动态拼 WHERE: 项目筛选 → 组织隔离 → 数据范围收窄,
// 书写顺序 = 占位符顺序 = 绑定顺序,三者严格一致
2026-09-20 02:44:51 +08:00
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 ( ) ) ;
}
2026-09-20 03:23:00 +08:00
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 " ) ;
2026-09-20 02:44:51 +08:00
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 ? ;
2026-07-02 05:38:01 +08:00
Ok ( Json ( json! ( { " code " : 0 , " data " : rows , " message " : " ok " } ) ) )
}
/// GET /api/cabin-boards/options — 仓板下拉列表
2026-09-20 03:23:00 +08:00
///
/// **归属校验**: `cabinet_id` 是外部输入,必须先过 [`scope_guard::verify_cabinet_ownership`]
/// (组织归属 + 数据范围,总管理员豁免)——历史缺陷:只按 `cabinet_id` 直查,
/// 传别人的柜子 id 就能拿到它的仓板列表。归属不符直接 403( 与 `/api/cabinets/{id}` 同一口径)。
2026-07-02 05:38:01 +08:00
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 " ) ? ;
2026-09-20 03:23:00 +08:00
scope_guard ::verify_cabinet_ownership ( & state . mysql , & user , params . cabinet_id ) . await ? ;
2026-07-02 05:38:01 +08:00
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 — 仓体下拉列表
2026-09-20 03:23:00 +08:00
///
/// **归属校验**: `cabin_board_id` → `cabin_boards.cabinet_id` → 柜子归属校验
/// (组织归属 + 数据范围)。历史缺陷:只按 `cabin_board_id` 直查,
/// 传别人的仓板 id 就能拿到它的仓体列表。仓板不存在 → 404; 归属不符 → 403。
2026-07-02 05:38:01 +08:00
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 " ) ? ;
2026-09-20 03:23:00 +08:00
// 仓板 → 柜子:拿不到说明仓板不存在(外部输入的 id 不保证有效)
let cabinet_id : Option < i64 > =
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 ? ;
2026-07-02 05:38:01 +08:00
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 " } ) ) )
}
2026-09-19 23:40:46 +08:00
2026-09-20 00:36:37 +08:00
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[ cfg(test) ]
mod tests {
use super ::* ;
2026-09-20 02:44:51 +08:00
/// pipeline 结果解析:柜级 6 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局,
2026-09-20 00:36:37 +08:00
/// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 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! [
2026-09-20 02:44:51 +08:00
// 柜级: rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2
2026-09-20 00:36:37 +08:00
Some ( " 85 " . into ( ) ) ,
Some ( " 1 " . into ( ) ) ,
Some ( " 0 " . into ( ) ) ,
Some ( " 29815 " . into ( ) ) ,
2026-09-20 02:44:51 +08:00
Some ( " 10 " . into ( ) ) ,
Some ( " 01 " . into ( ) ) ,
2026-09-20 00:36:37 +08:00
// 每仓体 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 ) , " 柜温应被取出 " ) ;
2026-09-20 02:44:51 +08:00
assert_eq! ( rt . status1 . as_deref ( ) , Some ( " 10 " ) , " 柜级 status1 应原样透出 hex " ) ;
assert_eq! ( rt . status2 . as_deref ( ) , Some ( " 01 " ) , " 柜级 status2 应原样透出 hex " ) ;
2026-09-20 00:36:37 +08:00
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 ) ) ;
}
2026-09-20 02:44:51 +08:00
/// 柜级位域解析口径(协议 §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 ) ;
}
2026-09-20 00:36:37 +08:00
/// 字段缺失/非数字时必须保持 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 ) ) ] ;
2026-09-20 02:44:51 +08:00
// 柜级 6 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失
2026-09-20 00:36:37 +08:00
let vals : Vec < Option < String > > = vec! [
None ,
2026-09-20 02:44:51 +08:00
None ,
None ,
2026-09-20 00:36:37 +08:00
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 ) ;
}
}