//! 充电柜矩阵系统 — 设备服务入口 //! //! 轻量化 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 连接成功"); // 初始化设备连接池(注入 Redis:下发指令时写挂起登记 `pending:{dev_id}`) let device_pool = tcp::connection::ConnectionPool::new(redis_conn.clone()); // 通过 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, redis::RedisError> = match redis::cmd("RPOP") .arg(&my_queue) .query_async::>(&mut conn) .await { Ok(Some(v)) => Ok(Some(v)), // 本节点队列为空时再取统一队列(多节点场景下的广播队列) Ok(None) => { let mut conn2 = redis.clone(); redis::cmd("RPOP") .arg("device:commands") .query_async::>(&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::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 = 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; } _ => { // 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景) match pool.send_with_msgid(&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; } } } }