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

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

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

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

2585 lines
103 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! 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);
}
}