12451 f80b3b634c fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底)
## 严重问题
- 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、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00

488 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 设备指令下发 — 通过 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(_))));
}
}