//! 组织树 API //! //! 两个独立接口: //! 1. /api/organization-tree — 返回多级组织->项目树 //! 2. /api/cabinets/filter — 根据组织/项目筛选设备列表 use axum::extract::{State, Query}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap}; use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; use crate::commands::AppState; /// SQL查询专用结构体(只包含数据库字段) #[derive(Debug, Clone, sqlx::FromRow)] struct CabinetSqlRow { id: i64, project_id: Option, abstract_id: Option, imei: String, iccid: Option, name: Option, address: Option, project_address: Option, status: i8, board_count: Option, channel_count: Option, idle_count: Option, charging_count: Option, full_count: Option, fault_count: Option, } impl From 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, project_address: row.project_address, 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, // 柜级位域由 attach_realtime_status 从 Redis 补取(取不到保持 null) status1: None, status2: None, is_online: None, org_disabled: None, } } } // --------------------------------------------------------------------------- // 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/... 原样保留) /// /// ⚠️ **口径说明(本次产品决定)**:`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`] 的注释。 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 "; /// 设备归属校验(非总管理员):设备必须属于当前用户组织 **且落在其数据范围内** /// /// 数据范围未配置时该附加条件为空串 ⇒ 与今天的行为完全一致(向后兼容)。 const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \ JOIN projects p ON c.project_id = p.id \ WHERE c.imei = ? AND p.organization_id = ?{})"; // --------------------------------------------------------------------------- // 组织树 // --------------------------------------------------------------------------- /// 组织树接口 — 返回多级组织->项目树(含设备数量) pub async fn organization_tree( user: CurrentUser, State(state): State, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let db = &state.mysql; // 获取所有组织 let orgs: Vec = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?; // 获取所有项目 let projects: Vec = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?; // 获取每个项目的设备数量 let project_count_map = fetch_project_counts(db).await?; // 递归构建树 let tree = build_tree(&orgs, &projects, &project_count_map, None); Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" }))) } /// 查询「项目ID -> 设备数量」映射 async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result, 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, parent_id: Option, ) -> Vec { 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 = 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() } // --------------------------------------------------------------------------- // 设备列表筛选 // --------------------------------------------------------------------------- /// 设备列表筛选参数 #[derive(Deserialize)] pub struct CabinetFilterParams { pub organization_id: Option, pub project_id: Option, pub page: Option, pub page_size: Option, pub bound: Option, } /// 设备列表接口 — 根据组织/项目筛选 pub async fn filter_cabinets( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let db = &state.mysql; 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; // 组织/项目过滤 + 已绑定/未绑定过滤 let (where_clause, binds) = build_filter_where(¶ms, &user); // 总数量 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 = 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 片段及其绑定参数(分支顺序与语义与原实现一致) /// /// **数据范围叠加**:组织隔离给出"能看到哪个组织",`CurrentUser::scope` 给出 /// "在这个组织里还能看哪些项目"(未配置 = 不限制)。两者**取交集**, /// 收窄条件统一落在 `c.project_id` 上(粒度到项目为止,不往下钻到单台柜子)。 fn build_filter_where( params: &CabinetFilterParams, user: &CurrentUser, ) -> (String, Vec) { let (where_clause, binds): (String, Vec) = if let Some(proj_id) = params.project_id { ("WHERE c.project_id = ?".into(), vec![proj_id.to_string()]) } else if let Some(org_id) = params.organization_id { ("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()]) } else if user.role_level >= 2 { (String::new(), vec![]) } else if let Some(org_id) = user.organization_id { ("JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?".into(), vec![org_id.to_string()]) } else { ("WHERE 1=0".into(), vec![]) }; // 已绑定/未绑定过滤(绑定 = 分配到项目) let mut where_clause = match params.bound.as_deref() { 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, }; // 数据范围(项目级)收窄:占位符在 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); (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 } } /// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致) async fn fetch_filter_total(db: &sqlx::MySqlPool, where_clause: &str, binds: &[String]) -> i64 { let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause); let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); for b in binds { count_query = count_query.bind(b); } count_query.fetch_one(db).await.unwrap_or(0) } /// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定) async fn fetch_filter_page( db: &sqlx::MySqlPool, where_clause: &str, binds: &[String], page_size: i64, offset: i64, ) -> Result, AppError> { let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", CABINET_FILTER_BASE_SQL, where_clause); let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql); for b in binds { data_query = data_query.bind(b); } let rows: Vec = data_query .bind(page_size) .bind(offset) .fetch_all(db) .await?; Ok(rows) } /// 设备列表 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>) -> Option { raw.and_then(|v| v.as_ref()) .map(|v| v.trim()) .filter(|v| !v.is_empty()) .map(str::to_string) } /// 从 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?; // 用Pipeline一次发所有HGET命令(每个设备1次HGET取6个字段) // ⚠️ 命令顺序与下面解析的下标一一对应:online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2 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"); // 柜级状态位(协议 §3.8):原样透出 hex,取不到 → null(不落 "00") pipe.cmd("HGET").arg(&key).arg("cabinet_status1"); pipe.cmd("HGET").arg(&key).arg("cabinet_status2"); } // 一次发送,获取所有结果 let results: Vec> = pipe.query_async(&mut conn).await.unwrap_or_default(); // 每6个一组解析 for (i, row) in rows.iter_mut().enumerate() { let base = i * DEVICE_LIST_FIELDS; 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::().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"); row.status1 = pick_non_empty_hex(results.get(base + 4)); row.status2 = pick_non_empty_hex(results.get(base + 5)); } // 通道级:按柜读取通道状态 → 派生「充满数 / 故障数」并覆盖 SQL 的返回值 apply_derived_counts(state, rows).await; Ok(()) } /// 按柜派生「充满数 / 故障数」并**覆盖** 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 = 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, 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 = 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 { 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>> = 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> = 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() } /// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标) async fn attach_org_disabled(db: &sqlx::MySqlPool, rows: &mut [CabinetRow]) { let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); if project_ids.is_empty() { return; } let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); let sql = format!( r#"SELECT p.id, o.disabled FROM projects p LEFT JOIN organizations o ON p.organization_id = o.id WHERE p.id IN ({})"#, 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 = 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(); } } } // --------------------------------------------------------------------------- // 设备实时状态 // --------------------------------------------------------------------------- /// 设备实时状态参数 #[derive(Deserialize)] pub struct DeviceStatusParams { pub imei: String, } /// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误) async fn open_redis_conn(state: &AppState) -> Result { 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)) }) } /// 设备实时状态接口 — 返回板级+通道级状态 pub async fn device_realtime_status( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; // 校验设备归属 ensure_device_access(&state, &user, ¶ms.imei).await?; let mut conn = open_redis_conn(&state).await?; let device_status = load_device_status(&mut conn, ¶ms.imei).await; let boards = load_boards_status(&mut conn, ¶ms.imei).await; Ok(Json(json!({ "code": 0, "data": { "device": device_status, "boards": boards, }, "message": "ok" }))) } /// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织, /// 且(配了数据范围时)设备所属项目必须落在范围内 async fn ensure_device_access( state: &AppState, user: &CurrentUser, imei: &str, ) -> Result<(), AppError> { if user.role_level < 2 { if let Some(org_id) = user.organization_id { 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); if !has_access { return Err(AppError::Forbidden("无权访问该设备".into())); } } else { return Err(AppError::Forbidden("无权访问该设备".into())); } } Ok(()) } /// 读取设备级字段(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); // 获取设备级字段 let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"]; let values: Vec> = redis::cmd("HMGET") .arg(&device_key) .arg(&fields) .query_async(&mut *conn) .await .unwrap_or_default(); json!({ "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::().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), }) } /// 通道级 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、**按解析出的板号分组**,再由通道数据组装板级视图。 async fn load_boards_status( conn: &mut redis::aio::MultiplexedConnection, imei: &str, ) -> Value { let ch_pattern = format!("device:{}:board:*:ch:*", imei); let ch_keys: Vec = redis::cmd("KEYS") .arg(&ch_pattern) .query_async(&mut *conn) .await .unwrap_or_default(); // 板号 → [(通道号, key)]:BTreeMap 保证板号升序,输出顺序稳定 let mut grouped: BTreeMap> = 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)); } } 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; } boards } /// 由**已排序**的通道级 key 组装单块仓控板的响应(板级 + 通道级)。 async fn load_board_status( conn: &mut redis::aio::MultiplexedConnection, entries: &[(i64, String)], ) -> Value { let mut channels = json!({}); 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); } channels[channel_no.to_string().as_str()] = channel; } json!({ "status_hex": status_hex, "channels": channels, }) } /// 电池状态位(协议 §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 { let text = raw?.trim(); if text.is_empty() || text.len() > 2 { return None; } u8::from_str_radix(text, 16).ok() } /// 通道派生结果(协议 §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 } /// 纯函数:把通道的**真实字段**组装成响应 JSON。 /// /// **响应字段名与旧实现完全一致**(前端在消费),但取值改为由真实字段推导 /// (位判断统一走 [`derive_channel_flags`],本函数不再自己算位): /// - `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 个字符;缺任一字节则为空串) /// - `status` = `fault` > `full` > `charging` > `idle`(见 [`ChannelFlags::status_name`]) /// - `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, voltage: Option, current: Option, temp: Option, ) -> Value { // hex 需要原始字节;位判断统一交给 `derive_channel_flags`(重复解析是无副作用的纯计算) let batt = parse_hex_byte(batt_raw); let pow = parse_hex_byte(pow_raw); let flags = derive_channel_flags(batt_raw, pow_raw); // 统一输出小写 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) }; json!({ "status": flags.status_name(), "on": flags.on, "full": flags.full, "fault": flags.fault, "hex": hex, "temp": temp, "batt_status": batt_status, "pow_status": pow_status, "soc": soc, "voltage": voltage, "current": current, }) } /// 读取单个通道状态。 /// /// ⚠️ 旧实现读的是 `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> = 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 { text(idx).and_then(|v| v.parse::().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::*; // 设备列表测试要构造 `CurrentUser`(其 `scope` 字段是 `UserScope`), // `super::*` 只带进来 `CurrentUser`,`UserScope` 需显式引入 use crate::middleware::auth::UserScope; /// 交付文档 §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"); } // ---- ChannelFlags / derive_channel_counts:由通道状态派生「充满数 / 故障数」 ---- /// 全空闲:有数据但既没充满也没故障(`batt_status=00`,电源已开)→ 全部记 idle #[test] fn test_derive_channel_counts_all_idle_channels_yield_zero_full_fault() { let channels: Vec = ["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); } } /// 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)); } /// 设备列表 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" ); } // ---- parse_channel_key:通道级 key 分组(板号 1–15 / 通道号 1–8)------- /// 正常通道 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 ); } // ----------------------------------------------------------------------- // 设备列表的数据范围(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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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"]); } }