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 顺序发布到测试与生产。
1071 lines
44 KiB
Rust
1071 lines
44 KiB
Rust
//! 能耗管理 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(¶ms);
|
||
|
||
// 序列化查询参数(含组织过滤信息 + 数据范围)
|
||
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(¶ms);
|
||
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(¬hing_connected),
|
||
0,
|
||
"无设备连接时在线数必须是 0,绝不能退化成柜子总数"
|
||
);
|
||
|
||
// 在线取数**只可能是可见柜的 IMEI**(组织隔离/范围之后),空列表 ⇒ 0
|
||
assert_eq!(cabinet_tree::count_online_flags(&[]), 0);
|
||
}
|
||
}
|