12451 c3a3ea7c3a fix(api)+fix(device): 状态取值收敛、超时方向守门补齐、在线判据口径订正
1) cabinets.status 写入侧收敛(此前 body.status 是 i8,0/99/-1 都能落库)
   - create_cabinet 的 INSERT **显式写 status = 1**,不再依赖列默认值 0:
     冻结口径是「2 = 禁用,其余 = 启用」,但列默认会产生第二种"启用"表示,
     任何按 status = 1 做的统计都会悄悄漏掉新建柜子(见「踩过的坑」第 5 条)。
   - PUT /api/cabinets/:id/status 增加白名单 {1,2},抽成纯函数 is_valid_cabinet_status
     并配守门单测(i8 全量遍历断言,杜绝"只挡住我知道的那几个")。
     白名单校验放在**鉴权之后**:越权恒 403,不会因参数非法而先返回 400
     (否则 400/403 的差异会变成"该柜子是否存在"的探测信号)。
   - 已实测自证:把白名单放宽成 [0,1,2] → 用例 FAILED / exit 101;还原 → 通过。
   - 生产库实测 68 台**全部已是 status=1**,故无需数据迁移。

2) order_timeout.rs 的超时方向守门(此前只在文档里留了「遗留」标记)
   - 选单 SQL 抽成常量 EXPIRED_ORDER_SCAN_SQL;
   - 补 3 个单测:SQL 文本方向断言(含**反证**写反形态)+ 纯函数孪生体双向断言
     (刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中 + 反证)+ 常量合理性。
   - Why:本文件与 report_worker.rs 是**等价但写法不同**的超时判据
     (那边绑负数、这边把 start_time 往前推),修好一边而另一边没有守门 = 漏网;
     方向写反**零报错、零日志、单测全绿**,只会静默提前结单、提前结算电量。
   - 已实测自证:反写成 start_time < TIMESTAMPADD(HOUR, ?, NOW())
     → test_expired_order_scan_sql_points_backwards FAILED / exit 101;还原 → 3 passed。

3) device-server 的在线判据注释订正(原文与代码事实不符)
   - 原文称在线键 device:{imei} "在验签前就会被写" —— **逐行核对后确认是错的**:
     online=1 只由 api-server 的 report_worker 在 handle_login 的
     sign == expected_sign 分支内(mark_device_online)以及 handle_status_post
     (write_device_state,该 act 已被登录门控挡住)写入;handle_auth_str 不写 online。
   - 真正不能拿它判下发的原因是另一条:它的 TTL 语义是"最近 300 秒收到过任意报文",
     断连后仍存活最多 5 分钟 ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。
   - 另注:server.rs::update_device_route 在 auth_str/login **到达时**写 node_id 并
     EXPIRE 300 —— 只刷 TTL、不写 online;与协议 §2.3 一致,故保留,但同样说明
     该键不能当"可下发"判据。

验收:api cargo check --all-targets 零 error/零 warning,单测 182 passed;
device 30 passed;两产物交叉编译通过,已按 device→api 顺序发布到测试与生产。
2026-09-20 04:31:42 +08:00

