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 仓体清零、唯一索引在位
This commit is contained in:
12451 2026-09-20 01:21:17 +08:00
parent 6b562ae286
commit db1264953a
13 changed files with 1299 additions and 241 deletions

178
deploy/backup-db.sh Normal file
View File

@ -0,0 +1,178 @@
#!/usr/bin/env bash
#
# PMS 数据库每日备份脚本(仓库源文件;部署位置 /usr/local/bin/pms-backup-db.sh)
#
# 背景(2026-09 运维审查):服务器上此前**没有任何数据库备份作业**,
# `crontab -l` 只有 acme.sh,/etc/cron.daily|weekly 为空,全盘无 mysqldump/xtrabackup 痕迹,
# 恢复能力实际为 0(完全依赖阿里云 RDS 自动备份,且该策略无法从 SQL 读取、无仓库记载)。
# 本脚本 + /etc/cron.d/pms-backup 补上这一环。
#
# 设计要点:
# 1. 备份 pms(生产)与 pms_dev(测试)两个库;--single-transaction 一致性快照,不锁表;
# 2. --routines --events --triggers 显式带上:本项目当前只有生成列(mysqldump 自动处理),
# 无存储过程/触发器/事件,但将来一旦引入必须能一并恢复,故现在就带上防漏;
# 3. --default-character-set=utf8mb4 与库字符集一致,避免中文/emoji 变问号;
# 4. 单库 dump(**不带 --databases**):dump 内不含 CREATE DATABASE / USE,
# 因此可以安全地恢复到任意临时库做演练(`zcat x.sql.gz | mysql pms_restore_check`),
# 绝不会因为 dump 里的 USE `pms` 而误写生产库;
# 5. --set-gtid-purged=OFF:RDS 开了 GTID 时,dump 会带 SET @@GLOBAL.GTID_PURGED,
# 恢复到非空实例会直接报 1840 失败;关掉它保证 dump 可移植;
# 6. 密码**不落脚本、不落 cron 行**:统一走 /root/.my.cnf(权限 600,--defaults-extra-file);
# 7. 落盘 /data/backup/mysql/<YYYY-MM-DD>/<db>_<日期>.sql.gz,
# 每个文件记录大小与精确行数(SUM(COUNT(*)))到日志,并做 gzip -t 完整性校验;
# 8. 保留最近 7 天:find ... -mtime +7 -delete(先删过期文件,再删变空的日期目录);
# 9. 只有**全部库都备份成功**才执行保留策略清理,避免"新备份失败 + 删掉旧备份"= 无备份;
# 10. flock 防止 cron 与手工执行重叠。
#
# 用法:
# /usr/local/bin/pms-backup-db.sh # 正常备份(cron 每日 03:30 调用)
# tail -n 100 /var/log/pms-backup-db.log # 查看备份日志
#
set -euo pipefail
# ---------- 可调参数 ----------
BACKUP_ROOT="/data/backup/mysql"
RETENTION_DAYS=7
DATABASES=("pms" "pms_dev")
MYSQL_HOST="127.0.0.1"
MYSQL_PORT="3306"
DEFAULTS_FILE="/root/.my.cnf"
LOG_FILE="/var/log/pms-backup-db.log"
LOCK_FILE="/var/lock/pms-backup-db.lock"
# ⚠️ --defaults-extra-file 必须是 mysqldump/mysql 的**第一个**参数,否则客户端报错退出
MYSQL_OPTS=(--defaults-extra-file="$DEFAULTS_FILE" -h "$MYSQL_HOST" -P "$MYSQL_PORT")
DUMP_OPTS=(
--single-transaction
--routines
--events
--triggers
--default-character-set=utf8mb4
--no-tablespaces
--set-gtid-purged=OFF
)
DATE_DIR="$(date +%Y-%m-%d)"
DEST_DIR="$BACKUP_ROOT/$DATE_DIR"
# ---------- 日志 ----------
# 同时写日志文件与标准输出;cron 行把标准输出丢到 /dev/null,避免日志重复
log() {
local line
line="$(date '+%Y-%m-%d %H:%M:%S') $*"
printf '%s\n' "$line" >> "$LOG_FILE"
printf '%s\n' "$line"
}
# ---------- 辅助查询 ----------
# 统计某库的基础表数量(失败返回 unknown,不中断备份)
count_tables() {
local db="$1" out
if ! out="$(mysql "${MYSQL_OPTS[@]}" -N -B -e \
"SELECT COUNT(*) FROM information_schema.tables \
WHERE table_schema='${db}' AND table_type='BASE TABLE'" 2>&1)"; then
log "[warn] 查询 ${db} 表数量失败: ${out}"
echo "unknown"
return 0
fi
echo "${out:-0}"
}
# 统计某库所有基础表的**精确**总行数(information_schema.TABLE_ROWS 是估算值,不能用)
#
# ⚠️ mysql 客户端**没有指定默认库**(凭据文件里也不给库),因此每张表必须写成
# `\`库名\`.\`表名\``;只写 `\`表名\`` 会报 1046 No database selected —— 曾经的实现
# 把 stderr 丢进 /dev/null,结果每库都静默统计成 0 行。
exact_row_count() {
local db="$1" tables union_sql rows
if ! tables="$(mysql "${MYSQL_OPTS[@]}" -N -B -e \
"SELECT table_name FROM information_schema.tables \
WHERE table_schema='${db}' AND table_type='BASE TABLE'" 2>&1)"; then
log "[warn] 查询 ${db} 表清单失败: ${tables}"
echo "unknown"
return 0
fi
if [ -z "$tables" ]; then
echo 0
return 0
fi
union_sql=""
while IFS= read -r t; do
[ -n "$t" ] && union_sql+="SELECT COUNT(*) AS c FROM \`${db}\`.\`${t}\` UNION ALL "
done <<< "$tables"
if ! rows="$(mysql "${MYSQL_OPTS[@]}" -N -B -e \
"SELECT SUM(c) FROM (${union_sql%UNION ALL }) AS t" 2>&1)"; then
log "[warn] 统计 ${db} 行数失败: ${rows}"
echo "unknown"
return 0
fi
echo "${rows:-0}"
}
# ---------- 单库备份 ----------
# 备份 -> gzip 中间文件 -> 原子改名 -> 完整性校验 -> 记录大小/表数/行数
dump_database() {
local db="$1" out="$2" bytes size tables rows
if ! mysqldump "${MYSQL_OPTS[@]}" "${DUMP_OPTS[@]}" "$db" | gzip > "${out}.part"; then
log "[ERROR] ${db} 备份失败(mysqldump/gzip 非 0 退出)"
rm -f "${out}.part"
return 1
fi
mv -f "${out}.part" "$out"
if ! gzip -t "$out"; then
log "[ERROR] ${db} 备份 gzip 完整性校验失败: ${out}"
return 1
fi
bytes="$(stat -c %s "$out")"
size="$(du -h "$out" | cut -f1)"
tables="$(count_tables "$db")"
rows="$(exact_row_count "$db")"
log "[OK] ${db} -> ${out} 大小=${size}(${bytes} 字节) 表数=${tables} 总行数=${rows} gzip -t=OK"
return 0
}
# ---------- 保留策略:只留最近 RETENTION_DAYS 天 ----------
apply_retention() {
local removed kept
removed="$(find "$BACKUP_ROOT" -mindepth 2 -maxdepth 2 -type f -name '*.sql.gz' \
-mtime +"${RETENTION_DAYS}" -print -delete | wc -l)"
# 删掉因文件被清理而变空的日期目录
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -empty -print -delete >> "$LOG_FILE"
kept="$(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d | sort | tr '\n' ' ')"
log "[retention] 已删除 ${RETENTION_DAYS} 天前的备份文件 ${removed} 个;当前保留日期目录: ${kept}"
}
# ---------- 主流程 ----------
main() {
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "[skip] 已有备份任务在运行,本次跳过"
exit 0
fi
if [ ! -r "$DEFAULTS_FILE" ]; then
log "[FATAL] 凭据文件 ${DEFAULTS_FILE} 不存在或不可读(应为 600 权限)"
exit 1
fi
mkdir -p "$DEST_DIR"
log "[start] 备份开始,目标目录 ${DEST_DIR},库: ${DATABASES[*]}"
local failed=0 db
for db in "${DATABASES[@]}"; do
dump_database "$db" "$DEST_DIR/${db}_${DATE_DIR}.sql.gz" || failed=1
done
if [ "$failed" -eq 0 ]; then
apply_retention
log "[done] 全部备份成功"
else
# 关键顺序:备份失败时**不能**执行清理,否则可能出现"新备份没有、旧备份被删"
log "[done] 存在失败的备份,已跳过保留策略清理(避免误删仅存可用备份)"
fi
exit "$failed"
}
main "$@"

View File

@ -10,6 +10,8 @@ ExecStart=/data/pms/api-server/pms-api
EnvironmentFile=/data/pms/api-server/.env EnvironmentFile=/data/pms/api-server/.env
Restart=always Restart=always
RestartSec=5 RestartSec=5
# 提高文件描述符上限:默认软限制 1024 在大规模设备接入时会先耗尽
LimitNOFILE=65535
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -10,6 +10,8 @@ ExecStart=/data/pms_dev/api-server/pms-api
EnvironmentFile=/data/pms_dev/api-server/.env EnvironmentFile=/data/pms_dev/api-server/.env
Restart=always Restart=always
RestartSec=5 RestartSec=5
# 提高文件描述符上限:默认软限制 1024 在大规模设备接入时会先耗尽
LimitNOFILE=65535
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

18
deploy/pms-backup.cron Normal file
View File

@ -0,0 +1,18 @@
# PMS 数据库每日备份 — cron 定义(仓库源文件;部署位置 /etc/cron.d/pms-backup)
#
# 安装:
# install -m 0644 deploy/pms-backup.cron /etc/cron.d/pms-backup
# install -m 0755 deploy/backup-db.sh /usr/local/bin/pms-backup-db.sh
# 验证:cat /etc/cron.d/pms-backup && /usr/local/bin/pms-backup-db.sh
#
# 说明:
# * /etc/cron.d/ 的文件必须带「用户」字段(这里的 root),且不能 group/other 可写;
# * 密码不在本行、也不在脚本里 —— 走 /root/.my.cnf(600);
# * 脚本自身把明细写 /var/log/pms-backup-db.log;cron 这里只兜住脚本意外崩溃的 stderr,
# 避免与脚本日志重复。
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=""
# 每日 03:30 备份 pms(生产) + pms_dev(测试),保留最近 7 天
30 3 * * * root /usr/local/bin/pms-backup-db.sh >/dev/null 2>>/var/log/pms-backup-db.cron.log

View File

