协议(唯一权威文档: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 通过
914 lines
30 KiB
Rust
914 lines
30 KiB
Rust
//! 设备管理 API — CRUD + 详情 + 级联下拉
|
||
//!
|
||
//! 提供柜子的增删改查、详情查询、安全码重新生成,
|
||
//! 以及柜子/仓板/仓体的级联下拉选项。
|
||
|
||
use axum::extract::{Path, Query, State};
|
||
use axum::Json;
|
||
use redis;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{json, Value};
|
||
use sqlx::MySqlPool;
|
||
|
||
use super::organizations::{
|
||
generate_auth_str,
|
||
CabinBoardRow, CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
|
||
};
|
||
use crate::error::AppError;
|
||
use crate::middleware::auth::{self, CurrentUser};
|
||
use super::scope_guard;
|
||
|
||
/// 基础柜子行(只包含数据库字段,用于简单查询)
|
||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||
struct CabinetBasicRow {
|
||
id: i64,
|
||
project_id: Option<i64>,
|
||
abstract_id: Option<String>,
|
||
imei: String,
|
||
iccid: Option<String>,
|
||
name: Option<String>,
|
||
address: Option<String>,
|
||
latitude: Option<String>,
|
||
longitude: Option<String>,
|
||
status: i8,
|
||
/// 充电工作模式:0=平台指令模式 1=自动充电工作模式
|
||
work_mode: Option<i8>,
|
||
}
|
||
|
||
impl From<CabinetBasicRow> for super::organizations::CabinetRow {
|
||
fn from(row: CabinetBasicRow) -> Self {
|
||
super::organizations::CabinetRow {
|
||
id: row.id,
|
||
project_id: row.project_id,
|
||
abstract_id: row.abstract_id,
|
||
imei: row.imei,
|
||
iccid: row.iccid,
|
||
name: row.name,
|
||
address: row.address,
|
||
status: row.status,
|
||
board_count: None,
|
||
channel_count: None,
|
||
idle_count: None,
|
||
charging_count: None,
|
||
full_count: None,
|
||
fault_count: None,
|
||
rssi: None,
|
||
pow_fail_dc: None,
|
||
pow_fail_ac: None,
|
||
is_online: None,
|
||
org_disabled: None,
|
||
project_address: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 项目访问权限验证
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 验证用户是否有权访问指定项目
|
||
///
|
||
/// 总管理员(role_level >= 2)可访问所有项目;
|
||
/// 企业管理员只能访问本组织下的项目。
|
||
async fn check_project_access(
|
||
user: &CurrentUser,
|
||
pool: &MySqlPool,
|
||
project_id: i64,
|
||
) -> Result<(), AppError> {
|
||
// 总管理员可访问所有项目
|
||
if user.role_level >= 2 {
|
||
return Ok(());
|
||
}
|
||
|
||
if let Some(org_id) = user.organization_id {
|
||
// 验证项目是否属于用户的组织
|
||
let project: Option<(i64,)> = sqlx::query_as(
|
||
"SELECT id FROM projects WHERE id = ? AND organization_id = ?",
|
||
)
|
||
.bind(project_id)
|
||
.bind(org_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
if project.is_none() {
|
||
return Err(AppError::Forbidden("无权访问该项目".into()));
|
||
}
|
||
} else {
|
||
return Err(AppError::Forbidden("无组织关联".into()));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 设备 CRUD
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// GET /api/projects/:project_id/cabinets — 按项目查询柜子列表
|
||
pub async fn list_cabinets(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(project_id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:view")?;
|
||
check_project_access(&user, &state.mysql, project_id).await?;
|
||
|
||
let db = &state.mysql;
|
||
let sql_rows = sqlx::query_as::<_, CabinetBasicRow>(
|
||
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status, work_mode
|
||
FROM cabinets WHERE project_id = ? ORDER BY id"#,
|
||
)
|
||
.bind(project_id)
|
||
.fetch_all(db)
|
||
.await?;
|
||
|
||
let rows: Vec<super::organizations::CabinetRow> = sql_rows.into_iter().map(Into::into).collect();
|
||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||
}
|
||
|
||
/// POST /api/cabinets — 批量添加柜子
|
||
///
|
||
/// 每个 IMEI 创建一个柜子,同时生成默认仓控板和 6 个仓体。
|
||
pub async fn create_cabinet(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Json(body): Json<CreateCabinetBody>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:create")?;
|
||
scope_guard::verify_project_ownership(&state.mysql, &user, body.project_id).await?;
|
||
|
||
let db = &state.mysql;
|
||
if body.imeis.is_empty() {
|
||
return Err(AppError::BadRequest("IMEI 列表不能为空".into()));
|
||
}
|
||
|
||
let mut unique_imeis: Vec<String> = Vec::new();
|
||
for imei in &body.imeis {
|
||
let trimmed = imei.trim().to_string();
|
||
if trimmed.len() != 15 {
|
||
return Err(AppError::BadRequest(
|
||
format!("IMEI 必须为15位数字: {}", trimmed),
|
||
));
|
||
}
|
||
if !unique_imeis.contains(&trimmed) {
|
||
unique_imeis.push(trimmed);
|
||
}
|
||
}
|
||
|
||
let mut created_ids: Vec<i64> = Vec::new();
|
||
|
||
for imei in &unique_imeis {
|
||
let auth_str = generate_auth_str();
|
||
|
||
let mut tx = db.begin().await.map_err(AppError::Database)?;
|
||
|
||
let result = sqlx::query(
|
||
r#"INSERT INTO cabinets (project_id, imei, auth_str)
|
||
VALUES (?, ?, ?)"#,
|
||
)
|
||
.bind(body.project_id)
|
||
.bind(imei)
|
||
.bind(&auth_str)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let cabinet_id = result.last_insert_id() as i64;
|
||
|
||
let board_result = sqlx::query(
|
||
r#"INSERT INTO cabin_boards (cabinet_id, board_id) VALUES (?, 1)"#,
|
||
)
|
||
.bind(cabinet_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let board_pk = board_result.last_insert_id() as i64;
|
||
|
||
for ch in 1..=6 {
|
||
sqlx::query(
|
||
r#"INSERT INTO compartments (cabin_board_id, channel_id) VALUES (?, ?)"#,
|
||
)
|
||
.bind(board_pk)
|
||
.bind(ch)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
|
||
tx.commit().await.map_err(AppError::Database)?;
|
||
created_ids.push(cabinet_id);
|
||
tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei);
|
||
}
|
||
|
||
Ok(Json(json!({ "code": 0, "data": { "ids": created_ids }, "message": "ok" })))
|
||
}
|
||
|
||
/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
|
||
pub async fn update_cabinet(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(id): Path<i64>,
|
||
Json(body): Json<UpdateCabinetBody>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:edit")?;
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||
|
||
let db = &state.mysql;
|
||
let rows = sqlx::query(
|
||
r#"UPDATE cabinets SET
|
||
project_id = COALESCE(?, project_id),
|
||
name = COALESCE(?, name),
|
||
abstract_id = COALESCE(?, abstract_id)
|
||
WHERE id = ?"#,
|
||
)
|
||
.bind(body.project_id)
|
||
.bind(&body.name)
|
||
.bind(&body.abstract_id)
|
||
.bind(id)
|
||
.execute(db)
|
||
.await?
|
||
.rows_affected();
|
||
|
||
if rows == 0 {
|
||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||
}
|
||
|
||
tracing::info!("更新柜子 id={}", id);
|
||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||
}
|
||
|
||
/// 柜子状态更新请求体
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct SetCabinetStatusBody {
|
||
pub status: i8,
|
||
}
|
||
|
||
/// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用)
|
||
///
|
||
/// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。
|
||
pub async fn set_cabinet_status(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(id): Path<i64>,
|
||
Json(body): Json<SetCabinetStatusBody>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:edit")?;
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||
|
||
// 禁/启用操作仅总管理员可执行
|
||
if user.role_level < 2 {
|
||
return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into()));
|
||
}
|
||
|
||
let db = &state.mysql;
|
||
let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?")
|
||
.bind(body.status)
|
||
.bind(id)
|
||
.execute(db)
|
||
.await?
|
||
.rows_affected();
|
||
|
||
if rows == 0 {
|
||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||
}
|
||
|
||
let action = if body.status == 2 { "禁用" } else { "启用" };
|
||
tracing::info!("{}柜子 id={}", action, id);
|
||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||
}
|
||
|
||
/// 充电工作模式更新请求体(前端契约:字符串 "0" / "1")
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct UpdateWorkModeBody {
|
||
pub work_mode: String,
|
||
}
|
||
|
||
/// PUT /api/cabinets/:id/work-mode — 设置充电工作模式
|
||
///
|
||
/// 0=平台指令模式(默认,需平台下发指令才充电)
|
||
/// 1=自动充电工作模式(设备自主充电)
|
||
/// 权限:device:edit(编辑设备),并校验柜子存在 + 组织归属。
|
||
pub async fn update_work_mode(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(id): Path<i64>,
|
||
Json(body): Json<UpdateWorkModeBody>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:edit")?;
|
||
// 组织隔离:非总管理员只能改本组织项目的柜子
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||
|
||
// 白名单取值,拒绝一切其它输入(含 "2"、"true" 等)
|
||
let mode: i8 = match body.work_mode.trim() {
|
||
"0" => 0,
|
||
"1" => 1,
|
||
other => {
|
||
return Err(AppError::BadRequest(format!(
|
||
"work_mode 只能为 \"0\"(平台指令模式) 或 \"1\"(自动充电模式),收到: {}",
|
||
other
|
||
)))
|
||
}
|
||
};
|
||
|
||
let rows = sqlx::query("UPDATE cabinets SET work_mode = ? WHERE id = ?")
|
||
.bind(mode)
|
||
.bind(id)
|
||
.execute(&state.mysql)
|
||
.await?
|
||
.rows_affected();
|
||
|
||
// 总管理员路径下 verify_cabinet_ownership 直接放行,这里兜底不存在的情况
|
||
if rows == 0 {
|
||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||
}
|
||
|
||
tracing::info!("设置充电工作模式 id={} work_mode={}", id, mode);
|
||
Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "ok" })))
|
||
}
|
||
|
||
/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
|
||
pub async fn delete_cabinet(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:delete")?;
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||
|
||
let db = &state.mysql;
|
||
let mut tx = db.begin().await.map_err(AppError::Database)?;
|
||
|
||
sqlx::query(
|
||
r#"DELETE compartments FROM compartments
|
||
INNER JOIN cabin_boards ON compartments.cabin_board_id = cabin_boards.id
|
||
WHERE cabin_boards.cabinet_id = ?"#,
|
||
)
|
||
.bind(id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
sqlx::query(r#"DELETE FROM cabin_boards WHERE cabinet_id = ?"#)
|
||
.bind(id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let rows = sqlx::query(r#"DELETE FROM cabinets WHERE id = ?"#)
|
||
.bind(id)
|
||
.execute(&mut *tx)
|
||
.await?
|
||
.rows_affected();
|
||
|
||
tx.commit().await.map_err(AppError::Database)?;
|
||
|
||
if rows == 0 {
|
||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||
}
|
||
|
||
tracing::info!("删除柜子 id={}", id);
|
||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/bind — 绑定平台抽象ID
|
||
pub async fn bind_cabinet(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:edit")?;
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||
|
||
let db = &state.mysql;
|
||
|
||
// 检查柜子是否存在且未绑定
|
||
let bound: (i64,) = sqlx::query_as(
|
||
"SELECT COUNT(*) FROM cabinets WHERE id = ? AND abstract_id IS NULL"
|
||
)
|
||
.bind(id)
|
||
.fetch_one(db)
|
||
.await?;
|
||
|
||
if bound.0 == 0 {
|
||
return Err(AppError::BadRequest("柜子不存在或已绑定".into()));
|
||
}
|
||
|
||
// 事务:插入绑定记录 + 回写设备表
|
||
let mut tx = db.begin().await.map_err(AppError::Database)?;
|
||
|
||
let result = sqlx::query(
|
||
"INSERT INTO abstract_bindings (cabinet_id) VALUES (?)"
|
||
)
|
||
.bind(id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let binding_id = result.last_insert_id() as u64;
|
||
let abstract_id = format!("10-{:08X}", binding_id);
|
||
|
||
sqlx::query(
|
||
"UPDATE cabinets SET abstract_id = ? WHERE id = ?"
|
||
)
|
||
.bind(&abstract_id)
|
||
.bind(id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await.map_err(AppError::Database)?;
|
||
|
||
tracing::info!("绑定柜子 id={} abstract_id={}", id, abstract_id);
|
||
Ok(Json(json!({ "code": 0, "data": { "abstract_id": abstract_id }, "message": "ok" })))
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组)
|
||
pub async fn replace_imei(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(id): Path<i64>,
|
||
Json(body): Json<ReplaceImeiBody>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:edit")?;
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||
|
||
let imei = body.imei.trim().to_string();
|
||
if imei.len() != 15 || !imei.chars().all(|c| c.is_ascii_digit()) {
|
||
return Err(AppError::BadRequest("IMEI 必须为15位数字".into()));
|
||
}
|
||
|
||
let db = &state.mysql;
|
||
|
||
// 检查柜子是否存在
|
||
let exists: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cabinets WHERE id = ?")
|
||
.bind(id)
|
||
.fetch_one(db)
|
||
.await?;
|
||
if exists.0 == 0 {
|
||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||
}
|
||
|
||
// 检查新IMEI是否已被其他柜子使用
|
||
let conflict: Vec<(i64, Option<String>)> = sqlx::query_as(
|
||
"SELECT id, name FROM cabinets WHERE imei = ? AND id != ?"
|
||
)
|
||
.bind(&imei)
|
||
.bind(id)
|
||
.fetch_all(db)
|
||
.await?;
|
||
|
||
if let Some(row) = conflict.first() {
|
||
let name = row.1.as_deref().unwrap_or("未知");
|
||
return Err(AppError::Conflict(
|
||
format!("IMEI {} 已被柜子 {} ({}) 使用", imei, row.0, name)
|
||
));
|
||
}
|
||
|
||
// 更新IMEI
|
||
sqlx::query("UPDATE cabinets SET imei = ? WHERE id = ?")
|
||
.bind(&imei)
|
||
.bind(id)
|
||
.execute(db)
|
||
.await?;
|
||
|
||
tracing::info!("更换IMEI id={} new_imei={}", id, imei);
|
||
Ok(Json(json!({ "code": 0, "data": null, "message": "ok" })))
|
||
}
|
||
|
||
/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
|
||
pub async fn regenerate_auth(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
// 仅总管理员可重置安全码:device:auth_str:reset 不在企业管理员/操作员的权限码表中,
|
||
// 且 check_permission 对 role_level >= 2 直接放行,因此等价于「仅总管理员可用」
|
||
auth::check_permission(&user, "device:auth_str:reset")?;
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?;
|
||
|
||
let db = &state.mysql;
|
||
let auth_str = generate_auth_str();
|
||
|
||
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ?, auth_str_issued = 0 WHERE id = ?"#)
|
||
.bind(&auth_str)
|
||
.bind(id)
|
||
.execute(db)
|
||
.await?
|
||
.rows_affected();
|
||
|
||
// 同步 Redis:
|
||
// 1. 用新码覆盖 auth_str:{imei} —— 旧安全码【立即失效】,设备继续用旧码 login 会收到「签名错误」,
|
||
// 从而触发设备重新调用 auth_str 申领新码,形成自愈闭环
|
||
// 2. 清除 auth_str:issued:{imei} 标记 —— 允许设备重新申领
|
||
let imei: Option<String> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?")
|
||
.bind(id)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
if let Some(imei) = imei {
|
||
let code_key = format!("auth_str:{}", imei);
|
||
let issued_key = format!("auth_str:issued:{}", imei);
|
||
let mut conn = state.redis.clone();
|
||
|
||
if let Err(e) = redis::cmd("SET")
|
||
.arg(&code_key)
|
||
.arg(&auth_str)
|
||
.query_async::<()>(&mut conn)
|
||
.await
|
||
{
|
||
tracing::error!("[regenerate_auth] SET {} 失败: {}", code_key, e);
|
||
}
|
||
|
||
let _: Result<(), _> = redis::cmd("DEL")
|
||
.arg(&issued_key)
|
||
.query_async(&mut conn)
|
||
.await;
|
||
}
|
||
|
||
if rows == 0 {
|
||
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
||
}
|
||
|
||
tracing::info!("重新生成 auth_str id={}", id);
|
||
Ok(Json(json!({ "code": 0, "data": { "auth_str": auth_str }, "message": "ok" })))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 柜子详情(含仓控板 + 仓体)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// GET /api/cabinets/:id — 柜子详情,包含仓控板和仓体
|
||
pub async fn get_cabinet_detail(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Path(id): Path<i64>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:view")?;
|
||
|
||
let db = &state.mysql;
|
||
let cabinet = sqlx::query_as::<_, CabinetBasicRow>(
|
||
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude, status, work_mode
|
||
FROM cabinets WHERE id = ?"#,
|
||
)
|
||
.bind(id)
|
||
.fetch_optional(db)
|
||
.await?
|
||
.ok_or_else(|| AppError::NotFound(format!("柜子 {} 不存在", id)))?;
|
||
|
||
// 验证用户是否有权访问该柜子所属的项目
|
||
if let Some(pid) = cabinet.project_id {
|
||
check_project_access(&user, db, pid).await?;
|
||
}
|
||
|
||
// 仓控板 + 仓体(含实时字段占位),并收集通道 Redis key 所需的映射
|
||
let (mut cabin_boards, comp_ids, comp_to_board) = load_boards_with_compartments(db, id).await?;
|
||
|
||
// 一次 pipeline 读取柜级 + 通道级实时状态;Redis 异常一律降级,不让详情接口整体失败
|
||
let realtime =
|
||
fetch_cabinet_realtime(&state.redis_url, &cabinet.imei, &comp_ids, &comp_to_board).await;
|
||
apply_realtime(&mut cabin_boards, &realtime);
|
||
|
||
// 查询项目名称
|
||
let project_name: Option<String> = if let Some(pid) = cabinet.project_id {
|
||
sqlx::query_scalar("SELECT name FROM projects WHERE id = ?")
|
||
.bind(pid)
|
||
.fetch_optional(db)
|
||
.await
|
||
.ok()
|
||
.flatten()
|
||
} else {
|
||
None
|
||
};
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"id": cabinet.id,
|
||
"project_id": cabinet.project_id,
|
||
"project_name": project_name,
|
||
"abstract_id": cabinet.abstract_id,
|
||
"imei": cabinet.imei,
|
||
"iccid": cabinet.iccid,
|
||
"name": cabinet.name,
|
||
"status": cabinet.status,
|
||
// 充电工作模式:字符串 "0"/"1"(前端契约),无值时为 null
|
||
"work_mode": cabinet.work_mode.map(|m| m.to_string()),
|
||
"rssi": realtime.rssi,
|
||
"pow_fail_dc": realtime.pow_fail_dc,
|
||
"pow_fail_ac": realtime.pow_fail_ac,
|
||
"address": cabinet.address,
|
||
"latitude": cabinet.latitude,
|
||
"longitude": cabinet.longitude,
|
||
"cabin_boards": cabin_boards,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 设备详情 — Redis 实时状态(柜级 + 通道级)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 详情页实时状态快照;Redis 不可用时整体为默认空值
|
||
#[derive(Default)]
|
||
struct CabinetRealtime {
|
||
rssi: Option<i32>,
|
||
pow_fail_dc: Option<bool>,
|
||
pow_fail_ac: Option<bool>,
|
||
/// 仓体 id -> (SOC, 电池ID)
|
||
soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>,
|
||
/// 仓体 id -> (温度, batt_status, pow_status)
|
||
ch_map: std::collections::HashMap<i64, (Option<i64>, Option<String>, Option<String>)>,
|
||
}
|
||
|
||
/// 查询仓控板与仓体,构建带实时字段占位的 JSON
|
||
///
|
||
/// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`,
|
||
/// 后两者供 Redis 批量查询通道级状态使用(板号/通道号为十进制不补零)。
|
||
async fn load_boards_with_compartments(
|
||
db: &MySqlPool,
|
||
cabinet_id: i64,
|
||
) -> Result<(Vec<Value>, Vec<i64>, Vec<(i64, (i8, i8))>), AppError> {
|
||
let boards: Vec<CabinBoardRow> = sqlx::query_as(
|
||
r#"SELECT id, cabinet_id, board_id, status
|
||
FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"#,
|
||
)
|
||
.bind(cabinet_id)
|
||
.fetch_all(db)
|
||
.await?;
|
||
|
||
let mut cabin_boards: Vec<Value> = Vec::new();
|
||
let mut comp_ids: Vec<i64> = Vec::new();
|
||
let mut comp_to_board: Vec<(i64, (i8, i8))> = Vec::new();
|
||
|
||
for board in boards {
|
||
let compartments: Vec<CompartmentRow> = sqlx::query_as(
|
||
r#"SELECT id, cabin_board_id, channel_id, status
|
||
FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"#,
|
||
)
|
||
.bind(board.id)
|
||
.fetch_all(db)
|
||
.await?;
|
||
|
||
for c in &compartments {
|
||
comp_ids.push(c.id);
|
||
comp_to_board.push((c.id, (board.board_id, c.channel_id)));
|
||
}
|
||
|
||
// batt_status / pow_status 为 2 位 hex 字符串,Redis 缺失时保持 null
|
||
let comp_values: Vec<Value> = compartments
|
||
.into_iter()
|
||
.map(|c| {
|
||
json!({
|
||
"id": c.id,
|
||
"channel_id": c.channel_id,
|
||
"status": c.status,
|
||
"batt_status": Value::Null,
|
||
"pow_status": Value::Null,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
cabin_boards.push(json!({
|
||
"id": board.id,
|
||
"board_id": board.board_id,
|
||
"status": board.status,
|
||
"compartments": comp_values,
|
||
}));
|
||
}
|
||
|
||
Ok((cabin_boards, comp_ids, comp_to_board))
|
||
}
|
||
|
||
/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态
|
||
///
|
||
/// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。
|
||
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
|
||
async fn fetch_cabinet_realtime(
|
||
redis_url: &str,
|
||
imei: &str,
|
||
comp_ids: &[i64],
|
||
comp_to_board: &[(i64, (i8, i8))],
|
||
) -> CabinetRealtime {
|
||
let client = match redis::Client::open(redis_url) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
tracing::warn!("[cabinet_detail] Redis 客户端创建失败,实时状态降级: {}", e);
|
||
return CabinetRealtime::default();
|
||
}
|
||
};
|
||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||
let mut conn = match client.get_multiplexed_async_connection().await {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
tracing::warn!("[cabinet_detail] Redis 连接失败,实时状态降级: {}", e);
|
||
return CabinetRealtime::default();
|
||
}
|
||
};
|
||
|
||
let key = format!("device:{}", imei);
|
||
let mut pipe = redis::pipe();
|
||
pipe.cmd("HGET").arg(&key).arg("rssi")
|
||
.cmd("HGET").arg(&key).arg("pow_fail_dc")
|
||
.cmd("HGET").arg(&key).arg("pow_fail_ac");
|
||
for cid in comp_ids {
|
||
pipe.cmd("GET").arg(format!("compartment:status:{}", cid));
|
||
}
|
||
// 通道级:板号/通道号为十进制不补零,与设备上报写入的 key 保持一致
|
||
for (_, (board_id, channel_id)) in comp_to_board {
|
||
let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id);
|
||
pipe.cmd("HGET").arg(&rkey).arg("temp")
|
||
.cmd("HGET").arg(&rkey).arg("batt_status")
|
||
.cmd("HGET").arg(&rkey).arg("pow_status");
|
||
}
|
||
|
||
// 用类型标注而非 turbofish:query_async 的泛型首参是连接类型
|
||
let queried: Result<Vec<Option<String>>, redis::RedisError> =
|
||
pipe.query_async(&mut conn).await;
|
||
match queried {
|
||
Ok(vals) => parse_cabinet_realtime(&vals, comp_ids, comp_to_board),
|
||
Err(e) => {
|
||
tracing::warn!("[cabinet_detail] Redis pipeline 读取失败,实时状态降级 imei={} err={}", imei, e);
|
||
CabinetRealtime::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 解析 pipeline 结果:柜级 3 项 + 每仓体 1 项(SOC JSON) + 每通道 3 项(temp/batt_status/pow_status)
|
||
fn parse_cabinet_realtime(
|
||
vals: &[Option<String>],
|
||
comp_ids: &[i64],
|
||
comp_to_board: &[(i64, (i8, i8))],
|
||
) -> CabinetRealtime {
|
||
/// 取出第 i 项的非空字符串
|
||
fn pick(vals: &[Option<String>], i: usize) -> Option<String> {
|
||
vals.get(i).and_then(|v| v.as_ref()).cloned()
|
||
}
|
||
|
||
let mut rt = CabinetRealtime {
|
||
rssi: pick(vals, 0).and_then(|v| v.parse().ok()),
|
||
pow_fail_dc: pick(vals, 1).map(|v| v == "1"),
|
||
pow_fail_ac: pick(vals, 2).map(|v| v == "1"),
|
||
..Default::default()
|
||
};
|
||
|
||
for (i, cid) in comp_ids.iter().enumerate() {
|
||
if let Some(data) = pick(vals, 3 + i) {
|
||
if let Ok(v) = serde_json::from_str::<Value>(&data) {
|
||
let soc = v.get("soc").and_then(|s| s.as_i64());
|
||
let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string);
|
||
rt.soc_map.insert(*cid, (soc, bid));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 通道级字段紧随 SOC 段之后,每通道 3 项
|
||
let base = 3 + comp_ids.len();
|
||
for (i, (cid, _)) in comp_to_board.iter().enumerate() {
|
||
let off = base + i * 3;
|
||
let temp = pick(vals, off).and_then(|v| v.parse::<i64>().ok());
|
||
let batt_status = pick(vals, off + 1);
|
||
let pow_status = pick(vals, off + 2);
|
||
rt.ch_map.insert(*cid, (temp, batt_status, pow_status));
|
||
}
|
||
|
||
rt
|
||
}
|
||
|
||
/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串
|
||
fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) {
|
||
for board in cabin_boards.iter_mut() {
|
||
let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else {
|
||
continue;
|
||
};
|
||
for comp in comps.iter_mut() {
|
||
let Some(cid) = comp.get("id").and_then(|i| i.as_i64()) else {
|
||
continue;
|
||
};
|
||
if let Some((soc, bid)) = rt.soc_map.get(&cid) {
|
||
if let Some(s) = soc {
|
||
comp["soc"] = json!(s);
|
||
}
|
||
if let Some(b) = bid {
|
||
comp["battery_id"] = json!(b);
|
||
}
|
||
}
|
||
if let Some((temp, batt_status, pow_status)) = rt.ch_map.get(&cid) {
|
||
if let Some(t) = temp {
|
||
comp["temp"] = json!(t);
|
||
}
|
||
if let Some(bs) = batt_status {
|
||
comp["batt_status"] = json!(bs);
|
||
}
|
||
if let Some(ps) = pow_status {
|
||
comp["pow_status"] = json!(ps);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 级联下拉选项
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 柜子下拉查询参数
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct CabinetListQuery {
|
||
pub project_id: Option<i64>,
|
||
}
|
||
|
||
/// 仓板下拉查询参数
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct CabinBoardListQuery {
|
||
pub cabinet_id: i64,
|
||
}
|
||
|
||
/// 仓体下拉查询参数
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct CompartmentListQuery {
|
||
pub cabin_board_id: i64,
|
||
}
|
||
|
||
/// 柜子下拉选项
|
||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||
pub struct CabinetOption {
|
||
pub id: i64,
|
||
pub name: Option<String>,
|
||
pub abstract_id: String,
|
||
pub imei: Option<String>,
|
||
pub iccid: Option<String>,
|
||
pub address: Option<String>,
|
||
pub status: i8,
|
||
}
|
||
|
||
/// 仓板下拉选项
|
||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||
pub struct CabinBoardOption {
|
||
pub id: i64,
|
||
pub board_id: i8,
|
||
}
|
||
|
||
/// 仓体下拉选项
|
||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||
pub struct CompartmentOption {
|
||
pub id: i64,
|
||
pub channel_id: i8,
|
||
}
|
||
|
||
/// GET /api/cabinets/options — 柜子下拉列表
|
||
pub async fn list_cabinet_options(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Query(params): Query<CabinetListQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:view")?;
|
||
|
||
let rows = if let Some(pid) = params.project_id {
|
||
sqlx::query_as::<_, CabinetOption>(
|
||
"SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets WHERE project_id = ? ORDER BY id"
|
||
)
|
||
.bind(pid)
|
||
.fetch_all(&state.mysql)
|
||
.await?
|
||
} else {
|
||
sqlx::query_as::<_, CabinetOption>(
|
||
"SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets ORDER BY id"
|
||
)
|
||
.fetch_all(&state.mysql)
|
||
.await?
|
||
};
|
||
|
||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||
}
|
||
|
||
/// GET /api/cabin-boards/options — 仓板下拉列表
|
||
pub async fn list_cabin_board_options(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Query(params): Query<CabinBoardListQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:view")?;
|
||
|
||
let rows = sqlx::query_as::<_, CabinBoardOption>(
|
||
"SELECT id, board_id FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"
|
||
)
|
||
.bind(params.cabinet_id)
|
||
.fetch_all(&state.mysql)
|
||
.await?;
|
||
|
||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||
}
|
||
|
||
/// GET /api/compartments/options — 仓体下拉列表
|
||
pub async fn list_compartment_options(
|
||
user: CurrentUser,
|
||
State(state): State<super::AppState>,
|
||
Query(params): Query<CompartmentListQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:view")?;
|
||
|
||
let rows = sqlx::query_as::<_, CompartmentOption>(
|
||
"SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"
|
||
)
|
||
.bind(params.cabin_board_id)
|
||
.fetch_all(&state.mysql)
|
||
.await?;
|
||
|
||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||
}
|
||
|