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:
parent
6b562ae286
commit
db1264953a
178
deploy/backup-db.sh
Normal file
178
deploy/backup-db.sh
Normal 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 "$@"
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
18
deploy/pms-backup.cron
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
362
docs/API.md
362
docs/API.md
@ -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` | 项目管理 |
|
||||||
|
|||||||
@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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": "更新成功" })))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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` 为空串时跳过台账)。
|
||||||
|
|||||||
@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user