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 顺序发布到测试与生产。
2585 lines
103 KiB
Rust
2585 lines
103 KiB
Rust
//! H5 用户端 API
|
||
//!
|
||
//! 提供微信扫码充电的 H5 页面所需的全部后端接口:
|
||
//! - GET /api/dashboard — 首页仪表盘数据
|
||
//! - GET /api/cabinets — 设备列表(按项目过滤)
|
||
//! - GET /api/cabinets/:id — 设备详情(含仓板+仓体状态)
|
||
//! - GET /api/cabinets/abstract/:aid — 通过抽象ID查设备(扫码入口,免认证)
|
||
//! - GET /api/compartments/:id — 仓体/通道详情
|
||
//! - POST /api/charge/start — 开始充电
|
||
//! - POST /api/charge/stop — 停止充电
|
||
//! - POST /api/door/open — 开门
|
||
//! - GET /api/cabinets/scan/:abstract_id — 扫码验证
|
||
//! - GET /api/my-organizations — 当前用户可见的组织(仅需登录;工厂全量 / 客户仅本组织)
|
||
|
||
use axum::extract::{Multipart, Path, Query, State};
|
||
use axum::Json;
|
||
use redis;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{json, Value};
|
||
use std::collections::HashMap;
|
||
use std::io::Write;
|
||
use uuid::Uuid;
|
||
|
||
use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD};
|
||
use crate::error::AppError;
|
||
use crate::middleware::auth::{self, CurrentUser};
|
||
|
||
use super::cabinet_tree::{derive_channel_flags, ChannelCounts};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 请求体定义
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 充电控制请求体(开始/停止充电、开门共用)
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct CompartmentActionInput {
|
||
compartment_id: i64,
|
||
}
|
||
|
||
/// 问题反馈请求体
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct FeedbackInput {
|
||
/// 反馈内容
|
||
content: String,
|
||
/// 联系方式(可选)
|
||
contact: Option<String>,
|
||
/// 图片URL列表(可选)
|
||
images: Option<Vec<String>>,
|
||
}
|
||
|
||
/// 设备列表查询参数
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct CabinetListQuery {
|
||
project_id: Option<i64>,
|
||
org_id: Option<i64>,
|
||
}
|
||
|
||
/// 仪表盘查询参数
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct DashboardQuery {
|
||
org_id: Option<i64>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 行映射结构体
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 项目简要信息行
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct ProjectRow {
|
||
id: i64,
|
||
name: String,
|
||
}
|
||
|
||
/// 柜子列表行(含通道统计)
|
||
///
|
||
/// ⚠️ 这里**只保留 DB 能给准的三个计数**(`channel_count` / `idle_count` / `charging_count`)。
|
||
/// `full_count` / `fault_count` 不在此结构体里 —— `compartments.status` 全仓只写 0/1(占用语义),
|
||
/// DB 永远给不出充满/故障,它们只能由 Redis 通道状态派生(见 [`CabinetRealtimeMaps::counts`])。
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct H5CabinetRow {
|
||
id: i64,
|
||
abstract_id: String,
|
||
name: Option<String>,
|
||
status: i8,
|
||
project_id: Option<i64>,
|
||
imei: Option<String>,
|
||
iccid: Option<String>,
|
||
address: Option<String>,
|
||
latitude: Option<String>,
|
||
longitude: Option<String>,
|
||
channel_count: i64,
|
||
idle_count: i64,
|
||
charging_count: i64,
|
||
}
|
||
|
||
/// 仓控板行
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct H5CabinBoardRow {
|
||
id: i64,
|
||
board_id: i8,
|
||
status: i8,
|
||
}
|
||
|
||
/// 仓体行
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct H5CompartmentRow {
|
||
id: i64,
|
||
channel_id: i8,
|
||
status: i8,
|
||
}
|
||
|
||
/// 充电记录简要行
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct ChargeRecordBriefRow {
|
||
id: i64,
|
||
start_time: Option<chrono::NaiveDateTime>,
|
||
end_time: Option<chrono::NaiveDateTime>,
|
||
energy: Option<String>,
|
||
}
|
||
|
||
/// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发)
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct CabinetImeiRow {
|
||
cabinet_id: i64,
|
||
imei: String,
|
||
board_id: i8,
|
||
channel_id: i8,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 仪表盘
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 数据范围(项目级)过滤片段:`cond` 以 ` AND ` 开头(拼在已有 WHERE 之后),`binds` 顺序与占位符一致
|
||
///
|
||
/// 未配置数据范围(或总管理员)时 `cond` 为空串、`binds` 为空 ⇒ 各统计 SQL 与今天逐字一致。
|
||
struct ScopeFilter<'a> {
|
||
cond: &'a str,
|
||
binds: &'a [String],
|
||
}
|
||
|
||
/// 构造作用于 `projects p` 的数据范围过滤片段
|
||
fn scope_filter_for_projects(scope: &crate::middleware::auth::UserScope) -> (String, Vec<String>) {
|
||
scope.project_condition("p.id")
|
||
}
|
||
|
||
/// 仪表盘首页
|
||
///
|
||
/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离 + 数据范围)
|
||
pub async fn get_dashboard(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Query(query): Query<DashboardQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
// 根据角色确定组织过滤条件
|
||
// 总管理员可通过 org_id 参数切换组织
|
||
let is_super = user.role_level >= 2 && query.org_id.is_none();
|
||
let org_id = query.org_id.or(user.organization_id);
|
||
|
||
// 数据范围(项目级):总管理员豁免;未配置 = 不限制(片段为空)
|
||
let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope());
|
||
let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds };
|
||
|
||
// 统计在线柜子数(带组织隔离;**以 Redis 为准**,与 `cabinets.status` 配置态无关)
|
||
let online_cabinets = count_online_cabinets(&state, is_super, org_id, &scope).await?;
|
||
// 统计总通道数(带组织隔离)
|
||
let total_channels = count_total_compartments(pool, is_super, org_id, &scope).await?;
|
||
|
||
// 统计充电中仓体数(带组织隔离)
|
||
let charging_count = count_compartments_by_status(pool, is_super, org_id, 1, &scope).await?;
|
||
|
||
// 统计空闲仓体数(带组织隔离):`status = 0` = 空闲(占用语义,DB 给得准)
|
||
let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0, &scope).await?;
|
||
|
||
// 充满 / 故障:**DB 给不出**(`compartments.status` 只写 0/1,2/3 永不出现),
|
||
// 只能由通道实时状态派生 —— 与设备列表/PC 设备卡片同一套规则(`derive_channel_flags`)。
|
||
// 拿不到 Redis 数据(设备离线 / 无通道键 / Redis 不可用)时按 0 计,不猜测。
|
||
let (full_count, fault_count) = count_derived_full_fault(&state, is_super, org_id, &scope).await;
|
||
// `fault_channels` 与 `fault_count` 同源同义(历史字段名重复),一并给派生值
|
||
let fault_channels = fault_count;
|
||
|
||
// 今日充电次数(带组织隔离)
|
||
let today_charge_count = count_today_charges(pool, is_super, org_id, &scope).await?;
|
||
|
||
// 今日充电量(kWh,带组织隔离)
|
||
let today_energy = sum_today_energy(pool, is_super, org_id, &scope).await?;
|
||
|
||
// 今日充电时长(小时,带组织隔离)
|
||
let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id, &scope).await?;
|
||
|
||
// 故障告警(取最近10条故障仓体,返回项目名+柜名)
|
||
let alerts = fetch_fault_alerts(pool, is_super, org_id, &scope).await?;
|
||
|
||
// 项目摘要(带组织过滤 + 数据范围)
|
||
let projects = fetch_project_summaries(pool, &user, org_id).await?;
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"online_cabinets": online_cabinets,
|
||
"total_channels": total_channels,
|
||
"charging_count": charging_count,
|
||
"idle_channels": idle_channels,
|
||
"full_count": full_count,
|
||
"fault_channels": fault_channels,
|
||
"fault_count": fault_count,
|
||
"today_charge_count": today_charge_count,
|
||
"today_energy_kwh": today_energy,
|
||
"today_charge_hours": today_charge_hours,
|
||
"alerts": alerts,
|
||
"projects": projects,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// 设备级 HMGET 的**字段清单**(顺序 = pipeline 命令顺序 = 解析下标)。
|
||
///
|
||
/// 下标:`rssi`(0) / `pow_fail_dc`(1) / `pow_fail_ac`(2) / `temp`(3) /
|
||
/// `cabinet_status1`(4) / `cabinet_status2`(5) / **`online`(6,追加在末尾)**。
|
||
/// ⚠️ 新增字段**只能追加在末尾**(前面的下标被下面的解析逐一对齐)。
|
||
const CABINET_HMGET_FIELDS: [&str; 7] = [
|
||
"rssi",
|
||
"pow_fail_dc",
|
||
"pow_fail_ac",
|
||
"temp",
|
||
"cabinet_status1",
|
||
"cabinet_status2",
|
||
// 实时在线:字段名直接取自 `cabinet_tree`(与设备列表 `is_online` 同一常量,跨模块同源)
|
||
super::cabinet_tree::DEVICE_ONLINE_FIELD,
|
||
];
|
||
|
||
/// 在线字段在 [`CABINET_HMGET_FIELDS`] 中的下标(追加在末尾)
|
||
const CABINET_HMGET_ONLINE_IDX: usize = 6;
|
||
|
||
/// 首页「在线设备」的**取数 SQL**:只取可见柜的 IMEI,在线与否交给 Redis 判定。
|
||
///
|
||
/// **不含任何 `status` 条件** —— `cabinets.status` 是启用/禁用**配置态**,
|
||
/// 全仓没有任何写入方会把它写成 0(离线),拿它统计会在"一台设备都没连"时
|
||
/// 返回"全部在线"。此处只负责按组织隔离 + 数据范围取出 IMEI。
|
||
fn online_cabinet_imeis_sql(is_super: bool, scope_cond: &str) -> String {
|
||
if is_super {
|
||
"SELECT cab.imei FROM cabinets cab WHERE cab.imei IS NOT NULL".to_string()
|
||
} else {
|
||
format!(
|
||
r#"SELECT cab.imei FROM cabinets cab
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE cab.imei IS NOT NULL AND p.organization_id = ?{}"#,
|
||
scope_cond
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 统计在线柜子数(**以 Redis 为准**,带组织隔离 + 数据范围)
|
||
///
|
||
/// # 为什么不再用 `cabinets.status = 1`
|
||
/// `cabinets.status` 是启用/禁用**配置态**(`2` = 禁用,其余 = 启用),
|
||
/// 全仓**没有任何写入方会把它写成 0(离线)** ⇒ 按它统计会在"一台设备都没连"时
|
||
/// 依然返回"全部在线"。现在改为:先按组织隔离 + 范围取出可见柜的 IMEI,
|
||
/// 再用一次 pipeline 查 `device:{imei}.online`(判据与设备列表 `is_online` 同一纯函数
|
||
/// [`cabinet_tree::is_device_online`])。
|
||
async fn count_online_cabinets(
|
||
state: &AppState,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> Result<i64, AppError> {
|
||
// 总管理员:全部柜子的 IMEI,不带任何组织条件(豁免)
|
||
if is_super {
|
||
return finish_online_count(state, online_cabinet_imeis_sql(true, ""), Vec::new()).await;
|
||
}
|
||
// 客户侧账号**无组织归属 ⇒ 空集**(绝不退化成"全部"),与 `visible_org_scope` 同口径
|
||
let Some(oid) = org_id else {
|
||
return Ok(0);
|
||
};
|
||
// 组织隔离 + 数据范围(绑定顺序 = 占位符顺序:先组织,再范围)
|
||
let mut binds: Vec<String> = vec![oid.to_string()];
|
||
binds.extend(scope.binds.iter().cloned());
|
||
finish_online_count(state, online_cabinet_imeis_sql(false, scope.cond), binds).await
|
||
}
|
||
|
||
/// 执行在线取数 SQL,并按 Redis 判据统计在线台数(`binds` 顺序 = 占位符顺序)
|
||
async fn finish_online_count(
|
||
state: &AppState,
|
||
sql: String,
|
||
binds: Vec<String>,
|
||
) -> Result<i64, AppError> {
|
||
let mut query = sqlx::query_scalar::<_, Option<String>>(&sql);
|
||
for bind in &binds {
|
||
query = query.bind(bind);
|
||
}
|
||
let imeis: Vec<String> = query
|
||
.fetch_all(&state.mysql)
|
||
.await?
|
||
.into_iter()
|
||
.flatten()
|
||
.collect();
|
||
// Redis 一次 pipeline 批量判定(**零 N 次单查**),口径同设备列表 is_online
|
||
Ok(super::cabinet_tree::count_online_devices(state, &imeis).await)
|
||
}
|
||
|
||
/// 统计总仓体数(带组织隔离 + 数据范围)
|
||
async fn count_total_compartments(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let count: i64 = if is_super {
|
||
sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets c ON cb.cabinet_id = c.id
|
||
WHERE c.status = 1"#,
|
||
)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
let sql = format!(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets c ON cb.cabinet_id = c.id
|
||
JOIN projects p ON c.project_id = p.id
|
||
WHERE c.status = 1 AND p.organization_id = ?{}"#,
|
||
scope.cond
|
||
);
|
||
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(org_id.unwrap());
|
||
for bind in scope.binds {
|
||
query = query.bind(bind);
|
||
}
|
||
query.fetch_one(pool).await?
|
||
};
|
||
Ok(count)
|
||
}
|
||
|
||
/// 按状态统计仓体数(带组织隔离 + 数据范围,通过 cabin_boards → cabinets → projects 链路)
|
||
async fn count_compartments_by_status(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
status: i8,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let count: i64 = if is_super {
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM compartments WHERE status = ?")
|
||
.bind(status)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
let sql = format!(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
INNER JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
INNER JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE comp.status = ? AND p.organization_id = ?{}"#,
|
||
scope.cond
|
||
);
|
||
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(status).bind(org_id.unwrap());
|
||
for bind in scope.binds {
|
||
query = query.bind(bind);
|
||
}
|
||
query.fetch_one(pool).await?
|
||
};
|
||
Ok(count)
|
||
}
|
||
|
||
/// 统计范围内的「充满 / 故障」通道数(**由通道实时状态派生**,带组织隔离)
|
||
///
|
||
/// 为什么不能用 SQL:`compartments.status` 只表达**占用**语义(`0 空闲 / 1 充电中`),
|
||
/// 平台从不写 2/3,所以 `WHERE comp.status = 2/3` 永远返回 0 —— 这正是首页统计行
|
||
/// "充满""故障"恒为 0 的原因。改为:按可见范围取柜子 → 拼通道 key → 一次 pipeline
|
||
/// 读 `batt_status`/`pow_status` → 复用 `cabinet_tree::derive_channel_counts`
|
||
/// 的优先级(fault > full > charging > idle)分区计数。
|
||
///
|
||
/// 可见范围与 [`count_compartments_by_status`] 保持一致:总管理员 = 全部柜子;否则 = 本组织项目下的柜子。
|
||
/// **拿不到 Redis 数据时按 0 计**(离线 / 无通道键 / Redis 不可用),绝不猜测。
|
||
async fn count_derived_full_fault(
|
||
state: &AppState,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> (i64, i64) {
|
||
let cabinet_ids = match fetch_scope_cabinet_ids(&state.mysql, is_super, org_id, scope).await {
|
||
Ok(ids) => ids,
|
||
Err(e) => {
|
||
tracing::error!("[h5] 首页计数:柜子范围查询失败,充满/故障按 0 计 err={}", e);
|
||
return (0, 0);
|
||
}
|
||
};
|
||
let entries = match super::cabinet_tree::fetch_channel_keys(&state.mysql, &cabinet_ids).await {
|
||
Ok(entries) => entries,
|
||
Err(e) => {
|
||
tracing::error!("[h5] 首页计数:通道 key 查询失败,充满/故障按 0 计 err={}", e);
|
||
return (0, 0);
|
||
}
|
||
};
|
||
|
||
let counts: HashMap<i64, ChannelCounts> =
|
||
super::cabinet_tree::derive_counts_by_cabinet(state, &entries).await;
|
||
|
||
counts
|
||
.values()
|
||
.fold((0_i64, 0_i64), |(full, fault), c| (full + c.2, fault + c.3))
|
||
}
|
||
|
||
/// 取统计范围内的柜子 ID:总管理员 = 全部柜子;组织用户 = 本组织**且落在数据范围内**的柜子;无组织 = 空集
|
||
/// (口径与 [`count_compartments_by_status`] 完全一致)
|
||
async fn fetch_scope_cabinet_ids(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> Result<Vec<i64>, sqlx::Error> {
|
||
if is_super {
|
||
return sqlx::query_scalar("SELECT id FROM cabinets").fetch_all(pool).await;
|
||
}
|
||
match org_id {
|
||
Some(oid) => {
|
||
let sql = format!(
|
||
r#"SELECT c.id FROM cabinets c
|
||
INNER JOIN projects p ON c.project_id = p.id
|
||
WHERE p.organization_id = ?{}"#,
|
||
scope.cond
|
||
);
|
||
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(oid);
|
||
for bind in scope.binds {
|
||
query = query.bind(bind);
|
||
}
|
||
query.fetch_all(pool).await
|
||
}
|
||
// `organization_id` 为 NULL 的客户侧账号看不到任何设备(空集,不是报错,也不退化成"全部")
|
||
None => Ok(Vec::new()),
|
||
}
|
||
}
|
||
|
||
/// 今日充电次数(带组织隔离 + 数据范围)
|
||
async fn count_today_charges(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let count: i64 = if is_super {
|
||
sqlx::query_scalar(
|
||
"SELECT COUNT(*) FROM charge_records WHERE DATE(start_time) = CURDATE()",
|
||
)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
let sql = format!(
|
||
r#"SELECT COUNT(*) FROM charge_records cr
|
||
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?{}"#,
|
||
scope.cond
|
||
);
|
||
let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(org_id.unwrap());
|
||
for bind in scope.binds {
|
||
query = query.bind(bind);
|
||
}
|
||
query.fetch_one(pool).await?
|
||
};
|
||
Ok(count)
|
||
}
|
||
|
||
/// 今日充电量(kWh,带组织隔离 + 数据范围)
|
||
async fn sum_today_energy(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let energy: Option<i64> = if is_super {
|
||
sqlx::query_scalar(
|
||
"SELECT CAST(COALESCE(SUM(energy), 0) AS SIGNED) FROM charge_records WHERE DATE(start_time) = CURDATE()",
|
||
)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
let sql = format!(
|
||
r#"SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr
|
||
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?{}"#,
|
||
scope.cond
|
||
);
|
||
let mut query = sqlx::query_scalar::<_, Option<i64>>(&sql).bind(org_id.unwrap());
|
||
for bind in scope.binds {
|
||
query = query.bind(bind);
|
||
}
|
||
query.fetch_one(pool).await?
|
||
};
|
||
Ok(energy.unwrap_or(0))
|
||
}
|
||
|
||
/// 今日充电时长(秒,带组织隔离 + 数据范围)
|
||
async fn sum_today_charge_hours(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
// 计算总秒数(未结束的记录用 NOW() 代替 end_time)
|
||
let total_seconds: Option<i64> = if is_super {
|
||
sqlx::query_scalar(
|
||
r#"SELECT CAST(COALESCE(SUM(
|
||
TIMESTAMPDIFF(SECOND, start_time, COALESCE(end_time, NOW()))
|
||
), 0) AS SIGNED) FROM charge_records
|
||
WHERE DATE(start_time) = CURDATE()"#,
|
||
)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
let sql = format!(
|
||
r#"SELECT CAST(COALESCE(SUM(
|
||
TIMESTAMPDIFF(SECOND, cr.start_time, COALESCE(cr.end_time, NOW()))
|
||
), 0) AS SIGNED) FROM charge_records cr
|
||
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?{}"#,
|
||
scope.cond
|
||
);
|
||
let mut query = sqlx::query_scalar::<_, Option<i64>>(&sql).bind(org_id.unwrap());
|
||
for bind in scope.binds {
|
||
query = query.bind(bind);
|
||
}
|
||
query.fetch_one(pool).await?
|
||
};
|
||
Ok(total_seconds.unwrap_or(0))
|
||
}
|
||
|
||
/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离 + 数据范围)
|
||
async fn fetch_fault_alerts(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
scope: &ScopeFilter<'_>,
|
||
) -> Result<Vec<Value>, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(vec![]);
|
||
}
|
||
let base_sql = if is_super {
|
||
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
|
||
comp.channel_id
|
||
FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
LEFT JOIN projects p ON cab.project_id = p.id
|
||
WHERE comp.status = 2
|
||
ORDER BY comp.updated_at DESC
|
||
LIMIT 10"#
|
||
.to_string()
|
||
} else {
|
||
format!(
|
||
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
|
||
comp.channel_id
|
||
FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE comp.status = 2 AND p.organization_id = ?{}
|
||
ORDER BY comp.updated_at DESC
|
||
LIMIT 10"#,
|
||
scope.cond
|
||
)
|
||
};
|
||
|
||
let rows: Vec<(String, String, i8)> = if is_super {
|
||
sqlx::query_as(&base_sql).fetch_all(pool).await?
|
||
} else {
|
||
let mut query = sqlx::query_as::<_, (String, String, i8)>(&base_sql).bind(org_id.unwrap());
|
||
for bind in scope.binds {
|
||
query = query.bind(bind);
|
||
}
|
||
query.fetch_all(pool).await?
|
||
};
|
||
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|(project, cabinet, channel)| {
|
||
json!({
|
||
"project": project,
|
||
"cabinet": cabinet,
|
||
"channel": channel,
|
||
"alert_type": "fault",
|
||
})
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
// --- 项目摘要 SQL(由函数体原样搬移,WHERE/JOIN/SELECT 未改动) ---
|
||
|
||
/// 指定组织下的项目
|
||
const PROJECTS_BY_ORG_SQL: &str = "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id";
|
||
/// 全部项目(总管理员未指定组织时)
|
||
const PROJECTS_ALL_SQL: &str = "SELECT id, name FROM projects ORDER BY id";
|
||
/// 项目下柜子数量
|
||
const PROJECT_CABINET_COUNT_SQL: &str = "SELECT COUNT(*) FROM cabinets WHERE project_id = ?";
|
||
/// 项目下充电中仓体数量
|
||
const PROJECT_CHARGING_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
WHERE cab.project_id = ? AND comp.status = 1"#;
|
||
/// 项目下空闲仓体数量
|
||
const PROJECT_IDLE_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
WHERE cab.project_id = ? AND comp.status = 0"#;
|
||
/// 项目下故障仓体数量
|
||
const PROJECT_FAULT_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
WHERE cab.project_id = ? AND comp.status = 2"#;
|
||
|
||
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
|
||
async fn fetch_project_summaries(
|
||
pool: &sqlx::MySqlPool,
|
||
user: &CurrentUser,
|
||
org_id: Option<i64>,
|
||
) -> Result<Vec<Value>, AppError> {
|
||
let projects = fetch_visible_projects(pool, user, org_id).await?;
|
||
|
||
let mut result = Vec::with_capacity(projects.len());
|
||
for p in &projects {
|
||
result.push(fetch_project_summary(pool, p).await?);
|
||
}
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
/// 获取用户可见的项目(优先使用查询参数 org_id)
|
||
///
|
||
/// **数据范围(项目级)**:组织隔离给出"哪些组织的项目",再按 `CurrentUser::scope`
|
||
/// 过滤掉范围外的项目(未配置 = 不限制;总管理员豁免)。
|
||
async fn fetch_visible_projects(
|
||
pool: &sqlx::MySqlPool,
|
||
user: &CurrentUser,
|
||
org_id: Option<i64>,
|
||
) -> Result<Vec<ProjectRow>, AppError> {
|
||
let filter_org = org_id.or(user.organization_id);
|
||
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
|
||
if let Some(oid) = filter_org {
|
||
sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL)
|
||
.bind(oid)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_as::<_, ProjectRow>(PROJECTS_ALL_SQL)
|
||
.fetch_all(pool)
|
||
.await?
|
||
}
|
||
} else if let Some(org_id) = user.organization_id {
|
||
sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL)
|
||
.bind(org_id)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else {
|
||
vec![]
|
||
};
|
||
|
||
let scope = user.effective_scope();
|
||
if scope.is_unrestricted() {
|
||
return Ok(projects);
|
||
}
|
||
Ok(projects
|
||
.into_iter()
|
||
.filter(|p| scope.allows_project(Some(p.id)))
|
||
.collect())
|
||
}
|
||
|
||
/// 汇总单个项目的柜子数 / 充电中 / 空闲 / 故障仓体数
|
||
async fn fetch_project_summary(
|
||
pool: &sqlx::MySqlPool,
|
||
p: &ProjectRow,
|
||
) -> Result<Value, AppError> {
|
||
let cabinet_count: i64 = sqlx::query_scalar(PROJECT_CABINET_COUNT_SQL)
|
||
.bind(p.id)
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
let charging: i64 = sqlx::query_scalar(PROJECT_CHARGING_COUNT_SQL)
|
||
.bind(p.id)
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
let idle: i64 = sqlx::query_scalar(PROJECT_IDLE_COUNT_SQL)
|
||
.bind(p.id)
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
let fault: i64 = sqlx::query_scalar(PROJECT_FAULT_COUNT_SQL)
|
||
.bind(p.id)
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
Ok(json!({
|
||
"id": p.id,
|
||
"name": p.name,
|
||
"cabinet_count": cabinet_count,
|
||
"charging": charging,
|
||
"idle": idle,
|
||
"fault": fault,
|
||
}))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 可见组织(小程序专用)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 可见组织行(**只暴露必要字段**:`id` / `name`)
|
||
///
|
||
/// 字段名与 `GET /api/organizations`(管理端 `OrganizationRow`)保持一致,便于前端复用同一类型;
|
||
/// 但**故意不带** `parent_id` / `disabled` 等管理端字段 —— 小程序只需要渲染组织名。
|
||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||
struct VisibleOrganizationRow {
|
||
id: i64,
|
||
name: String,
|
||
}
|
||
|
||
/// 工厂侧(总管理员)可见全部组织
|
||
const VISIBLE_ORGS_ALL_SQL: &str = "SELECT id, name FROM organizations ORDER BY id";
|
||
/// 客户侧用户只可见自己所属组织(`users.organization_id`)
|
||
const VISIBLE_ORGS_BY_ID_SQL: &str = "SELECT id, name FROM organizations WHERE id = ?";
|
||
|
||
/// 可见组织范围(由 [`visible_org_scope`] 依据角色算出;纯数据,便于单测)
|
||
#[derive(Debug, PartialEq, Eq)]
|
||
enum VisibleOrgScope {
|
||
/// 工厂/总管理员:全部组织
|
||
All,
|
||
/// 组织管理员/操作员:仅本组织
|
||
Own(i64),
|
||
/// 无组织归属:空集(**返回空数组,不报错**)
|
||
Empty,
|
||
}
|
||
|
||
/// 角色等级 + 所属组织 → 可见组织范围(纯函数,组织隔离的唯一判定点)
|
||
///
|
||
/// **组织可见性口径(全系统统一,见 AGENTS.md「组织架构」):**
|
||
/// - `role_level >= 2`(工厂/总管理员)→ 全部组织;
|
||
/// - 其他(组织管理员 `1` / 操作员 `0`)→ 仅 `users.organization_id` 指向的那一个组织;
|
||
/// - `organization_id` 为 NULL → 空集。
|
||
fn visible_org_scope(role_level: i32, organization_id: Option<i64>) -> VisibleOrgScope {
|
||
if role_level >= 2 {
|
||
return VisibleOrgScope::All;
|
||
}
|
||
match organization_id {
|
||
Some(org_id) => VisibleOrgScope::Own(org_id),
|
||
None => VisibleOrgScope::Empty,
|
||
}
|
||
}
|
||
|
||
/// GET /api/my-organizations — 当前登录用户**可见**的组织列表(小程序「选择组织」用)
|
||
///
|
||
/// **权限:仅需登录**(与 `GET /api/dashboard` 一致,内部按角色做组织隔离),
|
||
/// **不校验 `org:view`** —— 操作员(`normal_user`)的授权清单里没有该权限码
|
||
/// (`db/migrate.rs::seed_roles`),沿用管理端接口会 403、组织弹层永远为空。
|
||
///
|
||
/// **隔离(核心安全边界)**:响应只包含当前用户有权看到的组织 ——
|
||
/// 工厂(`role_level >= 2`)全量;客户侧(组织管理员/操作员)**只有自己所属组织**,
|
||
/// 绝不把全量组织泄露给客户角色。
|
||
///
|
||
/// 管理端的 `GET /api/organizations`(`org:view` + 全量)语义保持原样,不做任何改动。
|
||
pub async fn list_my_organizations(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
let rows: Vec<VisibleOrganizationRow> = match visible_org_scope(user.role_level, user.organization_id)
|
||
{
|
||
// 工厂:全部组织
|
||
VisibleOrgScope::All => {
|
||
sqlx::query_as::<_, VisibleOrganizationRow>(VISIBLE_ORGS_ALL_SQL)
|
||
.fetch_all(pool)
|
||
.await?
|
||
}
|
||
// 客户侧:**唯一的组织过滤点** —— `WHERE id = <users.organization_id>`
|
||
VisibleOrgScope::Own(org_id) => {
|
||
sqlx::query_as::<_, VisibleOrganizationRow>(VISIBLE_ORGS_BY_ID_SQL)
|
||
.bind(org_id)
|
||
.fetch_all(pool)
|
||
.await?
|
||
}
|
||
// 无组织归属:空数组(不是错误)
|
||
VisibleOrgScope::Empty => Vec::new(),
|
||
};
|
||
|
||
// 包装格式与 `GET /api/organizations` 完全一致(`data` 直接是组织数组,字段 id/name)
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": rows,
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 设备列表
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// --- 设备列表 SQL(由原函数体逐字搬移;`channel_count` / `idle_count` / `charging_count` 子查询与 CAST 未改动) ---
|
||
//
|
||
// ⚠️ **口径变更(产品决定,本次)**:`compartments.status` 只表达**占用**语义
|
||
// (`0 空闲 / 1 充电中` —— 平台只写这两个值,全仓没有任何写入点会写 2/3),
|
||
// "充满 / 故障"**不落库**。因此这 5 条分支 SQL(按项目 / 项目+组织 / 组织 / 全部 / 本组织)
|
||
// 里的 `full_count` / `fault_count` 子查询(`comp.status = 2 / 3`)已**整体删除**
|
||
// —— 它们在 DB 里恒为 0,留着只是白白多跑两次子查询;改为由通道实时状态派生:
|
||
// [`load_cabinet_realtime`] 批量读通道状态 → `cabinet_tree::derive_channel_counts`
|
||
// → [`build_cabinet_list`] 写回响应。**5 条分支共用同一条派生链路,一处在、处处在。**
|
||
// 响应字段名 `full_count` / `fault_count`(以及 `idle_count` / `charging_count`)**保持不变**。
|
||
|
||
/// 项目过滤 + 总管理员(不带组织条件)
|
||
const CABINETS_BY_PROJECT_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count
|
||
FROM cabinets c WHERE c.project_id = ?"#;
|
||
|
||
/// 项目过滤 + 企业管理员(额外校验项目归属组织)
|
||
const CABINETS_BY_PROJECT_AND_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count
|
||
FROM cabinets c
|
||
INNER JOIN projects p ON c.project_id = p.id
|
||
WHERE c.project_id = ? AND p.organization_id = ?"#;
|
||
|
||
/// 总管理员按组织过滤
|
||
const CABINETS_BY_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count
|
||
FROM cabinets c
|
||
INNER JOIN projects p ON c.project_id = p.id
|
||
WHERE p.organization_id = ?"#;
|
||
|
||
/// 总管理员查看全部
|
||
const CABINETS_ALL_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count
|
||
FROM cabinets c"#;
|
||
|
||
/// 企业管理员只看本组织
|
||
const CABINETS_BY_USER_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count
|
||
FROM cabinets c
|
||
INNER JOIN projects p ON c.project_id = p.id
|
||
WHERE p.organization_id = ?"#;
|
||
|
||
/// 柜子所属组织是否禁用(前端徽标用,`{}` 处填入 `?` 占位符列表)
|
||
const PROJECT_ORG_DISABLED_SQL: &str = r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p
|
||
LEFT JOIN organizations o ON p.organization_id = o.id
|
||
WHERE p.id IN ({})"#;
|
||
|
||
/// 设备列表的 Redis 实时状态(按 IMEI 索引)
|
||
#[derive(Default)]
|
||
struct CabinetRealtimeMaps {
|
||
rssi: HashMap<String, Option<i32>>,
|
||
dc: HashMap<String, Option<bool>>,
|
||
ac: HashMap<String, Option<bool>>,
|
||
temp: HashMap<String, Option<i32>>,
|
||
/// 设备在线(Redis `device:{imei}` 的 `online` 字段)
|
||
///
|
||
/// 判据与 PC 设备列表的 `is_online` **同源**(`cabinet_tree::device_online_flag`):
|
||
/// `Some(true)` 在线 / `Some(false)` 明确离线 / `None` = **无数据**(键或字段不存在)。
|
||
/// ⚠️ 与 DB 的 `cabinets.status`(启用/禁用**配置态**)**没有任何关系** ——
|
||
/// 小程序卡片不得再用 `cab.status` 推断在线。
|
||
online: HashMap<String, Option<bool>>,
|
||
/// **柜级**柜体状态1(协议 §3.8,2 位小写 hex 原样透出;`"10"` = 柜门打开)
|
||
///
|
||
/// 取不到(Redis 缺字段 / 设备离线 / Redis 不可用)→ `None` → 响应 `null`,
|
||
/// **绝不落 `"00"`**(`"00"` 是"全部正常"的有效值)。位域解析在前端纯函数里做。
|
||
status1: HashMap<String, Option<String>>,
|
||
/// **柜级**柜体状态2(协议 §3.8,bit0 声光报警 / bit1 柜门打开超时)
|
||
status2: HashMap<String, Option<String>>,
|
||
/// 柜子ID → 通道派生四计数 `(idle, charging, full, fault)`
|
||
///
|
||
/// 只有 `.2`(充满)/ `.3`(故障)会被写进响应:`idle_count` / `charging_count`
|
||
/// 用 DB 的占用值(`compartments.status` 0/1),**派生值不覆盖它们**。
|
||
/// 表里没有的柜子(无通道 / Redis 不可用)→ 调用方按 `(0,0,0,0)` 处理,
|
||
/// **离线无法判定,充满数与故障数一律记 0**。
|
||
counts: HashMap<i64, ChannelCounts>,
|
||
}
|
||
|
||
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
|
||
pub async fn get_cabinets(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Query(query): Query<CabinetListQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
// 权限校验
|
||
auth::check_permission(&user, "device:view")?;
|
||
|
||
let pool = &state.mysql;
|
||
|
||
// 组织隔离查询:设备基础信息 + 通道统计
|
||
let rows = fetch_cabinet_rows(pool, &user, query.project_id, query.org_id).await?;
|
||
|
||
// 从Redis批量查RSSI + pow_fail + temp
|
||
let realtime = load_cabinet_realtime(&state, &rows).await;
|
||
|
||
// 查询柜子所属组织是否禁用(用于前端徽标)
|
||
let org_disabled_map = load_org_disabled_map(pool, &rows).await;
|
||
|
||
let list = build_cabinet_list(rows, &realtime, &org_disabled_map);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": list,
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// 按「项目 / 角色」优先级查询用户可见的柜子行(分支顺序与原实现逐条对应)
|
||
///
|
||
/// 最后统一叠加**数据范围(项目级)**的内存过滤:未配置范围时原样返回(与今天完全一致)。
|
||
async fn fetch_cabinet_rows(
|
||
pool: &sqlx::MySqlPool,
|
||
user: &CurrentUser,
|
||
project_id: Option<i64>,
|
||
org_id: Option<i64>,
|
||
) -> Result<Vec<H5CabinetRow>, AppError> {
|
||
let rows = if let Some(pid) = project_id {
|
||
// 按项目过滤(同时验证组织权限)
|
||
fetch_cabinet_rows_by_project(pool, user, pid).await?
|
||
} else if user.role_level >= 2 {
|
||
// 总管理员:按组织过滤或看全部
|
||
fetch_cabinet_rows_for_admin(pool, org_id).await?
|
||
} else if let Some(org_id) = user.organization_id {
|
||
// 企业管理员只看本组织
|
||
sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_USER_ORG_SQL)
|
||
.bind(org_id)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else {
|
||
vec![]
|
||
};
|
||
|
||
Ok(retain_in_scope(rows, user))
|
||
}
|
||
|
||
/// 数据范围(项目级)内存过滤:未配置范围 / 总管理员时原样返回
|
||
fn retain_in_scope(rows: Vec<H5CabinetRow>, user: &CurrentUser) -> Vec<H5CabinetRow> {
|
||
let scope = user.effective_scope();
|
||
if scope.is_unrestricted() {
|
||
return rows;
|
||
}
|
||
rows.into_iter()
|
||
.filter(|row| scope.allows_project(row.project_id))
|
||
.collect()
|
||
}
|
||
|
||
/// 项目过滤分支:总管理员不带组织条件,企业管理员额外校验项目归属组织
|
||
async fn fetch_cabinet_rows_by_project(
|
||
pool: &sqlx::MySqlPool,
|
||
user: &CurrentUser,
|
||
pid: i64,
|
||
) -> Result<Vec<H5CabinetRow>, AppError> {
|
||
if user.role_level >= 2 {
|
||
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_PROJECT_SQL)
|
||
.bind(pid)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
Ok(rows)
|
||
} else if let Some(org_id) = user.organization_id {
|
||
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_PROJECT_AND_ORG_SQL)
|
||
.bind(pid)
|
||
.bind(org_id)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
Ok(rows)
|
||
} else {
|
||
Ok(vec![])
|
||
}
|
||
}
|
||
|
||
/// 总管理员分支:指定组织则按组织过滤,否则查看全部
|
||
async fn fetch_cabinet_rows_for_admin(
|
||
pool: &sqlx::MySqlPool,
|
||
org_id: Option<i64>,
|
||
) -> Result<Vec<H5CabinetRow>, AppError> {
|
||
if let Some(oid) = org_id {
|
||
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_ORG_SQL)
|
||
.bind(oid)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
Ok(rows)
|
||
} else {
|
||
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_ALL_SQL)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
Ok(rows)
|
||
}
|
||
}
|
||
|
||
/// HMGET 结果里的柜级 hex 位域取值(协议 §3.8):`None` / 空串 / 纯空白都算"无数据"
|
||
/// (→ 响应 `null`),**绝不落 `"00"`**(`"00"` 是"全部正常"的有效值,与"没数据"必须区分)。
|
||
/// 非空值**原样返回**,不解析、不改写大小写 —— 位域解析统一由前端纯函数完成。
|
||
fn pick_non_empty_hex(raw: Option<&Option<String>>) -> Option<String> {
|
||
raw.and_then(|v| v.as_ref())
|
||
.map(|v| v.trim())
|
||
.filter(|v| !v.is_empty())
|
||
.map(str::to_string)
|
||
}
|
||
|
||
/// 从 Redis 批量读取柜子实时状态(Pipeline HMGET,1 次网络往返)///
|
||
/// 注意:柜级 pipeline 只对带 IMEI 的行下发命令,结果按「行下标」对齐,
|
||
/// 与重构前的行为完全一致(这是既有约定,不做修正)。
|
||
///
|
||
/// 通道级派生计数([`CabinetRealtimeMaps::counts`])单独走**自己的一条 pipeline**,
|
||
/// 键由 DB 的「板号 + 通道号」拼出(`cabinet_tree::fetch_channel_keys`),
|
||
/// 因此不受上面那条「按行下标对齐」的历史约定影响,5 条分支 SQL 全部共用本函数。
|
||
async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> CabinetRealtimeMaps {
|
||
let mut maps = CabinetRealtimeMaps::default();
|
||
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||
let mut pipe = redis::pipe();
|
||
for r in rows {
|
||
if let Some(ref imei) = r.imei {
|
||
let key = format!("device:{imei}");
|
||
// ⚠️ 字段顺序由 CABINET_HMGET_FIELDS 唯一决定,与下面的下标解析一一对应
|
||
pipe.cmd("HMGET").arg(&key).arg(&CABINET_HMGET_FIELDS);
|
||
}
|
||
}
|
||
if !rows.is_empty() {
|
||
let results: Vec<Vec<Option<String>>> = match pipe.query_async(&mut conn).await {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
tracing::error!("[h5] Redis pipeline查询失败: {}", e);
|
||
Vec::new()
|
||
}
|
||
};
|
||
for (i, r) in rows.iter().enumerate() {
|
||
if let Some(ref imei) = r.imei {
|
||
if let Some(vals) = results.get(i) {
|
||
maps.rssi.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
||
maps.dc.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
||
maps.ac.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
||
maps.temp.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
||
// 柜级状态位:原样透出 hex;空串/缺失 → None(响应 null,不落 "00")
|
||
maps.status1.insert(imei.clone(), pick_non_empty_hex(vals.get(4)));
|
||
maps.status2.insert(imei.clone(), pick_non_empty_hex(vals.get(5)));
|
||
// 在线(下标 CABINET_HMGET_ONLINE_IDX):判据**唯一权威纯函数**,与设备列表同源
|
||
maps.online.insert(
|
||
imei.clone(),
|
||
super::cabinet_tree::device_online_flag(
|
||
vals.get(CABINET_HMGET_ONLINE_IDX)
|
||
.and_then(|v| v.as_ref())
|
||
.map(String::as_str),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 通道级:按柜派生「充满数 / 故障数」(DB 里 `compartments.status` 只写 0/1,给不出这两个)
|
||
let cabinet_ids: Vec<i64> = rows.iter().map(|r| r.id).collect();
|
||
let entries = match super::cabinet_tree::fetch_channel_keys(&state.mysql, &cabinet_ids).await {
|
||
Ok(entries) => entries,
|
||
Err(e) => {
|
||
tracing::error!("[h5] 通道 key 查询失败,充满/故障计数按 0 计 err={}", e);
|
||
Vec::new()
|
||
}
|
||
};
|
||
maps.counts = super::cabinet_tree::derive_counts_by_cabinet(state, &entries).await;
|
||
|
||
maps
|
||
}
|
||
|
||
/// 查询柜子所属组织是否禁用,返回「项目ID -> 是否禁用」映射
|
||
async fn load_org_disabled_map(
|
||
pool: &sqlx::MySqlPool,
|
||
rows: &[H5CabinetRow],
|
||
) -> HashMap<i64, bool> {
|
||
let mut org_disabled_map: HashMap<i64, bool> = HashMap::new();
|
||
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
|
||
if !project_ids.is_empty() {
|
||
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
|
||
// `{}` 处填入占位符列表,等价于原实现的 format! 拼接
|
||
let sql = PROJECT_ORG_DISABLED_SQL.replacen("{}", &placeholders.join(","), 1);
|
||
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
|
||
for pid in &project_ids {
|
||
q = q.bind(pid);
|
||
}
|
||
if let Ok(rows) = q.fetch_all(pool).await {
|
||
for (pid, disabled) in rows {
|
||
org_disabled_map.insert(pid, disabled == 1);
|
||
}
|
||
}
|
||
}
|
||
org_disabled_map
|
||
}
|
||
|
||
/// 行结构体 + 实时状态 → 响应 JSON 列表(字段名与语义与原实现逐字一致)
|
||
///
|
||
/// `full_count` / `fault_count` 的**取值来源变了**(字段名不变):不再来自 SQL
|
||
/// (`compartments.status = 2/3` 在 DB 里永远不成立),改为由通道实时状态派生
|
||
/// ([`CabinetRealtimeMaps::counts`],规则见 `cabinet_tree::derive_channel_counts`)。
|
||
/// 派生不到数据(设备离线 / 无该通道键 / Redis 不可用)→ **按 0 计,不猜测**。
|
||
fn build_cabinet_list(
|
||
rows: Vec<H5CabinetRow>,
|
||
realtime: &CabinetRealtimeMaps,
|
||
org_disabled_map: &HashMap<i64, bool>,
|
||
) -> Vec<Value> {
|
||
rows.into_iter()
|
||
.map(|r| {
|
||
let imei = r.imei.clone();
|
||
let rssi = imei.as_ref().and_then(|i| realtime.rssi.get(i).copied()).flatten();
|
||
let pow_fail_dc = imei.as_ref().and_then(|i| realtime.dc.get(i).copied()).flatten();
|
||
let pow_fail_ac = imei.as_ref().and_then(|i| realtime.ac.get(i).copied()).flatten();
|
||
let temp = imei.as_ref().and_then(|i| realtime.temp.get(i).copied()).flatten();
|
||
// 柜级状态位(协议 §3.8):原样透出 2 位 hex;取不到 → null(不落 "00")
|
||
let status1 = imei.as_ref().and_then(|i| realtime.status1.get(i).cloned()).flatten();
|
||
let status2 = imei.as_ref().and_then(|i| realtime.status2.get(i).cloned()).flatten();
|
||
// **实时在线**(Redis `device:{imei}.online`):`true` 在线 / `false` 明确离线 /
|
||
// `null` 无数据。字段名与 PC 端一致(`is_online`),小程序两端都消费它。
|
||
// ⚠️ 与 `status`(启用/禁用配置态)严格分离:`status === 2` 仍单独渲染「禁用」。
|
||
let is_online = imei.as_ref().and_then(|i| realtime.online.get(i).copied()).flatten();
|
||
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
|
||
// `ChannelCounts = (idle, charging, full, fault)`:只取派生出的充满/故障
|
||
let (_, _, full_count, fault_count) =
|
||
realtime.counts.get(&r.id).copied().unwrap_or((0, 0, 0, 0));
|
||
json!({
|
||
"id": r.id,
|
||
"abstract_id": r.abstract_id,
|
||
"name": r.name,
|
||
"imei": r.imei,
|
||
"iccid": r.iccid,
|
||
"status": r.status,
|
||
// 实时在线(Redis,判据与设备列表 `is_online` 同源);`status` 仍是配置态
|
||
"is_online": is_online,
|
||
"project_id": r.project_id,
|
||
"address": r.address,
|
||
"latitude": r.latitude,
|
||
"longitude": r.longitude,
|
||
"channel_count": r.channel_count,
|
||
"idle_count": r.idle_count,
|
||
"charging_count": r.charging_count,
|
||
"full_count": full_count,
|
||
"fault_count": fault_count,
|
||
"rssi": rssi,
|
||
"pow_fail_dc": pow_fail_dc,
|
||
"pow_fail_ac": pow_fail_ac,
|
||
"temp": temp,
|
||
// **柜级**状态位(协议 §3.8):原样 hex 字符串,取不到为 null
|
||
"status1": status1,
|
||
"status2": status2,
|
||
"org_disabled": org_disabled,
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 设备扫码(免认证)
|
||
// ---------------------------------------------------------------------------
|
||
pub async fn get_cabinet_by_abstract_id(
|
||
State(state): State<AppState>,
|
||
Path(abstract_id): Path<String>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
|
||
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?",
|
||
)
|
||
.bind(&abstract_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let (cab_id, abs_id, _imei, name, status) =
|
||
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
||
|
||
// 查询仓控板+仓体
|
||
let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>(
|
||
"SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id",
|
||
)
|
||
.bind(cab_id)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
let mut cabin_boards = Vec::with_capacity(boards.len());
|
||
for board in &boards {
|
||
let comps: Vec<H5CompartmentRow> = sqlx::query_as::<_, H5CompartmentRow>(
|
||
"SELECT id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id",
|
||
)
|
||
.bind(board.id)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
let compartments: Vec<Value> = comps
|
||
.into_iter()
|
||
.map(|c| json!({ "id": c.id, "channel_id": c.channel_id, "status": c.status }))
|
||
.collect();
|
||
|
||
cabin_boards.push(json!({
|
||
"id": board.id,
|
||
"board_id": board.board_id,
|
||
"status": board.status,
|
||
"compartments": compartments,
|
||
}));
|
||
}
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"id": cab_id,
|
||
"abstract_id": abs_id,
|
||
"name": name,
|
||
"status": status,
|
||
"cabin_boards": cabin_boards,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 仓体/通道详情
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 仓体基础信息(id, channel_id, status, cabin_board_id)
|
||
const COMPARTMENT_BY_ID_SQL: &str =
|
||
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?";
|
||
/// 仓体所属仓控板 → 柜子
|
||
const CABIN_BOARD_CABINET_ID_SQL: &str = "SELECT cabinet_id FROM cabin_boards WHERE id = ?";
|
||
/// 仓体所属仓控板号 + 柜子 IMEI(拼通道级 Redis key 用)
|
||
const CABIN_BOARD_REDIS_TARGET_SQL: &str = r#"SELECT cb.board_id, c.imei
|
||
FROM cabin_boards cb
|
||
INNER JOIN cabinets c ON c.id = cb.cabinet_id
|
||
WHERE cb.id = ?"#;
|
||
/// 仓体最近 10 条充电记录
|
||
const COMPARTMENT_CHARGE_RECORDS_SQL: &str = r#"SELECT id, start_time, end_time, energy
|
||
FROM charge_records
|
||
WHERE compartment_id = ?
|
||
ORDER BY start_time DESC LIMIT 10"#;
|
||
|
||
/// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验)
|
||
pub async fn get_compartment_detail(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
// 权限校验
|
||
auth::check_permission(&user, "device:view")?;
|
||
|
||
let pool = &state.mysql;
|
||
|
||
// 查询仓体基本信息
|
||
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(COMPARTMENT_BY_ID_SQL)
|
||
.bind(id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let (comp_id, channel_id, status, cabin_board_id) =
|
||
comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?;
|
||
|
||
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
|
||
verify_compartment_access(&user, cabin_board_id, pool).await?;
|
||
|
||
// 通道级实时数据 key 需要(IMEI, 仓控板号, 通道号);
|
||
// 关联缺失时记录并整体降级为「无数据」(字段 null),不影响 MySQL 主体数据
|
||
let redis_target: Option<(i8, String)> = sqlx::query_as(CABIN_BOARD_REDIS_TARGET_SQL)
|
||
.bind(cabin_board_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let realtime = match redis_target {
|
||
Some((board_id, imei)) => {
|
||
load_compartment_realtime(&state, &imei, board_id, channel_id).await
|
||
}
|
||
None => {
|
||
tracing::warn!(
|
||
comp_id,
|
||
cabin_board_id,
|
||
"[h5] 仓体缺少柜子/仓控板关联,实时数据按「无数据」返回"
|
||
);
|
||
CompartmentRealtime::default()
|
||
}
|
||
};
|
||
|
||
// 查询最近充电记录
|
||
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
|
||
COMPARTMENT_CHARGE_RECORDS_SQL,
|
||
)
|
||
.bind(comp_id)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
// 展示状态:**有通道实时数据时按派生值**给出 `0空闲 / 1充电中 / 2充满 / 3故障`
|
||
// (优先级 fault > full > charging > idle,与 PC/小程序三处映射口径一致);
|
||
// **拿不到数据时回落到 DB 的占用值**(0/1)—— 离线无法判定,不猜测。
|
||
// 字段名与类型不变(前端 `data.status` 直接消费),只是取值来源变了。
|
||
let flags = derive_channel_flags(realtime.batt_status.as_deref(), realtime.pow_status.as_deref());
|
||
let display_status = if flags.has_data { flags.status_code() } else { status };
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"id": comp_id,
|
||
"channel_id": channel_id,
|
||
"status": display_status,
|
||
"cabin_board_id": cabin_board_id,
|
||
// 以下实时字段全部取自设备上报真实写入的 Redis key;
|
||
// 取不到时返回 null(**不落 0**),避免把"无数据"伪装成 0
|
||
"voltage": realtime.voltage,
|
||
"current": realtime.current,
|
||
// energy 没有实时数据源(通道级 Hash 不含电量累计),如实返回 null
|
||
"energy": Value::Null,
|
||
"soc": realtime.soc,
|
||
"temp": realtime.temp,
|
||
// 电池状态 / 电源状态:两个相互独立的维度(协议 §3.8),
|
||
// 小程序 `v-if="data.batt_status != null || data.pow_status != null"` 依赖它们
|
||
"batt_status": realtime.batt_status,
|
||
"pow_status": realtime.pow_status,
|
||
// 以下 3 个字段当前**没有任何写入方**(协议未上报 BMS 单体明细),
|
||
// 保持空数组以维持既有字段类型(前端类型为 number[]/string[])
|
||
"cell_voltages": json!([]),
|
||
"cell_temps": json!([]),
|
||
"bms_alerts": json!([]),
|
||
"charge_records": map_charge_records(records),
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// 组织隔离:仓控板 → 柜子 → 项目 → 组织链路校验访问权限
|
||
async fn verify_compartment_access(
|
||
user: &CurrentUser,
|
||
cabin_board_id: i64,
|
||
pool: &sqlx::MySqlPool,
|
||
) -> Result<(), AppError> {
|
||
let cabinet_id: Option<(i64,)> = sqlx::query_as(CABIN_BOARD_CABINET_ID_SQL)
|
||
.bind(cabin_board_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
if let Some((cab_id,)) = cabinet_id {
|
||
verify_cabinet_access(user, cab_id, pool).await?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 仓体详情实时状态快照(全部来自**真实写入**的 Redis key)
|
||
///
|
||
/// 所有字段一律 `Option`:取不到就是 `None`(响应 JSON 为 `null`),**绝不落 0** ——
|
||
/// 0 是"真实读数为 0",与"无数据"必须区分(旧实现把所有缺失都回落成 0,
|
||
/// 小程序上表现为 SOC 0.0% / 0.0V / 0.0A 这种"看起来有值但其实是假的 0")。
|
||
#[derive(Default)]
|
||
struct CompartmentRealtime {
|
||
/// 电池状态(2 位 hex,协议 §3.8)
|
||
batt_status: Option<String>,
|
||
/// 电源状态(2 位 hex,与电池状态相互独立)
|
||
pow_status: Option<String>,
|
||
/// SOC,平台单位 ‰
|
||
soc: Option<i64>,
|
||
/// 充电电压,单位 mV
|
||
voltage: Option<i64>,
|
||
/// 充电电流,单位 mA
|
||
current: Option<i64>,
|
||
/// 电芯最高温度,单位 K×100
|
||
temp: Option<i64>,
|
||
}
|
||
|
||
/// 通道级 Hash 字段名(顺序即 HMGET 结果下标,与 `report_worker::write_channel_state` 的写入一致)
|
||
const COMPARTMENT_REALTIME_FIELDS: [&str; 6] =
|
||
["batt_status", "pow_status", "soc", "voltage", "current", "temp"];
|
||
|
||
/// 从 Redis 读取仓体实时数据
|
||
///
|
||
/// **数据来源(均为真实写入的 key):**
|
||
/// - 通道级 Hash `device:{imei}:board:{板号}:ch:{通道号}`:
|
||
/// `batt_status` / `pow_status` / `soc`(‰) / `voltage`(mV) / `current`(mA) / `temp`(K×100)
|
||
/// —— 由 `workers/report_worker.rs::write_channel_state` 在每次 `status_post` 时写入;
|
||
/// - `bat_in` 登记键 `device:{imei}:board:{板号}:ch:{通道号}:bat` 的 `soc`:
|
||
/// 仅当通道级 Hash 里没有 `soc`(设备长时间未上报 / Hash 过期)时作为**兜底**,
|
||
/// 它是"最近一次插电池时的起始 SOC",语义上弱于实时值。
|
||
///
|
||
/// **为什么不再读 `compartment:status:{id}`:** 该 key 在全仓**只有读取方、没有任何写入方**,
|
||
/// 取值恒为空 → 旧实现把它回落成 0,导致 SOC/电压/电流/能耗永远显示 0。
|
||
///
|
||
/// 板号/通道号为十进制不补零,与上报写入侧保持一致;任何 Redis 异常都只降级为 `None`,
|
||
/// 不让详情接口整体失败。
|
||
async fn load_compartment_realtime(
|
||
state: &AppState,
|
||
imei: &str,
|
||
board_id: i8,
|
||
channel_id: i8,
|
||
) -> CompartmentRealtime {
|
||
let client = match redis::Client::open(state.redis_url.as_str()) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
tracing::warn!("[h5] Redis 客户端创建失败,仓体实时数据降级 imei={} err={}", imei, e);
|
||
return CompartmentRealtime::default();
|
||
}
|
||
};
|
||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||
let mut conn = match client.get_multiplexed_async_connection().await {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
tracing::warn!("[h5] Redis 连接失败,仓体实时数据降级 imei={} err={}", imei, e);
|
||
return CompartmentRealtime::default();
|
||
}
|
||
};
|
||
|
||
let ch_key = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id);
|
||
|
||
// 1) 通道级 Hash:设备上报的真实实时值
|
||
let values: Result<Vec<Option<String>>, redis::RedisError> = redis::cmd("HMGET")
|
||
.arg(&ch_key)
|
||
.arg(&COMPARTMENT_REALTIME_FIELDS)
|
||
.query_async(&mut conn)
|
||
.await;
|
||
|
||
let mut rt = match values {
|
||
Ok(values) => parse_compartment_realtime(&values),
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
"[h5] 通道状态读取失败,实时数据降级 key={} err={}",
|
||
ch_key,
|
||
e
|
||
);
|
||
CompartmentRealtime::default()
|
||
}
|
||
};
|
||
|
||
// 2) SOC 兜底:通道 Hash 没值时才回读 `bat_in` 登记键(起始 SOC)
|
||
if rt.soc.is_none() {
|
||
let bat_key = format!("{}:bat", ch_key);
|
||
let registered: Result<Option<String>, redis::RedisError> = redis::cmd("HGET")
|
||
.arg(&bat_key)
|
||
.arg("soc")
|
||
.query_async(&mut conn)
|
||
.await;
|
||
match registered {
|
||
Ok(Some(soc)) => {
|
||
rt.soc = soc.trim().parse::<i64>().ok();
|
||
tracing::debug!("[h5] 通道无实时 SOC,回退 bat_in 登记值 key={}", bat_key);
|
||
}
|
||
Ok(None) => {}
|
||
Err(e) => {
|
||
tracing::warn!("[h5] bat_in 登记读取失败 key={} err={}", bat_key, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
rt
|
||
}
|
||
|
||
/// HMGET 结果(顺序同 [`COMPARTMENT_REALTIME_FIELDS`])→ 实时状态快照
|
||
///
|
||
/// 缺失项或非数字项一律 `None`(**不落 0**)。
|
||
fn parse_compartment_realtime(values: &[Option<String>]) -> CompartmentRealtime {
|
||
/// 取第 i 项的非空字符串(空白串视为"无数据",避免前端把空 hex 当有效状态渲染)
|
||
fn pick(values: &[Option<String>], i: usize) -> Option<String> {
|
||
values
|
||
.get(i)
|
||
.and_then(|v| v.as_ref())
|
||
.map(|v| v.trim().to_string())
|
||
.filter(|v| !v.is_empty())
|
||
}
|
||
/// 取第 i 项并解析为 i64(空值/非数字 → None)
|
||
fn pick_i64(values: &[Option<String>], i: usize) -> Option<i64> {
|
||
pick(values, i).and_then(|v| v.parse::<i64>().ok())
|
||
}
|
||
|
||
CompartmentRealtime {
|
||
batt_status: pick(values, 0),
|
||
pow_status: pick(values, 1),
|
||
soc: pick_i64(values, 2),
|
||
voltage: pick_i64(values, 3),
|
||
current: pick_i64(values, 4),
|
||
temp: pick_i64(values, 5),
|
||
}
|
||
}
|
||
|
||
/// 充电记录行 → 响应 JSON(`energy_kwh` / `cost` 语义不变)
|
||
fn map_charge_records(records: Vec<ChargeRecordBriefRow>) -> Vec<Value> {
|
||
records
|
||
.into_iter()
|
||
.map(|r| {
|
||
json!({
|
||
"id": r.id,
|
||
"start_time": r.start_time.map(|t| t.to_string()),
|
||
"end_time": r.end_time.map(|t| t.to_string()),
|
||
"energy_kwh": r.energy.unwrap_or_default().parse::<i64>().unwrap_or(0),
|
||
"cost": 0,
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 扫码验证
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// GET /api/h5/scan/:abstract_id — 扫码验证设备访问权限
|
||
///
|
||
/// 根据二维码中的抽象ID查找设备,验证当前用户是否有权访问。
|
||
/// 有权限返回设备ID和基本信息,无权限返回403。
|
||
pub async fn scan_cabinet(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(abstract_id): Path<String>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
// 查找设备
|
||
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
|
||
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?",
|
||
)
|
||
.bind(&abstract_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let (cab_id, abs_id, imei, name, status) =
|
||
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
||
|
||
// 验证用户是否有权限访问该设备
|
||
verify_cabinet_access(&user, cab_id, pool).await?;
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"id": cab_id,
|
||
"abstract_id": abs_id,
|
||
"imei": imei,
|
||
"name": name,
|
||
"status": status,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 充电控制
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// POST /api/h5/charge/start — 开始充电
|
||
///
|
||
/// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`,
|
||
/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。
|
||
/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。
|
||
///
|
||
/// ⚠️ **校验顺序(本 handler 的契约)**:
|
||
/// **权限 → 归属 → 设备启用 → 设备在线 → 建单 → 下发**。
|
||
/// 建单必须排在全部校验之后:否则设备离线/无权访问时接口虽然报错,订单却已经插进
|
||
/// `charge_orders`(幽灵单,只能等 10 小时超时结单,UI 上还会多出一次"充电中")。
|
||
pub async fn start_charge(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(body): Json<CompartmentActionInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "charge:start")?;
|
||
|
||
// 一次查出柜子/仓控板/通道 + 充电工作模式
|
||
// (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理)
|
||
let target: Option<(i64, String, i8, i8, Option<i8>)> = sqlx::query_as(
|
||
r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode
|
||
FROM cabinets cab
|
||
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
||
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
||
WHERE comp.id = ?"#,
|
||
)
|
||
.bind(body.compartment_id)
|
||
.fetch_optional(&state.mysql)
|
||
.await?;
|
||
|
||
// 仓体不存在:与 [`send_compartment_command`] 的报错语义一致(同一 JOIN 条件)
|
||
let (cabinet_id, imei, board_id, channel_id, mode) =
|
||
target.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
|
||
|
||
// 归属 → 设备启用 → 设备在线:全部**提前到建单之前**(顺序与 [`send_compartment_command`] 一致,
|
||
// 校验函数语义未改;下发路径仍会再校验一次,幂等无副作用)
|
||
verify_cabinet_access(&user, cabinet_id, &state.mysql).await?;
|
||
ensure_cabinet_enabled(&state.mysql, cabinet_id).await?;
|
||
ensure_device_online(&state, &imei).await?;
|
||
|
||
// 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单;
|
||
// **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单
|
||
if mode.unwrap_or(0) == 0 {
|
||
create_order_before_on(&state, &body, cabinet_id, &imei, board_id, channel_id).await;
|
||
} else {
|
||
tracing::info!(
|
||
"[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}",
|
||
body.compartment_id
|
||
);
|
||
}
|
||
|
||
send_compartment_command(&state, &user, body.compartment_id, "on").await
|
||
}
|
||
|
||
/// 平台下发 `on` **之前**建单(失败只记日志,绝不影响指令下发)。
|
||
///
|
||
/// 电池 id 与起始 SOC 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`)。
|
||
/// ⚠️ 登记键 `device:{imei}:board:{b}:ch:{c}:bat` 的 TTL 只有 48 小时
|
||
/// ([`crate::workers::report_worker`] 的 `CHANNEL_BAT_TTL_SECS`)—— 集中充电柜里电池常在仓内
|
||
/// 放置超过 48 小时,登记过期后**必须仍按"未知电池"建单**(与设备侧 `handle_device_on` 的兜底
|
||
/// 口径一致):否则整段会话没有 `charge_orders` / `charge_records` / 电量,
|
||
/// 用户在"充电记录"里看不到这次充电,属于**平台建单漏单**。
|
||
async fn create_order_before_on(
|
||
state: &AppState,
|
||
body: &CompartmentActionInput,
|
||
cabinet_id: i64,
|
||
imei: &str,
|
||
board_id: i8,
|
||
channel_id: i8,
|
||
) {
|
||
let (battery_id, start_soc) = match crate::workers::report_worker::load_channel_battery(
|
||
&state.redis,
|
||
imei,
|
||
board_id,
|
||
channel_id,
|
||
)
|
||
.await
|
||
{
|
||
Some(battery) => (battery.battery_id, Some(battery.soc)),
|
||
None => {
|
||
tracing::warn!(
|
||
"[h5] 该通道无 bat_in 登记,按未知电池建单 compartment_id={}",
|
||
body.compartment_id
|
||
);
|
||
(String::new(), None)
|
||
}
|
||
};
|
||
|
||
// `ensure_charge_order` 内部自带「按仓体进程内锁 + 条件插入 + 唯一索引」,
|
||
// 重复下发 / 双击不会多建单
|
||
if let Err(e) = crate::workers::report_worker::ensure_charge_order(
|
||
&state.mysql,
|
||
&state.redis,
|
||
imei,
|
||
cabinet_id,
|
||
board_id,
|
||
channel_id,
|
||
&battery_id,
|
||
start_soc,
|
||
)
|
||
.await
|
||
{
|
||
tracing::error!(
|
||
"[h5] 下发 on 前建单失败 compartment_id={} err={}",
|
||
body.compartment_id,
|
||
e
|
||
);
|
||
}
|
||
}
|
||
|
||
/// POST /api/h5/charge/stop — 停止充电
|
||
pub async fn stop_charge(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(body): Json<CompartmentActionInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "charge:stop")?;
|
||
send_compartment_command(&state, &user, body.compartment_id, "off").await
|
||
}
|
||
|
||
/// POST /api/h5/door/open — 开门(**柜级动作**)
|
||
///
|
||
/// 入参是仓体 ID(小程序扫码场景只知道扫到哪个仓),
|
||
/// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。
|
||
pub async fn open_door(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(body): Json<CompartmentActionInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "charge:open_door")?;
|
||
|
||
let cabinet_id: Option<i64> = sqlx::query_scalar(
|
||
r#"SELECT cb.cabinet_id FROM compartments comp
|
||
JOIN cabin_boards cb ON cb.id = comp.cabin_board_id
|
||
WHERE comp.id = ?"#,
|
||
)
|
||
.bind(body.compartment_id)
|
||
.fetch_optional(&state.mysql)
|
||
.await?;
|
||
let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
|
||
|
||
send_cabinet_command(&state, &user, cabinet_id, None, "open").await
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 辅助函数
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// --- 指令下发 SQL / Redis key(由原函数体逐字搬移,契约不变) ---
|
||
|
||
/// 仓体 → 柜子/IMEI/仓控板/通道 一次查询
|
||
const COMPARTMENT_TARGET_SQL: &str = r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
|
||
FROM cabinets cab
|
||
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
||
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
||
WHERE comp.id = ?"#;
|
||
/// 柜子ID → IMEI
|
||
const CABINET_IMEI_SQL: &str = "SELECT imei FROM cabinets WHERE id = ?";
|
||
/// 柜子是否被禁用(status = 2)
|
||
const CABINET_DISABLED_SQL: &str = "SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2";
|
||
/// 柜子所属组织是否被禁用
|
||
const CABINET_ORG_DISABLED_SQL: &str = r#"SELECT COUNT(*) > 0 FROM cabinets c
|
||
INNER JOIN projects p ON c.project_id = p.id
|
||
INNER JOIN organizations o ON p.organization_id = o.id
|
||
WHERE c.id = ? AND o.disabled = 1"#;
|
||
/// 设备指令队列 key(device-server 消费端契约,不可更改)
|
||
const DEVICE_COMMANDS_KEY: &str = "device:commands";
|
||
|
||
/// 校验柜子及其所属组织未被禁用(甲方禁用组织 → 其下柜子全部失效)
|
||
async fn ensure_cabinet_enabled(pool: &sqlx::MySqlPool, cabinet_id: i64) -> Result<(), AppError> {
|
||
// 检查设备是否被禁用
|
||
let disabled: bool = sqlx::query_scalar(CABINET_DISABLED_SQL)
|
||
.bind(cabinet_id)
|
||
.fetch_one(pool)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
if disabled {
|
||
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
|
||
}
|
||
|
||
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
|
||
let org_disabled: bool = sqlx::query_scalar(CABINET_ORG_DISABLED_SQL)
|
||
.bind(cabinet_id)
|
||
.fetch_one(pool)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
if org_disabled {
|
||
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 设备必须在线:平台对不在线的设备一律不投递
|
||
async fn ensure_device_online(state: &AppState, imei: &str) -> Result<(), AppError> {
|
||
if !state.nodes.is_device_online(imei).await {
|
||
return Err(AppError::BadRequest("设备不在线".into()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 序列化指令 → 注入内部路由字段 → LPUSH 进 `device:commands` 队列
|
||
async fn push_device_command(
|
||
state: &AppState,
|
||
cmd: &DeviceCommand,
|
||
imei: &str,
|
||
) -> Result<(), AppError> {
|
||
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
|
||
let mut json_val =
|
||
serde_json::to_value(cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
|
||
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
|
||
let json_str = json_val.to_string();
|
||
let mut conn = state.redis.clone();
|
||
redis::cmd("LPUSH")
|
||
.arg(DEVICE_COMMANDS_KEY)
|
||
.arg(&json_str)
|
||
.query_async::<()>(&mut conn)
|
||
.await
|
||
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 指令下发成功响应(返回码与文案与原实现一致)
|
||
fn command_dispatched_json() -> Json<Value> {
|
||
Json(json!({
|
||
"code": 0,
|
||
"data": { "success": true },
|
||
"message": "指令已下发"
|
||
}))
|
||
}
|
||
|
||
/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。
|
||
///
|
||
/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。
|
||
/// 柜级指令(`open` / `pow_on` / `pow_off` / `reset`)请用 [`send_cabinet_command`]。
|
||
async fn send_compartment_command(
|
||
state: &AppState,
|
||
user: &CurrentUser,
|
||
compartment_id: i64,
|
||
action: &str,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
// 一次查询拿到 cabinet_id + IMEI + board_id + channel_id
|
||
let cab_row = load_compartment_target(pool, compartment_id).await?;
|
||
|
||
// 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致)
|
||
ensure_cabinet_enabled(pool, cab_row.cabinet_id).await?;
|
||
verify_cabinet_access(user, cab_row.cabinet_id, pool).await?;
|
||
ensure_device_online(state, &cab_row.imei).await?;
|
||
|
||
let imei = &cab_row.imei;
|
||
|
||
// 构造协议 id:
|
||
// 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零)
|
||
// 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定
|
||
let id = format!("{:02}-{}", cab_row.board_id, cab_row.channel_id);
|
||
|
||
let cmd = DeviceCommand {
|
||
act: action.to_string(),
|
||
msg_id: 0,
|
||
id: Some(id.clone()),
|
||
params: Some(json!({})),
|
||
};
|
||
|
||
push_device_command(state, &cmd, imei).await?;
|
||
|
||
tracing::info!(
|
||
"[h5] 指令已推入队列 id={} action={} compartment_id={}",
|
||
id, action, compartment_id
|
||
);
|
||
|
||
Ok(command_dispatched_json())
|
||
}
|
||
|
||
/// 按仓体ID查询目标设备与通道信息
|
||
async fn load_compartment_target(
|
||
pool: &sqlx::MySqlPool,
|
||
compartment_id: i64,
|
||
) -> Result<CabinetImeiRow, AppError> {
|
||
let row: Option<CabinetImeiRow> = sqlx::query_as(COMPARTMENT_TARGET_SQL)
|
||
.bind(compartment_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 柜级 / 单仓控板级指令(pow_on / pow_off / reset)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 柜级 / 单仓控板级指令下发。
|
||
///
|
||
/// - `board_id = None` → **柜级**:协议**不带 `id`**(省略该字段)
|
||
/// - `board_id = Some(n)` → **单仓控板**:协议 `id = "{仓控板:02}"`(如 `"01"`)
|
||
///
|
||
/// 适用于 `open` / `pow_on` / `pow_off` / `reset`;`on` / `off` 走 [`send_compartment_command`]。
|
||
async fn send_cabinet_command(
|
||
state: &AppState,
|
||
user: &CurrentUser,
|
||
cabinet_id: i64,
|
||
board_id: Option<i8>,
|
||
action: &str,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
let imei = load_cabinet_imei(pool, cabinet_id).await?;
|
||
|
||
// 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致)
|
||
ensure_cabinet_enabled(pool, cabinet_id).await?;
|
||
verify_cabinet_access(user, cabinet_id, pool).await?;
|
||
ensure_device_online(state, &imei).await?;
|
||
|
||
// 构造协议 id:
|
||
// - `board_id = None` → **柜级**:**不带 id**(省略该字段)
|
||
// - `board_id = Some(n)` → **单仓控板**:`仓控板`(2 位补零,如 "01")
|
||
let id = board_id.map(|b| format!("{:02}", b));
|
||
|
||
let cmd = DeviceCommand {
|
||
act: action.to_string(),
|
||
msg_id: 0,
|
||
id: id.clone(),
|
||
params: Some(json!({})),
|
||
};
|
||
|
||
push_device_command(state, &cmd, &imei).await?;
|
||
|
||
tracing::info!(
|
||
"[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}",
|
||
id,
|
||
action,
|
||
cabinet_id,
|
||
board_id
|
||
);
|
||
|
||
Ok(command_dispatched_json())
|
||
}
|
||
|
||
/// 按柜子ID查询 IMEI
|
||
async fn load_cabinet_imei(pool: &sqlx::MySqlPool, cabinet_id: i64) -> Result<String, AppError> {
|
||
let imei: Option<String> = sqlx::query_scalar(CABINET_IMEI_SQL)
|
||
.bind(cabinet_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/open — **解锁柜门**(柜级)
|
||
///
|
||
/// 柜门是**整柜共用、只有一扇**,所以这是柜级动作(协议 `id` 省略)。
|
||
/// WEB 管理端设备详情页用这个;小程序扫码场景用 `POST /api/h5/door/open`(入参为仓体 ID)。
|
||
pub async fn cabinet_open(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "charge:open_door")?;
|
||
send_cabinet_command(&state, &user, id, None, "open").await
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/pow-on — 接触器**合闸**(柜级)
|
||
pub async fn cabinet_pow_on(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
send_cabinet_command(&state, &user, id, None, "pow_on").await
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/pow-off — 接触器**分闸**(柜级)
|
||
///
|
||
/// ⚠️ 分闸后**仓控板随之下电**:该柜仓体/电池数据不可用,设备会上报 `pow_fail_dc`;
|
||
/// 恢复供电须再下发 `pow-on`。
|
||
pub async fn cabinet_pow_off(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
send_cabinet_command(&state, &user, id, None, "pow_off").await
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/reset — **重启整柜**(柜级,协议 `id = IMEI`)
|
||
pub async fn cabinet_reset(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
send_cabinet_command(&state, &user, id, None, "reset").await
|
||
}
|
||
|
||
/// POST /api/cabin-boards/:id/reset — **重启单块仓控板**
|
||
/// (协议 `id = IMEI-仓控板号`,两段)
|
||
pub async fn board_reset(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(board_pk): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
|
||
let row: Option<(i64, i8)> =
|
||
sqlx::query_as("SELECT cabinet_id, board_id FROM cabin_boards WHERE id = ?")
|
||
.bind(board_pk)
|
||
.fetch_optional(&state.mysql)
|
||
.await?;
|
||
let (cabinet_id, board_id) =
|
||
row.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?;
|
||
|
||
send_cabinet_command(&state, &user, cabinet_id, Some(board_id), "reset").await
|
||
}
|
||
|
||
/// POST /api/feedback — 用户问题反馈提交(任何已登录用户可提交)
|
||
pub async fn submit_feedback(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(body): Json<FeedbackInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
let content = body.content.trim();
|
||
if content.is_empty() {
|
||
return Err(AppError::BadRequest("反馈内容不能为空".into()));
|
||
}
|
||
if content.chars().count() > 2000 {
|
||
return Err(AppError::BadRequest("反馈内容过长(最多2000字)".into()));
|
||
}
|
||
let contact = body.contact.as_ref().map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
|
||
if let Some(c) = &contact {
|
||
if c.chars().count() > 200 {
|
||
return Err(AppError::BadRequest("联系方式过长".into()));
|
||
}
|
||
}
|
||
|
||
// 图片校验:最多 6 张
|
||
let images = body.images.clone().unwrap_or_default();
|
||
let images: Vec<String> = images
|
||
.iter()
|
||
.map(|u| u.trim().to_string())
|
||
.filter(|u| !u.is_empty())
|
||
.collect();
|
||
if images.len() > 6 {
|
||
return Err(AppError::BadRequest("最多上传6张图片".into()));
|
||
}
|
||
let images_json = if images.is_empty() {
|
||
String::from("[]")
|
||
} else {
|
||
serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string())
|
||
};
|
||
|
||
sqlx::query(
|
||
"INSERT INTO user_feedback (user_id, content, contact, images) VALUES (?, ?, ?, ?)"
|
||
)
|
||
.bind(user.user_id)
|
||
.bind(content)
|
||
.bind(contact)
|
||
.bind(&images_json)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
|
||
tracing::info!(
|
||
"[h5] 问题反馈已提交 user_id={} content_len={}",
|
||
user.user_id, content.chars().count()
|
||
);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "success": true },
|
||
"message": "反馈已提交,感谢您的反馈"
|
||
})))
|
||
}
|
||
|
||
/// GET /api/feedback — 查询反馈列表(分页,管理员使用)
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct FeedbackListQuery {
|
||
page: Option<i64>,
|
||
page_size: Option<i64>,
|
||
/// 处理状态筛选:0=待处理 1=已处理;缺省/不传 = 全部。
|
||
/// 必须显式声明,否则 serde 会**静默丢弃**前端传来的 `status`(前端筛选点了没反应)。
|
||
status: Option<i8>,
|
||
}
|
||
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct FeedbackRow {
|
||
id: i64,
|
||
user_id: i64,
|
||
content: String,
|
||
contact: Option<String>,
|
||
images: Option<String>,
|
||
status: i8,
|
||
created_at: chrono::NaiveDateTime,
|
||
}
|
||
|
||
/// 反馈列表的 WHERE 子句与**文本**绑定值
|
||
///
|
||
/// - 组织隔离条件(文本)在 SQL 中**先于** `status` ⇒ 调用方必须先绑文本值、
|
||
/// 最后单独绑 `status`(整型);
|
||
/// - 工厂(`role_level >= 2`)不加组织条件(全量);
|
||
/// - 客户侧无组织 → `1=0` 空集。
|
||
fn feedback_filter(user: &CurrentUser, has_status: bool) -> (String, Vec<String>) {
|
||
let mut conditions: Vec<String> = Vec::new();
|
||
let mut binds: Vec<String> = Vec::new();
|
||
|
||
// 归属:反馈提交人必须属于当前用户所在组织(`user_feedback.user_id → users.organization_id`)
|
||
if user.role_level < auth::SUPER_ADMIN_ROLE_LEVEL {
|
||
match user.organization_id {
|
||
Some(org_id) => {
|
||
conditions.push(
|
||
"EXISTS (SELECT 1 FROM users u \
|
||
WHERE u.id = user_feedback.user_id AND u.organization_id = ?)"
|
||
.to_string(),
|
||
);
|
||
binds.push(org_id.to_string());
|
||
}
|
||
None => conditions.push("1=0".to_string()),
|
||
}
|
||
}
|
||
if has_status {
|
||
conditions.push("status = ?".to_string());
|
||
}
|
||
|
||
if conditions.is_empty() {
|
||
(String::new(), binds)
|
||
} else {
|
||
(format!(" WHERE {}", conditions.join(" AND ")), binds)
|
||
}
|
||
}
|
||
|
||
/// 校验当前用户是否有权处置(查看状态/改状态)某条反馈
|
||
///
|
||
/// 归属口径与 [`feedback_filter`] 完全一致:工厂豁免;客户侧必须是**本组织成员**提交的反馈。
|
||
/// 反馈不存在 → `NotFound`(保持原有 404 语义);存在但不属于本组织 → `Forbidden`
|
||
/// (**不返回 404**,避免"改别人的反馈"被静默当成"已处理成功")。
|
||
async fn verify_feedback_ownership(
|
||
pool: &sqlx::MySqlPool,
|
||
user: &CurrentUser,
|
||
feedback_id: i64,
|
||
) -> Result<(), AppError> {
|
||
if user.role_level >= auth::SUPER_ADMIN_ROLE_LEVEL {
|
||
return Ok(());
|
||
}
|
||
let Some(org_id) = user.organization_id else {
|
||
return Err(AppError::Forbidden("无权访问该资源".into()));
|
||
};
|
||
|
||
let owner_org: Option<Option<i64>> = sqlx::query_scalar(
|
||
"SELECT u.organization_id FROM user_feedback f \
|
||
JOIN users u ON u.id = f.user_id WHERE f.id = ?",
|
||
)
|
||
.bind(feedback_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
match owner_org {
|
||
None => Err(AppError::NotFound("反馈记录不存在".into())),
|
||
Some(Some(org)) if org == org_id => Ok(()),
|
||
_ => Err(AppError::Forbidden("无权访问该资源".into())),
|
||
}
|
||
}
|
||
|
||
/// GET /api/h5/feedback — 反馈列表(管理端)
|
||
///
|
||
/// 权限:反馈内容里带全部用户的**联系方式**,属于管理端审计类数据,
|
||
/// 因此不能只校验登录态 —— 复用权限种子里真实存在的 `operation_log:view`
|
||
/// (`db/migrate.rs::seed_roles`:总管理员全量、企业管理员含该码、操作员**没有**该码),
|
||
/// 语义上「查看管理端记录/日志」与「查看用户反馈」最贴近;
|
||
/// 操作员即使登录也会被 403 拦住。
|
||
///
|
||
/// **组织隔离(本次修复)**:反馈按**提交人**归属 ——
|
||
/// `user_feedback.user_id → users.organization_id`(与操作日志同一口径)。
|
||
/// 历史缺陷:本接口只校验了 `operation_log:view` 就全量查询 ⇒ 任何**企业管理员**
|
||
/// 都能读到其它客户组织的反馈(含联系方式)并改其处理状态。
|
||
/// 规则:工厂(`role_level >= 2`)全量;客户侧仅本组织成员提交的反馈;
|
||
/// `organization_id` 为 `NULL` → 空集(**绝不退化成全量**)。
|
||
/// 数据范围(`user_scopes`)**不叠加**:反馈与项目/设备没有关联列,项目级范围无法映射
|
||
/// (与操作日志完全同一口径,见 AGENTS.md「数据范围」一节的说明)。
|
||
///
|
||
/// `status` 为可选筛选条件,落到 SQL(不影响未传参时的原有查询语义)。
|
||
pub async fn list_feedback(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Query(query): Query<FeedbackListQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "operation_log:view")?;
|
||
|
||
let pool = &state.mysql;
|
||
let page = query.page.unwrap_or(1).max(1);
|
||
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
|
||
let offset = (page - 1) * page_size;
|
||
|
||
let status = query.status;
|
||
// 状态值口径与 update_feedback_status 保持一致(0待处理 / 1已处理)
|
||
if let Some(s) = status {
|
||
if s != 0 && s != 1 {
|
||
return Err(AppError::BadRequest("状态值必须为0(待处理)/1(已处理)".into()));
|
||
}
|
||
}
|
||
|
||
// 条件与绑定值一一对应:**文本条件(组织隔离)在前、`status`(整型)在后**,
|
||
// 因此绑定顺序同样是"先文本占位、后 status"(见下面的 count/list 绑定)
|
||
let (filter, org_binds) = feedback_filter(&user, status.is_some());
|
||
let mut binds = org_binds;
|
||
|
||
let count_sql = format!("SELECT COUNT(*) FROM user_feedback{}", filter);
|
||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||
for bind in &binds {
|
||
count_query = count_query.bind(bind);
|
||
}
|
||
if let Some(s) = status {
|
||
count_query = count_query.bind(s);
|
||
}
|
||
let total: i64 = count_query.fetch_one(pool).await?;
|
||
|
||
let list_sql = format!(
|
||
"SELECT id, user_id, content, contact, images, status, created_at
|
||
FROM user_feedback{} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||
filter
|
||
);
|
||
let mut list_query = sqlx::query_as::<_, FeedbackRow>(&list_sql);
|
||
for bind in binds.drain(..) {
|
||
list_query = list_query.bind(bind);
|
||
}
|
||
if let Some(s) = status {
|
||
list_query = list_query.bind(s);
|
||
}
|
||
let rows: Vec<FeedbackRow> = list_query
|
||
.bind(page_size)
|
||
.bind(offset)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
let list: Vec<Value> = rows.into_iter().map(|r| {
|
||
let images: Vec<String> = r.images
|
||
.as_deref()
|
||
.and_then(|s| serde_json::from_str(s).ok())
|
||
.unwrap_or_default();
|
||
json!({
|
||
"id": r.id,
|
||
"user_id": r.user_id,
|
||
"content": r.content,
|
||
"contact": r.contact,
|
||
"images": images,
|
||
"status": r.status,
|
||
"status_text": if r.status == 0 { "待处理" } else { "已处理" },
|
||
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||
})
|
||
}).collect();
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "list": list, "total": total, "page": page, "page_size": page_size },
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// PUT /api/feedback/{id} — 更新反馈状态(0待处理 → 1已处理)
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct FeedbackStatusInput {
|
||
status: i8,
|
||
}
|
||
|
||
const FEEDBACK_IMG_DIR: &str = "uploads/feedback";
|
||
|
||
pub async fn upload_feedback_image(
|
||
user: CurrentUser,
|
||
mut multipart: Multipart,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let mut file_data: Option<Vec<u8>> = None;
|
||
let mut ext = "".to_string();
|
||
|
||
while let Some(field) = multipart.next_field().await
|
||
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
|
||
let filename = field.file_name().unwrap_or("").to_string();
|
||
let data = field.bytes().await
|
||
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
|
||
if let Some(pos) = filename.rfind('.') {
|
||
ext = filename[pos..].to_string();
|
||
}
|
||
file_data = Some(data.to_vec());
|
||
}
|
||
|
||
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
|
||
if data.is_empty() {
|
||
return Err(AppError::BadRequest("上传内容为空".into()));
|
||
}
|
||
|
||
if data.len() > 5 * 1024 * 1024 {
|
||
return Err(AppError::BadRequest("图片文件不能超过 5MB".into()));
|
||
}
|
||
|
||
if ext.is_empty() {
|
||
ext = ".jpg".to_string();
|
||
}
|
||
// 扩展名白名单 + 文件魔数嗅探(拒绝 .svg/.htm 等可脚本化类型,防存储型 XSS)
|
||
// 返回值是按内容嗅探出的规范扩展名;落盘后缀与真实内容一致
|
||
let ext = super::upload_guard::validate_image_upload(&data, &ext)?;
|
||
|
||
let dir = std::path::Path::new(FEEDBACK_IMG_DIR);
|
||
std::fs::create_dir_all(dir)
|
||
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
|
||
|
||
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
|
||
let filepath = dir.join(&filename);
|
||
let mut f = std::fs::File::create(&filepath)
|
||
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
|
||
f.write_all(&data)
|
||
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
|
||
|
||
let url = format!("/uploads/feedback/{}", filename);
|
||
tracing::info!(
|
||
user_id = user.user_id, path = %url,
|
||
"反馈图片上传成功"
|
||
);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "url": url },
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
pub async fn update_feedback_status(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
Json(body): Json<FeedbackStatusInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
// 与 list_feedback 同一权限码:能改处理状态的人必须能看反馈列表
|
||
// (`operation_log:view` 在 `db/migrate.rs` 的权限种子里真实存在)
|
||
auth::check_permission(&user, "operation_log:view")?;
|
||
|
||
let pool = &state.mysql;
|
||
|
||
if body.status != 0 && body.status != 1 {
|
||
return Err(AppError::BadRequest("状态值不合法".into()));
|
||
}
|
||
|
||
// 归属校验:客户侧只能处置本组织成员提交的反馈(工厂豁免)。
|
||
// 历史缺陷:原先只校验权限码就直接 UPDATE ⇒ 企业管理员能改别的组织的反馈状态。
|
||
verify_feedback_ownership(pool, &user, id).await?;
|
||
|
||
let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?")
|
||
.bind(body.status)
|
||
.bind(id)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(AppError::Database)?
|
||
.rows_affected();
|
||
|
||
if affected == 0 {
|
||
return Err(AppError::NotFound("反馈记录不存在".into()));
|
||
}
|
||
|
||
tracing::info!(
|
||
"[h5] 反馈状态已更新 operator={} feedback_id={} status={}",
|
||
user.user_id, id, body.status
|
||
);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "success": true },
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// 验证用户是否有权访问指定柜子(组织数据隔离 + 数据范围)
|
||
///
|
||
/// 总管理员(role_level >= 2)可访问所有柜子(数据范围同样豁免);
|
||
/// 企业管理员只能访问本组织下项目关联的柜子,**且柜子所属项目必须在其数据范围内**;
|
||
/// 无组织关联的用户无权访问任何柜子。
|
||
async fn verify_cabinet_access(
|
||
user: &CurrentUser,
|
||
cabinet_id: i64,
|
||
pool: &sqlx::MySqlPool,
|
||
) -> Result<(), AppError> {
|
||
// 总管理员无需校验
|
||
if user.role_level >= 2 {
|
||
return Ok(());
|
||
}
|
||
|
||
let org_id = user
|
||
.organization_id
|
||
.ok_or_else(|| AppError::Forbidden("无组织关联,无权访问设备".into()))?;
|
||
|
||
// 通过 cabinet → project → organization 链路验证归属,并叠加数据范围(项目级)
|
||
let (scope_cond, scope_binds) = user.effective_scope().project_condition("p.id");
|
||
let sql = format!(
|
||
r#"SELECT cab.id FROM cabinets cab
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE cab.id = ? AND p.organization_id = ?{}"#,
|
||
scope_cond
|
||
);
|
||
let mut query = sqlx::query_as::<_, (i64,)>(&sql).bind(cabinet_id).bind(org_id);
|
||
for bind in &scope_binds {
|
||
query = query.bind(bind);
|
||
}
|
||
let access: Option<(i64,)> = query.fetch_optional(pool).await?;
|
||
|
||
if access.is_none() {
|
||
return Err(AppError::Forbidden("无权访问该设备".into()));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 单元测试
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// 小程序设备列表的柜级 hex 位域取值(协议 §3.8):
|
||
/// `"00"`(全正常)必须原样保留;缺失 / 空串 / 纯空白必须是 `None`(响应 `null`)。
|
||
#[test]
|
||
fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() { assert_eq!(
|
||
pick_non_empty_hex(Some(&Some("00".into()))).as_deref(),
|
||
Some("00"),
|
||
"\"00\" 是「全部正常」的有效值,与「无数据」必须区分"
|
||
);
|
||
assert_eq!(
|
||
pick_non_empty_hex(Some(&Some("10".into()))).as_deref(),
|
||
Some("10"),
|
||
"柜门打开位原样透出"
|
||
);
|
||
assert_eq!(pick_non_empty_hex(Some(&Some("01".into()))).as_deref(), Some("01"));
|
||
assert_eq!(pick_non_empty_hex(Some(&Some(String::new()))), None);
|
||
assert_eq!(pick_non_empty_hex(Some(&Some(" ".into()))), None);
|
||
assert_eq!(pick_non_empty_hex(Some(&None)), None);
|
||
assert_eq!(pick_non_empty_hex(None), None);
|
||
}
|
||
|
||
/// 通道级 HMGET 六字段(batt_status/pow_status/soc/voltage/current/temp)解析
|
||
#[test]
|
||
fn test_parse_compartment_realtime_reads_all_fields() {
|
||
let values: Vec<Option<String>> = vec![
|
||
Some("82".into()),
|
||
Some("03".into()),
|
||
Some("855".into()),
|
||
Some("53200".into()),
|
||
Some("1500".into()),
|
||
Some("29815".into()),
|
||
];
|
||
let rt = parse_compartment_realtime(&values);
|
||
|
||
assert_eq!(rt.batt_status.as_deref(), Some("82"));
|
||
assert_eq!(rt.pow_status.as_deref(), Some("03"));
|
||
assert_eq!(rt.soc, Some(855));
|
||
assert_eq!(rt.voltage, Some(53200));
|
||
assert_eq!(rt.current, Some(1500));
|
||
assert_eq!(rt.temp, Some(29815));
|
||
}
|
||
|
||
/// 无数据(key 不存在 → 全是 None)时必须返回 None,而不是 0
|
||
/// (这是 ② 的核心回归点:旧实现把"无数据"回落成 0.0% / 0.0V / 0.0A)
|
||
#[test]
|
||
fn test_parse_compartment_realtime_no_data_returns_none_not_zero() {
|
||
let values: Vec<Option<String>> = vec![None, None, None, None, None, None];
|
||
let rt = parse_compartment_realtime(&values);
|
||
|
||
assert_eq!(rt.soc, None, "无数据不能落 0");
|
||
assert_eq!(rt.voltage, None, "无数据不能落 0");
|
||
assert_eq!(rt.current, None, "无数据不能落 0");
|
||
assert_eq!(rt.temp, None);
|
||
assert!(rt.batt_status.is_none());
|
||
assert!(rt.pow_status.is_none());
|
||
}
|
||
|
||
/// 真实的 0 必须原样保留(0 与"无数据"不能混为一谈)
|
||
#[test]
|
||
fn test_parse_compartment_realtime_real_zero_is_kept() {
|
||
let values: Vec<Option<String>> = vec![
|
||
Some("00".into()),
|
||
Some("00".into()),
|
||
Some("0".into()),
|
||
Some("0".into()),
|
||
Some("0".into()),
|
||
Some("0".into()),
|
||
];
|
||
let rt = parse_compartment_realtime(&values);
|
||
|
||
assert_eq!(rt.soc, Some(0));
|
||
assert_eq!(rt.voltage, Some(0));
|
||
assert_eq!(rt.current, Some(0));
|
||
assert_eq!(rt.temp, Some(0));
|
||
assert_eq!(rt.batt_status.as_deref(), Some("00"));
|
||
}
|
||
|
||
/// 非数字垃圾值/结果长度不足时按无数据处理,绝不 panic、绝不落 0
|
||
#[test]
|
||
fn test_parse_compartment_realtime_garbage_is_none() {
|
||
let values: Vec<Option<String>> = vec![Some("abc".into()), Some(" ".into())];
|
||
let rt = parse_compartment_realtime(&values);
|
||
|
||
assert_eq!(rt.soc, None);
|
||
assert_eq!(rt.voltage, None);
|
||
assert_eq!(rt.current, None);
|
||
assert_eq!(rt.temp, None);
|
||
// batt_status / pow_status 是 hex 字符串,原样透传(不在这里解释位含义)
|
||
assert_eq!(rt.batt_status.as_deref(), Some("abc"));
|
||
}
|
||
|
||
/// HMGET 字段名顺序必须与解析下标一致(防止将来改字段顺序导致错位)
|
||
#[test]
|
||
fn test_compartment_realtime_fields_order_matches_indexes() {
|
||
assert_eq!(COMPARTMENT_REALTIME_FIELDS[0], "batt_status");
|
||
assert_eq!(COMPARTMENT_REALTIME_FIELDS[1], "pow_status");
|
||
assert_eq!(COMPARTMENT_REALTIME_FIELDS[2], "soc");
|
||
assert_eq!(COMPARTMENT_REALTIME_FIELDS[3], "voltage");
|
||
assert_eq!(COMPARTMENT_REALTIME_FIELDS[4], "current");
|
||
assert_eq!(COMPARTMENT_REALTIME_FIELDS[5], "temp");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 可见组织范围(组织隔离)
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// 工厂/总管理员:可见全部组织(即便自身也挂着 organization_id,也不降级为本组织)
|
||
#[test]
|
||
fn test_visible_org_scope_super_admin_sees_all() {
|
||
assert_eq!(visible_org_scope(2, None), VisibleOrgScope::All);
|
||
assert_eq!(visible_org_scope(2, Some(7)), VisibleOrgScope::All);
|
||
}
|
||
|
||
/// 操作员(role_level=0):只可见自己所属组织
|
||
#[test]
|
||
fn test_visible_org_scope_operator_sees_own_org_only() {
|
||
assert_eq!(visible_org_scope(0, Some(42)), VisibleOrgScope::Own(42));
|
||
}
|
||
|
||
/// 组织管理员(role_level=1):同样只可见自己所属组织(不允许跨组织)
|
||
#[test]
|
||
fn test_visible_org_scope_org_admin_sees_own_org_only() {
|
||
assert_eq!(visible_org_scope(1, Some(42)), VisibleOrgScope::Own(42));
|
||
}
|
||
|
||
/// 客户侧无组织归属:空集(**不能退化成"全部"**,否则越权)
|
||
#[test]
|
||
fn test_visible_org_scope_org_user_without_org_is_empty() {
|
||
assert_eq!(visible_org_scope(0, None), VisibleOrgScope::Empty);
|
||
assert_eq!(visible_org_scope(1, None), VisibleOrgScope::Empty);
|
||
}
|
||
|
||
/// 客户侧 SQL 必须带组织过滤条件(防止将来被改成全量查询导致越权)
|
||
#[test]
|
||
fn test_visible_orgs_own_sql_filters_by_id() {
|
||
assert!(
|
||
VISIBLE_ORGS_BY_ID_SQL.contains("WHERE id = ?"),
|
||
"客户侧可见组织查询必须按 organization_id 过滤"
|
||
);
|
||
assert_eq!(visible_org_scope(0, Some(1)), VisibleOrgScope::Own(1));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 实时在线(`is_online`)守门 —— 防回归:不得再拿 `cabinets.status` 当"在线"
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// 小程序列表的在线取数契约:
|
||
/// ① 在线字段**恰好出现一次**且位于清单末尾(下标常量与清单同步);
|
||
/// ② 字段名与设备列表 `is_online` **逐字一致**(跨模块同源守门)。
|
||
#[test]
|
||
fn test_h5_list_online_field_matches_device_list_criterion() {
|
||
let online_field = crate::routes::cabinet_tree::DEVICE_ONLINE_FIELD;
|
||
assert_eq!(
|
||
CABINET_HMGET_ONLINE_IDX,
|
||
CABINET_HMGET_FIELDS.len() - 1,
|
||
"在线字段必须追加在字段清单末尾(前面的下标解析依赖顺序)"
|
||
);
|
||
assert_eq!(
|
||
CABINET_HMGET_FIELDS[CABINET_HMGET_ONLINE_IDX], online_field,
|
||
"小程序列表在线取数字段必须与设备列表同源"
|
||
);
|
||
assert_eq!(
|
||
CABINET_HMGET_FIELDS.iter().filter(|f| **f == online_field).count(),
|
||
1,
|
||
"在线字段只允许出现一次"
|
||
);
|
||
// 三态:无数据(None)不是"离线"(false),小程序据此可以区分"未知"
|
||
assert_eq!(crate::routes::cabinet_tree::device_online_flag(None), None);
|
||
assert_eq!(
|
||
crate::routes::cabinet_tree::device_online_flag(Some("1")),
|
||
Some(true)
|
||
);
|
||
assert_eq!(
|
||
crate::routes::cabinet_tree::device_online_flag(Some("0")),
|
||
Some(false)
|
||
);
|
||
}
|
||
|
||
/// 首页「在线设备」的取数 SQL **不得依赖 `cabinets.status`**,
|
||
/// 且必须保留组织隔离:总管理员无组织条件、客户侧必须带 `p.organization_id = ?`。
|
||
#[test]
|
||
fn test_h5_dashboard_online_sql_has_no_status_and_keeps_org_isolation() {
|
||
let super_sql = online_cabinet_imeis_sql(true, "");
|
||
assert!(
|
||
!super_sql.to_lowercase().contains("status"),
|
||
"在线统计不得引用 cabinets.status:{}",
|
||
super_sql
|
||
);
|
||
assert!(super_sql.starts_with("SELECT cab.imei FROM cabinets cab"));
|
||
assert!(super_sql.contains("cab.imei IS NOT NULL"), "必须排除 NULL IMEI:{}", super_sql);
|
||
|
||
let org_sql = online_cabinet_imeis_sql(false, " AND p.id IN (?,?)");
|
||
assert!(
|
||
!org_sql.to_lowercase().contains("status"),
|
||
"在线统计不得引用 cabinets.status:{}",
|
||
org_sql
|
||
);
|
||
assert!(
|
||
org_sql.contains("INNER JOIN projects p ON cab.project_id = p.id"),
|
||
"客户侧必须经 projects 做组织隔离:{}",
|
||
org_sql
|
||
);
|
||
assert!(org_sql.contains("p.organization_id = ?"), "组织隔离条件丢失:{}", org_sql);
|
||
assert!(org_sql.contains("AND p.id IN (?,?)"), "数据范围条件丢失:{}", org_sql);
|
||
}
|
||
}
|