//! 能耗管理 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, pub project_id: Option, pub start_date: Option, pub end_date: Option, } /// 柜子排行行 #[derive(Debug, sqlx::FromRow)] struct CabinetRankRow { id: i64, abstract_id: String, name: Option, today_energy: Option, month_energy: Option, } /// 项目统计行 #[derive(Debug, sqlx::FromRow)] struct ProjectStatRow { id: i64, name: String, cabinet_count: i64, today_energy: Option, month_energy: Option, } /// 趋势数据行 #[derive(Debug, sqlx::FromRow)] struct TrendRow { period: String, // 日期或周/月标识 energy: Option, charge_count: Option, } /// 能耗明细行(用于导出) #[derive(Debug, sqlx::FromRow)] struct EnergyDetailRow { stat_date: chrono::NaiveDate, cabinet_id: i64, cabinet_name: Option, project_name: Option, total_energy: Option, charge_count: Option, } 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) { let (mut cond, mut binds): (String, Vec) = 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) { 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) { 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) { 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 { 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` 解码后过滤, /// 避免一行 NULL 让整个在线统计解码失败(接口 500)。 async fn visible_cabinet_imeis( pool: &sqlx::MySqlPool, user: &CurrentUser, ) -> Result, AppError> { let (sql, binds) = visible_cabinet_imeis_sql(user); let mut query = sqlx::query_scalar::<_, Option>(&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 { 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, ) -> Result, 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, ) -> Result, AppError> { auth::check_permission(&user, "energy:view")?; let pool = &state.mysql; // 组织过滤:INNER JOIN projects 把可见范围限制在用户所属组织 // (口径与原实现完全一致,只是不再经过 energy_stats) let (org_join, org_binds): (String, Vec) = 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 = 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, ) -> Result, AppError> { auth::check_permission(&user, "energy:view")?; let pool = &state.mysql; let (org_filter, org_binds): (String, Vec) = 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 = 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, Query(params): Query, ) -> Result, 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 = 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, Query(params): Query, ) -> Result, 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, project_id: Option, start_date: Option<&str>, end_date: Option<&str>, ) -> (String, Vec) { 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> { 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) { 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 = 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, 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> { 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> { // 更新状态为处理中 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, project_ids: Vec) -> 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> = 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> = vec![None; 68]; assert_eq!( cabinet_tree::count_online_flags(¬hing_connected), 0, "无设备连接时在线数必须是 0,绝不能退化成柜子总数" ); // 在线取数**只可能是可见柜的 IMEI**(组织隔离/范围之后),空列表 ⇒ 0 assert_eq!(cabinet_tree::count_online_flags(&[]), 0); } }