2026-07-02 05:38:01 +08:00
|
|
|
|
//! 设备管理 API — CRUD + 详情 + 级联下拉
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! 提供柜子的增删改查、详情查询、安全码重新生成,
|
|
|
|
|
|
//! 以及柜子/仓板/仓体的级联下拉选项。
|
|
|
|
|
|
|
|
|
|
|
|
use axum::extract::{Path, Query, State};
|
|
|
|
|
|
use axum::Json;
|
2026-07-04 04:03:02 +08:00
|
|
|
|
use redis;
|
2026-07-02 05:38:01 +08:00
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
|
use sqlx::MySqlPool;
|
|
|
|
|
|
|
|
|
|
|
|
use super::organizations::{
|
2026-07-03 02:45:00 +08:00
|
|
|
|
generate_auth_str,
|
|
|
|
|
|
CabinetDetail, CabinBoardRow, CabinBoardWithCompartments,
|
|
|
|
|
|
CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
|
2026-07-02 05:38:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
use crate::error::AppError;
|
|
|
|
|
|
use crate::middleware::auth::{self, CurrentUser};
|
|
|
|
|
|
|
2026-07-03 02:45:00 +08:00
|
|
|
|
/// 基础柜子行(只包含数据库字段,用于简单查询)
|
|
|
|
|
|
#[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>,
|
|
|
|
|
|
status: 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 项目访问权限验证
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// 验证用户是否有权访问指定项目
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 总管理员(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;
|
2026-07-03 02:45:00 +08:00
|
|
|
|
let sql_rows = sqlx::query_as::<_, CabinetBasicRow>(
|
2026-07-02 21:08:21 +08:00
|
|
|
|
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status
|
2026-07-02 05:38:01 +08:00
|
|
|
|
FROM cabinets WHERE project_id = ? ORDER BY id"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(project_id)
|
|
|
|
|
|
.fetch_all(db)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
2026-07-03 02:45:00 +08:00
|
|
|
|
let rows: Vec<super::organizations::CabinetRow> = sql_rows.into_iter().map(Into::into).collect();
|
2026-07-02 05:38:01 +08:00
|
|
|
|
Ok(Json(json!(rows)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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")?;
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
2026-07-03 02:45:00 +08:00
|
|
|
|
r#"INSERT INTO cabinets (project_id, imei, auth_str)
|
|
|
|
|
|
VALUES (?, ?, ?)"#,
|
2026-07-02 05:38:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
.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);
|
2026-07-03 02:45:00 +08:00
|
|
|
|
tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei);
|
2026-07-02 05:38:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({ "ids": created_ids })))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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")?;
|
|
|
|
|
|
|
|
|
|
|
|
let db = &state.mysql;
|
|
|
|
|
|
let rows = sqlx::query(
|
|
|
|
|
|
r#"UPDATE cabinets SET
|
|
|
|
|
|
project_id = COALESCE(?, project_id),
|
2026-07-03 02:45:00 +08:00
|
|
|
|
name = COALESCE(?, name),
|
|
|
|
|
|
abstract_id = COALESCE(?, abstract_id)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
WHERE id = ?"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(body.project_id)
|
|
|
|
|
|
.bind(&body.name)
|
2026-07-03 02:45:00 +08:00
|
|
|
|
.bind(&body.abstract_id)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
.bind(id)
|
|
|
|
|
|
.execute(db)
|
|
|
|
|
|
.await?
|
|
|
|
|
|
.rows_affected();
|
|
|
|
|
|
|
|
|
|
|
|
if rows == 0 {
|
|
|
|
|
|
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tracing::info!("更新柜子 id={}", id);
|
|
|
|
|
|
Ok(Json(json!({})))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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")?;
|
|
|
|
|
|
|
|
|
|
|
|
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!({})))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 02:45:00 +08:00
|
|
|
|
/// 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")?;
|
|
|
|
|
|
|
|
|
|
|
|
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!({ "abstract_id": abstract_id })))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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")?;
|
|
|
|
|
|
|
|
|
|
|
|
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!({})))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
/// 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> {
|
|
|
|
|
|
auth::check_permission(&user, "device:edit")?;
|
|
|
|
|
|
|
|
|
|
|
|
let db = &state.mysql;
|
|
|
|
|
|
let auth_str = generate_auth_str();
|
|
|
|
|
|
|
|
|
|
|
|
let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ? WHERE id = ?"#)
|
|
|
|
|
|
.bind(&auth_str)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.execute(db)
|
|
|
|
|
|
.await?
|
|
|
|
|
|
.rows_affected();
|
|
|
|
|
|
|
|
|
|
|
|
if rows == 0 {
|
|
|
|
|
|
return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tracing::info!("重新生成 auth_str id={}", id);
|
|
|
|
|
|
Ok(Json(json!({ "auth_str": auth_str })))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 柜子详情(含仓控板 + 仓体)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// GET /api/cabinets/:id — 柜子详情,包含仓控板和仓体
|
|
|
|
|
|
pub async fn get_cabinet_detail(
|
|
|
|
|
|
user: CurrentUser,
|
|
|
|
|
|
State(state): State<super::AppState>,
|
|
|
|
|
|
Path(id): Path<i64>,
|
2026-07-04 04:03:02 +08:00
|
|
|
|
) -> Result<Json<Value>, AppError> {
|
2026-07-02 05:38:01 +08:00
|
|
|
|
auth::check_permission(&user, "device:view")?;
|
|
|
|
|
|
|
|
|
|
|
|
let db = &state.mysql;
|
2026-07-03 02:45:00 +08:00
|
|
|
|
let cabinet = sqlx::query_as::<_, CabinetBasicRow>(
|
|
|
|
|
|
r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status
|
2026-07-02 05:38:01 +08:00
|
|
|
|
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?;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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(id)
|
|
|
|
|
|
.fetch_all(db)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
// 查询仓控板+仓体,同时构建 Value 以便注入 SOC
|
|
|
|
|
|
let mut cabin_boards: Vec<Value> = Vec::new();
|
|
|
|
|
|
let mut comp_ids: Vec<i64> = Vec::new();
|
2026-07-02 05:38:01 +08:00
|
|
|
|
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?;
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
for c in &compartments {
|
|
|
|
|
|
comp_ids.push(c.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let comp_values: Vec<Value> = compartments.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": comp_values,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 从 Redis 批量读取实时状态(RSSI/电源/SOC)
|
|
|
|
|
|
let (rssi, pow_fail_dc, pow_fail_ac, mut soc_map) = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
|
|
|
|
|
if let Ok(mut conn) = client.get_async_connection().await {
|
|
|
|
|
|
let key = format!("device:{}", cabinet.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));
|
|
|
|
|
|
}
|
|
|
|
|
|
let vals: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
|
|
|
|
|
|
let rssi: Option<i32> = vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse().ok());
|
|
|
|
|
|
let dc = vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1");
|
|
|
|
|
|
let ac = vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1");
|
|
|
|
|
|
let mut soc_map = std::collections::HashMap::new();
|
|
|
|
|
|
for (i, cid) in comp_ids.iter().enumerate() {
|
|
|
|
|
|
if let Some(Some(data)) = vals.get(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(|s| s.to_string());
|
|
|
|
|
|
soc_map.insert(*cid, (soc, bid));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
(rssi, dc, ac, soc_map)
|
|
|
|
|
|
} else { (None, None, None, std::collections::HashMap::new()) }
|
|
|
|
|
|
} else { (None, None, None, std::collections::HashMap::new()) };
|
|
|
|
|
|
|
|
|
|
|
|
// 注入 SOC 到每个仓体
|
|
|
|
|
|
for board in &mut cabin_boards {
|
|
|
|
|
|
if let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) {
|
|
|
|
|
|
for comp in comps {
|
|
|
|
|
|
if let Some(id) = comp.get("id").and_then(|i| i.as_i64()) {
|
|
|
|
|
|
if let Some((soc, bid)) = soc_map.get(&id) {
|
|
|
|
|
|
if let Some(s) = soc {
|
|
|
|
|
|
comp["soc"] = json!(s);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(b) = bid {
|
|
|
|
|
|
comp["battery_id"] = json!(b);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-02 05:38:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 04:03:02 +08:00
|
|
|
|
Ok(Json(json!({
|
|
|
|
|
|
"code": 0,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"id": cabinet.id,
|
|
|
|
|
|
"project_id": cabinet.project_id,
|
|
|
|
|
|
"abstract_id": cabinet.abstract_id,
|
|
|
|
|
|
"imei": cabinet.imei,
|
|
|
|
|
|
"iccid": cabinet.iccid,
|
|
|
|
|
|
"name": cabinet.name,
|
|
|
|
|
|
"status": cabinet.status,
|
|
|
|
|
|
"rssi": rssi,
|
|
|
|
|
|
"pow_fail_dc": pow_fail_dc,
|
|
|
|
|
|
"pow_fail_ac": pow_fail_ac,
|
|
|
|
|
|
"address": cabinet.address,
|
|
|
|
|
|
"cabin_boards": cabin_boards,
|
|
|
|
|
|
},
|
|
|
|
|
|
"message": "ok"
|
|
|
|
|
|
})))
|
2026-07-02 05:38:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 级联下拉选项
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// 柜子下拉查询参数
|
|
|
|
|
|
#[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,
|
2026-07-02 20:54:37 +08:00
|
|
|
|
pub imei: Option<String>,
|
|
|
|
|
|
pub iccid: Option<String>,
|
|
|
|
|
|
pub address: Option<String>,
|
|
|
|
|
|
pub status: i8,
|
2026-07-02 05:38:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 仓板下拉选项
|
|
|
|
|
|
#[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>(
|
2026-07-02 20:54:37 +08:00
|
|
|
|
"SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets WHERE project_id = ? ORDER BY id"
|
2026-07-02 05:38:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.fetch_all(&state.mysql)
|
|
|
|
|
|
.await?
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as::<_, CabinetOption>(
|
2026-07-02 20:54:37 +08:00
|
|
|
|
"SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets ORDER BY id"
|
2026-07-02 05:38:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
.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" })))
|
|
|
|
|
|
}
|