984 lines
31 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 页面所需的全部后端接口:
//! - POST /api/h5/auth/login — H5 手机号登录
//! - GET /api/h5/dashboard — 首页仪表盘数据
//! - GET /api/h5/projects — 项目列表
//! - GET /api/h5/cabinets — 设备列表(按项目过滤)
//! - GET /api/h5/cabinets/:id — 设备详情(含仓板+仓体状态)
//! - GET /api/h5/cabinets/abstract/:aid — 通过抽象ID查设备扫码入口
//! - GET /api/h5/compartments/:id — 仓体/通道详情
//! - POST /api/h5/charge/start — 开始充电
//! - POST /api/h5/charge/stop — 停止充电
//! - POST /api/h5/door/open — 开门
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::commands::{AppState, DeviceCommand};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
// ---------------------------------------------------------------------------
// 请求体定义
// ---------------------------------------------------------------------------
/// H5 登录请求体
#[derive(Debug, Deserialize)]
pub(crate) struct H5LoginInput {
phone: String,
password: String,
}
/// 充电控制请求体(开始/停止充电、开门共用)
#[derive(Debug, Deserialize)]
pub(crate) struct CompartmentActionInput {
compartment_id: i64,
}
/// 设备列表查询参数
#[derive(Debug, Deserialize)]
pub(crate) struct CabinetListQuery {
project_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>,
}
/// 仓控板行
#[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<f64>,
}
/// 柜子 IMEI 查询行(用于指令下发)
#[derive(Debug, sqlx::FromRow)]
struct CabinetImeiRow {
imei: String,
}
// ---------------------------------------------------------------------------
// H5 登录
// ---------------------------------------------------------------------------
/// POST /api/h5/auth/login — H5 用户登录(复用 users 表)
pub async fn h5_login(
State(state): State<AppState>,
Json(input): Json<H5LoginInput>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
if input.phone.is_empty() || input.password.is_empty() {
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
}
// 查询用户
let user_row: Option<(i64, Option<String>, String, String, i32)> = sqlx::query_as(
"SELECT id, name, phone, password, status FROM users WHERE phone = ?",
)
.bind(&input.phone)
.fetch_optional(pool)
.await?;
let (id, name, phone, stored_password, status) =
user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
if status == 0 {
return Err(AppError::Forbidden("账户已被禁用".into()));
}
let valid = auth::verify_password(&input.password, &stored_password)
.map_err(|_| AppError::Internal("密码验证失败".into()))?;
if !valid {
return Err(AppError::Unauthorized("手机号或密码错误".into()));
}
let token = auth::generate_token(id, &phone)?;
Ok(Json(json!({
"code": 0,
"data": {
"token": token,
"user": {
"id": id,
"phone": phone,
"name": name,
}
},
"message": "登录成功"
})))
}
// ---------------------------------------------------------------------------
// 仪表盘
// ---------------------------------------------------------------------------
/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离)
pub async fn get_dashboard(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 根据角色确定组织过滤条件
let org_id = user.organization_id;
let is_super = user.role_level >= 2;
// 统计在线柜子数(带组织隔离)
let online_cabinets = count_online_cabinets(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?;
// 今日充电次数(带组织隔离)
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,
"charging_count": charging_count,
"idle_channels": idle_channels,
"fault_channels": fault_channels,
"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)
}
/// 按状态统计仓体数(带组织隔离,通过 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<f64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0.0);
}
let energy: Option<f64> = if is_super {
sqlx::query_scalar(
"SELECT CAST(SUM(energy) AS DECIMAL(10,2)) FROM charge_records WHERE DATE(start_time) = CURDATE()",
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT CAST(SUM(cr.energy) AS DECIMAL(10,2)) 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.0))
}
/// 今日充电时长(小时,带组织隔离)
async fn sum_today_charge_hours(
pool: &sqlx::MySqlPool,
is_super: bool,
org_id: Option<i64>,
) -> Result<f64, AppError> {
if !is_super && org_id.is_none() {
return Ok(0.0);
}
// 计算总秒数(未结束的记录用 NOW() 代替 end_time
let total_seconds: f64 = if is_super {
sqlx::query_scalar(
r#"SELECT COALESCE(SUM(
TIMESTAMPDIFF(SECOND, start_time, COALESCE(end_time, NOW()))
), 0) FROM charge_records
WHERE DATE(start_time) = CURDATE()"#,
)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
r#"SELECT COALESCE(SUM(
TIMESTAMPDIFF(SECOND, cr.start_time, COALESCE(cr.end_time, NOW()))
), 0) 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?
};
// 秒转小时保留1位小数
Ok((total_seconds / 3600.0 * 10.0).round() / 10.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/projects — H5 项目列表(简要)
pub async fn get_projects(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
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 list: Vec<Value> = projects
.into_iter()
.map(|p| json!({ "id": p.id, "name": p.name }))
.collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 设备列表
// ---------------------------------------------------------------------------
/// 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>(
"SELECT id, abstract_id, name, status, project_id FROM cabinets WHERE project_id = ?",
)
.bind(pid)
.fetch_all(pool)
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT id, abstract_id, name, status, project_id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE cab.project_id = ? AND p.organization_id = ?"#,
)
.bind(pid)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
}
} else if user.role_level >= 2 {
// 总管理员看全部
sqlx::query_as::<_, H5CabinetRow>(
"SELECT id, abstract_id, name, status, project_id FROM cabinets",
)
.fetch_all(pool)
.await?
} else if let Some(org_id) = user.organization_id {
// 企业管理员只看本组织
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT id, abstract_id, name, status, project_id FROM cabinets cab
INNER JOIN projects p ON cab.project_id = p.id
WHERE p.organization_id = ?"#,
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"abstract_id": r.abstract_id,
"name": r.name,
"status": r.status,
"project_id": r.project_id,
})
})
.collect();
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
// ---------------------------------------------------------------------------
// 设备详情
// ---------------------------------------------------------------------------
/// GET /api/h5/cabinets/:id — H5 设备详情(含仓控板+仓体,带权限校验)
pub async fn get_cabinet_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")?;
// 组织隔离:验证用户是否有权访问该设备
verify_cabinet_access(&user, id, pool).await?;
// 查询柜子基本信息
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
let (cab_id, abstract_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": abstract_id,
"imei": imei,
"name": name,
"status": status,
"cabin_boards": cabin_boards,
},
"message": "ok"
})))
}
/// GET /api/h5/cabinets/abstract/:abstract_id — 通过抽象ID查设备扫码入口免认证
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,
"imei": imei,
"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 redis_key = format!("compartment:status:{}", comp_id);
let mut conn = state.redis.clone();
let realtime_data: Option<String> = redis::cmd("GET")
.arg(&redis_key)
.query_async(&mut conn)
.await
.unwrap_or(None);
let realtime: Value = realtime_data
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(json!({}));
// 查询最近充电记录
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
r#"SELECT id, start_time, end_time, CAST(energy AS DOUBLE) AS 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(0.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_f64()).unwrap_or(0.0),
"current": realtime.get("current").and_then(|v| v.as_f64()).unwrap_or(0.0),
"power": realtime.get("power").and_then(|v| v.as_f64()).unwrap_or(0.0),
"energy": realtime.get("energy").and_then(|v| v.as_f64()).unwrap_or(0.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"
})))
}
// ---------------------------------------------------------------------------
// 充电控制
// ---------------------------------------------------------------------------
/// 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(())
}