570 lines
16 KiB
Rust
Raw Normal View History

//! 设备管理 API — CRUD + 详情 + 级联下拉
//!
//! 提供柜子的增删改查、详情查询、安全码重新生成,
//! 以及柜子/仓板/仓体的级联下拉选项。
use axum::extract::{Path, Query, State};
use axum::Json;
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<CabinetDetail>, 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?;
let mut cabin_boards = 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?;
cabin_boards.push(CabinBoardWithCompartments {
id: board.id,
board_id: board.board_id,
status: board.status,
compartments,
});
}
Ok(Json(CabinetDetail {
id: cabinet.id,
project_id: cabinet.project_id,
abstract_id: cabinet.abstract_id,
imei: cabinet.imei,
name: cabinet.name,
status: cabinet.status,
cabin_boards,
}))
}
// ---------------------------------------------------------------------------
// 级联下拉选项
// ---------------------------------------------------------------------------
/// 柜子下拉查询参数
#[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" })))
}