协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
1570 lines
58 KiB
Rust
1570 lines
58 KiB
Rust
//! H5 用户端 API
|
||
//!
|
||
//! 提供微信扫码充电的 H5 页面所需的全部后端接口:
|
||
//! - GET /api/dashboard — 首页仪表盘数据
|
||
//! - GET /api/cabinets — 设备列表(按项目过滤)
|
||
//! - GET /api/cabinets/:id — 设备详情(含仓板+仓体状态)
|
||
//! - GET /api/cabinets/abstract/:aid — 通过抽象ID查设备(扫码入口,免认证)
|
||
//! - GET /api/compartments/:id — 仓体/通道详情
|
||
//! - POST /api/charge/start — 开始充电
|
||
//! - POST /api/charge/stop — 停止充电
|
||
//! - POST /api/door/open — 开门
|
||
//! - GET /api/cabinets/scan/:abstract_id — 扫码验证
|
||
|
||
use axum::extract::{Multipart, Path, Query, State};
|
||
use axum::Json;
|
||
use redis;
|
||
use serde::Deserialize;
|
||
use serde_json::{json, Value};
|
||
use std::io::Write;
|
||
use uuid::Uuid;
|
||
|
||
use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD};
|
||
use crate::error::AppError;
|
||
use crate::middleware::auth::{self, CurrentUser};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 请求体定义
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 充电控制请求体(开始/停止充电、开门共用)
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct CompartmentActionInput {
|
||
compartment_id: i64,
|
||
}
|
||
|
||
/// 问题反馈请求体
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct FeedbackInput {
|
||
/// 反馈内容
|
||
content: String,
|
||
/// 联系方式(可选)
|
||
contact: Option<String>,
|
||
/// 图片URL列表(可选)
|
||
images: Option<Vec<String>>,
|
||
}
|
||
|
||
/// 设备列表查询参数
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct CabinetListQuery {
|
||
project_id: Option<i64>,
|
||
org_id: Option<i64>,
|
||
}
|
||
|
||
/// 仪表盘查询参数
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct DashboardQuery {
|
||
org_id: Option<i64>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 行映射结构体
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 项目简要信息行
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct ProjectRow {
|
||
id: i64,
|
||
name: String,
|
||
}
|
||
|
||
/// 柜子列表行(含通道统计)
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct H5CabinetRow {
|
||
id: i64,
|
||
abstract_id: String,
|
||
name: Option<String>,
|
||
status: i8,
|
||
project_id: Option<i64>,
|
||
imei: Option<String>,
|
||
iccid: Option<String>,
|
||
address: Option<String>,
|
||
latitude: Option<String>,
|
||
longitude: Option<String>,
|
||
channel_count: i64,
|
||
idle_count: i64,
|
||
charging_count: i64,
|
||
full_count: i64,
|
||
fault_count: i64,
|
||
}
|
||
|
||
/// 仓控板行
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct H5CabinBoardRow {
|
||
id: i64,
|
||
board_id: i8,
|
||
status: i8,
|
||
}
|
||
|
||
/// 仓体行
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct H5CompartmentRow {
|
||
id: i64,
|
||
channel_id: i8,
|
||
status: i8,
|
||
}
|
||
|
||
/// 充电记录简要行
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct ChargeRecordBriefRow {
|
||
id: i64,
|
||
start_time: Option<chrono::NaiveDateTime>,
|
||
end_time: Option<chrono::NaiveDateTime>,
|
||
energy: Option<String>,
|
||
}
|
||
|
||
/// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发)
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct CabinetImeiRow {
|
||
cabinet_id: i64,
|
||
imei: String,
|
||
board_id: i8,
|
||
channel_id: i8,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 仪表盘
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离)
|
||
pub async fn get_dashboard(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Query(query): Query<DashboardQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
// 根据角色确定组织过滤条件
|
||
// 总管理员可通过 org_id 参数切换组织
|
||
let is_super = user.role_level >= 2 && query.org_id.is_none();
|
||
let org_id = query.org_id.or(user.organization_id);
|
||
|
||
// 统计在线柜子数(带组织隔离)
|
||
let online_cabinets = count_online_cabinets(pool, is_super, org_id).await?;
|
||
// 统计总通道数(带组织隔离)
|
||
let total_channels = count_total_compartments(pool, is_super, org_id).await?;
|
||
|
||
// 统计充电中仓体数(带组织隔离)
|
||
let charging_count = count_compartments_by_status(pool, is_super, org_id, 1).await?;
|
||
|
||
// 统计空闲仓体数(带组织隔离)
|
||
let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0).await?;
|
||
|
||
// 统计故障仓体数(带组织隔离)
|
||
let fault_channels = count_compartments_by_status(pool, is_super, org_id, 2).await?;
|
||
|
||
// 统计充满仓体数(status=2,带组织隔离)
|
||
let full_count = count_compartments_by_status(pool, is_super, org_id, 2).await?;
|
||
|
||
// 统计故障数(status=3,带组织隔离)
|
||
let fault_count = count_compartments_by_status(pool, is_super, org_id, 3).await?;
|
||
|
||
// 今日充电次数(带组织隔离)
|
||
let today_charge_count = count_today_charges(pool, is_super, org_id).await?;
|
||
|
||
// 今日充电量(kWh,带组织隔离)
|
||
let today_energy = sum_today_energy(pool, is_super, org_id).await?;
|
||
|
||
// 今日充电时长(小时,带组织隔离)
|
||
let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id).await?;
|
||
|
||
// 故障告警(取最近10条故障仓体,返回项目名+柜名)
|
||
let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
|
||
|
||
// 项目摘要(带组织过滤)
|
||
let projects = fetch_project_summaries(pool, &user, org_id).await?;
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"online_cabinets": online_cabinets,
|
||
"total_channels": total_channels,
|
||
"charging_count": charging_count,
|
||
"idle_channels": idle_channels,
|
||
"full_count": full_count,
|
||
"fault_channels": fault_channels,
|
||
"fault_count": fault_count,
|
||
"today_charge_count": today_charge_count,
|
||
"today_energy_kwh": today_energy,
|
||
"today_charge_hours": today_charge_hours,
|
||
"alerts": alerts,
|
||
"projects": projects,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// 统计在线柜子数(带组织隔离)
|
||
async fn count_online_cabinets(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let count: i64 = if is_super {
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM cabinets cab
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE cab.status = 1 AND p.organization_id = ?"#,
|
||
)
|
||
.bind(org_id.unwrap())
|
||
.fetch_one(pool)
|
||
.await?
|
||
};
|
||
Ok(count)
|
||
}
|
||
|
||
/// 统计总仓体数(带组织隔离)
|
||
async fn count_total_compartments(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let count: i64 = if is_super {
|
||
sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets c ON cb.cabinet_id = c.id
|
||
WHERE c.status = 1"#,
|
||
)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets c ON cb.cabinet_id = c.id
|
||
JOIN projects p ON c.project_id = p.id
|
||
WHERE c.status = 1 AND p.organization_id = ?"#,
|
||
)
|
||
.bind(org_id.unwrap())
|
||
.fetch_one(pool)
|
||
.await?
|
||
};
|
||
Ok(count)
|
||
}
|
||
|
||
/// 按状态统计仓体数(带组织隔离,通过 cabin_boards → cabinets → projects 链路)
|
||
async fn count_compartments_by_status(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
status: i8,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let count: i64 = if is_super {
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM compartments WHERE status = ?")
|
||
.bind(status)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
INNER JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
INNER JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE comp.status = ? AND p.organization_id = ?"#,
|
||
)
|
||
.bind(status)
|
||
.bind(org_id.unwrap())
|
||
.fetch_one(pool)
|
||
.await?
|
||
};
|
||
Ok(count)
|
||
}
|
||
|
||
/// 今日充电次数(带组织隔离)
|
||
async fn count_today_charges(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let count: i64 = if is_super {
|
||
sqlx::query_scalar(
|
||
"SELECT COUNT(*) FROM charge_records WHERE DATE(start_time) = CURDATE()",
|
||
)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM charge_records cr
|
||
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
|
||
)
|
||
.bind(org_id.unwrap())
|
||
.fetch_one(pool)
|
||
.await?
|
||
};
|
||
Ok(count)
|
||
}
|
||
|
||
/// 今日充电量(kWh,带组织隔离)
|
||
async fn sum_today_energy(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
let energy: Option<i64> = if is_super {
|
||
sqlx::query_scalar(
|
||
"SELECT CAST(COALESCE(SUM(energy), 0) AS SIGNED) FROM charge_records WHERE DATE(start_time) = CURDATE()",
|
||
)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_scalar(
|
||
r#"SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr
|
||
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
|
||
)
|
||
.bind(org_id.unwrap())
|
||
.fetch_one(pool)
|
||
.await?
|
||
};
|
||
Ok(energy.unwrap_or(0))
|
||
}
|
||
|
||
/// 今日充电时长(秒,带组织隔离)
|
||
async fn sum_today_charge_hours(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
) -> Result<i64, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(0);
|
||
}
|
||
// 计算总秒数(未结束的记录用 NOW() 代替 end_time)
|
||
let total_seconds: Option<i64> = if is_super {
|
||
sqlx::query_scalar(
|
||
r#"SELECT CAST(COALESCE(SUM(
|
||
TIMESTAMPDIFF(SECOND, start_time, COALESCE(end_time, NOW()))
|
||
), 0) AS SIGNED) FROM charge_records
|
||
WHERE DATE(start_time) = CURDATE()"#,
|
||
)
|
||
.fetch_one(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_scalar(
|
||
r#"SELECT CAST(COALESCE(SUM(
|
||
TIMESTAMPDIFF(SECOND, cr.start_time, COALESCE(cr.end_time, NOW()))
|
||
), 0) AS SIGNED) FROM charge_records cr
|
||
INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
|
||
)
|
||
.bind(org_id.unwrap())
|
||
.fetch_one(pool)
|
||
.await?
|
||
};
|
||
Ok(total_seconds.unwrap_or(0))
|
||
}
|
||
|
||
/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离)
|
||
async fn fetch_fault_alerts(
|
||
pool: &sqlx::MySqlPool,
|
||
is_super: bool,
|
||
org_id: Option<i64>,
|
||
) -> Result<Vec<Value>, AppError> {
|
||
if !is_super && org_id.is_none() {
|
||
return Ok(vec![]);
|
||
}
|
||
let base_sql = if is_super {
|
||
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
|
||
comp.channel_id
|
||
FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
LEFT JOIN projects p ON cab.project_id = p.id
|
||
WHERE comp.status = 2
|
||
ORDER BY comp.updated_at DESC
|
||
LIMIT 10"#
|
||
} else {
|
||
r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet,
|
||
comp.channel_id
|
||
FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE comp.status = 2 AND p.organization_id = ?
|
||
ORDER BY comp.updated_at DESC
|
||
LIMIT 10"#
|
||
};
|
||
|
||
let rows: Vec<(String, String, i8)> = if is_super {
|
||
sqlx::query_as(base_sql).fetch_all(pool).await?
|
||
} else {
|
||
sqlx::query_as(base_sql)
|
||
.bind(org_id.unwrap())
|
||
.fetch_all(pool)
|
||
.await?
|
||
};
|
||
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|(project, cabinet, channel)| {
|
||
json!({
|
||
"project": project,
|
||
"cabinet": cabinet,
|
||
"channel": channel,
|
||
"alert_type": "fault",
|
||
})
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
|
||
async fn fetch_project_summaries(
|
||
pool: &sqlx::MySqlPool,
|
||
user: &CurrentUser,
|
||
org_id: Option<i64>,
|
||
) -> Result<Vec<Value>, AppError> {
|
||
// 获取用户可见的项目(优先使用查询参数 org_id)
|
||
let filter_org = org_id.or(user.organization_id);
|
||
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
|
||
if let Some(oid) = filter_org {
|
||
sqlx::query_as::<_, ProjectRow>(
|
||
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
|
||
)
|
||
.bind(oid)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id")
|
||
.fetch_all(pool)
|
||
.await?
|
||
}
|
||
} else if let Some(org_id) = user.organization_id {
|
||
sqlx::query_as::<_, ProjectRow>(
|
||
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
|
||
)
|
||
.bind(org_id)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else {
|
||
vec![]
|
||
};
|
||
|
||
let mut result = Vec::with_capacity(projects.len());
|
||
for p in &projects {
|
||
let cabinet_count: i64 = sqlx::query_scalar(
|
||
"SELECT COUNT(*) FROM cabinets WHERE project_id = ?",
|
||
)
|
||
.bind(p.id)
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
let charging: i64 = sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
WHERE cab.project_id = ? AND comp.status = 1"#,
|
||
)
|
||
.bind(p.id)
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
let idle: i64 = sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
WHERE cab.project_id = ? AND comp.status = 0"#,
|
||
)
|
||
.bind(p.id)
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
let fault: i64 = sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) FROM compartments comp
|
||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||
WHERE cab.project_id = ? AND comp.status = 2"#,
|
||
)
|
||
.bind(p.id)
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
result.push(json!({
|
||
"id": p.id,
|
||
"name": p.name,
|
||
"cabinet_count": cabinet_count,
|
||
"charging": charging,
|
||
"idle": idle,
|
||
"fault": fault,
|
||
}));
|
||
}
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 设备列表
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
|
||
pub async fn get_cabinets(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Query(query): Query<CabinetListQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
// 权限校验
|
||
auth::check_permission(&user, "device:view")?;
|
||
|
||
let rows: Vec<H5CabinetRow> = if let Some(pid) = query.project_id {
|
||
// 按项目过滤(同时验证组织权限)
|
||
if user.role_level >= 2 {
|
||
sqlx::query_as::<_, H5CabinetRow>(
|
||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||
FROM cabinets c WHERE c.project_id = ?"#,
|
||
)
|
||
.bind(pid)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else if let Some(org_id) = user.organization_id {
|
||
sqlx::query_as::<_, H5CabinetRow>(
|
||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||
FROM cabinets c
|
||
INNER JOIN projects p ON c.project_id = p.id
|
||
WHERE c.project_id = ? AND p.organization_id = ?"#,
|
||
)
|
||
.bind(pid)
|
||
.bind(org_id)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else {
|
||
vec![]
|
||
}
|
||
} else if user.role_level >= 2 {
|
||
// 总管理员:按组织过滤或看全部
|
||
if let Some(oid) = query.org_id {
|
||
sqlx::query_as::<_, H5CabinetRow>(
|
||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||
FROM cabinets c
|
||
INNER JOIN projects p ON c.project_id = p.id
|
||
WHERE p.organization_id = ?"#,
|
||
)
|
||
.bind(oid)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else {
|
||
sqlx::query_as::<_, H5CabinetRow>(
|
||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||
FROM cabinets c"#,
|
||
)
|
||
.fetch_all(pool)
|
||
.await?
|
||
}
|
||
} else if let Some(org_id) = user.organization_id {
|
||
// 企业管理员只看本组织
|
||
sqlx::query_as::<_, H5CabinetRow>(
|
||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||
FROM cabinets c
|
||
INNER JOIN projects p ON c.project_id = p.id
|
||
WHERE p.organization_id = ?"#,
|
||
)
|
||
.bind(org_id)
|
||
.fetch_all(pool)
|
||
.await?
|
||
} else {
|
||
vec![]
|
||
};
|
||
|
||
// 从Redis批量查RSSI + pow_fail
|
||
let mut rssi_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
|
||
let mut dc_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new();
|
||
let mut ac_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new();
|
||
let mut temp_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
|
||
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||
let mut pipe = redis::pipe();
|
||
for r in &rows {
|
||
if let Some(ref imei) = r.imei {
|
||
let key = format!("device:{imei}");
|
||
pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]);
|
||
}
|
||
}
|
||
if !rows.is_empty() {
|
||
let results: Vec<Vec<Option<String>>> = match pipe.query_async(&mut conn).await {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
tracing::error!("[h5] Redis pipeline查询失败: {}", e);
|
||
Vec::new()
|
||
}
|
||
};
|
||
for (i, r) in rows.iter().enumerate() {
|
||
if let Some(ref imei) = r.imei {
|
||
if let Some(vals) = results.get(i) {
|
||
rssi_map.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
||
dc_map.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
||
ac_map.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
||
temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 查询柜子所属组织是否禁用(用于前端徽标)
|
||
let mut org_disabled_map: std::collections::HashMap<i64, bool> = std::collections::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();
|
||
let sql = format!(
|
||
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 ({})"#,
|
||
placeholders.join(",")
|
||
);
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let list: Vec<Value> = rows
|
||
.into_iter()
|
||
.map(|r| {
|
||
let imei = r.imei.clone();
|
||
let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten();
|
||
let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten();
|
||
let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten();
|
||
let temp = imei.as_ref().and_then(|i| temp_map.get(i).copied()).flatten();
|
||
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
|
||
json!({
|
||
"id": r.id,
|
||
"abstract_id": r.abstract_id,
|
||
"name": r.name,
|
||
"imei": r.imei,
|
||
"iccid": r.iccid,
|
||
"status": r.status,
|
||
"project_id": r.project_id,
|
||
"address": r.address,
|
||
"latitude": r.latitude,
|
||
"longitude": r.longitude,
|
||
"channel_count": r.channel_count,
|
||
"idle_count": r.idle_count,
|
||
"charging_count": r.charging_count,
|
||
"full_count": r.full_count,
|
||
"fault_count": r.fault_count,
|
||
"rssi": rssi,
|
||
"pow_fail_dc": pow_fail_dc,
|
||
"pow_fail_ac": pow_fail_ac,
|
||
"temp": temp,
|
||
"org_disabled": org_disabled,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": list,
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 设备扫码(免认证)
|
||
// ---------------------------------------------------------------------------
|
||
pub async fn get_cabinet_by_abstract_id(
|
||
State(state): State<AppState>,
|
||
Path(abstract_id): Path<String>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
|
||
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?",
|
||
)
|
||
.bind(&abstract_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let (cab_id, abs_id, _imei, name, status) =
|
||
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
||
|
||
// 查询仓控板+仓体
|
||
let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>(
|
||
"SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id",
|
||
)
|
||
.bind(cab_id)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
let mut cabin_boards = Vec::with_capacity(boards.len());
|
||
for board in &boards {
|
||
let comps: Vec<H5CompartmentRow> = sqlx::query_as::<_, H5CompartmentRow>(
|
||
"SELECT id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id",
|
||
)
|
||
.bind(board.id)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
let compartments: Vec<Value> = comps
|
||
.into_iter()
|
||
.map(|c| json!({ "id": c.id, "channel_id": c.channel_id, "status": c.status }))
|
||
.collect();
|
||
|
||
cabin_boards.push(json!({
|
||
"id": board.id,
|
||
"board_id": board.board_id,
|
||
"status": board.status,
|
||
"compartments": compartments,
|
||
}));
|
||
}
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"id": cab_id,
|
||
"abstract_id": abs_id,
|
||
"name": name,
|
||
"status": status,
|
||
"cabin_boards": cabin_boards,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 仓体/通道详情
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验)
|
||
pub async fn get_compartment_detail(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
// 权限校验
|
||
auth::check_permission(&user, "device:view")?;
|
||
|
||
// 查询仓体基本信息
|
||
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(
|
||
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?",
|
||
)
|
||
.bind(id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let (comp_id, channel_id, status, cabin_board_id) =
|
||
comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?;
|
||
|
||
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
|
||
let cabinet_id: Option<(i64,)> = sqlx::query_as(
|
||
"SELECT cabinet_id FROM cabin_boards WHERE id = ?",
|
||
)
|
||
.bind(cabin_board_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
if let Some((cab_id,)) = cabinet_id {
|
||
verify_cabinet_access(&user, cab_id, pool).await?;
|
||
}
|
||
|
||
// 从 Redis 获取实时数据
|
||
let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||
let key = format!("compartment:status:{}", comp_id);
|
||
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
|
||
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||
} else { json!({}) }
|
||
} else { json!({}) };
|
||
|
||
// 查询最近充电记录
|
||
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
|
||
r#"SELECT id, start_time, end_time, energy
|
||
FROM charge_records
|
||
WHERE compartment_id = ?
|
||
ORDER BY start_time DESC LIMIT 10"#,
|
||
)
|
||
.bind(comp_id)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
let charge_records: Vec<Value> = records
|
||
.into_iter()
|
||
.map(|r| {
|
||
json!({
|
||
"id": r.id,
|
||
"start_time": r.start_time.map(|t| t.to_string()),
|
||
"end_time": r.end_time.map(|t| t.to_string()),
|
||
"energy_kwh": r.energy.unwrap_or_default().parse::<i64>().unwrap_or(0),
|
||
"cost": 0,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"id": comp_id,
|
||
"channel_id": channel_id,
|
||
"status": status,
|
||
"cabin_board_id": cabin_board_id,
|
||
"voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0),
|
||
"current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0),
|
||
"energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0),
|
||
"soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0),
|
||
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])),
|
||
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])),
|
||
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])),
|
||
"charge_records": charge_records,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 扫码验证
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// GET /api/h5/scan/:abstract_id — 扫码验证设备访问权限
|
||
///
|
||
/// 根据二维码中的抽象ID查找设备,验证当前用户是否有权访问。
|
||
/// 有权限返回设备ID和基本信息,无权限返回403。
|
||
pub async fn scan_cabinet(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(abstract_id): Path<String>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
// 查找设备
|
||
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
|
||
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?",
|
||
)
|
||
.bind(&abstract_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let (cab_id, abs_id, imei, name, status) =
|
||
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
||
|
||
// 验证用户是否有权限访问该设备
|
||
verify_cabinet_access(&user, cab_id, pool).await?;
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"id": cab_id,
|
||
"abstract_id": abs_id,
|
||
"imei": imei,
|
||
"name": name,
|
||
"status": status,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 充电控制
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// POST /api/h5/charge/start — 开始充电
|
||
///
|
||
/// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`,
|
||
/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。
|
||
/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。
|
||
pub async fn start_charge(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(body): Json<CompartmentActionInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "charge:start")?;
|
||
|
||
// 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式
|
||
// (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理)
|
||
let order_row: Result<Option<(i64, String, i8, i8, Option<i8>)>, sqlx::Error> = sqlx::query_as(
|
||
r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode
|
||
FROM cabinets cab
|
||
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
||
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
||
WHERE comp.id = ?"#,
|
||
)
|
||
.bind(body.compartment_id)
|
||
.fetch_optional(&state.mysql)
|
||
.await;
|
||
|
||
match order_row {
|
||
// 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单;
|
||
// **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单
|
||
Ok(Some((cabinet_id, imei, board_id, channel_id, mode))) if mode.unwrap_or(0) == 0 => {
|
||
// 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`)
|
||
match crate::workers::report_worker::load_channel_battery(
|
||
&state.redis, &imei, board_id, channel_id,
|
||
)
|
||
.await
|
||
{
|
||
Some(battery) => {
|
||
if let Err(e) = crate::workers::report_worker::ensure_charge_order(
|
||
&state.mysql,
|
||
&state.redis,
|
||
&imei,
|
||
cabinet_id,
|
||
board_id,
|
||
channel_id,
|
||
&battery.battery_id,
|
||
Some(battery.soc),
|
||
)
|
||
.await
|
||
{
|
||
tracing::error!(
|
||
"[h5] 下发 on 前建单失败 compartment_id={} err={}",
|
||
body.compartment_id,
|
||
e
|
||
);
|
||
}
|
||
}
|
||
None => tracing::warn!(
|
||
"[h5] 该通道无 bat_in 登记,跳过建单 compartment_id={}",
|
||
body.compartment_id
|
||
),
|
||
}
|
||
}
|
||
Ok(Some(_)) => tracing::info!(
|
||
"[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}",
|
||
body.compartment_id
|
||
),
|
||
Ok(None) => tracing::warn!(
|
||
"[h5] 下发 on 前跳过建单:仓体不存在 compartment_id={}",
|
||
body.compartment_id
|
||
),
|
||
Err(e) => tracing::error!(
|
||
"[h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={}",
|
||
body.compartment_id,
|
||
e
|
||
),
|
||
}
|
||
|
||
send_compartment_command(&state, &user, body.compartment_id, "on").await
|
||
}
|
||
|
||
/// POST /api/h5/charge/stop — 停止充电
|
||
pub async fn stop_charge(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(body): Json<CompartmentActionInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "charge:stop")?;
|
||
send_compartment_command(&state, &user, body.compartment_id, "off").await
|
||
}
|
||
|
||
/// POST /api/h5/door/open — 开门(**柜级动作**)
|
||
///
|
||
/// 入参是仓体 ID(小程序扫码场景只知道扫到哪个仓),
|
||
/// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。
|
||
pub async fn open_door(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(body): Json<CompartmentActionInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "charge:open_door")?;
|
||
|
||
let cabinet_id: Option<i64> = sqlx::query_scalar(
|
||
r#"SELECT cb.cabinet_id FROM compartments comp
|
||
JOIN cabin_boards cb ON cb.id = comp.cabin_board_id
|
||
WHERE comp.id = ?"#,
|
||
)
|
||
.bind(body.compartment_id)
|
||
.fetch_optional(&state.mysql)
|
||
.await?;
|
||
let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
|
||
|
||
send_cabinet_command(&state, &user, cabinet_id, None, "open").await
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 辅助函数
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 根据仓体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 row: Option<CabinetImeiRow> = sqlx::query_as(
|
||
r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
|
||
FROM cabinets cab
|
||
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
||
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
||
WHERE comp.id = ?"#,
|
||
)
|
||
.bind(compartment_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
|
||
|
||
// 检查设备是否被禁用
|
||
let disabled: bool = sqlx::query_scalar(
|
||
"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2",
|
||
)
|
||
.bind(cab_row.cabinet_id)
|
||
.fetch_one(pool)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
if disabled {
|
||
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
|
||
}
|
||
|
||
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
|
||
let org_disabled: bool = sqlx::query_scalar(
|
||
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"#,
|
||
)
|
||
.bind(cab_row.cabinet_id)
|
||
.fetch_one(pool)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
if org_disabled {
|
||
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
|
||
}
|
||
|
||
// 校验操作权限(组织隔离)
|
||
verify_cabinet_access(user, cab_row.cabinet_id, pool).await?;
|
||
|
||
let imei = &cab_row.imei;
|
||
|
||
// 检查是否有设备节点在线
|
||
if !state.nodes.is_device_online(imei).await {
|
||
return Err(AppError::BadRequest("设备不在线".into()));
|
||
}
|
||
|
||
// 构造协议 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!({})),
|
||
};
|
||
|
||
// 注入内部路由字段(目标设备 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")
|
||
.arg(&json_str)
|
||
.query_async::<()>(&mut conn)
|
||
.await
|
||
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
||
|
||
tracing::info!(
|
||
"[h5] 指令已推入队列 id={} action={} compartment_id={}",
|
||
id, action, compartment_id
|
||
);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "success": true },
|
||
"message": "指令已下发"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 柜级 / 单仓控板级指令(pow_on / pow_off / reset)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 柜级 / 单仓控板级指令下发。
|
||
///
|
||
/// - `board_id = None` → **柜级**:协议**不带 `id`**(省略该字段)
|
||
/// - `board_id = Some(n)` → **单仓控板**:协议 `id = "{仓控板:02}"`(如 `"01"`)
|
||
///
|
||
/// 适用于 `open` / `pow_on` / `pow_off` / `reset`;`on` / `off` 走 [`send_compartment_command`]。
|
||
async fn send_cabinet_command(
|
||
state: &AppState,
|
||
user: &CurrentUser,
|
||
cabinet_id: i64,
|
||
board_id: Option<i8>,
|
||
action: &str,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
let imei: Option<String> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?")
|
||
.bind(cabinet_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
||
|
||
// 设备是否被禁用
|
||
let disabled: bool =
|
||
sqlx::query_scalar("SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2")
|
||
.bind(cabinet_id)
|
||
.fetch_one(pool)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
if disabled {
|
||
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
|
||
}
|
||
|
||
// 所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
|
||
let org_disabled: bool = sqlx::query_scalar(
|
||
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"#,
|
||
)
|
||
.bind(cabinet_id)
|
||
.fetch_one(pool)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
if org_disabled {
|
||
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
|
||
}
|
||
|
||
// 组织数据隔离校验
|
||
verify_cabinet_access(user, cabinet_id, pool).await?;
|
||
|
||
// 设备必须在线:平台对不在线的设备一律不投递
|
||
if !state.nodes.is_device_online(&imei).await {
|
||
return Err(AppError::BadRequest("设备不在线".into()));
|
||
}
|
||
|
||
// 构造协议 id:
|
||
// - `board_id = None` → **柜级**:**不带 id**(省略该字段)
|
||
// - `board_id = Some(n)` → **单仓控板**:`仓控板`(2 位补零,如 "01")
|
||
let id = board_id.map(|b| format!("{:02}", b));
|
||
|
||
let cmd = DeviceCommand {
|
||
act: action.to_string(),
|
||
msg_id: 0,
|
||
id: id.clone(),
|
||
params: Some(json!({})),
|
||
};
|
||
|
||
// 注入内部路由字段(目标设备 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")
|
||
.arg(&json_str)
|
||
.query_async::<()>(&mut conn)
|
||
.await
|
||
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
||
|
||
tracing::info!(
|
||
"[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}",
|
||
id,
|
||
action,
|
||
cabinet_id,
|
||
board_id
|
||
);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "success": true },
|
||
"message": "指令已下发"
|
||
})))
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/open — **解锁柜门**(柜级)
|
||
///
|
||
/// 柜门是**整柜共用、只有一扇**,所以这是柜级动作(协议 `id` 省略)。
|
||
/// WEB 管理端设备详情页用这个;小程序扫码场景用 `POST /api/h5/door/open`(入参为仓体 ID)。
|
||
pub async fn cabinet_open(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "charge:open_door")?;
|
||
send_cabinet_command(&state, &user, id, None, "open").await
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/pow-on — 接触器**合闸**(柜级)
|
||
pub async fn cabinet_pow_on(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
send_cabinet_command(&state, &user, id, None, "pow_on").await
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/pow-off — 接触器**分闸**(柜级)
|
||
///
|
||
/// ⚠️ 分闸后**仓控板随之下电**:该柜仓体/电池数据不可用,设备会上报 `pow_fail_dc`;
|
||
/// 恢复供电须再下发 `pow-on`。
|
||
pub async fn cabinet_pow_off(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
send_cabinet_command(&state, &user, id, None, "pow_off").await
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/reset — **重启整柜**(柜级,协议 `id = IMEI`)
|
||
pub async fn cabinet_reset(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
send_cabinet_command(&state, &user, id, None, "reset").await
|
||
}
|
||
|
||
/// POST /api/cabin-boards/:id/reset — **重启单块仓控板**
|
||
/// (协议 `id = IMEI-仓控板号`,两段)
|
||
pub async fn board_reset(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(board_pk): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
|
||
let row: Option<(i64, i8)> =
|
||
sqlx::query_as("SELECT cabinet_id, board_id FROM cabin_boards WHERE id = ?")
|
||
.bind(board_pk)
|
||
.fetch_optional(&state.mysql)
|
||
.await?;
|
||
let (cabinet_id, board_id) =
|
||
row.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?;
|
||
|
||
send_cabinet_command(&state, &user, cabinet_id, Some(board_id), "reset").await
|
||
}
|
||
|
||
/// POST /api/feedback — 用户问题反馈提交(任何已登录用户可提交)
|
||
pub async fn submit_feedback(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(body): Json<FeedbackInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
let content = body.content.trim();
|
||
if content.is_empty() {
|
||
return Err(AppError::BadRequest("反馈内容不能为空".into()));
|
||
}
|
||
if content.chars().count() > 2000 {
|
||
return Err(AppError::BadRequest("反馈内容过长(最多2000字)".into()));
|
||
}
|
||
let contact = body.contact.as_ref().map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
|
||
if let Some(c) = &contact {
|
||
if c.chars().count() > 200 {
|
||
return Err(AppError::BadRequest("联系方式过长".into()));
|
||
}
|
||
}
|
||
|
||
// 图片校验:最多 6 张
|
||
let images = body.images.clone().unwrap_or_default();
|
||
let images: Vec<String> = images
|
||
.iter()
|
||
.map(|u| u.trim().to_string())
|
||
.filter(|u| !u.is_empty())
|
||
.collect();
|
||
if images.len() > 6 {
|
||
return Err(AppError::BadRequest("最多上传6张图片".into()));
|
||
}
|
||
let images_json = if images.is_empty() {
|
||
String::from("[]")
|
||
} else {
|
||
serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string())
|
||
};
|
||
|
||
sqlx::query(
|
||
"INSERT INTO user_feedback (user_id, content, contact, images) VALUES (?, ?, ?, ?)"
|
||
)
|
||
.bind(user.user_id)
|
||
.bind(content)
|
||
.bind(contact)
|
||
.bind(&images_json)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
|
||
tracing::info!(
|
||
"[h5] 问题反馈已提交 user_id={} content_len={}",
|
||
user.user_id, content.chars().count()
|
||
);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "success": true },
|
||
"message": "反馈已提交,感谢您的反馈"
|
||
})))
|
||
}
|
||
|
||
/// GET /api/feedback — 查询反馈列表(分页,管理员使用)
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct FeedbackListQuery {
|
||
page: Option<i64>,
|
||
page_size: Option<i64>,
|
||
}
|
||
|
||
#[derive(Debug, sqlx::FromRow)]
|
||
struct FeedbackRow {
|
||
id: i64,
|
||
user_id: i64,
|
||
content: String,
|
||
contact: Option<String>,
|
||
images: Option<String>,
|
||
status: i8,
|
||
created_at: chrono::NaiveDateTime,
|
||
}
|
||
|
||
/// GET /api/h5/feedback — 反馈列表(管理端)
|
||
///
|
||
/// `_user` 仅用于强制登录态(CurrentUser 提取器),当前实现按全量分页返回。
|
||
pub async fn list_feedback(
|
||
_user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Query(query): Query<FeedbackListQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
let page = query.page.unwrap_or(1).max(1);
|
||
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
|
||
let offset = (page - 1) * page_size;
|
||
|
||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_feedback")
|
||
.fetch_one(pool)
|
||
.await?;
|
||
|
||
let rows: Vec<FeedbackRow> = sqlx::query_as::<_, FeedbackRow>(
|
||
r#"SELECT id, user_id, content, contact, images, status, created_at
|
||
FROM user_feedback
|
||
ORDER BY created_at DESC
|
||
LIMIT ? OFFSET ?"#,
|
||
)
|
||
.bind(page_size)
|
||
.bind(offset)
|
||
.fetch_all(pool)
|
||
.await?;
|
||
|
||
let list: Vec<Value> = rows.into_iter().map(|r| {
|
||
let images: Vec<String> = r.images
|
||
.as_deref()
|
||
.and_then(|s| serde_json::from_str(s).ok())
|
||
.unwrap_or_default();
|
||
json!({
|
||
"id": r.id,
|
||
"user_id": r.user_id,
|
||
"content": r.content,
|
||
"contact": r.contact,
|
||
"images": images,
|
||
"status": r.status,
|
||
"status_text": if r.status == 0 { "待处理" } else { "已处理" },
|
||
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||
})
|
||
}).collect();
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "list": list, "total": total, "page": page, "page_size": page_size },
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// PUT /api/feedback/{id} — 更新反馈状态(0待处理 → 1已处理)
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct FeedbackStatusInput {
|
||
status: i8,
|
||
}
|
||
|
||
const FEEDBACK_IMG_DIR: &str = "uploads/feedback";
|
||
|
||
pub async fn upload_feedback_image(
|
||
user: CurrentUser,
|
||
mut multipart: Multipart,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let mut file_data: Option<Vec<u8>> = None;
|
||
let mut ext = "".to_string();
|
||
|
||
while let Some(field) = multipart.next_field().await
|
||
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
|
||
let filename = field.file_name().unwrap_or("").to_string();
|
||
let data = field.bytes().await
|
||
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
|
||
if let Some(pos) = filename.rfind('.') {
|
||
ext = filename[pos..].to_string();
|
||
}
|
||
file_data = Some(data.to_vec());
|
||
}
|
||
|
||
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
|
||
if data.is_empty() {
|
||
return Err(AppError::BadRequest("上传内容为空".into()));
|
||
}
|
||
|
||
if data.len() > 5 * 1024 * 1024 {
|
||
return Err(AppError::BadRequest("图片文件不能超过 5MB".into()));
|
||
}
|
||
|
||
if ext.is_empty() {
|
||
ext = ".jpg".to_string();
|
||
}
|
||
let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||
if !allowed.contains(&ext.as_str()) {
|
||
return Err(AppError::BadRequest("仅支持 jpg/png/gif/webp 格式".into()));
|
||
}
|
||
|
||
let dir = std::path::Path::new(FEEDBACK_IMG_DIR);
|
||
std::fs::create_dir_all(dir)
|
||
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
|
||
|
||
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
|
||
let filepath = dir.join(&filename);
|
||
let mut f = std::fs::File::create(&filepath)
|
||
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
|
||
f.write_all(&data)
|
||
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
|
||
|
||
let url = format!("/uploads/feedback/{}", filename);
|
||
tracing::info!(
|
||
user_id = user.user_id, path = %url,
|
||
"反馈图片上传成功"
|
||
);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "url": url },
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
pub async fn update_feedback_status(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(id): Path<i64>,
|
||
Json(body): Json<FeedbackStatusInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
if body.status != 0 && body.status != 1 {
|
||
return Err(AppError::BadRequest("状态值不合法".into()));
|
||
}
|
||
|
||
let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?")
|
||
.bind(body.status)
|
||
.bind(id)
|
||
.execute(pool)
|
||
.await
|
||
.map_err(AppError::Database)?
|
||
.rows_affected();
|
||
|
||
if affected == 0 {
|
||
return Err(AppError::NotFound("反馈记录不存在".into()));
|
||
}
|
||
|
||
tracing::info!(
|
||
"[h5] 反馈状态已更新 operator={} feedback_id={} status={}",
|
||
user.user_id, id, body.status
|
||
);
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "success": true },
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// 验证用户是否有权访问指定柜子(组织数据隔离)
|
||
///
|
||
/// 总管理员(role_level >= 2)可访问所有柜子;
|
||
/// 企业管理员只能访问本组织下项目关联的柜子;
|
||
/// 无组织关联的用户无权访问任何柜子。
|
||
async fn verify_cabinet_access(
|
||
user: &CurrentUser,
|
||
cabinet_id: i64,
|
||
pool: &sqlx::MySqlPool,
|
||
) -> Result<(), AppError> {
|
||
// 总管理员无需校验
|
||
if user.role_level >= 2 {
|
||
return Ok(());
|
||
}
|
||
|
||
let org_id = user
|
||
.organization_id
|
||
.ok_or_else(|| AppError::Forbidden("无组织关联,无权访问设备".into()))?;
|
||
|
||
// 通过 cabinet → project → organization 链路验证归属
|
||
let access: Option<(i64,)> = sqlx::query_as(
|
||
r#"SELECT cab.id FROM cabinets cab
|
||
INNER JOIN projects p ON cab.project_id = p.id
|
||
WHERE cab.id = ? AND p.organization_id = ?"#,
|
||
)
|
||
.bind(cabinet_id)
|
||
.bind(org_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
if access.is_none() {
|
||
return Err(AppError::Forbidden("无权访问该设备".into()));
|
||
}
|
||
|
||
Ok(())
|
||
}
|