- 越权面(全部 before/after 实测可复现,10 条):cabinets/filter 的 project_id/organization_id
参数分支越权(客户可拿对方向柜全字段)、organization-tree 返回全量组织树、
cabin-boards/compartments/cabinets 三个 options 无归属校验、batteries 无归属(改为经
charge_orders→cabinets→projects 间接归属)、feedback 跨组织读且能改他人状态、
GET /downloads/{id} 漏 user_id 过滤(跨用户元数据泄露)、
POST /downloads/cleanup 全局删除所有组织导出(改客户侧只清自己)、
/api/nodes/* 仅需登录(客户可读内网 IP 并注销节点)→ 收紧 role_level>=2;
/api/devices/stats 同样收紧;新增共享闸门 auth::require_super_admin
- TCP 登录门控:下行投递改为"登录前白名单"(PRE_LOGIN_ALLOWED_ACTS),与上行同一规则
- 60 秒超时对齐协议:挂起项 TTL 120→60 + 值内嵌 deadline 精确判窗 + 超时 WARN(兼容老格式)
- 下发前在线判据改连接级认证标记 device:authed:{imei}(消除"管理端显示成功、设备收不到")
⚠️ device-server 与 api-server 必须同批发布
- 协议对齐:auth_str 登记校验提到限流之前、build_spec Topology 分支 clamp(15/8)、
行号引用改小节引用、修 3 处错引小节
- 状态语义:compartments.status 只表占用,充满/故障由通道状态派生;柜级 status1/status2
与开门按钮(柜级独立动作不联动);列注释对齐(cabinets.status 补 2=禁用、
charge_orders.status 标注 2 保留未使用)
- 假绿单测修复:超时 SQL 抽常量 + 纯函数孪生体双向断言(方向写反必红)
- RSSI 两端统一为同源纯函数 5 档(小程序显示随之变化)+ test/rssi_signal.test.mjs
- nginx:扫码入口 abstract 限流(zone=scan_abstract_zone 30r/m burst=20)+ 主配置仓库镜像
- 文档:AGENTS.md 新增「踩过的坑」7 条教训 + 部署注意事项 11 条 + 文档口径优先级;
docs/API.md 接口总表 80 条与 mod.rs 逐条一致、Redis 契约按代码重写、9 处事实错误修正;
权威协议文档 v1.1.1(协议行为未变,仅修订文档自身的错误/矛盾/缺失)
1315 lines
50 KiB
Rust
1315 lines
50 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::cabinet_tree::derive_channel_flags;
|
||
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,
|
||
// 列表(按项目查柜子)不读 Redis,柜级位域如实为 null
|
||
status1: None,
|
||
status2: 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 (scope_cond, scope_binds) = user.effective_scope().project_condition("id");
|
||
let sql = format!(
|
||
"SELECT id FROM projects WHERE id = ? AND organization_id = ?{}",
|
||
scope_cond
|
||
);
|
||
let mut query = sqlx::query_as::<_, (i64,)>(&sql).bind(project_id).bind(org_id);
|
||
for bind in &scope_binds {
|
||
query = query.bind(bind);
|
||
}
|
||
let project: Option<(i64,)> = query.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;
|
||
// ⚠️ 字段必须与 `CabinetBasicRow` 一一对应(含 `latitude`/`longitude`):
|
||
// 少了经纬度这一列,`FromRow` 解码会直接报 `no column found for name: latitude`
|
||
// 并以 `DATABASE_ERROR` 500 掉整个列表接口(历史缺陷,本次随数据范围改造一并修掉)。
|
||
// 经纬度与详情接口同口径:`CAST(... AS CHAR)`,规避 sqlx 对 DECIMAL 的解码问题。
|
||
let sql_rows = 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 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?;
|
||
}
|
||
|
||
// 数据范围兜底:**未划拨到项目的柜子**(`project_id` 为空)走不到上面的项目校验,
|
||
// 配了范围的用户一律不可见(`allows_project(None)` 在有限制时为 false)。
|
||
if !user.effective_scope().allows_project(cabinet.project_id) {
|
||
return Err(AppError::Forbidden("无权访问该设备".into()));
|
||
}
|
||
|
||
// 仓控板 + 仓体(含实时字段占位),并收集通道 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,
|
||
// 柜温:Redis `device:{imei}.temp`,语义 K×100(与柜级 temp 口径一致);
|
||
// 取不到为 null(前端显示 `--°C`),不落 0
|
||
"temp": realtime.temp,
|
||
// **柜级**状态位(协议 §3.8):Redis `device:{imei}` 的 `cabinet_status1/2`
|
||
// 原样透出 2 位小写 hex 字符串(**不在后端解析成布尔**,前端两端有统一纯函数);
|
||
// 取不到为 null —— 与 `"00"`("全部正常"的有效值)严格区分,绝不瞎填 "00"
|
||
"status1": realtime.status1,
|
||
"status2": realtime.status2,
|
||
"address": cabinet.address,
|
||
"latitude": cabinet.latitude,
|
||
"longitude": cabinet.longitude,
|
||
"cabin_boards": cabin_boards,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 设备详情 — Redis 实时状态(柜级 + 通道级)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 详情页实时状态快照;Redis 不可用时整体为默认空值
|
||
///
|
||
/// 所有字段一律 `Option`:Redis 里取不到就保持 `None`(响应 JSON 为 `null`),
|
||
/// **绝不落成 0/空串** —— 0 是"真实读数为 0",与"无数据"必须区分。
|
||
#[derive(Default)]
|
||
struct CabinetRealtime {
|
||
rssi: Option<i32>,
|
||
pow_fail_dc: Option<bool>,
|
||
pow_fail_ac: Option<bool>,
|
||
/// 柜温,平台单位 K×100(Redis `device:{imei}` 的 `temp`)
|
||
temp: Option<i64>,
|
||
/// **柜级**柜体状态1(协议 §3.8,Redis `device:{imei}` 的 `cabinet_status1`)
|
||
///
|
||
/// 2 位小写 hex 字符串**原样透出**(如 `"10"` = 柜门打开、`"82"` = 喷淋 + 烟雾),
|
||
/// **不在 Rust 里解析成布尔** —— 它是位域,前端两端有统一的 hex 解析纯函数
|
||
/// (`web/src/utils/deviceStatus.ts` / `h5-uniapp/src/utils/deviceStatus.ts` 的
|
||
/// `parseCabinetStatus1`),与既有 `batt_status` / `pow_status` 的做法完全一致。
|
||
///
|
||
/// ⚠️ `None`(响应 `null`)= **无数据**,与 `Some("00")`(**全正常**的有效值)语义不同,
|
||
/// 绝不能把缺失落成 `"00"`。
|
||
status1: Option<String>,
|
||
/// **柜级**柜体状态2(协议 §3.8,Redis `device:{imei}` 的 `cabinet_status2`)
|
||
///
|
||
/// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。
|
||
/// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。
|
||
status2: Option<String>,
|
||
/// 仓体 id -> (SOC, 电池ID)
|
||
soc_map: std::collections::HashMap<i64, (Option<i64>, Option<String>)>,
|
||
/// 仓体 id -> 通道级实时状态
|
||
ch_map: std::collections::HashMap<i64, ChannelRealtime>,
|
||
}
|
||
|
||
/// 单通道实时状态(与 `report_worker::write_channel_state` 写入的 Hash 字段一一对应)
|
||
///
|
||
/// 单位口径与 `docs/API.md` 一致:`soc` ‰ / `voltage` mV / `current` mA / `temp` K×100,
|
||
/// `batt_status` / `pow_status` 为 2 位 hex 字符串。
|
||
#[derive(Default, Clone, Debug)]
|
||
struct ChannelRealtime {
|
||
/// 电芯最高温度,单位 K×100
|
||
temp: Option<i64>,
|
||
/// 电池状态(2 位 hex)
|
||
batt_status: Option<String>,
|
||
/// 电源状态(2 位 hex)
|
||
pow_status: Option<String>,
|
||
/// 充电电压,单位 mV
|
||
voltage: Option<i64>,
|
||
/// 充电电流,单位 mA
|
||
current: Option<i64>,
|
||
}
|
||
|
||
/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数:
|
||
/// `rssi` / `pow_fail_dc` / `pow_fail_ac` / `temp` / `cabinet_status1` / `cabinet_status2`。
|
||
///
|
||
/// ⚠️ **命令顺序与下标一一对应**:本常量同时是 [`fetch_cabinet_realtime`] 下发 `HGET` 的
|
||
/// 顺序(下标 0..[`CABINET_REALTIME_FIELDS`])与 [`parse_cabinet_realtime`] 的柜级下标解析依据,
|
||
/// 还决定了 SOC 段(`CABINET_REALTIME_FIELDS + i`)与通道段(`+ comp_ids.len()`)的起始偏移。
|
||
/// **新增柜级字段必须同步改这一处,且只能追加在末尾**(改动即破坏下标对齐)。
|
||
const CABINET_REALTIME_FIELDS: usize = 6;
|
||
/// 柜级 hex 位域字段在 pipeline 结果中的下标(协议 §3.8,**柜级**:`cabinet.status1` / `status2`)
|
||
const CABINET_STATUS1_IDX: usize = 4;
|
||
const CABINET_STATUS2_IDX: usize = 5;
|
||
/// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current
|
||
const CHANNEL_REALTIME_FIELDS: usize = 5;
|
||
|
||
/// 查询仓控板与仓体,构建带实时字段占位的 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,
|
||
// 通道级实时字段占位:Redis 缺失时保持 null(**不落 0**,
|
||
// 前端 `c.voltage ?? '-'` 据此显示"-"而不是"-mV")
|
||
"voltage": Value::Null,
|
||
"current": Value::Null,
|
||
"temp": 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}) 实时状态
|
||
///
|
||
/// 为什么放一起:详情接口要求低延迟,柜级 6 个字段 + 每通道 5 个字段共用一次网络往返。
|
||
/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。
|
||
///
|
||
/// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段:
|
||
/// 设备上报(`report_worker::write_device_state` / `write_channel_state`)早就写进了 Redis,
|
||
/// 只是接口没取,导致 PC 端柜温恒为 `--°C`、电压电流恒为 `-mV -mA`。
|
||
///
|
||
/// 柜级第 5、6 个字段(`cabinet_status1` / `cabinet_status2`,协议 §3.8)同样是**补取**:
|
||
/// `report_worker::handle_status_post` 一直在写,但此前全仓无人读 —— 本次透出为
|
||
/// 响应根字段 `status1` / `status2`(**柜级**语义,前端按层级展示在"柜子"层面)。
|
||
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);
|
||
// ⚠️ 命令顺序必须与 CABINET_REALTIME_FIELDS / CABINET_STATUS*_IDX 及
|
||
// parse_cabinet_realtime 的下标解析**完全一致**(新增柜级字段只能追加在末尾)
|
||
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")
|
||
.cmd("HGET").arg(&key).arg("temp")
|
||
// 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析
|
||
.cmd("HGET").arg(&key).arg("cabinet_status1")
|
||
.cmd("HGET").arg(&key).arg("cabinet_status2");
|
||
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")
|
||
.cmd("HGET").arg(&rkey).arg("voltage")
|
||
.cmd("HGET").arg(&rkey).arg("current");
|
||
}
|
||
|
||
// 用类型标注而非 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 结果:柜级 [`CABINET_REALTIME_FIELDS`] 项 + 每仓体 1 项(SOC JSON)
|
||
/// + 每通道 [`CHANNEL_REALTIME_FIELDS`] 项(temp/batt_status/pow_status/voltage/current)
|
||
///
|
||
/// 任何一项缺失或解析失败 → 对应字段保持 `None`(**不落 0**)。
|
||
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()
|
||
}
|
||
/// 取出第 i 项并解析为 i64(空值/非数字 → None)
|
||
fn pick_i64(vals: &[Option<String>], i: usize) -> Option<i64> {
|
||
pick(vals, i).and_then(|v| v.trim().parse::<i64>().ok())
|
||
}
|
||
/// 取出第 i 项的非空字符串:`None` 或**纯空白**都视为"无数据"(Redis 里不该有空值)
|
||
fn pick_non_empty(vals: &[Option<String>], i: usize) -> Option<String> {
|
||
pick(vals, i).filter(|v| !v.trim().is_empty())
|
||
}
|
||
|
||
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"),
|
||
temp: pick_i64(vals, 3),
|
||
// 柜级位域:**原样透出 hex**(不解析成布尔 —— 位域不是单标志);
|
||
// 缺数据时保持 None → 响应 null,**绝不落成 "00"**("00" 是"全部正常"的有效值)
|
||
status1: pick_non_empty(vals, CABINET_STATUS1_IDX),
|
||
status2: pick_non_empty(vals, CABINET_STATUS2_IDX),
|
||
..Default::default()
|
||
};
|
||
|
||
for (i, cid) in comp_ids.iter().enumerate() {
|
||
if let Some(data) = pick(vals, CABINET_REALTIME_FIELDS + 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 段之后,每通道 CHANNEL_REALTIME_FIELDS 项
|
||
let base = CABINET_REALTIME_FIELDS + comp_ids.len();
|
||
for (i, (cid, _)) in comp_to_board.iter().enumerate() {
|
||
let off = base + i * CHANNEL_REALTIME_FIELDS;
|
||
rt.ch_map.insert(
|
||
*cid,
|
||
ChannelRealtime {
|
||
temp: pick_i64(vals, off),
|
||
batt_status: pick(vals, off + 1),
|
||
pow_status: pick(vals, off + 2),
|
||
voltage: pick_i64(vals, off + 3),
|
||
current: pick_i64(vals, off + 4),
|
||
},
|
||
);
|
||
}
|
||
|
||
rt
|
||
}
|
||
|
||
/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串,也绝不落 0
|
||
///
|
||
/// 另外把每个仓体的 `status` 从「DB 占用值(0/1)」改写成**派生展示值**
|
||
/// (有通道数据时 `0空闲 / 1充电中 / 2充满 / 3故障`;无数据时保持 DB 值),
|
||
/// 因为 `compartments.status` 只表达占用,DB 里永远看不到"充满/故障"。
|
||
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(ch) = rt.ch_map.get(&cid) {
|
||
// 展示状态改为**派生值**:有通道实时数据时按
|
||
// fault > full > charging > idle → `3故障 / 2充满 / 1充电中 / 0空闲`
|
||
// (与 `cabinet_tree::derive_channel_flags` 唯一权威规则同源,
|
||
// 前端 `STATUS_MAP` 只按 0/1/2/3 渲染,**没有"禁用"档**);
|
||
// 拿不到数据时**保留上面写入的 DB 占用值(0/1)** —— 离线无法判定,不猜测。
|
||
let flags =
|
||
derive_channel_flags(ch.batt_status.as_deref(), ch.pow_status.as_deref());
|
||
if flags.has_data {
|
||
comp["status"] = json!(flags.status_code());
|
||
}
|
||
if let Some(t) = ch.temp {
|
||
comp["temp"] = json!(t);
|
||
}
|
||
if let Some(bs) = &ch.batt_status {
|
||
comp["batt_status"] = json!(bs);
|
||
}
|
||
if let Some(ps) = &ch.pow_status {
|
||
comp["pow_status"] = json!(ps);
|
||
}
|
||
if let Some(v) = ch.voltage {
|
||
comp["voltage"] = json!(v);
|
||
}
|
||
if let Some(c) = ch.current {
|
||
comp["current"] = json!(c);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 级联下拉选项
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 柜子下拉查询参数
|
||
#[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,
|
||
}
|
||
|
||
/// 柜子下拉列表的固定列清单(列表/详情共用同一组字段,避免两处漂移)
|
||
const CABINET_OPTION_COLUMNS: &str =
|
||
"id, name, abstract_id, imei, iccid, address, status";
|
||
|
||
/// GET /api/cabinets/options — 柜子下拉列表
|
||
///
|
||
/// **两道收窄(取交集)**:
|
||
/// 1. **组织隔离**([`auth::org_condition_for_project`])—— 无条件叠加:
|
||
/// 总管理员全量、客户侧仅本组织、无组织空集(**绝不退化成全量**);
|
||
/// 2. **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。
|
||
///
|
||
/// 历史缺陷:本接口只叠加了数据范围、**没有组织隔离** ⇒ 未配置 scope 的客户账号
|
||
/// 不带 `project_id` 调用时能看到**全平台所有柜子**(含 IMEI/ICCID/地址)。
|
||
/// `project_id` 入参只是纯筛选,不构成归属边界。
|
||
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")?;
|
||
|
||
// 动态拼 WHERE:项目筛选 → 组织隔离 → 数据范围收窄,
|
||
// 书写顺序 = 占位符顺序 = 绑定顺序,三者严格一致
|
||
let mut where_clause = String::new();
|
||
let mut binds: Vec<String> = Vec::new();
|
||
if let Some(pid) = params.project_id {
|
||
where_clause.push_str("project_id = ?");
|
||
binds.push(pid.to_string());
|
||
}
|
||
let (org_cond, org_binds) = auth::org_condition_for_project(&user, "project_id");
|
||
auth::append_condition(&mut where_clause, &org_cond);
|
||
binds.extend(org_binds);
|
||
let (scope_cond, scope_binds) = user.effective_scope().project_condition("project_id");
|
||
auth::append_condition(&mut where_clause, &scope_cond);
|
||
binds.extend(scope_binds);
|
||
|
||
let mut sql = format!("SELECT {} FROM cabinets", CABINET_OPTION_COLUMNS);
|
||
if !where_clause.is_empty() {
|
||
sql.push_str(" WHERE ");
|
||
sql.push_str(&where_clause);
|
||
}
|
||
sql.push_str(" ORDER BY id");
|
||
|
||
let mut query = sqlx::query_as::<_, CabinetOption>(&sql);
|
||
for bind in &binds {
|
||
query = query.bind(bind);
|
||
}
|
||
let rows = query.fetch_all(&state.mysql).await?;
|
||
|
||
Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
|
||
}
|
||
|
||
/// GET /api/cabin-boards/options — 仓板下拉列表
|
||
///
|
||
/// **归属校验**:`cabinet_id` 是外部输入,必须先过 [`scope_guard::verify_cabinet_ownership`]
|
||
/// (组织归属 + 数据范围,总管理员豁免)——历史缺陷:只按 `cabinet_id` 直查,
|
||
/// 传别人的柜子 id 就能拿到它的仓板列表。归属不符直接 403(与 `/api/cabinets/{id}` 同一口径)。
|
||
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")?;
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, params.cabinet_id).await?;
|
||
|
||
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 — 仓体下拉列表
|
||
///
|
||
/// **归属校验**:`cabin_board_id` → `cabin_boards.cabinet_id` → 柜子归属校验
|
||
/// (组织归属 + 数据范围)。历史缺陷:只按 `cabin_board_id` 直查,
|
||
/// 传别人的仓板 id 就能拿到它的仓体列表。仓板不存在 → 404;归属不符 → 403。
|
||
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")?;
|
||
|
||
// 仓板 → 柜子:拿不到说明仓板不存在(外部输入的 id 不保证有效)
|
||
let cabinet_id: Option<i64> =
|
||
sqlx::query_scalar("SELECT cabinet_id FROM cabin_boards WHERE id = ?")
|
||
.bind(params.cabin_board_id)
|
||
.fetch_optional(&state.mysql)
|
||
.await?;
|
||
let cabinet_id =
|
||
cabinet_id.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?;
|
||
scope_guard::verify_cabinet_ownership(&state.mysql, &user, cabinet_id).await?;
|
||
|
||
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" })))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 单元测试
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// pipeline 结果解析:柜级 6 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局,
|
||
/// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 Redis 但接口不取)
|
||
#[test]
|
||
fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() {
|
||
let comp_ids = vec![11_i64, 12_i64];
|
||
let comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))];
|
||
let vals: Vec<Option<String>> = vec![
|
||
// 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2
|
||
Some("85".into()),
|
||
Some("1".into()),
|
||
Some("0".into()),
|
||
Some("29815".into()),
|
||
Some("10".into()),
|
||
Some("01".into()),
|
||
// 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None)
|
||
None,
|
||
None,
|
||
// 通道 11:temp / batt_status / pow_status / voltage / current
|
||
Some("29815".into()),
|
||
Some("82".into()),
|
||
Some("03".into()),
|
||
Some("53200".into()),
|
||
Some("1500".into()),
|
||
// 通道 12
|
||
Some("30015".into()),
|
||
Some("02".into()),
|
||
Some("01".into()),
|
||
Some("48000".into()),
|
||
Some("0".into()),
|
||
];
|
||
|
||
let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board);
|
||
|
||
assert_eq!(rt.rssi, Some(85));
|
||
assert_eq!(rt.pow_fail_dc, Some(true));
|
||
assert_eq!(rt.pow_fail_ac, Some(false));
|
||
assert_eq!(rt.temp, Some(29815), "柜温应被取出");
|
||
assert_eq!(rt.status1.as_deref(), Some("10"), "柜级 status1 应原样透出 hex");
|
||
assert_eq!(rt.status2.as_deref(), Some("01"), "柜级 status2 应原样透出 hex");
|
||
|
||
let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态");
|
||
assert_eq!(ch11.voltage, Some(53200), "电压应被取出");
|
||
assert_eq!(ch11.current, Some(1500), "电流应被取出");
|
||
assert_eq!(ch11.temp, Some(29815));
|
||
assert_eq!(ch11.batt_status.as_deref(), Some("82"));
|
||
assert_eq!(ch11.pow_status.as_deref(), Some("03"));
|
||
|
||
let ch12 = rt.ch_map.get(&12).expect("通道 12 应有实时状态");
|
||
// 0 是**真实读数**,必须原样保留(不能与"无数据"混为一谈)
|
||
assert_eq!(ch12.current, Some(0));
|
||
assert_eq!(ch12.voltage, Some(48000));
|
||
}
|
||
|
||
/// 柜级位域解析口径(协议 §3.8 / §4.2):
|
||
/// `"00"` 是"全部正常"的**有效值**,`"10"` 是柜门打开,`"82"` 是喷淋 + 烟雾,
|
||
/// `"01"`(status2) 是声光报警 —— 一律**原样透出**,不作任何改写、不解析成布尔。
|
||
#[test]
|
||
fn test_parse_cabinet_realtime_cabinet_status_bits_raw_hex() {
|
||
let comp_ids: Vec<i64> = vec![];
|
||
let comp_to_board: Vec<(i64, (i8, i8))> = vec![];
|
||
|
||
// "00" = 全 0:全正常(**不是**"无数据")
|
||
let vals = vec![
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some("00".into()),
|
||
Some("00".into()),
|
||
];
|
||
let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board);
|
||
assert_eq!(rt.status1.as_deref(), Some("00"), "全正常必须是 Some(\"00\") 而不是 None");
|
||
assert_eq!(rt.status2.as_deref(), Some("00"));
|
||
|
||
// "82" = bit7 喷淋 + bit1 烟雾;"10" = 柜门打开
|
||
let vals = vec![
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some("82".into()),
|
||
Some("10".into()),
|
||
];
|
||
let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board);
|
||
assert_eq!(rt.status1.as_deref(), Some("82"), "喷淋+烟雾应原样透出");
|
||
assert_eq!(rt.status2.as_deref(), Some("10"), "status2 也必须原样透出(不在后端做位判定)");
|
||
}
|
||
|
||
/// 柜级位域**取不到**时必须返回 `None`(响应 `null`)—— 绝不瞎填 `"00"`:
|
||
/// `"00"` 是"全部正常"的有效值,与"没数据"必须区分得开。
|
||
#[test]
|
||
fn test_parse_cabinet_realtime_cabinet_status_missing_is_null_not_00() {
|
||
let comp_ids: Vec<i64> = vec![];
|
||
let comp_to_board: Vec<(i64, (i8, i8))> = vec![];
|
||
|
||
// HGET 未命中(None)与空串/纯空白都算"无数据"
|
||
for raw in [None, Some(String::new()), Some(" ".into())] {
|
||
let vals = vec![
|
||
Some("85".into()),
|
||
None,
|
||
None,
|
||
None,
|
||
raw.clone(),
|
||
raw,
|
||
];
|
||
let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board);
|
||
assert_eq!(rt.status1, None, "缺失/空值必须为 None,不能落成 \"00\"");
|
||
assert_eq!(rt.status2, None, "缺失/空值必须为 None,不能落成 \"00\"");
|
||
}
|
||
|
||
// 长度不足(Redis 里只有柜级前 4 项)同样安全降级为 None,不能 panic
|
||
let short = vec![Some("85".into()), None, None, Some("29815".into())];
|
||
let rt = parse_cabinet_realtime(&short, &comp_ids, &comp_to_board);
|
||
assert_eq!(rt.status1, None);
|
||
assert_eq!(rt.status2, None);
|
||
|
||
// 空切片(Redis 整体不可用)也不能 panic
|
||
let rt = parse_cabinet_realtime(&[], &comp_ids, &comp_to_board);
|
||
assert_eq!(rt.status1, None);
|
||
assert_eq!(rt.status2, None);
|
||
}
|
||
|
||
/// 柜级下标常量与 [`CABINET_REALTIME_FIELDS`] 必须同步
|
||
/// (命令顺序与下标一一对应;新增柜级字段只能追加在末尾)
|
||
#[test]
|
||
fn test_cabinet_realtime_field_indices_are_in_sync() {
|
||
assert_eq!(
|
||
CABINET_REALTIME_FIELDS, 6,
|
||
"柜级 HGET 字段数变了:必须同步 fetch_cabinet_realtime 的命令顺序与本文件的下标解析"
|
||
);
|
||
assert_eq!(CABINET_STATUS1_IDX, CABINET_REALTIME_FIELDS - 2);
|
||
assert_eq!(CABINET_STATUS2_IDX, CABINET_REALTIME_FIELDS - 1);
|
||
}
|
||
|
||
/// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0)
|
||
#[test]
|
||
fn test_parse_cabinet_realtime_missing_fields_stay_none() {
|
||
let comp_ids = vec![7_i64];
|
||
let comp_to_board = vec![(7_i64, (1_i8, 1_i8))];
|
||
// 柜级 6 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失
|
||
let vals: Vec<Option<String>> = vec![
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some("29815".into()),
|
||
Some("82".into()),
|
||
Some("03".into()),
|
||
Some("abc".into()),
|
||
None,
|
||
];
|
||
|
||
let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board);
|
||
|
||
assert_eq!(rt.temp, None);
|
||
assert_eq!(rt.rssi, None);
|
||
let ch = rt.ch_map.get(&7).expect("通道应存在(缺失也是 None 集合)");
|
||
assert_eq!(ch.voltage, None, "非数字电压必须是 None 而不是 0");
|
||
assert_eq!(ch.current, None, "缺失电流必须是 None 而不是 0");
|
||
assert_eq!(ch.temp, Some(29815));
|
||
}
|
||
|
||
/// apply_realtime:只覆盖取到的值,取不到的字段保持占位 null
|
||
#[test]
|
||
fn test_apply_realtime_keeps_null_when_missing() {
|
||
let mut wrap = vec![json!({
|
||
"compartments": [{
|
||
"id": 11,
|
||
"channel_id": 1,
|
||
"status": 0,
|
||
"batt_status": Value::Null,
|
||
"pow_status": Value::Null,
|
||
"voltage": Value::Null,
|
||
"current": Value::Null,
|
||
"temp": Value::Null,
|
||
}]
|
||
})];
|
||
|
||
let mut ch_map = std::collections::HashMap::new();
|
||
ch_map.insert(
|
||
11_i64,
|
||
ChannelRealtime {
|
||
temp: None,
|
||
batt_status: Some("82".into()),
|
||
pow_status: None,
|
||
voltage: Some(53200),
|
||
current: None,
|
||
},
|
||
);
|
||
let rt = CabinetRealtime { ch_map, ..Default::default() };
|
||
|
||
apply_realtime(&mut wrap, &rt);
|
||
|
||
let comp = &wrap[0]["compartments"][0];
|
||
assert_eq!(comp["batt_status"], json!("82"));
|
||
assert_eq!(comp["voltage"], json!(53200));
|
||
assert_eq!(comp["current"], Value::Null, "缺失电流必须保持 null");
|
||
assert_eq!(comp["temp"], Value::Null, "缺失温度必须保持 null");
|
||
assert_eq!(comp["pow_status"], Value::Null);
|
||
}
|
||
}
|
||
|