641 lines
20 KiB
Rust
Raw Normal View History

//! 设备管理 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,
CabinetDetail, CabinBoardRow, CabinBoardWithCompartments,
CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// 基础柜子行(只包含数据库字段,用于简单查询)
#[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,
}
}
}
// ---------------------------------------------------------------------------
// 项目访问权限验证
// ---------------------------------------------------------------------------
/// 验证用户是否有权访问指定项目
///
/// 总管理员(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
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!(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(
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!({ "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),
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!({})))
}
/// 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!({})))
}
/// 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!({})))
}
/// 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>,
) -> 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, status
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?;
// 查询仓控板+仓体,同时构建 Value 以便注入 SOC
let mut cabin_boards: Vec<Value> = Vec::new();
let mut comp_ids: Vec<i64> = 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);
}
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);
}
}
}
}
}
}
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"
})))
}
// ---------------------------------------------------------------------------
// 级联下拉选项
// ---------------------------------------------------------------------------
/// 柜子下拉查询参数
#[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" })))
}