//! 组织树 API //! //! 两个独立接口: //! 1. /api/organization-tree — 返回多级组织->项目树 //! 2. /api/cabinets/filter — 根据组织/项目筛选设备列表 use axum::extract::{State, Query}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; use crate::commands::AppState; /// SQL查询专用结构体(只包含数据库字段) #[derive(Debug, Clone, sqlx::FromRow)] struct CabinetSqlRow { id: i64, project_id: Option, abstract_id: Option, imei: String, iccid: Option, name: Option, address: Option, project_address: Option, status: i8, board_count: Option, channel_count: Option, idle_count: Option, charging_count: Option, full_count: Option, fault_count: Option, } impl From for CabinetRow { fn from(row: CabinetSqlRow) -> Self { 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, project_address: row.project_address, status: row.status, board_count: row.board_count, channel_count: row.channel_count, idle_count: row.idle_count, charging_count: row.charging_count, full_count: row.full_count, fault_count: row.fault_count, rssi: None, pow_fail_dc: None, pow_fail_ac: None, is_online: None, org_disabled: None, } } } // --------------------------------------------------------------------------- // SQL 常量 // // 说明:以下 SQL 均由原函数体**原样搬移**,未改动任何 SELECT 字段列表、 // 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。 // --------------------------------------------------------------------------- /// 全部组织(组织树用) const ORGS_ALL_SQL: &str = "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"; /// 全部项目(经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题) const PROJECTS_ALL_SQL: &str = "SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id"; /// 各项目下的设备数量 const PROJECT_CABINET_COUNTS_SQL: &str = "SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id"; /// 设备列表基础 SQL:联表查询获取通道状态统计(子查询 idle_count/charging_count/... 原样保留) const CABINET_FILTER_BASE_SQL: &str = " SELECT c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, (SELECT address FROM projects WHERE id = c.project_id) as project_address, c.status, (SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count, (SELECT COUNT(*) FROM cabin_boards cb2 JOIN compartments comp ON comp.cabin_board_id = cb2.id WHERE cb2.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM cabin_boards cb3 JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count, (SELECT COUNT(*) FROM cabin_boards cb4 JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count, (SELECT COUNT(*) FROM cabin_boards cb5 JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count, (SELECT COUNT(*) FROM cabin_boards cb6 JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count FROM cabinets c "; /// 设备归属校验(非总管理员):设备必须属于当前用户组织 const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \ JOIN projects p ON c.project_id = p.id \ WHERE c.imei = ? AND p.organization_id = ?)"; // --------------------------------------------------------------------------- // 组织树 // --------------------------------------------------------------------------- /// 组织树接口 — 返回多级组织->项目树(含设备数量) pub async fn organization_tree( user: CurrentUser, State(state): State, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let db = &state.mysql; // 获取所有组织 let orgs: Vec = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?; // 获取所有项目 let projects: Vec = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?; // 获取每个项目的设备数量 let project_count_map = fetch_project_counts(db).await?; // 递归构建树 let tree = build_tree(&orgs, &projects, &project_count_map, None); Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" }))) } /// 查询「项目ID -> 设备数量」映射 async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result, AppError> { let project_counts: Vec<(i64, i64)> = sqlx::query_as(PROJECT_CABINET_COUNTS_SQL) .fetch_all(db) .await?; Ok(project_counts.into_iter().collect()) } /// 递归构建「组织 -> 项目」树节点,项目名称携带设备数量 fn build_tree( orgs: &[OrganizationRow], projects: &[ProjectRow], project_count_map: &HashMap, parent_id: Option, ) -> Vec { orgs.iter() .filter(|o| o.parent_id == parent_id) .map(|org| { // 递归获取子组织 let children = build_tree(orgs, projects, project_count_map, Some(org.id)); // 获取该组织下的项目 let org_projects: Vec = projects .iter() .filter(|p| p.organization_id == Some(org.id)) .map(|p| { let count = project_count_map.get(&p.id).copied().unwrap_or(0); TreeNode { id: p.id, name: format!("{} ({})", p.name, count), children: None, abstract_id: None, imei: None, status: None, disabled: None, address: p.address.clone(), latitude: p.latitude.clone(), longitude: p.longitude.clone(), } }) .collect(); // 合并子组织和项目 let mut all_children = children; all_children.extend(org_projects); // 计算本组织下的总设备数量 let total_count: i64 = all_children .iter() .map(|c| { // 从项目名称中提取数量 if let Some(start) = c.name.rfind('(') { if let Some(end) = c.name.rfind(')') { c.name[start + 1..end].parse().unwrap_or(0) } else { 0 } } else { 0 } }) .sum(); TreeNode { id: org.id, name: format!("{} ({})", org.name, total_count), children: if all_children.is_empty() { None } else { Some(all_children) }, abstract_id: None, imei: None, status: None, disabled: org.disabled, address: None, latitude: None, longitude: None, } }) .collect() } // --------------------------------------------------------------------------- // 设备列表筛选 // --------------------------------------------------------------------------- /// 设备列表筛选参数 #[derive(Deserialize)] pub struct CabinetFilterParams { pub organization_id: Option, pub project_id: Option, pub page: Option, pub page_size: Option, pub bound: Option, } /// 设备列表接口 — 根据组织/项目筛选 pub async fn filter_cabinets( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let db = &state.mysql; let page = params.page.unwrap_or(1).max(1); let page_size = params.page_size.unwrap_or(12).clamp(1, 100); let offset = (page - 1) * page_size; // 组织/项目过滤 + 已绑定/未绑定过滤 let (where_clause, binds) = build_filter_where(¶ms, &user); // 总数量 let total = fetch_filter_total(db, &where_clause, &binds).await; if total == 0 || offset >= total { return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" }))); } // 分页数据 let sql_rows = fetch_filter_page(db, &where_clause, &binds, page_size, offset).await?; // 转换为CabinetRow let mut rows: Vec = sql_rows.into_iter().map(CabinetRow::from).collect(); // 从Redis获取实时状态(Pipeline批量HGET,1次网络往返) attach_realtime_status(&state, &mut rows).await?; // 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标) attach_org_disabled(db, &mut rows).await; Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" }))) } /// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致) fn build_filter_where( params: &CabinetFilterParams, user: &CurrentUser, ) -> (String, Vec) { let (where_clause, binds): (String, Vec) = if let Some(proj_id) = params.project_id { ("WHERE c.project_id = ?".into(), vec![proj_id.to_string()]) } else if let Some(org_id) = params.organization_id { ("JOIN projects p ON c.project_id = p.id WHERE p.organization_id IN ( SELECT id FROM organizations WHERE id = ? OR parent_id = ? )".into(), vec![org_id.to_string(), org_id.to_string()]) } else if user.role_level >= 2 { (String::new(), vec![]) } else if let Some(org_id) = user.organization_id { ("JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?".into(), vec![org_id.to_string()]) } else { ("WHERE 1=0".into(), vec![]) }; // 已绑定/未绑定过滤(绑定 = 分配到项目) let where_clause = match params.bound.as_deref() { Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"), Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"), _ => where_clause, }; (where_clause, binds) } /// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接 fn append_bound_filter(where_clause: String, where_new: &str, and_tail: &str) -> String { if where_clause.is_empty() { where_new.to_string() } else { let mut clause = where_clause; clause.push_str(and_tail); clause } } /// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致) async fn fetch_filter_total(db: &sqlx::MySqlPool, where_clause: &str, binds: &[String]) -> i64 { let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause); let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); for b in binds { count_query = count_query.bind(b); } count_query.fetch_one(db).await.unwrap_or(0) } /// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定) async fn fetch_filter_page( db: &sqlx::MySqlPool, where_clause: &str, binds: &[String], page_size: i64, offset: i64, ) -> Result, AppError> { let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", CABINET_FILTER_BASE_SQL, where_clause); let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql); for b in binds { data_query = data_query.bind(b); } let rows: Vec = data_query .bind(page_size) .bind(offset) .fetch_all(db) .await?; Ok(rows) } /// 从 Redis 批量补齐实时状态(Pipeline HGET,1 次网络往返) async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Result<(), AppError> { // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection let mut conn = open_redis_conn(state).await?; // 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段) let mut pipe = redis::pipe(); for row in rows.iter() { let key = format!("device:{}", row.imei); pipe.cmd("HGET").arg(&key).arg("online"); pipe.cmd("HGET").arg(&key).arg("rssi"); pipe.cmd("HGET").arg(&key).arg("pow_fail_dc"); pipe.cmd("HGET").arg(&key).arg("pow_fail_ac"); } // 一次发送,获取所有结果 let results: Vec> = pipe.query_async(&mut conn).await.unwrap_or_default(); // 每4个一组解析 for (i, row) in rows.iter_mut().enumerate() { let base = i * 4; row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1"); row.rssi = results .get(base + 1) .and_then(|v| v.as_ref()) .and_then(|v| v.parse::().ok()); row.pow_fail_dc = results .get(base + 2) .and_then(|v| v.as_ref()) .map(|v| v == "1"); row.pow_fail_ac = results .get(base + 3) .and_then(|v| v.as_ref()) .map(|v| v == "1"); } Ok(()) } /// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标) async fn attach_org_disabled(db: &sqlx::MySqlPool, rows: &mut [CabinetRow]) { let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); if project_ids.is_empty() { return; } let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); let sql = format!( r#"SELECT p.id, o.disabled FROM projects p LEFT JOIN organizations o ON p.organization_id = o.id WHERE p.id IN ({})"#, placeholders.join(",") ); let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); for pid in &project_ids { q = q.bind(pid); } if let Ok(results) = q.fetch_all(db).await { let disabled_map: HashMap = results.into_iter().map(|(pid, d)| (pid, d == 1)).collect(); for row in rows.iter_mut() { row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied(); } } } // --------------------------------------------------------------------------- // 设备实时状态 // --------------------------------------------------------------------------- /// 设备实时状态参数 #[derive(Deserialize)] pub struct DeviceStatusParams { pub imei: String, } /// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误) async fn open_redis_conn(state: &AppState) -> Result { let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { AppError::Internal(format!("Redis客户端创建失败: {}", e)) })?; // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection client.get_multiplexed_async_connection().await.map_err(|e| { AppError::Internal(format!("Redis连接失败: {}", e)) }) } /// 设备实时状态接口 — 返回板级+通道级状态 pub async fn device_realtime_status( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; // 校验设备归属 ensure_device_access(&state, &user, ¶ms.imei).await?; let mut conn = open_redis_conn(&state).await?; let device_status = load_device_status(&mut conn, ¶ms.imei).await; let boards = load_boards_status(&mut conn, ¶ms.imei).await; Ok(Json(json!({ "code": 0, "data": { "device": device_status, "boards": boards, }, "message": "ok" }))) } /// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织 async fn ensure_device_access( state: &AppState, user: &CurrentUser, imei: &str, ) -> Result<(), AppError> { if user.role_level < 2 { if let Some(org_id) = user.organization_id { let has_access: bool = sqlx::query_scalar(DEVICE_ACCESS_SQL) .bind(imei) .bind(org_id) .fetch_one(&state.mysql) .await .unwrap_or(false); if !has_access { return Err(AppError::Forbidden("无权访问该设备".into())); } } else { return Err(AppError::Forbidden("无权访问该设备".into())); } } Ok(()) } /// 读取设备级字段(online / rssi / pow_fail_dc / pow_fail_ac) async fn load_device_status( conn: &mut redis::aio::MultiplexedConnection, imei: &str, ) -> Value { let device_key = format!("device:{}", imei); // 获取设备级字段 let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"]; let values: Vec> = redis::cmd("HMGET") .arg(&device_key) .arg(&fields) .query_async(&mut *conn) .await .unwrap_or_default(); json!({ "online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), "rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()), "pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), "pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), }) } /// 扫描并组装全部仓控板状态(板级 + 通道级) async fn load_boards_status( conn: &mut redis::aio::MultiplexedConnection, imei: &str, ) -> Value { // 扫描板级key: device:{imei}:board:* let board_pattern = format!("device:{}:board:*", imei); let board_keys: Vec = redis::cmd("KEYS") .arg(&board_pattern) .query_async(&mut *conn) .await .unwrap_or_default(); let mut boards = json!({}); for board_key in &board_keys { // 提取board_idx let board_idx = board_key.split(":").last().unwrap_or(""); if board_idx.is_empty() { continue; } boards[board_idx] = load_single_board(conn, board_key).await; } boards } /// 读取单块仓控板的板级字段,并扫描其下的通道级 key async fn load_single_board( conn: &mut redis::aio::MultiplexedConnection, board_key: &str, ) -> Value { // 获取板级字段 let board_fields = vec!["status_hex"]; let board_values: Vec> = redis::cmd("HMGET") .arg(board_key) .arg(&board_fields) .query_async(&mut *conn) .await .unwrap_or_default(); let mut board_data = json!({ "status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(), }); // 扫描通道级key: device:{imei}:board:{idx}:ch:* let ch_pattern = format!("{}:ch:*", board_key); let ch_keys: Vec = redis::cmd("KEYS") .arg(&ch_pattern) .query_async(&mut *conn) .await .unwrap_or_default(); let mut channels = json!({}); for ch_key in &ch_keys { let ch_idx = ch_key.split(":").last().unwrap_or(""); if ch_idx.is_empty() { continue; } channels[ch_idx] = load_channel_status(conn, ch_key).await; } board_data["channels"] = channels; board_data } /// 读取单个通道状态(状态映射优先级:fault > full > on > idle) async fn load_channel_status( conn: &mut redis::aio::MultiplexedConnection, ch_key: &str, ) -> Value { let ch_fields = vec!["on", "full", "fault", "hex", "temp"]; let ch_values: Vec> = redis::cmd("HMGET") .arg(ch_key) .arg(&ch_fields) .query_async(&mut *conn) .await .unwrap_or_default(); // 状态映射:fault > full > on > idle let fault = ch_values .get(0) .and_then(|v| v.as_ref()) .map(|v| v == "1") .unwrap_or(false); let full = ch_values .get(1) .and_then(|v| v.as_ref()) .map(|v| v == "1") .unwrap_or(false); let on = ch_values .get(2) .and_then(|v| v.as_ref()) .map(|v| v == "1") .unwrap_or(false); let status = if fault { "fault" } else if full { "full" } else if on { "charging" } else { "idle" }; json!({ "status": status, "on": on, "full": full, "fault": fault, "hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(), "temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()), }) }