12451 db1264953a fix(ops): 恢复能力与运维缺口修复(建表顺序/备份/建单漏单/214仓体/队列上限/NOFILE)
## 恢复能力(此前实际为 0)
- migrate.rs 的 SCHEMA_SQL 存在前向引用:charge_orders 建表引用其后才创建的 compartments
  → 全新空库必然报 MySQL 1824,而 main.rs 是 .expect("数据库迁移失败") + Restart=always
  → 无限崩溃循环。修正为把 cabin_boards/compartments 移到 cabinets 之后
  (只挪位置、21 条语句字符数完全一致;实测旧版 20/21 → 新版 21/21,
   并用真实二进制在全新空库跑通、建出 21 表 153 列)
- 新增每日备份 deploy/backup-db.sh + deploy/pms-backup.cron:
  pms 与 pms_dev 各一份,--single-transaction --routines --triggers,单库 dump(无 USE/CREATE DATABASE,
  演练可安全恢复到临时库),.part 原子 mv、gzip -t 校验、记录表数与精确行数、保留 7 天、flock 防重叠、
  任一库失败则跳过清理并 exit 1(避免失败时误删旧备份)。cron 已真实触发验证;
  恢复演练:21 表逐表行数一致(1339=1339)后清理无残留
- migrate.rs 每次启动无条件 ALTER cabinets.abstract_id 改为先查 information_schema 再决定,
  实测老库触发 1 次、后续启动 0 次

## 订单与占用
- h5.rs start_charge:校验顺序改为 权限→归属→设备启用→设备在线→建单→下发(原为先建单后校验,
  设备离线时会留下幽灵单);取不到 bat_in 登记(48h TTL 过期)时不再跳过建单,改为按未知电池建单
  (与设备侧 handle_device_on 兜底一致)—— 此前会导致整段会话无订单/无记录/无电量
- report_worker.rs 新增无订单却标占用的兜底复位(优先按通道 batt_status bit7,拿不到状态时按
  超过 12 小时未更新),并已幂等清零生产历史遗留下的 214 行;两库复查均为 0

## 队列与资源上限
- device-server:device:reports 加 LPUSH→LLEN→LTRIM 0 199999→LLEN 的 pipeline 与越界 WARN
  (此前无上限:68 柜停 8 天、1000 柜停 13 小时即打满 1GB Redis,且会连累同实例既有生产平台)
- 死信队列加 LTRIM 0 9999 + EXPIRE 7 天,写入时 WARN 带累计死信数
- 两服务各加 600s 一次的队列深度 INFO 日志(可观测性)
- 4 个 systemd 单元(仓库 + 线上已安装)加 LimitNOFILE=65535(原软限制 1024,约 1000 柜即 accept 失败)

