//! H5 用户端 API //! //! 提供微信扫码充电的 H5 页面所需的全部后端接口: //! - GET /api/dashboard — 首页仪表盘数据 //! - GET /api/cabinets — 设备列表(按项目过滤) //! - GET /api/cabinets/:id — 设备详情(含仓板+仓体状态) //! - GET /api/cabinets/abstract/:aid — 通过抽象ID查设备(扫码入口,免认证) //! - GET /api/compartments/:id — 仓体/通道详情 //! - POST /api/charge/start — 开始充电 //! - POST /api/charge/stop — 停止充电 //! - POST /api/door/open — 开门 //! - GET /api/cabinets/scan/:abstract_id — 扫码验证 use axum::extract::{Multipart, Path, Query, State}; use axum::Json; use redis; use serde::Deserialize; use serde_json::{json, Value}; use std::io::Write; use uuid::Uuid; use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; // --------------------------------------------------------------------------- // 请求体定义 // --------------------------------------------------------------------------- /// 充电控制请求体(开始/停止充电、开门共用) #[derive(Debug, Deserialize)] pub(crate) struct CompartmentActionInput { compartment_id: i64, } /// 问题反馈请求体 #[derive(Debug, Deserialize)] pub(crate) struct FeedbackInput { /// 反馈内容 content: String, /// 联系方式(可选) contact: Option, /// 图片URL列表(可选) images: Option>, } /// 设备列表查询参数 #[derive(Debug, Deserialize)] pub(crate) struct CabinetListQuery { project_id: Option, org_id: Option, } /// 仪表盘查询参数 #[derive(Debug, Deserialize)] pub(crate) struct DashboardQuery { org_id: Option, } // --------------------------------------------------------------------------- // 行映射结构体 // --------------------------------------------------------------------------- /// 项目简要信息行 #[derive(Debug, sqlx::FromRow)] struct ProjectRow { id: i64, name: String, } /// 柜子列表行(含通道统计) #[derive(Debug, sqlx::FromRow)] struct H5CabinetRow { id: i64, abstract_id: String, name: Option, status: i8, project_id: Option, imei: Option, iccid: Option, address: Option, latitude: Option, longitude: Option, channel_count: i64, idle_count: i64, charging_count: i64, full_count: i64, fault_count: i64, } /// 仓控板行 #[derive(Debug, sqlx::FromRow)] struct H5CabinBoardRow { id: i64, board_id: i8, status: i8, } /// 仓体行 #[derive(Debug, sqlx::FromRow)] struct H5CompartmentRow { id: i64, channel_id: i8, status: i8, } /// 充电记录简要行 #[derive(Debug, sqlx::FromRow)] struct ChargeRecordBriefRow { id: i64, start_time: Option, end_time: Option, energy: Option, } /// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发) #[derive(Debug, sqlx::FromRow)] struct CabinetImeiRow { cabinet_id: i64, imei: String, board_id: i8, channel_id: i8, } // --------------------------------------------------------------------------- // 仪表盘 // --------------------------------------------------------------------------- /// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离) pub async fn get_dashboard( user: CurrentUser, State(state): State, Query(query): Query, ) -> Result, AppError> { let pool = &state.mysql; // 根据角色确定组织过滤条件 // 总管理员可通过 org_id 参数切换组织 let is_super = user.role_level >= 2 && query.org_id.is_none(); let org_id = query.org_id.or(user.organization_id); // 统计在线柜子数(带组织隔离) let online_cabinets = count_online_cabinets(pool, is_super, org_id).await?; // 统计总通道数(带组织隔离) let total_channels = count_total_compartments(pool, is_super, org_id).await?; // 统计充电中仓体数(带组织隔离) let charging_count = count_compartments_by_status(pool, is_super, org_id, 1).await?; // 统计空闲仓体数(带组织隔离) let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0).await?; // 统计故障仓体数(带组织隔离) let fault_channels = count_compartments_by_status(pool, is_super, org_id, 2).await?; // 统计充满仓体数(status=2,带组织隔离) let full_count = count_compartments_by_status(pool, is_super, org_id, 2).await?; // 统计故障数(status=3,带组织隔离) let fault_count = count_compartments_by_status(pool, is_super, org_id, 3).await?; // 今日充电次数(带组织隔离) let today_charge_count = count_today_charges(pool, is_super, org_id).await?; // 今日充电量(kWh,带组织隔离) let today_energy = sum_today_energy(pool, is_super, org_id).await?; // 今日充电时长(小时,带组织隔离) let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id).await?; // 故障告警(取最近10条故障仓体,返回项目名+柜名) let alerts = fetch_fault_alerts(pool, is_super, org_id).await?; // 项目摘要(带组织过滤) let projects = fetch_project_summaries(pool, &user, org_id).await?; Ok(Json(json!({ "code": 0, "data": { "online_cabinets": online_cabinets, "total_channels": total_channels, "charging_count": charging_count, "idle_channels": idle_channels, "full_count": full_count, "fault_channels": fault_channels, "fault_count": fault_count, "today_charge_count": today_charge_count, "today_energy_kwh": today_energy, "today_charge_hours": today_charge_hours, "alerts": alerts, "projects": projects, }, "message": "ok" }))) } /// 统计在线柜子数(带组织隔离) async fn count_online_cabinets( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); } let count: i64 = if is_super { sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1") .fetch_one(pool) .await? } else { sqlx::query_scalar( r#"SELECT COUNT(*) FROM cabinets cab INNER JOIN projects p ON cab.project_id = p.id WHERE cab.status = 1 AND p.organization_id = ?"#, ) .bind(org_id.unwrap()) .fetch_one(pool) .await? }; Ok(count) } /// 统计总仓体数(带组织隔离) async fn count_total_compartments( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); } let count: i64 = if is_super { sqlx::query_scalar( r#"SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets c ON cb.cabinet_id = c.id WHERE c.status = 1"#, ) .fetch_one(pool) .await? } else { sqlx::query_scalar( r#"SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets c ON cb.cabinet_id = c.id JOIN projects p ON c.project_id = p.id WHERE c.status = 1 AND p.organization_id = ?"#, ) .bind(org_id.unwrap()) .fetch_one(pool) .await? }; Ok(count) } /// 按状态统计仓体数(带组织隔离,通过 cabin_boards → cabinets → projects 链路) async fn count_compartments_by_status( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, status: i8, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); } let count: i64 = if is_super { sqlx::query_scalar("SELECT COUNT(*) FROM compartments WHERE status = ?") .bind(status) .fetch_one(pool) .await? } else { sqlx::query_scalar( r#"SELECT COUNT(*) FROM compartments comp INNER JOIN cabin_boards cb ON comp.cabin_board_id = cb.id INNER JOIN cabinets cab ON cb.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id WHERE comp.status = ? AND p.organization_id = ?"#, ) .bind(status) .bind(org_id.unwrap()) .fetch_one(pool) .await? }; Ok(count) } /// 今日充电次数(带组织隔离) async fn count_today_charges( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); } let count: i64 = if is_super { sqlx::query_scalar( "SELECT COUNT(*) FROM charge_records WHERE DATE(start_time) = CURDATE()", ) .fetch_one(pool) .await? } else { sqlx::query_scalar( r#"SELECT COUNT(*) FROM charge_records cr INNER JOIN cabinets cab ON cr.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#, ) .bind(org_id.unwrap()) .fetch_one(pool) .await? }; Ok(count) } /// 今日充电量(kWh,带组织隔离) async fn sum_today_energy( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); } let energy: Option = if is_super { sqlx::query_scalar( "SELECT CAST(COALESCE(SUM(energy), 0) AS SIGNED) FROM charge_records WHERE DATE(start_time) = CURDATE()", ) .fetch_one(pool) .await? } else { sqlx::query_scalar( r#"SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr INNER JOIN cabinets cab ON cr.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#, ) .bind(org_id.unwrap()) .fetch_one(pool) .await? }; Ok(energy.unwrap_or(0)) } /// 今日充电时长(秒,带组织隔离) async fn sum_today_charge_hours( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); } // 计算总秒数(未结束的记录用 NOW() 代替 end_time) let total_seconds: Option = if is_super { sqlx::query_scalar( r#"SELECT CAST(COALESCE(SUM( TIMESTAMPDIFF(SECOND, start_time, COALESCE(end_time, NOW())) ), 0) AS SIGNED) FROM charge_records WHERE DATE(start_time) = CURDATE()"#, ) .fetch_one(pool) .await? } else { sqlx::query_scalar( r#"SELECT CAST(COALESCE(SUM( TIMESTAMPDIFF(SECOND, cr.start_time, COALESCE(cr.end_time, NOW())) ), 0) AS SIGNED) FROM charge_records cr INNER JOIN cabinets cab ON cr.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#, ) .bind(org_id.unwrap()) .fetch_one(pool) .await? }; Ok(total_seconds.unwrap_or(0)) } /// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离) async fn fetch_fault_alerts( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, ) -> Result, AppError> { if !is_super && org_id.is_none() { return Ok(vec![]); } let base_sql = if is_super { r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet, comp.channel_id FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets cab ON cb.cabinet_id = cab.id LEFT JOIN projects p ON cab.project_id = p.id WHERE comp.status = 2 ORDER BY comp.updated_at DESC LIMIT 10"# } else { r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet, comp.channel_id FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets cab ON cb.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id WHERE comp.status = 2 AND p.organization_id = ? ORDER BY comp.updated_at DESC LIMIT 10"# }; let rows: Vec<(String, String, i8)> = if is_super { sqlx::query_as(base_sql).fetch_all(pool).await? } else { sqlx::query_as(base_sql) .bind(org_id.unwrap()) .fetch_all(pool) .await? }; Ok(rows .into_iter() .map(|(project, cabinet, channel)| { json!({ "project": project, "cabinet": cabinet, "channel": channel, "alert_type": "fault", }) }) .collect()) } /// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计) async fn fetch_project_summaries( pool: &sqlx::MySqlPool, user: &CurrentUser, org_id: Option, ) -> Result, AppError> { // 获取用户可见的项目(优先使用查询参数 org_id) let filter_org = org_id.or(user.organization_id); let projects: Vec = if user.role_level >= 2 { if let Some(oid) = filter_org { sqlx::query_as::<_, ProjectRow>( "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", ) .bind(oid) .fetch_all(pool) .await? } else { sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id") .fetch_all(pool) .await? } } else if let Some(org_id) = user.organization_id { sqlx::query_as::<_, ProjectRow>( "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", ) .bind(org_id) .fetch_all(pool) .await? } else { vec![] }; let mut result = Vec::with_capacity(projects.len()); for p in &projects { let cabinet_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM cabinets WHERE project_id = ?", ) .bind(p.id) .fetch_one(pool) .await?; let charging: i64 = sqlx::query_scalar( r#"SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets cab ON cb.cabinet_id = cab.id WHERE cab.project_id = ? AND comp.status = 1"#, ) .bind(p.id) .fetch_one(pool) .await?; let idle: i64 = sqlx::query_scalar( r#"SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets cab ON cb.cabinet_id = cab.id WHERE cab.project_id = ? AND comp.status = 0"#, ) .bind(p.id) .fetch_one(pool) .await?; let fault: i64 = sqlx::query_scalar( r#"SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets cab ON cb.cabinet_id = cab.id WHERE cab.project_id = ? AND comp.status = 2"#, ) .bind(p.id) .fetch_one(pool) .await?; result.push(json!({ "id": p.id, "name": p.name, "cabinet_count": cabinet_count, "charging": charging, "idle": idle, "fault": fault, })); } Ok(result) } // --------------------------------------------------------------------------- // 设备列表 // --------------------------------------------------------------------------- /// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离) pub async fn get_cabinets( user: CurrentUser, State(state): State, Query(query): Query, ) -> Result, AppError> { let pool = &state.mysql; // 权限校验 auth::check_permission(&user, "device:view")?; let rows: Vec = if let Some(pid) = query.project_id { // 按项目过滤(同时验证组织权限) if user.role_level >= 2 { sqlx::query_as::<_, H5CabinetRow>( r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count FROM cabinets c WHERE c.project_id = ?"#, ) .bind(pid) .fetch_all(pool) .await? } else if let Some(org_id) = user.organization_id { sqlx::query_as::<_, H5CabinetRow>( r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count FROM cabinets c INNER JOIN projects p ON c.project_id = p.id WHERE c.project_id = ? AND p.organization_id = ?"#, ) .bind(pid) .bind(org_id) .fetch_all(pool) .await? } else { vec![] } } else if user.role_level >= 2 { // 总管理员:按组织过滤或看全部 if let Some(oid) = query.org_id { sqlx::query_as::<_, H5CabinetRow>( r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count FROM cabinets c INNER JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?"#, ) .bind(oid) .fetch_all(pool) .await? } else { sqlx::query_as::<_, H5CabinetRow>( r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count FROM cabinets c"#, ) .fetch_all(pool) .await? } } else if let Some(org_id) = user.organization_id { // 企业管理员只看本组织 sqlx::query_as::<_, H5CabinetRow>( r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count FROM cabinets c INNER JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?"#, ) .bind(org_id) .fetch_all(pool) .await? } else { vec![] }; // 从Redis批量查RSSI + pow_fail let mut rssi_map: std::collections::HashMap> = std::collections::HashMap::new(); let mut dc_map: std::collections::HashMap> = std::collections::HashMap::new(); let mut ac_map: std::collections::HashMap> = std::collections::HashMap::new(); let mut temp_map: std::collections::HashMap> = std::collections::HashMap::new(); if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection if let Ok(mut conn) = client.get_multiplexed_async_connection().await { let mut pipe = redis::pipe(); for r in &rows { if let Some(ref imei) = r.imei { let key = format!("device:{imei}"); pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]); } } if !rows.is_empty() { let results: Vec>> = match pipe.query_async(&mut conn).await { Ok(r) => r, Err(e) => { tracing::error!("[h5] Redis pipeline查询失败: {}", e); Vec::new() } }; for (i, r) in rows.iter().enumerate() { if let Some(ref imei) = r.imei { if let Some(vals) = results.get(i) { rssi_map.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); dc_map.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1")); ac_map.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1")); temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); } } } } } } // 查询柜子所属组织是否禁用(用于前端徽标) let mut org_disabled_map: std::collections::HashMap = std::collections::HashMap::new(); { let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); if !project_ids.is_empty() { let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); let sql = format!( r#"SELECT p.id, IFNULL(o.disabled, 0) 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(rows) = q.fetch_all(pool).await { for (pid, disabled) in rows { org_disabled_map.insert(pid, disabled == 1); } } } } let list: Vec = rows .into_iter() .map(|r| { let imei = r.imei.clone(); let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten(); let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten(); let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten(); let temp = imei.as_ref().and_then(|i| temp_map.get(i).copied()).flatten(); let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false); json!({ "id": r.id, "abstract_id": r.abstract_id, "name": r.name, "imei": r.imei, "iccid": r.iccid, "status": r.status, "project_id": r.project_id, "address": r.address, "latitude": r.latitude, "longitude": r.longitude, "channel_count": r.channel_count, "idle_count": r.idle_count, "charging_count": r.charging_count, "full_count": r.full_count, "fault_count": r.fault_count, "rssi": rssi, "pow_fail_dc": pow_fail_dc, "pow_fail_ac": pow_fail_ac, "temp": temp, "org_disabled": org_disabled, }) }) .collect(); Ok(Json(json!({ "code": 0, "data": list, "message": "ok" }))) } // --------------------------------------------------------------------------- // 设备扫码(免认证) // --------------------------------------------------------------------------- pub async fn get_cabinet_by_abstract_id( State(state): State, Path(abstract_id): Path, ) -> Result, AppError> { let pool = &state.mysql; let cab: Option<(i64, String, String, Option, i8)> = sqlx::query_as( "SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?", ) .bind(&abstract_id) .fetch_optional(pool) .await?; let (cab_id, abs_id, _imei, name, status) = cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; // 查询仓控板+仓体 let boards: Vec = sqlx::query_as::<_, H5CabinBoardRow>( "SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id", ) .bind(cab_id) .fetch_all(pool) .await?; let mut cabin_boards = Vec::with_capacity(boards.len()); for board in &boards { let comps: Vec = sqlx::query_as::<_, H5CompartmentRow>( "SELECT id, channel_id, status FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id", ) .bind(board.id) .fetch_all(pool) .await?; let compartments: Vec = comps .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": compartments, })); } Ok(Json(json!({ "code": 0, "data": { "id": cab_id, "abstract_id": abs_id, "name": name, "status": status, "cabin_boards": cabin_boards, }, "message": "ok" }))) } // --------------------------------------------------------------------------- // 仓体/通道详情 // --------------------------------------------------------------------------- /// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验) pub async fn get_compartment_detail( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { let pool = &state.mysql; // 权限校验 auth::check_permission(&user, "device:view")?; // 查询仓体基本信息 let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as( "SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?", ) .bind(id) .fetch_optional(pool) .await?; let (comp_id, channel_id, status, cabin_board_id) = comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?; // 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限 let cabinet_id: Option<(i64,)> = sqlx::query_as( "SELECT cabinet_id FROM cabin_boards WHERE id = ?", ) .bind(cabin_board_id) .fetch_optional(pool) .await?; if let Some((cab_id,)) = cabinet_id { verify_cabinet_access(&user, cab_id, pool).await?; } // 从 Redis 获取实时数据 let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection if let Ok(mut conn) = client.get_multiplexed_async_connection().await { let key = format!("compartment:status:{}", comp_id); let val: Option = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None); val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})) } else { json!({}) } } else { json!({}) }; // 查询最近充电记录 let records: Vec = sqlx::query_as::<_, ChargeRecordBriefRow>( r#"SELECT id, start_time, end_time, energy FROM charge_records WHERE compartment_id = ? ORDER BY start_time DESC LIMIT 10"#, ) .bind(comp_id) .fetch_all(pool) .await?; let charge_records: Vec = records .into_iter() .map(|r| { json!({ "id": r.id, "start_time": r.start_time.map(|t| t.to_string()), "end_time": r.end_time.map(|t| t.to_string()), "energy_kwh": r.energy.unwrap_or_default().parse::().unwrap_or(0), "cost": 0, }) }) .collect(); Ok(Json(json!({ "code": 0, "data": { "id": comp_id, "channel_id": channel_id, "status": status, "cabin_board_id": cabin_board_id, "voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0), "current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0), "energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0), "soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0), "cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])), "cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])), "bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])), "charge_records": charge_records, }, "message": "ok" }))) } // --------------------------------------------------------------------------- // 扫码验证 // --------------------------------------------------------------------------- /// GET /api/h5/scan/:abstract_id — 扫码验证设备访问权限 /// /// 根据二维码中的抽象ID查找设备,验证当前用户是否有权访问。 /// 有权限返回设备ID和基本信息,无权限返回403。 pub async fn scan_cabinet( user: CurrentUser, State(state): State, Path(abstract_id): Path, ) -> Result, AppError> { let pool = &state.mysql; // 查找设备 let cab: Option<(i64, String, String, Option, i8)> = sqlx::query_as( "SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?", ) .bind(&abstract_id) .fetch_optional(pool) .await?; let (cab_id, abs_id, imei, name, status) = cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; // 验证用户是否有权限访问该设备 verify_cabinet_access(&user, cab_id, pool).await?; Ok(Json(json!({ "code": 0, "data": { "id": cab_id, "abstract_id": abs_id, "imei": imei, "name": name, "status": status, }, "message": "ok" }))) } // --------------------------------------------------------------------------- // 充电控制 // --------------------------------------------------------------------------- /// POST /api/h5/charge/start — 开始充电 /// /// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`, /// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。 /// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。 pub async fn start_charge( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge:start")?; // 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式 // (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理) let order_row: Result)>, sqlx::Error> = sqlx::query_as( r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode FROM cabinets cab JOIN cabin_boards cb ON cb.cabinet_id = cab.id JOIN compartments comp ON comp.cabin_board_id = cb.id WHERE comp.id = ?"#, ) .bind(body.compartment_id) .fetch_optional(&state.mysql) .await; match order_row { // 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单; // **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单 Ok(Some((cabinet_id, imei, board_id, channel_id, mode))) if mode.unwrap_or(0) == 0 => { // 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`) match crate::workers::report_worker::load_channel_battery( &state.redis, &imei, board_id, channel_id, ) .await { Some(battery) => { if let Err(e) = crate::workers::report_worker::ensure_charge_order( &state.mysql, &state.redis, &imei, cabinet_id, board_id, channel_id, &battery.battery_id, Some(battery.soc), ) .await { tracing::error!( "[h5] 下发 on 前建单失败 compartment_id={} err={}", body.compartment_id, e ); } } None => tracing::warn!( "[h5] 该通道无 bat_in 登记,跳过建单 compartment_id={}", body.compartment_id ), } } Ok(Some(_)) => tracing::info!( "[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}", body.compartment_id ), Ok(None) => tracing::warn!( "[h5] 下发 on 前跳过建单:仓体不存在 compartment_id={}", body.compartment_id ), Err(e) => tracing::error!( "[h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={}", body.compartment_id, e ), } send_compartment_command(&state, &user, body.compartment_id, "on").await } /// POST /api/h5/charge/stop — 停止充电 pub async fn stop_charge( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge:stop")?; send_compartment_command(&state, &user, body.compartment_id, "off").await } /// POST /api/h5/door/open — 开门(**柜级动作**) /// /// 入参是仓体 ID(小程序扫码场景只知道扫到哪个仓), /// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。 pub async fn open_door( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge:open_door")?; let cabinet_id: Option = sqlx::query_scalar( r#"SELECT cb.cabinet_id FROM compartments comp JOIN cabin_boards cb ON cb.id = comp.cabin_board_id WHERE comp.id = ?"#, ) .bind(body.compartment_id) .fetch_optional(&state.mysql) .await?; let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?; send_cabinet_command(&state, &user, cabinet_id, None, "open").await } // --------------------------------------------------------------------------- // 辅助函数 // --------------------------------------------------------------------------- /// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。 /// /// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。 /// 柜级指令(`open` / `pow_on` / `pow_off` / `reset`)请用 [`send_cabinet_command`]。 async fn send_compartment_command( state: &AppState, user: &CurrentUser, compartment_id: i64, action: &str, ) -> Result, AppError> { let pool = &state.mysql; // 一次查询拿到 cabinet_id + IMEI + board_id + channel_id let row: Option = sqlx::query_as( r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id FROM cabinets cab JOIN cabin_boards cb ON cb.cabinet_id = cab.id JOIN compartments comp ON comp.cabin_board_id = cb.id WHERE comp.id = ?"#, ) .bind(compartment_id) .fetch_optional(pool) .await?; let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?; // 检查设备是否被禁用 let disabled: bool = sqlx::query_scalar( "SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2", ) .bind(cab_row.cabinet_id) .fetch_one(pool) .await .map_err(AppError::Database)?; if disabled { return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into())); } // 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效) let org_disabled: bool = sqlx::query_scalar( r#"SELECT COUNT(*) > 0 FROM cabinets c INNER JOIN projects p ON c.project_id = p.id INNER JOIN organizations o ON p.organization_id = o.id WHERE c.id = ? AND o.disabled = 1"#, ) .bind(cab_row.cabinet_id) .fetch_one(pool) .await .map_err(AppError::Database)?; if org_disabled { return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into())); } // 校验操作权限(组织隔离) verify_cabinet_access(user, cab_row.cabinet_id, pool).await?; let imei = &cab_row.imei; // 检查是否有设备节点在线 if !state.nodes.is_device_online(imei).await { return Err(AppError::BadRequest("设备不在线".into())); } // 构造协议 id: // 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零) // 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定 let id = format!("{:02}-{}", cab_row.board_id, cab_row.channel_id); let cmd = DeviceCommand { act: action.to_string(), msg_id: 0, id: Some(id.clone()), params: Some(json!({})), }; // 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见) let mut json_val = serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?; json_val[ROUTE_DEV_ID_FIELD] = json!(imei); let json_str = json_val.to_string(); let mut conn = state.redis.clone(); redis::cmd("LPUSH") .arg("device:commands") .arg(&json_str) .query_async::<()>(&mut conn) .await .map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?; tracing::info!( "[h5] 指令已推入队列 id={} action={} compartment_id={}", id, action, compartment_id ); Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "指令已下发" }))) } // --------------------------------------------------------------------------- // 柜级 / 单仓控板级指令(pow_on / pow_off / reset) // --------------------------------------------------------------------------- /// 柜级 / 单仓控板级指令下发。 /// /// - `board_id = None` → **柜级**:协议**不带 `id`**(省略该字段) /// - `board_id = Some(n)` → **单仓控板**:协议 `id = "{仓控板:02}"`(如 `"01"`) /// /// 适用于 `open` / `pow_on` / `pow_off` / `reset`;`on` / `off` 走 [`send_compartment_command`]。 async fn send_cabinet_command( state: &AppState, user: &CurrentUser, cabinet_id: i64, board_id: Option, action: &str, ) -> Result, AppError> { let pool = &state.mysql; let imei: Option = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") .bind(cabinet_id) .fetch_optional(pool) .await?; let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; // 设备是否被禁用 let disabled: bool = sqlx::query_scalar("SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2") .bind(cabinet_id) .fetch_one(pool) .await .map_err(AppError::Database)?; if disabled { return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into())); } // 所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效) let org_disabled: bool = sqlx::query_scalar( r#"SELECT COUNT(*) > 0 FROM cabinets c INNER JOIN projects p ON c.project_id = p.id INNER JOIN organizations o ON p.organization_id = o.id WHERE c.id = ? AND o.disabled = 1"#, ) .bind(cabinet_id) .fetch_one(pool) .await .map_err(AppError::Database)?; if org_disabled { return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into())); } // 组织数据隔离校验 verify_cabinet_access(user, cabinet_id, pool).await?; // 设备必须在线:平台对不在线的设备一律不投递 if !state.nodes.is_device_online(&imei).await { return Err(AppError::BadRequest("设备不在线".into())); } // 构造协议 id: // - `board_id = None` → **柜级**:**不带 id**(省略该字段) // - `board_id = Some(n)` → **单仓控板**:`仓控板`(2 位补零,如 "01") let id = board_id.map(|b| format!("{:02}", b)); let cmd = DeviceCommand { act: action.to_string(), msg_id: 0, id: id.clone(), params: Some(json!({})), }; // 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见) let mut json_val = serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?; json_val[ROUTE_DEV_ID_FIELD] = json!(imei); let json_str = json_val.to_string(); let mut conn = state.redis.clone(); redis::cmd("LPUSH") .arg("device:commands") .arg(&json_str) .query_async::<()>(&mut conn) .await .map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?; tracing::info!( "[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}", id, action, cabinet_id, board_id ); Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "指令已下发" }))) } /// POST /api/cabinets/:id/open — **解锁柜门**(柜级) /// /// 柜门是**整柜共用、只有一扇**,所以这是柜级动作(协议 `id` 省略)。 /// WEB 管理端设备详情页用这个;小程序扫码场景用 `POST /api/h5/door/open`(入参为仓体 ID)。 pub async fn cabinet_open( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "charge:open_door")?; send_cabinet_command(&state, &user, id, None, "open").await } /// POST /api/cabinets/:id/pow-on — 接触器**合闸**(柜级) pub async fn cabinet_pow_on( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:operate")?; send_cabinet_command(&state, &user, id, None, "pow_on").await } /// POST /api/cabinets/:id/pow-off — 接触器**分闸**(柜级) /// /// ⚠️ 分闸后**仓控板随之下电**:该柜仓体/电池数据不可用,设备会上报 `pow_fail_dc`; /// 恢复供电须再下发 `pow-on`。 pub async fn cabinet_pow_off( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:operate")?; send_cabinet_command(&state, &user, id, None, "pow_off").await } /// POST /api/cabinets/:id/reset — **重启整柜**(柜级,协议 `id = IMEI`) pub async fn cabinet_reset( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:operate")?; send_cabinet_command(&state, &user, id, None, "reset").await } /// POST /api/cabin-boards/:id/reset — **重启单块仓控板** /// (协议 `id = IMEI-仓控板号`,两段) pub async fn board_reset( user: CurrentUser, State(state): State, Path(board_pk): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:operate")?; let row: Option<(i64, i8)> = sqlx::query_as("SELECT cabinet_id, board_id FROM cabin_boards WHERE id = ?") .bind(board_pk) .fetch_optional(&state.mysql) .await?; let (cabinet_id, board_id) = row.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?; send_cabinet_command(&state, &user, cabinet_id, Some(board_id), "reset").await } /// POST /api/feedback — 用户问题反馈提交(任何已登录用户可提交) pub async fn submit_feedback( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { let pool = &state.mysql; let content = body.content.trim(); if content.is_empty() { return Err(AppError::BadRequest("反馈内容不能为空".into())); } if content.chars().count() > 2000 { return Err(AppError::BadRequest("反馈内容过长(最多2000字)".into())); } let contact = body.contact.as_ref().map(|c| c.trim().to_string()).filter(|c| !c.is_empty()); if let Some(c) = &contact { if c.chars().count() > 200 { return Err(AppError::BadRequest("联系方式过长".into())); } } // 图片校验:最多 6 张 let images = body.images.clone().unwrap_or_default(); let images: Vec = images .iter() .map(|u| u.trim().to_string()) .filter(|u| !u.is_empty()) .collect(); if images.len() > 6 { return Err(AppError::BadRequest("最多上传6张图片".into())); } let images_json = if images.is_empty() { String::from("[]") } else { serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string()) }; sqlx::query( "INSERT INTO user_feedback (user_id, content, contact, images) VALUES (?, ?, ?, ?)" ) .bind(user.user_id) .bind(content) .bind(contact) .bind(&images_json) .execute(pool) .await .map_err(AppError::Database)?; tracing::info!( "[h5] 问题反馈已提交 user_id={} content_len={}", user.user_id, content.chars().count() ); Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "反馈已提交,感谢您的反馈" }))) } /// GET /api/feedback — 查询反馈列表(分页,管理员使用) #[derive(Debug, Deserialize)] pub(crate) struct FeedbackListQuery { page: Option, page_size: Option, } #[derive(Debug, sqlx::FromRow)] struct FeedbackRow { id: i64, user_id: i64, content: String, contact: Option, images: Option, status: i8, created_at: chrono::NaiveDateTime, } /// GET /api/h5/feedback — 反馈列表(管理端) /// /// `_user` 仅用于强制登录态(CurrentUser 提取器),当前实现按全量分页返回。 pub async fn list_feedback( _user: CurrentUser, State(state): State, Query(query): Query, ) -> Result, AppError> { let pool = &state.mysql; let page = query.page.unwrap_or(1).max(1); let page_size = query.page_size.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * page_size; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_feedback") .fetch_one(pool) .await?; let rows: Vec = sqlx::query_as::<_, FeedbackRow>( r#"SELECT id, user_id, content, contact, images, status, created_at FROM user_feedback ORDER BY created_at DESC LIMIT ? OFFSET ?"#, ) .bind(page_size) .bind(offset) .fetch_all(pool) .await?; let list: Vec = rows.into_iter().map(|r| { let images: Vec = r.images .as_deref() .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); json!({ "id": r.id, "user_id": r.user_id, "content": r.content, "contact": r.contact, "images": images, "status": r.status, "status_text": if r.status == 0 { "待处理" } else { "已处理" }, "created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), }) }).collect(); Ok(Json(json!({ "code": 0, "data": { "list": list, "total": total, "page": page, "page_size": page_size }, "message": "ok" }))) } /// PUT /api/feedback/{id} — 更新反馈状态(0待处理 → 1已处理) #[derive(Debug, Deserialize)] pub(crate) struct FeedbackStatusInput { status: i8, } const FEEDBACK_IMG_DIR: &str = "uploads/feedback"; pub async fn upload_feedback_image( user: CurrentUser, mut multipart: Multipart, ) -> Result, AppError> { let mut file_data: Option> = None; let mut ext = "".to_string(); while let Some(field) = multipart.next_field().await .map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? { let filename = field.file_name().unwrap_or("").to_string(); let data = field.bytes().await .map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?; if let Some(pos) = filename.rfind('.') { ext = filename[pos..].to_string(); } file_data = Some(data.to_vec()); } let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?; if data.is_empty() { return Err(AppError::BadRequest("上传内容为空".into())); } if data.len() > 5 * 1024 * 1024 { return Err(AppError::BadRequest("图片文件不能超过 5MB".into())); } if ext.is_empty() { ext = ".jpg".to_string(); } let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; if !allowed.contains(&ext.as_str()) { return Err(AppError::BadRequest("仅支持 jpg/png/gif/webp 格式".into())); } let dir = std::path::Path::new(FEEDBACK_IMG_DIR); std::fs::create_dir_all(dir) .map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?; let filename = format!("{}{}", Uuid::new_v4().simple(), ext); let filepath = dir.join(&filename); let mut f = std::fs::File::create(&filepath) .map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?; f.write_all(&data) .map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?; let url = format!("/uploads/feedback/{}", filename); tracing::info!( user_id = user.user_id, path = %url, "反馈图片上传成功" ); Ok(Json(json!({ "code": 0, "data": { "url": url }, "message": "ok" }))) } pub async fn update_feedback_status( user: CurrentUser, State(state): State, Path(id): Path, Json(body): Json, ) -> Result, AppError> { let pool = &state.mysql; if body.status != 0 && body.status != 1 { return Err(AppError::BadRequest("状态值不合法".into())); } let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?") .bind(body.status) .bind(id) .execute(pool) .await .map_err(AppError::Database)? .rows_affected(); if affected == 0 { return Err(AppError::NotFound("反馈记录不存在".into())); } tracing::info!( "[h5] 反馈状态已更新 operator={} feedback_id={} status={}", user.user_id, id, body.status ); Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "ok" }))) } /// 验证用户是否有权访问指定柜子(组织数据隔离) /// /// 总管理员(role_level >= 2)可访问所有柜子; /// 企业管理员只能访问本组织下项目关联的柜子; /// 无组织关联的用户无权访问任何柜子。 async fn verify_cabinet_access( user: &CurrentUser, cabinet_id: i64, pool: &sqlx::MySqlPool, ) -> Result<(), AppError> { // 总管理员无需校验 if user.role_level >= 2 { return Ok(()); } let org_id = user .organization_id .ok_or_else(|| AppError::Forbidden("无组织关联,无权访问设备".into()))?; // 通过 cabinet → project → organization 链路验证归属 let access: Option<(i64,)> = sqlx::query_as( r#"SELECT cab.id FROM cabinets cab INNER JOIN projects p ON cab.project_id = p.id WHERE cab.id = ? AND p.organization_id = ?"#, ) .bind(cabinet_id) .bind(org_id) .fetch_optional(pool) .await?; if access.is_none() { return Err(AppError::Forbidden("无权访问该设备".into())); } Ok(()) }