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-09-20 02:44:51 +08:00
//! - GET /api/my-organizations — 当前用户可见的组织(仅需登录;工厂全量 / 客户仅本组织)
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-09-20 02:44:51 +08:00
use serde ::{ Deserialize , Serialize } ;
2026-07-02 05:38:01 +08:00
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 } ;
2026-09-20 02:44:51 +08:00
use super ::cabinet_tree ::{ derive_channel_flags , ChannelCounts } ;
2026-07-02 05:38:01 +08:00
// ---------------------------------------------------------------------------
// 请求体定义
// ---------------------------------------------------------------------------
/// 充电控制请求体(开始/停止充电、开门共用)
#[ 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-09-20 02:44:51 +08:00
///
/// ⚠️ 这里**只保留 DB 能给准的三个计数**( `channel_count` / `idle_count` / `charging_count`)。
/// `full_count` / `fault_count` 不在此结构体里 —— `compartments.status` 全仓只写 0/1( 占用语义) ,
/// DB 永远给不出充满/故障,它们只能由 Redis 通道状态派生(见 [`CabinetRealtimeMaps::counts`])。
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 ,
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
}
// ---------------------------------------------------------------------------
// 仪表盘
// ---------------------------------------------------------------------------
2026-09-20 02:44:51 +08:00
/// 数据范围(项目级)过滤片段:`cond` 以 ` AND ` 开头(拼在已有 WHERE 之后),`binds` 顺序与占位符一致
///
/// 未配置数据范围(或总管理员)时 `cond` 为空串、`binds` 为空 ⇒ 各统计 SQL 与今天逐字一致。
struct ScopeFilter < ' a > {
cond : & ' a str ,
binds : & ' a [ String ] ,
}
/// 构造作用于 `projects p` 的数据范围过滤片段
fn scope_filter_for_projects ( scope : & crate ::middleware ::auth ::UserScope ) -> ( String , Vec < String > ) {
scope . project_condition ( " p.id " )
}
/// 仪表盘首页
///
/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离 + 数据范围)
2026-07-02 05:38:01 +08:00
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
2026-09-20 02:44:51 +08:00
// 数据范围(项目级):总管理员豁免;未配置 = 不限制(片段为空)
let ( scope_cond , scope_binds ) = scope_filter_for_projects ( & user . effective_scope ( ) ) ;
let scope = ScopeFilter { cond : & scope_cond , binds : & scope_binds } ;
2026-09-20 04:31:42 +08:00
// 统计在线柜子数(带组织隔离;**以 Redis 为准**,与 `cabinets.status` 配置态无关)
let online_cabinets = count_online_cabinets ( & state , is_super , org_id , & scope ) . await ? ;
2026-07-04 04:03:02 +08:00
// 统计总通道数(带组织隔离)
2026-09-20 02:44:51 +08:00
let total_channels = count_total_compartments ( pool , is_super , org_id , & scope ) . await ? ;
2026-07-02 05:38:01 +08:00
// 统计充电中仓体数(带组织隔离)
2026-09-20 02:44:51 +08:00
let charging_count = count_compartments_by_status ( pool , is_super , org_id , 1 , & scope ) . await ? ;
2026-07-02 05:38:01 +08:00
2026-09-20 02:44:51 +08:00
// 统计空闲仓体数(带组织隔离):`status = 0` = 空闲( 占用语义, DB 给得准)
let idle_channels = count_compartments_by_status ( pool , is_super , org_id , 0 , & scope ) . await ? ;
2026-07-04 04:03:02 +08:00
2026-09-20 02:44:51 +08:00
// 充满 / 故障:**DB 给不出**( `compartments.status` 只写 0/1, 2/3 永不出现),
// 只能由通道实时状态派生 —— 与设备列表/PC 设备卡片同一套规则(`derive_channel_flags`)。
// 拿不到 Redis 数据(设备离线 / 无通道键 / Redis 不可用)时按 0 计,不猜测。
let ( full_count , fault_count ) = count_derived_full_fault ( & state , is_super , org_id , & scope ) . await ;
// `fault_channels` 与 `fault_count` 同源同义(历史字段名重复),一并给派生值
let fault_channels = fault_count ;
2026-07-04 04:03:02 +08:00
2026-07-02 05:38:01 +08:00
// 今日充电次数(带组织隔离)
2026-09-20 02:44:51 +08:00
let today_charge_count = count_today_charges ( pool , is_super , org_id , & scope ) . await ? ;
2026-07-02 05:38:01 +08:00
// 今日充电量( kWh, 带组织隔离)
2026-09-20 02:44:51 +08:00
let today_energy = sum_today_energy ( pool , is_super , org_id , & scope ) . await ? ;
2026-07-02 05:38:01 +08:00
// 今日充电时长(小时,带组织隔离)
2026-09-20 02:44:51 +08:00
let today_charge_hours = sum_today_charge_hours ( pool , is_super , org_id , & scope ) . await ? ;
2026-07-02 05:38:01 +08:00
// 故障告警( 取最近10条故障仓体, 返回项目名+柜名)
2026-09-20 02:44:51 +08:00
let alerts = fetch_fault_alerts ( pool , is_super , org_id , & scope ) . await ? ;
2026-07-02 05:38:01 +08:00
2026-09-20 02:44:51 +08:00
// 项目摘要(带组织过滤 + 数据范围)
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 "
} ) ) )
}
2026-09-20 04:31:42 +08:00
/// 设备级 HMGET 的**字段清单**(顺序 = pipeline 命令顺序 = 解析下标)。
///
/// 下标:`rssi`(0) / `pow_fail_dc`(1) / `pow_fail_ac`(2) / `temp`(3) /
/// `cabinet_status1`(4) / `cabinet_status2`(5) / **`online`(6, 追加在末尾)**。
/// ⚠️ 新增字段**只能追加在末尾**(前面的下标被下面的解析逐一对齐)。
const CABINET_HMGET_FIELDS : [ & str ; 7 ] = [
" rssi " ,
" pow_fail_dc " ,
" pow_fail_ac " ,
" temp " ,
" cabinet_status1 " ,
" cabinet_status2 " ,
// 实时在线:字段名直接取自 `cabinet_tree`(与设备列表 `is_online` 同一常量,跨模块同源)
super ::cabinet_tree ::DEVICE_ONLINE_FIELD ,
] ;
/// 在线字段在 [`CABINET_HMGET_FIELDS`] 中的下标(追加在末尾)
const CABINET_HMGET_ONLINE_IDX : usize = 6 ;
/// 首页「在线设备」的**取数 SQL**:只取可见柜的 IMEI, 在线与否交给 Redis 判定。
///
/// **不含任何 `status` 条件** —— `cabinets.status` 是启用/禁用**配置态**,
/// 全仓没有任何写入方会把它写成 0( 离线) , 拿它统计会在"一台设备都没连"时
/// 返回"全部在线"。此处只负责按组织隔离 + 数据范围取出 IMEI。
fn online_cabinet_imeis_sql ( is_super : bool , scope_cond : & str ) -> String {
if is_super {
" SELECT cab.imei FROM cabinets cab WHERE cab.imei IS NOT NULL " . to_string ( )
} else {
format! (
r #" SELECT cab.imei FROM cabinets cab
INNER JOIN projects p ON cab . project_id = p . id
WHERE cab . imei IS NOT NULL AND p . organization_id = ? { } " #,
scope_cond
)
}
}
/// 统计在线柜子数(**以 Redis 为准**,带组织隔离 + 数据范围)
///
/// # 为什么不再用 `cabinets.status = 1`
/// `cabinets.status` 是启用/禁用**配置态**( `2` = 禁用,其余 = 启用),
/// 全仓**没有任何写入方会把它写成 0( 离线) ** ⇒ 按它统计会在"一台设备都没连"时
/// 依然返回"全部在线"。现在改为:先按组织隔离 + 范围取出可见柜的 IMEI,
/// 再用一次 pipeline 查 `device:{imei}.online`(判据与设备列表 `is_online` 同一纯函数
/// [`cabinet_tree::is_device_online`])。
2026-07-02 05:38:01 +08:00
async fn count_online_cabinets (
2026-09-20 04:31:42 +08:00
state : & AppState ,
2026-07-02 05:38:01 +08:00
is_super : bool ,
org_id : Option < i64 > ,
2026-09-20 02:44:51 +08:00
scope : & ScopeFilter < '_ > ,
2026-07-02 05:38:01 +08:00
) -> Result < i64 , AppError > {
2026-09-20 04:31:42 +08:00
// 总管理员:全部柜子的 IMEI, 不带任何组织条件( 豁免)
if is_super {
return finish_online_count ( state , online_cabinet_imeis_sql ( true , " " ) , Vec ::new ( ) ) . await ;
2026-07-02 05:38:01 +08:00
}
2026-09-20 04:31:42 +08:00
// 客户侧账号**无组织归属 ⇒ 空集**(绝不退化成"全部"),与 `visible_org_scope` 同口径
let Some ( oid ) = org_id else {
return Ok ( 0 ) ;
2026-07-02 05:38:01 +08:00
} ;
2026-09-20 04:31:42 +08:00
// 组织隔离 + 数据范围(绑定顺序 = 占位符顺序:先组织,再范围)
let mut binds : Vec < String > = vec! [ oid . to_string ( ) ] ;
binds . extend ( scope . binds . iter ( ) . cloned ( ) ) ;
finish_online_count ( state , online_cabinet_imeis_sql ( false , scope . cond ) , binds ) . await
}
/// 执行在线取数 SQL, 并按 Redis 判据统计在线台数(`binds` 顺序 = 占位符顺序)
async fn finish_online_count (
state : & AppState ,
sql : String ,
binds : Vec < String > ,
) -> Result < i64 , AppError > {
let mut query = sqlx ::query_scalar ::< _ , Option < String > > ( & sql ) ;
for bind in & binds {
query = query . bind ( bind ) ;
}
let imeis : Vec < String > = query
. fetch_all ( & state . mysql )
. await ?
. into_iter ( )
. flatten ( )
. collect ( ) ;
// Redis 一次 pipeline 批量判定(**零 N 次单查**),口径同设备列表 is_online
Ok ( super ::cabinet_tree ::count_online_devices ( state , & imeis ) . await )
2026-07-02 05:38:01 +08:00
}
2026-09-20 02:44:51 +08:00
/// 统计总仓体数(带组织隔离 + 数据范围)
2026-07-04 04:03:02 +08:00
async fn count_total_compartments (
pool : & sqlx ::MySqlPool ,
is_super : bool ,
org_id : Option < i64 > ,
2026-09-20 02:44:51 +08:00
scope : & ScopeFilter < '_ > ,
2026-07-04 04:03:02 +08:00
) -> 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 {
2026-09-20 02:44:51 +08:00
let sql = format! (
2026-07-04 04:03:02 +08:00
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
2026-09-20 02:44:51 +08:00
WHERE c . status = 1 AND p . organization_id = ? { } " #,
scope . cond
) ;
let mut query = sqlx ::query_scalar ::< _ , i64 > ( & sql ) . bind ( org_id . unwrap ( ) ) ;
for bind in scope . binds {
query = query . bind ( bind ) ;
}
query . fetch_one ( pool ) . await ?
2026-07-04 04:03:02 +08:00
} ;
Ok ( count )
}
2026-09-20 02:44:51 +08:00
/// 按状态统计仓体数(带组织隔离 + 数据范围,通过 cabin_boards → cabinets → projects 链路)
2026-07-02 05:38:01 +08:00
async fn count_compartments_by_status (
pool : & sqlx ::MySqlPool ,
is_super : bool ,
org_id : Option < i64 > ,
status : i8 ,
2026-09-20 02:44:51 +08:00
scope : & ScopeFilter < '_ > ,
2026-07-02 05:38:01 +08:00
) -> 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 {
2026-09-20 02:44:51 +08:00
let sql = format! (
2026-07-02 05:38:01 +08:00
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
2026-09-20 02:44:51 +08:00
WHERE comp . status = ? AND p . organization_id = ? { } " #,
scope . cond
) ;
let mut query = sqlx ::query_scalar ::< _ , i64 > ( & sql ) . bind ( status ) . bind ( org_id . unwrap ( ) ) ;
for bind in scope . binds {
query = query . bind ( bind ) ;
}
query . fetch_one ( pool ) . await ?
2026-07-02 05:38:01 +08:00
} ;
Ok ( count )
}
2026-09-20 02:44:51 +08:00
/// 统计范围内的「充满 / 故障」通道数(**由通道实时状态派生**,带组织隔离)
///
/// 为什么不能用 SQL: `compartments.status` 只表达**占用**语义(`0 空闲 / 1 充电中`) ,
/// 平台从不写 2/3, 所以 `WHERE comp.status = 2/3` 永远返回 0 —— 这正是首页统计行
/// "充满""故障"恒为 0 的原因。改为:按可见范围取柜子 → 拼通道 key → 一次 pipeline
/// 读 `batt_status`/`pow_status` → 复用 `cabinet_tree::derive_channel_counts`
/// 的优先级( fault > full > charging > idle) 分区计数。
///
/// 可见范围与 [`count_compartments_by_status`] 保持一致:总管理员 = 全部柜子;否则 = 本组织项目下的柜子。
/// **拿不到 Redis 数据时按 0 计**(离线 / 无通道键 / Redis 不可用),绝不猜测。
async fn count_derived_full_fault (
state : & AppState ,
is_super : bool ,
org_id : Option < i64 > ,
scope : & ScopeFilter < '_ > ,
) -> ( i64 , i64 ) {
let cabinet_ids = match fetch_scope_cabinet_ids ( & state . mysql , is_super , org_id , scope ) . await {
Ok ( ids ) = > ids ,
Err ( e ) = > {
tracing ::error! ( " [h5] 首页计数:柜子范围查询失败,充满/故障按 0 计 err={} " , e ) ;
return ( 0 , 0 ) ;
}
} ;
let entries = match super ::cabinet_tree ::fetch_channel_keys ( & state . mysql , & cabinet_ids ) . await {
Ok ( entries ) = > entries ,
Err ( e ) = > {
tracing ::error! ( " [h5] 首页计数:通道 key 查询失败,充满/故障按 0 计 err={} " , e ) ;
return ( 0 , 0 ) ;
}
} ;
let counts : HashMap < i64 , ChannelCounts > =
super ::cabinet_tree ::derive_counts_by_cabinet ( state , & entries ) . await ;
counts
. values ( )
. fold ( ( 0_ i64 , 0_ i64 ) , | ( full , fault ) , c | ( full + c . 2 , fault + c . 3 ) )
}
/// 取统计范围内的柜子 ID: 总管理员 = 全部柜子;组织用户 = 本组织**且落在数据范围内**的柜子;无组织 = 空集
/// (口径与 [`count_compartments_by_status`] 完全一致)
async fn fetch_scope_cabinet_ids (
pool : & sqlx ::MySqlPool ,
is_super : bool ,
org_id : Option < i64 > ,
scope : & ScopeFilter < '_ > ,
) -> Result < Vec < i64 > , sqlx ::Error > {
if is_super {
return sqlx ::query_scalar ( " SELECT id FROM cabinets " ) . fetch_all ( pool ) . await ;
}
match org_id {
Some ( oid ) = > {
let sql = format! (
r #" SELECT c.id FROM cabinets c
INNER JOIN projects p ON c . project_id = p . id
WHERE p . organization_id = ? { } " #,
scope . cond
) ;
let mut query = sqlx ::query_scalar ::< _ , i64 > ( & sql ) . bind ( oid ) ;
for bind in scope . binds {
query = query . bind ( bind ) ;
}
query . fetch_all ( pool ) . await
}
// `organization_id` 为 NULL 的客户侧账号看不到任何设备(空集,不是报错,也不退化成"全部")
None = > Ok ( Vec ::new ( ) ) ,
}
}
/// 今日充电次数(带组织隔离 + 数据范围)
2026-07-02 05:38:01 +08:00
async fn count_today_charges (
pool : & sqlx ::MySqlPool ,
is_super : bool ,
org_id : Option < i64 > ,
2026-09-20 02:44:51 +08:00
scope : & ScopeFilter < '_ > ,
2026-07-02 05:38:01 +08:00
) -> 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 {
2026-09-20 02:44:51 +08:00
let sql = format! (
2026-07-02 05:38:01 +08:00
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
2026-09-20 02:44:51 +08:00
WHERE DATE ( cr . start_time ) = CURDATE ( ) AND p . organization_id = ? { } " #,
scope . cond
) ;
let mut query = sqlx ::query_scalar ::< _ , i64 > ( & sql ) . bind ( org_id . unwrap ( ) ) ;
for bind in scope . binds {
query = query . bind ( bind ) ;
}
query . fetch_one ( pool ) . await ?
2026-07-02 05:38:01 +08:00
} ;
Ok ( count )
}
2026-09-20 02:44:51 +08:00
/// 今日充电量( kWh, 带组织隔离 + 数据范围)
2026-07-02 05:38:01 +08:00
async fn sum_today_energy (
pool : & sqlx ::MySqlPool ,
is_super : bool ,
org_id : Option < i64 > ,
2026-09-20 02:44:51 +08:00
scope : & ScopeFilter < '_ > ,
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 {
2026-09-20 02:44:51 +08:00
let sql = format! (
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
2026-09-20 02:44:51 +08:00
WHERE DATE ( cr . start_time ) = CURDATE ( ) AND p . organization_id = ? { } " #,
scope . cond
) ;
let mut query = sqlx ::query_scalar ::< _ , Option < i64 > > ( & sql ) . bind ( org_id . unwrap ( ) ) ;
for bind in scope . binds {
query = query . bind ( bind ) ;
}
query . fetch_one ( pool ) . await ?
2026-07-02 05:38:01 +08:00
} ;
2026-07-03 10:20:29 +08:00
Ok ( energy . unwrap_or ( 0 ) )
2026-07-02 05:38:01 +08:00
}
2026-09-20 02:44:51 +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-09-20 02:44:51 +08:00
scope : & ScopeFilter < '_ > ,
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 {
2026-09-20 02:44:51 +08:00
let sql = format! (
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
2026-09-20 02:44:51 +08:00
WHERE DATE ( cr . start_time ) = CURDATE ( ) AND p . organization_id = ? { } " #,
scope . cond
) ;
let mut query = sqlx ::query_scalar ::< _ , Option < i64 > > ( & sql ) . bind ( org_id . unwrap ( ) ) ;
for bind in scope . binds {
query = query . bind ( bind ) ;
}
query . fetch_one ( pool ) . await ?
2026-07-02 05:38:01 +08:00
} ;
2026-07-03 10:20:29 +08:00
Ok ( total_seconds . unwrap_or ( 0 ) )
2026-07-02 05:38:01 +08:00
}
2026-09-20 02:44:51 +08:00
/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离 + 数据范围)
2026-07-02 05:38:01 +08:00
async fn fetch_fault_alerts (
pool : & sqlx ::MySqlPool ,
is_super : bool ,
org_id : Option < i64 > ,
2026-09-20 02:44:51 +08:00
scope : & ScopeFilter < '_ > ,
2026-07-02 05:38:01 +08:00
) -> 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 " #
2026-09-20 02:44:51 +08:00
. to_string ( )
2026-07-02 05:38:01 +08:00
} else {
2026-09-20 02:44:51 +08:00
format! (
r #" SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
2026-07-02 05:38:01 +08:00
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
2026-09-20 02:44:51 +08:00
WHERE comp . status = 2 AND p . organization_id = ? { }
2026-07-02 05:38:01 +08:00
ORDER BY comp . updated_at DESC
2026-09-20 02:44:51 +08:00
LIMIT 10 " #,
scope . cond
)
2026-07-02 05:38:01 +08:00
} ;
let rows : Vec < ( String , String , i8 ) > = if is_super {
2026-09-20 02:44:51 +08:00
sqlx ::query_as ( & base_sql ) . fetch_all ( pool ) . await ?
2026-07-02 05:38:01 +08:00
} else {
2026-09-20 02:44:51 +08:00
let mut query = sqlx ::query_as ::< _ , ( String , String , i8 ) > ( & base_sql ) . bind ( org_id . unwrap ( ) ) ;
for bind in scope . binds {
query = query . bind ( bind ) ;
}
query . fetch_all ( pool ) . await ?
2026-07-02 05:38:01 +08:00
} ;
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)
2026-09-20 02:44:51 +08:00
///
/// **数据范围(项目级)**:组织隔离给出"哪些组织的项目",再按 `CurrentUser::scope`
/// 过滤掉范围外的项目(未配置 = 不限制;总管理员豁免)。
2026-09-20 00:01:01 +08:00
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 02:44:51 +08:00
let scope = user . effective_scope ( ) ;
if scope . is_unrestricted ( ) {
return Ok ( projects ) ;
}
Ok ( projects
. into_iter ( )
. filter ( | p | scope . allows_project ( Some ( p . id ) ) )
. collect ( ) )
2026-09-20 00:01:01 +08:00
}
/// 汇总单个项目的柜子数 / 充电中 / 空闲 / 故障仓体数
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 02:44:51 +08:00
// ---------------------------------------------------------------------------
// 可见组织(小程序专用)
// ---------------------------------------------------------------------------
/// 可见组织行(**只暴露必要字段**: `id` / `name`)
///
/// 字段名与 `GET /api/organizations`(管理端 `OrganizationRow`)保持一致,便于前端复用同一类型;
/// 但**故意不带** `parent_id` / `disabled` 等管理端字段 —— 小程序只需要渲染组织名。
#[ derive(Debug, Serialize, sqlx::FromRow) ]
struct VisibleOrganizationRow {
id : i64 ,
name : String ,
}
/// 工厂侧(总管理员)可见全部组织
const VISIBLE_ORGS_ALL_SQL : & str = " SELECT id, name FROM organizations ORDER BY id " ;
/// 客户侧用户只可见自己所属组织(`users.organization_id`)
const VISIBLE_ORGS_BY_ID_SQL : & str = " SELECT id, name FROM organizations WHERE id = ? " ;
/// 可见组织范围(由 [`visible_org_scope`] 依据角色算出;纯数据,便于单测)
#[ derive(Debug, PartialEq, Eq) ]
enum VisibleOrgScope {
/// 工厂/总管理员:全部组织
All ,
/// 组织管理员/操作员:仅本组织
Own ( i64 ) ,
/// 无组织归属:空集(**返回空数组,不报错**)
Empty ,
}
/// 角色等级 + 所属组织 → 可见组织范围(纯函数,组织隔离的唯一判定点)
///
/// **组织可见性口径(全系统统一,见 AGENTS.md「组织架构」) : **
/// - `role_level >= 2`(工厂/总管理员)→ 全部组织;
/// - 其他(组织管理员 `1` / 操作员 `0`)→ 仅 `users.organization_id` 指向的那一个组织;
/// - `organization_id` 为 NULL → 空集。
fn visible_org_scope ( role_level : i32 , organization_id : Option < i64 > ) -> VisibleOrgScope {
if role_level > = 2 {
return VisibleOrgScope ::All ;
}
match organization_id {
Some ( org_id ) = > VisibleOrgScope ::Own ( org_id ) ,
None = > VisibleOrgScope ::Empty ,
}
}
/// GET /api/my-organizations — 当前登录用户**可见**的组织列表(小程序「选择组织」用)
///
/// **权限:仅需登录**(与 `GET /api/dashboard` 一致,内部按角色做组织隔离),
/// **不校验 `org:view`** —— 操作员(`normal_user`)的授权清单里没有该权限码
/// ( `db/migrate.rs::seed_roles`),沿用管理端接口会 403、组织弹层永远为空。
///
/// **隔离(核心安全边界)**:响应只包含当前用户有权看到的组织 ——
/// 工厂(`role_level >= 2`)全量;客户侧(组织管理员/操作员)**只有自己所属组织**,
/// 绝不把全量组织泄露给客户角色。
///
/// 管理端的 `GET /api/organizations`( `org:view` + 全量)语义保持原样,不做任何改动。
pub async fn list_my_organizations (
user : CurrentUser ,
State ( state ) : State < AppState > ,
) -> Result < Json < Value > , AppError > {
let pool = & state . mysql ;
let rows : Vec < VisibleOrganizationRow > = match visible_org_scope ( user . role_level , user . organization_id )
{
// 工厂:全部组织
VisibleOrgScope ::All = > {
sqlx ::query_as ::< _ , VisibleOrganizationRow > ( VISIBLE_ORGS_ALL_SQL )
. fetch_all ( pool )
. await ?
}
// 客户侧:**唯一的组织过滤点** —— `WHERE id = <users.organization_id>`
VisibleOrgScope ::Own ( org_id ) = > {
sqlx ::query_as ::< _ , VisibleOrganizationRow > ( VISIBLE_ORGS_BY_ID_SQL )
. bind ( org_id )
. fetch_all ( pool )
. await ?
}
// 无组织归属:空数组(不是错误)
VisibleOrgScope ::Empty = > Vec ::new ( ) ,
} ;
// 包装格式与 `GET /api/organizations` 完全一致(`data` 直接是组织数组,字段 id/name)
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : rows ,
" message " : " ok "
} ) ) )
}
2026-07-02 05:38:01 +08:00
// ---------------------------------------------------------------------------
// 设备列表
// ---------------------------------------------------------------------------
2026-09-20 02:44:51 +08:00
// --- 设备列表 SQL( 由原函数体逐字搬移; `channel_count` / `idle_count` / `charging_count` 子查询与 CAST 未改动) ---
//
// ⚠️ **口径变更(产品决定,本次)**: `compartments.status` 只表达**占用**语义
// ( `0 空闲 / 1 充电中` —— 平台只写这两个值,全仓没有任何写入点会写 2/3) ,
// "充满 / 故障"**不落库**。因此这 5 条分支 SQL( 按项目 / 项目+组织 / 组织 / 全部 / 本组织)
// 里的 `full_count` / `fault_count` 子查询(`comp.status = 2 / 3`)已**整体删除**
// —— 它们在 DB 里恒为 0, 留着只是白白多跑两次子查询; 改为由通道实时状态派生:
// [`load_cabinet_realtime`] 批量读通道状态 → `cabinet_tree::derive_channel_counts`
// → [`build_cabinet_list`] 写回响应。**5 条分支共用同一条派生链路,一处在、处处在。**
// 响应字段名 `full_count` / `fault_count`(以及 `idle_count` / `charging_count`) **保持不变**。
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 ,
2026-09-20 02:44:51 +08:00
( 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
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 ,
2026-09-20 02:44:51 +08:00
( 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
2026-07-04 04:03:02 +08:00
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 ,
2026-09-20 02:44:51 +08:00
( 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
2026-07-04 04:03:02 +08:00
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 ,
2026-09-20 02:44:51 +08:00
( 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
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 ,
2026-09-20 02:44:51 +08:00
( 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
2026-07-04 04:03:02 +08:00
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 > > ,
2026-09-20 04:31:42 +08:00
/// 设备在线( Redis `device:{imei}` 的 `online` 字段)
///
/// 判据与 PC 设备列表的 `is_online` **同源**( `cabinet_tree::device_online_flag`) :
/// `Some(true)` 在线 / `Some(false)` 明确离线 / `None` = **无数据**(键或字段不存在)。
/// ⚠️ 与 DB 的 `cabinets.status`(启用/禁用**配置态**) **没有任何关系** ——
/// 小程序卡片不得再用 `cab.status` 推断在线。
online : HashMap < String , Option < bool > > ,
2026-09-20 02:44:51 +08:00
/// **柜级**柜体状态1( 协议 §3.8, 2 位小写 hex 原样透出;`"10"` = 柜门打开)
///
/// 取不到( Redis 缺字段 / 设备离线 / Redis 不可用)→ `None` → 响应 `null`,
/// **绝不落 `"00"`**( `"00"` 是"全部正常"的有效值)。位域解析在前端纯函数里做。
status1 : HashMap < String , Option < String > > ,
/// **柜级**柜体状态2( 协议 §3.8, bit0 声光报警 / bit1 柜门打开超时)
status2 : HashMap < String , Option < String > > ,
/// 柜子ID → 通道派生四计数 `(idle, charging, full, fault)`
///
/// 只有 `.2`(充满)/ `.3`(故障)会被写进响应:`idle_count` / `charging_count`
/// 用 DB 的占用值(`compartments.status` 0/1) , **派生值不覆盖它们**。
/// 表里没有的柜子(无通道 / Redis 不可用)→ 调用方按 `(0,0,0,0)` 处理,
/// **离线无法判定,充满数与故障数一律记 0**。
counts : HashMap < i64 , ChannelCounts > ,
2026-09-20 00:01:01 +08:00
}
/// 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 "
} ) ) )
}
/// 按「项目 / 角色」优先级查询用户可见的柜子行(分支顺序与原实现逐条对应)
2026-09-20 02:44:51 +08:00
///
/// 最后统一叠加**数据范围(项目级)**的内存过滤:未配置范围时原样返回(与今天完全一致)。
2026-09-20 00:01:01 +08:00
async fn fetch_cabinet_rows (
pool : & sqlx ::MySqlPool ,
user : & CurrentUser ,
project_id : Option < i64 > ,
org_id : Option < i64 > ,
) -> Result < Vec < H5CabinetRow > , AppError > {
2026-09-20 02:44:51 +08:00
let rows = if let Some ( pid ) = project_id {
2026-09-20 00:01:01 +08:00
// 按项目过滤(同时验证组织权限)
2026-09-20 02:44:51 +08:00
fetch_cabinet_rows_by_project ( pool , user , pid ) . await ?
2026-09-20 00:01:01 +08:00
} else if user . role_level > = 2 {
// 总管理员:按组织过滤或看全部
2026-09-20 02:44:51 +08:00
fetch_cabinet_rows_for_admin ( pool , org_id ) . await ?
2026-09-20 00:01:01 +08:00
} else if let Some ( org_id ) = user . organization_id {
// 企业管理员只看本组织
2026-09-20 02:44:51 +08:00
sqlx ::query_as ::< _ , H5CabinetRow > ( CABINETS_BY_USER_ORG_SQL )
2026-09-20 00:01:01 +08:00
. bind ( org_id )
. fetch_all ( pool )
2026-09-20 02:44:51 +08:00
. await ?
2026-07-02 05:38:01 +08:00
} else {
2026-09-20 02:44:51 +08:00
vec! [ ]
} ;
Ok ( retain_in_scope ( rows , user ) )
}
/// 数据范围(项目级)内存过滤:未配置范围 / 总管理员时原样返回
fn retain_in_scope ( rows : Vec < H5CabinetRow > , user : & CurrentUser ) -> Vec < H5CabinetRow > {
let scope = user . effective_scope ( ) ;
if scope . is_unrestricted ( ) {
return rows ;
2026-09-20 00:01:01 +08:00
}
2026-09-20 02:44:51 +08:00
rows . into_iter ( )
. filter ( | row | scope . allows_project ( row . project_id ) )
. collect ( )
2026-09-20 00:01:01 +08:00
}
/// 项目过滤分支:总管理员不带组织条件,企业管理员额外校验项目归属组织
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 02:44:51 +08:00
/// HMGET 结果里的柜级 hex 位域取值(协议 §3.8) : `None` / 空串 / 纯空白都算"无数据"
/// (→ 响应 `null`) , **绝不落 `"00"`**( `"00"` 是"全部正常"的有效值,与"没数据"必须区分)。
/// 非空值**原样返回**,不解析、不改写大小写 —— 位域解析统一由前端纯函数完成。
fn pick_non_empty_hex ( raw : Option < & Option < String > > ) -> Option < String > {
raw . and_then ( | v | v . as_ref ( ) )
. map ( | v | v . trim ( ) )
. filter ( | v | ! v . is_empty ( ) )
. map ( str ::to_string )
}
/// 从 Redis 批量读取柜子实时状态( Pipeline HMGET, 1 次网络往返)///
/// 注意:柜级 pipeline 只对带 IMEI 的行下发命令,结果按「行下标」对齐,
2026-09-20 00:01:01 +08:00
/// 与重构前的行为完全一致(这是既有约定,不做修正)。
2026-09-20 02:44:51 +08:00
///
/// 通道级派生计数([`CabinetRealtimeMaps::counts`])单独走**自己的一条 pipeline**,
/// 键由 DB 的「板号 + 通道号」拼出(`cabinet_tree::fetch_channel_keys`) ,
/// 因此不受上面那条「按行下标对齐」的历史约定影响, 5 条分支 SQL 全部共用本函数。
2026-09-20 00:01:01 +08:00
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} " ) ;
2026-09-20 04:31:42 +08:00
// ⚠️ 字段顺序由 CABINET_HMGET_FIELDS 唯一决定,与下面的下标解析一一对应
pipe . cmd ( " HMGET " ) . arg ( & key ) . arg ( & CABINET_HMGET_FIELDS ) ;
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-20 02:44:51 +08:00
// 柜级状态位:原样透出 hex; 空串/缺失 → None( 响应 null, 不落 "00")
maps . status1 . insert ( imei . clone ( ) , pick_non_empty_hex ( vals . get ( 4 ) ) ) ;
maps . status2 . insert ( imei . clone ( ) , pick_non_empty_hex ( vals . get ( 5 ) ) ) ;
2026-09-20 04:31:42 +08:00
// 在线(下标 CABINET_HMGET_ONLINE_IDX) : 判据**唯一权威纯函数**,与设备列表同源
maps . online . insert (
imei . clone ( ) ,
super ::cabinet_tree ::device_online_flag (
vals . get ( CABINET_HMGET_ONLINE_IDX )
. and_then ( | v | v . as_ref ( ) )
. map ( String ::as_str ) ,
) ,
) ;
2026-09-19 23:40:46 +08:00
}
2026-07-04 04:03:02 +08:00
}
}
}
}
}
2026-09-20 02:44:51 +08:00
// 通道级:按柜派生「充满数 / 故障数」( DB 里 `compartments.status` 只写 0/1, 给不出这两个)
let cabinet_ids : Vec < i64 > = rows . iter ( ) . map ( | r | r . id ) . collect ( ) ;
let entries = match super ::cabinet_tree ::fetch_channel_keys ( & state . mysql , & cabinet_ids ) . await {
Ok ( entries ) = > entries ,
Err ( e ) = > {
tracing ::error! ( " [h5] 通道 key 查询失败,充满/故障计数按 0 计 err={} " , e ) ;
Vec ::new ( )
}
} ;
maps . counts = super ::cabinet_tree ::derive_counts_by_cabinet ( state , & entries ) . await ;
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 列表(字段名与语义与原实现逐字一致)
2026-09-20 02:44:51 +08:00
///
/// `full_count` / `fault_count` 的**取值来源变了**(字段名不变):不再来自 SQL
/// ( `compartments.status = 2/3` 在 DB 里永远不成立),改为由通道实时状态派生
/// ( [`CabinetRealtimeMaps::counts`],规则见 `cabinet_tree::derive_channel_counts`)。
/// 派生不到数据(设备离线 / 无该通道键 / Redis 不可用)→ **按 0 计,不猜测**。
2026-09-20 00:01:01 +08:00
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-20 02:44:51 +08:00
// 柜级状态位(协议 §3.8):原样透出 2 位 hex; 取不到 → null( 不落 "00")
let status1 = imei . as_ref ( ) . and_then ( | i | realtime . status1 . get ( i ) . cloned ( ) ) . flatten ( ) ;
let status2 = imei . as_ref ( ) . and_then ( | i | realtime . status2 . get ( i ) . cloned ( ) ) . flatten ( ) ;
2026-09-20 04:31:42 +08:00
// **实时在线**( Redis `device:{imei}.online`) : `true` 在线 / `false` 明确离线 /
// `null` 无数据。字段名与 PC 端一致(`is_online`),小程序两端都消费它。
// ⚠️ 与 `status`(启用/禁用配置态)严格分离:`status === 2` 仍单独渲染「禁用」。
let is_online = imei . as_ref ( ) . and_then ( | i | realtime . online . get ( i ) . copied ( ) ) . flatten ( ) ;
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-09-20 02:44:51 +08:00
// `ChannelCounts = (idle, charging, full, fault)`:只取派生出的充满/故障
let ( _ , _ , full_count , fault_count ) =
realtime . counts . get ( & r . id ) . copied ( ) . unwrap_or ( ( 0 , 0 , 0 , 0 ) ) ;
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 ,
2026-09-20 04:31:42 +08:00
// 实时在线( Redis, 判据与设备列表 `is_online` 同源);`status` 仍是配置态
" is_online " : is_online ,
2026-07-02 05:38:01 +08:00
" 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 ,
2026-09-20 02:44:51 +08:00
" full_count " : full_count ,
" fault_count " : fault_count ,
2026-07-04 04:03:02 +08:00
" rssi " : rssi ,
2026-09-19 23:40:46 +08:00
" pow_fail_dc " : pow_fail_dc ,
" pow_fail_ac " : pow_fail_ac ,
" temp " : temp ,
2026-09-20 02:44:51 +08:00
// **柜级**状态位(协议 §3.8):原样 hex 字符串,取不到为 null
" status1 " : status1 ,
" status2 " : status2 ,
2026-09-19 23:40:46 +08:00
" 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 = ? " ;
2026-09-20 00:36:37 +08:00
/// 仓体所属仓控板号 + 柜子 IMEI( 拼通道级 Redis key 用)
const CABIN_BOARD_REDIS_TARGET_SQL : & str = r #" SELECT cb.board_id, c.imei
FROM cabin_boards cb
INNER JOIN cabinets c ON c . id = cb . cabinet_id
WHERE cb . id = ? " #;
2026-09-20 00:01:01 +08:00
/// 仓体最近 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
2026-09-20 00:36:37 +08:00
// 通道级实时数据 key 需要( IMEI, 仓控板号, 通道号);
// 关联缺失时记录并整体降级为「无数据」(字段 null) , 不影响 MySQL 主体数据
let redis_target : Option < ( i8 , String ) > = sqlx ::query_as ( CABIN_BOARD_REDIS_TARGET_SQL )
. bind ( cabin_board_id )
. fetch_optional ( pool )
. await ? ;
let realtime = match redis_target {
Some ( ( board_id , imei ) ) = > {
load_compartment_realtime ( & state , & imei , board_id , channel_id ) . await
}
None = > {
tracing ::warn! (
comp_id ,
cabin_board_id ,
" [h5] 仓体缺少柜子/仓控板关联,实时数据按「无数据」返回 "
) ;
CompartmentRealtime ::default ( )
}
} ;
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 ? ;
2026-09-20 02:44:51 +08:00
// 展示状态:**有通道实时数据时按派生值**给出 `0空闲 / 1充电中 / 2充满 / 3故障`
// (优先级 fault > full > charging > idle, 与 PC/小程序三处映射口径一致);
// **拿不到数据时回落到 DB 的占用值**( 0/1) —— 离线无法判定,不猜测。
// 字段名与类型不变(前端 `data.status` 直接消费),只是取值来源变了。
let flags = derive_channel_flags ( realtime . batt_status . as_deref ( ) , realtime . pow_status . as_deref ( ) ) ;
let display_status = if flags . has_data { flags . status_code ( ) } else { status } ;
2026-07-02 05:38:01 +08:00
Ok ( Json ( json! ( {
" code " : 0 ,
" data " : {
" id " : comp_id ,
" channel_id " : channel_id ,
2026-09-20 02:44:51 +08:00
" status " : display_status ,
2026-07-02 05:38:01 +08:00
" cabin_board_id " : cabin_board_id ,
2026-09-20 00:36:37 +08:00
// 以下实时字段全部取自设备上报真实写入的 Redis key;
// 取不到时返回 null( **不落 0**),避免把"无数据"伪装成 0
" voltage " : realtime . voltage ,
" current " : realtime . current ,
// energy 没有实时数据源(通道级 Hash 不含电量累计),如实返回 null
" energy " : Value ::Null ,
" soc " : realtime . soc ,
" temp " : realtime . temp ,
// 电池状态 / 电源状态:两个相互独立的维度(协议 §3.8) ,
// 小程序 `v-if="data.batt_status != null || data.pow_status != null"` 依赖它们
" batt_status " : realtime . batt_status ,
" pow_status " : realtime . pow_status ,
// 以下 3 个字段当前**没有任何写入方**(协议未上报 BMS 单体明细),
// 保持空数组以维持既有字段类型(前端类型为 number[]/string[])
" cell_voltages " : json ! ( [ ] ) ,
" cell_temps " : json ! ( [ ] ) ,
" bms_alerts " : json ! ( [ ] ) ,
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 ( ( ) )
}
2026-09-20 00:36:37 +08:00
/// 仓体详情实时状态快照(全部来自**真实写入**的 Redis key)
///
/// 所有字段一律 `Option`:取不到就是 `None`(响应 JSON 为 `null`) , **绝不落 0** ——
/// 0 是"真实读数为 0",与"无数据"必须区分(旧实现把所有缺失都回落成 0,
/// 小程序上表现为 SOC 0.0% / 0.0V / 0.0A 这种"看起来有值但其实是假的 0")。
#[ derive(Default) ]
struct CompartmentRealtime {
/// 电池状态( 2 位 hex, 协议 §3.8)
batt_status : Option < String > ,
/// 电源状态( 2 位 hex, 与电池状态相互独立)
pow_status : Option < String > ,
/// SOC, 平台单位 ‰
soc : Option < i64 > ,
/// 充电电压,单位 mV
voltage : Option < i64 > ,
/// 充电电流,单位 mA
current : Option < i64 > ,
/// 电芯最高温度,单位 K× 100
temp : Option < i64 > ,
}
/// 通道级 Hash 字段名(顺序即 HMGET 结果下标,与 `report_worker::write_channel_state` 的写入一致)
const COMPARTMENT_REALTIME_FIELDS : [ & str ; 6 ] =
[ " batt_status " , " pow_status " , " soc " , " voltage " , " current " , " temp " ] ;
/// 从 Redis 读取仓体实时数据
///
/// **数据来源(均为真实写入的 key) : **
/// - 通道级 Hash `device:{imei}:board:{板号}:ch:{通道号}`:
/// `batt_status` / `pow_status` / `soc`(‰) / `voltage`(mV) / `current`(mA) / `temp`(K× 100)
/// —— 由 `workers/report_worker.rs::write_channel_state` 在每次 `status_post` 时写入;
/// - `bat_in` 登记键 `device:{imei}:board:{板号}:ch:{通道号}:bat` 的 `soc`:
/// 仅当通道级 Hash 里没有 `soc`(设备长时间未上报 / Hash 过期)时作为**兜底**,
/// 它是"最近一次插电池时的起始 SOC",语义上弱于实时值。
///
/// **为什么不再读 `compartment:status:{id}`: ** 该 key 在全仓**只有读取方、没有任何写入方**,
/// 取值恒为空 → 旧实现把它回落成 0, 导致 SOC/电压/电流/能耗永远显示 0。
///
/// 板号/通道号为十进制不补零,与上报写入侧保持一致;任何 Redis 异常都只降级为 `None`,
/// 不让详情接口整体失败。
async fn load_compartment_realtime (
state : & AppState ,
imei : & str ,
board_id : i8 ,
channel_id : i8 ,
) -> CompartmentRealtime {
let client = match redis ::Client ::open ( state . redis_url . as_str ( ) ) {
Ok ( c ) = > c ,
Err ( e ) = > {
tracing ::warn! ( " [h5] Redis 客户端创建失败,仓体实时数据降级 imei={} err={} " , imei , e ) ;
return CompartmentRealtime ::default ( ) ;
}
} ;
// 使用多路复用连接( MultiplexedConnection) , 替代已废弃的 aio::Connection
let mut conn = match client . get_multiplexed_async_connection ( ) . await {
Ok ( c ) = > c ,
Err ( e ) = > {
tracing ::warn! ( " [h5] Redis 连接失败,仓体实时数据降级 imei={} err={} " , imei , e ) ;
return CompartmentRealtime ::default ( ) ;
}
} ;
let ch_key = format! ( " device: {} :board: {} :ch: {} " , imei , board_id , channel_id ) ;
// 1) 通道级 Hash: 设备上报的真实实时值
let values : Result < Vec < Option < String > > , redis ::RedisError > = redis ::cmd ( " HMGET " )
. arg ( & ch_key )
. arg ( & COMPARTMENT_REALTIME_FIELDS )
. query_async ( & mut conn )
. await ;
let mut rt = match values {
Ok ( values ) = > parse_compartment_realtime ( & values ) ,
Err ( e ) = > {
tracing ::warn! (
" [h5] 通道状态读取失败,实时数据降级 key={} err={} " ,
ch_key ,
e
) ;
CompartmentRealtime ::default ( )
}
} ;
// 2) SOC 兜底:通道 Hash 没值时才回读 `bat_in` 登记键(起始 SOC)
if rt . soc . is_none ( ) {
let bat_key = format! ( " {} :bat " , ch_key ) ;
let registered : Result < Option < String > , redis ::RedisError > = redis ::cmd ( " HGET " )
. arg ( & bat_key )
. arg ( " soc " )
. query_async ( & mut conn )
. await ;
match registered {
Ok ( Some ( soc ) ) = > {
rt . soc = soc . trim ( ) . parse ::< i64 > ( ) . ok ( ) ;
tracing ::debug! ( " [h5] 通道无实时 SOC, 回退 bat_in 登记值 key={} " , bat_key ) ;
}
Ok ( None ) = > { }
Err ( e ) = > {
tracing ::warn! ( " [h5] bat_in 登记读取失败 key={} err={} " , bat_key , e ) ;
}
}
}
rt
}
/// HMGET 结果(顺序同 [`COMPARTMENT_REALTIME_FIELDS`])→ 实时状态快照
///
/// 缺失项或非数字项一律 `None`( **不落 0**)。
fn parse_compartment_realtime ( values : & [ Option < String > ] ) -> CompartmentRealtime {
/// 取第 i 项的非空字符串(空白串视为"无数据",避免前端把空 hex 当有效状态渲染)
fn pick ( values : & [ Option < String > ] , i : usize ) -> Option < String > {
values
. get ( i )
. and_then ( | v | v . as_ref ( ) )
. map ( | v | v . trim ( ) . to_string ( ) )
. filter ( | v | ! v . is_empty ( ) )
}
/// 取第 i 项并解析为 i64( 空值/非数字 → None)
fn pick_i64 ( values : & [ Option < String > ] , i : usize ) -> Option < i64 > {
pick ( values , i ) . and_then ( | v | v . parse ::< i64 > ( ) . ok ( ) )
}
CompartmentRealtime {
batt_status : pick ( values , 0 ) ,
pow_status : pick ( values , 1 ) ,
soc : pick_i64 ( values , 2 ) ,
voltage : pick_i64 ( values , 3 ) ,
current : pick_i64 ( values , 4 ) ,
temp : pick_i64 ( values , 5 ) ,
}
2026-09-20 00:01:01 +08:00
}
/// 充电记录行 → 响应 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-09-20 01:21:17 +08:00
///
/// ⚠️ **校验顺序(本 handler 的契约)**:
/// **权限 → 归属 → 设备启用 → 设备在线 → 建单 → 下发**。
/// 建单必须排在全部校验之后:否则设备离线/无权访问时接口虽然报错,订单却已经插进
/// `charge_orders`(幽灵单,只能等 10 小时超时结单, UI 上还会多出一次"充电中")。
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
2026-09-20 01:21:17 +08:00
// 一次查出柜子/仓控板/通道 + 充电工作模式
2026-09-19 23:40:46 +08:00
// ( `cabinets.work_mode` 是 TINYINT: 0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理)
2026-09-20 01:21:17 +08:00
let target : Option < ( i64 , String , i8 , i8 , Option < i8 > ) > = sqlx ::query_as (
2026-09-19 23:40:46 +08:00
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 )
2026-09-20 01:21:17 +08:00
. await ? ;
// 仓体不存在:与 [`send_compartment_command`] 的报错语义一致(同一 JOIN 条件)
let ( cabinet_id , imei , board_id , channel_id , mode ) =
target . ok_or_else ( | | AppError ::NotFound ( " 仓体对应的设备不存在 " . into ( ) ) ) ? ;
// 归属 → 设备启用 → 设备在线:全部**提前到建单之前**(顺序与 [`send_compartment_command`] 一致,
// 校验函数语义未改;下发路径仍会再校验一次,幂等无副作用)
verify_cabinet_access ( & user , cabinet_id , & state . mysql ) . await ? ;
ensure_cabinet_enabled ( & state . mysql , cabinet_id ) . await ? ;
ensure_device_online ( & state , & imei ) . await ? ;
// 只有**平台指令模式**( `work_mode="0"`/NULL) 才由平台下发 `on` 并在此建单;
// **自动充电模式**( `"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单
if mode . unwrap_or ( 0 ) = = 0 {
create_order_before_on ( & state , & body , cabinet_id , & imei , board_id , channel_id ) . await ;
} else {
tracing ::info! (
2026-09-19 23:40:46 +08:00
" [h5] 自动充电模式( work_mode=1) , 建单由设备上报的 on 负责 compartment_id={} " ,
body . compartment_id
2026-09-20 01:21:17 +08:00
) ;
2026-09-19 23:40:46 +08:00
}
send_compartment_command ( & state , & user , body . compartment_id , " on " ) . await
2026-07-02 05:38:01 +08:00
}
2026-09-20 01:21:17 +08:00
/// 平台下发 `on` **之前**建单(失败只记日志,绝不影响指令下发)。
///
/// 电池 id 与起始 SOC 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`)。
/// ⚠️ 登记键 `device:{imei}:board:{b}:ch:{c}:bat` 的 TTL 只有 48 小时
/// ( [`crate::workers::report_worker`] 的 `CHANNEL_BAT_TTL_SECS`)—— 集中充电柜里电池常在仓内
/// 放置超过 48 小时,登记过期后**必须仍按"未知电池"建单**(与设备侧 `handle_device_on` 的兜底
/// 口径一致):否则整段会话没有 `charge_orders` / `charge_records` / 电量,
/// 用户在"充电记录"里看不到这次充电,属于**平台建单漏单**。
async fn create_order_before_on (
state : & AppState ,
body : & CompartmentActionInput ,
cabinet_id : i64 ,
imei : & str ,
board_id : i8 ,
channel_id : i8 ,
) {
let ( battery_id , start_soc ) = match crate ::workers ::report_worker ::load_channel_battery (
& state . redis ,
imei ,
board_id ,
channel_id ,
)
. await
{
Some ( battery ) = > ( battery . battery_id , Some ( battery . soc ) ) ,
None = > {
tracing ::warn! (
" [h5] 该通道无 bat_in 登记,按未知电池建单 compartment_id={} " ,
body . compartment_id
) ;
( String ::new ( ) , None )
}
} ;
// `ensure_charge_order` 内部自带「按仓体进程内锁 + 条件插入 + 唯一索引」,
// 重复下发 / 双击不会多建单
if let Err ( e ) = crate ::workers ::report_worker ::ensure_charge_order (
& state . mysql ,
& state . redis ,
imei ,
cabinet_id ,
board_id ,
channel_id ,
& battery_id ,
start_soc ,
)
. await
{
tracing ::error! (
" [h5] 下发 on 前建单失败 compartment_id={} err={} " ,
body . compartment_id ,
e
) ;
}
}
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 > ,
2026-09-20 00:36:37 +08:00
/// 处理状态筛选: 0=待处理 1=已处理;缺省/不传 = 全部。
/// 必须显式声明,否则 serde 会**静默丢弃**前端传来的 `status`(前端筛选点了没反应)。
status : Option < i8 > ,
2026-09-19 23:40:46 +08:00
}
#[ 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 ,
}
2026-09-20 03:23:00 +08:00
/// 反馈列表的 WHERE 子句与**文本**绑定值
///
/// - 组织隔离条件(文本)在 SQL 中**先于** `status` ⇒ 调用方必须先绑文本值、
/// 最后单独绑 `status`(整型);
/// - 工厂(`role_level >= 2`)不加组织条件(全量);
/// - 客户侧无组织 → `1=0` 空集。
fn feedback_filter ( user : & CurrentUser , has_status : bool ) -> ( String , Vec < String > ) {
let mut conditions : Vec < String > = Vec ::new ( ) ;
let mut binds : Vec < String > = Vec ::new ( ) ;
// 归属:反馈提交人必须属于当前用户所在组织(`user_feedback.user_id → users.organization_id`)
if user . role_level < auth ::SUPER_ADMIN_ROLE_LEVEL {
match user . organization_id {
Some ( org_id ) = > {
conditions . push (
" EXISTS (SELECT 1 FROM users u \
WHERE u . id = user_feedback . user_id AND u . organization_id = ? ) "
. to_string ( ) ,
) ;
binds . push ( org_id . to_string ( ) ) ;
}
None = > conditions . push ( " 1=0 " . to_string ( ) ) ,
}
}
if has_status {
conditions . push ( " status = ? " . to_string ( ) ) ;
}
if conditions . is_empty ( ) {
( String ::new ( ) , binds )
} else {
( format! ( " WHERE {} " , conditions . join ( " AND " ) ) , binds )
}
}
/// 校验当前用户是否有权处置(查看状态/改状态)某条反馈
///
/// 归属口径与 [`feedback_filter`] 完全一致:工厂豁免;客户侧必须是**本组织成员**提交的反馈。
/// 反馈不存在 → `NotFound`(保持原有 404 语义);存在但不属于本组织 → `Forbidden`
/// ( **不返回 404**,避免"改别人的反馈"被静默当成"已处理成功")。
async fn verify_feedback_ownership (
pool : & sqlx ::MySqlPool ,
user : & CurrentUser ,
feedback_id : i64 ,
) -> Result < ( ) , AppError > {
if user . role_level > = auth ::SUPER_ADMIN_ROLE_LEVEL {
return Ok ( ( ) ) ;
}
let Some ( org_id ) = user . organization_id else {
return Err ( AppError ::Forbidden ( " 无权访问该资源 " . into ( ) ) ) ;
} ;
let owner_org : Option < Option < i64 > > = sqlx ::query_scalar (
" SELECT u.organization_id FROM user_feedback f \
JOIN users u ON u . id = f . user_id WHERE f . id = ? " ,
)
. bind ( feedback_id )
. fetch_optional ( pool )
. await ? ;
match owner_org {
None = > Err ( AppError ::NotFound ( " 反馈记录不存在 " . into ( ) ) ) ,
Some ( Some ( org ) ) if org = = org_id = > Ok ( ( ) ) ,
_ = > Err ( AppError ::Forbidden ( " 无权访问该资源 " . into ( ) ) ) ,
}
}
2026-09-19 23:40:46 +08:00
/// GET /api/h5/feedback — 反馈列表(管理端)
///
2026-09-20 00:36:37 +08:00
/// 权限:反馈内容里带全部用户的**联系方式**,属于管理端审计类数据,
/// 因此不能只校验登录态 —— 复用权限种子里真实存在的 `operation_log:view`
/// ( `db/migrate.rs::seed_roles`:总管理员全量、企业管理员含该码、操作员**没有**该码),
/// 语义上「查看管理端记录/日志」与「查看用户反馈」最贴近;
/// 操作员即使登录也会被 403 拦住。
///
2026-09-20 03:23:00 +08:00
/// **组织隔离(本次修复)**:反馈按**提交人**归属 ——
/// `user_feedback.user_id → users.organization_id`(与操作日志同一口径)。
/// 历史缺陷:本接口只校验了 `operation_log:view` 就全量查询 ⇒ 任何**企业管理员**
/// 都能读到其它客户组织的反馈(含联系方式)并改其处理状态。
/// 规则:工厂(`role_level >= 2`)全量;客户侧仅本组织成员提交的反馈;
/// `organization_id` 为 `NULL` → 空集(**绝不退化成全量**)。
/// 数据范围(`user_scopes`) **不叠加**:反馈与项目/设备没有关联列,项目级范围无法映射
/// (与操作日志完全同一口径,见 AGENTS.md「数据范围」一节的说明) 。
///
2026-09-20 00:36:37 +08:00
/// `status` 为可选筛选条件,落到 SQL( 不影响未传参时的原有查询语义) 。
2026-09-19 23:40:46 +08:00
pub async fn list_feedback (
2026-09-20 00:36:37 +08:00
user : CurrentUser ,
2026-09-19 23:40:46 +08:00
State ( state ) : State < AppState > ,
Query ( query ) : Query < FeedbackListQuery > ,
) -> Result < Json < Value > , AppError > {
2026-09-20 00:36:37 +08:00
auth ::check_permission ( & user , " operation_log:view " ) ? ;
2026-09-19 23:40:46 +08:00
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 ;
2026-09-20 00:36:37 +08:00
let status = query . status ;
// 状态值口径与 update_feedback_status 保持一致( 0待处理 / 1已处理)
if let Some ( s ) = status {
if s ! = 0 & & s ! = 1 {
return Err ( AppError ::BadRequest ( " 状态值必须为0(待处理)/1(已处理) " . into ( ) ) ) ;
}
}
2026-09-19 23:40:46 +08:00
2026-09-20 03:23:00 +08:00
// 条件与绑定值一一对应:**文本条件(组织隔离)在前、`status`(整型)在后**,
// 因此绑定顺序同样是"先文本占位、后 status"(见下面的 count/list 绑定)
let ( filter , org_binds ) = feedback_filter ( & user , status . is_some ( ) ) ;
let mut binds = org_binds ;
2026-09-20 00:36:37 +08:00
let count_sql = format! ( " SELECT COUNT(*) FROM user_feedback {} " , filter ) ;
let mut count_query = sqlx ::query_scalar ::< _ , i64 > ( & count_sql ) ;
2026-09-20 03:23:00 +08:00
for bind in & binds {
count_query = count_query . bind ( bind ) ;
}
2026-09-20 00:36:37 +08:00
if let Some ( s ) = status {
count_query = count_query . bind ( s ) ;
}
let total : i64 = count_query . fetch_one ( pool ) . await ? ;
let list_sql = format! (
" SELECT id, user_id, content, contact, images, status, created_at
FROM user_feedback { } ORDER BY created_at DESC LIMIT ? OFFSET ? " ,
filter
) ;
let mut list_query = sqlx ::query_as ::< _ , FeedbackRow > ( & list_sql ) ;
2026-09-20 03:23:00 +08:00
for bind in binds . drain ( .. ) {
list_query = list_query . bind ( bind ) ;
}
2026-09-20 00:36:37 +08:00
if let Some ( s ) = status {
list_query = list_query . bind ( s ) ;
}
let rows : Vec < FeedbackRow > = list_query
. bind ( page_size )
. bind ( offset )
. fetch_all ( pool )
. await ? ;
2026-09-19 23:40:46 +08:00
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 ( ) ;
}
2026-09-20 02:44:51 +08:00
// 扩展名白名单 + 文件魔数嗅探(拒绝 .svg/.htm 等可脚本化类型,防存储型 XSS)
// 返回值是按内容嗅探出的规范扩展名;落盘后缀与真实内容一致
let ext = super ::upload_guard ::validate_image_upload ( & data , & ext ) ? ;
2026-09-19 23:40:46 +08:00
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 > {
2026-09-20 00:36:37 +08:00
// 与 list_feedback 同一权限码:能改处理状态的人必须能看反馈列表
// ( `operation_log:view` 在 `db/migrate.rs` 的权限种子里真实存在)
auth ::check_permission ( & user , " operation_log:view " ) ? ;
2026-09-19 23:40:46 +08:00
let pool = & state . mysql ;
if body . status ! = 0 & & body . status ! = 1 {
return Err ( AppError ::BadRequest ( " 状态值不合法 " . into ( ) ) ) ;
}
2026-09-20 03:23:00 +08:00
// 归属校验:客户侧只能处置本组织成员提交的反馈(工厂豁免)。
// 历史缺陷:原先只校验权限码就直接 UPDATE ⇒ 企业管理员能改别的组织的反馈状态。
verify_feedback_ownership ( pool , & user , id ) . await ? ;
2026-09-19 23:40:46 +08:00
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-09-20 02:44:51 +08:00
/// 验证用户是否有权访问指定柜子(组织数据隔离 + 数据范围)
2026-07-02 05:38:01 +08:00
///
2026-09-20 02:44:51 +08:00
/// 总管理员( role_level >= 2) 可访问所有柜子( 数据范围同样豁免) ;
/// 企业管理员只能访问本组织下项目关联的柜子,**且柜子所属项目必须在其数据范围内**;
2026-07-02 05:38:01 +08:00
/// 无组织关联的用户无权访问任何柜子。
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 ( ) ) ) ? ;
2026-09-20 02:44:51 +08:00
// 通过 cabinet → project → organization 链路验证归属,并叠加数据范围(项目级)
let ( scope_cond , scope_binds ) = user . effective_scope ( ) . project_condition ( " p.id " ) ;
let sql = format! (
2026-07-02 05:38:01 +08:00
r #" SELECT cab.id FROM cabinets cab
INNER JOIN projects p ON cab . project_id = p . id
2026-09-20 02:44:51 +08:00
WHERE cab . id = ? AND p . organization_id = ? { } " #,
scope_cond
) ;
let mut query = sqlx ::query_as ::< _ , ( i64 , ) > ( & sql ) . bind ( cabinet_id ) . bind ( org_id ) ;
for bind in & scope_binds {
query = query . bind ( bind ) ;
}
let access : Option < ( i64 , ) > = query . fetch_optional ( pool ) . await ? ;
2026-07-02 05:38:01 +08:00
if access . is_none ( ) {
return Err ( AppError ::Forbidden ( " 无权访问该设备 " . into ( ) ) ) ;
}
Ok ( ( ) )
}
2026-09-20 00:36:37 +08:00
// ---------------------------------------------------------------------------
// 单元测试
// ---------------------------------------------------------------------------
#[ cfg(test) ]
mod tests {
use super ::* ;
2026-09-20 02:44:51 +08:00
/// 小程序设备列表的柜级 hex 位域取值(协议 §3.8) :
/// `"00"`(全正常)必须原样保留;缺失 / 空串 / 纯空白必须是 `None`(响应 `null`)。
#[ test ]
2026-09-20 04:31:42 +08:00
fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing ( ) { assert_eq! (
2026-09-20 02:44:51 +08:00
pick_non_empty_hex ( Some ( & Some ( " 00 " . into ( ) ) ) ) . as_deref ( ) ,
Some ( " 00 " ) ,
" \" 00 \" 是「全部正常」的有效值,与「无数据」必须区分 "
) ;
assert_eq! (
pick_non_empty_hex ( Some ( & Some ( " 10 " . into ( ) ) ) ) . as_deref ( ) ,
Some ( " 10 " ) ,
" 柜门打开位原样透出 "
) ;
assert_eq! ( pick_non_empty_hex ( Some ( & Some ( " 01 " . into ( ) ) ) ) . as_deref ( ) , Some ( " 01 " ) ) ;
assert_eq! ( pick_non_empty_hex ( Some ( & Some ( String ::new ( ) ) ) ) , None ) ;
assert_eq! ( pick_non_empty_hex ( Some ( & Some ( " " . into ( ) ) ) ) , None ) ;
assert_eq! ( pick_non_empty_hex ( Some ( & None ) ) , None ) ;
assert_eq! ( pick_non_empty_hex ( None ) , None ) ;
}
2026-09-20 00:36:37 +08:00
/// 通道级 HMGET 六字段( batt_status/pow_status/soc/voltage/current/temp) 解析
#[ test ]
fn test_parse_compartment_realtime_reads_all_fields ( ) {
let values : Vec < Option < String > > = vec! [
Some ( " 82 " . into ( ) ) ,
Some ( " 03 " . into ( ) ) ,
Some ( " 855 " . into ( ) ) ,
Some ( " 53200 " . into ( ) ) ,
Some ( " 1500 " . into ( ) ) ,
Some ( " 29815 " . into ( ) ) ,
] ;
let rt = parse_compartment_realtime ( & values ) ;
assert_eq! ( rt . batt_status . as_deref ( ) , Some ( " 82 " ) ) ;
assert_eq! ( rt . pow_status . as_deref ( ) , Some ( " 03 " ) ) ;
assert_eq! ( rt . soc , Some ( 855 ) ) ;
assert_eq! ( rt . voltage , Some ( 53200 ) ) ;
assert_eq! ( rt . current , Some ( 1500 ) ) ;
assert_eq! ( rt . temp , Some ( 29815 ) ) ;
}
/// 无数据( key 不存在 → 全是 None) 时必须返回 None, 而不是 0
/// (这是 ② 的核心回归点:旧实现把"无数据"回落成 0.0% / 0.0V / 0.0A)
#[ test ]
fn test_parse_compartment_realtime_no_data_returns_none_not_zero ( ) {
let values : Vec < Option < String > > = vec! [ None , None , None , None , None , None ] ;
let rt = parse_compartment_realtime ( & values ) ;
assert_eq! ( rt . soc , None , " 无数据不能落 0 " ) ;
assert_eq! ( rt . voltage , None , " 无数据不能落 0 " ) ;
assert_eq! ( rt . current , None , " 无数据不能落 0 " ) ;
assert_eq! ( rt . temp , None ) ;
assert! ( rt . batt_status . is_none ( ) ) ;
assert! ( rt . pow_status . is_none ( ) ) ;
}
/// 真实的 0 必须原样保留( 0 与"无数据"不能混为一谈)
#[ test ]
fn test_parse_compartment_realtime_real_zero_is_kept ( ) {
let values : Vec < Option < String > > = vec! [
Some ( " 00 " . into ( ) ) ,
Some ( " 00 " . into ( ) ) ,
Some ( " 0 " . into ( ) ) ,
Some ( " 0 " . into ( ) ) ,
Some ( " 0 " . into ( ) ) ,
Some ( " 0 " . into ( ) ) ,
] ;
let rt = parse_compartment_realtime ( & values ) ;
assert_eq! ( rt . soc , Some ( 0 ) ) ;
assert_eq! ( rt . voltage , Some ( 0 ) ) ;
assert_eq! ( rt . current , Some ( 0 ) ) ;
assert_eq! ( rt . temp , Some ( 0 ) ) ;
assert_eq! ( rt . batt_status . as_deref ( ) , Some ( " 00 " ) ) ;
}
/// 非数字垃圾值/结果长度不足时按无数据处理,绝不 panic、绝不落 0
#[ test ]
fn test_parse_compartment_realtime_garbage_is_none ( ) {
let values : Vec < Option < String > > = vec! [ Some ( " abc " . into ( ) ) , Some ( " " . into ( ) ) ] ;
let rt = parse_compartment_realtime ( & values ) ;
assert_eq! ( rt . soc , None ) ;
assert_eq! ( rt . voltage , None ) ;
assert_eq! ( rt . current , None ) ;
assert_eq! ( rt . temp , None ) ;
// batt_status / pow_status 是 hex 字符串,原样透传(不在这里解释位含义)
assert_eq! ( rt . batt_status . as_deref ( ) , Some ( " abc " ) ) ;
}
/// HMGET 字段名顺序必须与解析下标一致(防止将来改字段顺序导致错位)
#[ test ]
fn test_compartment_realtime_fields_order_matches_indexes ( ) {
assert_eq! ( COMPARTMENT_REALTIME_FIELDS [ 0 ] , " batt_status " ) ;
assert_eq! ( COMPARTMENT_REALTIME_FIELDS [ 1 ] , " pow_status " ) ;
assert_eq! ( COMPARTMENT_REALTIME_FIELDS [ 2 ] , " soc " ) ;
assert_eq! ( COMPARTMENT_REALTIME_FIELDS [ 3 ] , " voltage " ) ;
assert_eq! ( COMPARTMENT_REALTIME_FIELDS [ 4 ] , " current " ) ;
assert_eq! ( COMPARTMENT_REALTIME_FIELDS [ 5 ] , " temp " ) ;
}
2026-09-20 02:44:51 +08:00
// -----------------------------------------------------------------------
// 可见组织范围(组织隔离)
// -----------------------------------------------------------------------
/// 工厂/总管理员:可见全部组织(即便自身也挂着 organization_id, 也不降级为本组织)
#[ test ]
fn test_visible_org_scope_super_admin_sees_all ( ) {
assert_eq! ( visible_org_scope ( 2 , None ) , VisibleOrgScope ::All ) ;
assert_eq! ( visible_org_scope ( 2 , Some ( 7 ) ) , VisibleOrgScope ::All ) ;
}
/// 操作员( role_level=0) : 只可见自己所属组织
#[ test ]
fn test_visible_org_scope_operator_sees_own_org_only ( ) {
assert_eq! ( visible_org_scope ( 0 , Some ( 42 ) ) , VisibleOrgScope ::Own ( 42 ) ) ;
}
/// 组织管理员( role_level=1) : 同样只可见自己所属组织( 不允许跨组织)
#[ test ]
fn test_visible_org_scope_org_admin_sees_own_org_only ( ) {
assert_eq! ( visible_org_scope ( 1 , Some ( 42 ) ) , VisibleOrgScope ::Own ( 42 ) ) ;
}
/// 客户侧无组织归属:空集(**不能退化成"全部"**,否则越权)
#[ test ]
fn test_visible_org_scope_org_user_without_org_is_empty ( ) {
assert_eq! ( visible_org_scope ( 0 , None ) , VisibleOrgScope ::Empty ) ;
assert_eq! ( visible_org_scope ( 1 , None ) , VisibleOrgScope ::Empty ) ;
}
/// 客户侧 SQL 必须带组织过滤条件(防止将来被改成全量查询导致越权)
#[ test ]
fn test_visible_orgs_own_sql_filters_by_id ( ) {
assert! (
VISIBLE_ORGS_BY_ID_SQL . contains ( " WHERE id = ? " ) ,
" 客户侧可见组织查询必须按 organization_id 过滤 "
) ;
assert_eq! ( visible_org_scope ( 0 , Some ( 1 ) ) , VisibleOrgScope ::Own ( 1 ) ) ;
}
2026-09-20 04:31:42 +08:00
// -----------------------------------------------------------------------
// 实时在线(`is_online`)守门 —— 防回归:不得再拿 `cabinets.status` 当"在线"
// -----------------------------------------------------------------------
/// 小程序列表的在线取数契约:
/// ① 在线字段**恰好出现一次**且位于清单末尾(下标常量与清单同步);
/// ② 字段名与设备列表 `is_online` **逐字一致**(跨模块同源守门)。
#[ test ]
fn test_h5_list_online_field_matches_device_list_criterion ( ) {
let online_field = crate ::routes ::cabinet_tree ::DEVICE_ONLINE_FIELD ;
assert_eq! (
CABINET_HMGET_ONLINE_IDX ,
CABINET_HMGET_FIELDS . len ( ) - 1 ,
" 在线字段必须追加在字段清单末尾(前面的下标解析依赖顺序) "
) ;
assert_eq! (
CABINET_HMGET_FIELDS [ CABINET_HMGET_ONLINE_IDX ] , online_field ,
" 小程序列表在线取数字段必须与设备列表同源 "
) ;
assert_eq! (
CABINET_HMGET_FIELDS . iter ( ) . filter ( | f | * * f = = online_field ) . count ( ) ,
1 ,
" 在线字段只允许出现一次 "
) ;
// 三态: 无数据( None) 不是"离线"( false) , 小程序据此可以区分"未知"
assert_eq! ( crate ::routes ::cabinet_tree ::device_online_flag ( None ) , None ) ;
assert_eq! (
crate ::routes ::cabinet_tree ::device_online_flag ( Some ( " 1 " ) ) ,
Some ( true )
) ;
assert_eq! (
crate ::routes ::cabinet_tree ::device_online_flag ( Some ( " 0 " ) ) ,
Some ( false )
) ;
}
/// 首页「在线设备」的取数 SQL **不得依赖 `cabinets.status`**,
/// 且必须保留组织隔离:总管理员无组织条件、客户侧必须带 `p.organization_id = ?`。
#[ test ]
fn test_h5_dashboard_online_sql_has_no_status_and_keeps_org_isolation ( ) {
let super_sql = online_cabinet_imeis_sql ( true , " " ) ;
assert! (
! super_sql . to_lowercase ( ) . contains ( " status " ) ,
" 在线统计不得引用 cabinets.status: {} " ,
super_sql
) ;
assert! ( super_sql . starts_with ( " SELECT cab.imei FROM cabinets cab " ) ) ;
assert! ( super_sql . contains ( " cab.imei IS NOT NULL " ) , " 必须排除 NULL IMEI: {} " , super_sql ) ;
let org_sql = online_cabinet_imeis_sql ( false , " AND p.id IN (?,?) " ) ;
assert! (
! org_sql . to_lowercase ( ) . contains ( " status " ) ,
" 在线统计不得引用 cabinets.status: {} " ,
org_sql
) ;
assert! (
org_sql . contains ( " INNER JOIN projects p ON cab.project_id = p.id " ) ,
" 客户侧必须经 projects 做组织隔离:{} " ,
org_sql
) ;
assert! ( org_sql . contains ( " p.organization_id = ? " ) , " 组织隔离条件丢失:{} " , org_sql ) ;
assert! ( org_sql . contains ( " AND p.id IN (?,?) " ) , " 数据范围条件丢失:{} " , org_sql ) ;
}
2026-09-20 00:36:37 +08:00
}