976 lines
35 KiB
Rust
Raw Normal View History

//! 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 — 扫码验证
use axum::extract::{Path, Query, State};
use axum::Json;
use redis;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::commands::{AppState, DeviceCommand};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
// ---------------------------------------------------------------------------
// 请求体定义
// ---------------------------------------------------------------------------
/// 充电控制请求体(开始/停止充电、开门共用)
#[derive(Debug, Deserialize)]
pub(crate) struct CompartmentActionInput {
compartment_id: i64,
}
/// 设备列表查询参数
#[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,
}
/// 柜子列表行(含通道统计)
#[derive(Debug, sqlx::FromRow)]
struct H5CabinetRow {
id: i64,
abstract_id: String,
name: Option<String>,
status: i8,
project_id: Option<i64>,
imei: Option<String>,
channel_count: i64,
idle_count: i64,
charging_count: i64,
full_count: i64,
fault_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 {
imei: String,
}
// ---------------------------------------------------------------------------
// 仪表盘
// ---------------------------------------------------------------------------
/// 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 online_cabinets = count_online_cabinets(pool, is_super, org_id).await?;
// 统计总通道数(带组织隔离)
let total_channels = count_total_compartments(pool, is_super, org_id).await?;
// 统计充电中仓体数(带组织隔离)
let charging_count = count_compartments_by_status(pool, is_super, org_id, 1).await?;
// 统计空闲仓体数(带组织隔离)
let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0).await?;
// 统计故障仓体数(带组织隔离)
let fault_channels = count_compartments_by_status(pool, is_super, org_id, 2).await?;
// 统计充满仓体数(status=2,带组织隔离)
let full_count = count_compartments_by_status(pool, is_super, org_id, 2).await?;
// 统计故障数(status=3,带组织隔离)
let fault_count = count_compartments_by_status(pool, is_super, org_id, 3).await?;
// 今日充电次数(带组织隔离)
let today_charge_count = count_today_charges(pool, is_super, org_id).await?;
// 今日充电量(kWh,带组织隔离)
let today_energy = sum_today_energy(pool, is_super, org_id).await?;
// 今日充电时长(小时,带组织隔离)
let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id).await?;
// 故障告警(取最近10条故障仓体,返回项目名+柜名)
let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
// 项目摘要
let projects = fetch_project_summaries(pool, &user).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"
})))
}
/// 统计在线柜子数(带组织隔离)
async fn count_online_cabinets(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> 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 cabinets WHERE status = 1")
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.status = 1 AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 统计总仓体数(带组织隔离)
async fn count_total_compartments(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> 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 {
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
JOIN projects p ON c.project_id = p.id
WHERE c.status = 1 AND p.organization_id = ?"#,
)
.bind(org_id.unwrap())
.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,
) -> 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 {
sqlx::query_scalar(
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 = ?"#,
)
.bind(status)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 今日充电次数(带组织隔离)
async fn count_today_charges(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> 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 {
sqlx::query_scalar(
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 = ?"#,
)
.bind(org_id.unwrap())
.fetch_one(pool)
.await?
};
Ok(count)
}
/// 今日充电量(kWh,带组织隔离)
async fn sum_today_energy(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> 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 {
sqlx::query_scalar(
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 = ?"#,
)
.bind(org_id.unwrap())
.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>,
) -> 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 {
sqlx::query_scalar(
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 = ?"#,
)
.bind(org_id.unwrap())
.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>,
) -> 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"#
} else {
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"#
};
let rows: Vec<(String, String, i8)> = if is_super {
sqlx::query_as(base_sql).fetch_all(pool).await?
} else {
sqlx::query_as(base_sql)
.bind(org_id.unwrap())
.fetch_all(pool)
.await?
};
Ok(rows
.into_iter()
.map(|(project, cabinet, channel)| {
json!({
"project": project,
"cabinet": cabinet,
"channel": channel,
"alert_type": "fault",
})
})
.collect())
}
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
async fn fetch_project_summaries(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
) -> Result<Vec<Value>, AppError> {
// 获取用户可见的项目
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id")
.fetch_all(pool)
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, ProjectRow>(
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
let mut result = Vec::with_capacity(projects.len());
for p in &projects {
let cabinet_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets WHERE project_id = ?",
)
.bind(p.id)
.fetch_one(pool)
.await?;
let charging: i64 = sqlx::query_scalar(
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"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
let idle: i64 = sqlx::query_scalar(
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"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
let fault: i64 = sqlx::query_scalar(
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"#,
)
.bind(p.id)
.fetch_one(pool)
.await?;
result.push(json!({
"id": p.id,
"name": p.name,
"cabinet_count": cabinet_count,
"charging": charging,
"idle": idle,
"fault": fault,
}));
}
Ok(result)
}
// ---------------------------------------------------------------------------
// 设备列表
// ---------------------------------------------------------------------------
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
pub async fn get_cabinets(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
let rows: Vec<H5CabinetRow> = if let Some(pid) = query.project_id {
// 按项目过滤(同时验证组织权限)
if user.role_level >= 2 {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
(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,
(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 = 2) as full_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 = 3) as fault_count
FROM cabinets c WHERE c.project_id = ?"#,
)
.bind(pid)
.fetch_all(pool)
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
(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,
(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 = 2) as full_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 = 3) as fault_count
FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
WHERE c.project_id = ? AND p.organization_id = ?"#,
)
.bind(pid)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
}
} else if user.role_level >= 2 {
// 总管理员:按组织过滤或看全部
if let Some(oid) = query.org_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
(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,
(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 = 2) as full_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 = 3) as fault_count
FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
WHERE p.organization_id = ?"#,
)
.bind(oid)
.fetch_all(pool)
.await?
} else {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
(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,
(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 = 2) as full_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 = 3) as fault_count
FROM cabinets c"#,
)
.fetch_all(pool)
.await?
}
} else if let Some(org_id) = user.organization_id {
// 企业管理员只看本组织
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id,
(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,
(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 = 2) as full_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 = 3) as fault_count
FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
WHERE p.organization_id = ?"#,
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
// 从Redis批量查RSSI
let mut rssi_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
if let Ok(mut conn) = client.get_async_connection().await {
let mut pipe = redis::pipe();
for r in &rows {
if let Some(ref imei) = r.imei {
pipe.cmd("HGET").arg(&format!("device:{imei}")).arg("rssi");
}
}
if !rows.is_empty() {
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
for (i, r) in rows.iter().enumerate() {
if r.imei.is_some() {
let val = results.get(i).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok());
rssi_map.insert(r.imei.clone().unwrap_or_default(), val);
}
}
}
}
}
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
let rssi = r.imei.as_ref().and_then(|imei| rssi_map.get(imei).copied()).flatten();
json!({
"id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"status": r.status,
"project_id": r.project_id,
"channel_count": r.channel_count,
"idle_count": r.idle_count,
"charging_count": r.charging_count,
"full_count": r.full_count,
"fault_count": r.fault_count,
"rssi": rssi,
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 设备扫码(免认证)
// ---------------------------------------------------------------------------
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>, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, iccid, name, status FROM cabinets WHERE abstract_id = ?",
)
.bind(&abstract_id)
.fetch_optional(pool)
.await?;
let (cab_id, abs_id, imei, iccid, 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,
"imei": imei,
"iccid": iccid,
"name": name,
"status": status,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 仓体/通道详情
// ---------------------------------------------------------------------------
/// 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> {
let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
// 查询仓体基本信息
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?",
)
.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 链路验证访问权限
let cabinet_id: Option<(i64,)> = sqlx::query_as(
"SELECT cabinet_id FROM cabin_boards WHERE id = ?",
)
.bind(cabin_board_id)
.fetch_optional(pool)
.await?;
if let Some((cab_id,)) = cabinet_id {
verify_cabinet_access(&user, cab_id, pool).await?;
}
// 从 Redis 获取实时数据
let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
if let Ok(mut conn) = client.get_async_connection().await {
let key = format!("compartment:status:{}", comp_id);
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
} else { json!({}) }
} else { json!({}) };
// 查询最近充电记录
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
r#"SELECT id, start_time, end_time, energy
FROM charge_records
WHERE compartment_id = ?
ORDER BY start_time DESC LIMIT 10"#,
)
.bind(comp_id)
.fetch_all(pool)
.await?;
let charge_records: 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();
Ok(Json(json!({
"code": 0,
"data": {
"id": comp_id,
"channel_id": channel_id,
"status": status,
"cabin_board_id": cabin_board_id,
"voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0),
"current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0),
"power": realtime.get("power").and_then(|v| v.as_i64()).unwrap_or(0),
"energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0),
"soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0),
"soh": realtime.get("soh").and_then(|v| v.as_i64()).unwrap_or(0),
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])),
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])),
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])),
"charge_records": charge_records,
},
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 扫码验证
// ---------------------------------------------------------------------------
/// 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 — 开始充电
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")?;
send_compartment_command(&state, body.compartment_id, "on").await
}
/// 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, body.compartment_id, "off").await
}
/// POST /api/h5/door/open — 开门
pub async fn open_door(
user: CurrentUser,
State(state): State<AppState>,
Json(body): Json<CompartmentActionInput>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:door:unlock")?;
send_compartment_command(&state, body.compartment_id, "open").await
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
/// 根据仓体ID查找所属柜子的IMEI,并通过 Redis 下发指令
async fn send_compartment_command(
state: &AppState,
compartment_id: i64,
action: &str,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 通过 compartment → cabin_board → cabinet 链路查找 IMEI 和 sub_device_id
let row: Option<CabinetImeiRow> = sqlx::query_as(
r#"SELECT cab.imei 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(compartment_id)
.fetch_optional(pool)
.await?;
let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
let imei = &cab_row.imei;
// 查询 sub_device_id(仓控板 ID)
let sub_row: Option<(i8,)> = sqlx::query_as(
r#"SELECT cb.board_id FROM cabin_boards cb
JOIN compartments comp ON comp.cabin_board_id = cb.id
WHERE comp.id = ?"#,
)
.bind(compartment_id)
.fetch_optional(pool)
.await?;
let sub_device_id = sub_row.map(|(bid,)| bid as u8);
// 检查是否有设备节点在线
if !state.nodes.is_device_online(imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 构建指令并通过 Redis 下发
let cmd = DeviceCommand {
act: action.to_string(),
msg_id: 0,
dev_id: Some(imei.clone()),
sub_device_id,
params: Some(json!({})),
};
let json_str = cmd.to_json();
let mut conn = state.redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
tracing::info!(
"[h5] 指令已推入队列 imei={} action={} compartment_id={}",
imei, action, compartment_id
);
Ok(Json(json!({
"code": 0,
"data": { "success": true },
"message": "指令已下发"
})))
}
/// 验证用户是否有权访问指定柜子(组织数据隔离)
///
/// 总管理员(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 access: Option<(i64,)> = sqlx::query_as(
r#"SELECT cab.id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.id = ? AND p.organization_id = ?"#,
)
.bind(cabinet_id)
.bind(org_id)
.fetch_optional(pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("无权访问该设备".into()));
}
Ok(())
}