1071 lines
44 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 — 总览、排行、趋势、导出
//!
//! **数据源:`charge_records` 实时聚合**(不再读写 `energy_stats` 表)。
//!
//! `energy_stats` 是"按天快照"的汇总表,但全仓**没有任何写入方**(无 INSERT/REPLACE),
//! 生产与测试库该表恒为 0 行 → 原先的 summary / 柜排名 / 项目统计 / 趋势 / 导出**全部恒为 0**
//! (客户侧表现为"没用电")。真实数据源是 `charge_records`:每次充电结束由订单结算
//! 写入一条(含 `cabinet_id` / `compartment_id` / `start_time` / `end_time` / `energy` / `max_temperature` / `status`)。
//! 因此这里统一改为**按时间范围 + 组织隔离直接聚合 `charge_records`**。
//!
//! 口径说明(详见 [`time_window`] 与 `charge_records` 的 DDL 注释):
//! - 时间字段统一用 `start_time`(不是 `end_time`):与 `h5.rs` 的今日统计
//! (`DATE(cr.start_time) = CURDATE()`)以及"充电记录"列表的时间筛选口径一致,
//! 且每笔充电记录**只归属它开始的那一天**,不会因跨天而当天重复计数;
//! - `energy` 单位为 **Wh**(`workers/report_worker.rs` 结算时写入 `energy::mwh_to_wh(mwh)`);
//! - 电费估算按 ¥0.80/kWh 计算:`Wh × 80(分/kWh) / 1000 / 100` = 元。
//!
//! `energy_stats` 表**保留不删**(历史语义/回滚余地),但本模块已完全不再引用它。
use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use super::cabinet_tree;
/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh
const COST_PER_WH_CENT: i64 = 80;
/// 趋势查询 `range`(天数)的合法区间上限,防超大值拖垮查询
const TREND_RANGE_MAX: i64 = 3650;
/// 趋势查询 `range` 的合法区间下限(至少 1 天)
const TREND_RANGE_MIN: i64 = 1;
/// 导出明细单次最大行数(防止无上限导出把内存/Excel 撑爆)
const EXPORT_MAX_ROWS: i64 = 100_000;
/// 聚合表达式:`energy` 列在 pms 是 BIGINT、在 pms_dev 是 DECIMAL,
/// 统一 `CAST(... AS SIGNED)` 后交给 Rust `i64`;`COALESCE` 保证无数据时为 0 而非 NULL。
const SUM_ENERGY_SIGNED: &str = "CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED)";
/// 聚合表达式:充电次数。`COUNT(*)` 原生类型是 BIGINT UNSIGNED,
/// 不能直接解码成 `i64`(sqlx 类型不匹配),必须 CAST。
const COUNT_CHARGES_SIGNED: &str = "CAST(COUNT(*) AS SIGNED)";
/// 能耗聚合的时间窗口 SQL 片段(全部按 `cr.start_time` 半开区间 `[起, 止)`)。
///
/// # 为什么用半开区间而不是 `DATE(cr.start_time) = CURDATE()`
/// `DATE(列) = 常量` 会让 `start_time` 上的索引失效(列被函数包裹 ⇒ 无法走 range scan),
/// 而 `cr.start_time >= 起 AND cr.start_time < 止` 是**等价的**且能命中
/// `idx_charge_records_start_time`。时刻口径与 `DATE(...)` 完全一致:
/// 只按"充电开始的那一刻"归属,不用 `end_time`。
mod time_window {
/// 今日:`[今天 00:00:00, 明天 00:00:00)`
pub const TODAY: &str =
"cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY";
/// 本月:`[本月 1 日 00:00:00, 下月 1 日 00:00:00)`
///
/// 原 `energy_stats` 实现只有下界(`stat_date >= 本月1日`),因为 `stat_date` 是"按天快照"、
/// 不存在未来日期。换成时间戳列后补上上界:口径不变(仍只含本月),
/// 同时避免时钟偏移产生的未来行被算进本月。
pub const THIS_MONTH: &str = "cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01')";
/// 导出用的"结束日期"(含当天):`end_date <= ?` 在 DATE 列上原本含当天,
/// 换成时间戳列后必须写成 `< 次日 00:00:00` 才是同样的"含当天"语义。
pub const BEFORE_NEXT_DAY: &str = "cr.start_time < DATE_ADD(?, INTERVAL 1 DAY)";
}
/// 把外部传入的 `range` 夹到合法区间,防负数 / 0 / 超大值
/// (负数会让 `DATE_SUB(..., INTERVAL -5 DAY)` 变成"未来 5 天",超大值会扫全表)。
fn clamp_range(range: i64) -> i64 {
range.clamp(TREND_RANGE_MIN, TREND_RANGE_MAX)
}
/// 趋势查询参数
#[derive(Debug, Deserialize)]
pub struct TrendQuery {
/// day | week | month
#[serde(default = "default_granularity")]
pub granularity: String,
/// 天数范围,默认30天
#[serde(default = "default_range")]
pub range: i64,
}
/// 导出查询参数
#[derive(Debug, Deserialize)]
pub struct ExportQuery {
pub cabinet_id: Option<i64>,
pub project_id: Option<i64>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
/// 柜子排行行
#[derive(Debug, sqlx::FromRow)]
struct CabinetRankRow {
id: i64,
abstract_id: String,
name: Option<String>,
today_energy: Option<i64>,
month_energy: Option<i64>,
}
/// 项目统计行
#[derive(Debug, sqlx::FromRow)]
struct ProjectStatRow {
id: i64,
name: String,
cabinet_count: i64,
today_energy: Option<i64>,
month_energy: Option<i64>,
}
/// 趋势数据行
#[derive(Debug, sqlx::FromRow)]
struct TrendRow {
period: String, // 日期或周/月标识
energy: Option<i64>,
charge_count: Option<i64>,
}
/// 能耗明细行(用于导出)
#[derive(Debug, sqlx::FromRow)]
struct EnergyDetailRow {
stat_date: chrono::NaiveDate,
cabinet_id: i64,
cabinet_name: Option<String>,
project_name: Option<String>,
total_energy: Option<i64>,
charge_count: Option<i64>,
}
fn default_granularity() -> String { "day".to_string() }
fn default_range() -> i64 { 30 }
// ---------------------------------------------------------------------------
// 过滤条件构造(组织隔离 + 数据范围,两处口径必须完全一致)
// ---------------------------------------------------------------------------
/// `charge_records cr` 上的过滤片段:组织隔离 **∧** 数据范围(项目级)
///
/// - 未配置数据范围 ⇒ 只有组织条件(与今天完全一致);
/// - 无组织的客户侧账号 ⇒ ` AND 1=0`(空集,绝不退化成"全部");
/// - 总管理员 ⇒ 两个条件都为空(豁免)。
///
/// 返回的是**条件后缀**(调用方拼在 `WHERE {时间窗口}` 之后)⇒ 这里直接 `push_str`
/// 追加 ` AND ...`;**不能**用 `append_where`(空串时会补 `WHERE`,拼出 `WHERE a WHERE b`)。
fn charge_record_filters(user: &CurrentUser) -> (String, Vec<String>) {
let (mut cond, mut binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(),
vec![oid.to_string()],
)
} else {
(" AND 1=0".to_string(), vec![])
};
let (scope_cond, scope_binds) = user.effective_scope().cabinet_condition("cr.cabinet_id");
cond.push_str(&scope_cond);
binds.extend(scope_binds);
(cond, binds)
}
// ---------------------------------------------------------------------------
// 可见柜子 / 在线柜数(组织隔离 + 数据范围,两处口径必须完全一致)
// ---------------------------------------------------------------------------
/// 可见柜子(`cabinets c`)的过滤**条件后缀**:组织隔离 **∧** 数据范围(项目级)。
///
/// - 总管理员(`role_level >= 2`)⇒ 空串(豁免);
/// - 客户侧账号有组织 ⇒ ` AND p.organization_id = ?` + 项目级范围收窄;
/// - 客户侧账号**无组织** ⇒ ` AND 1=0`(空集,**绝不退化成"全部"**)。
///
/// ⚠️ 返回的是**条件后缀**(调用方自己起 `WHERE`),因此片段一律以 ` AND ` 开头;
/// 用 `1=0` 表达"空集"可以避免在 Rust 里为"无组织"再分叉一条分支。
fn visible_cabinet_filters(user: &CurrentUser) -> (String, Vec<String>) {
if user.role_level >= 2 {
return (String::new(), vec![]);
}
let Some(oid) = user.organization_id else {
return (" AND 1=0".to_string(), vec![]);
};
let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id");
// 绑定顺序必须与占位符出现顺序一致:先组织,再项目级范围
(
format!(" AND p.organization_id = ?{}", scope_cond),
std::iter::once(oid.to_string()).chain(scope_binds).collect(),
)
}
/// `cabinet_count`(**全部可见柜数**)的 SQL —— 语义冻结:只数柜子。
///
/// **不含任何 `status` 条件**:`cabinets.status` 是启用/禁用**配置态**,
/// 既不表示在线,也不该影响"柜子总数"(禁用中的柜子依然是柜子)。
fn visible_cabinets_count_sql(user: &CurrentUser) -> (String, Vec<String>) {
let (cond, binds) = visible_cabinet_filters(user);
let sql = if user.role_level >= 2 {
format!("SELECT COUNT(*) FROM cabinets c WHERE 1=1{}", cond)
} else {
format!(
"SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE 1=1{}",
cond
)
};
(sql, binds)
}
/// 在线统计的**取数 SQL**:只取可见柜的 IMEI(`imei IS NOT NULL`)。
///
/// # 为什么 DB 不参与"在线"判定
/// `cabinets.status` 是**启用/禁用配置态**(全仓唯一写入方
/// `routes/cabinets.rs::set_cabinet_status` 只在 `2`(禁用)与其它值(启用)之间切,
/// **没有任何地方写 0(离线)**)⇒ 按 `status = 1` 统计在线,会在**一台设备都没连**时
/// 依然返回"全部在线"。
///
/// 因此这里只负责按**组织隔离 + 数据范围**取出一批 IMEI,在线与否交给
/// [`cabinet_tree::count_online_devices`](Redis 一次 pipeline,判据与设备列表
/// `is_online` 同源)。
fn visible_cabinet_imeis_sql(user: &CurrentUser) -> (String, Vec<String>) {
let (cond, binds) = visible_cabinet_filters(user);
let sql = if user.role_level >= 2 {
format!(
"SELECT c.imei FROM cabinets c WHERE c.imei IS NOT NULL{}",
cond
)
} else {
format!(
"SELECT c.imei FROM cabinets c JOIN projects p ON c.project_id = p.id \
WHERE c.imei IS NOT NULL{}",
cond
)
};
(sql, binds)
}
/// 执行「可见柜计数」SQL(组织隔离 + 数据范围)
async fn count_visible_cabinets(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
) -> Result<i64, AppError> {
let (sql, binds) = visible_cabinets_count_sql(user);
let mut query = sqlx::query_scalar::<_, i64>(&sql);
for bind in &binds {
query = query.bind(bind);
}
Ok(query.fetch_one(pool).await?)
}
/// 取当前用户**可见柜子的 IMEI 列表**(组织隔离 + 数据范围,顺序无关)
///
/// `imei` 列可空(老数据/联调库有 NULL),按 `Option<String>` 解码后过滤,
/// 避免一行 NULL 让整个在线统计解码失败(接口 500)。
async fn visible_cabinet_imeis(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
) -> Result<Vec<String>, AppError> {
let (sql, binds) = visible_cabinet_imeis_sql(user);
let mut query = sqlx::query_scalar::<_, Option<String>>(&sql);
for bind in &binds {
query = query.bind(bind);
}
Ok(query.fetch_all(pool).await?.into_iter().flatten().collect())
}
/// `online_count`:**在线柜数 = 可见柜里 Redis 明确在线的台数**。
///
/// 先按组织隔离 + 数据范围取出可见柜的 IMEI,再用**一次 pipeline** 批量查
/// `device:{imei}.online`(**零 N 次单查**);判据是
/// [`cabinet_tree::is_device_online`] —— 与设备列表 `is_online` 同一纯函数,
/// 且**完全不看 `cabinets.status`**。
async fn count_online_cabinets(
state: &super::AppState,
user: &CurrentUser,
) -> Result<i64, AppError> {
let imeis = visible_cabinet_imeis(&state.mysql, user).await?;
Ok(cabinet_tree::count_online_devices(state, &imeis).await)
}
/// GET /api/energy-stats/summary — 总览
///
/// 返回今日/本月能耗、电费估算、柜子/在线数量。
pub async fn get_summary(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
// 今天/本月能耗的过滤条件 = 组织隔离 + 数据范围(项目级,未配置 = 不限制)
let (org_filter, org_binds) = charge_record_filters(&user);
// 今日总能耗:聚合 charge_records(原来是 energy_stats.stat_date = CURDATE())
let today_sql = format!(
"SELECT {} FROM charge_records cr WHERE {}{}",
SUM_ENERGY_SIGNED,
time_window::TODAY,
org_filter
);
let mut today_q = sqlx::query_scalar::<_, i64>(&today_sql);
for v in &org_binds { today_q = today_q.bind(v); }
let today_energy: i64 = today_q.fetch_one(pool).await?;
// 本月总能耗:聚合 charge_records(原来是 energy_stats.stat_date >= 本月1日)
let month_sql = format!(
"SELECT {} FROM charge_records cr WHERE {}{}",
SUM_ENERGY_SIGNED,
time_window::THIS_MONTH,
org_filter
);
let mut month_q = sqlx::query_scalar::<_, i64>(&month_sql);
for v in &org_binds { month_q = month_q.bind(v); }
let month_energy: i64 = month_q.fetch_one(pool).await?;
// 柜子总数(全部可见柜,语义不变)/ 在线柜子数(**以 Redis 为准**,与 `cabinets.status` 无关)
let cabinet_count = count_visible_cabinets(pool, &user).await?;
let online_count = count_online_cabinets(&state, &user).await?;
Ok(Json(json!({
"code": 0,
"data": {
"today_energy": today_energy,
"month_energy": month_energy,
"estimated_cost": (month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0,
"cabinet_count": cabinet_count,
"online_count": online_count,
},
"message": "ok"
})))
}
/// GET /api/energy-stats/cabinets — 柜子能耗排行
///
/// 按今日能耗降序排列,返回柜子列表及今日/本月能耗。
pub async fn get_cabinet_ranking(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
// 组织过滤:INNER JOIN projects 把可见范围限制在用户所属组织
// (口径与原实现完全一致,只是不再经过 energy_stats)
let (org_join, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(
" JOIN projects p ON c.project_id = p.id AND p.organization_id = ?".to_string(),
vec![oid.to_string()],
)
} else {
(" JOIN projects p ON 1=0".to_string(), vec![])
};
// 数据范围(项目级)叠加:未配置 = 不限制;总管理员豁免。
// 这里是 **JOIN 片段后缀**(拼在 `FROM cabinets c {}` 位置),直接原样拼接 ` AND ...`。
let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id");
let (org_join, org_binds) = {
let mut join = org_join;
let mut binds = org_binds;
join.push_str(&scope_cond);
binds.extend(scope_binds);
(join, binds)
};
// 今日/本月能耗改为「按 cabinet_id 聚合 charge_records」的两个派生表:
// 每个柜子至多一行,LEFT JOIN 到 cabinets 后语义与原 energy_stats 版本一致。
//
// `COALESCE(c.abstract_id, '')`:`abstract_id` 已被迁移改成**可空**(老数据/联调库里就有 NULL),
// 而 `CabinetRankRow.abstract_id` 是 `String` ⇒ 遇到 NULL 会让整条查询解码失败、接口 500。
// 响应字段名与类型不变(仍是字符串),只是把 NULL 显示为空串而不是让整个排行崩掉。
let sql = format!(
"SELECT c.id, COALESCE(c.abstract_id, '') AS abstract_id, c.name, \
CAST(COALESCE(cr_today.today_total, 0) AS SIGNED) AS today_energy, \
CAST(COALESCE(cr_month.month_total, 0) AS SIGNED) AS month_energy \
FROM cabinets c \
LEFT JOIN ( \
SELECT cr.cabinet_id, SUM(cr.energy) AS today_total \
FROM charge_records cr \
WHERE {today} \
GROUP BY cr.cabinet_id \
) cr_today ON c.id = cr_today.cabinet_id \
LEFT JOIN ( \
SELECT cr.cabinet_id, SUM(cr.energy) AS month_total \
FROM charge_records cr \
WHERE {month} \
GROUP BY cr.cabinet_id \
) cr_month ON c.id = cr_month.cabinet_id \
{} \
ORDER BY today_energy DESC, c.id ASC",
org_join,
today = time_window::TODAY,
month = time_window::THIS_MONTH,
);
let mut query = sqlx::query_as::<_, CabinetRankRow>(&sql);
for v in &org_binds { query = query.bind(v); }
let rows = query.fetch_all(pool).await?;
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"cabinet_id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"today_energy": r.today_energy.unwrap_or(0),
"month_energy": r.month_energy.unwrap_or(0),
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
/// GET /api/energy-stats/projects — 项目能耗统计
///
/// 按今日能耗降序排列,返回项目级聚合数据。
pub async fn get_project_stats(
user: CurrentUser,
State(state): State<super::AppState>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
let (org_filter, org_binds): (String, Vec<String>) = if user.role_level >= 2 {
(String::new(), vec![])
} else if let Some(oid) = user.organization_id {
(" WHERE p.organization_id = ?".to_string(), vec![oid.to_string()])
} else {
(" WHERE 1=0".to_string(), vec![])
};
// 数据范围(项目级)叠加:只统计范围内项目的能耗(未配置 = 不限制)。
// 这里是 **WHERE 后缀**(拼在 `GROUP BY` 之前),直接原样拼接 ` AND ...`。
let (scope_cond, scope_binds) = user.effective_scope().project_condition("p.id");
let (org_filter, org_binds) = {
let mut cond = org_filter;
let mut binds = org_binds;
cond.push_str(&scope_cond);
binds.extend(scope_binds);
(cond, binds)
};
// `charge_records` 只有 `cabinet_id`,项目维度必须经 cabinets → projects 关联;
// 两个派生表都**按 cabinet_id 聚合**(每柜一行),避免"记录级 JOIN 让 SUM 被放大 N 倍":
// 若直接 LEFT JOIN charge_records 再 GROUP BY 项目,则本月汇总会被今天的记录条数重复累加。
let sql = format!(
"SELECT p.id, p.name, \
COUNT(DISTINCT c.id) AS cabinet_count, \
CAST(COALESCE(SUM(cr_today.today_total), 0) AS SIGNED) AS today_energy, \
CAST(COALESCE(SUM(cr_month.month_total), 0) AS SIGNED) AS month_energy \
FROM projects p \
LEFT JOIN cabinets c ON p.id = c.project_id \
LEFT JOIN ( \
SELECT cr.cabinet_id, SUM(cr.energy) AS today_total \
FROM charge_records cr \
WHERE {today} \
GROUP BY cr.cabinet_id \
) cr_today ON c.id = cr_today.cabinet_id \
LEFT JOIN ( \
SELECT cr.cabinet_id, SUM(cr.energy) AS month_total \
FROM charge_records cr \
WHERE {month} \
GROUP BY cr.cabinet_id \
) cr_month ON c.id = cr_month.cabinet_id \
{} \
GROUP BY p.id, p.name \
ORDER BY today_energy DESC, p.id ASC",
org_filter,
today = time_window::TODAY,
month = time_window::THIS_MONTH,
);
let mut query = sqlx::query_as::<_, ProjectStatRow>(&sql);
for v in &org_binds { query = query.bind(v); }
let rows = query.fetch_all(pool).await?;
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"project_id": r.id,
"name": r.name,
"cabinet_count": r.cabinet_count,
"today_energy": r.today_energy.unwrap_or(0),
"month_energy": r.month_energy.unwrap_or(0),
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
/// GET /api/energy-stats/trend — 趋势数据
///
/// granularity: day | week | month, 默认 day
/// range: 天数范围, 默认 30
pub async fn get_trend(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<TrendQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:view")?;
let pool = &state.mysql;
// 组织过滤:cr.cabinet_id → cabinets.project_id → projects.organization_id
// (口径与原实现完全一致,仅把别名 es → cr)+ 数据范围(项目级)
let (org_filter, org_binds) = charge_record_filters(&user);
// range 必须夹紧:负数会让 DATE_SUB 变成"未来 N 天",超大值会拖垮查询
let range = clamp_range(params.range);
// 前端的粒度取值只有 day / week / month(见 software/web/src/pages/energy/index.tsx 的
// Select options),未知取值按 day 处理(与原实现一致,不报错)。
//
// 三档都改为「按 period 分组聚合 charge_records」:
// 原 day 分支没有 GROUP BY(直接吐 energy_stats 的行),而换到 charge_records 后
// 一天会有多条记录,**必须按 DATE(start_time) 分组**,否则同一 period 会重复出现多行,
// 折线图/柱状图的 X 轴会重复。period 文本格式与原实现保持一致
// (day = 'YYYY-MM-DD',week = '%Y-%u',month = '%Y-%m'),前端只把它当分类标签用。
let (period_expr, group_expr) = match params.granularity.as_str() {
"week" => ("DATE_FORMAT(cr.start_time, '%Y-%u')", "period"),
"month" => ("DATE_FORMAT(cr.start_time, '%Y-%m')", "period"),
_ => ("DATE_FORMAT(cr.start_time, '%Y-%m-%d')", "period"),
};
let sql = format!(
"SELECT {} AS period, {} AS energy, {} AS charge_count \
FROM charge_records cr \
WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
GROUP BY {} ORDER BY period ASC",
period_expr,
SUM_ENERGY_SIGNED,
COUNT_CHARGES_SIGNED,
org_filter,
group_expr
);
let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(range);
for v in &org_binds { q = q.bind(v); }
let rows = q.fetch_all(pool).await?;
let list: Vec<Value> = rows.iter().map(|r| {
json!({
"period": r.period,
"energy": r.energy.unwrap_or(0),
"charge_count": r.charge_count.unwrap_or(0),
})
}).collect();
Ok(Json(json!({
"code": 0,
"data": {
"granularity": params.granularity,
"list": list,
},
"message": "ok"
})))
}
/// GET /api/energy-stats/export — 异步导出能耗报表
pub async fn export_energy_stats(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<ExportQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "energy:export")?;
let user_id = user.user_id;
let file_name = generate_export_filename(&params);
// 序列化查询参数(含组织过滤信息 + 数据范围)
let query_json = serde_json::to_string(&json!({
"cabinet_id": params.cabinet_id,
"project_id": params.project_id,
"start_date": params.start_date,
"end_date": params.end_date,
"org_id": user.organization_id,
"role_level": user.role_level,
// 数据范围(项目级白名单)必须随任务一起落库:导出在后台跑,
// 不带过去就会绕过 scope(越权导出更大范围)
"scope_project_ids": user.effective_scope().project_ids,
}))
.unwrap_or_default();
// 插入下载任务
let result = sqlx::query(
"INSERT INTO download_tasks (user_id, task_type, status, file_name, error_message) \
VALUES (?, ?, 0, ?, ?)"
)
.bind(user_id)
.bind("energy_stats")
.bind(&file_name)
.bind(&query_json)
.execute(&state.mysql)
.await?;
let task_id = result.last_insert_id() as i64;
// 后台异步生成 Excel
let pool = state.mysql.clone();
tokio::spawn(async move {
if let Err(e) = generate_energy_excel(pool, task_id).await {
tracing::error!("导出能耗统计失败 task_id={}: {}", task_id, e);
}
});
Ok(Json(json!({
"code": 0,
"data": { "task_id": task_id },
"message": "导出任务已创建"
})))
}
/// 生成导出文件名
fn generate_export_filename(params: &ExportQuery) -> String {
let start = params
.start_date
.as_ref()
.map(|s| s.replace('-', ""))
.unwrap_or_default();
let end = params
.end_date
.as_ref()
.map(|s| s.replace('-', ""))
.unwrap_or_default();
format!("能耗统计_{}-{}.xlsx", start, end)
}
/// 构建导出条件
///
/// 列名口径:`cabinet_id` 由 `es.` 改为 `cr.`;日期由 `stat_date`(DATE 列)改为
/// `start_time`(TIMESTAMP 列),并保持「含当天」的语义(见 `time_window::BEFORE_NEXT_DAY`)。
fn build_export_clause(
cabinet_id: Option<i64>,
project_id: Option<i64>,
start_date: Option<&str>,
end_date: Option<&str>,
) -> (String, Vec<String>) {
let mut conditions = Vec::new();
let mut bind_values = Vec::new();
if let Some(id) = cabinet_id {
conditions.push("cr.cabinet_id = ?");
bind_values.push(id.to_string());
}
if let Some(id) = project_id {
conditions.push("c.project_id = ?");
bind_values.push(id.to_string());
}
if let Some(d) = start_date {
conditions.push("cr.start_time >= ?");
bind_values.push(d.to_string());
}
if let Some(d) = end_date {
conditions.push(time_window::BEFORE_NEXT_DAY);
bind_values.push(d.to_string());
}
let clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(clause, bind_values)
}
/// 读取导出任务的文件名与查询参数
///
/// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
async fn load_export_task(
pool: &sqlx::MySqlPool,
task_id: i64,
) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
)
.bind(task_id)
.fetch_optional(pool)
.await?;
let (file_name, query_json) = row.ok_or("任务不存在")?;
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
Ok((file_name, params))
}
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
fn build_export_where(params: &Value) -> (String, Vec<String>) {
let (mut where_clause, mut bind_values) = build_export_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()),
params.get("project_id").and_then(|v| v.as_i64()),
params.get("start_date").and_then(|v| v.as_str()),
params.get("end_date").and_then(|v| v.as_str()),
);
// 组织过滤(非总管理员)
let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0);
let org_id = params.get("org_id").and_then(|v| v.as_i64());
if role_level < 2 {
if let Some(oid) = org_id {
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
where_clause.push_str(&format!("{}cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix));
bind_values.push(oid.to_string());
} else {
let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " };
where_clause.push_str(&format!("{}1=0", prefix));
}
}
// 数据范围(非总管理员):与在线查询同一套项目级收窄
if role_level < 2 {
let project_ids: Vec<i64> = params
.get("scope_project_ids")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_i64()).collect())
.unwrap_or_default();
let (scope_cond, scope_binds) =
auth::UserScope { project_ids }.cabinet_condition("cr.cabinet_id");
auth::append_where(&mut where_clause, &scope_cond);
bind_values.extend(scope_binds);
}
(where_clause, bind_values)
}
/// 查询能耗明细行(按「日期 + 柜子」聚合 `charge_records`)
///
/// 原来直接读 `energy_stats`(每柜每天一行);改为聚合后**必须显式分组**,
/// 分组键 = `DATE(start_time)` + `cabinet_id` + 柜名/项目名(`ONLY_FULL_GROUP_BY` 下
/// 非聚合列必须出现在 GROUP BY 中),输出列与原结构体完全一致。
async fn fetch_energy_detail_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<Vec<EnergyDetailRow>, sqlx::Error> {
// 组织隔离/筛选条件加在 WHERE 里,可能整段为空 ⇒ 这里必须自己兜住
// `start_time IS NULL`(该列可空):`DATE(NULL)` 会是 NULL,
// 反序列化成 `chrono::NaiveDate` 会直接失败,让整个导出任务挂掉。
let where_clause = if where_clause.is_empty() {
"WHERE cr.start_time IS NOT NULL".to_string()
} else {
format!("{} AND cr.start_time IS NOT NULL", where_clause)
};
let sql = format!(
"SELECT DATE(cr.start_time) AS stat_date, cr.cabinet_id, c.name AS cabinet_name, \
p.name AS project_name, \
CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS total_energy, \
CAST(COUNT(*) AS SIGNED) AS charge_count \
FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN projects p ON c.project_id = p.id \
{} \
GROUP BY DATE(cr.start_time), cr.cabinet_id, c.name, p.name \
ORDER BY stat_date DESC, cr.cabinet_id ASC \
LIMIT {}",
where_clause, EXPORT_MAX_ROWS
);
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
for val in bind_values {
query = query.bind(val);
}
query.fetch_all(pool).await
}
/// 构建导出工作簿(表头 + 数据行)
fn build_energy_workbook(
rows: &[EnergyDetailRow],
) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name("能耗统计")?;
let headers = [
"日期", "柜子ID", "柜子名称", "所属项目", "能耗(Wh)", "充电次数",
];
for (col, header) in headers.iter().enumerate() {
sheet.write_string(0, col as u16, *header)?;
}
for (row_idx, record) in rows.iter().enumerate() {
let row = (row_idx + 1) as u32;
sheet.write_string(row, 0, record.stat_date.to_string())?;
sheet.write_number(row, 1, record.cabinet_id as f64)?;
sheet.write_string(row, 2, record.cabinet_name.as_deref().unwrap_or("-"))?;
sheet.write_string(row, 3, record.project_name.as_deref().unwrap_or("-"))?;
sheet.write_number(row, 4, record.total_energy.unwrap_or(0) as f64)?;
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
}
Ok(workbook)
}
/// 将导出任务标记为完成并记录文件路径
async fn mark_task_completed(
pool: &sqlx::MySqlPool,
task_id: i64,
file_path: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(file_path)
.bind(task_id)
.execute(pool)
.await?;
Ok(())
}
/// 后台异步生成能耗 Excel 文件
async fn generate_energy_excel(
pool: sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let (file_name, params) = load_export_task(&pool, task_id).await?;
// 组织过滤(非总管理员)+ 查询能耗明细
let (where_clause, bind_values) = build_export_where(&params);
let rows = fetch_energy_detail_rows(&pool, &where_clause, &bind_values).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = build_energy_workbook(&rows)?;
// 保存文件
let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?;
let file_path = format!("{}/{}", file_dir, file_name);
workbook.save(&file_path)?;
// 更新任务状态为完成
mark_task_completed(&pool, task_id, &file_path).await?;
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
Ok(())
}
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// 趋势 `range` 夹紧:负数会让 `DATE_SUB(CURDATE(), INTERVAL -5 DAY)` 变成"未来 5 天",
/// 0 会让窗口退化成"仅今天零点以后",超大值会全表扫描。
#[test]
fn test_clamp_range_bounds_illegal_values() {
assert_eq!(clamp_range(30), 30, "合法值应原样保留");
assert_eq!(clamp_range(0), TREND_RANGE_MIN, "0 应被抬到下限");
assert_eq!(clamp_range(-5), TREND_RANGE_MIN, "负数应被抬到下限");
assert_eq!(clamp_range(999_999), TREND_RANGE_MAX, "超大值应被压到上限");
}
/// 时间窗口片段必须只按 `start_time` 归属(不得出现 `end_time` / `stat_date`),
/// 且今日/本月都必须是半开区间(索引可用的写法)。
#[test]
fn test_time_window_uses_start_time_half_open_ranges() {
assert!(time_window::TODAY.contains("cr.start_time >= CURDATE()"));
assert!(time_window::TODAY.contains("cr.start_time < CURDATE() + INTERVAL 1 DAY"));
assert!(time_window::THIS_MONTH.contains("cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01')"));
assert!(time_window::THIS_MONTH.contains("INTERVAL 1 MONTH"));
// 导出结束日期必须"含当天" ⇒ 用 < 次日 00:00:00,而不是 <= 当天(会丢掉当天全部记录)
assert_eq!(time_window::BEFORE_NEXT_DAY, "cr.start_time < DATE_ADD(?, INTERVAL 1 DAY)");
for window in [time_window::TODAY, time_window::THIS_MONTH] {
assert!(!window.contains("end_time"), "口径必须是 start_time:{}", window);
assert!(!window.contains("stat_date"), "不得再引用 energy_stats.stat_date:{}", window);
}
}
/// 导出 WHERE 构建:列名必须指向 charge_records(`cr.`),
/// 日期条件必须落在 `cr.start_time`,且参数一律走占位符(不做字符串拼接)。
#[test]
fn test_build_export_clause_targets_charge_records() {
let (clause, binds) = build_export_clause(
Some(7),
Some(3),
Some("2026-01-01"),
Some("2026-01-31"),
);
assert!(clause.starts_with("WHERE "), "非空条件应带 WHERE:{}", clause);
assert!(clause.contains("cr.cabinet_id = ?"), "柜子过滤应指向 cr:{}", clause);
assert!(clause.contains("c.project_id = ?"), "项目过滤应指向 cabinets:{}", clause);
assert!(clause.contains("cr.start_time >= ?"), "起始日期应落在 cr.start_time:{}", clause);
assert!(clause.contains(time_window::BEFORE_NEXT_DAY), "结束日期应含当天:{}", clause);
assert!(!clause.contains("energy_stats"), "不得再引用 energy_stats:{}", clause);
assert_eq!(binds, vec!["7", "3", "2026-01-01", "2026-01-31"]);
}
/// 无条件时应返回空子句(由 `fetch_energy_detail_rows` 自己补 `start_time IS NOT NULL` 兜底)
#[test]
fn test_build_export_clause_empty_when_no_filters() {
let (clause, binds) = build_export_clause(None, None, None, None);
assert!(clause.is_empty());
assert!(binds.is_empty());
}
/// 组织隔离:非总管理员 + 有组织 ⇒ 必须追加 `cr.cabinet_id IN (...)` 子查询并带上组织 ID;
/// 非总管理员 + 无组织 ⇒ 必须追加 `1=0` 空集(**绝不退化成"全部"**)。
#[test]
fn test_build_export_where_keeps_org_isolation() {
// 总管理员:不加任何组织条件
let (clause, binds) = build_export_where(&json!({"role_level": 2}));
assert!(clause.is_empty(), "总管理员不应有额外条件:{}", clause);
assert!(binds.is_empty());
// 组织管理员:追加组织子查询
let (clause, binds) = build_export_where(&json!({"role_level": 1, "org_id": 42}));
assert!(
clause.contains("cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)"),
"组织隔离条件丢失:{}",
clause
);
assert_eq!(binds, vec!["42"]);
// 客户侧账号但无组织:空集
let (clause, binds) = build_export_where(&json!({"role_level": 0}));
assert_eq!(clause, "WHERE 1=0");
assert!(binds.is_empty());
// 已有条件时组织条件用 AND 拼接(不能覆盖原有筛选)
let (clause, _) = build_export_where(&json!({"cabinet_id": 9, "role_level": 1, "org_id": 42}));
assert!(clause.contains("cr.cabinet_id = ? AND cr.cabinet_id IN ("), "拼接方式错误:{}", clause);
}
/// 电费估算口径:`energy` 单位 Wh,单价 0.80 元/kWh ⇒ 1000 Wh = 0.80 元。
#[test]
fn test_estimated_cost_unit_is_wh_times_80_cent_per_kwh() {
let month_energy_wh: i64 = 1000;
let cost = (month_energy_wh * COST_PER_WH_CENT / 1000) as f64 / 100.0;
assert!((cost - 0.80).abs() < f64::EPSILON, "1000Wh 应为 0.8 元,实际 {}", cost);
// 22 Wh(联调实测值)≈ 0.0176 元,不应被当成 22 kWh(那会是 17.6 元)
let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0;
assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small);
}
// -----------------------------------------------------------------------
// 在线柜数守门(防回归:不得再拿 `cabinets.status` 当"在线")
// -----------------------------------------------------------------------
/// 构造单测用的 `CurrentUser`(不连库;`permissions` 与范围按需给)
fn user(role_level: i32, organization_id: Option<i64>, project_ids: Vec<i64>) -> CurrentUser {
CurrentUser {
user_id: 1,
phone: "13000000000".to_string(),
permissions: Default::default(),
role_level,
organization_id,
scope: crate::middleware::auth::UserScope { project_ids },
}
}
/// ① **在线统计不依赖 `cabinets.status`**。
///
/// `cabinets.status` 是启用/禁用**配置态**:全仓唯一写入方
/// (`routes/cabinets.rs::set_cabinet_status`)只在 `2`(禁用)与其它值(启用)之间切,
/// **没有任何地方写 0(离线)**。所以只要 SQL 里出现 `status` 条件,
/// 就必然在"一台设备都没连"时返回"全部在线"。
#[test]
fn test_visible_cabinet_sql_never_filters_by_status() {
let cases = [
user(2, None, vec![]), // 总管理员
user(1, Some(42), vec![]), // 组织管理员(未配范围)
user(1, Some(42), vec![7, 8]), // 组织管理员 + 项目级范围
user(0, None, vec![]), // 客户侧账号无组织 ⇒ 空集
user(0, Some(42), vec![]), // 操作员
];
for u in &cases {
let (count_sql, _) = visible_cabinets_count_sql(u);
let (imei_sql, _) = visible_cabinet_imeis_sql(u);
for sql in [&count_sql, &imei_sql] {
assert!(
!sql.to_lowercase().contains("status"),
"柜数/在线统计不得引用 cabinets.status:{}",
sql
);
}
}
}
/// ② 组织隔离与数据范围**必须保留**(收窄只能取交集,绝不放大):
/// 总管理员豁免;非总管理员带组织条件 + 项目级范围;**无组织 ⇒ 空集**(不是"全部")。
#[test]
fn test_visible_cabinet_sql_keeps_org_isolation_and_scope() {
// 总管理员豁免:即使身上带着组织与范围,也不得出现在 SQL 里
let (sql, binds) = visible_cabinet_imeis_sql(&user(2, Some(99), vec![7]));
assert!(!sql.contains("organization_id"), "总管理员应豁免组织条件:{}", sql);
assert!(!sql.contains("IN ("), "总管理员应豁免数据范围:{}", sql);
assert!(binds.is_empty(), "总管理员不应有任何绑定值");
// 组织管理员:组织条件 + 项目级范围(绑定顺序必须与占位符顺序一致)
let (sql, binds) = visible_cabinet_imeis_sql(&user(1, Some(42), vec![7, 8]));
assert!(sql.contains("p.organization_id = ?"), "组织隔离丢失:{}", sql);
assert!(sql.contains("c.project_id IN (?,?)"), "数据范围丢失:{}", sql);
assert_eq!(binds, vec!["42", "7", "8"], "绑定顺序必须与占位符一致");
// 客户侧账号无组织 ⇒ 空集(**绝不退化成"全部"**),两条 SQL 都必须如此
let (imei_sql, binds) = visible_cabinet_imeis_sql(&user(0, None, vec![]));
assert!(imei_sql.contains("1=0"), "无组织时在线取数必须是空集:{}", imei_sql);
assert!(binds.is_empty());
let (count_sql, _) = visible_cabinets_count_sql(&user(0, None, vec![]));
assert!(count_sql.contains("1=0"), "无组织时柜数必须是空集:{}", count_sql);
// 在线取数只取 IMEI(在线判定交给 Redis,DB 不参与)
let (sql, _) = visible_cabinet_imeis_sql(&user(1, Some(42), vec![]));
assert!(sql.starts_with("SELECT c.imei FROM cabinets c"), "取数应只取 IMEI:{}", sql);
assert!(sql.contains("c.imei IS NOT NULL"), "必须排除 NULL 的 IMEI:{}", sql);
}
/// ③ **与设备列表 `is_online` 口径同源**:`energy_stats` 没有第二套在线判据 ——
/// 在线计数一律经 `cabinet_tree::count_online_flags`,其唯一依据是
/// `cabinet_tree::is_device_online` / `device_online_flag`(设备列表用的是同一对函数)。
/// 逐值断言同一批原始值在两处结论完全一致,并覆盖「0 台连接 ⇒ 0」这条语义。
#[test]
fn test_online_count_same_source_as_cabinet_tree_device_list() {
// 同一批原始值,覆盖三态边界:在线 / 明确离线 / 无数据 / 空串 / 非法值
let raws: Vec<Option<String>> = vec![
Some("1".into()),
Some("0".into()),
None,
Some(String::new()),
Some("2".into()),
];
// 期望值直接由设备列表的判据(`is_online` == `device_online_flag(..) == Some(true)`)给出
let expected = raws
.iter()
.filter(|v| cabinet_tree::device_online_flag(v.as_deref()) == Some(true))
.count() as i64;
assert_eq!(
cabinet_tree::count_online_flags(&raws),
expected,
"在线计数与设备列表 is_online 判据必须同源"
);
assert_eq!(expected, 1, "5 个样本里只有 1 个明确在线");
// **0 台设备连接 ⇒ online_count = 0**(而不是等于柜子总数)——生产实测的回归形态
let nothing_connected: Vec<Option<String>> = vec![None; 68];
assert_eq!(
cabinet_tree::count_online_flags(&nothing_connected),
0,
"无设备连接时在线数必须是 0,绝不能退化成柜子总数"
);
// 在线取数**只可能是可见柜的 IMEI**(组织隔离/范围之后),空列表 ⇒ 0
assert_eq!(cabinet_tree::count_online_flags(&[]), 0);
}
}