//! 充电柜矩阵系统 — 设备服务入口
//!
//! 轻量化 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