12451 999885c38f feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复
协议(唯一权威文档:docs/柜控板固件对接文档.md)
- 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc
- dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除)
- id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI
- msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报
- 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login

平台侧实现
- 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status
- 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库)
- 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除
- 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs)
- 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单

修复(均为部署后才暴露的问题)
- 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是
  "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒
- sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上)
- 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic)
- 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换

部署
- 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向)
- 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测)
- 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、
  并用本地 .env 覆盖线上 .env)

质量
- 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约)
- 两服务 cargo check 零错误零告警;web tsc --noEmit 通过
- test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:41:09 +08:00

109 lines
4.2 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.

//! 设备日志保留策略协程
//!
//! 业务背景:`device_logs` 是**只增不减**的表 —— `status_post` 每台柜子每 60 秒写一条,
//! 加上 `bat_in`/`bat_out`/停电/回执等事件,量级随时间线性增长且**原本没有任何清理策略**,
//! 长期会把 RDS 撑满(磁盘告警、备份变慢、查询变慢)。
//!
//! 本协程按**保留天数**定期分批删除过期日志:
//! - **分批 `DELETE ... LIMIT`**:避免一条大删除造成长事务、锁表、主从延迟;
//! - 单轮有**最大批数上限**:防止积压过多时一轮跑太久;
//! - 所有异常只记日志,**绝不让循环退出**(清理是运维保障,不能反过来拖垮服务)。
//!
//! 依赖索引 `device_logs(created_at)`(由 `db::migrate` 幂等创建)—— 否则每次删除都会全表扫描。
use std::time::Duration;
use sqlx::MySqlPool;
/// 日志保留天数:超过该天数的 `device_logs` 会被清理。
///
/// 取 90 天的理由:设备日志主要服务于"最近出了什么问题"的排障,
/// 3 个月足够回溯;再久的数据排障价值低,但存储成本持续累积。
const RETENTION_DAYS: i64 = 90;
/// 单批删除行数:控制单条 SQL 的影响行数,避免长事务
const BATCH_LIMIT: i64 = 5000;
/// 单轮最多删多少批:积压很多时一轮也不会跑太久,剩下的等下一轮
const MAX_BATCHES_PER_ROUND: usize = 20;
/// 执行间隔:6 小时一次即可(保留策略对时效性不敏感,低频更省资源)
const SCAN_INTERVAL_SECS: u64 = 6 * 3600;
/// 启动设备日志保留协程(由 main.rs 以 tokio::spawn 启动,常驻运行)
pub async fn start(pool: MySqlPool) {
tracing::info!(
"[log_retention] 启动设备日志清理,保留 {} 天,执行间隔 {}s,单批 {} 行",
RETENTION_DAYS,
SCAN_INTERVAL_SECS,
BATCH_LIMIT
);
loop {
// 单轮失败不退出:记 error 后等下一个周期继续
if let Err(e) = purge_once(&pool).await {
tracing::error!("[log_retention] 本轮清理失败,等待下轮重试: {}", e);
}
tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await;
}
}
/// 单轮清理:分批删除 `created_at` 早于 `NOW() - RETENTION_DAYS` 的日志。
///
/// 返回删掉的总行数(便于日志与测试断言);SQL 失败向上抛,由调用方记录并继续循环。
async fn purge_once(pool: &MySqlPool) -> Result<u64, sqlx::Error> {
let mut total: u64 = 0;
for _ in 0..MAX_BATCHES_PER_ROUND {
// 阈值交给 MySQL 计算(created_at 也是 MySQL 写的),避免本地时区换算偏差;
// 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。
let affected = sqlx::query(
"DELETE FROM device_logs \
WHERE created_at < TIMESTAMPADD(DAY, ?, NOW()) \
LIMIT ?",
)
.bind(-RETENTION_DAYS)
.bind(BATCH_LIMIT)
.execute(pool)
.await?
.rows_affected();
total += affected;
// 本批不足一批 → 已删完,提前结束
if affected < BATCH_LIMIT as u64 {
break;
}
// 批间短暂让出,避免长时间占满数据库连接
tokio::time::sleep(Duration::from_millis(200)).await;
}
if total > 0 {
tracing::info!(
"[log_retention] 已清理过期设备日志 {} 行(保留 {} 天)",
total,
RETENTION_DAYS
);
} else {
tracing::debug!("[log_retention] 无过期设备日志需要清理");
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
/// 保留策略的常量约束:必须是正数、批大小与批数上限在合理区间,
/// 避免误改成 0 天(清空全部)或超大值(一次锁表)
#[test]
fn test_retention_constants_are_sane() {
assert!(RETENTION_DAYS > 0, "保留天数必须为正,否则会清空全部日志");
assert!(BATCH_LIMIT > 0 && BATCH_LIMIT <= 50_000, "单批行数需在合理区间");
assert!(MAX_BATCHES_PER_ROUND > 0, "每轮至少允许一批");
assert!(SCAN_INTERVAL_SECS >= 60, "清理间隔不应过于频繁");
}
}