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

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

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

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

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

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

179 lines
7.1 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 "$@"