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-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 ,
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) 可访问所有项目;
/// 企业管理员只能访问本组织下的项目。
async fn check_project_access (
user : & CurrentUser ,
pool : & MySqlPool ,
project_id : i64 ,
) -> Result < ( ) , AppError > {
// 总管理员可访问所有项目
if user . role_level > = 2 {
return Ok ( ( ) ) ;
}
if let Some ( org_id ) = user . organization_id {
// 验证项目是否属于用户的组织
let project : Option < ( i64 , ) > = sqlx ::query_as (
" SELECT id FROM projects WHERE id = ? AND organization_id = ? " ,
)
. bind ( project_id )
. bind ( org_id )
. fetch_optional ( pool )
. await ? ;
if project . is_none ( ) {
return Err ( AppError ::Forbidden ( " 无权访问该项目 " . into ( ) ) ) ;
}
} else {
return Err ( AppError ::Forbidden ( " 无组织关联 " . into ( ) ) ) ;
}
Ok ( ( ) )
}
// ---------------------------------------------------------------------------
// 设备 CRUD
// ---------------------------------------------------------------------------
/// GET /api/projects/:project_id/cabinets — 按项目查询柜子列表
pub async fn list_cabinets (
user : CurrentUser ,
State ( state ) : State < 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-07-03 02:45:00 +08:00
let sql_rows = sqlx ::query_as ::< _ , CabinetBasicRow > (
2026-09-19 23:40:46 +08:00
r #" SELECT id, project_id, abstract_id, imei, iccid, name, address, 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-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 ,
" address " : cabinet . address ,
" latitude " : cabinet . latitude ,
" longitude " : cabinet . longitude ,
" cabin_boards " : cabin_boards ,
} ,
" message " : " ok "
} ) ) )
}
// ---------------------------------------------------------------------------
// 设备详情 — Redis 实时状态(柜级 + 通道级)
// ---------------------------------------------------------------------------
/// 详情页实时状态快照; Redis 不可用时整体为默认空值
#[ derive(Default) ]
struct CabinetRealtime {
rssi : Option < i32 > ,
pow_fail_dc : Option < bool > ,
pow_fail_ac : Option < bool > ,
/// 仓体 id -> (SOC, 电池ID)
soc_map : std ::collections ::HashMap < i64 , ( Option < i64 > , Option < String > ) > ,
/// 仓体 id -> (温度, batt_status, pow_status)
ch_map : std ::collections ::HashMap < i64 , ( Option < i64 > , Option < String > , Option < String > ) > ,
}
/// 查询仓控板与仓体,构建带实时字段占位的 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-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}) 实时状态
///
/// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL, 不能因缓存故障整体失败。
async fn fetch_cabinet_realtime (
redis_url : & str ,
imei : & str ,
comp_ids : & [ i64 ] ,
comp_to_board : & [ ( i64 , ( i8 , i8 ) ) ] ,
) -> CabinetRealtime {
let client = match redis ::Client ::open ( redis_url ) {
Ok ( c ) = > c ,
Err ( e ) = > {
tracing ::warn! ( " [cabinet_detail] Redis 客户端创建失败,实时状态降级: {} " , e ) ;
return CabinetRealtime ::default ( ) ;
}
} ;
// 使用多路复用连接( MultiplexedConnection) , 替代已废弃的 aio::Connection
let mut conn = match client . get_multiplexed_async_connection ( ) . await {
Ok ( c ) = > c ,
Err ( e ) = > {
tracing ::warn! ( " [cabinet_detail] Redis 连接失败,实时状态降级: {} " , e ) ;
return CabinetRealtime ::default ( ) ;
}
} ;
let key = format! ( " device: {} " , imei ) ;
let mut pipe = redis ::pipe ( ) ;
pipe . cmd ( " HGET " ) . arg ( & key ) . arg ( " rssi " )
. cmd ( " HGET " ) . arg ( & key ) . arg ( " pow_fail_dc " )
. cmd ( " HGET " ) . arg ( & key ) . arg ( " pow_fail_ac " ) ;
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 " ) ;
}
// 用类型标注而非 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 结果:柜级 3 项 + 每仓体 1 项(SOC JSON) + 每通道 3 项(temp/batt_status/pow_status)
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 ( )
}
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 " ) ,
.. Default ::default ( )
} ;
for ( i , cid ) in comp_ids . iter ( ) . enumerate ( ) {
if let Some ( data ) = pick ( vals , 3 + 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 ) ) ;
2026-07-04 04:03:02 +08:00
}
2026-09-19 23:40:46 +08:00
}
}
// 通道级字段紧随 SOC 段之后,每通道 3 项
let base = 3 + comp_ids . len ( ) ;
for ( i , ( cid , _ ) ) in comp_to_board . iter ( ) . enumerate ( ) {
let off = base + i * 3 ;
let temp = pick ( vals , off ) . and_then ( | v | v . parse ::< i64 > ( ) . ok ( ) ) ;
let batt_status = pick ( vals , off + 1 ) ;
let pow_status = pick ( vals , off + 2 ) ;
rt . ch_map . insert ( * cid , ( temp , batt_status , pow_status ) ) ;
}
rt
}
/// 把实时状态注入仓体 JSON: 缺失字段保持 null / 原值,绝不写空字符串
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-19 23:40:46 +08:00
if let Some ( ( temp , batt_status , pow_status ) ) = rt . ch_map . get ( & cid ) {
if let Some ( t ) = temp {
comp [ " temp " ] = json! ( t ) ;
}
if let Some ( bs ) = batt_status {
comp [ " batt_status " ] = json! ( bs ) ;
}
if let Some ( ps ) = pow_status {
comp [ " pow_status " ] = json! ( ps ) ;
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 ,
}
/// 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 " ) ? ;
let rows = if let Some ( pid ) = params . project_id {
sqlx ::query_as ::< _ , CabinetOption > (
2026-07-02 20:54:37 +08:00
" SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets WHERE project_id = ? ORDER BY id "
2026-07-02 05:38:01 +08:00
)
. bind ( pid )
. fetch_all ( & state . mysql )
. await ?
} else {
sqlx ::query_as ::< _ , CabinetOption > (
2026-07-02 20:54:37 +08:00
" SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets ORDER BY id "
2026-07-02 05:38:01 +08:00
)
. 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 " } ) ) )
}
2026-09-19 23:40:46 +08:00