From d4b69f10f99745f572d64923593a589a8c05f712 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 16 Mar 2026 17:30:55 +0800 Subject: [PATCH] Refactor backup config and Telegram notifications --- .gitignore | 1 + backup.sh | 227 +++++++++++++------------------------- clean_backups.sh | 71 ++---------- env.conf.example | 7 +- exclude.list | 18 +-- exclude.user.list.example | 31 ++++++ lib/telegram.sh | 138 +++++++++++++++++++++++ readme.md | 54 ++++----- 8 files changed, 293 insertions(+), 254 deletions(-) mode change 100644 => 100755 clean_backups.sh create mode 100644 exclude.user.list.example create mode 100755 lib/telegram.sh diff --git a/.gitignore b/.gitignore index 4c08b6c..ead7a8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ env.conf backup.log +exclude.user.list diff --git a/backup.sh b/backup.sh index 33233a9..8a9239b 100755 --- a/backup.sh +++ b/backup.sh @@ -3,24 +3,19 @@ # - 从同目录 env.conf 加载配置,支持命令行覆盖 # - 打包多目录(支持 pigz),生成 SHA256 校验,obsutil 分片/并发上传 # - 上传 .tar.gz 以及 .tar.gz.sha256,并下载远端 .sha256 比对确保完整性 -# - 成功/失败写入日志并 Telegram 通知(Markdown) +# - 成功/失败写入日志并 Telegram 通知 # - 支持 --dry-run / --verify / --keep-local / --concurrency / --rate / --label / --sse # 退出码:0 成功;2 配置缺失;3 打包失败;4 上传失败;5 校验失败 set -Eeuo pipefail -############################# -# 通用工具函数 -############################# -# 兼容性更好的脚本路径解析(在 set -u 下也安全) SCRIPT_PATH="${BASH_SOURCE[0]:-$0}" SCRIPT_DIR="$(cd "$(dirname -- "$SCRIPT_PATH")" && pwd -P)" export PATH="$SCRIPT_DIR:$PATH" cd "$SCRIPT_DIR" -# 记录日志到文件与终端 -log_ts() { date '+%Y-%m-%d %H:%M:%S'; } LOG_PREFIX="[BACKUP]" +log_ts() { date '+%Y-%m-%d %H:%M:%S'; } log() { local level="$1"; shift local line @@ -28,82 +23,20 @@ log() { echo "$line" | tee -a "${LOG_FILE:-./backup.log}" } -############################# -# Telegram 通知(Markdown) -############################# -send_telegram() { - local status="${1-}" # success | error | dryrun - local reason="${2-}" # 错误原因(可选) - local error_log="${3-}" # 详细错误日志(可选) +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/telegram.sh" - if [[ -z "${TG_BOT_TOKEN:-}" || -z "${TG_USER_ID:-}" ]]; then - log "INFO" "未配置Telegram通知,跳过发送" - return 0 - fi - - local api="https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" - local current_date; current_date="$(date +'%Y-%m-%d %H:%M:%S')" - local message - - if [[ "$status" == "success" ]]; then - message="*备份成功通知*\n\n" - message+="备份主机:${LABEL}\n" - message+="备份时间:${current_date}\n" - message+="备份内容:${BACKUP_DIR}\n" - message+="文件大小:${BACKUP_SIZE_HUMAN}\n" - message+="存储位置:obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/${ARCHIVE_BASE}\n" - message+="打包用时:${PACK_SECONDS}s\n" - message+="上传用时:${UP_SECONDS}s\n" - message+="平均速率:${SPEED_HUMAN}" - elif [[ "$status" == "dryrun" ]]; then - message="*备份自检(Dry-run)*\n\n" - message+="备份主机:${LABEL}\n" - message+="备份时间:${current_date}\n" - message+="备份内容:${BACKUP_DIR}\n" - message+="归档名称:${ARCHIVE_BASE}\n" - message+="文件大小:${BACKUP_SIZE_HUMAN}\n" - message+="打包用时:${PACK_SECONDS}s" - else - message="*备份失败通知*\n\n${LABEL} 备份失败!" - if [[ -n "$reason" ]]; then - message+="\n原因:${reason}" - fi - if [[ -n "$error_log" ]]; then - message+="\n错误日志:\n\`\`\`${error_log}\`\`\`" - fi - fi - - # 仅把字面 \n 转成 %0A;保留其他字符原样 - local encoded_message=${message//\\n/%0A} - - local response - response="$(curl -sS -X POST "$api" \ - -d "chat_id=${TG_USER_ID}" \ - -d "parse_mode=Markdown" \ - -d "disable_web_page_preview=true" \ - -d "text=${encoded_message}" 2>&1 || true)" - - if echo "$response" | grep -q '"ok":true'; then - log "INFO" "Telegram通知发送成功" - else - log "WARN" "Telegram通知发送失败: $response" - fi -} - - -# 失败统一处理(触发 trap) on_error() { local exit_code=$? local line_no=${BASH_LINENO[0]:-} - log "ERROR" "脚本异常退出(exit=$exit_code, line=${line_no})." local tail_log + log "ERROR" "脚本异常退出(exit=$exit_code, line=${line_no})." tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)" - send_telegram "error" "脚本异常退出(exit=${exit_code}, line=${line_no})" "$tail_log" + tg_backup_error "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "脚本异常退出(exit=${exit_code}, line=${line_no})" "$tail_log" exit "$exit_code" } trap on_error ERR -# 命令可用性检查 need_cmd() { command -v "$1" >/dev/null 2>&1 || { log "ERROR" "未找到命令:$1" @@ -111,7 +44,6 @@ need_cmd() { } } -# 通用重试(指数回退 2^n 秒) retry() { local max="$1"; shift local desc="$1"; shift @@ -129,10 +61,6 @@ retry() { done } -############################# -# 加载配置与参数 -############################# - CONF_FILE="${SCRIPT_DIR}/env.conf" if [[ ! -f "$CONF_FILE" ]]; then echo "未找到配置文件 ${CONF_FILE},请先复制/编辑 env.conf" >&2 @@ -142,14 +70,15 @@ fi # shellcheck disable=SC1090 source "$CONF_FILE" -# 默认参数值(可被命令行覆盖) +BACKUP_PATHS=() + KEEP_LOCAL=false DRY_RUN=false VERIFY_ONLY=false CLI_LABEL="" -CLI_SSE="none" # none|kms|aes256 -CLI_CONCURRENCY="" # 映射到 -j -CLI_RATE="" # 速率限制(示例 10MB/s) +CLI_SSE="none" +CLI_CONCURRENCY="" +CLI_RATE="" GPG_RECIPIENT="" GPG_SYM=false @@ -164,7 +93,6 @@ usage() { USAGE } -# 解析参数 while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true; shift;; @@ -181,39 +109,52 @@ while [[ $# -gt 0 ]]; do esac done -# 应用 label 覆盖(仅影响远端前缀,不改变本地) if [[ -n "$CLI_LABEL" ]]; then LABEL="$CLI_LABEL" OBS_BACKUP_DIR="backup/${LABEL}" fi -# 并发覆盖 if [[ -n "$CLI_CONCURRENCY" ]]; then OBS_JOBS="$CLI_CONCURRENCY" fi -# 速率(obsutil 具体参数随版本可能不同,这里传递 --rate 或 -limit 的等价形式) OBS_RATE_FLAG="" if [[ -n "${CLI_RATE:-}" ]]; then OBS_RATE_FLAG="--rate=${CLI_RATE}" fi -# 路径与文件 -EXCLUDE_FILE="${SCRIPT_DIR}/exclude.list" -[[ -f "$EXCLUDE_FILE" ]] || touch "$EXCLUDE_FILE" +DEFAULT_EXCLUDE_FILE="${SCRIPT_DIR}/exclude.list" +USER_EXCLUDE_FILE="${SCRIPT_DIR}/exclude.user.list" +[[ -f "$DEFAULT_EXCLUDE_FILE" ]] || touch "$DEFAULT_EXCLUDE_FILE" +[[ -f "$USER_EXCLUDE_FILE" ]] || cp -f "${SCRIPT_DIR}/exclude.user.list.example" "$USER_EXCLUDE_FILE" -# 基础校验 : "${OBS_BUCKET:?缺少 OBS_BUCKET}" : "${OBS_BACKUP_DIR:?缺少 OBS_BACKUP_DIR}" -: "${BACKUP_DIR:?缺少 BACKUP_DIR}" : "${LOG_FILE:?缺少 LOG_FILE}" : "${OBS_PARALLEL:?缺少 OBS_PARALLEL}" "${OBS_JOBS:?缺少 OBS_JOBS}" : "${OBS_PARTSIZE:?缺少 OBS_PARTSIZE}" "${OBS_THRESHOLD:?缺少 OBS_THRESHOLD}" : "${RETAIN_DAYS:?缺少 RETAIN_DAYS}" -############################# -# 依赖检查与自检 -############################# +if [[ -z "${BACKUP_DIRS:-}" && -n "${BACKUP_DIR:-}" ]]; then + BACKUP_DIRS="$(printf '%s\n' "$BACKUP_DIR")" +fi + +if [[ -z "${BACKUP_DIRS:-}" ]]; then + echo "缺少 BACKUP_DIRS 配置" >&2 + exit 2 +fi + +while IFS= read -r path; do + [[ -n "$path" ]] || continue + [[ "$path" =~ ^[[:space:]]*# ]] && continue + BACKUP_PATHS+=("$path") +done <<< "$BACKUP_DIRS" + +if [[ "${#BACKUP_PATHS[@]}" -eq 0 ]]; then + echo "BACKUP_DIRS 没有有效目录" >&2 + exit 2 +fi + need_cmd tar need_cmd sha256sum need_cmd curl @@ -225,7 +166,6 @@ if ! command -v obsutil >/dev/null 2>&1; then exit 2 fi -# 严格按要求:配置文件位于 ~/.obsutilconfig OBSUTIL_CONFIG_PATH="${HOME}/.obsutilconfig" if [[ ! -f "$OBSUTIL_CONFIG_PATH" ]]; then log "ERROR" "未检测到 obsutil 配置文件:${OBSUTIL_CONFIG_PATH}" @@ -233,9 +173,6 @@ if [[ ! -f "$OBSUTIL_CONFIG_PATH" ]]; then exit 2 fi -############################# -# verify-only: 远端可用性检查 -############################# if $VERIFY_ONLY; then log "INFO" "开始远端可用性检查(ls + stat)" set +e @@ -256,39 +193,33 @@ if $VERIFY_ONLY; then fi set -e -############################# -# 打包准备 -############################# - -# 组装要打包的 -C 与相对路径参数 build_tar_file_list() { local args=() - for p in $BACKUP_DIR; do + local p abs parent base + for p in "${BACKUP_PATHS[@]}"; do if [[ ! -e "$p" ]]; then log "WARN" "路径不存在,跳过:$p" continue fi - local abs; abs="$(readlink -f "$p")" - local parent; parent="$(dirname "$abs")" - local base; base="$(basename "$abs")" + abs="$(readlink -f "$p")" + parent="$(dirname "$abs")" + base="$(basename "$abs")" args+=("-C" "$parent" "$base") done printf '%s\n' "${args[@]}" } -# 粗略空间检查 check_space() { local total=0 - for p in $BACKUP_DIR; do + local p sz avail need + for p in "${BACKUP_PATHS[@]}"; do [[ -e "$p" ]] || continue - local sz - sz=$(du -sb --apparent-size "$p" 2>/dev/null | awk '{s+=$1} END{print s+0}') + sz="$(du -sb --apparent-size "$p" 2>/dev/null | cut -f1)" total=$((total + sz)) done - local avail - avail=$(df -P "$SCRIPT_DIR" | awk 'NR==2{print $4}') # KB + avail=$(df -P "$SCRIPT_DIR" | awk 'NR==2{print $4}') avail=$((avail * 1024)) - local need=$(( total + total/10 + 100*1024*1024 )) + need=$(( total + total/10 + 100*1024*1024 )) if (( avail < need )); then log "ERROR" "磁盘空间不足。需要约 $need 字节,可用 $avail 字节。" return 1 @@ -305,30 +236,35 @@ SHA_PATH="${ARCHIVE_PATH}.sha256" USE_PIGZ=false if command -v pigz >/dev/null 2>&1; then USE_PIGZ=true; fi -# 打包与压缩 pack() { - local start; start=$(date +%s) - local tar_args; mapfile -t tar_args < <(build_tar_file_list) + local start end + local tar_args + start=$(date +%s) + mapfile -t tar_args < <(build_tar_file_list) if [[ "${#tar_args[@]}" -eq 0 ]]; then - log "ERROR" "没有可打包的路径(BACKUP_DIR 均不存在)" + log "ERROR" "没有可打包的路径(BACKUP_DIRS 均不存在)" return 1 fi - log "INFO" "开始打包(支持 exclude.list),pigz=${USE_PIGZ}" + log "INFO" "开始打包(支持默认与用户排除规则),pigz=${USE_PIGZ}" if $USE_PIGZ; then - tar --warning=no-file-changed --exclude-from="$EXCLUDE_FILE" \ + tar --warning=no-file-changed \ + --exclude-from="$DEFAULT_EXCLUDE_FILE" \ + --exclude-from="$USER_EXCLUDE_FILE" \ -c "${tar_args[@]}" | pigz > "$ARCHIVE_PATH" else - tar --warning=no-file-changed --exclude-from="$EXCLUDE_FILE" \ + tar --warning=no-file-changed \ + --exclude-from="$DEFAULT_EXCLUDE_FILE" \ + --exclude-from="$USER_EXCLUDE_FILE" \ -czf "$ARCHIVE_PATH" "${tar_args[@]}" fi - local end; end=$(date +%s) + end=$(date +%s) PACK_SECONDS=$((end - start)) - PACK_SIZE=$(stat -c '%s' "$ARCHIVE_PATH") - log "INFO" "打包完成:$(numfmt --to=iec "$PACK_SIZE") 用时 ${PACK_SECONDS}s" + PACK_SIZE="$(stat -c '%s' "$ARCHIVE_PATH")" + BACKUP_SIZE_HUMAN="$(numfmt --to=iec "$PACK_SIZE")" + log "INFO" "打包完成:${BACKUP_SIZE_HUMAN} 用时 ${PACK_SECONDS}s" } -# 可选:GPG 加密 maybe_encrypt() { if [[ -n "$GPG_RECIPIENT" ]]; then log "INFO" "使用 GPG 收件人加密:$GPG_RECIPIENT" @@ -349,15 +285,16 @@ maybe_encrypt() { ARCHIVE_PATH="${ARCHIVE_PATH}.gpg" ARCHIVE_BASE="${ARCHIVE_BASE}.gpg" fi + + PACK_SIZE="$(stat -c '%s' "$ARCHIVE_PATH")" + BACKUP_SIZE_HUMAN="$(numfmt --to=iec "$PACK_SIZE")" } -# 生成 sha256 gen_sha() { sha256sum "$ARCHIVE_PATH" > "$SHA_PATH" log "INFO" "已生成校验文件:${SHA_PATH##*/}" } -# 构建 obsutil 参数 build_obs_flags() { local flags=() flags+=("-f") @@ -383,14 +320,15 @@ build_obs_flags() { printf '%s\n' "${flags[@]}" } -# 上传(含重试) upload() { - local start; start=$(date +%s) - local dst_dir="obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/" - local flags; mapfile -t flags < <(build_obs_flags) + local start end dst_dir + local flags + start=$(date +%s) + dst_dir="obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/" + mapfile -t flags < <(build_obs_flags) retry 3 "上传归档 ${ARCHIVE_BASE}" obsutil cp "$ARCHIVE_PATH" "${dst_dir}${ARCHIVE_BASE}" "${flags[@]}" retry 3 "上传校验 ${ARCHIVE_BASE}.sha256" obsutil cp "$SHA_PATH" "${dst_dir}${ARCHIVE_BASE}.sha256" "${flags[@]}" - local end; end=$(date +%s) + end=$(date +%s) UP_SECONDS=$((end - start)) if (( UP_SECONDS > 0 )); then AVG_SPEED="$(( PACK_SIZE / UP_SECONDS ))" @@ -399,11 +337,11 @@ upload() { fi } -# 远端完整性校验(下载 .sha256 比对) verify_remote() { - local tmp_remote="${SHA_PATH}.remote" + local tmp_remote dst + tmp_remote="${SHA_PATH}.remote" rm -f "$tmp_remote" - local dst="obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/${ARCHIVE_BASE}.sha256" + dst="obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/${ARCHIVE_BASE}.sha256" retry 3 "下载远端校验 ${ARCHIVE_BASE}.sha256" obsutil cp "$dst" "$tmp_remote" -f >/dev/null if ! cmp -s "$SHA_PATH" "$tmp_remote"; then log "ERROR" "远端 .sha256 与本地不一致" @@ -413,10 +351,6 @@ verify_remote() { log "INFO" "远端完整性校验通过(.sha256 一致)" } -############################# -# 主流程 -############################# - SECONDS=0 PACK_SECONDS=0 UP_SECONDS=0 @@ -430,37 +364,30 @@ retry 3 "打包与压缩" pack || { log "ERROR" "打包失败"; exit 3; } maybe_encrypt gen_sha -# 计算人类可读大小 -BACKUP_SIZE_HUMAN="$(numfmt --to=iec "$PACK_SIZE")" - if $DRY_RUN; then log "INFO" "Dry-run 模式:不执行上传。归档:${ARCHIVE_BASE} 大小:${BACKUP_SIZE_HUMAN}" log "INFO" "本次总用时:${SECONDS}s" - send_telegram "dryrun" + tg_backup_dryrun "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "$(printf '%s\n' "${BACKUP_PATHS[@]}")" "$(tg_read_user_excludes "$USER_EXCLUDE_FILE")" "$ARCHIVE_BASE" "$BACKUP_SIZE_HUMAN" "${PACK_SECONDS}s" exit 0 fi -# 上传 retry 3 "上传到 OBS" upload || { log "ERROR" "上传失败"; exit 4; } -# 完整性校验 if ! verify_remote; then + local_tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)" log "ERROR" "上传后完整性校验失败" - local tail_log; tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)" - send_telegram "error" "上传后完整性校验失败:${ARCHIVE_BASE}" "$tail_log" + tg_backup_error "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "上传后完整性校验失败:${ARCHIVE_BASE}" "$local_tail_log" exit 5 fi -# 清理本地 if ! $KEEP_LOCAL; then rm -f "$ARCHIVE_PATH" "$SHA_PATH" || true log "INFO" "已删除本地临时归档与校验文件" fi -# 汇总 TOTAL_SECS=$SECONDS SPEED_HUMAN="$(numfmt --to=iec "$AVG_SPEED")/s" log "INFO" "完成:归档大小=${BACKUP_SIZE_HUMAN} 打包=${PACK_SECONDS}s 上传=${UP_SECONDS}s 平均速率=${SPEED_HUMAN} 总用时=${TOTAL_SECS}s" -send_telegram "success" +tg_backup_success "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "$(printf '%s\n' "${BACKUP_PATHS[@]}")" "$(tg_read_user_excludes "$USER_EXCLUDE_FILE")" "$BACKUP_SIZE_HUMAN" "$ARCHIVE_BASE" "obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/${ARCHIVE_BASE}" "${PACK_SECONDS}s" "${UP_SECONDS}s" "$SPEED_HUMAN" exit 0 diff --git a/clean_backups.sh b/clean_backups.sh old mode 100644 new mode 100755 index 5c512e2..bb709d4 --- a/clean_backups.sh +++ b/clean_backups.sh @@ -2,7 +2,7 @@ # 清理 OBS 历史备份:按文件名前缀与日期,仅保留最近 N 天 # - 从同目录 env.conf 加载配置;支持 --retain-days / --prefix 覆盖 # - 删除匹配 backup_YYYYmmdd_HHMMSS.tar.gz[.gpg] 以及对应 .sha256 -# - 记录日志并发送 Telegram 摘要(Markdown) +# - 记录日志并发送 Telegram 摘要 # 退出码:0 成功;2 配置缺失/依赖缺失;6 清理失败 set -Eeuo pipefail @@ -21,64 +21,15 @@ log() { echo "$line" | tee -a "${LOG_FILE:-./backup.log}" } -############################# -# Telegram 通知(Markdown) -############################# -send_telegram() { - local status="${1-}" # success | error - local reason="${2-}" # 错误原因(可选) - local error_log="${3-}" # 详细错误日志(可选) - - if [[ -z "${TG_BOT_TOKEN:-}" || -z "${TG_USER_ID:-}" ]]; then - log "INFO" "未配置Telegram通知,跳过发送" - return 0 - fi - - local api="https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" - local current_date; current_date="$(date +'%Y-%m-%d %H:%M:%S')" - local message - - if [[ "$status" == "success" ]]; then - message="*清理完成通知*\n\n" - message+="主机:${LABEL}\n" - message+="时间:${current_date}\n" - message+="前缀:obs://${OBS_BUCKET}/${TARGET_PREFIX}\n" - message+="保留天数:${RETAIN_DAYS}\n" - message+="保留对象数:${#TO_KEEP[@]}\n" - message+="删除成功:${DEL_OK}\n" - message+="删除失败:${DEL_FAIL}" - else - message="*清理失败通知*\n\n${LABEL} 清理任务失败!" - if [[ -n "$reason" ]]; then - message+="\n原因:${reason}" - fi - if [[ -n "$error_log" ]]; then - message+="\n错误日志:\n\`\`\`${error_log}\`\`\`" - fi - fi - - # 转换换行符 \n -> %0A - local encoded_message=${message//\\n/%0A} - - local response - response="$(curl -sS -X POST "$api" \ - -d "chat_id=${TG_USER_ID}" \ - -d "parse_mode=Markdown" \ - -d "disable_web_page_preview=true" \ - -d "text=${encoded_message}" 2>&1 || true)" - - if echo "$response" | grep -q '"ok":true'; then - log "INFO" "Telegram通知发送成功" - else - log "WARN" "Telegram通知发送失败: $response" - fi -} +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/telegram.sh" on_error() { local exit_code=$? + local tail_log log "ERROR" "清理异常退出(exit=${exit_code})" - local tail_log; tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)" - send_telegram "error" "脚本异常退出(exit=${exit_code})" "$tail_log" + tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)" + tg_clean_error "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "脚本异常退出(exit=${exit_code})" "$tail_log" exit "$exit_code" } trap on_error ERR @@ -102,7 +53,6 @@ source "$CONF_FILE" need_cmd obsutil -# 严格按要求:配置文件位于 ~/.obsutilconfig OBSUTIL_CONFIG_PATH="${HOME}/.obsutilconfig" if [[ ! -f "$OBSUTIL_CONFIG_PATH" ]]; then log "ERROR" "未检测到 obsutil 配置文件:${OBSUTIL_CONFIG_PATH}" @@ -110,7 +60,6 @@ if [[ ! -f "$OBSUTIL_CONFIG_PATH" ]]; then exit 2 fi -# 覆盖参数 CLI_RETAIN="" CLI_PREFIX="" usage() { @@ -134,15 +83,13 @@ if [[ -n "$CLI_PREFIX" ]]; then TARGET_PREFIX="${TARGET_PREFIX}${CLI_PREFIX%/}/" log "INFO" "开始清理:bucket=${OBS_BUCKET} prefix=${TARGET_PREFIX} 保留最近 ${RETAIN_DAYS} 天" -# 计算截止日期(N 天前零点,格式 YYYYmmdd) CUTOFF_DATE="$(date -d "-${RETAIN_DAYS} days" +%Y%m%d)" -# 列举对象 LIST_OUT="$(mktemp)" if ! obsutil ls "obs://${OBS_BUCKET}/${TARGET_PREFIX}" >"$LIST_OUT" 2>/dev/null; then log "ERROR" "无法列举 obs://${OBS_BUCKET}/${TARGET_PREFIX}" rm -f "$LIST_OUT" - send_telegram "error" "无法列举 obs://${OBS_BUCKET}/${TARGET_PREFIX}" "" + tg_clean_error "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "无法列举 obs://${OBS_BUCKET}/${TARGET_PREFIX}" "" exit 6 fi @@ -167,7 +114,6 @@ for obj in "${OBJECTS[@]}"; do fi done -# 去重 if [[ "${#TO_DELETE[@]}" -gt 0 ]]; then mapfile -t TO_DELETE < <(printf '%s\n' "${TO_DELETE[@]}" | awk '!a[$0]++') fi @@ -190,8 +136,7 @@ else done fi -# 通知与退出码 -send_telegram "success" +tg_clean_success "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "obs://${OBS_BUCKET}/${TARGET_PREFIX}" "${RETAIN_DAYS}" "${#TO_KEEP[@]}" "${DEL_OK}" "${DEL_FAIL}" if (( DEL_FAIL > 0 )); then exit 6 fi diff --git a/env.conf.example b/env.conf.example index 7b183f3..bb5cfe2 100644 --- a/env.conf.example +++ b/env.conf.example @@ -7,8 +7,11 @@ OBS_BACKUP_DIR="backup/${LABEL}" # 备份存放目录 TG_BOT_TOKEN="" # 留空表示不启用 TG_USER_ID="" # 留空表示不启用 -# 备份目录,支持多个目录,用空格分隔 -BACKUP_DIR="/root/data" +# 备份目录,使用多行格式;每行一个目录 +BACKUP_DIRS=$(cat <<'EOF' +/root/data +EOF +) # 日志目录 LOG_FILE="./backup.log" diff --git a/exclude.list b/exclude.list index ec3bb17..104c546 100644 --- a/exclude.list +++ b/exclude.list @@ -2,22 +2,12 @@ # exclude.list # ============================================================ # 说明: -# 1. 本文件由 tar --exclude-from 读取,匹配的是“归档内相对路径”,不是绝对路径。 -# 2. 排除目录时,建议同时写: +# 1. 本文件只放“默认排除规则”,一般不用修改。 +# 2. 如果要添加业务自定义排除规则,请编辑 exclude.user.list。 +# 3. 规则由 tar --exclude-from 读取,匹配的是归档内相对路径,不是绝对路径。 +# 4. 排除目录时,建议同时写: # */dir # */dir/* -# 3. 如果要排除单个业务文件或业务目录,请优先追加到下面“用户自定义规则”区域。 - -# ============================================================ -# 用户自定义规则 -# 在下面追加你自己的业务排除项 -# -# 示例:排除单个文件 -# */project/src/main.js -# -# 示例:排除一个目录 -# */project/cache -# */project/cache/* # ============================================================ # ============================================================ diff --git a/exclude.user.list.example b/exclude.user.list.example new file mode 100644 index 0000000..815f640 --- /dev/null +++ b/exclude.user.list.example @@ -0,0 +1,31 @@ +# ============================================================ +# exclude.user.list +# ============================================================ +# 说明: +# 1. 这个文件只放“用户自定义排除规则”。 +# 2. backup.sh 会自动同时加载 exclude.list 和 exclude.user.list。 +# 3. 本文件建议复制为 exclude.user.list 后自行修改。 +# 4. exclude.user.list 已加入 .gitignore,不会提交到 Git。 +# +# 使用方法: +# cp exclude.user.list.example exclude.user.list +# +# 规则示例: +# - 排除单个文件 +# */project/src/main.js +# +# - 排除一个目录 +# */project/cache +# */project/cache/* +# +# - 排除某个固定路径 +# /root/data/dify +# +# - 排除任意位置的隐藏目录 +# */.halo +# */.halo/* +# ============================================================ + +/root/data/dify +*/.halo +*/.halo/* diff --git a/lib/telegram.sh b/lib/telegram.sh new file mode 100755 index 0000000..723aa72 --- /dev/null +++ b/lib/telegram.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +tg_code_block() { + local text="${1-}" + text="$(printf '%s' "$text" | sed -e 's/\\/\\\\/g' -e 's/`/\\`/g')" + printf '```text\n%s\n```' "$text" +} + +tg_send_message() { + local message="${1-}" + local response api + + if [[ -z "${TG_BOT_TOKEN:-}" || -z "${TG_USER_ID:-}" ]]; then + log "INFO" "未配置Telegram通知,跳过发送" + return 0 + fi + + api="https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" + response="$(curl -sS -X POST "$api" \ + --data-urlencode "chat_id=${TG_USER_ID}" \ + --data-urlencode "parse_mode=MarkdownV2" \ + --data-urlencode "disable_web_page_preview=true" \ + --data-urlencode "text=${message}" 2>&1 || true)" + + if echo "$response" | grep -q '"ok":true'; then + log "INFO" "Telegram通知发送成功" + else + log "WARN" "Telegram通知发送失败: $response" + fi +} + +tg_read_user_excludes() { + local file="${1-}" + local out="" rule + + [[ -f "$file" ]] || { + printf '无' + return 0 + } + + while IFS= read -r rule; do + [[ -n "$rule" ]] || continue + [[ "$rule" =~ ^[[:space:]]*# ]] && continue + out+="${rule}"$'\n' + done < "$file" + + if [[ -z "$out" ]]; then + printf '无' + else + printf '%s' "${out%$'\n'}" + fi +} + +tg_backup_success() { + local label="$1" current_date="$2" backup_dirs="$3" user_excludes="$4" backup_size="$5" archive_name="$6" location="$7" pack_time="$8" upload_time="$9" speed="${10}" + local archive_info message + archive_info=$( + cat <