2026-07-02 05:38:01 +08:00
//! H5 用户端 API
//!
//! 提供微信扫码充电的 H5 页面所需的全部后端接口:
2026-07-04 04:03:02 +08:00
//! - 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 — 扫码验证
2026-07-02 05:38:01 +08:00
2026-09-19 23:40:46 +08:00
use axum ::extract ::{ Multipart , Path , Query , State } ;
2026-07-02 05:38:01 +08:00
use axum ::Json ;
2026-07-04 04:03:02 +08:00
use redis ;
2026-07-02 05:38:01 +08:00
use serde ::Deserialize ;
use serde_json ::{ json , Value } ;
2026-09-20 00:01:01 +08:00
use std ::collections ::HashMap ;
2026-09-19 23:40:46 +08:00
use std ::io ::Write ;
use uuid ::Uuid ;
2026-07-02 05:38:01 +08:00
2026-09-19 23:40:46 +08:00
use crate ::commands ::{ AppState , DeviceCommand , ROUTE_DEV_ID_FIELD } ;
2026-07-02 05:38:01 +08:00
use crate ::error ::AppError ;
use crate ::middleware ::auth ::{ self , CurrentUser } ;
// ---------------------------------------------------------------------------
// 请求体定义
// ---------------------------------------------------------------------------
/// 充电控制请求体(开始/停止充电、开门共用)
#[ derive(Debug, Deserialize) ]
pub ( crate ) struct CompartmentActionInput {
compartment_id : i64 ,
}
2026-09-19 23:40:46 +08:00
/// 问题反馈请求体
#[ derive(Debug, Deserialize) ]
pub ( crate ) struct FeedbackInput {
/// 反馈内容
content : String ,
/// 联系方式(可选)
contact : Option < String > ,
/// 图片URL列表( 可选)
images : Option < Vec < String > > ,
}
2026-07-02 05:38:01 +08:00
/// 设备列表查询参数
#[ derive(Debug, Deserialize) ]
pub ( crate ) struct CabinetListQuery {
project_id : Option < i64 > ,
2026-07-04 04:03:02 +08:00
org_id : Option < i64 > ,
}
/// 仪表盘查询参数
#[ derive(Debug, Deserialize) ]
pub ( crate ) struct DashboardQuery {
org_id : Option < i64 > ,
2026-07-02 05:38:01 +08:00
}
// ---------------------------------------------------------------------------
// 行映射结构体
// ---------------------------------------------------------------------------
/// 项目简要信息行
#[ derive(Debug, sqlx::FromRow) ]
struct ProjectRow {
id : i64 ,
name : String ,
}
2026-07-04 04:03:02 +08:00
/// 柜子列表行(含通道统计)
2026-07-02 05:38:01 +08:00
#[ derive(Debug, sqlx::FromRow) ]
struct H5CabinetRow {
id : i64 ,
abstract_id : String ,
name : Option < String > ,
status : i8 ,
project_id : Option < i64 > ,
2026-07-04 04:03:02 +08:00
imei : Option < String > ,
2026-09-19 23:40:46 +08:00
iccid : Option < String > ,
address : Option < String > ,
latitude : Option < String > ,
longitude : Option < String > ,
2026-07-04 04:03:02 +08:00
channel_count : i64 ,
idle_count : i64 ,
charging_count : i64 ,
full_count : i64 ,
fault_count : i64 ,
2026-07-02 05:38:01 +08:00
}
/// 仓控板行
#[ 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 > ,
2026-07-03 10:20:29 +08:00
energy : Option < String > ,
2026-07-02 05:38:01 +08:00
}
2026-09-19 23:40:46 +08:00
/// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发)
2026-07-02 05:38:01 +08:00
#[ derive(Debug, sqlx::FromRow) ]
struct CabinetImeiRow {
2026-09-19 23:40:46 +08:00
cabinet_id : i64 ,
2026-07-02 05:38:01 +08:00
imei : String ,
2026-09-19 23:40:46 +08:00
board_id : i8 ,
channel_id : i8 ,
2026-07-02 05:38:01 +08:00
}
// ---------------------------------------------------------------------------
// 仪表盘
// ---------------------------------------------------------------------------
/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离)
pub async fn get_dashboard (
user : CurrentUser ,
State ( state ) : State < AppState > ,
2026-07-04 04:03:02 +08:00
Query ( query ) : Query < DashboardQuery > ,
2026-07-02 05:38:01 +08:00
) -> Result < Json < Value > , AppError > {
let pool = & state . mysql ;
// 根据角色确定组织过滤条件
2026-09-19 23:40:46 +08:00
// 总管理员可通过 org_id 参数切换组织
2026-07-04 04:03:02 +08:00
let is_super = user . role_level > = 2 & & query . org_id . is_none ( ) ;
let org_id = query . org_id . or ( user . organization_id ) ;
2026-07-02 05:38:01 +08:00
// 统计在线柜子数(带组织隔离)
let online_cabinets = count_online_cabinets ( pool , is_super , org_id ) . await ? ;
2026-07-04 04:03:02 +08:00
// 统计总通道数(带组织隔离)
let total_channels = count_total_compartments ( pool , is_super , org_id ) . await ? ;
2026-07-02 05:38:01 +08:00
// 统计充电中仓体数(带组织隔离)
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 ? ;
2026-07-04 04:03:02 +08:00
// 统计充满仓体数( 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 ? ;
2026-07-02 05:38:01 +08:00
// 今日充电次数(带组织隔离)
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 ? ;
2026-09-19 23:40:46 +08:00
// 项目摘要(带组织过滤)
let projects = fetch_project_summaries ( pool , & user , org_id ) . await ? ;
2026-07-02 05:38:01 +08:00
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : {
" online_cabinets " : online_cabinets ,
2026-07-04 04:03:02 +08:00
" total_channels " : total_channels ,
2026-07-02 05:38:01 +08:00
" charging_count " : charging_count ,
" idle_channels " : idle_channels ,
2026-07-04 04:03:02 +08:00
" full_count " : full_count ,
2026-07-02 05:38:01 +08:00
" fault_channels " : fault_channels ,
2026-07-04 04:03:02 +08:00
" fault_count " : fault_count ,
2026-07-02 05:38:01 +08:00
" 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 )
}
2026-07-04 04:03:02 +08:00
/// 统计总仓体数(带组织隔离)
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 )
}
2026-07-02 05:38:01 +08:00
/// 按状态统计仓体数(带组织隔离,通过 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 > ,
2026-07-03 10:20:29 +08:00
) -> Result < i64 , AppError > {
2026-07-02 05:38:01 +08:00
if ! is_super & & org_id . is_none ( ) {
2026-07-03 10:20:29 +08:00
return Ok ( 0 ) ;
2026-07-02 05:38:01 +08:00
}
2026-07-03 10:20:29 +08:00
let energy : Option < i64 > = if is_super {
2026-07-02 05:38:01 +08:00
sqlx ::query_scalar (
2026-07-04 04:03:02 +08:00
" SELECT CAST(COALESCE(SUM(energy), 0) AS SIGNED) FROM charge_records WHERE DATE(start_time) = CURDATE() " ,
2026-07-02 05:38:01 +08:00
)
. fetch_one ( pool )
. await ?
} else {
sqlx ::query_scalar (
2026-07-04 04:03:02 +08:00
r #" SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr
2026-07-02 05:38:01 +08:00
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 ?
} ;
2026-07-03 10:20:29 +08:00
Ok ( energy . unwrap_or ( 0 ) )
2026-07-02 05:38:01 +08:00
}
2026-07-03 10:20:29 +08:00
/// 今日充电时长(秒,带组织隔离)
2026-07-02 05:38:01 +08:00
async fn sum_today_charge_hours (
pool : & sqlx ::MySqlPool ,
is_super : bool ,
org_id : Option < i64 > ,
2026-07-03 10:20:29 +08:00
) -> Result < i64 , AppError > {
2026-07-02 05:38:01 +08:00
if ! is_super & & org_id . is_none ( ) {
2026-07-03 10:20:29 +08:00
return Ok ( 0 ) ;
2026-07-02 05:38:01 +08:00
}
// 计算总秒数(未结束的记录用 NOW() 代替 end_time)
2026-07-03 10:20:29 +08:00
let total_seconds : Option < i64 > = if is_super {
2026-07-02 05:38:01 +08:00
sqlx ::query_scalar (
2026-07-04 04:03:02 +08:00
r #" SELECT CAST(COALESCE(SUM(
2026-07-02 05:38:01 +08:00
TIMESTAMPDIFF ( SECOND , start_time , COALESCE ( end_time , NOW ( ) ) )
2026-07-04 04:03:02 +08:00
) , 0 ) AS SIGNED ) FROM charge_records
2026-07-02 05:38:01 +08:00
WHERE DATE ( start_time ) = CURDATE ( ) " #,
)
. fetch_one ( pool )
. await ?
} else {
sqlx ::query_scalar (
2026-07-04 04:03:02 +08:00
r #" SELECT CAST(COALESCE(SUM(
2026-07-02 05:38:01 +08:00
TIMESTAMPDIFF ( SECOND , cr . start_time , COALESCE ( cr . end_time , NOW ( ) ) )
2026-07-04 04:03:02 +08:00
) , 0 ) AS SIGNED ) FROM charge_records cr
2026-07-02 05:38:01 +08:00
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 ?
} ;
2026-07-03 10:20:29 +08:00
Ok ( total_seconds . unwrap_or ( 0 ) )
2026-07-02 05:38:01 +08:00
}
/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离)
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 ( ) )
}
2026-09-20 00:01:01 +08:00
// --- 项目摘要 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 " #;
2026-07-02 05:38:01 +08:00
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
async fn fetch_project_summaries (
pool : & sqlx ::MySqlPool ,
user : & CurrentUser ,
2026-09-19 23:40:46 +08:00
org_id : Option < i64 > ,
2026-07-02 05:38:01 +08:00
) -> Result < Vec < Value > , AppError > {
2026-09-20 00:01:01 +08:00
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)
async fn fetch_visible_projects (
pool : & sqlx ::MySqlPool ,
user : & CurrentUser ,
org_id : Option < i64 > ,
) -> Result < Vec < ProjectRow > , AppError > {
2026-09-19 23:40:46 +08:00
let filter_org = org_id . or ( user . organization_id ) ;
2026-07-02 05:38:01 +08:00
let projects : Vec < ProjectRow > = if user . role_level > = 2 {
2026-09-19 23:40:46 +08:00
if let Some ( oid ) = filter_org {
2026-09-20 00:01:01 +08:00
sqlx ::query_as ::< _ , ProjectRow > ( PROJECTS_BY_ORG_SQL )
. bind ( oid )
. fetch_all ( pool )
. await ?
2026-09-19 23:40:46 +08:00
} else {
2026-09-20 00:01:01 +08:00
sqlx ::query_as ::< _ , ProjectRow > ( PROJECTS_ALL_SQL )
2026-09-19 23:40:46 +08:00
. fetch_all ( pool )
. await ?
}
2026-07-02 05:38:01 +08:00
} else if let Some ( org_id ) = user . organization_id {
2026-09-20 00:01:01 +08:00
sqlx ::query_as ::< _ , ProjectRow > ( PROJECTS_BY_ORG_SQL )
. bind ( org_id )
. fetch_all ( pool )
. await ?
2026-07-02 05:38:01 +08:00
} else {
vec! [ ]
} ;
2026-09-20 00:01:01 +08:00
Ok ( projects )
}
/// 汇总单个项目的柜子数 / 充电中 / 空闲 / 故障仓体数
async fn fetch_project_summary (
pool : & sqlx ::MySqlPool ,
p : & ProjectRow ,
) -> Result < Value , AppError > {
let cabinet_count : i64 = sqlx ::query_scalar ( PROJECT_CABINET_COUNT_SQL )
2026-07-02 05:38:01 +08:00
. bind ( p . id )
. fetch_one ( pool )
. await ? ;
2026-09-20 00:01:01 +08:00
let charging : i64 = sqlx ::query_scalar ( PROJECT_CHARGING_COUNT_SQL )
2026-07-02 05:38:01 +08:00
. bind ( p . id )
. fetch_one ( pool )
. await ? ;
2026-09-20 00:01:01 +08:00
let idle : i64 = sqlx ::query_scalar ( PROJECT_IDLE_COUNT_SQL )
2026-07-02 05:38:01 +08:00
. bind ( p . id )
. fetch_one ( pool )
. await ? ;
2026-09-20 00:01:01 +08:00
let fault : i64 = sqlx ::query_scalar ( PROJECT_FAULT_COUNT_SQL )
2026-07-02 05:38:01 +08:00
. bind ( p . id )
. fetch_one ( pool )
. await ? ;
2026-09-20 00:01:01 +08:00
Ok ( json! ( {
" id " : p . id ,
" name " : p . name ,
" cabinet_count " : cabinet_count ,
" charging " : charging ,
" idle " : idle ,
" fault " : fault ,
} ) )
2026-07-02 05:38:01 +08:00
}
// ---------------------------------------------------------------------------
// 设备列表
// ---------------------------------------------------------------------------
2026-09-20 00:01:01 +08:00
// --- 设备列表 SQL( 由原函数体逐字搬移; SELECT 字段列表 / 子查询 / CAST 均未改动) ---
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
/// 项目过滤 + 总管理员(不带组织条件)
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,
2026-07-04 04:03:02 +08:00
( 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
2026-09-20 00:01:01 +08:00
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,
2026-07-04 04:03:02 +08:00
( 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
2026-09-20 00:01:01 +08:00
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,
2026-07-04 04:03:02 +08:00
( 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
2026-09-20 00:01:01 +08:00
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,
2026-07-04 04:03:02 +08:00
( 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
2026-09-20 00:01:01 +08:00
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,
2026-07-04 04:03:02 +08:00
( 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
2026-09-20 00:01:01 +08:00
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 > > ,
}
/// 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 > {
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 {
// 企业管理员只看本组织
let rows = sqlx ::query_as ::< _ , H5CabinetRow > ( CABINETS_BY_USER_ORG_SQL )
. bind ( org_id )
. fetch_all ( pool )
. await ? ;
Ok ( rows )
2026-07-02 05:38:01 +08:00
} else {
2026-09-20 00:01:01 +08:00
Ok ( vec! [ ] )
}
}
/// 项目过滤分支:总管理员不带组织条件,企业管理员额外校验项目归属组织
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 )
}
}
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
/// 从 Redis 批量读取柜子实时状态( Pipeline HMGET, 1 次网络往返)
///
/// 注意: pipeline 只对带 IMEI 的行下发命令,结果按「行下标」对齐,
/// 与重构前的行为完全一致(这是既有约定,不做修正)。
async fn load_cabinet_realtime ( state : & AppState , rows : & [ H5CabinetRow ] ) -> CabinetRealtimeMaps {
let mut maps = CabinetRealtimeMaps ::default ( ) ;
2026-07-04 04:03:02 +08:00
if let Ok ( client ) = redis ::Client ::open ( state . redis_url . as_str ( ) ) {
2026-09-19 23:40:46 +08:00
// 使用多路复用连接( MultiplexedConnection) , 替代已废弃的 aio::Connection
if let Ok ( mut conn ) = client . get_multiplexed_async_connection ( ) . await {
2026-07-04 04:03:02 +08:00
let mut pipe = redis ::pipe ( ) ;
2026-09-20 00:01:01 +08:00
for r in rows {
2026-07-04 04:03:02 +08:00
if let Some ( ref imei ) = r . imei {
2026-09-19 23:40:46 +08:00
let key = format! ( " device: {imei} " ) ;
pipe . cmd ( " HMGET " ) . arg ( & key ) . arg ( & [ " rssi " , " pow_fail_dc " , " pow_fail_ac " , " temp " ] ) ;
2026-07-04 04:03:02 +08:00
}
}
if ! rows . is_empty ( ) {
2026-09-19 23:40:46 +08:00
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 ( )
}
} ;
2026-07-04 04:03:02 +08:00
for ( i , r ) in rows . iter ( ) . enumerate ( ) {
2026-09-19 23:40:46 +08:00
if let Some ( ref imei ) = r . imei {
if let Some ( vals ) = results . get ( i ) {
2026-09-20 00:01:01 +08:00
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 ( ) ) ) ;
2026-09-19 23:40:46 +08:00
}
2026-07-04 04:03:02 +08:00
}
}
}
}
}
2026-09-20 00:01:01 +08:00
maps
}
2026-07-04 04:03:02 +08:00
2026-09-20 00:01:01 +08:00
/// 查询柜子所属组织是否禁用, 返回「项目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 ) ;
2026-09-19 23:40:46 +08:00
}
}
}
2026-09-20 00:01:01 +08:00
org_disabled_map
}
2026-09-19 23:40:46 +08:00
2026-09-20 00:01:01 +08:00
/// 行结构体 + 实时状态 → 响应 JSON 列表(字段名与语义与原实现逐字一致)
fn build_cabinet_list (
rows : Vec < H5CabinetRow > ,
realtime : & CabinetRealtimeMaps ,
org_disabled_map : & HashMap < i64 , bool > ,
) -> Vec < Value > {
rows . into_iter ( )
2026-07-02 05:38:01 +08:00
. map ( | r | {
2026-09-19 23:40:46 +08:00
let imei = r . imei . clone ( ) ;
2026-09-20 00:01:01 +08:00
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 ( ) ;
2026-09-19 23:40:46 +08:00
let org_disabled = r . project_id . and_then ( | pid | org_disabled_map . get ( & pid ) ) . copied ( ) . unwrap_or ( false ) ;
2026-07-02 05:38:01 +08:00
json! ( {
" id " : r . id ,
" abstract_id " : r . abstract_id ,
" name " : r . name ,
2026-09-19 23:40:46 +08:00
" imei " : r . imei ,
" iccid " : r . iccid ,
2026-07-02 05:38:01 +08:00
" status " : r . status ,
" project_id " : r . project_id ,
2026-09-19 23:40:46 +08:00
" address " : r . address ,
" latitude " : r . latitude ,
" longitude " : r . longitude ,
2026-07-04 04:03:02 +08:00
" 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 ,
2026-09-19 23:40:46 +08:00
" pow_fail_dc " : pow_fail_dc ,
" pow_fail_ac " : pow_fail_ac ,
" temp " : temp ,
" org_disabled " : org_disabled ,
2026-07-02 05:38:01 +08:00
} )
} )
2026-09-20 00:01:01 +08:00
. collect ( )
2026-07-02 05:38:01 +08:00
}
// ---------------------------------------------------------------------------
2026-07-04 04:03:02 +08:00
// 设备扫码(免认证)
2026-07-02 05:38:01 +08:00
// ---------------------------------------------------------------------------
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 ;
2026-09-19 23:40:46 +08:00
let cab : Option < ( i64 , String , String , Option < String > , i8 ) > = sqlx ::query_as (
" SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ? " ,
2026-07-02 05:38:01 +08:00
)
. bind ( & abstract_id )
. fetch_optional ( pool )
. await ? ;
2026-09-19 23:40:46 +08:00
let ( cab_id , abs_id , _imei , name , status ) =
2026-07-02 05:38:01 +08:00
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 "
} ) ) )
}
// ---------------------------------------------------------------------------
// 仓体/通道详情
// ---------------------------------------------------------------------------
2026-09-20 00:01:01 +08:00
/// 仓体基础信息( 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 = ? " ;
/// 仓体最近 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 " #;
2026-07-02 05:38:01 +08:00
/// 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 " ) ? ;
2026-09-20 00:01:01 +08:00
let pool = & state . mysql ;
2026-07-02 05:38:01 +08:00
// 查询仓体基本信息
2026-09-20 00:01:01 +08:00
let comp : Option < ( i64 , i8 , i8 , i64 ) > = sqlx ::query_as ( COMPARTMENT_BY_ID_SQL )
. bind ( id )
. fetch_optional ( pool )
. await ? ;
2026-07-02 05:38:01 +08:00
let ( comp_id , channel_id , status , cabin_board_id ) =
comp . ok_or_else ( | | AppError ::NotFound ( " 仓体不存在 " . into ( ) ) ) ? ;
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
2026-09-20 00:01:01 +08:00
verify_compartment_access ( & user , cabin_board_id , pool ) . await ? ;
2026-07-02 05:38:01 +08:00
// 从 Redis 获取实时数据
2026-09-20 00:01:01 +08:00
let realtime = load_compartment_realtime ( & state , comp_id ) . await ;
2026-07-02 05:38:01 +08:00
// 查询最近充电记录
let records : Vec < ChargeRecordBriefRow > = sqlx ::query_as ::< _ , ChargeRecordBriefRow > (
2026-09-20 00:01:01 +08:00
COMPARTMENT_CHARGE_RECORDS_SQL ,
2026-07-02 05:38:01 +08:00
)
. bind ( comp_id )
. fetch_all ( pool )
. await ? ;
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : {
" id " : comp_id ,
" channel_id " : channel_id ,
" status " : status ,
" cabin_board_id " : cabin_board_id ,
2026-07-03 10:20:29 +08:00
" 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 ) ,
" energy " : realtime . get ( " energy " ) . and_then ( | v | v . as_i64 ( ) ) . unwrap_or ( 0 ) ,
2026-07-02 05:38:01 +08:00
" soc " : realtime . get ( " soc " ) . 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! ( [ ] ) ) ,
2026-09-20 00:01:01 +08:00
" charge_records " : map_charge_records ( records ) ,
2026-07-02 05:38:01 +08:00
} ,
" message " : " ok "
} ) ) )
}
2026-09-20 00:01:01 +08:00
/// 组织隔离:仓控板 → 柜子 → 项目 → 组织链路校验访问权限
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: `compartment:status:{comp_id}`)
async fn load_compartment_realtime ( state : & AppState , comp_id : i64 ) -> Value {
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 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! ( { } ) }
}
/// 充电记录行 → 响应 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 ( )
}
2026-07-04 04:03:02 +08:00
// ---------------------------------------------------------------------------
// 扫码验证
// ---------------------------------------------------------------------------
/// 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 "
} ) ) )
}
2026-07-02 05:38:01 +08:00
// ---------------------------------------------------------------------------
// 充电控制
// ---------------------------------------------------------------------------
/// POST /api/h5/charge/start — 开始充电
2026-09-19 23:40:46 +08:00
///
/// 协议 §5.7: **`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`,
/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。
/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。
2026-07-02 05:38:01 +08:00
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 " ) ? ;
2026-09-19 23:40:46 +08:00
// 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式
// ( `cabinets.work_mode` 是 TINYINT: 0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理)
let order_row : Result < Option < ( i64 , String , i8 , i8 , Option < i8 > ) > , sqlx ::Error > = 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 ;
match order_row {
// 只有**平台指令模式**( `work_mode="0"`/NULL) 才由平台下发 `on` 并在此建单;
// **自动充电模式**( `"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单
Ok ( Some ( ( cabinet_id , imei , board_id , channel_id , mode ) ) ) if mode . unwrap_or ( 0 ) = = 0 = > {
// 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`)
match crate ::workers ::report_worker ::load_channel_battery (
& state . redis , & imei , board_id , channel_id ,
)
. await
{
Some ( battery ) = > {
if let Err ( e ) = crate ::workers ::report_worker ::ensure_charge_order (
& state . mysql ,
& state . redis ,
& imei ,
cabinet_id ,
board_id ,
channel_id ,
& battery . battery_id ,
Some ( battery . soc ) ,
)
. await
{
tracing ::error! (
" [h5] 下发 on 前建单失败 compartment_id={} err={} " ,
body . compartment_id ,
e
) ;
}
}
None = > tracing ::warn! (
" [h5] 该通道无 bat_in 登记,跳过建单 compartment_id={} " ,
body . compartment_id
) ,
}
}
Ok ( Some ( _ ) ) = > tracing ::info! (
" [h5] 自动充电模式( work_mode=1) , 建单由设备上报的 on 负责 compartment_id={} " ,
body . compartment_id
) ,
Ok ( None ) = > tracing ::warn! (
" [h5] 下发 on 前跳过建单:仓体不存在 compartment_id={} " ,
body . compartment_id
) ,
Err ( e ) = > tracing ::error! (
" [h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={} " ,
body . compartment_id ,
e
) ,
}
send_compartment_command ( & state , & user , body . compartment_id , " on " ) . await
2026-07-02 05:38:01 +08:00
}
/// 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 " ) ? ;
2026-09-19 23:40:46 +08:00
send_compartment_command ( & state , & user , body . compartment_id , " off " ) . await
2026-07-02 05:38:01 +08:00
}
2026-09-19 23:40:46 +08:00
/// POST /api/h5/door/open — 开门(**柜级动作**)
///
/// 入参是仓体 ID( 小程序扫码场景只知道扫到哪个仓) ,
/// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。
2026-07-02 05:38:01 +08:00
pub async fn open_door (
user : CurrentUser ,
State ( state ) : State < AppState > ,
Json ( body ) : Json < CompartmentActionInput > ,
) -> Result < Json < Value > , AppError > {
2026-09-19 23:40:46 +08:00
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
2026-07-02 05:38:01 +08:00
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
2026-09-20 00:01:01 +08:00
// --- 指令下发 SQL / Redis key( 由原函数体逐字搬移, 契约不变) ---
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
/// 仓体 → 柜子/IMEI/仓控板/通道 一次查询
const COMPARTMENT_TARGET_SQL : & str = r #" SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
2026-09-19 23:40:46 +08:00
FROM cabinets cab
2026-07-02 05:38:01 +08:00
JOIN cabin_boards cb ON cb . cabinet_id = cab . id
JOIN compartments comp ON comp . cabin_board_id = cb . id
2026-09-20 00:01:01 +08:00
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 " ;
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
/// 校验柜子及其所属组织未被禁用(甲方禁用组织 → 其下柜子全部失效)
async fn ensure_cabinet_enabled ( pool : & sqlx ::MySqlPool , cabinet_id : i64 ) -> Result < ( ) , AppError > {
2026-09-19 23:40:46 +08:00
// 检查设备是否被禁用
2026-09-20 00:01:01 +08:00
let disabled : bool = sqlx ::query_scalar ( CABINET_DISABLED_SQL )
. bind ( cabinet_id )
. fetch_one ( pool )
. await
. map_err ( AppError ::Database ) ? ;
2026-09-19 23:40:46 +08:00
if disabled {
return Err ( AppError ::BadRequest ( " 设备已被禁用,无法执行操作 " . into ( ) ) ) ;
}
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
2026-09-20 00:01:01 +08:00
let org_disabled : bool = sqlx ::query_scalar ( CABINET_ORG_DISABLED_SQL )
. bind ( cabinet_id )
. fetch_one ( pool )
. await
. map_err ( AppError ::Database ) ? ;
2026-09-19 23:40:46 +08:00
if org_disabled {
return Err ( AppError ::BadRequest ( " 设备所属组织已被禁用,无法执行操作 " . into ( ) ) ) ;
}
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
Ok ( ( ) )
}
2026-07-02 05:38:01 +08:00
2026-09-20 00:01:01 +08:00
/// 设备必须在线:平台对不在线的设备一律不投递
async fn ensure_device_online ( state : & AppState , imei : & str ) -> Result < ( ) , AppError > {
2026-07-02 05:38:01 +08:00
if ! state . nodes . is_device_online ( imei ) . await {
return Err ( AppError ::BadRequest ( " 设备不在线 " . into ( ) ) ) ;
}
2026-09-20 00:01:01 +08:00
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 ;
2026-07-02 05:38:01 +08:00
2026-09-19 23:40:46 +08:00
// 构造协议 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! ( { } ) ) ,
} ;
2026-09-20 00:01:01 +08:00
push_device_command ( state , & cmd , imei ) . await ? ;
2026-09-19 23:40:46 +08:00
tracing ::info! (
" [h5] 指令已推入队列 id={} action={} compartment_id={} " ,
id , action , compartment_id
) ;
2026-09-20 00:01:01 +08:00
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 ( ) ) )
2026-09-19 23:40:46 +08:00
}
// ---------------------------------------------------------------------------
// 柜级 / 单仓控板级指令( 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 ;
2026-09-20 00:01:01 +08:00
let imei = load_cabinet_imei ( pool , cabinet_id ) . await ? ;
2026-09-19 23:40:46 +08:00
2026-09-20 00:01:01 +08:00
// 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致)
ensure_cabinet_enabled ( pool , cabinet_id ) . await ? ;
2026-09-19 23:40:46 +08:00
verify_cabinet_access ( user , cabinet_id , pool ) . await ? ;
2026-09-20 00:01:01 +08:00
ensure_device_online ( state , & imei ) . await ? ;
2026-09-19 23:40:46 +08:00
// 构造协议 id:
// - `board_id = None` → **柜级**: **不带 id**(省略该字段)
// - `board_id = Some(n)` → **单仓控板**: `仓控板`( 2 位补零,如 "01")
let id = board_id . map ( | b | format! ( " {:02} " , b ) ) ;
2026-07-02 05:38:01 +08:00
let cmd = DeviceCommand {
act : action . to_string ( ) ,
msg_id : 0 ,
2026-09-19 23:40:46 +08:00
id : id . clone ( ) ,
2026-07-02 05:38:01 +08:00
params : Some ( json! ( { } ) ) ,
} ;
2026-09-20 00:01:01 +08:00
push_device_command ( state , & cmd , & imei ) . await ? ;
2026-07-02 05:38:01 +08:00
tracing ::info! (
2026-09-19 23:40:46 +08:00
" [设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?} " ,
id ,
action ,
cabinet_id ,
board_id
2026-07-02 05:38:01 +08:00
) ;
2026-09-20 00:01:01 +08:00
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 ( ) ) )
2026-07-02 05:38:01 +08:00
}
2026-09-19 23:40:46 +08:00
/// 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 > ,
}
#[ 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 ,
}
/// GET /api/h5/feedback — 反馈列表(管理端)
///
/// `_user` 仅用于强制登录态( CurrentUser 提取器),当前实现按全量分页返回。
pub async fn list_feedback (
_user : CurrentUser ,
State ( state ) : State < AppState > ,
Query ( query ) : Query < FeedbackListQuery > ,
) -> Result < Json < Value > , AppError > {
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 total : i64 = sqlx ::query_scalar ( " SELECT COUNT(*) FROM user_feedback " )
. fetch_one ( pool )
. await ? ;
let rows : Vec < FeedbackRow > = sqlx ::query_as ::< _ , FeedbackRow > (
r #" SELECT id, user_id, content, contact, images, status, created_at
FROM user_feedback
ORDER BY created_at DESC
LIMIT ? OFFSET ? " #,
)
. 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 ( ) ;
}
let allowed = [ " .jpg " , " .jpeg " , " .png " , " .gif " , " .webp " ] ;
if ! allowed . contains ( & ext . as_str ( ) ) {
return Err ( AppError ::BadRequest ( " 仅支持 jpg/png/gif/webp 格式 " . into ( ) ) ) ;
}
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 > {
let pool = & state . mysql ;
if body . status ! = 0 & & body . status ! = 1 {
return Err ( AppError ::BadRequest ( " 状态值不合法 " . into ( ) ) ) ;
}
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 "
} ) ) )
}
2026-07-02 05:38:01 +08:00
/// 验证用户是否有权访问指定柜子(组织数据隔离)
///
/// 总管理员( 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 ( ( ) )
}