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

1422 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! 组织树 API
//!
//! 两个独立接口:
//! 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<i64>,
abstract_id: Option<String>,
imei: String,
iccid: Option<String>,
name: Option<String>,
address: Option<String>,
project_address: Option<String>,
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,
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<AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:view")?;
let db = &state.mysql;
// 获取所有组织
let orgs: Vec<OrganizationRow> = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?;
// 获取所有项目
let projects: Vec<ProjectRow> = 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<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()
}
// ---------------------------------------------------------------------------
// 设备列表筛选
// ---------------------------------------------------------------------------
/// 设备列表筛选参数
#[derive(Deserialize)]
pub struct CabinetFilterParams {
pub organization_id: Option<i64>,
pub project_id: Option<i64>,
pub page: Option<i64>,
pub page_size: Option<i64>,
pub bound: Option<String>,
}
/// 设备列表接口 — 根据组织/项目筛选
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;
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(&params, &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<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 片段及其绑定参数(分支顺序与语义与原实现一致)
///
/// **数据范围叠加**:组织隔离给出"能看到哪个组织",`CurrentUser::scope` 给出
/// "在这个组织里还能看哪些项目"(未配置 = 不限制)。两者**取交集**,
/// 收窄条件统一落在 `c.project_id` 上(粒度到项目为止,不往下钻到单台柜子)。
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 {
("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<Vec<CabinetSqlRow>, 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<CabinetSqlRow> = 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<String>>) -> Option<String> {
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<Option<String>> = 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::<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");
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<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()
}
/// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标)
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
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<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();
}
}
}
// ---------------------------------------------------------------------------
// 设备实时状态
// ---------------------------------------------------------------------------
/// 设备实时状态参数
#[derive(Deserialize)]
pub struct DeviceStatusParams {
pub imei: String,
}
/// 打开 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))
})
}
/// 设备实时状态接口 — 返回板级+通道级状态
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")?;
// 校验设备归属
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"
})))
}
/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织,
/// 且(配了数据范围时)设备所属项目必须落在范围内
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<Option<String>> = 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::<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),
})
}
/// 通道级 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<String> = redis::cmd("KEYS")
.arg(&ch_pattern)
.query_async(&mut *conn)
.await
.unwrap_or_default();
// 板号 → [(通道号, 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));
}
}
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<u8> {
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<i64>,
voltage: Option<i64>,
current: Option<i64>,
temp: Option<i64>,
) -> 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<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::*;
// 设备列表测试要构造 `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<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);
}
}
/// 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(&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"]);
}
}