12451 999885c38f feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复
协议(唯一权威文档: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 通过
2026-09-19 23:41:09 +08:00

1570 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! H5 用户端 API
//!
//! 提供微信扫码充电的 H5 页面所需的全部后端接口:
//! - 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(())
}