2026-07-02 05:38:01 +08:00
//! 组织树 API
//!
2026-07-02 21:20:06 +08:00
//! 两个独立接口:
2026-07-02 21:32:06 +08:00
//! 1. /api/organization-tree — 返回多级组织->项目树
2026-07-02 21:20:06 +08:00
//! 2. /api/cabinets/filter — 根据组织/项目筛选设备列表
2026-07-02 05:38:01 +08:00
2026-07-02 21:20:06 +08:00
use axum ::extract ::{ State , Query } ;
2026-07-02 05:38:01 +08:00
use axum ::Json ;
2026-07-02 21:20:06 +08:00
use serde ::Deserialize ;
2026-07-02 05:38:01 +08:00
use serde_json ::{ json , Value } ;
2026-09-20 00:36:37 +08:00
use std ::collections ::{ BTreeMap , HashMap } ;
2026-07-02 05:38:01 +08:00
use super ::organizations ::{ CabinetRow , OrganizationRow , ProjectRow , TreeNode } ;
use crate ::error ::AppError ;
use crate ::middleware ::auth ::{ self , CurrentUser } ;
use crate ::commands ::AppState ;
2026-07-03 02:45:00 +08:00
/// SQL查询专用结构体( 只包含数据库字段)
#[ derive(Debug, Clone, sqlx::FromRow) ]
struct CabinetSqlRow {
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
project_address : Option < String > ,
2026-07-03 02:45:00 +08:00
status : i8 ,
board_count : Option < i64 > ,
channel_count : Option < i64 > ,
idle_count : Option < i64 > ,
charging_count : Option < i64 > ,
full_count : Option < i64 > ,
fault_count : Option < i64 > ,
}
impl From < CabinetSqlRow > for CabinetRow {
fn from ( row : CabinetSqlRow ) -> Self {
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 ,
2026-09-19 23:40:46 +08:00
project_address : row . project_address ,
2026-07-03 02:45:00 +08:00
status : row . status ,
board_count : row . board_count ,
channel_count : row . channel_count ,
idle_count : row . idle_count ,
charging_count : row . charging_count ,
full_count : row . full_count ,
fault_count : row . fault_count ,
rssi : None ,
pow_fail_dc : None ,
pow_fail_ac : None ,
2026-09-20 02:44:51 +08:00
// 柜级位域由 attach_realtime_status 从 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 ,
2026-07-03 02:45:00 +08:00
}
}
}
2026-09-20 00:01:01 +08:00
// ---------------------------------------------------------------------------
// SQL 常量
//
// 说明:以下 SQL 均由原函数体**原样搬移**,未改动任何 SELECT 字段列表、
// 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。
// ---------------------------------------------------------------------------
/// 全部组织(组织树用)
const ORGS_ALL_SQL : & str = " SELECT id, name, parent_id, disabled FROM organizations ORDER BY id " ;
/// 全部项目(经纬度转 CHAR, 规避 sqlx 的 DECIMAL 解码问题)
const PROJECTS_ALL_SQL : & str = " SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id " ;
/// 各项目下的设备数量
const PROJECT_CABINET_COUNTS_SQL : & str =
" SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id " ;
/// 设备列表基础 SQL: 联表查询获取通道状态统计( 子查询 idle_count/charging_count/... 原样保留)
2026-09-20 02:44:51 +08:00
///
/// ⚠️ **口径说明(本次产品决定)**: `compartments.status` 只表达**占用**语义
/// ( `0 空闲 / 1 充电中`,这正是平台实际写入的两个值),"充满/故障"不落库。
/// 因此:
/// - `idle_count` / `charging_count` 两条子查询(`comp.status = 0 / 1`) **保持原样**,语义正确;
/// - `full_count` / `fault_count` 两条子查询(`comp.status = 2 / 3`)在 DB 里**恒为 0**
/// (全仓没有任何写入点会写 2/3) , 本次采用「**SQL 保留、结果由派生值覆盖**」方案:
/// 这两列仍被 SELECT, 但响应值一律由 [`apply_derived_counts`] 从通道实时状态派生后覆盖,
/// 详见 [`ChannelCounts`] 的注释。
2026-09-20 00:01:01 +08:00
const CABINET_FILTER_BASE_SQL : & str = "
SELECT
c . id , c . project_id , c . abstract_id , c . imei , c . iccid , c . name , c . address ,
( SELECT address FROM projects WHERE id = c . project_id ) as project_address ,
c . status ,
( SELECT COUNT ( * ) FROM cabin_boards cb WHERE cb . cabinet_id = c . id ) as board_count ,
( SELECT COUNT ( * ) FROM cabin_boards cb2
JOIN compartments comp ON comp . cabin_board_id = cb2 . id
WHERE cb2 . cabinet_id = c . id ) as channel_count ,
( SELECT COUNT ( * ) FROM cabin_boards cb3
JOIN compartments comp2 ON comp2 . cabin_board_id = cb3 . id
WHERE cb3 . cabinet_id = c . id AND comp2 . status = 0 ) as idle_count ,
( SELECT COUNT ( * ) FROM cabin_boards cb4
JOIN compartments comp3 ON comp3 . cabin_board_id = cb4 . id
WHERE cb4 . cabinet_id = c . id AND comp3 . status = 1 ) as charging_count ,
( SELECT COUNT ( * ) FROM cabin_boards cb5
JOIN compartments comp4 ON comp4 . cabin_board_id = cb5 . id
WHERE cb5 . cabinet_id = c . id AND comp4 . status = 2 ) as full_count ,
( SELECT COUNT ( * ) FROM cabin_boards cb6
JOIN compartments comp5 ON comp5 . cabin_board_id = cb6 . id
WHERE cb6 . cabinet_id = c . id AND comp5 . status = 3 ) as fault_count
FROM cabinets c
" ;
2026-09-20 02:44:51 +08:00
/// 设备归属校验(非总管理员):设备必须属于当前用户组织 **且落在其数据范围内**
///
/// 数据范围未配置时该附加条件为空串 ⇒ 与今天的行为完全一致(向后兼容)。
2026-09-20 00:01:01 +08:00
const DEVICE_ACCESS_SQL : & str = " SELECT EXISTS(SELECT 1 FROM cabinets c \
JOIN projects p ON c . project_id = p . id \
2026-09-20 02:44:51 +08:00
WHERE c . imei = ? AND p . organization_id = ? { } ) " ;
2026-09-20 00:01:01 +08:00
// ---------------------------------------------------------------------------
// 组织树
// ---------------------------------------------------------------------------
2026-07-02 21:42:52 +08:00
/// 组织树接口 — 返回多级组织->项目树(含设备数量)
2026-07-02 05:38:01 +08:00
pub async fn organization_tree (
user : CurrentUser ,
State ( state ) : State < AppState > ,
) -> Result < Json < Value > , AppError > {
auth ::check_permission ( & user , " device:view " ) ? ;
let db = & state . mysql ;
2026-07-02 21:42:52 +08:00
// 获取所有组织
2026-09-20 00:01:01 +08:00
let orgs : Vec < OrganizationRow > = sqlx ::query_as ( ORGS_ALL_SQL ) . fetch_all ( db ) . await ? ;
2026-07-02 05:38:01 +08:00
2026-07-02 21:20:06 +08:00
// 获取所有项目
2026-09-20 00:01:01 +08:00
let projects : Vec < ProjectRow > = sqlx ::query_as ( PROJECTS_ALL_SQL ) . fetch_all ( db ) . await ? ;
2026-07-02 05:38:01 +08:00
2026-07-02 21:42:52 +08:00
// 获取每个项目的设备数量
2026-09-20 00:01:01 +08:00
let project_count_map = fetch_project_counts ( db ) . await ? ;
2026-07-02 21:42:52 +08:00
2026-07-02 21:32:06 +08:00
// 递归构建树
2026-07-02 21:42:52 +08:00
let tree = build_tree ( & orgs , & projects , & project_count_map , None ) ;
2026-07-02 05:38:01 +08:00
2026-09-19 23:40:46 +08:00
Ok ( Json ( json! ( { " code " : 0 , " data " : tree , " message " : " ok " } ) ) )
2026-07-02 05:38:01 +08:00
}
2026-07-02 21:20:06 +08:00
2026-09-20 00:01:01 +08:00
/// 查询「项目ID -> 设备数量」映射
async fn fetch_project_counts ( db : & sqlx ::MySqlPool ) -> Result < HashMap < i64 , i64 > , AppError > {
let project_counts : Vec < ( i64 , i64 ) > = sqlx ::query_as ( PROJECT_CABINET_COUNTS_SQL )
. fetch_all ( db )
. await ? ;
Ok ( project_counts . into_iter ( ) . collect ( ) )
}
/// 递归构建「组织 -> 项目」树节点,项目名称携带设备数量
fn build_tree (
orgs : & [ OrganizationRow ] ,
projects : & [ ProjectRow ] ,
project_count_map : & HashMap < i64 , i64 > ,
parent_id : Option < i64 > ,
) -> Vec < TreeNode > {
orgs . iter ( )
. filter ( | o | o . parent_id = = parent_id )
. map ( | org | {
// 递归获取子组织
let children = build_tree ( orgs , projects , project_count_map , Some ( org . id ) ) ;
// 获取该组织下的项目
let org_projects : Vec < TreeNode > = projects
. iter ( )
. filter ( | p | p . organization_id = = Some ( org . id ) )
. map ( | p | {
let count = project_count_map . get ( & p . id ) . copied ( ) . unwrap_or ( 0 ) ;
TreeNode {
id : p . id ,
name : format ! ( " {} ({}) " , p . name , count ) ,
children : None ,
abstract_id : None ,
imei : None ,
status : None ,
disabled : None ,
address : p . address . clone ( ) ,
latitude : p . latitude . clone ( ) ,
longitude : p . longitude . clone ( ) ,
}
} )
. collect ( ) ;
// 合并子组织和项目
let mut all_children = children ;
all_children . extend ( org_projects ) ;
// 计算本组织下的总设备数量
let total_count : i64 = all_children
. iter ( )
. map ( | c | {
// 从项目名称中提取数量
if let Some ( start ) = c . name . rfind ( '(' ) {
if let Some ( end ) = c . name . rfind ( ')' ) {
c . name [ start + 1 .. end ] . parse ( ) . unwrap_or ( 0 )
} else {
0
}
} else {
0
}
} )
. sum ( ) ;
TreeNode {
id : org . id ,
name : format ! ( " {} ({}) " , org . name , total_count ) ,
children : if all_children . is_empty ( ) {
None
} else {
Some ( all_children )
} ,
abstract_id : None ,
imei : None ,
status : None ,
disabled : org . disabled ,
address : None ,
latitude : None ,
longitude : None ,
}
} )
. collect ( )
}
// ---------------------------------------------------------------------------
// 设备列表筛选
// ---------------------------------------------------------------------------
2026-07-02 21:20:06 +08:00
/// 设备列表筛选参数
#[ derive(Deserialize) ]
pub struct CabinetFilterParams {
pub organization_id : Option < i64 > ,
pub project_id : Option < i64 > ,
2026-07-03 02:45:00 +08:00
pub page : Option < i64 > ,
pub page_size : Option < i64 > ,
pub bound : Option < String > ,
2026-07-02 21:20:06 +08:00
}
/// 设备列表接口 — 根据组织/项目筛选
pub async fn filter_cabinets (
user : CurrentUser ,
State ( state ) : State < AppState > ,
Query ( params ) : Query < CabinetFilterParams > ,
) -> Result < Json < Value > , AppError > {
auth ::check_permission ( & user , " device:view " ) ? ;
let db = & state . mysql ;
2026-07-03 02:45:00 +08:00
let page = params . page . unwrap_or ( 1 ) . max ( 1 ) ;
let page_size = params . page_size . unwrap_or ( 12 ) . clamp ( 1 , 100 ) ;
let offset = ( page - 1 ) * page_size ;
2026-07-02 21:20:06 +08:00
2026-09-20 00:01:01 +08:00
// 组织/项目过滤 + 已绑定/未绑定过滤
let ( where_clause , binds ) = build_filter_where ( & params , & user ) ;
2026-07-02 22:09:19 +08:00
2026-09-20 00:01:01 +08:00
// 总数量
let total = fetch_filter_total ( db , & where_clause , & binds ) . await ;
if total = = 0 | | offset > = total {
return Ok ( Json ( json! ( { " code " : 0 , " data " : { " total " : 0 , " data " : [ ] } , " message " : " ok " } ) ) ) ;
}
// 分页数据
let sql_rows = fetch_filter_page ( db , & where_clause , & binds , page_size , offset ) . await ? ;
// 转换为CabinetRow
let mut rows : Vec < CabinetRow > = sql_rows . into_iter ( ) . map ( CabinetRow ::from ) . collect ( ) ;
// 从Redis获取实时状态( Pipeline批量HGET, 1次网络往返)
attach_realtime_status ( & state , & mut rows ) . await ? ;
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
attach_org_disabled ( db , & mut rows ) . await ;
Ok ( Json ( json! ( { " code " : 0 , " data " : { " total " : total , " data " : rows } , " message " : " ok " } ) ) )
}
/// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致)
2026-09-20 02:44:51 +08:00
///
/// **数据范围叠加**:组织隔离给出"能看到哪个组织", `CurrentUser::scope` 给出
/// "在这个组织里还能看哪些项目"(未配置 = 不限制)。两者**取交集**,
/// 收窄条件统一落在 `c.project_id` 上(粒度到项目为止,不往下钻到单台柜子)。
2026-09-20 00:01:01 +08:00
fn build_filter_where (
params : & CabinetFilterParams ,
user : & CurrentUser ,
) -> ( String , Vec < String > ) {
let ( where_clause , binds ) : ( String , Vec < String > ) = if let Some ( proj_id ) = params . project_id {
2026-07-03 02:45:00 +08:00
( " WHERE c.project_id = ? " . into ( ) , vec! [ proj_id . to_string ( ) ] )
2026-07-02 21:20:06 +08:00
} else if let Some ( org_id ) = params . organization_id {
2026-07-03 02:45:00 +08:00
( " JOIN projects p ON c.project_id = p.id
WHERE p . organization_id IN (
SELECT id FROM organizations WHERE id = ? OR parent_id = ?
) " .into(), vec![org_id.to_string(), org_id.to_string()])
2026-07-02 21:20:06 +08:00
} else if user . role_level > = 2 {
2026-07-03 02:45:00 +08:00
( String ::new ( ) , vec! [ ] )
2026-07-02 21:20:06 +08:00
} else if let Some ( org_id ) = user . organization_id {
2026-07-03 02:45:00 +08:00
( " JOIN projects p ON c.project_id = p.id
WHERE p . organization_id = ? " .into(), vec![org_id.to_string()])
2026-07-02 21:20:06 +08:00
} else {
2026-07-03 02:45:00 +08:00
( " WHERE 1=0 " . into ( ) , vec! [ ] )
2026-07-02 21:20:06 +08:00
} ;
2026-09-19 23:40:46 +08:00
// 已绑定/未绑定过滤(绑定 = 分配到项目)
2026-09-20 02:44:51 +08:00
let mut where_clause = match params . bound . as_deref ( ) {
2026-09-20 00:01:01 +08:00
Some ( " 1 " ) = > append_bound_filter ( where_clause , " WHERE c.project_id IS NOT NULL " , " AND c.project_id IS NOT NULL " ) ,
Some ( " 0 " ) = > append_bound_filter ( where_clause , " WHERE c.project_id IS NULL " , " AND c.project_id IS NULL " ) ,
_ = > where_clause ,
} ;
2026-09-20 02:44:51 +08:00
// 数据范围(项目级)收窄:占位符在 SQL 文本中位于最后 ⇒ 绑定值也必须追加在最后。
// 用 `effective_scope()`(而不是 `user.scope`) : **总管理员恒为"不限制"**,
// 即便别处构造的 CurrentUser 带着非空 scope 也不会把工厂自己收窄。
let mut binds = binds ;
let ( scope_cond , scope_binds ) = user . effective_scope ( ) . project_condition ( " c.project_id " ) ;
auth ::append_where ( & mut where_clause , & scope_cond ) ;
binds . extend ( scope_binds ) ;
2026-09-20 00:01:01 +08:00
( where_clause , binds )
}
/// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接
fn append_bound_filter ( where_clause : String , where_new : & str , and_tail : & str ) -> String {
if where_clause . is_empty ( ) {
where_new . to_string ( )
} else {
let mut clause = where_clause ;
clause . push_str ( and_tail ) ;
clause
2026-07-03 02:45:00 +08:00
}
2026-09-20 00:01:01 +08:00
}
2026-07-03 02:45:00 +08:00
2026-09-20 00:01:01 +08:00
/// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致)
async fn fetch_filter_total ( db : & sqlx ::MySqlPool , where_clause : & str , binds : & [ String ] ) -> i64 {
2026-07-03 02:45:00 +08:00
let count_sql = format! ( " SELECT COUNT(*) FROM cabinets c {} " , where_clause ) ;
let mut count_query = sqlx ::query_scalar ::< _ , i64 > ( & count_sql ) ;
2026-09-20 00:01:01 +08:00
for b in binds {
2026-07-03 02:45:00 +08:00
count_query = count_query . bind ( b ) ;
}
2026-09-20 00:01:01 +08:00
count_query . fetch_one ( db ) . await . unwrap_or ( 0 )
}
2026-07-03 02:45:00 +08:00
2026-09-20 00:01:01 +08:00
/// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定)
async fn fetch_filter_page (
db : & sqlx ::MySqlPool ,
where_clause : & str ,
binds : & [ String ] ,
page_size : i64 ,
offset : i64 ,
) -> Result < Vec < CabinetSqlRow > , AppError > {
let data_sql = format! ( " {} {} ORDER BY c.id LIMIT ? OFFSET ? " , CABINET_FILTER_BASE_SQL , where_clause ) ;
2026-07-03 02:45:00 +08:00
let mut data_query = sqlx ::query_as ::< _ , CabinetSqlRow > ( & data_sql ) ;
2026-09-20 00:01:01 +08:00
for b in binds {
2026-07-03 02:45:00 +08:00
data_query = data_query . bind ( b ) ;
}
2026-09-20 00:01:01 +08:00
let rows : Vec < CabinetSqlRow > = data_query
2026-07-03 02:45:00 +08:00
. bind ( page_size )
. bind ( offset )
2026-09-20 00:01:01 +08:00
. fetch_all ( db )
. await ? ;
Ok ( rows )
}
2026-07-03 02:45:00 +08:00
2026-09-20 02:44:51 +08:00
/// 设备列表 pipeline 中**每个设备**下发的 `HGET` 字段个数。
///
/// ⚠️ **命令顺序与下标一一对应**: `online`(0) / `rssi`(1) / `pow_fail_dc`(2) /
/// `pow_fail_ac`(3) / `cabinet_status1`(4) / `cabinet_status2`(5)。
/// 该常量是 [`attach_realtime_status`] 里 `base = i * DEVICE_LIST_FIELDS` 的唯一依据,
/// **新增字段必须同步改这里、命令顺序与解析下标**(只能追加在末尾)。
const DEVICE_LIST_FIELDS : usize = 6 ;
/// 柜级 hex 位域取值:`None` / 空串 / 纯空白都算"无数据"(→ 响应 `null`) ,
/// **绝不落成 `"00"`**( `"00"` 是"全部正常"的有效值,与"没数据"必须区分)。
/// 非空值**原样返回**(不解析、不改写大小写 —— 前端两端有统一的 hex 解析纯函数)。
fn pick_non_empty_hex ( raw : Option < & Option < String > > ) -> Option < String > {
raw . and_then ( | v | v . as_ref ( ) )
. map ( | v | v . trim ( ) )
. filter ( | v | ! v . is_empty ( ) )
. map ( str ::to_string )
}
2026-09-20 00:01:01 +08:00
/// 从 Redis 批量补齐实时状态( Pipeline HGET, 1 次网络往返)
async fn attach_realtime_status ( state : & AppState , rows : & mut [ CabinetRow ] ) -> Result < ( ) , AppError > {
// 使用多路复用连接( MultiplexedConnection) , 替代已废弃的 aio::Connection
let mut conn = open_redis_conn ( state ) . await ? ;
2026-09-20 02:44:51 +08:00
// 用Pipeline一次发所有HGET命令( 每个设备1次HGET取6个字段)
// ⚠️ 命令顺序与下面解析的下标一一对应: online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2
2026-09-20 00:01:01 +08:00
let mut pipe = redis ::pipe ( ) ;
for row in rows . iter ( ) {
let key = format! ( " device: {} " , row . imei ) ;
pipe . cmd ( " HGET " ) . arg ( & key ) . arg ( " online " ) ;
pipe . cmd ( " HGET " ) . arg ( & key ) . arg ( " rssi " ) ;
pipe . cmd ( " HGET " ) . arg ( & key ) . arg ( " pow_fail_dc " ) ;
pipe . cmd ( " HGET " ) . arg ( & key ) . arg ( " pow_fail_ac " ) ;
2026-09-20 02:44:51 +08:00
// 柜级状态位(协议 §3.8):原样透出 hex, 取不到 → null( 不落 "00")
pipe . cmd ( " HGET " ) . arg ( & key ) . arg ( " cabinet_status1 " ) ;
pipe . cmd ( " HGET " ) . arg ( & key ) . arg ( " cabinet_status2 " ) ;
2026-09-20 00:01:01 +08:00
}
2026-07-03 02:45:00 +08:00
2026-09-20 00:01:01 +08:00
// 一次发送,获取所有结果
let results : Vec < Option < String > > = pipe . query_async ( & mut conn ) . await . unwrap_or_default ( ) ;
2026-09-20 02:44:51 +08:00
// 每6个一组解析
2026-09-20 00:01:01 +08:00
for ( i , row ) in rows . iter_mut ( ) . enumerate ( ) {
2026-09-20 02:44:51 +08:00
let base = i * DEVICE_LIST_FIELDS ;
2026-09-20 00:01:01 +08:00
row . is_online = results . get ( base ) . and_then ( | v | v . as_ref ( ) ) . map ( | v | v = = " 1 " ) ;
row . rssi = results
. get ( base + 1 )
. and_then ( | v | v . as_ref ( ) )
. and_then ( | v | v . parse ::< i32 > ( ) . ok ( ) ) ;
row . pow_fail_dc = results
. get ( base + 2 )
. and_then ( | v | v . as_ref ( ) )
. map ( | v | v = = " 1 " ) ;
row . pow_fail_ac = results
. get ( base + 3 )
. and_then ( | v | v . as_ref ( ) )
. map ( | v | v = = " 1 " ) ;
2026-09-20 02:44:51 +08:00
row . status1 = pick_non_empty_hex ( results . get ( base + 4 ) ) ;
row . status2 = pick_non_empty_hex ( results . get ( base + 5 ) ) ;
2026-07-03 02:45:00 +08:00
}
2026-09-20 02:44:51 +08:00
// 通道级:按柜读取通道状态 → 派生「充满数 / 故障数」并覆盖 SQL 的返回值
apply_derived_counts ( state , rows ) . await ;
2026-09-20 00:01:01 +08:00
Ok ( ( ) )
}
2026-09-20 02:44:51 +08:00
/// 按柜派生「充满数 / 故障数」并**覆盖** SQL 的返回值
///
/// - 依据:通道实时状态 `batt_status` / `pow_status` → [`derive_channel_flags`],
/// 与设备实时详情接口**同一套唯一权威推导规则**(优先级 fault > full > charging > idle) ;
/// - **拿不到 Redis 数据时(设备离线 / 该通道无键 / Redis 不可用)无法判定,按 0 计**,
/// 绝不猜测、也不回落到 SQL 的占位值(那两个值在 DB 里恒为 0, 回落没有意义) ;
/// - `idle_count` / `charging_count` **不动**:它们是 DB 的占用语义( 0/1) , 本来就是对的。
async fn apply_derived_counts ( state : & AppState , rows : & mut [ CabinetRow ] ) {
let cabinet_ids : Vec < i64 > = rows . iter ( ) . map ( | r | r . id ) . collect ( ) ;
let entries = match fetch_channel_keys ( & state . mysql , & cabinet_ids ) . await {
Ok ( entries ) = > entries ,
Err ( e ) = > {
tracing ::error! (
" [cabinets] 通道 key 查询失败,充满/故障计数按 0 计( 离线口径) err={} " ,
e
) ;
Vec ::new ( )
}
} ;
let counts = derive_counts_by_cabinet ( state , & entries ) . await ;
for row in rows . iter_mut ( ) {
// `ChannelCounts = (idle, charging, full, fault)`:只取后两位覆盖响应
let ( _ , _ , full , fault ) = counts . get ( & row . id ) . copied ( ) . unwrap_or ( ( 0 , 0 , 0 , 0 ) ) ;
row . full_count = Some ( full ) ;
row . fault_count = Some ( fault ) ;
}
}
/// 通道级 Redis key 查询行:`(柜子ID, IMEI, 仓控板号, 通道号)`
type ChannelKeyRow = ( i64 , String , i8 , i8 ) ;
/// 通道状态 pipeline 每次 HMGET 的字段(下标顺序即 [`derive_channel_flags`] 的入参顺序)
const CHANNEL_FLAG_FIELDS : [ & str ; 2 ] = [ " batt_status " , " pow_status " ] ;
/// 查询这批柜子的通道级 Redis key, 返回 `(柜子ID, device:{imei}:board:{板号}:ch:{通道号})`
///
/// 为什么按 DB 的「板号 + 通道号」直接拼 key, 而不是 `KEYS device:{imei}:board:*:ch:*`:
/// `KEYS` 是**全库扫描**,列表页 12~100 个柜就是 12~100 次全库扫描( Redis 单线程会被打满);
/// 而通道键的构成与设备上报写入侧完全一致(`workers/report_worker.rs::channel_status_key`:
/// 板号/通道号十进制不补零),按 DB 拼 key 精确且零扫描。
pub ( crate ) async fn fetch_channel_keys (
db : & sqlx ::MySqlPool ,
cabinet_ids : & [ i64 ] ,
) -> Result < Vec < ( i64 , String ) > , AppError > {
if cabinet_ids . is_empty ( ) {
return Ok ( Vec ::new ( ) ) ;
}
let placeholders = vec! [ " ? " ; cabinet_ids . len ( ) ] . join ( " , " ) ;
let sql = format! (
r #" SELECT c.id, c.imei, b.board_id, comp.channel_id
FROM cabinets c
JOIN cabin_boards b ON b . cabinet_id = c . id
JOIN compartments comp ON comp . cabin_board_id = b . id
WHERE c . id IN ( { } )
ORDER BY c . id , b . board_id , comp . channel_id " #,
placeholders
) ;
let mut query = sqlx ::query_as ::< _ , ChannelKeyRow > ( & sql ) ;
for id in cabinet_ids {
query = query . bind ( id ) ;
}
let rows : Vec < ChannelKeyRow > = query . fetch_all ( db ) . await ? ;
Ok ( rows
. into_iter ( )
// IMEI 为空的柜子拼不出有效通道 key( `device::board:1:ch:1`),直接跳过
. filter ( | ( _ , imei , _ , _ ) | ! imei . is_empty ( ) )
. map ( | ( cabinet_id , imei , board_id , channel_id ) | {
(
cabinet_id ,
format! ( " device: {} :board: {} :ch: {} " , imei , board_id , channel_id ) ,
)
} )
. collect ( ) )
}
/// 批量读取通道状态并按柜聚合出四计数,返回 `柜子ID → ChannelCounts`
///
/// **离线策略(产品口径)**:通道读不到数据(键不存在 / 字段缺失)时该通道按派生 idle 记账,
/// 因此设备离线、通道从未上报时 `full` / `fault` 恒为 0 —— **离线无法判定,不猜测**;
/// 表里没有的柜子同样按全 0 处理(调用方 `unwrap_or_default`)。
///
/// ⚠️ 通道级 Hash 由上报链路写入后**没有 TTL**,设备离线后仍会残留最后一次上报值,
/// 所以本口径是「最后一次上报的通道状态」而非严格「在线实时」;若要收紧为仅在线设备,
/// 需额外用 `device:{imei}`( TTL 300s) 是否存在做闸门( 本次未做, 见报告待决策项) 。
///
/// Redis 不可用 / pipeline 失败只记 ERROR 并返回空表(等价于全部无数据 → 充满/故障记 0) ,
/// 绝不因此让列表接口整体失败。
pub ( crate ) async fn derive_counts_by_cabinet (
state : & AppState ,
entries : & [ ( i64 , String ) ] ,
) -> HashMap < i64 , ChannelCounts > {
if entries . is_empty ( ) {
return HashMap ::new ( ) ;
}
let mut conn = match open_redis_conn ( state ) . await {
Ok ( conn ) = > conn ,
Err ( e ) = > {
tracing ::error! ( " [cabinets] Redis 连接失败,充满/故障计数按 0 计 err={} " , e ) ;
return HashMap ::new ( ) ;
}
} ;
// 每个通道 1 次 HMGET( 2 个字段),全部走一次 pipeline
let mut pipe = redis ::pipe ( ) ;
for ( _ , key ) in entries {
pipe . cmd ( " HMGET " ) . arg ( key ) . arg ( & CHANNEL_FLAG_FIELDS ) ;
}
let results : Vec < Vec < Option < String > > > = match pipe . query_async ( & mut conn ) . await {
Ok ( results ) = > results ,
Err ( e ) = > {
tracing ::error! ( " [cabinets] 通道状态读取失败,充满/故障计数按 0 计 err={} " , e ) ;
return HashMap ::new ( ) ;
}
} ;
let mut per_cabinet : HashMap < i64 , Vec < ChannelFlags > > = HashMap ::new ( ) ;
for ( idx , ( cabinet_id , _ ) ) in entries . iter ( ) . enumerate ( ) {
let field = | i : usize | -> Option < & str > {
results
. get ( idx )
. and_then ( | vals | vals . get ( i ) )
. and_then ( | v | v . as_deref ( ) )
} ;
per_cabinet
. entry ( * cabinet_id )
. or_default ( )
. push ( derive_channel_flags ( field ( 0 ) , field ( 1 ) ) ) ;
}
per_cabinet
. into_iter ( )
. map ( | ( cabinet_id , flags ) | ( cabinet_id , derive_channel_counts ( & flags ) ) )
. collect ( )
}
2026-09-20 00:01:01 +08:00
/// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标)
async fn attach_org_disabled ( db : & sqlx ::MySqlPool , rows : & mut [ CabinetRow ] ) {
let project_ids : Vec < i64 > = rows . iter ( ) . filter_map ( | r | r . project_id ) . collect ( ) ;
if project_ids . is_empty ( ) {
return ;
}
let placeholders : Vec < String > = project_ids . iter ( ) . map ( | _ | " ? " . to_string ( ) ) . collect ( ) ;
let sql = format! (
r #" SELECT p.id, o.disabled FROM projects p
2026-09-19 23:40:46 +08:00
LEFT JOIN organizations o ON p . organization_id = o . id
WHERE p . id IN ( { } ) " #,
2026-09-20 00:01:01 +08:00
placeholders . join ( " , " )
) ;
let mut q = sqlx ::query_as ::< _ , ( i64 , i8 ) > ( & sql ) ;
for pid in & project_ids {
q = q . bind ( pid ) ;
}
if let Ok ( results ) = q . fetch_all ( db ) . await {
let disabled_map : HashMap < i64 , bool > =
results . into_iter ( ) . map ( | ( pid , d ) | ( pid , d = = 1 ) ) . collect ( ) ;
for row in rows . iter_mut ( ) {
row . org_disabled = row . project_id . and_then ( | pid | disabled_map . get ( & pid ) ) . copied ( ) ;
2026-09-19 23:40:46 +08:00
}
}
2026-07-03 02:45:00 +08:00
}
2026-09-20 00:01:01 +08:00
// ---------------------------------------------------------------------------
// 设备实时状态
// ---------------------------------------------------------------------------
2026-07-03 02:45:00 +08:00
/// 设备实时状态参数
#[ derive(Deserialize) ]
pub struct DeviceStatusParams {
pub imei : String ,
}
2026-09-20 00:01:01 +08:00
/// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误)
async fn open_redis_conn ( state : & AppState ) -> Result < redis ::aio ::MultiplexedConnection , AppError > {
let client = redis ::Client ::open ( state . redis_url . as_str ( ) ) . map_err ( | e | {
AppError ::Internal ( format! ( " Redis客户端创建失败: {} " , e ) )
} ) ? ;
// 使用多路复用连接( MultiplexedConnection) , 替代已废弃的 aio::Connection
client . get_multiplexed_async_connection ( ) . await . map_err ( | e | {
AppError ::Internal ( format! ( " Redis连接失败: {} " , e ) )
} )
}
2026-07-03 02:45:00 +08:00
/// 设备实时状态接口 — 返回板级+通道级状态
pub async fn device_realtime_status (
user : CurrentUser ,
State ( state ) : State < AppState > ,
Query ( params ) : Query < DeviceStatusParams > ,
) -> Result < Json < Value > , AppError > {
auth ::check_permission ( & user , " device:view " ) ? ;
2026-09-19 23:40:46 +08:00
// 校验设备归属
2026-09-20 00:01:01 +08:00
ensure_device_access ( & state , & user , & params . imei ) . await ? ;
let mut conn = open_redis_conn ( & state ) . await ? ;
let device_status = load_device_status ( & mut conn , & params . imei ) . await ;
let boards = load_boards_status ( & mut conn , & params . imei ) . await ;
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : {
" device " : device_status ,
" boards " : boards ,
} ,
" message " : " ok "
} ) ) )
}
2026-09-20 02:44:51 +08:00
/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织,
/// 且(配了数据范围时)设备所属项目必须落在范围内
2026-09-20 00:01:01 +08:00
async fn ensure_device_access (
state : & AppState ,
user : & CurrentUser ,
imei : & str ,
) -> Result < ( ) , AppError > {
2026-09-19 23:40:46 +08:00
if user . role_level < 2 {
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 ( " p.id " ) ;
let sql = DEVICE_ACCESS_SQL . replacen ( " {} " , & scope_cond , 1 ) ;
let mut query = sqlx ::query_scalar ::< _ , bool > ( & sql ) . bind ( imei ) . bind ( org_id ) ;
for bind in & scope_binds {
query = query . bind ( bind ) ;
}
let has_access : bool = query . fetch_one ( & state . mysql ) . await . unwrap_or ( false ) ;
2026-09-19 23:40:46 +08:00
if ! has_access {
return Err ( AppError ::Forbidden ( " 无权访问该设备 " . into ( ) ) ) ;
}
} else {
return Err ( AppError ::Forbidden ( " 无权访问该设备 " . into ( ) ) ) ;
}
}
2026-09-20 00:01:01 +08:00
Ok ( ( ) )
}
2026-09-19 23:40:46 +08:00
2026-09-20 00:01:01 +08:00
/// 读取设备级字段( online / rssi / pow_fail_dc / pow_fail_ac)
async fn load_device_status (
conn : & mut redis ::aio ::MultiplexedConnection ,
imei : & str ,
) -> Value {
let device_key = format! ( " device: {} " , imei ) ;
2026-07-03 02:45:00 +08:00
// 获取设备级字段
let fields = vec! [ " online " , " rssi " , " pow_fail_dc " , " pow_fail_ac " ] ;
let values : Vec < Option < String > > = redis ::cmd ( " HMGET " )
. arg ( & device_key )
. arg ( & fields )
2026-09-20 00:01:01 +08:00
. query_async ( & mut * conn )
2026-07-03 02:45:00 +08:00
. await
. unwrap_or_default ( ) ;
2026-09-20 00:01:01 +08:00
json! ( {
2026-07-03 02:45:00 +08:00
" online " : values . get ( 0 ) . and_then ( | v | v . as_ref ( ) ) . map ( | v | v = = " 1 " ) . unwrap_or ( false ) ,
" rssi " : values . get ( 1 ) . and_then ( | v | v . as_ref ( ) ) . and_then ( | v | v . parse ::< i32 > ( ) . ok ( ) ) ,
" pow_fail_dc " : values . get ( 2 ) . and_then ( | v | v . as_ref ( ) ) . map ( | v | v = = " 1 " ) . unwrap_or ( false ) ,
" pow_fail_ac " : values . get ( 3 ) . and_then ( | v | v . as_ref ( ) ) . map ( | v | v = = " 1 " ) . unwrap_or ( false ) ,
2026-09-20 00:01:01 +08:00
} )
}
2026-07-03 02:45:00 +08:00
2026-09-20 00:36:37 +08:00
/// 通道级 key `device:{imei}:board:{板号}:ch:{通道号}` 的段数(板号/通道号均十进制不补零)
const CHANNEL_KEY_SEGMENTS : usize = 6 ;
/// 仓控板号量程:协议 §3.7「仓控板 1– 15」
const BOARD_ID_MIN : i64 = 1 ;
const BOARD_ID_MAX : i64 = 15 ;
/// 通道号量程:协议 §3.7「通道 1– 8」( 不是 0– 7)
const CHANNEL_ID_MIN : i64 = 1 ;
const CHANNEL_ID_MAX : i64 = 8 ;
/// 从通道级 key 解析 `(板号, 通道号)`:段数为 6 / 第 3 段是 `board` / 第 5 段是 `ch` /
/// 板号在 1– 15、通道号在 1– 8 才算合法,否则 `None`。
///
/// 为什么必须按段解析:`KEYS device:{imei}:board:*:ch:*` 会把内部键
/// `...:ch:{通道号}:bat`(电池台账)与 `...:ch:{通道号}:order`(订单电量累加器)也扫出来,
/// 按段校验能把它们挡掉,**不会生成幽灵板/幽灵通道**。
fn parse_channel_key ( key : & str ) -> Option < ( i64 , i64 ) > {
let parts : Vec < & str > = key . split ( ':' ) . collect ( ) ;
if parts . len ( ) ! = CHANNEL_KEY_SEGMENTS
| | parts . get ( 2 ) ! = Some ( & " board " )
| | parts . get ( 4 ) ! = Some ( & " ch " )
{
return None ;
}
let board_no : i64 = parts . get ( 3 ) ? . parse ( ) . ok ( ) ? ;
let channel_no : i64 = parts . get ( 5 ) ? . parse ( ) . ok ( ) ? ;
if ! ( BOARD_ID_MIN ..= BOARD_ID_MAX ) . contains ( & board_no )
| | ! ( CHANNEL_ID_MIN ..= CHANNEL_ID_MAX ) . contains ( & channel_no )
{
return None ;
}
Some ( ( board_no , channel_no ) )
}
/// 扫描并组装全部仓控板状态(板级 + 通道级)。
///
/// ⚠️ 设备上报链路**只写通道级 key**( `device:{imei}:board:{板号}:ch:{通道号}`,
/// 见 `workers/report_worker.rs::write_channel_state`) , **新协议里根本没有板级 hash**
/// (老实现的 `device:{imei}:board:{板号}.status_hex` 已不复存在)。
/// 旧代码按 `KEYS device:{imei}:board:*` 取"板级 key",实际扫到的全是 `...:ch:{通道号}`
/// 这类 key, 把**通道号当成了板号** → 返回一堆幽灵板(每个还都没有 channels) ,
/// 这也是本接口"永远返回空"的另一半原因。
/// 现在改为扫描通道级 key、**按解析出的板号分组**,再由通道数据组装板级视图。
2026-09-20 00:01:01 +08:00
async fn load_boards_status (
conn : & mut redis ::aio ::MultiplexedConnection ,
imei : & str ,
) -> Value {
2026-09-20 00:36:37 +08:00
let ch_pattern = format! ( " device: {} :board:*:ch:* " , imei ) ;
let ch_keys : Vec < String > = redis ::cmd ( " KEYS " )
. arg ( & ch_pattern )
2026-09-20 00:01:01 +08:00
. query_async ( & mut * conn )
2026-07-03 02:45:00 +08:00
. await
. unwrap_or_default ( ) ;
2026-09-20 00:36:37 +08:00
// 板号 → [(通道号, key)]: BTreeMap 保证板号升序,输出顺序稳定
let mut grouped : BTreeMap < i64 , Vec < ( i64 , String ) > > = BTreeMap ::new ( ) ;
for key in ch_keys {
if let Some ( ( board_no , channel_no ) ) = parse_channel_key ( & key ) {
grouped . entry ( board_no ) . or_default ( ) . push ( ( channel_no , key ) ) ;
2026-07-03 02:45:00 +08:00
}
2026-09-20 00:36:37 +08:00
}
2026-07-03 02:45:00 +08:00
2026-09-20 00:36:37 +08:00
let mut boards = json! ( { } ) ;
for ( board_no , mut entries ) in grouped {
entries . sort_by_key ( | ( channel_no , _ ) | * channel_no ) ;
let board = load_board_status ( conn , & entries ) . await ;
boards [ board_no . to_string ( ) . as_str ( ) ] = board ;
2026-09-20 00:01:01 +08:00
}
2026-07-03 02:45:00 +08:00
2026-09-20 00:01:01 +08:00
boards
}
2026-09-20 00:36:37 +08:00
/// 由**已排序**的通道级 key 组装单块仓控板的响应(板级 + 通道级)。
async fn load_board_status (
2026-09-20 00:01:01 +08:00
conn : & mut redis ::aio ::MultiplexedConnection ,
2026-09-20 00:36:37 +08:00
entries : & [ ( i64 , String ) ] ,
2026-09-20 00:01:01 +08:00
) -> Value {
let mut channels = json! ( { } ) ;
2026-09-20 00:36:37 +08:00
let mut status_hex = String ::new ( ) ;
for ( channel_no , ch_key ) in entries {
let channel = load_channel_status ( conn , ch_key ) . await ;
// 板级 `status_hex` 已废弃(新协议板级没有状态字节):
// 用各通道的 `batt_status + pow_status`(各 2 位 hex) 按**通道号升序**拼回,
// 与旧格式 `0001000200...` 同构,语义完全由真实字段解释 —— **字段名保持不变**。
if let Some ( hex ) = channel . get ( " hex " ) . and_then ( | v | v . as_str ( ) ) {
status_hex . push_str ( hex ) ;
2026-07-03 02:45:00 +08:00
}
2026-09-20 00:36:37 +08:00
channels [ channel_no . to_string ( ) . as_str ( ) ] = channel ;
2026-07-03 02:45:00 +08:00
}
2026-09-20 00:36:37 +08:00
json! ( {
" status_hex " : status_hex ,
" channels " : channels ,
} )
2026-09-20 00:01:01 +08:00
}
2026-09-20 00:36:37 +08:00
/// 电池状态位(协议 §3.8 `batt_status`) : bit0 充满 / bit1 正在充电 /
/// bit4 过欠压 / bit5 过流 / bit6 热失控 / bit7 有电池
const BATT_FULL_MASK : u8 = 0x01 ;
const BATT_CHARGING_MASK : u8 = 0x02 ;
const BATT_FAULT_MASK : u8 = 0x10 | 0x20 | 0x40 ;
/// 电源状态位(协议 §3.8 `pow_status`) : bit0 电源故障
const POW_FAULT_MASK : u8 = 0x01 ;
/// 2 位(兼容 1 位) hex → u8; 字段缺失 / 长度超 2 / 非 hex → `None`
/// (与 `web/src/utils/deviceStatus.ts::parseHexByte` 口径一致)
fn parse_hex_byte ( raw : Option < & str > ) -> Option < u8 > {
let text = raw ? . trim ( ) ;
if text . is_empty ( ) | | text . len ( ) > 2 {
return None ;
}
u8 ::from_str_radix ( text , 16 ) . ok ( )
}
2026-09-20 02:44:51 +08:00
/// 通道派生结果(协议 §3.8):由 `batt_status` / `pow_status` 两个字节推导出的三态布尔
///
/// 这是**全仓唯一权威推导规则**:设备实时详情([`derive_channel_view`])、
/// 设备列表的充满/故障计数([`derive_channel_counts`])以及仓体展示状态
/// ( `routes::cabinets::get_cabinet_detail` / `routes::h5::get_compartment_detail`)
/// **全部复用它,任何地方都不允许另写一套位判断**。
#[ derive(Debug, Clone, Copy, PartialEq, Eq) ]
pub ( crate ) struct ChannelFlags {
/// `batt_status` bit1( 0x02 正在充电)
pub on : bool ,
/// `batt_status` bit0( 0x01 充满)
pub full : bool ,
/// `batt_status` bit4/5/6( 过欠压 / 过流 / 热失控)**或** `pow_status` bit0( 电源故障)
pub fault : bool ,
/// Redis 里是否存在**可判定**的通道数据(`batt_status` / `pow_status` 至少一个合法)
///
/// 为 `false` 表示设备离线或该通道从未上报 —— 此时无法判定充满/故障,
/// 调用方必须按「无数据」处理(计数按 0 计 / 展示回落到 DB 占用值),**不得猜测**。
pub has_data : bool ,
}
impl ChannelFlags {
/// 展示状态名: fault > full > charging > idle( 唯一优先级顺序, 与旧实现一致)
pub ( crate ) fn status_name ( & self ) -> & 'static str {
if self . fault {
" fault "
} else if self . full {
" full "
} else if self . on {
" charging "
} else {
" idle "
}
}
/// 前端状态码:`0 空闲 / 1 充电中 / 2 充满 / 3 故障`
///
/// 与 `web/src/pages/devices/cabinet.tsx::STATUS_MAP`、
/// `h5-uniapp/src/pages/device-detail/index.vue::compClass`、
/// `h5-uniapp/src/pages/compartment-detail/index.vue::statusText` **四处口径必须一致**
/// ( **没有"禁用"这一档**:仓体级禁用不存在)。
pub ( crate ) fn status_code ( & self ) -> i8 {
match self . status_name ( ) {
" fault " = > 3 ,
" full " = > 2 ,
" charging " = > 1 ,
_ = > 0 ,
}
}
}
/// 纯函数:由通道真实字段推导 [`ChannelFlags`](协议 §3.8,位定义与
/// `software/web/src/utils/deviceStatus.ts` 完全一致)
pub ( crate ) fn derive_channel_flags ( batt_raw : Option < & str > , pow_raw : Option < & str > ) -> ChannelFlags {
let batt = parse_hex_byte ( batt_raw ) ;
let pow = parse_hex_byte ( pow_raw ) ;
ChannelFlags {
full : batt . is_some_and ( | b | b & BATT_FULL_MASK ! = 0 ) ,
on : batt . is_some_and ( | b | b & BATT_CHARGING_MASK ! = 0 ) ,
fault : batt . is_some_and ( | b | b & BATT_FAULT_MASK ! = 0 )
| | pow . is_some_and ( | p | p & POW_FAULT_MASK ! = 0 ) ,
has_data : batt . is_some ( ) | | pow . is_some ( ) ,
}
}
/// 通道四计数(派生口径),顺序固定:`(idle, charging, full, fault)`
///
/// ⚠️ 生产中只有 `.2`(充满)/ `.3`(故障)会被写回响应:
/// `idle_count` / `charging_count` 一律用 DB 的**占用**值(`compartments.status` 的 0/1,
/// 那才是这两列的语义),派生出来的 idle/charging 分区**不覆盖**它们。
/// 保留这两个分区是为了让「全空闲 / 充电中 / 充满 / 故障 / 优先级冲突 / 离线无数据」
/// 这些边界能在同一个可测纯函数里被完整覆盖(见本文件单测)。
pub ( crate ) type ChannelCounts = ( i64 , i64 , i64 , i64 ) ;
/// 纯函数:按 [`ChannelFlags`] 的优先级( fault > full > charging > idle) 把通道集合分区计数
///
/// 无数据(`has_data == false`)的通道按派生 idle 记账 —— 因此「离线 / 无该通道键」时
/// 充满数与故障数恒为 0( **离线无法判定,按 0 计**)。
pub ( crate ) fn derive_channel_counts ( channels : & [ ChannelFlags ] ) -> ChannelCounts {
let mut counts : ChannelCounts = ( 0 , 0 , 0 , 0 ) ;
for flags in channels {
match flags . status_code ( ) {
3 = > counts . 3 + = 1 ,
2 = > counts . 2 + = 1 ,
1 = > counts . 1 + = 1 ,
_ = > counts . 0 + = 1 ,
}
}
counts
}
2026-09-20 00:36:37 +08:00
/// 纯函数:把通道的**真实字段**组装成响应 JSON。
///
2026-09-20 02:44:51 +08:00
/// **响应字段名与旧实现完全一致**(前端在消费),但取值改为由真实字段推导
/// (位判断统一走 [`derive_channel_flags`],本函数不再自己算位):
2026-09-20 00:36:37 +08:00
/// - `on` = `batt_status` bit1( 0x02 正在充电)
/// - `full` = `batt_status` bit0( 0x01 充满)
/// - `fault` = `batt_status` bit4/5/6( 过欠压 / 过流 / 热失控)**或** `pow_status` bit0( 电源故障)
/// - `hex` = `batt_status` + `pow_status`(即通道 18 字符 hex 的前 4 个字符;缺任一字节则为空串)
2026-09-20 02:44:51 +08:00
/// - `status` = `fault` > `full` > `charging` > `idle`(见 [`ChannelFlags::status_name`])
2026-09-20 00:36:37 +08:00
/// - `temp` = 原样透传(单位 **K× 100**,由前端换算,见 `web/.../cabinet.tsx`)
/// 另外**新增** `batt_status` / `pow_status` / `soc`(‰)/ `voltage`( mV) / `current`( mA)
/// 便于管理端展示真实语义(新增字段不影响既有字段名)。
fn derive_channel_view (
batt_raw : Option < & str > ,
pow_raw : Option < & str > ,
soc : Option < i64 > ,
voltage : Option < i64 > ,
current : Option < i64 > ,
temp : Option < i64 > ,
2026-09-20 00:01:01 +08:00
) -> Value {
2026-09-20 02:44:51 +08:00
// hex 需要原始字节;位判断统一交给 `derive_channel_flags`(重复解析是无副作用的纯计算)
2026-09-20 00:36:37 +08:00
let batt = parse_hex_byte ( batt_raw ) ;
let pow = parse_hex_byte ( pow_raw ) ;
2026-09-20 02:44:51 +08:00
let flags = derive_channel_flags ( batt_raw , pow_raw ) ;
2026-09-20 00:01:01 +08:00
2026-09-20 00:36:37 +08:00
// 统一输出小写 2 位 hex( 与 `report_worker` 的写入格式一致)
let batt_status = batt . map ( | b | format! ( " {:02x} " , b ) ) . unwrap_or_default ( ) ;
let pow_status = pow . map ( | p | format! ( " {:02x} " , p ) ) . unwrap_or_default ( ) ;
let hex = if batt_status . is_empty ( ) | | pow_status . is_empty ( ) {
String ::new ( )
} else {
format! ( " {} {} " , batt_status , pow_status )
} ;
2026-09-20 00:01:01 +08:00
json! ( {
2026-09-20 02:44:51 +08:00
" status " : flags . status_name ( ) ,
" on " : flags . on ,
" full " : flags . full ,
" fault " : flags . fault ,
2026-09-20 00:36:37 +08:00
" hex " : hex ,
" temp " : temp ,
" batt_status " : batt_status ,
" pow_status " : pow_status ,
" soc " : soc ,
" voltage " : voltage ,
" current " : current ,
2026-09-20 00:01:01 +08:00
} )
2026-07-02 21:20:06 +08:00
}
2026-09-20 00:36:37 +08:00
/// 读取单个通道状态。
///
/// ⚠️ 旧实现读的是 `on` / `full` / `fault` / `hex`,但设备上报链路
/// ( `workers/report_worker.rs::write_channel_state`) **只写**:
/// `batt_status` / `pow_status`(各 2 位 hex) / `soc`(‰)/ `voltage`( mV) /
/// `current`( mA) / `temp`( K× 100) —— 旧字段根本不存在,于是本接口恒返回
/// `status:"idle"` + 三个 `false` + `hex:""`,管理端"设备实时详情"一直是坏的。
/// 现在读**真实存在**的字段,再由它们推导旧字段(字段名保持不变,推导规则见 [`derive_channel_view`])。
async fn load_channel_status (
conn : & mut redis ::aio ::MultiplexedConnection ,
ch_key : & str ,
) -> Value {
let ch_fields = vec! [ " batt_status " , " pow_status " , " soc " , " voltage " , " current " , " temp " ] ;
let ch_values : Vec < Option < String > > = redis ::cmd ( " HMGET " )
. arg ( ch_key )
. arg ( & ch_fields )
. query_async ( & mut * conn )
. await
. unwrap_or_default ( ) ;
let text = | idx : usize | -> Option < & str > {
ch_values . get ( idx ) . and_then ( | v | v . as_ref ( ) ) . map ( | v | v . as_str ( ) )
} ;
let number = | idx : usize | -> Option < i64 > { text ( idx ) . and_then ( | v | v . parse ::< i64 > ( ) . ok ( ) ) } ;
derive_channel_view (
text ( 0 ) ,
text ( 1 ) ,
number ( 2 ) ,
number ( 3 ) ,
number ( 4 ) ,
number ( 5 ) ,
)
}
// ---------------------------------------------------------------------------
// 单元测试:通道状态推导(协议 §3.8,口径必须与 web 端 deviceStatus.ts 一致)
//
// 只测纯函数(不连 Redis / DB) : 这一层是"设备实时详情"接口正确性的关键,
// 因为它负责把 `batt_status` / `pow_status` 翻译成前端消费的旧字段名。
// ---------------------------------------------------------------------------
#[ cfg(test) ]
mod tests {
use super ::* ;
2026-09-20 02:44:51 +08:00
// 设备列表测试要构造 `CurrentUser`(其 `scope` 字段是 `UserScope`) ,
// `super::*` 只带进来 `CurrentUser`, `UserScope` 需显式引入
use crate ::middleware ::auth ::UserScope ;
2026-09-20 00:36:37 +08:00
/// 交付文档 §5.3 示例 `828255152c01f40143`:有电池 + 正在充电、电源已打开
#[ test ]
fn test_derive_channel_view_charging_channel_sets_on_true ( ) {
let view = derive_channel_view ( Some ( " 82 " ) , Some ( " 82 " ) , Some ( 850 ) , Some ( 54200 ) , Some ( 5000 ) , Some ( 32300 ) ) ;
assert_eq! ( view [ " status " ] , " charging " ) ;
assert_eq! ( view [ " on " ] , true ) ;
assert_eq! ( view [ " full " ] , false ) ;
assert_eq! ( view [ " fault " ] , false ) ;
assert_eq! ( view [ " hex " ] , " 8282 " ) ;
assert_eq! ( view [ " batt_status " ] , " 82 " ) ;
assert_eq! ( view [ " pow_status " ] , " 82 " ) ;
assert_eq! ( view [ " soc " ] , 850 ) ;
assert_eq! ( view [ " voltage " ] , 54200 ) ;
assert_eq! ( view [ " current " ] , 5000 ) ;
assert_eq! ( view [ " temp " ] , 32300 ) ;
}
/// 充满(`batt_status` bit0) + 仍在报充电位 → `status` 取「充满」(优先级 full > charging)
#[ test ]
fn test_derive_channel_view_full_battery_prefers_full_over_charging ( ) {
let view = derive_channel_view ( Some ( " 83 " ) , Some ( " 82 " ) , None , None , None , None ) ;
assert_eq! ( view [ " full " ] , true ) ;
assert_eq! ( view [ " on " ] , true ) ;
assert_eq! ( view [ " status " ] , " full " ) ;
}
/// `batt_status` 的过欠压 / 过流 / 热失控位( bit4/5/6) 任一置位 → 故障
#[ test ]
fn test_derive_channel_view_batt_fault_bits_map_to_fault ( ) {
for ( batt , other ) in [ ( " 10 " , " 00 " ) , ( " 20 " , " 00 " ) , ( " 40 " , " 00 " ) , ( " 10 " , " 82 " ) ] {
let view = derive_channel_view ( Some ( batt ) , Some ( other ) , None , None , None , None ) ;
assert_eq! ( view [ " fault " ] , true , " batt={batt} pow={other} " ) ;
assert_eq! ( view [ " status " ] , " fault " , " batt={batt} pow={other} " ) ;
}
// 故障优先级最高:即使同时"充满 + 正在充电",仍是 fault
let view = derive_channel_view ( Some ( " 43 " ) , Some ( " 00 " ) , None , None , None , None ) ;
assert_eq! ( view [ " fault " ] , true ) ;
assert_eq! ( view [ " status " ] , " fault " ) ;
}
/// `pow_status` bit0 = 电源故障 → 通道判故障(与电池状态相互独立)
#[ test ]
fn test_derive_channel_view_pow_fault_maps_to_fault ( ) {
let view = derive_channel_view ( Some ( " 82 " ) , Some ( " 01 " ) , None , None , None , None ) ;
assert_eq! ( view [ " fault " ] , true ) ;
assert_eq! ( view [ " status " ] , " fault " ) ;
assert_eq! ( view [ " hex " ] , " 8201 " ) ;
}
/// **空仓**: 无电池( batt `00`) + 电源已打开( pow `82`)→ 语义仍是 idle,
/// 但电源状态照实返回(两维度独立,协议 §3.8)
#[ test ]
fn test_derive_channel_view_empty_channel_keeps_power_status ( ) {
let view = derive_channel_view ( Some ( " 00 " ) , Some ( " 82 " ) , Some ( 0 ) , Some ( 0 ) , Some ( 0 ) , Some ( 0 ) ) ;
assert_eq! ( view [ " status " ] , " idle " ) ;
assert_eq! ( view [ " on " ] , false ) ;
assert_eq! ( view [ " full " ] , false ) ;
assert_eq! ( view [ " fault " ] , false ) ;
assert_eq! ( view [ " hex " ] , " 0082 " ) ;
assert_eq! ( view [ " pow_status " ] , " 82 " ) ;
}
/// 字段整体缺失(老数据 / 通道刚建立)→ 全部落空,**不得 panic、不得拼出半个字节**
#[ test ]
fn test_derive_channel_view_missing_fields_yields_idle_and_empty_hex ( ) {
let view = derive_channel_view ( None , None , None , None , None , None ) ;
assert_eq! ( view [ " status " ] , " idle " ) ;
assert_eq! ( view [ " on " ] , false ) ;
assert_eq! ( view [ " full " ] , false ) ;
assert_eq! ( view [ " fault " ] , false ) ;
assert_eq! ( view [ " hex " ] , " " ) ;
assert_eq! ( view [ " batt_status " ] , " " ) ;
assert_eq! ( view [ " pow_status " ] , " " ) ;
assert_eq! ( view [ " soc " ] , Value ::Null ) ;
assert_eq! ( view [ " temp " ] , Value ::Null ) ;
}
/// 只有一个字节(例如电源状态缺失)→ `hex` 必须为空串,而不是只拼一半
#[ test ]
fn test_derive_channel_view_single_byte_yields_empty_hex ( ) {
let view = derive_channel_view ( Some ( " 82 " ) , None , None , None , None , None ) ;
assert_eq! ( view [ " hex " ] , " " ) ;
assert_eq! ( view [ " batt_status " ] , " 82 " ) ;
assert_eq! ( view [ " status " ] , " charging " ) ;
}
/// hex 大小写不敏感 + 兼容 1 位写法,输出统一补零小写(与 web 端 `parseHexByte` 一致)
#[ test ]
fn test_derive_channel_view_normalizes_hex_case_and_padding ( ) {
let view = derive_channel_view ( Some ( " 8A " ) , Some ( " A " ) , None , None , None , None ) ;
assert_eq! ( view [ " batt_status " ] , " 8a " ) ;
assert_eq! ( view [ " pow_status " ] , " 0a " ) ;
assert_eq! ( view [ " hex " ] , " 8a0a " ) ;
}
2026-09-20 02:44:51 +08:00
// ---- ChannelFlags / derive_channel_counts: 由通道状态派生「充满数 / 故障数」 ----
/// 全空闲:有数据但既没充满也没故障(`batt_status=00`,电源已开)→ 全部记 idle
#[ test ]
fn test_derive_channel_counts_all_idle_channels_yield_zero_full_fault ( ) {
let channels : Vec < ChannelFlags > = [ " 00 " , " 80 " ]
. iter ( )
. map ( | batt | derive_channel_flags ( Some ( * batt ) , Some ( " 82 " ) ) )
. collect ( ) ;
assert_eq! ( derive_channel_counts ( & channels ) , ( 2 , 0 , 0 , 0 ) ) ;
}
/// 充电中(`batt_status` bit1) → 记 charging, 不算充满也不算故障
#[ test ]
fn test_derive_channel_counts_charging_channels_counted_as_charging ( ) {
let channels = vec! [
derive_channel_flags ( Some ( " 82 " ) , Some ( " 82 " ) ) ,
derive_channel_flags ( Some ( " 82 " ) , Some ( " 82 " ) ) ,
derive_channel_flags ( Some ( " 00 " ) , Some ( " 82 " ) ) ,
] ;
assert_eq! ( derive_channel_counts ( & channels ) , ( 1 , 2 , 0 , 0 ) ) ;
}
/// 充满(`batt_status` bit0) : 即使同时带充电位( 0x83) 也只记充满
#[ test ]
fn test_derive_channel_counts_full_channels_counted_as_full ( ) {
let channels = vec! [
derive_channel_flags ( Some ( " 81 " ) , Some ( " 82 " ) ) ,
derive_channel_flags ( Some ( " 83 " ) , Some ( " 82 " ) ) ,
] ;
assert_eq! ( derive_channel_counts ( & channels ) , ( 0 , 0 , 2 , 0 ) ) ;
}
/// 故障:`batt_status` bit4/5/6 与 `pow_status` bit0 都算故障(两个维度独立)
#[ test ]
fn test_derive_channel_counts_fault_channels_counted_as_fault ( ) {
let channels = vec! [
derive_channel_flags ( Some ( " 10 " ) , Some ( " 00 " ) ) ,
derive_channel_flags ( Some ( " 20 " ) , Some ( " 00 " ) ) ,
derive_channel_flags ( Some ( " 40 " ) , Some ( " 00 " ) ) ,
derive_channel_flags ( Some ( " 00 " ) , Some ( " 01 " ) ) ,
] ;
assert_eq! ( derive_channel_counts ( & channels ) , ( 0 , 0 , 0 , 4 ) ) ;
}
/// **优先级冲突**: 同时满足「充满」( bit0) 与「故障」( bit4) 时算故障, 不算充满
#[ test ]
fn test_derive_channel_counts_fault_wins_over_full_on_conflict ( ) {
// 0x11 = 充满 + 过欠压; 0x13 = 充满 + 充电中 + 过欠压; 0x41 = 充满 + 热失控
let channels = vec! [
derive_channel_flags ( Some ( " 11 " ) , Some ( " 00 " ) ) ,
derive_channel_flags ( Some ( " 13 " ) , Some ( " 00 " ) ) ,
derive_channel_flags ( Some ( " 41 " ) , Some ( " 00 " ) ) ,
] ;
let ( idle , charging , full , fault ) = derive_channel_counts ( & channels ) ;
assert_eq! ( ( idle , charging , full , fault ) , ( 0 , 0 , 0 , 3 ) ) ;
}
/// **离线 / 无数据**:空集合与「字段全缺失」的通道 → 充满数故障数都必须为 0( 不猜测)
#[ test ]
fn test_derive_channel_counts_offline_missing_data_yields_zero_full_fault ( ) {
// 该柜在 Redis 里一个通道键都没有(设备从未上报)
assert_eq! ( derive_channel_counts ( & [ ] ) , ( 0 , 0 , 0 , 0 ) ) ;
// 键存在但字段全缺失(老数据 / 通道刚建立)→ 无数据,同样不得计成充满或故障
let no_data = vec! [
derive_channel_flags ( None , None ) ,
derive_channel_flags ( None , None ) ,
] ;
let ( idle , charging , full , fault ) = derive_channel_counts ( & no_data ) ;
assert_eq! ( ( idle , charging , full , fault ) , ( 2 , 0 , 0 , 0 ) ) ;
// 混合:一半有数据(一个充满 + 一个故障)、一半无数据 → 只统计有数据的那两个
let mixed = vec! [
derive_channel_flags ( Some ( " 81 " ) , Some ( " 82 " ) ) ,
derive_channel_flags ( Some ( " 10 " ) , Some ( " 00 " ) ) ,
derive_channel_flags ( None , None ) ,
derive_channel_flags ( None , None ) ,
] ;
let ( idle , charging , full , fault ) = derive_channel_counts ( & mixed ) ;
assert_eq! ( ( idle , charging , full , fault ) , ( 2 , 0 , 1 , 1 ) ) ;
}
/// `has_data` / `status_code`:与前端 `0空闲/1充电中/2充满/3故障` 口径一致,无"禁用"档
#[ test ]
fn test_channel_flags_has_data_and_status_code_match_frontend_mapping ( ) {
// 无数据(字段缺失)→ has_data=false, 状态码回落 0( 调用方据此改用 DB 占用值)
let none = derive_channel_flags ( None , None ) ;
assert! ( ! none . has_data ) ;
assert_eq! ( none . status_code ( ) , 0 ) ;
// 只有电源状态、没有电池状态 → 数据存在,按电源维度判定(电源故障=3)
let pow_only = derive_channel_flags ( None , Some ( " 01 " ) ) ;
assert! ( pow_only . has_data ) ;
assert_eq! ( pow_only . status_code ( ) , 3 ) ;
assert_eq! ( derive_channel_flags ( Some ( " 00 " ) , Some ( " 82 " ) ) . status_code ( ) , 0 ) ;
assert_eq! ( derive_channel_flags ( Some ( " 82 " ) , Some ( " 82 " ) ) . status_code ( ) , 1 ) ;
assert_eq! ( derive_channel_flags ( Some ( " 81 " ) , Some ( " 02 " ) ) . status_code ( ) , 2 ) ;
assert_eq! ( derive_channel_flags ( Some ( " 10 " ) , Some ( " 00 " ) ) . status_code ( ) , 3 ) ;
// 非法 hex 与空串都按「无数据」处理(不 panic、不猜成充满/故障)
for bad in [ " " , " " , " zz " , " 123 " ] {
let flags = derive_channel_flags ( Some ( bad ) , Some ( bad ) ) ;
assert! ( ! flags . has_data , " 非法 hex {bad:?} 必须按无数据处理 " ) ;
assert_eq! ( flags . status_code ( ) , 0 ) ;
}
}
2026-09-20 00:36:37 +08:00
/// hex 解析:长度超 2 / 非 hex / 空白 → `None`
#[ test ]
fn test_parse_hex_byte_rejects_malformed_values ( ) {
assert_eq! ( parse_hex_byte ( None ) , None ) ;
assert_eq! ( parse_hex_byte ( Some ( " " ) ) , None ) ;
assert_eq! ( parse_hex_byte ( Some ( " " ) ) , None ) ;
assert_eq! ( parse_hex_byte ( Some ( " 123 " ) ) , None ) ;
assert_eq! ( parse_hex_byte ( Some ( " zz " ) ) , None ) ;
assert_eq! ( parse_hex_byte ( Some ( " 82 " ) ) , Some ( 0x82 ) ) ;
assert_eq! ( parse_hex_byte ( Some ( " 82 " ) ) , Some ( 0x82 ) ) ;
}
2026-09-20 02:44:51 +08:00
/// 设备列表 pipeline 的柜级 hex 位域取值(协议 §3.8) :
/// `"00"`(全正常)必须原样保留,缺失/空串/纯空白必须为 `None`(响应 `null`)。
#[ test ]
fn test_pick_non_empty_hex_keeps_00_but_drops_missing ( ) {
assert_eq! (
pick_non_empty_hex ( Some ( & Some ( " 00 " . to_string ( ) ) ) ) . as_deref ( ) ,
Some ( " 00 " ) ,
" \" 00 \" 是「全部正常」的有效值,不能当成无数据 "
) ;
assert_eq! (
pick_non_empty_hex ( Some ( & Some ( " 10 " . to_string ( ) ) ) ) . as_deref ( ) ,
Some ( " 10 " ) ,
" 柜门打开位必须原样透出 "
) ;
assert_eq! ( pick_non_empty_hex ( Some ( & Some ( String ::new ( ) ) ) ) , None ) ;
assert_eq! ( pick_non_empty_hex ( Some ( & Some ( " " . to_string ( ) ) ) ) , None ) ;
assert_eq! ( pick_non_empty_hex ( Some ( & None ) ) , None ) ;
assert_eq! ( pick_non_empty_hex ( None ) , None ) ;
}
/// 设备列表 pipeline 每设备字段数必须与命令顺序/解析下标同步
#[ test ]
fn test_device_list_fields_in_sync ( ) {
assert_eq! (
DEVICE_LIST_FIELDS , 6 ,
" online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2 "
) ;
}
2026-09-20 00:36:37 +08:00
2026-09-20 02:44:51 +08:00
// ---- parse_channel_key: 通道级 key 分组(板号 1– 15 / 通道号 1– 8) -------
2026-09-20 00:36:37 +08:00
/// 正常通道 key → (板号, 通道号)
#[ test ]
fn test_parse_channel_key_accepts_real_channel_keys ( ) {
assert_eq! (
parse_channel_key ( " device:864720562557021:board:1:ch:3 " ) ,
Some ( ( 1 , 3 ) )
) ;
assert_eq! (
parse_channel_key ( " device:864720562557021:board:15:ch:8 " ) ,
Some ( ( 15 , 8 ) )
) ;
}
/// **内部键必须被挡掉**: `...:ch:{通道号}:bat`(电池台账)/ `...:ch:{通道号}:order`(电量累加器)
/// 若被当成通道,会生成幽灵板(旧实现就是按 KEYS 末段取"板号"的)
#[ test ]
fn test_parse_channel_key_rejects_internal_keys ( ) {
assert_eq! (
parse_channel_key ( " device:864720562557021:board:1:ch:3:bat " ) ,
None
) ;
assert_eq! (
parse_channel_key ( " device:864720562557021:board:1:ch:3:order " ) ,
None
) ;
// 旧实现会扫到这种"板级 key"(其实不存在),现在也必须拒绝
assert_eq! ( parse_channel_key ( " device:864720562557021:board:1 " ) , None ) ;
assert_eq! ( parse_channel_key ( " device:864720562557021 " ) , None ) ;
assert_eq! ( parse_channel_key ( " " ) , None ) ;
}
/// 量程与结构校验:板号 0/16、通道号 0/9、非数字、段内关键字写错都拒绝
#[ test ]
fn test_parse_channel_key_rejects_out_of_range_and_malformed ( ) {
assert_eq! ( parse_channel_key ( " device:864720562557021:board:0:ch:3 " ) , None ) ;
assert_eq! ( parse_channel_key ( " device:864720562557021:board:16:ch:3 " ) , None ) ;
assert_eq! ( parse_channel_key ( " device:864720562557021:board:1:ch:0 " ) , None ) ;
assert_eq! ( parse_channel_key ( " device:864720562557021:board:1:ch:9 " ) , None ) ;
assert_eq! ( parse_channel_key ( " device:864720562557021:board:a:ch:3 " ) , None ) ;
assert_eq! ( parse_channel_key ( " device:864720562557021:board:1:xx:3 " ) , None ) ;
assert_eq! (
parse_channel_key ( " device:864720562557021:brd:1:ch:3 " ) ,
None
) ;
}
2026-09-20 02:44:51 +08:00
// -----------------------------------------------------------------------
// 设备列表的数据范围( user_scopes) 叠加
// -----------------------------------------------------------------------
/// 构造一个客户侧用户( role=1, 属组织 42) , scope 由调用方给定
fn make_user ( scope : UserScope ) -> CurrentUser {
CurrentUser {
user_id : 9 ,
phone : " 13800138000 " . into ( ) ,
permissions : std ::collections ::HashSet ::new ( ) ,
role_level : 1 ,
organization_id : Some ( 42 ) ,
scope ,
}
}
/// **未配置 scope = 不限制**:设备列表 WHERE 与原实现逐字一致(向后兼容)
#[ test ]
fn test_build_filter_where_without_scope_is_unchanged ( ) {
let params = CabinetFilterParams {
organization_id : None ,
project_id : None ,
page : None ,
page_size : None ,
bound : None ,
} ;
let ( clause , binds ) = build_filter_where ( & params , & make_user ( UserScope ::default ( ) ) ) ;
assert_eq! ( clause , " JOIN projects p ON c.project_id = p.id \n WHERE p.organization_id = ? " ) ;
assert_eq! ( binds , vec! [ " 42 " ] ) ;
}
/// **配置了项目范围**:组织条件之后必须再叠加 `c.project_id IN (...)`(取交集)
#[ test ]
fn test_build_filter_where_with_scope_appends_project_narrowing ( ) {
let params = CabinetFilterParams {
organization_id : None ,
project_id : None ,
page : None ,
page_size : None ,
bound : None ,
} ;
let user = make_user ( UserScope { project_ids : vec ! [ 7 , 9 ] } ) ;
let ( clause , binds ) = build_filter_where ( & params , & user ) ;
assert! ( clause . contains ( " WHERE p.organization_id = ? " ) , " 组织隔离不能丢:{} " , clause ) ;
assert! ( clause . contains ( " AND c.project_id IN (?,?) " ) , " 数据范围必须叠加:{} " , clause ) ;
assert_eq! ( binds , vec! [ " 42 " , " 7 " , " 9 " ] , " 绑定顺序必须与占位符顺序一致 " ) ;
}
/// 显式指定了范围外项目 / 其他组织时,范围条件仍然叠加 ⇒ 结果必然为空集(不泄露)
#[ test ]
fn test_build_filter_where_scope_applies_to_explicit_project_and_org_params ( ) {
let user = make_user ( UserScope { project_ids : vec ! [ 7 ] } ) ;
let by_project = CabinetFilterParams {
organization_id : None ,
project_id : Some ( 999 ) ,
page : None ,
page_size : None ,
bound : None ,
} ;
let ( clause , binds ) = build_filter_where ( & by_project , & user ) ;
assert_eq! ( clause , " WHERE c.project_id = ? AND c.project_id IN (?) " ) ;
assert_eq! ( binds , vec! [ " 999 " , " 7 " ] ) ;
let by_org = CabinetFilterParams {
organization_id : Some ( 43 ) ,
project_id : None ,
page : None ,
page_size : None ,
bound : None ,
} ;
let ( clause , binds ) = build_filter_where ( & by_org , & user ) ;
assert! ( clause . contains ( " AND c.project_id IN (?) " ) , " 组织参数分支也要叠加范围:{} " , clause ) ;
assert_eq! ( binds , vec! [ " 43 " , " 43 " , " 7 " ] ) ;
}
/// 已绑定/未绑定过滤与数据范围可以共存,且绑定值顺序正确
#[ test ]
fn test_build_filter_where_scope_combines_with_bound_filter ( ) {
let params = CabinetFilterParams {
organization_id : None ,
project_id : None ,
page : None ,
page_size : None ,
bound : Some ( " 1 " . into ( ) ) ,
} ;
let user = make_user ( UserScope { project_ids : vec ! [ 7 ] } ) ;
let ( clause , binds ) = build_filter_where ( & params , & user ) ;
assert! ( clause . ends_with ( " AND c.project_id IS NOT NULL AND c.project_id IN (?) " ) , " {} " , clause ) ;
assert_eq! ( binds , vec! [ " 42 " , " 7 " ] ) ;
}
/// 总管理员( role_level>=2) 不受数据范围收窄( 豁免)
#[ test ]
fn test_build_filter_where_super_admin_ignores_scope ( ) {
let params = CabinetFilterParams {
organization_id : None ,
project_id : None ,
page : None ,
page_size : None ,
bound : None ,
} ;
let mut user = make_user ( UserScope { project_ids : vec ! [ 7 ] } ) ;
user . role_level = 2 ;
user . organization_id = None ;
let ( clause , binds ) = build_filter_where ( & params , & user ) ;
assert! ( clause . is_empty ( ) , " 总管理员无任何过滤:{} " , clause ) ;
assert! ( binds . is_empty ( ) ) ;
}
/// 设备归属校验 SQL: 未配置范围时不收窄, 配置后必须叠加项目收窄
#[ test ]
fn test_device_access_sql_appends_scope_only_when_configured ( ) {
let plain = DEVICE_ACCESS_SQL . replacen ( " {} " , " " , 1 ) ;
assert! ( plain . contains ( " p.organization_id = ?) " ) ) ;
assert! ( ! plain . contains ( " project_id IN " ) ) ;
let ( cond , binds ) = UserScope { project_ids : vec ! [ 7 , 9 ] } . project_condition ( " p.id " ) ;
let narrowed = DEVICE_ACCESS_SQL . replacen ( " {} " , & cond , 1 ) ;
assert! ( narrowed . contains ( " p.id IN (?,?) " ) , " {} " , narrowed ) ;
assert_eq! ( binds , vec! [ " 7 " , " 9 " ] ) ;
}
2026-09-20 00:36:37 +08:00
}