//! 设备管理 API — CRUD + 详情 + 级联下拉 //! //! 提供柜子的增删改查、详情查询、安全码重新生成, //! 以及柜子/仓板/仓体的级联下拉选项。 use axum::extract::{Path, Query, State}; use axum::Json; use redis; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sqlx::MySqlPool; use super::organizations::{ generate_auth_str, CabinBoardRow, CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody, }; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; use super::scope_guard; /// 基础柜子行(只包含数据库字段,用于简单查询) #[derive(Debug, Clone, Serialize, sqlx::FromRow)] struct CabinetBasicRow { id: i64, project_id: Option, abstract_id: Option, imei: String, iccid: Option, name: Option, address: Option, latitude: Option, longitude: Option, status: i8, /// 充电工作模式:0=平台指令模式 1=自动充电工作模式 work_mode: Option, } impl From for super::organizations::CabinetRow { fn from(row: CabinetBasicRow) -> Self { super::organizations::CabinetRow { id: row.id, project_id: row.project_id, abstract_id: row.abstract_id, imei: row.imei, iccid: row.iccid, name: row.name, address: row.address, status: row.status, board_count: None, channel_count: None, idle_count: None, charging_count: None, full_count: None, fault_count: None, rssi: None, pow_fail_dc: None, pow_fail_ac: None, is_online: None, org_disabled: None, project_address: None, } } } // --------------------------------------------------------------------------- // 项目访问权限验证 // --------------------------------------------------------------------------- /// 验证用户是否有权访问指定项目 /// /// 总管理员(role_level >= 2)可访问所有项目; /// 企业管理员只能访问本组织下的项目。 async fn check_project_access( user: &CurrentUser, pool: &MySqlPool, project_id: i64, ) -> Result<(), AppError> { // 总管理员可访问所有项目 if user.role_level >= 2 { return Ok(()); } if let Some(org_id) = user.organization_id { // 验证项目是否属于用户的组织 let project: Option<(i64,)> = sqlx::query_as( "SELECT id FROM projects WHERE id = ? AND organization_id = ?", ) .bind(project_id) .bind(org_id) .fetch_optional(pool) .await?; if project.is_none() { return Err(AppError::Forbidden("无权访问该项目".into())); } } else { return Err(AppError::Forbidden("无组织关联".into())); } Ok(()) } // --------------------------------------------------------------------------- // 设备 CRUD // --------------------------------------------------------------------------- /// GET /api/projects/:project_id/cabinets — 按项目查询柜子列表 pub async fn list_cabinets( user: CurrentUser, State(state): State, Path(project_id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; check_project_access(&user, &state.mysql, project_id).await?; let db = &state.mysql; let sql_rows = sqlx::query_as::<_, CabinetBasicRow>( r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status, work_mode FROM cabinets WHERE project_id = ? ORDER BY id"#, ) .bind(project_id) .fetch_all(db) .await?; let rows: Vec = 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, Json(body): Json, ) -> Result, 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 = 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 = 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, Path(id): Path, Json(body): Json, ) -> Result, 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, Path(id): Path, Json(body): Json, ) -> Result, 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, Path(id): Path, Json(body): Json, ) -> Result, 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, Path(id): Path, ) -> Result, 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, Path(id): Path, ) -> Result, 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, Path(id): Path, Json(body): Json, ) -> Result, 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)> = 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, Path(id): Path, ) -> Result, 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 = 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, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let db = &state.mysql; let cabinet = sqlx::query_as::<_, CabinetBasicRow>( r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude, status, work_mode FROM cabinets WHERE id = ?"#, ) .bind(id) .fetch_optional(db) .await? .ok_or_else(|| AppError::NotFound(format!("柜子 {} 不存在", id)))?; // 验证用户是否有权访问该柜子所属的项目 if let Some(pid) = cabinet.project_id { check_project_access(&user, db, pid).await?; } // 仓控板 + 仓体(含实时字段占位),并收集通道 Redis key 所需的映射 let (mut cabin_boards, comp_ids, comp_to_board) = load_boards_with_compartments(db, id).await?; // 一次 pipeline 读取柜级 + 通道级实时状态;Redis 异常一律降级,不让详情接口整体失败 let realtime = fetch_cabinet_realtime(&state.redis_url, &cabinet.imei, &comp_ids, &comp_to_board).await; apply_realtime(&mut cabin_boards, &realtime); // 查询项目名称 let project_name: Option = if let Some(pid) = cabinet.project_id { sqlx::query_scalar("SELECT name FROM projects WHERE id = ?") .bind(pid) .fetch_optional(db) .await .ok() .flatten() } else { None }; Ok(Json(json!({ "code": 0, "data": { "id": cabinet.id, "project_id": cabinet.project_id, "project_name": project_name, "abstract_id": cabinet.abstract_id, "imei": cabinet.imei, "iccid": cabinet.iccid, "name": cabinet.name, "status": cabinet.status, // 充电工作模式:字符串 "0"/"1"(前端契约),无值时为 null "work_mode": cabinet.work_mode.map(|m| m.to_string()), "rssi": realtime.rssi, "pow_fail_dc": realtime.pow_fail_dc, "pow_fail_ac": realtime.pow_fail_ac, "address": cabinet.address, "latitude": cabinet.latitude, "longitude": cabinet.longitude, "cabin_boards": cabin_boards, }, "message": "ok" }))) } // --------------------------------------------------------------------------- // 设备详情 — Redis 实时状态(柜级 + 通道级) // --------------------------------------------------------------------------- /// 详情页实时状态快照;Redis 不可用时整体为默认空值 #[derive(Default)] struct CabinetRealtime { rssi: Option, pow_fail_dc: Option, pow_fail_ac: Option, /// 仓体 id -> (SOC, 电池ID) soc_map: std::collections::HashMap, Option)>, /// 仓体 id -> (温度, batt_status, pow_status) ch_map: std::collections::HashMap, Option, Option)>, } /// 查询仓控板与仓体,构建带实时字段占位的 JSON /// /// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`, /// 后两者供 Redis 批量查询通道级状态使用(板号/通道号为十进制不补零)。 async fn load_boards_with_compartments( db: &MySqlPool, cabinet_id: i64, ) -> Result<(Vec, Vec, Vec<(i64, (i8, i8))>), AppError> { let boards: Vec = 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 = Vec::new(); let mut comp_ids: Vec = Vec::new(); let mut comp_to_board: Vec<(i64, (i8, i8))> = Vec::new(); for board in boards { let compartments: Vec = 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 = compartments .into_iter() .map(|c| { json!({ "id": c.id, "channel_id": c.channel_id, "status": c.status, "batt_status": Value::Null, "pow_status": Value::Null, }) }) .collect(); cabin_boards.push(json!({ "id": board.id, "board_id": board.board_id, "status": board.status, "compartments": comp_values, })); } Ok((cabin_boards, comp_ids, comp_to_board)) } /// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态 /// /// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。 /// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 async fn fetch_cabinet_realtime( redis_url: &str, imei: &str, comp_ids: &[i64], comp_to_board: &[(i64, (i8, i8))], ) -> CabinetRealtime { let client = match redis::Client::open(redis_url) { Ok(c) => c, Err(e) => { tracing::warn!("[cabinet_detail] Redis 客户端创建失败,实时状态降级: {}", e); return CabinetRealtime::default(); } }; // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection let mut conn = match client.get_multiplexed_async_connection().await { Ok(c) => c, Err(e) => { tracing::warn!("[cabinet_detail] Redis 连接失败,实时状态降级: {}", e); return CabinetRealtime::default(); } }; let key = format!("device:{}", imei); let mut pipe = redis::pipe(); pipe.cmd("HGET").arg(&key).arg("rssi") .cmd("HGET").arg(&key).arg("pow_fail_dc") .cmd("HGET").arg(&key).arg("pow_fail_ac"); for cid in comp_ids { pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); } // 通道级:板号/通道号为十进制不补零,与设备上报写入的 key 保持一致 for (_, (board_id, channel_id)) in comp_to_board { let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id); pipe.cmd("HGET").arg(&rkey).arg("temp") .cmd("HGET").arg(&rkey).arg("batt_status") .cmd("HGET").arg(&rkey).arg("pow_status"); } // 用类型标注而非 turbofish:query_async 的泛型首参是连接类型 let queried: Result>, redis::RedisError> = pipe.query_async(&mut conn).await; match queried { Ok(vals) => parse_cabinet_realtime(&vals, comp_ids, comp_to_board), Err(e) => { tracing::warn!("[cabinet_detail] Redis pipeline 读取失败,实时状态降级 imei={} err={}", imei, e); CabinetRealtime::default() } } } /// 解析 pipeline 结果:柜级 3 项 + 每仓体 1 项(SOC JSON) + 每通道 3 项(temp/batt_status/pow_status) fn parse_cabinet_realtime( vals: &[Option], comp_ids: &[i64], comp_to_board: &[(i64, (i8, i8))], ) -> CabinetRealtime { /// 取出第 i 项的非空字符串 fn pick(vals: &[Option], i: usize) -> Option { vals.get(i).and_then(|v| v.as_ref()).cloned() } let mut rt = CabinetRealtime { rssi: pick(vals, 0).and_then(|v| v.parse().ok()), pow_fail_dc: pick(vals, 1).map(|v| v == "1"), pow_fail_ac: pick(vals, 2).map(|v| v == "1"), ..Default::default() }; for (i, cid) in comp_ids.iter().enumerate() { if let Some(data) = pick(vals, 3 + i) { if let Ok(v) = serde_json::from_str::(&data) { let soc = v.get("soc").and_then(|s| s.as_i64()); let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string); rt.soc_map.insert(*cid, (soc, bid)); } } } // 通道级字段紧随 SOC 段之后,每通道 3 项 let base = 3 + comp_ids.len(); for (i, (cid, _)) in comp_to_board.iter().enumerate() { let off = base + i * 3; let temp = pick(vals, off).and_then(|v| v.parse::().ok()); let batt_status = pick(vals, off + 1); let pow_status = pick(vals, off + 2); rt.ch_map.insert(*cid, (temp, batt_status, pow_status)); } rt } /// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串 fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) { for board in cabin_boards.iter_mut() { let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else { continue; }; for comp in comps.iter_mut() { let Some(cid) = comp.get("id").and_then(|i| i.as_i64()) else { continue; }; if let Some((soc, bid)) = rt.soc_map.get(&cid) { if let Some(s) = soc { comp["soc"] = json!(s); } if let Some(b) = bid { comp["battery_id"] = json!(b); } } if let Some((temp, batt_status, pow_status)) = rt.ch_map.get(&cid) { if let Some(t) = temp { comp["temp"] = json!(t); } if let Some(bs) = batt_status { comp["batt_status"] = json!(bs); } if let Some(ps) = pow_status { comp["pow_status"] = json!(ps); } } } } } // --------------------------------------------------------------------------- // 级联下拉选项 // --------------------------------------------------------------------------- /// 柜子下拉查询参数 #[derive(Debug, Deserialize)] pub struct CabinetListQuery { pub project_id: Option, } /// 仓板下拉查询参数 #[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, pub abstract_id: String, pub imei: Option, pub iccid: Option, pub address: Option, 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, Query(params): Query, ) -> Result, 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, Query(params): Query, ) -> Result, 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, Query(params): Query, ) -> Result, 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" }))) }