//! 设备指令下发 — 通过 Redis 队列向设备发送指令 //! //! API 服务不直接持有 TCP 连接,而是通过 Redis LIST `device:cmd:{imei}` //! 推送指令,由设备服务消费并发送到设备。指令响应通过 `device:reply:{imei}` //! 队列返回,API 服务通过 `PendingCommands` 匹配 msg_id 完成异步等待。 use std::collections::HashMap; use std::sync::Arc; use axum::extract::{Path, State}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use tokio::sync::{oneshot, Mutex}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; use crate::protocol::ServerResponse; /// msg_id → oneshot Sender 映射表 #[derive(Clone)] pub struct PendingCommands { inner: Arc>>>, } impl PendingCommands { pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(HashMap::new())) } } /// 注册等待中的指令 #[allow(dead_code)] pub async fn insert(&self, msg_id: u32, tx: oneshot::Sender) { self.inner.lock().await.insert(msg_id, tx); } /// 移除并返回指定 msg_id 的 sender(消费一次) pub async fn remove(&self, msg_id: u32) -> Option> { self.inner.lock().await.remove(&msg_id) } } /// 设备服务节点信息 #[derive(Debug, Clone)] pub struct NodeInfo { pub node_id: String, pub ip: String, pub tcp_port: u16, /// RFC3339 格式的最后心跳时间,从 Redis 读取 pub last_heartbeat: Option, } /// 节点注册表(Redis 存储,API 服务从 Redis 读取节点信息) /// /// 设备服务通过 HSET 写入 `node:{node_id}` hash, /// API 服务通过此模块读取。Redis 是节点信息的单一数据源。 #[derive(Clone)] pub struct NodeRegistry { redis: redis::aio::ConnectionManager, } impl NodeRegistry { pub fn new(redis: redis::aio::ConnectionManager) -> Self { Self { redis } } fn node_key(node_id: &str) -> String { format!("node:{}", node_id) } /// 注册节点(写入 Redis HSET) pub async fn register(&self, node_id: &str, ip: &str, tcp_port: u16) { let key = Self::node_key(node_id); let now = chrono::Utc::now().to_rfc3339(); let mut conn = self.redis.clone(); let _: Result<(), _> = redis::cmd("HSET") .arg(&key) .arg("node_id").arg(node_id) .arg("ip").arg(ip) .arg("tcp_port").arg(tcp_port.to_string()) .arg("status").arg("online") .arg("last_heartbeat").arg(&now) .query_async(&mut conn) .await; // ponytail: HSET 不支持 EX,用 EXPIRE 设置 TTL let _: Result<(), _> = redis::cmd("EXPIRE") .arg(&key) .arg(300_u64) .query_async(&mut conn) .await; } /// 移除节点(从 Redis 删除) pub async fn remove(&self, node_id: &str) { let key = Self::node_key(node_id); let mut conn = self.redis.clone(); let _: Result<(), _> = redis::cmd("DEL") .arg(&key) .query_async(&mut conn) .await; } /// 更新心跳时间戳 pub async fn heartbeat(&self, node_id: &str) -> bool { let key = Self::node_key(node_id); let mut conn = self.redis.clone(); let result: Result<(), _> = redis::cmd("HSET") .arg(&key) .arg("last_heartbeat") .arg(chrono::Utc::now().to_rfc3339()) .query_async(&mut conn) .await; // 心跳同时刷新 TTL let _: Result<(), _> = redis::cmd("EXPIRE") .arg(&key) .arg(300_u64) .query_async(&mut conn) .await; result.is_ok() } /// 列出所有节点(从 Redis 读取,使用 SCAN 替代 KEYS 避免阻塞) pub async fn list(&self) -> Vec { let mut conn = self.redis.clone(); let keys = self.scan_keys(&mut conn, "node:*").await; let mut nodes = Vec::with_capacity(keys.len()); for key in keys { let mut conn = self.redis.clone(); let info: HashMap = redis::cmd("HGETALL") .arg(&key) .query_async(&mut conn) .await .unwrap_or_default(); let node_id = info.get("node_id").cloned().unwrap_or_default(); if node_id.is_empty() { continue; // ponytail: 跳过空记录 } nodes.push(NodeInfo { node_id, ip: info.get("ip").cloned().unwrap_or_default(), tcp_port: info.get("tcp_port").and_then(|p| p.parse().ok()).unwrap_or(0), last_heartbeat: info.get("last_heartbeat").cloned(), }); } nodes } /// 使用 SCAN 命令安全地获取所有匹配模式的键(避免 KEYS 阻塞) async fn scan_keys(&self, conn: &mut redis::aio::ConnectionManager, pattern: &str) -> Vec { let mut keys = Vec::new(); let mut cursor = 0i64; loop { let (next_cursor, batch): (i64, Vec) = redis::cmd("SCAN") .arg(cursor) .arg("MATCH") .arg(pattern) .arg("COUNT") .arg(100) .query_async(conn) .await .unwrap_or_default(); keys.extend(batch); if next_cursor == 0 { break; } cursor = next_cursor; } keys } /// 检查设备是否在线(通过 Redis 中的在线标记) pub async fn is_device_online(&self, imei: &str) -> bool { let key = format!("device:{}", imei); let mut conn = self.redis.clone(); let exists: Result = redis::cmd("EXISTS") .arg(&key) .query_async(&mut conn) .await; exists.unwrap_or(false) } } /// 应用共享状态 #[derive(Clone)] #[allow(dead_code)] pub struct AppState { pub redis: redis::aio::ConnectionManager, pub mysql: sqlx::MySqlPool, pub pending_commands: PendingCommands, pub nodes: NodeRegistry, pub redis_url: String, /// JWT 签名密钥 — 启动阶段从环境变量读取,缺失则 panic(禁止硬编码回退) pub jwt_secret: Arc, } /// 指令下发请求体 #[derive(Debug, Deserialize)] pub struct CommandRequest { pub act: String, #[serde(default)] pub msg_id: u32, #[serde(flatten)] pub params: Option, } /// 内部路由字段名:推入 `device:commands` 时附加,值 = 目标设备 IMEI。 /// /// 用途:device-server 据此决定把指令发给哪条连接;**下发前摘除,设备侧不可见**。 /// 为什么需要它:协议定稿后 `id` 只表示**动作目标**(柜级省略 / 一段=单板 / 两段=仓体级), /// **不再含 IMEI**,所以设备身份必须另用内部字段传递。 pub const ROUTE_DEV_ID_FIELD: &str = "_dev_id"; /// 设备指令结构(序列化后推入 Redis) /// /// 协议格式(v1.1):`id` 只表示**动作目标**,**不含 IMEI**(设备身份由 TCP 连接确定) /// - **柜级**指令(open/pow_on/pow_off/reset 整柜):**不带 `id`**(字段省略) /// - **单仓控板**指令(reset 单板):`仓控板`(如 `"01"`) /// - **仓体级**指令(on/off):`仓控板-通道`(如 `"01-3"`) #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DeviceCommand { pub act: String, pub msg_id: u32, /// 动作目标(柜级为 `None`),序列化为 "id" #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, /// 扩展参数(充电参数等) #[serde(flatten)] pub params: Option, } /// 向设备发送指令(通过 Redis 队列) pub async fn send_device_command( redis: &redis::aio::ConnectionManager, dev_id: &str, cmd: &DeviceCommand, ) -> Result<(), String> { let mut json_val = serde_json::to_value(cmd).map_err(|e| format!("指令序列化失败: {}", e))?; // 内部路由字段:告知 device-server 这条指令发给哪台设备(下发前摘除) json_val[ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id); let json_str = json_val.to_string(); let mut conn = redis.clone(); redis::cmd("LPUSH") .arg("device:commands") .arg(&json_str) .query_async::<()>(&mut conn) .await .map_err(|e| format!("Redis 推入失败: {}", e))?; Ok(()) } /// 向设备发送响应(通过 Redis 队列) #[allow(dead_code)] pub async fn send_device_response( redis: &redis::aio::ConnectionManager, _dev_id: &str, resp: &ServerResponse, ) -> Result<(), String> { let json_str = resp.to_json(); let mut conn = redis.clone(); redis::cmd("LPUSH") .arg("device:commands") .arg(&json_str) .query_async::<()>(&mut conn) .await .map_err(|e| format!("Redis 推入失败: {}", e))?; Ok(()) } /// POST /api/devices/:dev_id/command — 向指定设备下发指令 pub async fn send_command( user: CurrentUser, State(state): State, Path(dev_id): Path, Json(req): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:operate")?; // 检查设备是否被禁用 let disabled: bool = sqlx::query_scalar( r#"SELECT COUNT(*) > 0 FROM cabinets WHERE imei = ? AND status = 2"#, ) .bind(&dev_id) .fetch_one(&state.mysql) .await .map_err(AppError::Database)?; if disabled { return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into())); } let cmd = DeviceCommand { act: req.act, msg_id: req.msg_id, id: Some(dev_id.clone()), params: req.params, }; send_device_command(&state.redis, &dev_id, &cmd) .await .map_err(AppError::Internal)?; tracing::info!("[command] 已推入队列 dev_id={} act={}", dev_id, cmd.act); Ok(Json(json!({ "suc": 1 }))) } /// GET /api/devices/stats — 在线设备统计 pub async fn device_stats( user: CurrentUser, State(state): State, ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; let nodes = state.nodes.list().await; Ok(Json(json!({ "node_count": nodes.len(), }))) }