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

261 lines
11 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.

//! 充电柜矩阵系统 — 设备服务入口
//!
//! 轻量化 TCP 服务,只处理设备长连接通信和指令解析。
//! 不存储 DB、不验证业务逻辑,所有设备上报通过 Redis 转发给 API 服务。
//!
//! 启动流程:
//! 1. 连接 Redis
//! 2. 通过 Redis 注册到 API 服务(节点信息写入 Redis)
//! 3. 启动心跳协程(定期更新 Redis 中的心跳时间戳)
//! 4. 启动指令消费协程(从 Redis 读取平台指令并发送到设备)
//! 5. 启动 TCP 监听
mod config;
mod redis_conn;
mod tcp;
use std::time::Duration;
use config::Config;
/// 内部路由字段名:api-server 在推入 `device:commands` 时附加,
/// 指明"这条指令发给哪台设备"(值 = IMEI)。device-server 读取后**下发前摘除**,
/// 设备侧不可见(与 `_close` 同类)。
const ROUTE_DEV_ID_FIELD: &str = "_dev_id";
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
// 加载配置
let cfg = Config::from_env();
tracing::info!("配置加载完成 node_id={}", cfg.node_id);
// 连接 Redis
let redis_conn = redis_conn::init_connection(&cfg.redis_url)
.await
.expect("Redis 连接失败");
tracing::info!("Redis 连接成功");
// 初始化设备连接池(纯内存结构:`dev_id` → 连接,增删按 `conn_id` 校验归属)
let device_pool = tcp::connection::ConnectionPool::new();
// 通过 Redis 注册节点信息
register_node(&redis_conn, &cfg).await;
// 启动心跳协程(通过 Redis 更新心跳时间戳)
let hb_redis = redis_conn.clone();
let hb_node_id = cfg.node_id.clone();
let hb_interval = cfg.heartbeat_interval;
let hb_ttl = cfg.register_ttl;
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(hb_interval));
loop {
interval.tick().await;
heartbeat(&hb_redis, &hb_node_id, hb_ttl).await;
}
});
// 启动指令消费协程(从 Redis 读取平台指令并发送到设备)
let cmd_redis = redis_conn.clone();
let cmd_pool = device_pool.clone();
let cmd_node_id = cfg.node_id.clone();
tokio::spawn(async move {
consume_commands(cmd_redis, cmd_pool, cmd_node_id).await;
});
// 启动 TCP 监听
let tcp_addr = format!("{}:{}", cfg.host, cfg.tcp_port);
tracing::info!("TCP 设备服务启动于 {}", tcp_addr);
tcp::server::run_tcp_server(tcp_addr, device_pool, redis_conn, cfg.node_id).await;
}
/// 通过 Redis 注册节点信息(HSET hash 结构,与 heartbeat 保持一致)
async fn register_node(redis: &redis::aio::ConnectionManager, cfg: &Config) {
let key = format!("node:{}", cfg.node_id);
let now = chrono::Utc::now().to_rfc3339();
let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg("node_id")
.arg(&cfg.node_id)
.arg("ip")
.arg(&cfg.host)
.arg("tcp_port")
.arg(cfg.tcp_port.to_string())
.arg("status")
.arg("online")
.arg("registered_at")
.arg(&now)
.arg("last_heartbeat")
.arg(&now)
.query_async(&mut conn)
.await;
// ponytail: HSET 后单独设置 TTL(redis-rs 的 HSET 不支持 EX 选项)
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(cfg.register_ttl)
.query_async(&mut conn)
.await;
match result {
Ok(_) => tracing::info!("已通过 Redis 注册节点 node_id={}", cfg.node_id),
Err(e) => tracing::error!("节点注册失败: {}", e),
}
}
/// 通过 Redis 更新心跳时间戳,同时刷新 TTL
async fn heartbeat(redis: &redis::aio::ConnectionManager, node_id: &str, register_ttl: u64) {
let key = format!("node:{}", node_id);
let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg("last_heartbeat")
.arg(chrono::Utc::now().to_rfc3339())
.query_async(&mut conn)
.await;
// ponytail: 心跳同时刷新 TTL,避免节点被过期清理
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(register_ttl)
.query_async(&mut conn)
.await;
match result {
Ok(_) => tracing::debug!("心跳已更新 node_id={}", node_id),
Err(e) => tracing::warn!("心跳更新失败: {}", e),
}
}
/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 和本节点队列读取指令,
/// 查询路由后发送到正确节点。
async fn consume_commands(
redis: redis::aio::ConnectionManager,
pool: tcp::connection::ConnectionPool,
node_id: String,
) {
tracing::info!("[cmd_consumer] 启动 node_id={}", node_id);
let my_queue = format!("device:commands:{}", node_id);
loop {
let mut conn = redis.clone();
// **非阻塞轮询**取一条待下发指令,先取本节点专属队列、再取统一队列。
//
// 为什么不用 `BRPOP`(2026-09 实测结论):Redis 经 nginx stream 代理访问阿里云 RDS,
// 客户端又是多路复用的 `ConnectionManager`,这种组合下 `BRPOP` 的阻塞语义**不可靠**:
// - `BRPOP key 0`(无限阻塞)→ 代理掐掉空闲连接后**消费永久卡死**,且无任何错误日志;
// - `BRPOP key N`(有限超时)→ **"超时才醒"而非"数据到达即醒"**,实测下行指令
// (含 `login` 应答)延迟 15~45 秒,超过设备的等待窗口后会断开,平台侧表现为
// 「发送失败 设备不在线 → 入死信」。
//
// 轮询把投递延迟稳定在 200ms 内,且不依赖阻塞命令语义。
// (生产者用 `LPUSH`,这里用 `RPOP`,FIFO 顺序与原先一致。)
const POLL_INTERVAL_MS: u64 = 200;
let picked: Result<Option<String>, redis::RedisError> = match redis::cmd("RPOP")
.arg(&my_queue)
.query_async::<Option<String>>(&mut conn)
.await
{
Ok(Some(v)) => Ok(Some(v)),
// 本节点队列为空时再取统一队列(多节点场景下的广播队列)
Ok(None) => {
let mut conn2 = redis.clone();
redis::cmd("RPOP")
.arg("device:commands")
.query_async::<Option<String>>(&mut conn2)
.await
}
Err(e) => Err(e),
};
match picked {
Ok(Some(cmd_json)) => {
// 内部路由字段 `_dev_id`(由 api-server 附加):它才是"这条指令发给哪台设备"。
// ⚠️ 不能再用协议 `id` 推导 —— 协议定稿后 `id` 只表示**动作目标**
// (柜级省略 / 一段=单板 / 两段=仓体级),**不含 IMEI**。
let parsed: Option<serde_json::Value> = serde_json::from_str(&cmd_json).ok();
let dev_id = parsed
.as_ref()
.and_then(|v| v.get(ROUTE_DEV_ID_FIELD).and_then(|d| d.as_str()))
.map(String::from);
let dev_id = match dev_id {
Some(s) if !s.is_empty() => s,
_ => {
tracing::warn!(
"[cmd_consumer] 指令缺少内部路由字段 {},丢弃:{}",
ROUTE_DEV_ID_FIELD,
cmd_json
);
continue;
}
};
// 协议 `id`(动作目标)—— 仅用于日志;柜级指令不带此字段
let proto_id = parsed
.as_ref()
.and_then(|v| v.get("id").and_then(|d| d.as_str()))
.unwrap_or("-")
.to_string();
// 查询路由:HGET device:{dev_id} node_id
let mut route_conn = redis.clone();
let target_node: Option<String> = redis::cmd("HGET")
.arg(format!("device:{}", dev_id))
.arg("node_id")
.query_async(&mut route_conn)
.await
.unwrap_or(None);
match target_node {
Some(ref nid) if nid != &node_id => {
// 设备在其他节点,转发到该节点专属队列
tracing::debug!("[cmd] 转发 dev_id={} 到节点 {}", dev_id, nid);
let mut reroute_conn = redis.clone();
let _: Result<(), _> = redis::cmd("LPUSH")
.arg(format!("device:commands:{}", nid))
.arg(&cmd_json)
.query_async(&mut reroute_conn)
.await;
}
_ => {
// 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景)
// Redis 由调用方传入(仅用于挂起登记 `pending:{dev_id}`,失败不影响下发)
match pool.send_with_msgid(&redis, &dev_id, &cmd_json).await {
Ok(()) => {
tracing::debug!("[cmd] 已发送到设备 dev_id={} id={}", dev_id, proto_id);
}
Err(e) => {
tracing::error!("[cmd] 发送失败 dev_id={} err={} 已入死信", dev_id, e);
// ponytail: 丢入死信队列,不重试(重试语义和次数待业务明确)
let mut dl_conn = redis.clone();
let _: Result<(), _> = redis::cmd("LPUSH")
.arg("device:commands:dead")
.arg(&cmd_json)
.query_async(&mut dl_conn)
.await;
}
}
}
}
}
Ok(None) => {
// 两个队列都为空:短暂让出,避免空转打满 CPU(延迟上限 = 本间隔)
tracing::trace!("[cmd_consumer] 队列为空,继续等待");
tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
Err(e) => {
tracing::error!("[cmd_consumer] RPOP 错误: {}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}