179 lines
7.1 KiB
Bash
Raw Permalink Normal View History

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