@ -10,6 +10,8 @@ ExecStart=/data/pms/device-server/pms-device-server
EnvironmentFile=/data/pms/device-server/.env EnvironmentFile=/data/pms/device-server/.env
Restart=always Restart=always
RestartSec=5 RestartSec=5
# 提高文件描述符上限:默认软限制 1024 ⇒ 约 1000 台柜子就 accept() 失败
LimitNOFILE=65535
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -10,6 +10,8 @@ ExecStart=/data/pms_dev/device-server/device-server
EnvironmentFile=/data/pms_dev/device-server/.env EnvironmentFile=/data/pms_dev/device-server/.env
Restart=always Restart=always
RestartSec=5 RestartSec=5
# 提高文件描述符上限:默认软限制 1024 ⇒ 约 1000 台柜子就 accept() 失败
LimitNOFILE=65535
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -11,79 +11,140 @@
## 统一端点清单 ## 统一端点清单
> **唯一事实来源**:`software/api-server/src/routes/mod.rs` 的 `.route(...)` 注册清单。
> 本表**一行 = 一次 `.route(...)` 注册**,共 **79** 行,与
> `Select-String -Path software\api-server\src\routes\mod.rs -Pattern '\.route\(' | Measure-Object`
> 得到的 **79** 条完全一致。同一次注册里链式绑定多个方法时,"方法"列列出全部方法(如 `GET / POST`),
> 权限列按方法分别标注。
>
> 权限码**一律取自 `db/migrate.rs::seed_permissions` 的种子表**(共 31 个权限码)。
> 处理器内部没有调用 `check_permission` 的接口,如实标注为**"仅需登录"**(只过认证中间件)
> 或**"免认证"**(公开路由),不臆造权限码。
>
> ⚠️ `mod.rs` 里还有一条 `.nest_service("/uploads", ServeDir::new("uploads"))`(静态头像目录,**免认证**),
> 它不是 `.route(...)` 注册,故不计入上表的 79 条。
| 方法 | 路径 | 说明 | 权限 | | 方法 | 路径 | 说明 | 权限 |
|------|------|------|------| |------|------|------|------|
| POST | `/api/auth/login` | 登录 | 免认证 | | POST | `/api/auth/logout` | 登出 | 仅需登录 |
| GET | `/api/auth/logout` | 登出 | 需认证 | | GET | `/api/auth/me` | 当前用户信息 | 仅需登录 |
| GET | `/api/auth/me` | 当前用户信息 | 需认证 | | PUT | `/api/auth/avatar` | 更新头像 URL | 仅需登录 |
| GET | `/api/dashboard` | 仪表盘数据 | 需认证 | | POST | `/api/auth/avatar/upload` | 上传头像文件(multipart) | 仅需登录 |
| GET | `/api/cabinets` | 设备列表(含 RSSI) | `device:view` | | POST | `/api/devices/{dev_id}/command` | 下发设备指令 | `device:operate` |
| GET | `/api/devices/stats` | 设备服务节点统计 | `device:view` |
| POST | `/api/nodes/register` | 设备服务节点注册(device-server 调用) | 仅需登录 |
| POST | `/api/nodes/{node_id}/heartbeat` | 节点心跳 | 仅需登录 |
| POST | `/api/nodes/{node_id}/deregister` | 节点注销 | 仅需登录 |
| GET | `/api/nodes` | 节点列表 | 仅需登录 |
| GET / POST | `/api/roles` | 角色列表 / 创建角色 | GET 仅需登录;POST `role:create` |
| PUT / DELETE | `/api/roles/{id}` | 更新 / 删除角色 | `role:edit` / `role:delete` |
| PUT | `/api/roles/{id}/permissions` | 角色赋权 | `role:edit` |
| GET | `/api/permissions` | 权限码列表 | 仅需登录 |
| GET | `/api/users/{id}/permissions` | 用户权限(角色合并结果) | `user:view` |
| PUT | `/api/users/{id}/roles` | 分配角色 | `user:edit` |
| PUT / GET | `/api/users/{id}/scopes` | 设置 / 查询用户数据范围 | PUT `user:edit`;GET `user:view` |
| GET / POST | `/api/organizations` | 组织列表 / 创建组织 | `org:view` / `org:create` |
| PUT / DELETE | `/api/organizations/{id}` | 更新 / 删除组织 | `org:edit` / `org:delete` |
| PUT | `/api/organizations/{id}/status` | 禁用 / 启用组织 | 仅总管理员(代码内 `role_level=2` 判断,**非权限码**) |
| GET / POST | `/api/organizations/{org_id}/projects` | 项目列表 / 创建项目 | `project:view` / `project:create` |
| PUT / DELETE | `/api/projects/{id}` | 更新 / 删除项目 | `project:edit` / `project:delete` |
| GET | `/api/projects/{project_id}/cabinets` | 项目下设备列表 | `device:view` |
| POST | `/api/cabinets` | 批量创建柜子 | `device:create` | | POST | `/api/cabinets` | 批量创建柜子 | `device:create` |
| GET | `/api/cabinets/:id` | 设备详情(含仓板+仓体) | `device:view` | | PUT / DELETE / GET | `/api/cabinets/{id}` | 更新 / 删除 / 设备详情(含仓板+仓体) | `device:edit` / `device:delete` / `device:view` |
| PUT | `/api/cabinets/:id` | 更新柜子 | `device:edit` | | PUT | `/api/cabinets/{id}/status` | 启用 / 停用柜子 | `device:edit` |
| DELETE | `/api/cabinets/:id` | 删除柜子 | `device:delete` | | PUT | `/api/cabinets/{id}/work-mode` | 设置充电工作模式(0=平台指令,1=自动) | `device:edit` |
| POST | `/api/cabinets/:id/regenerate-auth` | 重置安全码(仅总管理员) | `device:auth_str:reset` | | POST | `/api/cabinets/{id}/regenerate-auth` | 重置安全码 | `device:auth_str:reset`(仅总管理员) |
| POST | `/api/cabinets/:id/bind` | 绑定设备到项目 | `device:edit` | | POST | `/api/cabinets/{id}/open` | 解锁柜门(柜级,管理端设备详情页用) | `charge:open_door` |
| POST | `/api/cabinets/:id/replace-imei` | 更换 IMEI | `device:edit` |
| GET | `/api/cabinets/abstract/:abstract_id` | 扫码查设备(免认证) | 免认证 |
| GET | `/api/cabinets/scan/:abstract_id` | 扫码验证(需认证) | 需认证 |
| GET | `/api/cabinets/filter` | 设备列表(管理端) | 需认证 |
| GET | `/api/cabinets/options` | 设备下拉选项 | 需认证 |
| GET | `/api/cabin-boards/options` | 仓控板下拉选项 | 需认证 |
| GET | `/api/compartments/options` | 仓体下拉选项 | 需认证 |
| GET | `/api/cabinets/realtime-status` | 设备实时状态 | 需认证 |
| GET | `/api/compartments/:id` | 仓体/通道详情 | 需认证 |
| POST | `/api/charge/start` | 开始充电 | `charge:start` |
| POST | `/api/charge/stop` | 停止充电 | `charge:stop` |
| POST | `/api/door/open` | 开门(**柜级**:整柜共用一扇门,只解锁需人拉开) | `charge:open_door` |
| POST | `/api/cabinets/{id}/open` | 解锁柜门(柜级,同上;管理端设备详情页用) | `charge:open_door` |
| POST | `/api/cabinets/{id}/pow-on` | 接触器合闸(柜级) | `device:operate` | | POST | `/api/cabinets/{id}/pow-on` | 接触器合闸(柜级) | `device:operate` |
| POST | `/api/cabinets/{id}/pow-off` | 接触器分闸(柜级) | `device:operate` | | POST | `/api/cabinets/{id}/pow-off` | 接触器分闸(柜级) | `device:operate` |
| POST | `/api/cabinets/{id}/reset` | 重启整柜(柜级) | `device:operate` | | POST | `/api/cabinets/{id}/reset` | 重启整柜(柜级) | `device:operate` |
| POST | `/api/cabin-boards/{id}/reset` | 重启单块仓控板 | `device:operate` | | POST | `/api/cabin-boards/{id}/reset` | 重启单块仓控板 | `device:operate` |
| GET | `/api/organizations` | 组织列表 | `org:view` | | POST | `/api/cabinets/{id}/bind` | 绑定设备到项目 | `device:edit` |
| POST | `/api/organizations` | 创建组织 | `org:create` | | POST | `/api/cabinets/{id}/replace-imei` | 更换 IMEI | `device:edit` |
| PUT | `/api/organizations/:id` | 更新组织 | `org:edit` | | GET | `/api/organization-tree` | 组织树(含项目/设备) | `device:view` |
| DELETE | `/api/organizations/:id` | 删除组织 | `org:delete` | | GET | `/api/cabinets/filter` | 设备列表(管理端,**响应为 `{total,data}`**,见下) | `device:view` |
| GET | `/api/organizations/:org_id/projects` | 项目列表 | 需认证 | | GET | `/api/cabinets/realtime-status` | 单设备实时状态(板级+通道级) | `device:view` |
| POST | `/api/organizations/:org_id/projects` | 创建项目 | `project:create` | | GET | `/api/cabinets/options` | 设备下拉选项 | `device:view` |
| PUT | `/api/projects/:id` | 更新项目 | `project:edit` | | GET | `/api/cabin-boards/options` | 仓控板下拉选项 | `device:view` |
| DELETE | `/api/projects/:id` | 删除项目 | `project:delete` | | GET | `/api/compartments/options` | 仓体下拉选项 | `device:view` |
| GET | `/api/organization-tree` | 组织树(含项目/设备) | 需认证 | | GET | `/api/charge-records` | 充电记录列表 | `charge_record:view` |
| GET | `/api/charge-records` | 充电记录 | `charge_record:view` | | GET | `/api/charge-records/export` | 导出充电记录(异步任务) | `charge_record:export` |
| GET | `/api/charge-records/export` | 导出充电记录 | `charge_record:export` |
| GET | `/api/charge-orders` | 充电订单列表 | `charge_record:view` | | GET | `/api/charge-orders` | 充电订单列表 | `charge_record:view` |
| GET | `/api/batteries` | 电池台账列表 | `device:view` | | GET | `/api/batteries` | 电池台账列表 | `device:view` |
| GET | `/api/device-logs` | 设备日志 | `device_log:view` |
| GET | `/api/downloads` | 下载中心列表 | `charge_record:export` |
| GET | `/api/downloads/{id}` | 下载任务详情 | `charge_record:export` |
| GET | `/api/downloads/{id}/file` | 下载文件 | `charge_record:export` |
| POST | `/api/downloads/cleanup` | 清理过期下载 | `charge_record:export` |
| GET | `/api/energy-stats/summary` | 能耗概览 | `energy:view` | | GET | `/api/energy-stats/summary` | 能耗概览 | `energy:view` |
| GET | `/api/energy-stats/cabinets` | 柜子能耗排行 | `energy:view` | | GET | `/api/energy-stats/cabinets` | 柜子能耗排行 | `energy:view` |
| GET | `/api/energy-stats/projects` | 项目能耗统计 | `energy:view` | | GET | `/api/energy-stats/projects` | 项目能耗统计 | `energy:view` |
| GET | `/api/energy-stats/trend` | 能耗趋势 | `energy:view` | | GET | `/api/energy-stats/trend` | 能耗趋势 | `energy:view` |
| GET | `/api/energy-stats/export` | 导出能耗数据 | `energy:view` | | GET | `/api/energy-stats/export` | 导出能耗数据(异步任务) | **`energy:export`** |
| GET | `/api/device-logs` | 设备日志 | `device:view` | | GET / POST | `/api/users` | 用户列表 / 创建用户 | `user:view` / `user:create` |
| GET | `/api/operation-logs` | 操作日志 | `system:log` | | PUT / DELETE | `/api/users/{id}` | 更新 / 删除用户 | `user:edit` / `user:delete` |
| GET | `/api/users` | 用户列表 | `user:view` | | PUT | `/api/users/{id}/reset-password` | 重置密码 | `user:edit` |
| POST | `/api/users` | 创建用户 | `user:create` | | PUT | `/api/users/{id}/status` | 设置用户状态 | `user:edit` |
| PUT | `/api/users/:id` | 更新用户 | `user:edit` | | GET | `/api/operation-logs` | 操作日志 | **`operation_log:view`** |
| DELETE | `/api/users/:id` | 删除用户 | `user:delete` | | GET | `/api/dashboard` | 小程序首页仪表盘 | 仅需登录(内部按角色做组织隔离) |
| PUT | `/api/users/:id/reset-password` | 重置密码 | `user:edit` | | GET | `/api/cabinets` | 设备列表(含实时 RSSI) | `device:view` |
| PUT | `/api/users/:id/status` | 设置用户状态 | `user:edit` | | GET | `/api/compartments/{id}` | 仓体/通道详情(实时 BMS + 充电记录) | `device:view` |
| GET | `/api/users/:id/permissions` | 用户权限 | `user:view` | | POST | `/api/charge/start` | 开始充电 | `charge:start` |
| PUT | `/api/users/:id/roles` | 分配角色 | `user:edit` | | POST | `/api/charge/stop` | 停止充电 | `charge:stop` |
| GET | `/api/users/:id/scopes` | 用户数据范围 | `user:view` | | POST | `/api/door/open` | 开门(**柜级**:整柜共用一扇门,只解锁需人拉开) | `charge:open_door` |
| PUT | `/api/users/:id/scopes` | 设置数据范围 | `user:edit` | | GET | `/api/cabinets/scan/{abstract_id}` | 扫码验证 | 仅需登录(组织归属校验,**非权限码**) |
| GET | `/api/roles` | 角色列表 | `role:view` | | POST / GET | `/api/feedback` | 提交问题反馈 / 反馈列表 | POST 仅需登录;GET `operation_log:view` |
| POST | `/api/roles` | 创建角色 | `role:create` | | PUT | `/api/feedback/{id}` | 更新反馈处理状态 | `operation_log:view` |
| PUT | `/api/roles/:id` | 更新角色 | `role:edit` | | POST | `/api/feedback/upload` | 上传反馈图片(multipart) | 仅需登录 |
| DELETE | `/api/roles/:id` | 删除角色 | `role:delete` |
| PUT | `/api/roles/:id/permissions` | 角色赋权 | `role:edit` |
| GET | `/api/permissions` | 权限码列表 | `role:view` |
| GET | `/api/downloads` | 下载中心 | 需认证 |
| GET | `/api/downloads/:id` | 下载详情 | 需认证 |
| GET | `/api/downloads/:id/file` | 下载文件 | 需认证 |
| POST | `/api/downloads/cleanup` | 清理过期下载 | 需认证 |
| POST | `/api/devices/:dev_id/command` | 下发设备指令 | `device:operate` |
| GET | `/api/devices/stats` | 设备服务统计 | 需认证 |
| GET | `/api/health` | 健康检查 | 免认证 | | GET | `/api/health` | 健康检查 | 免认证 |
| GET | `/api/auth/challenge` | 获取登录挑战码 | 免认证 |
| POST | `/api/auth/login` | 登录 | 免认证 |
| POST | `/api/auth/wx-login` | 微信小程序手机号快速登录 | 免认证 |
| GET | `/api/cabinets/abstract/{abstract_id}` | 扫码查设备(二维码入口) | 免认证 |
| GET / POST | `/api/wechat/callback` | 微信服务器校验 / 消息回调 | 免认证 |
| GET | `/api/maps/suggestion` | 腾讯地图输入提示代理 | 免认证 |
| POST | `/api/maps/geocoder` | 腾讯地图逆地址解析代理 | 免认证 |
| GET | `/api/maps/place-search` | 腾讯地图地点搜索代理 | 免认证 |
### 列表响应包装(与实现逐条核对)
`data` 字段的结构**并非全站统一**,按实现如实记录如下:
| 接口 | `data` 结构 | 备注 |
|------|-------------|------|
| `GET /api/charge-records` | `{list,total,page,page_size}` | 标准分页 |
| `GET /api/charge-orders` | `{list,total,page,page_size}` | 标准分页 |
| `GET /api/batteries` | `{list,total,page,page_size}` | 标准分页 |
| `GET /api/device-logs` | `{list,total,page,page_size}` | 标准分页 |
| `GET /api/downloads` | `{list,total,page,page_size}` | 标准分页 |
| `GET /api/feedback` | `{list,total,page,page_size}` | 标准分页 |
| `GET /api/users` | `{list,total}` | ⚠️ **没有 `page` / `page_size`** |
| `GET /api/operation-logs` | `{list,total}` | ⚠️ **没有 `page` / `page_size`** |
| `GET /api/cabinets/filter` | `{total,data}` | ⚠️ **与其它列表不同**:列表键名是 `data` 而不是 `list`,且无 `page` / `page_size` |
| `GET /api/energy-stats/cabinets` | `[...]` 数组 | ⚠️ `data` 直接是数组,无分页包装 |
| `GET /api/energy-stats/projects` | `[...]` 数组 | ⚠️ `data` 直接是数组,无分页包装 |
| `GET /api/energy-stats/trend` | `{granularity,list}` | ⚠️ 无 `total` / `page` / `page_size` |
| `GET /api/roles`、`GET /api/permissions` | `[...]` 数组 | ⚠️ `data` 直接是数组 |
| `GET /api/organization-tree` | `[...]` 树形数组 | 根节点数组 |
> ⚠️ **内部接口例外**:`/api/nodes/*` 与 `/api/devices/*` 直接返回 `{"suc":1}` /
> `{"nodes":[...],"count":N}` / `{"node_count":N}`,**不带** `{code,data,message}` 外层 —— 它们面向
> device-server 内部调用,不是给前端消费的标准响应。
### 关键数值字段单位(抽查结论)
| 字段 | 出现位置 | 单位 | 前端显示 |
|------|----------|------|----------|
| `energy` | 充电记录 / 充电订单 / 能耗统计 | **Wh**(整数) | `/ 1000` → kWh |
| `today_energy` / `month_energy` | 能耗概览 | **Wh**(整数) | `/ 1000` → kWh |
| `max_temperature` | 充电记录 | **K×100**(整数) | `/ 100 - 273.15` → °C |
| `soc` / `start_soc` / `end_soc` | 电池台账 / 充电订单 / 通道状态 | **‰**(整数,0–10000) | `/ 10` → % |
| `voltage` / `cell_voltages[]` | 仓体详情 / BMS | **mV** | `/ 1000` → V |
| `current` | 仓体详情 / BMS | **mA** | `/ 1000` → A |
| `rssi` | 设备列表 / 通道状态 | **0–99 信号等级**(不是 dBm) | 直接显示 |
| `estimated_cost` | 能耗概览 | **元**(`f64`,业务计算值) | 不转换 |
> 完整单位规范见文末 [数据单位规范](#数据单位规范);后端**不做任何单位换算**,一律返回原始整数值。
--- ---
@ -120,6 +181,55 @@
**角色等级:** 0=操作员,1=企业管理员,2=总管理员 **角色等级:** 0=操作员,1=企业管理员,2=总管理员
> ⚠️ 登录**必须**带滑块验证码:先调 `GET /api/auth/challenge` 拿 `challenge_id`,拖动对齐后把
> `challenge_id` + `captcha_x`(偏移量)一并提交给 `POST /api/auth/login`;
> 缺 `challenge_id` 直接 400「缺少登录验证参数」,偏移量校验不过返回 400「验证码错误,请刷新页面重试」。
### GET /api/auth/challenge
获取登录滑块验证码(**免认证**)。返回带缺口的背景图与拼图块图(data URL)。
```json
{
"code": 0,
"data": {
"challenge_id": "b1f0...",
"bg_image": "data:image/png;base64,...",
"piece_image": "data:image/png;base64,..."
},
"message": "ok"
}
```
### POST /api/auth/wx-login
微信小程序手机号快速登录(**免认证**)。前端先 `wx.login()` 拿 `code`、`getPhoneNumber` 拿 `phone_code`;
后端向微信换取 `openid` + 手机号,按**手机号**匹配用户后签发 JWT,并把 `openid` 写回 `users.openid`。
**请求:** `{ "code": "081...", "phone_code": "e3f..." }`
**响应:** 与 `POST /api/auth/login` 完全同构(`{token, user:{id,name,phone,role_level,organization_id,permissions}}`),
`message` 为「登录成功」;账户被禁用返回 403「账户已被禁用」。
### PUT /api/auth/avatar
更新当前用户头像 URL(仅需登录)。**请求:** `{ "avatar": "https://.../a.png" }`;
**响应:** `{ "code": 0, "data": { "avatar": "https://.../a.png" }, "message": "ok" }`。
### POST /api/auth/avatar/upload
上传头像文件(仅需登录,`multipart/form-data`)。
- 大小上限 **2MB**,超限 400「头像文件不能超过 2MB」;内容为空 400「上传内容为空」;
- 扩展名白名单 `.jpg/.jpeg/.png/.gif/.webp/.svg`,**不在白名单内一律回落为 `.jpg`**(不是报错);
- 无扩展名时默认 `.jpg`。
**响应:** `{ "code": 0, "data": { "avatar": "/uploads/avatars/<uuid>.jpg" }, "message": "ok" }`
(文件落盘为 `uploads/avatars/<uuid><ext>`,同时写回 `users.avatar`)
> ⚠️ 静态文件目录由 `mod.rs` 的 `.nest_service("/uploads", ServeDir::new("uploads"))` 提供,**免认证**。
> 代码里该处理器的文档注释误写成 `GET`,**实际注册的是 `POST`**(以 `mod.rs` 为准)。
--- ---
## 小程序专用接口 ## 小程序专用接口
@ -170,7 +280,7 @@
} }
``` ```
### GET /api/cabinets/abstract/:abstract_id ### GET /api/cabinets/abstract/{abstract_id}
扫码入口,免认证。通过二维码中的抽象ID查找设备。 扫码入口,免认证。通过二维码中的抽象ID查找设备。
@ -199,7 +309,7 @@
} }
``` ```
### GET /api/compartments/:id ### GET /api/compartments/{id}
仓体/通道详情,含实时 BMS 数据和充电记录。 仓体/通道详情,含实时 BMS 数据和充电记录。
@ -228,9 +338,21 @@
> 权限:`charge:open_door` + 组织隔离 > 权限:`charge:open_door` + 组织隔离
### GET /api/cabinets/scan/:abstract_id ### GET /api/cabinets/scan/{abstract_id}
扫码验证,需认证。验证当前用户是否有权访问该设备。 扫码验证,需认证。验证当前用户是否有权访问该设备(校验**组织归属**;无独立权限码)。
### 问题反馈
| 接口 | 说明 | 权限 |
|------|------|------|
| `POST /api/feedback` | 提交反馈(`content` 必填,≤2000 字,可带 `images`) | 仅需登录(任何已登录用户可提交) |
| `GET /api/feedback` | 反馈列表(`{list,total,page,page_size}`) | `operation_log:view` |
| `PUT /api/feedback/{id}` | 更新处理状态 | `operation_log:view` |
| `POST /api/feedback/upload` | 上传反馈图片(`multipart/form-data`) | 仅需登录 |
`POST /api/feedback` 响应:`{ "code": 0, "data": { "success": true }, "message": ... }`;
`POST /api/feedback/upload` 响应:`{ "code": 0, "data": { "url": "..." }, "message": "ok" }`。
--- ---
@ -285,6 +407,52 @@
**状态码:** 0=充电中,1=正常结束,2=异常中断,3=手动停止 **状态码:** 0=充电中,1=正常结束,2=异常中断,3=手动停止
**单位:** `energy` = **Wh**(整数,前端 `/ 1000` 显示 kWh);`max_temperature` = **K×100**(整数,`/ 100 - 273.15` 显示 °C)。
`start_time` / `end_time` 为字符串时间,非数值单位。
### GET /api/cabinets/filter
设备列表(管理端)。**响应包装与其它列表接口不同**:`data` 是 `{total,data}`,
列表键名是 `data` 而**不是** `list`,且**没有** `page` / `page_size`。
```json
{
"code": 0,
"data": {
"total": 2,
"data": [
{ "id": 1, "abstract_id": "10-00000000", "name": "太湖新城换电柜A", "status": 1, "online": 1, "rssi": 85 }
]
},
"message": "ok"
}
```
> ⚠️ 前端解析时不要套用 `{list,total,page,page_size}` 的通用分页逻辑。
### PUT /api/cabinets/{id}/work-mode
设置柜子充电工作模式。权限 `device:edit` + 组织归属校验。
**请求:** `{ "work_mode": "0" }`(**字符串**,前端契约)—— 白名单取值,`"0"`=平台指令模式(默认)、
`"1"`=自动充电模式;其它值(含 `"2"`/`"true"`)返回 400。
**响应:** `{ "code": 0, "data": { "success": true }, "message": "ok" }`
### PUT /api/cabinets/{id}/status
禁用 / 启用柜子。权限 `device:edit` + 组织归属校验,**且仅总管理员(`role_level=2`)可执行**,
否则 403「仅总管理员可禁用/启用柜子」。
**请求:** `{ "status": 2 }`;**响应:** `{ "code": 0, "data": null, "message": "ok" }`
### PUT /api/organizations/{id}/status
禁用 / 启用组织。**仅总管理员**(代码内 `role_level < 2` 判断,**不走权限码**);
禁用后该组织及其子组织的柜子全部不可用(指令链路拦截)。
**请求:** `{ "disabled": 1 }`(only `0`/`1`,其它值 400);**响应:** `{ "code": 0, "data": null, "message": "禁用成功" }`
### GET /api/charge-records/export ### GET /api/charge-records/export
异步导出充电记录 Excel。创建导出任务后自行去下载中心取文件。 异步导出充电记录 Excel。创建导出任务后自行去下载中心取文件。
@ -329,8 +497,8 @@
"battery_id": "BAT001", "battery_id": "BAT001",
"start_time": "2026-07-05 10:00:00", "start_time": "2026-07-05 10:00:00",
"end_time": "2026-07-05 12:00:00", "end_time": "2026-07-05 12:00:00",
"start_soc": 20, "start_soc": 200,
"end_soc": 95, "end_soc": 950,
"energy": 1200, "energy": 1200,
"reason": null, "reason": null,
"reason_text": "-", "reason_text": "-",
@ -349,6 +517,8 @@
**订单状态:** 0=充电中,1=正常结束,2=手动停止,3=异常终止,4=拔出结束 **订单状态:** 0=充电中,1=正常结束,2=手动停止,3=异常终止,4=拔出结束
**单位:** `energy` = **Wh**(整数);`start_soc` / `end_soc` = **‰**(整数,0–10000,前端 `/ 10` 显示 %)。
### GET /api/batteries ### GET /api/batteries
电池台账列表。记录每块电池的插入/拔出事件和当前状态。 电池台账列表。记录每块电池的插入/拔出事件和当前状态。
@ -375,7 +545,7 @@
"brand": "星恒", "brand": "星恒",
"model": "XH-48V", "model": "XH-48V",
"voltage": 52000, "voltage": 52000,
"soc": 45, "soc": 4500,
"status": 1, "status": 1,
"cabinet_id": 1, "cabinet_id": 1,
"compartment_id": 1, "compartment_id": 1,
@ -391,6 +561,9 @@
} }
``` ```
**单位:** `voltage` = **mV**(52000 = 52.000V);`soc` = **‰**(4500 = 45.00%,表字段注释即 `电量(‰)`)。
⚠️ 历史文档曾把示例写成 `"soc": 45`,那是按"百分比"写的**错误示例**,实现返回的是千分比。
### GET /api/energy-stats/summary ### GET /api/energy-stats/summary
能耗概览,按组织数据隔离返回今日/本月能耗数据。 能耗概览,按组织数据隔离返回今日/本月能耗数据。
@ -416,6 +589,45 @@
--- ---
## 免认证接口与内部接口
> 本节全部来自 `mod.rs::public_routes()`(**免认证**,不经过认证中间件)与节点管理接口。
### GET /api/health
健康检查(免认证)。
### GET / POST /api/wechat/callback
微信服务器回调(免认证):`GET` 做服务器地址校验(回显 `echostr`),`POST` 接收消息(明文 XML)。
Nginx 有独立的 `/api/wechat/` location 直连 API 服务,不经 `/api/pms/` 前缀重写。
### 腾讯地图代理(免认证,前端需要)
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/maps/suggestion` | GET | 输入提示(keyword 联想) |
| `/api/maps/geocoder` | POST | 逆地址解析(经纬度 → 地址) |
| `/api/maps/place-search` | GET | 地点搜索 |
> 代理的目的:把腾讯地图 Key 留在服务端,前端不暴露 Key。
### 设备服务节点与指令接口(响应不含标准外层)
| 接口 | 方法 | 权限 | 响应 |
|------|------|------|------|
| `/api/nodes/register` | POST | 仅需登录 | `{"suc":1}` |
| `/api/nodes/{node_id}/heartbeat` | POST | 仅需登录 | `{"suc":1}`;未注册 `{"suc":0,"err":"节点未注册"}` |
| `/api/nodes/{node_id}/deregister` | POST | 仅需登录 | `{"suc":1}` |
| `/api/nodes` | GET | 仅需登录 | `{"nodes":[...],"count":N}` |
| `/api/devices/stats` | GET | `device:view` | `{"node_count":N}` |
| `/api/devices/{dev_id}/command` | POST | `device:operate` | `{"suc":1}` |
> `nodes/*` 是 device-server 内部调用;`devices/*` 是管理端/后端调用。两者都**不返回**
> `{code,data,message}` 标准外层(见上表"内部接口例外")。
---
## 数据单位规范 ## 数据单位规范
**核心原则:所有数值字段使用整数存储,禁止浮点类型 (f32/f64/DECIMAL/FLOAT)** **核心原则:所有数值字段使用整数存储,禁止浮点类型 (f32/f64/DECIMAL/FLOAT)**
@ -755,8 +967,19 @@ device:{imei} # 设备级 Hash
## 权限码列表 ## 权限码列表
> 以 `software/api-server/src/db/migrate.rs::seed_permissions` 的**种子表为准**(下表与之对齐)。 > 以 `software/api-server/src/db/migrate.rs::seed_permissions` 的**种子表为准**(下表与之对齐,共 **31** 个权限码)。
> ⚠️ 历史上代码里曾误用一个**不存在**的权限码 `device:door:unlock`(种子里从来没有它)→ 导致开门对所有角色 403,**已修正为 `charge:open_door`**。 > ⚠️ 历史上代码里曾误用一个**不存在**的权限码 `device:door:unlock`(种子里从来没有它)→ 导致开门对所有角色 403,**已修正为 `charge:open_door`**。
> ⚠️ 同样地,**不存在** `system:log` 这个权限码(文档曾误写)——操作日志用的是 **`operation_log:view`**;
> 照 `system:log` 配角色会导致该角色**永远看不到操作日志**。
>
> **默认角色授权(`seed_roles`)**:
> - `super_admin`(总管理员):`grant_all_permissions` —— 种子表全部 31 个;
> - `org_admin`(企业管理员):24 个 —— `device:view/operate/create/edit`、`charge:start/stop/open_door`、
> `charge_record:view/export`、`device_log:view`、`energy:view/export`、`user:view/create/edit`、
> `operation_log:view`、`org:*`(4)、`project:*`(4);
> **不含** `device:delete`、`device:auth_str:reset`、`user:delete`、`role:*`;
> - `normal_user`(操作员):8 个 —— `device:view/operate`、`charge:start/stop/open_door`、
> `charge_record:view`、`device_log:view`、`energy:view`。
| 权限码 | 说明 | | 权限码 | 说明 |
|--------|------| |--------|------|
@ -770,11 +993,12 @@ device:{imei} # 设备级 Hash
| `charge:stop` | 停止充电 | | `charge:stop` | 停止充电 |
| `charge:open_door` | 开门(柜门解锁) | | `charge:open_door` | 开门(柜门解锁) |
| `charge_record:view` | 查看充电记录 | | `charge_record:view` | 查看充电记录 |
| `charge_record:export` | 导出充电记录 | | `charge_record:export` | 导出充电记录(**也用于下载中心 4 个接口**) |
| `device_log:view` | 查看设备日志 | | `device_log:view` | 查看设备日志 |
| `energy:view` | 查看能耗 | | `energy:view` | 查看能耗 |
| `energy:export` | 导出能耗 | | `energy:export` | 导出能耗 |
| `user:view` / `user:create` / `user:edit` / `user:delete` | 用户管理 | | `user:view` / `user:create` / `user:edit` / `user:delete` | 用户管理 |
| `role:view` / `role:create` / `role:edit` / `role:delete` | 角色管理 | | `role:view` / `role:create` / `role:edit` / `role:delete` | 角色管理 |
| `operation_log:view` | 查看操作日志 | | `operation_log:view` | 查看操作日志(**也用于问题反馈列表/处理状态 2 个接口**) |
| `org:view` / `org:create` / `org:edit` / `org:delete` | 组织管理 | | `org:view` / `org:create` / `org:edit` / `org:delete` | 组织管理 |
| `project:view` / `project:create` / `project:edit` / `project:delete` | 项目管理 |

View File

@ -4,6 +4,14 @@ use sqlx::MySqlPool;
/// 全量建表语句(幂等:全部 `CREATE TABLE IF NOT EXISTS`)。 /// 全量建表语句(幂等:全部 `CREATE TABLE IF NOT EXISTS`)。
/// 抽成常量是为了让 [`run`] 保持可读(此前 280 行的函数里有 233 行是这段 SQL)。 /// 抽成常量是为了让 [`run`] 保持可读(此前 280 行的函数里有 233 行是这段 SQL)。
///
/// ⚠️ **语句顺序即依赖顺序**:本常量由 [`run`] 按 `;` 逐条执行,MySQL 的
/// `FOREIGN KEY ... REFERENCES` 要求被引用表**必须已存在**(否则报 1824
/// `Failed to open the referenced table`)。因此每张表都必须排在所有引用它的表之前,
/// 顺序为:organizations → projects → cabinets → cabin_boards → compartments →
/// 其余业务表(含引用 compartments 的 charge_orders / charge_records)。
/// 调整顺序时只允许整块移动 CREATE TABLE,不得改动块内 DDL 文本,
/// 并有 `tests::test_schema_sql_table_order_has_forward_reference_returns_none` 守门。
const SCHEMA_SQL: &str = r#" const SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS organizations ( CREATE TABLE IF NOT EXISTS organizations (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
@ -39,6 +47,26 @@ const SCHEMA_SQL: &str = r#"
FOREIGN KEY (project_id) REFERENCES projects(id) FOREIGN KEY (project_id) REFERENCES projects(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabin_boards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
board_id TINYINT NOT NULL COMMENT '仓控板ID 1-6',
status TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS compartments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabin_board_id BIGINT NOT NULL,
channel_id TINYINT NOT NULL COMMENT '通道ID 1-6',
status TINYINT DEFAULT 0 COMMENT '0空闲 1充电中 2故障 3禁用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cabin_board_id) REFERENCES cabin_boards(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sim_change_logs ( CREATE TABLE IF NOT EXISTS sim_change_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
imei VARCHAR(15) NOT NULL COMMENT '设备IMEI', imei VARCHAR(15) NOT NULL COMMENT '设备IMEI',
@ -86,26 +114,6 @@ const SCHEMA_SQL: &str = r#"
INDEX idx_compartment (compartment_id) INDEX idx_compartment (compartment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabin_boards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
board_id TINYINT NOT NULL COMMENT '仓控板ID 1-6',
status TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS compartments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabin_board_id BIGINT NOT NULL,
channel_id TINYINT NOT NULL COMMENT '通道ID 1-6',
status TINYINT DEFAULT 0 COMMENT '0空闲 1充电中 2故障 3禁用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cabin_board_id) REFERENCES cabin_boards(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号', phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号',
@ -246,8 +254,21 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
} }
// 兼容已有数据库:将 abstract_id 改为可空 // 兼容已有数据库:将 abstract_id 改为可空
let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#) // 只在列当前确实为 NOT NULL(老库)时才 ALTER:避免每次重启都对 cabinets 做一次 DDL
.execute(pool).await; // 容错口径与修改前一致(原先为 `let _ =`):失败只记 ERROR,绝不中断启动
if let Err(e) = ensure_column_nullable(
pool,
"cabinets",
"abstract_id",
r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#,
)
.await
{
tracing::error!(
"[migrate] cabinets.abstract_id 可空化迁移失败(服务继续启动)err={}",
e
);
}
// 更新 charge_records.max_temperature 注释(0.1°C → K×100) // 更新 charge_records.max_temperature 注释(0.1°C → K×100)
// 只在注释确实过时(老库)时才 ALTER:避免每次启动都对整表做一次 MODIFY // 只在注释确实过时(老库)时才 ALTER:避免每次启动都对整表做一次 MODIFY
@ -468,6 +489,42 @@ async fn ensure_column(
Ok(()) Ok(())
} }
/// 列可空性幂等迁移:仅当该列**当前为 NOT NULL**(老库)时才 ALTER 为可空
///
/// 为什么:`cabinets.abstract_id` 在老库上是 `NOT NULL`,需要改成可空;
/// 但无条件执行 MODIFY 会让**每次服务启动**都对 `cabinets` 做一次 DDL
/// (MODIFY 会走「重建整表」路径,大表上会长时间持有元数据锁、阻塞并发读写)。
/// 而该 DDL 只在老库上真正必要,新库/已迁移库应当完全跳过。
///
/// 安全性:表名/列名/DDL 全部来自本文件内的白名单常量,不含任何外部输入拼接。
async fn ensure_column_nullable(
pool: &MySqlPool,
table: &str,
column: &str,
ddl: &str,
) -> Result<(), sqlx::Error> {
// ⚠️ 与 `ensure_column_comment` 同样的坑:MySQL 8 上 `information_schema` 的字符列
// 会被 sqlx 报告为 **VARBINARY**,直接按 `Option<String>` 解码会 panic,
// 必须显式 `CAST(... AS CHAR)`。
let is_nullable: Option<String> = sqlx::query_scalar(
"SELECT CAST(IS_NULLABLE AS CHAR) FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
)
.bind(table)
.bind(column)
.fetch_optional(pool)
.await?;
// 列不存在(None)时不动:建列由 ensure_column 负责
if let Some(flag) = is_nullable {
if flag != "YES" {
sqlx::query(ddl).execute(pool).await?;
tracing::info!("[migrate] 已将 {}.{} 改为可空", table, column);
}
}
Ok(())
}
/// 列注释幂等更新:仅当现有注释不包含 `marker` 时才 ALTER /// 列注释幂等更新:仅当现有注释不包含 `marker` 时才 ALTER
/// ///
/// 为什么:老库的列注释不会随 CREATE TABLE IF NOT EXISTS 更新; /// 为什么:老库的列注释不会随 CREATE TABLE IF NOT EXISTS 更新;
@ -765,3 +822,85 @@ async fn grant_permissions_by_codes(
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::SCHEMA_SQL;
/// `SCHEMA_SQL` 中每条建表语句的固定前缀
const CREATE_PREFIX: &str = "CREATE TABLE IF NOT EXISTS ";
/// 外键引用的关键字(用于从建表语句中抽取被引用表名)
const REFERENCES_MARKER: &str = "REFERENCES ";
/// 期望的建表数量(表数量变化时该断言会提醒同步更新)
const EXPECTED_TABLE_COUNT: usize = 21;
/// 抽取建表语句中所有 `REFERENCES <表名>` 的被引用表名
fn referenced_tables(statement: &str) -> Vec<&str> {
let mut refs = Vec::new();
let mut rest = statement;
while let Some(pos) = rest.find(REFERENCES_MARKER) {
let after = &rest[pos + REFERENCES_MARKER.len()..];
let name = after
.split(|c: char| !(c.is_alphanumeric() || c == '_'))
.next()
.unwrap_or_default();
if !name.is_empty() {
refs.push(name);
}
rest = after;
}
refs
}
/// 按出现顺序解析 `SCHEMA_SQL`,返回 (表名, 建表语句全文)
fn parse_table_definitions() -> Vec<(String, &'static str)> {
let mut defs = Vec::new();
for statement in SCHEMA_SQL.split(';') {
let statement = statement.trim();
if let Some(rest) = statement.strip_prefix(CREATE_PREFIX) {
let name = rest
.split(|c: char| c.is_whitespace() || c == '(')
.next()
.unwrap_or_default()
.to_string();
defs.push((name, statement));
}
}
defs
}
/// `SCHEMA_SQL` 必须按「被引用表在前」排列 ——
/// 脚本按 `;` 逐条执行,前向引用会让全新空库直接报 1824 并 panic 退出。
#[test]
fn test_schema_sql_table_order_has_forward_reference_returns_none() {
let defs = parse_table_definitions();
assert_eq!(
defs.len(),
EXPECTED_TABLE_COUNT,
"SCHEMA_SQL 建表数量与预期不符(新增/删除表时请同步更新本断言)"
);
let mut defined: Vec<&str> = Vec::new();
for (table, statement) in &defs {
for referenced in referenced_tables(statement) {
assert!(
defined.contains(&referenced),
"前向引用:表 `{}` 引用了尚未创建的 `{}`(全新库执行会报 1824 \
Failed to open the referenced table)",
table,
referenced
);
}
defined.push(table);
}
}
/// 解析器自检:`REFERENCES x(id)` 应被识别为表 `x`
#[test]
fn test_referenced_tables_single_foreign_key_returns_table_name() {
let refs = referenced_tables(
"CREATE TABLE IF NOT EXISTS t (a BIGINT, FOREIGN KEY (a) REFERENCES other(id))",
);
assert_eq!(refs, vec!["other"]);
}
}

View File

@ -1190,6 +1190,11 @@ pub async fn scan_cabinet(
/// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`, /// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`,
/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。 /// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。
/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。 /// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。
///
/// ⚠️ **校验顺序(本 handler 的契约)**:
/// **权限 → 归属 → 设备启用 → 设备在线 → 建单 → 下发**。
/// 建单必须排在全部校验之后:否则设备离线/无权访问时接口虽然报错,订单却已经插进
/// `charge_orders`(幽灵单,只能等 10 小时超时结单,UI 上还会多出一次"充电中")。
pub async fn start_charge( pub async fn start_charge(
user: CurrentUser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
@ -1197,9 +1202,9 @@ pub async fn start_charge(
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge:start")?; auth::check_permission(&user, "charge:start")?;
// 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式 // 一次查出柜子/仓控板/通道 + 充电工作模式
// (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理) // (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理)
let order_row: Result<Option<(i64, String, i8, i8, Option<i8>)>, sqlx::Error> = sqlx::query_as( let target: Option<(i64, String, i8, i8, Option<i8>)> = sqlx::query_as(
r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode
FROM cabinets cab FROM cabinets cab
JOIN cabin_boards cb ON cb.cabinet_id = cab.id JOIN cabin_boards cb ON cb.cabinet_id = cab.id
@ -1208,28 +1213,77 @@ pub async fn start_charge(
) )
.bind(body.compartment_id) .bind(body.compartment_id)
.fetch_optional(&state.mysql) .fetch_optional(&state.mysql)
.await; .await?;
// 仓体不存在:与 [`send_compartment_command`] 的报错语义一致(同一 JOIN 条件)
let (cabinet_id, imei, board_id, channel_id, mode) =
target.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
// 归属 → 设备启用 → 设备在线:全部**提前到建单之前**(顺序与 [`send_compartment_command`] 一致,
// 校验函数语义未改;下发路径仍会再校验一次,幂等无副作用)
verify_cabinet_access(&user, cabinet_id, &state.mysql).await?;
ensure_cabinet_enabled(&state.mysql, cabinet_id).await?;
ensure_device_online(&state, &imei).await?;
match order_row {
// 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单; // 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单;
// **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单 // **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单
Ok(Some((cabinet_id, imei, board_id, channel_id, mode))) if mode.unwrap_or(0) == 0 => { if mode.unwrap_or(0) == 0 {
// 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`) create_order_before_on(&state, &body, cabinet_id, &imei, board_id, channel_id).await;
match crate::workers::report_worker::load_channel_battery( } else {
&state.redis, &imei, board_id, channel_id, tracing::info!(
"[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}",
body.compartment_id
);
}
send_compartment_command(&state, &user, body.compartment_id, "on").await
}
/// 平台下发 `on` **之前**建单(失败只记日志,绝不影响指令下发)。
///
/// 电池 id 与起始 SOC 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`)。
/// ⚠️ 登记键 `device:{imei}:board:{b}:ch:{c}:bat` 的 TTL 只有 48 小时
/// ([`crate::workers::report_worker`] 的 `CHANNEL_BAT_TTL_SECS`)—— 集中充电柜里电池常在仓内
/// 放置超过 48 小时,登记过期后**必须仍按"未知电池"建单**(与设备侧 `handle_device_on` 的兜底
/// 口径一致):否则整段会话没有 `charge_orders` / `charge_records` / 电量,
/// 用户在"充电记录"里看不到这次充电,属于**平台建单漏单**。
async fn create_order_before_on(
state: &AppState,
body: &CompartmentActionInput,
cabinet_id: i64,
imei: &str,
board_id: i8,
channel_id: i8,
) {
let (battery_id, start_soc) = match crate::workers::report_worker::load_channel_battery(
&state.redis,
imei,
board_id,
channel_id,
) )
.await .await
{ {
Some(battery) => { Some(battery) => (battery.battery_id, Some(battery.soc)),
None => {
tracing::warn!(
"[h5] 该通道无 bat_in 登记,按未知电池建单 compartment_id={}",
body.compartment_id
);
(String::new(), None)
}
};
// `ensure_charge_order` 内部自带「按仓体进程内锁 + 条件插入 + 唯一索引」,
// 重复下发 / 双击不会多建单
if let Err(e) = crate::workers::report_worker::ensure_charge_order( if let Err(e) = crate::workers::report_worker::ensure_charge_order(
&state.mysql, &state.mysql,
&state.redis, &state.redis,
&imei, imei,
cabinet_id, cabinet_id,
board_id, board_id,
channel_id, channel_id,
&battery.battery_id, &battery_id,
Some(battery.soc), start_soc,
) )
.await .await
{ {
@ -1240,29 +1294,6 @@ pub async fn start_charge(
); );
} }
} }
None => tracing::warn!(
"[h5] 该通道无 bat_in 登记,跳过建单 compartment_id={}",
body.compartment_id
),
}
}
Ok(Some(_)) => tracing::info!(
"[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}",
body.compartment_id
),
Ok(None) => tracing::warn!(
"[h5] 下发 on 前跳过建单:仓体不存在 compartment_id={}",
body.compartment_id
),
Err(e) => tracing::error!(
"[h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={}",
body.compartment_id,
e
),
}
send_compartment_command(&state, &user, body.compartment_id, "on").await
}
/// POST /api/h5/charge/stop — 停止充电 /// POST /api/h5/charge/stop — 停止充电
pub async fn stop_charge( pub async fn stop_charge(

View File

@ -589,15 +589,7 @@ pub async fn update_user(
let pool = &state.mysql; let pool = &state.mysql;
// 检查用户存在 let (phone, old_name, old_role, old_org_id) = load_user_for_update(pool, user_id).await?;
let old: Option<(String, Option<String>, i32, Option<i64>)> = sqlx::query_as(
"SELECT phone, name, role, organization_id FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let (phone, old_name, old_role, old_org_id) = old
.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
let new_name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()); let new_name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
let new_role = input.role.unwrap_or(old_role); let new_role = input.role.unwrap_or(old_role);
@ -616,10 +608,57 @@ pub async fn update_user(
) )
.await?; .await?;
persist_user_update(pool, user_id, &new_name, new_role, new_org_id, old_role).await?;
log_user_update(
pool,
current_user.user_id,
user_id,
&phone,
old_name.as_deref(),
new_name.as_deref(),
old_role,
new_role,
)
.await;
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
}
/// 读取待编辑用户的原始信息(`phone` / `name` / `role` / `organization_id`)
///
/// 不存在时直接返回 `NotFound`。原属 [`update_user`] 的"检查用户存在"段。
async fn load_user_for_update(
pool: &sqlx::MySqlPool,
user_id: i64,
) -> Result<(String, Option<String>, i32, Option<i64>), AppError> {
// 检查用户存在
let old: Option<(String, Option<String>, i32, Option<i64>)> = sqlx::query_as(
"SELECT phone, name, role, organization_id FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let (phone, old_name, old_role, old_org_id) = old
.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
Ok((phone, old_name, old_role, old_org_id))
}
/// 落库用户改名/改角色/改组织,并在**角色真的变了**时同步 `user_roles` 映射
///
/// 原属 [`update_user`] 的 `UPDATE` 段与角色同步段(注释随之原样搬移)。
async fn persist_user_update(
pool: &sqlx::MySqlPool,
user_id: i64,
new_name: &Option<String>,
new_role: i32,
new_org_id: Option<i64>,
old_role: i32,
) -> Result<(), AppError> {
let rows = sqlx::query( let rows = sqlx::query(
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?" "UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
) )
.bind(&new_name) .bind(new_name)
.bind(new_role) .bind(new_role)
.bind(new_org_id) .bind(new_org_id)
.bind(user_id) .bind(user_id)
@ -646,19 +685,33 @@ pub async fn update_user(
sync_user_role_mapping(pool, user_id, new_role).await?; sync_user_role_mapping(pool, user_id, new_role).await?;
} }
Ok(())
}
/// 记录"编辑用户"操作日志(含变更前后对比)并打业务日志
///
/// 原属 [`update_user`] 的"记录操作日志"段。
async fn log_user_update(
pool: &sqlx::MySqlPool,
actor_id: i64,
user_id: i64,
phone: &str,
old_name: Option<&str>,
new_name: Option<&str>,
old_role: i32,
new_role: i32,
) {
// 记录操作日志 // 记录操作日志
let detail = format!( let detail = format!(
"编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}", "编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}",
phone, phone,
old_name.as_deref().unwrap_or("-"), old_name.unwrap_or("-"),
new_name.as_deref().unwrap_or("-"), new_name.unwrap_or("-"),
old_role, new_role old_role, new_role
); );
log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await; log_operation(pool, actor_id, "edit", "user", Some(user_id), &detail).await;
tracing::info!(user_id, "编辑用户"); tracing::info!(user_id, "编辑用户");
Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" })))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -53,6 +53,12 @@ const REDIS_POLL_INTERVAL_MS: u64 = 200;
/// Redis key 前缀 /// Redis key 前缀
const REDIS_KEY_AUTH_STR: &str = "auth_str:"; const REDIS_KEY_AUTH_STR: &str = "auth_str:";
/// Redis 队列深度 INFO 日志的间隔(秒)。
///
/// 取 10 分钟:只为在日志里留下**积压趋势**(`device:reports` / `device:commands` /
/// `device:commands:dead`),频率低到对 Redis 无感(3 次 `LLEN` / 10 分钟)。
const QUEUE_DEPTH_LOG_INTERVAL_SECS: u64 = 600;
/// 签名截取范围(8 hex = 4 bytes,受 MCU 硬件协议约束不可改) /// 签名截取范围(8 hex = 4 bytes,受 MCU 硬件协议约束不可改)
/// 审查编号:I5(已确认不修,硬件约束。设备一次只能获取一次 auth_str,除非显式标记重新生成) /// 审查编号:I5(已确认不修,硬件约束。设备一次只能获取一次 auth_str,除非显式标记重新生成)
const SIGN_START: usize = 56; const SIGN_START: usize = 56;
@ -96,6 +102,17 @@ const MAX_CHANNEL_ID: i64 = 8;
/// 占用状态对账周期:与 `order_timeout` 的订单超时扫描同频(300 秒),避免额外数据库压力 /// 占用状态对账周期:与 `order_timeout` 的订单超时扫描同频(300 秒),避免额外数据库压力
const RECONCILE_INTERVAL_SECS: u64 = 300; const RECONCILE_INTERVAL_SECS: u64 = 300;
/// 「无订单却标占用」兜底复位的**陈旧阈值**(小时)。
///
/// 依据:`order_timeout` 的订单超时是 **10 小时**(见 [`ORDER_TIMEOUT_HOURS`]),
/// 任何**真实存在过的**充电会话最迟 10 小时就会被结单、从而落入"有已结束订单"的正常复位分支。
/// 因此一条 `status=1` **且一笔订单都没有**、又**超过该阈值没有更新**的仓体,
/// 不可能是正在充电的会话,只能是历史遗留的错误状态(例如平台建单漏单时代留下的脏标记)。
///
/// 取 **12 小时 = 10 小时订单超时 + 2 小时余量**:宁可多留 2 小时也不早复位。
/// ⚠️ 该阈值只是**拿不到 Redis 通道状态时**的兜底;优先判据是通道 `batt_status` 的 bit7(有无电池)。
const RECONCILE_ORDERLESS_STALE_HOURS: i64 = 12;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 频率限制器 // 频率限制器
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -139,6 +156,9 @@ pub async fn start(redis: ConnectionManager, mysql: MySqlPool) {
// 靠"通道连续报无电池"提前结单(待确认第 18 项的兜底)—— 按分工这都属于本 worker 的职责。 // 靠"通道连续报无电池"提前结单(待确认第 18 项的兜底)—— 按分工这都属于本 worker 的职责。
tokio::spawn(reconcile_usage_loop(mysql.clone(), redis.clone())); tokio::spawn(reconcile_usage_loop(mysql.clone(), redis.clone()));
// 低频队列深度日志(见 [`QUEUE_DEPTH_LOG_INTERVAL_SECS`]):让"积压"在日志里可见
let mut last_depth_log = Instant::now();
loop { loop {
let mut conn = redis.clone(); let mut conn = redis.clone();
// 非阻塞轮询取一条上报(原因见 [`REDIS_POLL_INTERVAL_MS`]): // 非阻塞轮询取一条上报(原因见 [`REDIS_POLL_INTERVAL_MS`]):
@ -165,6 +185,30 @@ pub async fn start(redis: ConnectionManager, mysql: MySqlPool) {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
} }
} }
// 队列深度周期性 INFO 日志(节流,仅观测)
if last_depth_log.elapsed() >= Duration::from_secs(QUEUE_DEPTH_LOG_INTERVAL_SECS) {
last_depth_log = Instant::now();
log_queue_depths(&redis).await;
}
}
}
/// 周期性记录关键 Redis 队列深度(INFO;读取失败只记 WARN,绝不影响消费)。
///
/// 为什么需要:Redis 实例 `maxmemory=1GB` + `maxmemory-policy=volatile-lru`,而实例上绝大多数
/// key 没有 TTL(可淘汰集合极小)—— 一旦 `device:reports` 无界积压,写命令 OOM 会**连累同实例上
/// 既有的充电站生产平台**,而积压本身只能从队列深度看出来(消费停滞时没有错误日志)。
/// 生产端已加 `LTRIM` 上限(见 `device-server/src/tcp/handler.rs`),这里补上"看得见"的一环。
async fn log_queue_depths(redis: &ConnectionManager) {
for key in ["device:reports", "device:commands", "device:commands:dead"] {
let mut conn = redis.clone();
let depth: Result<i64, redis::RedisError> =
redis::cmd("LLEN").arg(key).query_async(&mut conn).await;
match depth {
Ok(d) => tracing::info!("[queue] {} depth={}", key, d),
Err(e) => tracing::warn!("[queue] 读取队列深度失败 key={} err={}", key, e),
}
} }
} }
@ -1534,12 +1578,10 @@ pub async fn ensure_charge_order(
battery_id: &str, battery_id: &str,
start_soc: Option<i64>, start_soc: Option<i64>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let compartment_id = match find_compartment_id(pool, cabinet_id, board_id, channel_id).await? { let compartment_id = match resolve_order_compartment(pool, cabinet_id, board_id, channel_id).await?
{
Some(id) => id, Some(id) => id,
None => { None => return Ok(()),
tracing::warn!("[order] 仓体不存在,跳过建单 cabinet_id={} board={} ch={}", cabinet_id, board_id, channel_id);
return Ok(());
}
}; };
// 「查活跃单 → 判定 → 结束老单 → 插入新单」**整段按仓体串行化**(进程内锁,见 // 「查活跃单 → 判定 → 结束老单 → 插入新单」**整段按仓体串行化**(进程内锁,见
@ -1553,24 +1595,7 @@ pub async fn ensure_charge_order(
// 锁只护住本进程、条件插入在"读快照"实现里仍有窗口、唯一键只能兜底报错。 // 锁只护住本进程、条件插入在"读快照"实现里仍有窗口、唯一键只能兜底报错。
let _guard = acquire_compartment_lock(compartment_id).await; let _guard = acquire_compartment_lock(compartment_id).await;
let active = find_active_order(pool, compartment_id).await?; if !reconcile_active_order(
// 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变)
let decision = decide_order_action(active.as_ref().map(|(_, b)| b.as_str()), battery_id);
match &active {
None => {}
Some(_) if decision == OrderDecision::Skip => {
tracing::debug!(
"[order] 通道已有活跃订单且电池未变,不新建 compartment_id={}",
compartment_id
);
return Ok(());
}
Some((order_id, old_battery)) => {
// 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。
// 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。
end_replaced_order(
pool, pool,
redis, redis,
imei, imei,
@ -1578,11 +1603,10 @@ pub async fn ensure_charge_order(
channel_id, channel_id,
compartment_id, compartment_id,
battery_id, battery_id,
*order_id,
old_battery,
) )
.await?; .await?
} {
return Ok(());
} }
let start_soc = match start_soc { let start_soc = match start_soc {
@ -1613,6 +1637,73 @@ pub async fn ensure_charge_order(
Ok(()) Ok(())
} }
/// 解析建单目标仓体;仓体不存在时告警并返回 `None`(调用方据此跳过建单)
///
/// 原属 [`ensure_charge_order`] 的"定位仓体"段。
async fn resolve_order_compartment(
pool: &MySqlPool,
cabinet_id: i64,
board_id: i8,
channel_id: i8,
) -> Result<Option<i64>, Box<dyn std::error::Error + Send + Sync>> {
match find_compartment_id(pool, cabinet_id, board_id, channel_id).await? {
Some(id) => Ok(Some(id)),
None => {
tracing::warn!("[order] 仓体不存在,跳过建单 cabinet_id={} board={} ch={}", cabinet_id, board_id, channel_id);
Ok(None)
}
}
}
/// 建单前的活跃单对账:已存在且电池未变 ⇒ 返回 `false`(跳过建单);
/// 电池变了 ⇒ 先结束老订单再返回 `true`
///
/// 原属 [`ensure_charge_order`] 的"查活跃单 → 判定 → 结束老单"段,
/// 调用方必须已持有该仓体的进程内锁(见 [`acquire_compartment_lock`])。
async fn reconcile_active_order(
pool: &MySqlPool,
redis: &ConnectionManager,
imei: &str,
board_id: i8,
channel_id: i8,
compartment_id: i64,
battery_id: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let active = find_active_order(pool, compartment_id).await?;
// 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变)
let decision = decide_order_action(active.as_ref().map(|(_, b)| b.as_str()), battery_id);
match &active {
None => {}
Some(_) if decision == OrderDecision::Skip => {
tracing::debug!(
"[order] 通道已有活跃订单且电池未变,不新建 compartment_id={}",
compartment_id
);
return Ok(false);
}
Some((order_id, old_battery)) => {
// 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。
// 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。
end_replaced_order(
pool,
redis,
imei,
board_id,
channel_id,
compartment_id,
battery_id,
*order_id,
old_battery,
)
.await?;
}
}
Ok(true)
}
/// 查该仓体当前活跃订单(`status = 0`)→ `(订单 id, 电池 id)`;无活跃订单返回 `None`。 /// 查该仓体当前活跃订单(`status = 0`)→ `(订单 id, 电池 id)`;无活跃订单返回 `None`。
async fn find_active_order( async fn find_active_order(
pool: &MySqlPool, pool: &MySqlPool,
@ -2043,6 +2134,8 @@ async fn mark_battery_status(pool: &MySqlPool, battery_id: &str, status: i8) {
/// 走到 `bat_out` / `off` 的复位分支,若不兜底,仓体会永远显示"充电中"。 /// 走到 `bat_out` / `off` 的复位分支,若不兜底,仓体会永远显示"充电中"。
/// 为什么加 `EXISTS(已结束订单)` 条件:**刚 `bat_in` 还未开始充电**的仓体/电池 /// 为什么加 `EXISTS(已结束订单)` 条件:**刚 `bat_in` 还未开始充电**的仓体/电池
/// (`status=1` 但一笔订单都没有)不应被误复位,那属于正常的"已插入、待充电"状态。 /// (`status=1` 但一笔订单都没有)不应被误复位,那属于正常的"已插入、待充电"状态。
/// ⚠️ 该条件也意味着"零订单的历史脏行"永不自愈 —— 这类行由
/// [`reconcile_orderless_compartments`] 用**通道状态 + 陈旧阈值**单独兜底(见该函数说明)。
async fn reconcile_usage_loop(mysql: MySqlPool, redis: ConnectionManager) { async fn reconcile_usage_loop(mysql: MySqlPool, redis: ConnectionManager) {
let mut ticker = tokio::time::interval(Duration::from_secs(RECONCILE_INTERVAL_SECS)); let mut ticker = tokio::time::interval(Duration::from_secs(RECONCILE_INTERVAL_SECS));
loop { loop {
@ -2055,9 +2148,11 @@ async fn reconcile_usage_loop(mysql: MySqlPool, redis: ConnectionManager) {
/// 单轮对账(见 [`reconcile_usage_loop`] 的说明) /// 单轮对账(见 [`reconcile_usage_loop`] 的说明)
/// ///
/// 两件事: /// 三件事:
/// 1. **占用状态复位**:订单已全部结束但 `compartments`/`batteries` 还标着"占用"的,置回空闲 /// 1. **占用状态复位**:订单已全部结束但 `compartments`/`batteries` 还标着"占用"的,置回空闲
/// 2. **"无电池"状态兜底**(**待确认第 18 项**):该通道 `batt_status` 的 **bit7 连续 /// 2. **"无订单却标占用"兜底复位**([`reconcile_orderless_compartments`]):`status=1` 但
/// **一笔订单都没有**的行走不到第 1 步(它要求 `EXISTS(已结束订单)`),必须单独兜底
/// 3. **"无电池"状态兜底**(**待确认第 18 项**):该通道 `batt_status` 的 **bit7 连续
/// [`energy::NO_BATTERY_STRIKES`] 次为 0**(协议 §5.8 位定义:0=无电池)却还挂着活跃订单 /// [`energy::NO_BATTERY_STRIKES`] 次为 0**(协议 §5.8 位定义:0=无电池)却还挂着活跃订单
/// ⇒ 判定"人把电池拔走了但设备没机会上报 `bat_out`",**提前结单**,避免订单一直挂到 10 小时超时。 /// ⇒ 判定"人把电池拔走了但设备没机会上报 `bat_out`",**提前结单**,避免订单一直挂到 10 小时超时。
/// ///
@ -2098,9 +2193,141 @@ async fn reconcile_usage_once(
batteries.rows_affected() batteries.rows_affected()
); );
} }
reconcile_orderless_compartments(mysql, redis).await?;
Ok(()) Ok(())
} }
/// 兜底复位「`compartments.status=1` 但**一笔订单都没有**」的行。
///
/// **为什么必须有这一步(生产实测事故)**:`reconcile_usage_once` 的主复位要求
/// `EXISTS(已结束订单)`,而"平台下发 `on` 前建单失败/漏单"时代留下的行**一笔订单都没有**,
/// 于是永不自愈 —— 生产库 936 个仓体里曾长期有 **214 个 `status=1`(充电中)却零订单**,
/// UI 与统计口径都跟着错(最后更新停在很久以前)。新增条件/新增语句,**不改既有过滤语义**。
///
/// 判据(先严后宽,只清"确实空闲"的行):
/// 1. 无任何订单(`NOT EXISTS`)—— 有活跃订单必然保留;有已结束订单的交给主复位分支;
/// 2. **优先**用 Redis 通道状态 `batt_status` 的 **bit7**(协议 §5.8:bit7=1 有电池 / 0 无电池):
/// - bit7=0 ⇒ 通道里没有电池 ⇒ 确实空闲 ⇒ 复位;
/// - bit7=1 ⇒ 电池还在仓里(可能是"已插入待充电"的正常状态)⇒ **保留**;
/// 3. 拿不到通道状态(键不存在 / Redis 故障 / Redis 重启后状态丢失)⇒ 退回
/// "最后更新时间超过 [`RECONCILE_ORDERLESS_STALE_HOURS`] 小时"作为兜底(依据见该常量)。
///
/// 复位按**单行 + 同样的守卫条件**执行(`UPDATE ... WHERE id=? AND status=1 AND NOT EXISTS(...)`),
/// 避免"扫描与复位之间刚 `bat_in` 建了单"被误复位。
async fn reconcile_orderless_compartments(
mysql: &MySqlPool,
redis: &ConnectionManager,
) -> Result<(), sqlx::Error> {
// 候选行:`status=1` 且该仓体**没有任何订单**;顺带查出通道定位信息与"是否已陈旧"
let candidates: Vec<(i64, String, i8, i8, bool)> = sqlx::query_as(
"SELECT comp.id, cab.imei, cb.board_id, comp.channel_id,
(comp.updated_at < (NOW() - INTERVAL ? HOUR)) AS stale
FROM compartments comp
JOIN cabin_boards cb ON cb.id = comp.cabin_board_id
JOIN cabinets cab ON cab.id = cb.cabinet_id
WHERE comp.status = 1
AND NOT EXISTS (SELECT 1 FROM charge_orders co WHERE co.compartment_id = comp.id)
LIMIT 500",
)
.bind(RECONCILE_ORDERLESS_STALE_HOURS)
.fetch_all(mysql)
.await?;
let scanned = candidates.len();
// 复位计数按判据分开统计,便于运维从日志区分"通道确认空闲"与"陈旧兜底"
let mut reset_idle = 0u64;
let mut reset_stale = 0u64;
let mut kept_battery_present = 0u64;
for (compartment_id, imei, board_id, channel_id, stale) in candidates {
match channel_battery_present(redis, &imei, board_id.into(), channel_id.into()).await {
// 通道状态可用:bit7=0(无电池)⇒ 确实空闲
Some(false) => {
reset_idle += reset_orderless_compartment(mysql, compartment_id).await?;
}
// bit7=1 ⇒ 电池还在仓里,可能是"已插入待充电",**不动**
Some(true) => kept_battery_present += 1,
// 拿不到状态 ⇒ 只能按"最后更新时间超过 N 小时"兜底
None if stale => {
reset_stale += reset_orderless_compartment(mysql, compartment_id).await?;
}
None => {}
}
}
if reset_idle > 0 || reset_stale > 0 {
tracing::warn!(
"[reconcile] 无订单却标占用(status=1)的仓体已兜底复位:\
按通道状态 batt_status.bit7=0(确实无电池)复位 {} 行,\
按最后更新时间超过 {} 小时(拿不到通道状态)复位 {} 行;\
本轮候选 {} 行,因通道仍报有电池而保留 {} 行",
reset_idle,
RECONCILE_ORDERLESS_STALE_HOURS,
reset_stale,
scanned,
kept_battery_present
);
} else if scanned > 0 && kept_battery_present > 0 {
tracing::info!(
"[reconcile] 无订单却标占用(status=1)的仓体 {} 行均报有电池,按「已插入待充电」保留",
kept_battery_present
);
}
Ok(())
}
/// 读取通道 `batt_status` 的 **bit7**(有无电池):`Some(true)`=有电池 / `Some(false)`=无电池 /
/// `None`=拿不到状态(键不存在或 Redis 读取失败)。
///
/// 位定义见协议 §5.8 与 [`energy::accumulate`] 的同一判据(`batt_status & 0x80`)。
/// 键为无 TTL 的通道实时状态 Hash(`report_worker` 每次 `status_post` 覆盖),
/// 因此"键不存在"只可能是设备长期离线或 Redis 重启 —— 调用方需自行兜底。
async fn channel_battery_present(
redis: &ConnectionManager,
imei: &str,
board_id: i64,
channel_id: i64,
) -> Option<bool> {
let key = channel_status_key(imei, board_id, channel_id);
let mut conn = redis.clone();
let value: Result<Option<String>, redis::RedisError> = redis::cmd("HGET")
.arg(&key)
.arg("batt_status")
.query_async(&mut conn)
.await;
match value {
Ok(Some(hex)) => u8::from_str_radix(hex.trim(), 16).ok().map(|b| b & 0x80 != 0),
Ok(None) => None,
Err(e) => {
tracing::warn!("[reconcile] 读取通道状态失败 key={} err={}", key, e);
None
}
}
}
/// 复位**单行**"无订单却标占用"的仓体,返回实际影响行数。
///
/// 守卫条件与候选查询完全一致(`status=1` + 无任何订单),
/// 因此即便扫描与复位之间该仓体刚建了单,也**不会**被误复位。
async fn reset_orderless_compartment(
mysql: &MySqlPool,
compartment_id: i64,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
"UPDATE compartments SET status = 0
WHERE id = ? AND status = 1
AND NOT EXISTS (SELECT 1 FROM charge_orders co WHERE co.compartment_id = compartments.id)",
)
.bind(compartment_id)
.execute(mysql)
.await?;
Ok(result.rows_affected())
}
/// **待确认第 18 项的兜底**:对"通道连续多次报无电池但订单还挂着"的订单提前结单。 /// **待确认第 18 项的兜底**:对"通道连续多次报无电池但订单还挂着"的订单提前结单。
/// ///
/// 场景:停电/离线期间人把电池取走 —— 设备**根本没机会上报 `bat_out`**, /// 场景:停电/离线期间人把电池取走 —— 设备**根本没机会上报 `bat_out`**,
@ -2552,11 +2779,63 @@ async fn handle_bat_out(
.map(|c| c.soc); .map(|c| c.soc);
// 结束该通道活跃订单(统一结算:写回平台侧积分电量 + 生成充电记录)。 // 结束该通道活跃订单(统一结算:写回平台侧积分电量 + 生成充电记录)。
let updated = finalize_active_order_for_bat_out(
mysql,
redis,
dev_id,
board_id,
channel_id,
compartment_id,
battery_id,
end_soc,
)
.await?;
reset_usage_status_unless_finalized(mysql, dev_id, compartment_id, battery_id, updated).await;
// 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id。
// //
// 先判定"这条 bat_out 是不是当前这笔活跃订单的电池"(见 [`should_finalize_bat_out`]): // ⚠️ 这里**刻意不按 `updated > 0` 门控**(虽然占用复位是门控的):`off` + `bat_out`
// `bat_out` 可能是**上一块电池的迟到报文**(协议 §5.6 要求先发 off 再发 bat_out, // 成对到达时,胜出的往往是 `off` ⇒ `bat_out` 拿到的 `updated == 0`,
// 两条报文由 `tokio::spawn` 并发处理,中间人还可能已经换上新电池并建了新单), // 而**清理通道登记只有 `bat_out` 这一条路径**,门控会让登记永远残留。
// 那种情况下按仓体收尾会把**正在充电的新单**结束掉(reason 记成 user_stop、电量错位)。 // 真正的保护是"**登记的 battery_id 必须与本次 bat_out 的 battery_id 一致**":
// 若该通道已经插入了新电池(新 `bat_in` 覆盖了登记),就不会误删新电池的登记。
delete_channel_battery_if_matches(redis, dev_id, board_id, channel_id, battery_id).await;
log_bat_out(mysql, dev_id, msg, compartment_id, battery_id, end_soc).await;
tracing::info!(
"[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}",
dev_id,
compartment_id,
battery_id,
end_soc
);
let resp = ServerResponse::success_for(msg);
send_response_to_device(redis, dev_id, &resp).await?;
Ok(())
}
/// 结束 `bat_out` 对应的活跃订单,返回受影响行数(`0` = 没有可结束的活跃订单)
///
/// 先判定"这条 bat_out 是不是当前这笔活跃订单的电池"(见 [`should_finalize_bat_out`]):
/// `bat_out` 可能是**上一块电池的迟到报文**(协议 §5.6 要求先发 off 再发 bat_out,
/// 两条报文由 `tokio::spawn` 并发处理,中间人还可能已经换上新电池并建了新单),
/// 那种情况下按仓体收尾会把**正在充电的新单**结束掉(reason 记成 user_stop、电量错位)。
///
/// 原属 [`handle_bat_out`] 的"结束活跃订单"段。
async fn finalize_active_order_for_bat_out(
mysql: &MySqlPool,
redis: &ConnectionManager,
dev_id: &str,
board_id: i8,
channel_id: i8,
compartment_id: i64,
battery_id: &str,
end_soc: Option<i64>,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let active = find_active_order(mysql, compartment_id).await?; let active = find_active_order(mysql, compartment_id).await?;
let active_battery = active.as_ref().map(|(_, battery)| battery.as_str()); let active_battery = active.as_ref().map(|(_, battery)| battery.as_str());
let updated = if should_finalize_bat_out(active_battery, battery_id) { let updated = if should_finalize_bat_out(active_battery, battery_id) {
@ -2588,11 +2867,24 @@ async fn handle_bat_out(
); );
0 0
}; };
Ok(updated)
}
/// 订单确实结束(`updated > 0`)时才复位占用状态;否则告警且**不动**占用标记
///
/// 没有活跃订单可结束(重发 bat_out / 已被 off 或超时路径结单)⇒
/// **不复位占用状态**(与 [`handle_device_off`] 一致):此时仓体/电池可能已经属于
/// **新的一单**(新电池插入 + `on` 建单),盲目复位会把新单的占用标记清掉。
///
/// 原属 [`handle_bat_out`] 的"占用状态复位"段。
async fn reset_usage_status_unless_finalized(
mysql: &MySqlPool,
dev_id: &str,
compartment_id: i64,
battery_id: &str,
updated: u64,
) {
if updated == 0 { if updated == 0 {
// 没有活跃订单可结束(重发 bat_out / 已被 off 或超时路径结单)⇒
// **不复位占用状态**(与 [`handle_device_off`] 一致):此时仓体/电池可能已经属于
// **新的一单**(新电池插入 + `on` 建单),盲目复位会把新单的占用标记清掉。
tracing::warn!( tracing::warn!(
"[bat_out] 无活跃订单可结束,不复位占用状态 dev_id={} compartment_id={}", "[bat_out] 无活跃订单可结束,不复位占用状态 dev_id={} compartment_id={}",
dev_id, dev_id,
@ -2602,30 +2894,6 @@ async fn handle_bat_out(
// 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账) // 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账)
reset_usage_status(mysql, compartment_id, battery_id).await; reset_usage_status(mysql, compartment_id, battery_id).await;
} }
// 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id。
//
// ⚠️ 这里**刻意不按 `updated > 0` 门控**(虽然占用复位是门控的):`off` + `bat_out`
// 成对到达时,胜出的往往是 `off` ⇒ `bat_out` 拿到的 `updated == 0`,
// 而**清理通道登记只有 `bat_out` 这一条路径**,门控会让登记永远残留。
// 真正的保护是"**登记的 battery_id 必须与本次 bat_out 的 battery_id 一致**":
// 若该通道已经插入了新电池(新 `bat_in` 覆盖了登记),就不会误删新电池的登记。
delete_channel_battery_if_matches(redis, dev_id, board_id, channel_id, battery_id).await;
log_bat_out(mysql, dev_id, msg, compartment_id, battery_id, end_soc).await;
tracing::info!(
"[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}",
dev_id,
compartment_id,
battery_id,
end_soc
);
let resp = ServerResponse::success_for(msg);
send_response_to_device(redis, dev_id, &resp).await?;
Ok(())
} }
/// 订单结束 ⇒ 占用状态复位:仓体置空闲 + 电池台账置空闲(`battery_id` 为空串时跳过台账)。 /// 订单结束 ⇒ 占用状态复位:仓体置空闲 + 电池台账置空闲(`battery_id` 为空串时跳过台账)。

View File

@ -23,6 +23,30 @@ use config::Config;
/// 设备侧不可见(与 `_close` 同类)。 /// 设备侧不可见(与 `_close` 同类)。
const ROUTE_DEV_ID_FIELD: &str = "_dev_id"; const ROUTE_DEV_ID_FIELD: &str = "_dev_id";
/// 死信队列 key(下发失败、不重试的指令)。
///
/// 历史上只写不读、**无 TTL、无上限、无告警**:设备长期离线时它会无限增长,
/// 在 `maxmemory-policy=volatile-lru` 且既有 key 几乎都无 TTL 的实例上就是一颗容量炸弹。
const DEAD_LETTER_KEY: &str = "device:commands:dead";
/// 死信队列长度上限:保留**最近** 10000 条(更早的直接丢弃)。
///
/// 取 10000 的依据:死信是"无法下发"的指令,排查时只需要近期样本(单条指令 JSON 约 200~400B,
/// 10000 条 ≈ 2~4MB,对 1GB 实例可忽略);数量级再大也无人逐条看,只会白占内存。
const DEAD_LETTER_MAX: i64 = 10_000;
/// 死信保留期:**7 天**(604800 秒)。
///
/// 依据:与 `log_retention` 的日志保留口径一致 —— 设备离线一周以上的历史指令已无排查价值,
/// 而 TTL 保证即使长时间无新死信写入,历史死信也不会永久占据内存。
const DEAD_LETTER_TTL_SECS: i64 = 7 * 24 * 3600;
/// Redis 队列深度 INFO 日志的间隔(秒)。
///
/// 取 10 分钟:只为在日志里留下**积压趋势**(`device:commands` / `device:reports` /
/// `device:commands:dead`),频率低到对 Redis 无感(每次 3~4 条 `LLEN`)。
const QUEUE_DEPTH_LOG_INTERVAL_SECS: u64 = 600;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
@ -144,6 +168,9 @@ async fn consume_commands(
tracing::info!("[cmd_consumer] 启动 node_id={}", node_id); tracing::info!("[cmd_consumer] 启动 node_id={}", node_id);
let my_queue = format!("device:commands:{}", node_id); let my_queue = format!("device:commands:{}", node_id);
// 低频队列深度日志(见 [`QUEUE_DEPTH_LOG_INTERVAL_SECS`]):让"积压"在日志里可见
let mut last_depth_log = std::time::Instant::now();
loop { loop {
let mut conn = redis.clone(); let mut conn = redis.clone();
// **非阻塞轮询**取一条待下发指令,先取本节点专属队列、再取统一队列。 // **非阻塞轮询**取一条待下发指令,先取本节点专属队列、再取统一队列。
@ -235,12 +262,8 @@ async fn consume_commands(
Err(e) => { Err(e) => {
tracing::error!("[cmd] 发送失败 dev_id={} err={} 已入死信", dev_id, e); tracing::error!("[cmd] 发送失败 dev_id={} err={} 已入死信", dev_id, e);
// ponytail: 丢入死信队列,不重试(重试语义和次数待业务明确) // ponytail: 丢入死信队列,不重试(重试语义和次数待业务明确)
let mut dl_conn = redis.clone(); // 入队同时**限长 + 设 TTL**,并回报累计死信数(可观测性)
let _: Result<(), _> = redis::cmd("LPUSH") push_dead_letter(&redis, &cmd_json, &dev_id, &e.to_string()).await;
.arg("device:commands:dead")
.arg(&cmd_json)
.query_async(&mut dl_conn)
.await;
} }
} }
} }
@ -256,5 +279,78 @@ async fn consume_commands(
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
} }
} }
// 队列深度周期性 INFO 日志(节流,仅观测)
if last_depth_log.elapsed() >= Duration::from_secs(QUEUE_DEPTH_LOG_INTERVAL_SECS) {
last_depth_log = std::time::Instant::now();
log_queue_depths(&redis, &my_queue).await;
}
}
}
/// 写入死信队列(**限长 + TTL + WARN**),失败只记 ERROR、不影响消费循环。
///
/// - `LTRIM 0 DEAD_LETTER_MAX-1`:只保留最近 `DEAD_LETTER_MAX` 条;
/// - `EXPIRE DEAD_LETTER_TTL_SECS`:即使不再有死信写入,历史死信也会自然过期;
/// - `WARN` 里带上 `LLEN` 得到的**累计死信数**:让运维能从日志看出"死信在持续增长",
/// 而不是只看到孤立的一条 ERROR(历史实现的问题正是"只写不读、无任何可观测性")。
async fn push_dead_letter(
redis: &redis::aio::ConnectionManager,
cmd_json: &str,
dev_id: &str,
err: &str,
) {
let mut conn = redis.clone();
// 3 条命令 1 次往返:入队 → 限长 → 设 TTL(全部 `ignore()`,返回 `()`)
let mut pipe = redis::pipe();
pipe.cmd("LPUSH").arg(DEAD_LETTER_KEY).arg(cmd_json).ignore();
pipe.cmd("LTRIM")
.arg(DEAD_LETTER_KEY)
.arg(0)
.arg(DEAD_LETTER_MAX - 1)
.ignore();
pipe.cmd("EXPIRE")
.arg(DEAD_LETTER_KEY)
.arg(DEAD_LETTER_TTL_SECS)
.ignore();
let pushed: Result<(), redis::RedisError> = pipe.query_async(&mut conn).await;
if let Err(e) = pushed {
tracing::error!("[cmd] 死信入队失败 dev_id={} err={}", dev_id, e);
return;
}
// 再取一次长度作为"累计死信数"(读取失败不影响死信已入队的事实,按 -1 打日志)
let mut depth_conn = redis.clone();
let depth: i64 = redis::cmd("LLEN")
.arg(DEAD_LETTER_KEY)
.query_async(&mut depth_conn)
.await
.unwrap_or(-1);
tracing::warn!(
"[cmd] 指令已入死信队列 dev_id={} 累计死信数={} (上限 {} 条 / 保留 {} 秒)err={}",
dev_id,
depth,
DEAD_LETTER_MAX,
DEAD_LETTER_TTL_SECS,
err
);
}
/// 周期性记录关键 Redis 队列深度(INFO;读取失败只记 WARN,绝不影响消费)。
///
/// 为什么需要:Redis 实例 `maxmemory=1GB` + `maxmemory-policy=volatile-lru`,实例上绝大多数 key
/// 没有 TTL(可淘汰集合≈1)—— 队列无界积压会让写命令 OOM 并**连累同实例上既有充电站生产平台**;
/// 而消费端卡死时没有任何错误日志,深度日志是唯一的预警。
async fn log_queue_depths(redis: &redis::aio::ConnectionManager, node_queue: &str) {
for key in ["device:commands", node_queue, "device:reports", DEAD_LETTER_KEY] {
let mut conn = redis.clone();
let depth: Result<i64, redis::RedisError> =
redis::cmd("LLEN").arg(key).query_async(&mut conn).await;
match depth {
Ok(d) => tracing::info!("[queue] {} depth={}", key, d),
Err(e) => tracing::warn!("[queue] 读取队列深度失败 key={} err={}", key, e),
}
} }
} }

View File

@ -10,6 +10,24 @@
use super::connection::ConnectionPool; use super::connection::ConnectionPool;
use super::protocol::DeviceMessage; 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 上报队列。 /// 分发消息:补写设备身份后转发到 Redis 上报队列。
/// ///
/// `current_dev_id` = 本 TCP 连接绑定的设备身份(由 `auth_str`/`login` 建立)。 /// `current_dev_id` = 本 TCP 连接绑定的设备身份(由 `auth_str`/`login` 建立)。
@ -50,6 +68,11 @@ fn attach_dev_id(msg: &mut DeviceMessage, current_dev_id: Option<&str>) {
} }
/// 将设备消息转发到 Redis 统一上报队列 `device:reports` /// 将设备消息转发到 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) { async fn forward_to_redis(redis: &redis::aio::ConnectionManager, msg: &DeviceMessage) {
let json_str = match serde_json::to_string(msg) { let json_str = match serde_json::to_string(msg) {
Ok(json) => json, Ok(json) => json,
@ -61,20 +84,40 @@ async fn forward_to_redis(redis: &redis::aio::ConnectionManager, msg: &DeviceMes
}; };
let mut conn = redis.clone(); let mut conn = redis.clone();
let result: Result<(), _> = redis::cmd("LPUSH") // 一条 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("device:reports")
.arg(&json_str) .arg(0)
.query_async(&mut conn) .arg(REPORTS_QUEUE_MAX - 1)
.await; .ignore();
pipe.cmd("LLEN").arg("device:reports");
let result: Result<(i64, i64), redis::RedisError> = pipe.query_async(&mut conn).await;
match result { match result {
Ok(_) => { 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!( tracing::debug!(
"[forward] 已推送 dev_id={} act={}", "[forward] 已推送 dev_id={} act={} queue_len={}",
msg.dev_id.as_deref().unwrap_or("?"), msg.dev_id.as_deref().unwrap_or("?"),
msg.act msg.act,
len_after_trim
); );
} }
Err(e) => tracing::error!("[forward] Redis LPUSH 失败: {}", e), }
Err(e) => tracing::error!("[forward] Redis LPUSH/LTRIM 失败: {}", e),
} }
} }