2026-07-02 05:38:01 +08:00
|
|
|
|
//! 设备指令下发 — 通过 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<Mutex<HashMap<u32, oneshot::Sender<Value>>>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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<Value>) {
|
|
|
|
|
|
self.inner.lock().await.insert(msg_id, tx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 移除并返回指定 msg_id 的 sender(消费一次)
|
|
|
|
|
|
pub async fn remove(&self, msg_id: u32) -> Option<oneshot::Sender<Value>> {
|
|
|
|
|
|
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<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 节点注册表(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<NodeInfo> {
|
|
|
|
|
|
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<String, String> = 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<String> {
|
|
|
|
|
|
let mut keys = Vec::new();
|
|
|
|
|
|
let mut cursor = 0i64;
|
|
|
|
|
|
loop {
|
|
|
|
|
|
let (next_cursor, batch): (i64, Vec<String>) = 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 {
|
2026-09-19 23:40:46 +08:00
|
|
|
|
let key = format!("device:{}", imei);
|
2026-07-02 05:38:01 +08:00
|
|
|
|
let mut conn = self.redis.clone();
|
|
|
|
|
|
let exists: Result<bool, _> = 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,
|
2026-07-03 02:45:00 +08:00
|
|
|
|
pub redis_url: String,
|
2026-09-19 23:40:46 +08:00
|
|
|
|
/// JWT 签名密钥 — 启动阶段从环境变量读取,缺失则 panic(禁止硬编码回退)
|
|
|
|
|
|
pub jwt_secret: Arc<str>,
|
2026-07-02 05:38:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 指令下发请求体
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
|
pub struct CommandRequest {
|
|
|
|
|
|
pub act: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub msg_id: u32,
|
|
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
|
pub params: Option<Value>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
/// 内部路由字段名:推入 `device:commands` 时附加,值 = 目标设备 IMEI。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 用途:device-server 据此决定把指令发给哪条连接;**下发前摘除,设备侧不可见**。
|
|
|
|
|
|
/// 为什么需要它:协议定稿后 `id` 只表示**动作目标**(柜级省略 / 一段=单板 / 两段=仓体级),
|
|
|
|
|
|
/// **不再含 IMEI**,所以设备身份必须另用内部字段传递。
|
|
|
|
|
|
pub const ROUTE_DEV_ID_FIELD: &str = "_dev_id";
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
/// 设备指令结构(序列化后推入 Redis)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// 协议格式(v1.1):`id` 只表示**动作目标**,**不含 IMEI**(设备身份由 TCP 连接确定)
|
|
|
|
|
|
/// - **柜级**指令(open/pow_on/pow_off/reset 整柜):**不带 `id`**(字段省略)
|
|
|
|
|
|
/// - **单仓控板**指令(reset 单板):`仓控板`(如 `"01"`)
|
|
|
|
|
|
/// - **仓体级**指令(on/off):`仓控板-通道`(如 `"01-3"`)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
|
|
|
|
pub struct DeviceCommand {
|
|
|
|
|
|
pub act: String,
|
|
|
|
|
|
pub msg_id: u32,
|
2026-09-19 23:40:46 +08:00
|
|
|
|
/// 动作目标(柜级为 `None`),序列化为 "id"
|
2026-07-02 05:38:01 +08:00
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
2026-09-19 23:40:46 +08:00
|
|
|
|
pub id: Option<String>,
|
|
|
|
|
|
/// 扩展参数(充电参数等)
|
2026-07-02 05:38:01 +08:00
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
|
pub params: Option<Value>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 向设备发送指令(通过 Redis 队列)
|
|
|
|
|
|
pub async fn send_device_command(
|
|
|
|
|
|
redis: &redis::aio::ConnectionManager,
|
2026-09-19 23:40:46 +08:00
|
|
|
|
dev_id: &str,
|
2026-07-02 05:38:01 +08:00
|
|
|
|
cmd: &DeviceCommand,
|
|
|
|
|
|
) -> Result<(), String> {
|
2026-09-19 23:40:46 +08:00
|
|
|
|
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();
|
2026-07-02 05:38:01 +08:00
|
|
|
|
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<AppState>,
|
|
|
|
|
|
Path(dev_id): Path<String>,
|
|
|
|
|
|
Json(req): Json<CommandRequest>,
|
|
|
|
|
|
) -> Result<Json<Value>, AppError> {
|
|
|
|
|
|
auth::check_permission(&user, "device:operate")?;
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
// 检查设备是否被禁用
|
|
|
|
|
|
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()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
let cmd = DeviceCommand {
|
|
|
|
|
|
act: req.act,
|
|
|
|
|
|
msg_id: req.msg_id,
|
2026-09-19 23:40:46 +08:00
|
|
|
|
id: Some(dev_id.clone()),
|
2026-07-02 05:38:01 +08:00
|
|
|
|
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<AppState>,
|
|
|
|
|
|
) -> Result<Json<Value>, AppError> {
|
|
|
|
|
|
auth::check_permission(&user, "device:view")?;
|
|
|
|
|
|
let nodes = state.nodes.list().await;
|
|
|
|
|
|
Ok(Json(json!({
|
|
|
|
|
|
"node_count": nodes.len(),
|
|
|
|
|
|
})))
|
|
|
|
|
|
}
|