## 严重问题
- 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、消费链路正常、迁移已建索引
261 lines
11 KiB
Rust
261 lines
11 KiB
Rust
//! 充电柜矩阵系统 — 设备服务入口
|
||
//!
|
||
//! 轻量化 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;
|
||
}
|
||
}
|
||
}
|
||
}
|