## 严重问题
- order_timeout: 超时条件方向写反(`start_time < TIMESTAMPADD(HOUR,10,NOW())` 等价于
`start_time < NOW()+10h`,条件恒真)→ 活跃订单会被当超时单提前结掉。改为
`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`,并补"刚建单不命中/超期命中"双向断言
- C1 device-server 连接池可被未验签的报文 dev_id 顶替(覆盖式 insert + 断连无条件 remove):
改为 conn_id 归属模型 —— 唯一连接标识、remove_if/touch/is_authenticated 均带 conn 归属校验、
接管时用 oneshot 信号主动驱逐旧连接、每条上行前用 is_owner 复核持有者身份
- C2 POST /api/devices/{imei}/command 零归属校验 + act 自由字符串 + 把 IMEI 塞进协议 id:
补 scope_guard 归属校验 + act 白名单 + id 从 params 显式取(拒绝 15 位 IMEI)
- 非总管理员权限集恒为空(user_roles 从不写入且无分配入口)→ 全站 403:
user_roles 为空时按 users.role 回退内置角色权限 + 建/改用户幂等同步映射(仅角色变化时同步)
## 订单生命周期并发正确性
- finalize_order 先取电量累加器后抢结束权 → off/bat_out 成对到达时 energy 被写 0:
改为先原子抢占(WHERE compartment_id=? AND status=0 ORDER BY id DESC LIMIT 1),
抢到者才 take_and_reset 取电量;抢不到直接返回且不碰累加器
- 建单"先查后插"无锁无唯一约束 → 同仓体两笔活跃单:
条件插入(INSERT ... SELECT ... WHERE NOT EXISTS)+ 按仓体进程内锁
+ DB 层 active_flag 生成列 + UNIQUE 唯一索引(幂等迁移,有重复则记 ERROR 跳过,绝不阻断启动)
- handle_bat_out: updated==0 时不再复位占用;通道登记删除改为 battery_id 一致才删;
新增 should_finalize_bat_out 挡住"迟到 bat_out 结束新单"
- 电压/电流加量程夹取(0–100000 mV/mA 之外按 0 计并 WARN),防位翻转导致积分暴增
## 协议/契约
- spec 绝不可能是 "0000"(生产 42/68 台拓扑为空):新增 build_spec 三级兜底
(真实拓扑 → cabinets.cell×6 → 6×6),兜底生效记 WARN 报出拓扑数据
- /api/cabinets/realtime-status 改读真实字段(原来读的设备链路从不写的旧字段,恒返回 idle/空),
用 derive_channel_view 推导旧字段以保持响应字段名不变;修掉"把通道号当板号"的幽灵板问题
- 小程序通道详情/PC 详情改读通道 Hash(弃用无写入方的 compartment:status:{id}),
取不到返回 null 而非假的 0;补 voltage/current/temp/batt_status/pow_status
- 反馈列表补权限码 operation_log:view + status 筛选真正落到 SQL
- 小程序柜温换算(29800 曾被显示成 29800°C);签名注释 7→8 位;msg_id 序列化省略 null;
parse_compartment_id 与 status_post 补板号 1–15/通道 1–8 量程校验(不再写 board:0 垃圾 key)
## 文档
- docs/柜控板固件对接文档.md:确立数值口径统一规则(hex 字节流=原始硬件单位·平台换算;
JSON 数值字段=平台单位·设备换算)、修正 A.5.3 的 id 说明、作废变更记录原位标注(不删历史)、
spec 表述改为"按拓扑现算+兜底、恒 4 位不会是 0000"、修复文首 7 项表格被引用块切断丢表头、
修正 §10 误引 channels[].channel、写明无电池兜底阈值与 #16 风险
## 运维加固(服务器侧,非代码)
- 关闭 MySQL/Redis 公网暴露:nginx stream 由 0.0.0.0 改为只监听 127.0.0.1 + ZeroTier
- worker_shutdown_timeout 30s(原先每次 reload 泄漏永不退出的 worker)
- 测试证书改指向 app 证书(原测试证书 9 天后过期且无续期机制);补测试环境 uploads 目录;.env 0600
## 验收
- cargo check 两服务 0 error / 0 warning;cargo test api 88 + device 22 全过
- 路由 79 → 79 逐条一致;web tsc 干净
- 端到端联调(测试环境)13/13,订单结算与重构前逐字段一致;唯一索引已由迁移真实建出
- 生产已部署并验证:服务 active、health 200、ERROR 0、消费链路正常、迁移已建索引
488 lines
18 KiB
Rust
488 lines
18 KiB
Rust
//! 设备指令下发 — 通过 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 {
|
||
let key = format!("device:{}", imei);
|
||
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,
|
||
pub redis_url: String,
|
||
/// JWT 签名密钥 — 启动阶段从环境变量读取,缺失则 panic(禁止硬编码回退)
|
||
pub jwt_secret: Arc<str>,
|
||
}
|
||
|
||
/// 指令下发请求体
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct CommandRequest {
|
||
pub act: String,
|
||
#[serde(default)]
|
||
pub msg_id: u32,
|
||
#[serde(flatten)]
|
||
pub params: Option<Value>,
|
||
}
|
||
|
||
/// 内部路由字段名:推入 `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<String>,
|
||
/// 扩展参数(充电参数等)
|
||
#[serde(flatten)]
|
||
pub params: Option<Value>,
|
||
}
|
||
|
||
/// 向设备发送指令(通过 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(())
|
||
}
|
||
|
||
/// 本接口放行的**柜级/仓体级指令白名单**。
|
||
///
|
||
/// 为什么必须有白名单:`act` 是请求体里的自由字符串。原先不加限制 + 不做归属校验,
|
||
/// 任何持有 `device:operate` 的低权限用户都能对**全平台任意柜机**下发协议里的
|
||
/// **任何**动作(开门 / 断电 / 重启),属于越权高危操作(C2)。
|
||
///
|
||
/// 只放行管理端确有需要的动作:
|
||
/// - `open` / `pow_on` / `pow_off`:**柜级**(协议不带 `id`)
|
||
/// - `reset`:柜级;单仓控板时由调用方显式传 `id = "01"`
|
||
/// - `on` / `off`:**仓体级**,调用方必须显式传 `id = "01-3"`(协议要求)
|
||
const CABINET_COMMAND_ACTS: &[&str] = &["on", "off", "open", "pow_on", "pow_off", "reset"];
|
||
|
||
/// 协议 `id` 的合法形态校验:`id` 只表示**动作目标** ——
|
||
/// 一段 = 单仓控板(`"01"`)/ 两段 = 仓体级(`"01-3"`),每段 1~2 位数字。
|
||
///
|
||
/// 协议定稿后 `id` **不含 IMEI**(设备身份由 TCP 连接确定),因此 15 位 IMEI 一律非法。
|
||
fn is_valid_command_target(id: &str) -> bool {
|
||
/// 单段:1~2 位数字(仓控板 `"1"`/`"01"`,通道 `"3"`)
|
||
fn segment_ok(seg: &str) -> bool {
|
||
!seg.is_empty() && seg.len() <= 2 && seg.bytes().all(|b| b.is_ascii_digit())
|
||
}
|
||
|
||
let mut parts = id.split('-');
|
||
match (parts.next(), parts.next(), parts.next()) {
|
||
(Some(a), None, _) => segment_ok(a),
|
||
(Some(a), Some(b), None) => segment_ok(a) && segment_ok(b),
|
||
// 空串、三段及以上(含 IMEI 这类无分隔的长数字)一律拒绝
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// 从请求体参数里取出协议 `id`(动作目标),并从 `params` 中**摘除**。
|
||
///
|
||
/// - 取不到(柜级指令本就不带 `id`)→ `None`
|
||
/// - 由调用方显式给出:单板 `reset` → `"01"`;仓体级 `on`/`off` → `"01-3"`
|
||
/// - **绝不允许再用 IMEI 当 `id`**:下行路由靠内部字段
|
||
/// [`ROUTE_DEV_ID_FIELD`](`_dev_id`),协议 `id` 与设备身份无关
|
||
///
|
||
/// 为什么必须摘除:`DeviceCommand::params` 是 `#[serde(flatten)]`,
|
||
/// 若 `params` 里仍带 `id`,序列化时会与结构体显式 `id` 字段互相覆盖,
|
||
/// 可能把未校验的值(甚至非字符串)写进下行报文。
|
||
fn take_command_target(params: &mut Option<Value>) -> Result<Option<String>, AppError> {
|
||
let raw = params
|
||
.as_mut()
|
||
.and_then(|p| p.as_object_mut())
|
||
.and_then(|obj| obj.remove("id"));
|
||
|
||
match raw {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::String(s)) => {
|
||
if !is_valid_command_target(&s) {
|
||
return Err(AppError::BadRequest(format!(
|
||
"指令目标 id 非法: {}(应形如 \"01\" 或 \"01-3\",且不含 IMEI)",
|
||
s
|
||
)));
|
||
}
|
||
Ok(Some(s))
|
||
}
|
||
Some(_) => Err(AppError::BadRequest("指令目标 id 必须是字符串".into())),
|
||
}
|
||
}
|
||
|
||
/// POST /api/devices/:dev_id/command — 向指定设备下发指令
|
||
///
|
||
/// 安全链路(C2 加固,缺一不可):
|
||
/// 1. `dev_id`(IMEI) 反查 `cabinets.id`,查不到 → `NotFound`
|
||
/// 2. **组织归属校验** `verify_cabinet_ownership`(与 `routes/cabinets.rs` 同一套隔离规则;
|
||
/// 总管理员 role_level=2 豁免)
|
||
/// 3. 设备禁用校验
|
||
/// 4. `act` 白名单(见 [`CABINET_COMMAND_ACTS`])
|
||
/// 5. 设备必须在线(与 `/api/h5/charge/start` 一致,复用 `NodeRegistry::is_device_online`)
|
||
/// 6. 协议 `id` 只表示动作目标、由调用方显式给出,**绝不再填 IMEI**
|
||
pub async fn send_command(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Path(dev_id): Path<String>,
|
||
Json(mut req): Json<CommandRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
auth::check_permission(&user, "device:operate")?;
|
||
|
||
// 1. IMEI → 柜子ID(归属校验必须落在 cabinets.id 上,故先反查)
|
||
let cabinet_id: Option<i64> = sqlx::query_scalar("SELECT id FROM cabinets WHERE imei = ?")
|
||
.bind(&dev_id)
|
||
.fetch_optional(&state.mysql)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
let cabinet_id =
|
||
cabinet_id.ok_or_else(|| AppError::NotFound(format!("设备 {} 不存在", dev_id)))?;
|
||
|
||
// 2. 组织数据隔离:非总管理员只能操作本组织项目下的柜子
|
||
crate::routes::scope_guard::verify_cabinet_ownership(&state.mysql, &user, cabinet_id).await?;
|
||
|
||
// 3. 设备禁用校验
|
||
let disabled: bool = sqlx::query_scalar(
|
||
r#"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2"#,
|
||
)
|
||
.bind(cabinet_id)
|
||
.fetch_one(&state.mysql)
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
if disabled {
|
||
return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into()));
|
||
}
|
||
|
||
// 4. `act` 白名单
|
||
if !CABINET_COMMAND_ACTS.contains(&req.act.as_str()) {
|
||
return Err(AppError::BadRequest(format!(
|
||
"不支持的指令 act={},本接口仅允许: {}",
|
||
req.act,
|
||
CABINET_COMMAND_ACTS.join("/")
|
||
)));
|
||
}
|
||
|
||
// 5. 设备必须在线(平台对不在线的设备一律不投递)
|
||
if !state.nodes.is_device_online(&dev_id).await {
|
||
return Err(AppError::BadRequest("设备不在线".into()));
|
||
}
|
||
|
||
// 6. 协议 `id`(动作目标):只从请求体显式取,**不再把 IMEI 塞进去**
|
||
let id = take_command_target(&mut req.params)?;
|
||
|
||
// 仓体级动作(on/off)必须带目标,否则设备侧无法定位仓体(协议要求 `id` = 仓控板-通道)
|
||
if matches!(req.act.as_str(), "on" | "off") && id.is_none() {
|
||
return Err(AppError::BadRequest(format!(
|
||
"指令 act={} 必须显式给出目标 id(仓控板-通道,如 \"01-3\")",
|
||
req.act
|
||
)));
|
||
}
|
||
|
||
let cmd = DeviceCommand { act: req.act, msg_id: req.msg_id, id, params: req.params };
|
||
|
||
send_device_command(&state.redis, &dev_id, &cmd)
|
||
.await
|
||
.map_err(AppError::Internal)?;
|
||
|
||
tracing::info!(
|
||
"[command] 已推入队列 dev_id={} cabinet_id={} act={} id={:?}",
|
||
dev_id,
|
||
cabinet_id,
|
||
cmd.act,
|
||
cmd.id
|
||
);
|
||
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(),
|
||
})))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 单元测试:C2 协议 `id`(动作目标)的校验与摘除
|
||
// ---------------------------------------------------------------------------
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
/// 合法形态:一段 = 单仓控板(`"01"`)、两段 = 仓体级(`"01-3"`),每段 1~2 位数字
|
||
#[test]
|
||
fn test_is_valid_command_target_accepts_board_and_compartment() {
|
||
for ok in ["01", "1", "6", "01-3", "1-3", "12-34"] {
|
||
assert!(is_valid_command_target(ok), "{} 应为合法目标", ok);
|
||
}
|
||
}
|
||
|
||
/// IMEI(15 位纯数字)与其它畸形值一律非法 —— 协议 `id` **不含 IMEI**
|
||
#[test]
|
||
fn test_is_valid_command_target_rejects_imei_and_malformed() {
|
||
for bad in ["864720562557021", "", "-", "01-", "-3", "01-3-2", "0a", "123", "01 -3"] {
|
||
assert!(!is_valid_command_target(bad), "{} 应为非法目标", bad);
|
||
}
|
||
}
|
||
|
||
/// 显式给出 `id` 时取出,并把它从 `params` 中摘除
|
||
/// (否则 `#[serde(flatten)]` 会在序列化时覆盖结构体的显式 `id` 字段)
|
||
#[test]
|
||
fn test_take_command_target_extracts_and_removes_id() {
|
||
let mut params = Some(json!({ "id": "01-3", "duration": 60 }));
|
||
let id = take_command_target(&mut params).unwrap();
|
||
assert_eq!(id.as_deref(), Some("01-3"));
|
||
|
||
let obj = params.as_ref().and_then(|p| p.as_object()).unwrap();
|
||
assert!(!obj.contains_key("id"), "id 必须从 params 摘除");
|
||
assert_eq!(obj["duration"], 60, "其它参数必须原样保留");
|
||
}
|
||
|
||
/// 柜级指令不带 `id` → `None`(**绝不回填 IMEI**)
|
||
#[test]
|
||
fn test_take_command_target_absent_returns_none() {
|
||
assert_eq!(take_command_target(&mut None).unwrap(), None);
|
||
assert_eq!(take_command_target(&mut Some(json!({ "id": null }))).unwrap(), None);
|
||
assert_eq!(take_command_target(&mut Some(json!({ "board": 1 }))).unwrap(), None);
|
||
}
|
||
|
||
/// 非法目标(IMEI / 非字符串)必须报错,不得下发
|
||
#[test]
|
||
fn test_take_command_target_rejects_invalid_id() {
|
||
let mut imei = Some(json!({ "id": "864720562557021" }));
|
||
assert!(matches!(take_command_target(&mut imei), Err(AppError::BadRequest(_))));
|
||
|
||
let mut numeric = Some(json!({ "id": 3 }));
|
||
assert!(matches!(take_command_target(&mut numeric), Err(AppError::BadRequest(_))));
|
||
}
|
||
}
|