## 文档
- docs/API.md:接口总表 71 → 79 条并与 mod.rs 的 79 次 .route( 注册双向比对一致;
  修正 system:log(种子里不存在的权限码)→ operation_log:view、登出 GET→POST、能耗导出 → energy:export;
  补齐 21 个未记录接口;新增"列表响应包装"与"关键数值字段单位"两节

## 超长函数
- report_worker.rs handle_bat_out 100→63、ensure_charge_order 88→68;users.rs update_user 81→45
  (纯重构:字符串字面量多重集零差异,allow/unwrap/expect/panic 计数不变)

## 验收
- cargo check 两服务 0 error/0 warning;cargo test api 90 + device 22 全过
- 前端真实类型检查 npm run typecheck(= tsc -b;原 `tsc --noEmit` 因 tsconfig 是 solution 文件而空跑=假绿)
- 端到端联调(测试环境)13/13;生产已部署并验证:服务 active、health 200、ERROR 0、
  消费链路正常、214 仓体清零、唯一索引在位
2026-09-20 01:21:17 +08:00

124 lines
6.0 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.

//! 设备消息处理逻辑(轻量化)。
//!
//! 设备服务只做**协议透传**,不处理业务逻辑:
//! - `status_post` 的通道数据在设备侧已是 **18 字符 hex 明文**,本服务**原样透传**,
//! 不再做任何拆解/位域解析(解析归 api-server 的业务层)
//! - 所有设备上报消息统一转发到 Redis `device:reports` 队列
//! - 不写 DB、不验证签名、不处理业务规则
//! - auth_str/login 等协议级消息也由 API 服务处理后通过 `device:commands` 回复
use super::connection::ConnectionPool;
use super::protocol::DeviceMessage;
/// 上报队列 `device:reports` 的**长度上限**(超过就裁剪最旧的)。
///
/// **为什么必须有上限**:本服务只 `LPUSH`,api-server 是唯一消费者;一旦 api-server 长时间不消费
/// (宕机/卡死/上线顺序颠倒),队列会无界增长,而 Redis 实例 `maxmemory=1GB`、
/// `maxmemory-policy=volatile-lru` 且实例上绝大多数 key 没有 TTL(可淘汰集合≈1)⇒
/// **写命令 OOM 会连累同实例上既有的充电站生产平台**。
///
/// **为什么取 200000**(估算依据):
/// - 单条 `status_post` 序列化后约 **1.1~1.2KB**(含 36 通道 18 字符 hex + 柜级字段);
/// - 68 柜规模:每柜每天 1440 条(协议 §5.3 固定 60 秒一次)⇒ 约 **9.8 万条/天 ≈ 115MB/天**,
/// 20 万条可容纳 **约 2 天**的完全积压(远超"重启窗口 + 正常积压");
/// - 1000 柜规模:约 **144 万条/天**,20 万条 ≈ **3.3 小时** 的缓冲;
/// - 内存占用上限 ≈ 20 万 × 1.15KB ≈ **230MB**,只占 1GB 实例的约 23%,
/// 给同实例其他业务留足余量(这正是设上限的目的)。
///
/// ⚠️ 不改队列名、不改报文格式(协议冻结)。
const REPORTS_QUEUE_MAX: i64 = 200_000;
/// 分发消息:补写设备身份后转发到 Redis 上报队列。
///
/// `current_dev_id` = 本 TCP 连接绑定的设备身份(由 `auth_str`/`login` 建立)。
/// 为什么需要它:新协议里业务上行**不带 `dev_id`**(设备身份由连接确定),
/// 而 api-server 依赖 `dev_id` 识别设备,故转发前必须补上。
///
/// 本服务**不直接回复设备**:应答由 api-server 经 Redis `device:commands` 下发,
/// 因此这里没有返回值(历史上曾返回 `Option<ServerResponse>` 但恒为 `None`,
/// 那个结构体副本已删除,避免与 api-server 的真实定义漂移)。
pub async fn handle_message(
mut msg: DeviceMessage,
current_dev_id: Option<&str>,
_pool: &ConnectionPool,
redis: &redis::aio::ConnectionManager,
) {
attach_dev_id(&mut msg, current_dev_id);
// 所有消息统一转发到 Redis,由 API 服务的 report_worker 处理
forward_to_redis(redis, &msg).await;
// 不直接回复设备,等待 API 服务通过 device:commands 队列回复
}
/// 把连接绑定的设备身份写入待转发报文。
///
/// 以**连接**为准(而非报文自带值):设备身份由 TCP 连接确定,
/// 这样即使设备漏带/错带 `dev_id`,api-server 侧也不会认错设备。
fn attach_dev_id(msg: &mut DeviceMessage, current_dev_id: Option<&str>) {
match current_dev_id {
Some(dev_id) if !dev_id.is_empty() => msg.dev_id = Some(dev_id.to_string()),
// 理论上不会出现:未登录连接的业务报文已被 server.rs 的门控丢弃。
// 此时仍照常转发(不丢数据),仅记 WARN 便于排查。
_ => tracing::warn!(
"[forward] 连接未绑定 dev_id,转发报文将不含设备身份, act={}",
msg.act
),
}
}
/// 将设备消息转发到 Redis 统一上报队列 `device:reports`
///
/// `LPUSH` 后用同一条 pipeline 做 `LLEN → LTRIM → LLEN`:
/// - 保证队列**有界**(上限见 [`REPORTS_QUEUE_MAX`]);
/// - 裁剪发生时记 **WARN** 并给出被丢弃条数 —— 运维必须知道"丢了上报",
/// 而不是让 Redis 在无界增长里悄悄 OOM。
async fn forward_to_redis(redis: &redis::aio::ConnectionManager, msg: &DeviceMessage) {
let json_str = match serde_json::to_string(msg) {
Ok(json) => json,
Err(e) => {
// 序列化失败说明报文结构异常,记 ERROR 并丢弃(不推送半截数据)
tracing::error!("[forward] 消息序列化失败 act={} err={}", msg.act, e);
return;
}
};
let mut conn = redis.clone();
// 一条 pipeline 完成:入队 → 取裁剪前长度 → 裁剪到上限 → 取裁剪后长度
// (4 条命令 1 次往返;`ignore()` 的两条不占返回位)
let mut pipe = redis::pipe();
pipe.cmd("LPUSH").arg("device:reports").arg(&json_str).ignore();
pipe.cmd("LLEN").arg("device:reports");
pipe.cmd("LTRIM")
.arg("device:reports")
.arg(0)
.arg(REPORTS_QUEUE_MAX - 1)
.ignore();
pipe.cmd("LLEN").arg("device:reports");
let result: Result<(i64, i64), redis::RedisError> = pipe.query_async(&mut conn).await;
match result {
Ok((len_before_trim, len_after_trim)) => {
if len_before_trim > len_after_trim {
// 越界裁剪:明确告知丢弃了多少条(这些上报已不可恢复,需要人工关注消费端状态)
tracing::warn!(
"[forward] device:reports 超过上限 {},已裁剪最旧的 {} 条上报(长度 {} → {})",
REPORTS_QUEUE_MAX,
len_before_trim - len_after_trim,
len_before_trim,
len_after_trim
);
} else {
tracing::debug!(
"[forward] 已推送 dev_id={} act={} queue_len={}",
msg.dev_id.as_deref().unwrap_or("?"),
msg.act,
len_after_trim
);
}
}
Err(e) => tracing::error!("[forward] Redis LPUSH/LTRIM 失败: {}", e),
}
}