From ad2305305b5b1bbfcc6d776d9d420ff2cf036552 Mon Sep 17 00:00:00 2001 From: aiyimickey <39365912+aiyimickey@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:30:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=206=E2=86=922=20=E2=80=94=20backup.sh?= =?UTF-8?q?=20+=20restore.sh=20=E7=BB=9F=E4=B8=80=E5=A4=87=E4=BB=BD/?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backup.sh: --db-only / --full / 默认站点完整备份 - restore.sh: --db / --full / 默认交互式站点恢复 - auto-discovery 减少到 1 份(旧方案 6 份) - 删除旧脚本 backup-sites/db/full.sh + restore-sites/db/full.sh - 更新 README --- README.md | 26 ++-- backup/README.md | 51 ++++---- backup/backup.sh | 206 ++++++++++++++++++++++++++++++ backup/restore.sh | 314 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 554 insertions(+), 43 deletions(-) create mode 100644 backup/backup.sh create mode 100644 backup/restore.sh diff --git a/README.md b/README.md index 42ebd73..0cb8af9 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,8 @@ | 脚本 | 用途 | 说明 | |------|------|------| -| `backup/backup-sites.sh` | 站点独立备份 | **推荐**:按站点独立打包 | -| `backup/backup-db.sh` | 仅数据库备份 | 轻量快速 | -| `backup/backup-full.sh` | 完整灾难恢复备份 | 包含所有数据 | -| `backup/restore-sites.sh` | 站点恢复 | 交互式选择恢复 | -| `backup/restore-db.sh` | 数据库恢复 | 恢复单个数据库 | -| `backup/restore-full.sh` | 完整恢复 | 灾难恢复使用 | +| `backup/backup.sh` | 统一备份 | --db-only / --full | +| `backup/restore.sh` | 统一恢复 | --db / --full | > 详细文档见 [backup/README.md](backup/README.md) @@ -88,19 +84,19 @@ LDAP 部署特殊处理:上传 Dockerfile + docker-compose.yml,自动创建 ## backup/ — 备份与恢复 ```bash -# 站点独立备份(推荐) -bash scripts/backup/backup-sites.sh # 备份所有站点 -bash scripts/backup/backup-sites.sh assets issue # 备份指定站点 +# 站点完整备份(推荐) +bash scripts/backup/backup.sh # 备份所有 +bash scripts/backup/backup.sh assets monitor # 指定站点 +bash scripts/backup/backup.sh --db-only # 仅数据库 +bash scripts/backup/backup.sh --full # 灾难备份 # 恢复 -bash scripts/backup/restore-sites.sh # 交互式恢复 -bash scripts/backup/restore-sites.sh 20260622_101858 # 指定时间戳 - -# 数据库单独备份 -bash scripts/backup/backup-db.sh +bash scripts/backup/restore.sh # 交互式 +bash scripts/backup/restore.sh --db issue file.db # 数据库恢复 +bash scripts/backup/restore.sh --full backup.tar # 灾难恢复 ``` -支持站点:`assets` / `issue` / `ldap` / `gitea` / `oa` / `monitor`(自动发现,无需手动添加) +支持站点:自动发现所有含 DB 的 `*-ai` 目录,无需手动添加 备份内容:数据库 + 上传文件 + 配置文件 + nginx + SSL 证书 diff --git a/backup/README.md b/backup/README.md index 48ec91a..ae293c0 100644 --- a/backup/README.md +++ b/backup/README.md @@ -6,51 +6,46 @@ ``` scripts/backup/ -├── backup-db.sh # 数据库备份(轻量) -├── backup-full.sh # 完整灾难恢复备份 -├── backup-sites.sh # 站点独立备份(推荐) -├── restore-db.sh # 数据库恢复 -├── restore-full.sh # 完整恢复 -└── restore-sites.sh # 站点恢复 +├── backup.sh # 统一备份(默认=站点完整,--db-only=仅DB,--full=灾难) +└── restore.sh # 统一恢复(默认=交互式,--db=数据库,--full=灾难) ``` -## 备份策略对比 +## 备份策略 -| 脚本 | 内容 | 大小 | 用途 | +| 命令 | 内容 | 大小 | 用途 | |------|------|------|------| -| backup-db.sh | 仅数据库 | ~3M | 日常快速备份 | -| backup-sites.sh | 数据库+配置+文件 | ~135M | **推荐**:按站点独立备份 | -| backup-full.sh | 全部数据 | ~135M | 完整灾难恢复 | +| `backup.sh` | 数据库+配置+文件 | ~135M | **推荐**:按站点独立备份 | +| `backup.sh --db-only` | 仅数据库 | ~3M | 日常快速备份 | +| `backup.sh --full` | 全部数据 | ~135M | 完整灾难恢复 | ## 使用方法 -### 站点独立备份(推荐) +### 备份 ```bash -# 备份所有站点 -bash scripts/backup/backup-sites.sh +# 站点完整备份(默认,推荐) +bash scripts/backup/backup.sh +bash scripts/backup/backup.sh assets monitor # 指定站点 -# 仅备份指定站点 -bash scripts/backup/backup-sites.sh assets issue +# 仅数据库 +bash scripts/backup/backup.sh --db-only -# 可用站点(自动发现含 DB 的 *-ai 目录):assets, issue, ldap, gitea, oa, monitor +# 灾难备份 +bash scripts/backup/backup.sh --full ``` -### 恢复站点 +### 恢复 ```bash -# 交互式恢复(自动检测备份,选择站点) -bash scripts/backup/restore-sites.sh +# 交互式恢复(默认,自动检测备份) +bash scripts/backup/restore.sh +bash scripts/backup/restore.sh 20260708_120000 # 指定时间戳 -# 指定时间戳恢复 -bash scripts/backup/restore-sites.sh 20260622_101858 -``` +# 数据库恢复 +bash scripts/backup/restore.sh --db monitor monitor-20260708.db -### 数据库单独备份 - -```bash -# 仅备份数据库(快速) -bash scripts/backup/backup-db.sh +# 灾难恢复 +bash scripts/backup/restore.sh --full full-backup-20260708.tar.gz ``` ## 备份内容 diff --git a/backup/backup.sh b/backup/backup.sh new file mode 100644 index 0000000..3532204 --- /dev/null +++ b/backup/backup.sh @@ -0,0 +1,206 @@ +#!/bin/bash +# 统一备份脚本 +# 用法: +# bash backup.sh # 站点完整备份(默认,推荐) +# bash backup.sh assets monitor # 备份指定站点 +# bash backup.sh --db-only # 仅数据库备份 +# bash backup.sh --full # 灾难恢复完整备份 +set -e + +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +LOCAL_DIR="/Users/niuniu/programs/docker/db-backups" +REMOTE_BASE="/root/docker" +CLOUD_DIR="/root/docker/db-backups" + +log() { echo "[$(date '+%H:%M:%S')] $1"; } +warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; } + +# === 自动发现含数据库的站点 === +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DB_SITES="" +for dir in "$PROJECT_ROOT"/*-ai/; do + s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue + [ -f "$dir/docker-compose.yml" ] || continue + grep -qE "\.db|DATABASE_PATH|sqlite" "$dir/docker-compose.yml" 2>/dev/null || continue + [[ "$s" == "oa" ]] && continue + DB_SITES="$DB_SITES $s" +done + +# === 模式 1:站点完整备份(默认)=== +backup_site() { + local site="$1" + local backup_name="${site}-${TIMESTAMP}.tar.gz" + log "备份 ${site}..." + + ssh txjp "rm -rf /tmp/backup-${site} && mkdir -p /tmp/backup-${site}/data" + + case "$site" in + assets) + ssh txjp "docker exec assets-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/assets.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || true + ssh txjp "docker cp assets-ai:/app/data/assets.db /tmp/backup-${site}/data/" + ssh txjp "cp -a /root/docker/assets-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true" + ssh txjp "cp /root/docker/assets-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/assets-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/assets-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" + ;; + issue) + ssh txjp "docker exec issue-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/issue.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || true + ssh txjp "docker cp issue-ai:/app/data/issue.db /tmp/backup-${site}/data/" + ssh txjp "cp -a /root/docker/issue-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true" + ssh txjp "cp -a /root/docker/issue-ai/data/reports /tmp/backup-${site}/data/ 2>/dev/null || true" + ssh txjp "cp /root/docker/issue-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/issue-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/issue-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" + ;; + ldap) + ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true + ssh txjp "docker cp lldap:/data/users.db /tmp/backup-${site}/data/" + ssh txjp "cp /root/docker/ldap-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/ldap-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/ldap-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" + ;; + gitea) + ssh txjp "docker cp gitea-ai:/data/gitea.db /tmp/backup-${site}/data/" + ssh txjp "cp -a /root/docker/gitea-ai/data/git /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/backup-${site}/gitea-conf 2>/dev/null || true" + ssh txjp "cp /root/docker/gitea-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" + ;; + oa) + ssh txjp "cp /root/docker/oa-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/oa-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" + ;; + monitor) + ssh txjp "docker exec monitor-ai sqlite3 /app/data/monitor.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true + ssh txjp "docker cp monitor-ai:/app/data/monitor.db /tmp/backup-${site}/data/" + ssh txjp "cp /root/docker/monitor-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/monitor-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" + ;; + *) + warn "${site}: 无专用备份逻辑,仅备份配置文件" + ssh txjp "cp /root/docker/${site}/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" + ssh txjp "cp /root/docker/${site}/.env /tmp/backup-${site}/ 2>/dev/null || true" + ;; + esac + + ssh txjp "cd /tmp && tar czf ${backup_name} backup-${site}/" + mkdir -p "${LOCAL_DIR}"; scp "txjp:/tmp/${backup_name}" "${LOCAL_DIR}/" + ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/" + ssh txjp "rm -rf /tmp/backup-${site} /tmp/${backup_name}" + log "${site} 备份完成: ${backup_name}" +} + +backup_shared() { + local backup_name="shared-${TIMESTAMP}.tar.gz" + log "备份共享资源..." + ssh txjp "rm -rf /tmp/backup-shared && mkdir -p /tmp/backup-shared" + ssh txjp "mkdir -p /tmp/backup-shared/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/backup-shared/nginx/ 2>/dev/null || true" + ssh txjp "mkdir -p /tmp/backup-shared/ssl && cp -a /etc/letsencrypt/live /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/backup-shared/ssl/ 2>/dev/null || true" + ssh txjp "cp /root/docker/nginx-proxy-ai/docker-compose.yml /tmp/backup-shared/ 2>/dev/null || true" + ssh txjp "cd /tmp && tar czf ${backup_name} backup-shared/" + mkdir -p "${LOCAL_DIR}"; scp "txjp:/tmp/${backup_name}" "${LOCAL_DIR}/" + ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/" + ssh txjp "rm -rf /tmp/backup-shared /tmp/${backup_name}" + log "共享资源备份完成: ${backup_name}" +} + +# === 模式 2:仅数据库备份 === +backup_db_only() { + log "开始数据库备份..." + + declare -A DB_CONTAINERS=([ldap]="lldap:/data/users.db" [gitea]="gitea-ai:/data/gitea.db") + + for s in $DB_SITES; do + if [[ -n "${DB_CONTAINERS[$s]}" ]]; then + ssh txjp "docker exec ${DB_CONTAINERS[$s]%%:*} sqlite3 ${DB_CONTAINERS[$s]##*:} 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & + else + ssh txjp "docker exec ${s} sqlite3 /app/data/*.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & + fi + done; wait + + ssh txjp "mkdir -p /tmp/db-backup" + for s in $DB_SITES; do + if [[ -n "${DB_CONTAINERS[$s]}" ]]; then + cname="${DB_CONTAINERS[$s]%%:*}"; dbpath="${DB_CONTAINERS[$s]##*:}" + else + cname="${s}"; short="${s%-ai}"; dbpath="/app/data/${short}.db" + fi + log "备份 ${s}..." + ssh txjp "docker cp ${cname}:${dbpath} /tmp/db-backup/${s}-${TIMESTAMP}.db" + done + + mkdir -p "${LOCAL_DIR}" + ssh txjp "mkdir -p ${CLOUD_DIR}" + for s in $DB_SITES; do + ssh txjp "cp /tmp/db-backup/${s}-${TIMESTAMP}.db ${CLOUD_DIR}/" + scp "txjp:/tmp/db-backup/${s}-${TIMESTAMP}.db" "${LOCAL_DIR}/" + f="${LOCAL_DIR}/${s}-${TIMESTAMP}.db" + sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1 || warn "${s} 备份文件无效" + done + + ssh txjp "rm -rf /tmp/db-backup" + log "数据库备份完成" +} + +# === 模式 3:灾难恢复完整备份 === +backup_full() { + log "开始完整灾难恢复备份..." + + for s in $DB_SITES; do + case "$s" in + ldap) ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true ;; + *) ssh txjp "docker exec ${s} sqlite3 /app/data/*.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & ;; + esac + done; wait + + ssh txjp "mkdir -p /tmp/full-backup/databases" + for s in $DB_SITES; do + case "$s" in + ldap) ssh txjp "docker cp lldap:/data/users.db /tmp/full-backup/databases/ldap.db" ;; + gitea) ssh txjp "docker cp gitea-ai:/data/gitea.db /tmp/full-backup/databases/gitea.db" ;; + *) short="${s%-ai}"; ssh txjp "docker cp ${s}:/app/data/${short}.db /tmp/full-backup/databases/${short}.db" ;; + esac + done + + # Gitea 代码仓库 + ssh txjp "mkdir -p /tmp/full-backup/gitea && cp -a /root/docker/gitea-ai/data/git /tmp/full-backup/gitea/ && cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/full-backup/gitea/ 2>/dev/null || true" + # 上传文件 + ssh txjp "mkdir -p /tmp/full-backup/uploads && cp -a /root/docker/assets-ai/data/uploads /tmp/full-backup/uploads/assets-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/uploads /tmp/full-backup/uploads/issue-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/reports /tmp/full-backup/uploads/issue-reports 2>/dev/null || true" + # Nginx + SSL + ssh txjp "mkdir -p /tmp/full-backup/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/full-backup/nginx/ 2>/dev/null || true" + ssh txjp "mkdir -p /tmp/full-backup/ssl && cp -a /etc/letsencrypt/live /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/full-backup/ssl/ 2>/dev/null || true" + + local name="full-backup-${TIMESTAMP}"; ssh txjp "cd /tmp && tar czf ${name}.tar.gz full-backup/" + mkdir -p "${LOCAL_DIR}"; scp "txjp:/tmp/${name}.tar.gz" "${LOCAL_DIR}/" + ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${name}.tar.gz ${CLOUD_DIR}/" + ssh txjp "rm -rf /tmp/full-backup /tmp/${name}.tar.gz" + log "完整备份完成: ${name}.tar.gz" +} + +# === 主流程 === +case "${1:-}" in + --db-only) + backup_db_only ;; + --full) + backup_full ;; + --help|-h) + echo "用法: bash backup.sh [--db-only|--full] [站点名...]" + echo " 无参数 站点完整备份(全部含 DB 站点)" + echo " --db-only 仅数据库备份" + echo " --full 灾难恢复完整备份" + echo " 站点名... 备份指定站点" + ;; + *) + TARGETS="${@:-all}" + log "站点完整备份: ${TARGETS}" + if [[ "$TARGETS" == "all" ]]; then + ALL="$(for s in $DB_SITES; do echo -n "$s "; done) oa" + for site in $ALL; do backup_site "$site"; done + backup_shared + else + for site in $TARGETS; do backup_site "$site"; done + fi + ;; +esac + +echo ""; log "备份完成!" +ls -lh ${LOCAL_DIR}/*-${TIMESTAMP}* 2>/dev/null diff --git a/backup/restore.sh b/backup/restore.sh new file mode 100644 index 0000000..529f0f6 --- /dev/null +++ b/backup/restore.sh @@ -0,0 +1,314 @@ +#!/bin/bash +# 统一恢复脚本 +# 用法: +# bash restore.sh # 交互式站点恢复(默认) +# bash restore.sh 20260708_120000 # 指定时间戳恢复 +# bash restore.sh --db issue issue.db # 数据库恢复 +# bash restore.sh --full full-backup.tar.gz # 灾难恢复 +set -e + +BACKUP_DIR="/Users/niuniu/programs/docker/db-backups" +REMOTE_BASE="/root/docker" + +log() { echo "[$(date '+%H:%M:%S')] $1"; } +warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; } +error() { echo "[$(date '+%H:%M:%S')] [错误] $1"; exit 1; } + +RESTORE_DIR="$(mktemp -d)" + +# === 自动发现可恢复站点 === +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ALL_SITES="shared" +for dir in "$PROJECT_ROOT"/*-ai/; do + s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue + [ -f "$dir/docker-compose.yml" ] || continue + ALL_SITES="$ALL_SITES $s" +done + +# === 工具函数 === +check_backup() { + local site="$1" ts="$2" + [[ -f "${BACKUP_DIR}/${site}-${ts}.tar.gz" ]] && echo "local:${BACKUP_DIR}/${site}-${ts}.tar.gz" && return + ssh txjp "test -f /root/docker/db-backups/${site}-${ts}.tar.gz" 2>/dev/null && echo "cloud:${site}-${ts}.tar.gz" && return + echo "" +} + +download_and_extract() { + local site="$1" ts="$2" + local backup_info=$(check_backup "$site" "$ts") + [[ -z "$backup_info" ]] && error "${site} 备份文件不存在: ${ts}" + + local src="${backup_info#*:}" + rm -rf "$RESTORE_DIR/backup-${site}"; mkdir -p "$RESTORE_DIR/backup-${site}" + + if [[ "$backup_info" == local:* ]]; then + log "从本地解压: ${BACKUP_DIR}/${site}-${ts}.tar.gz" + tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}" 2>/dev/null || tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}" --strip-components=1 2>/dev/null || tar tzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" --show-transformed-names 2>/dev/null | head -1 + tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}" + else + log "从云端下载: ${ts}" + scp "txjp:${src}" "$RESTORE_DIR/" + local f="$RESTORE_DIR/${site}-${ts}.tar.gz" + mkdir -p "$RESTORE_DIR/backup-${site}" + tar xzf "$f" -C "$RESTORE_DIR/backup-${site}" + fi +} + +# === 站点恢复函数 === +restore_shared() { + local ts="$TIMESTAMP" + local src="${RESTORE_DIR}/backup-shared" + [[ -f "$src/nginx"/*.conf ]] && { mkdir -p "$REMOTE_BASE/nginx-proxy-ai/conf.d"; scp "$src/nginx/"* txjp:"$REMOTE_BASE/nginx-proxy-ai/conf.d/"; } + ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || warn "nginx 重载失败" + log "共享配置恢复完成" +} + +restore_assets() { + log "恢复 assets..." + download_and_extract "assets" "$TIMESTAMP" + local src="${RESTORE_DIR}/backup-assets/backup-assets" + [[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-assets" + for f in docker-compose.yml .env Dockerfile; do + [[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/assets-ai/ + done + [[ -d "$src/data/uploads" ]] && ssh txjp "rm -rf /root/docker/assets-ai/data/uploads && cp -a $src/data/uploads /root/docker/assets-ai/data/" + [[ -f "$src/data/assets.db" ]] && ssh txjp "docker cp $src/data/assets.db assets-ai:/app/data/assets.db" + ssh txjp "cd $REMOTE_BASE/assets-ai && docker compose down 2>/dev/null || true && docker compose up -d" + log "assets 恢复完成" +} + +restore_issue() { + log "恢复 issue..." + download_and_extract "issue" "$TIMESTAMP" + local src="${RESTORE_DIR}/backup-issue/backup-issue" + [[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-issue" + for f in docker-compose.yml .env Dockerfile; do + [[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/issue-ai/ + done + [[ -d "$src/data/uploads" ]] && ssh txjp "rm -rf /root/docker/issue-ai/data/uploads && cp -a $src/data/uploads /root/docker/issue-ai/data/" + [[ -d "$src/data/reports" ]] && ssh txjp "rm -rf /root/docker/issue-ai/data/reports && cp -a $src/data/reports /root/docker/issue-ai/data/" + [[ -f "$src/data/issue.db" ]] && ssh txjp "docker cp $src/data/issue.db issue-ai:/app/data/issue.db" + ssh txjp "cd $REMOTE_BASE/issue-ai && docker compose down 2>/dev/null || true && docker compose up -d" + log "issue 恢复完成" +} + +restore_ldap() { + log "恢复 ldap..." + download_and_extract "ldap" "$TIMESTAMP" + local src="${RESTORE_DIR}/backup-ldap/backup-ldap" + [[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-ldap" + for f in docker-compose.yml .env Dockerfile; do + [[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/ldap-ai/ + done + [[ -f "$src/data/users.db" ]] && ssh txjp "docker cp $src/data/users.db lldap:/data/users.db" + ssh txjp "cd $REMOTE_BASE/ldap-ai && docker compose down 2>/dev/null || true && docker compose up -d" + log "ldap 恢复完成" +} + +restore_gitea() { + log "恢复 gitea..." + download_and_extract "gitea" "$TIMESTAMP" + local src="${RESTORE_DIR}/backup-gitea/backup-gitea" + [[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-gitea" + [[ -f "$src/docker-compose.yml" ]] && scp "$src/docker-compose.yml" txjp:$REMOTE_BASE/gitea-ai/ + [[ -f "$src/data/gitea.db" ]] && ssh txjp "docker cp $src/data/gitea.db gitea-ai:/data/gitea.db" + ssh txjp "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d" + log "gitea 恢复完成" +} + +restore_oa() { + log "恢复 oa..." + download_and_extract "oa" "$TIMESTAMP" + local src="${RESTORE_DIR}/backup-oa/backup-oa" + [[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-oa" + for f in docker-compose.yml .env; do + [[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/oa-ai/ + done + ssh txjp "cd $REMOTE_BASE/oa-ai && docker compose down 2>/dev/null || true && docker compose up -d" + log "oa 恢复完成" +} + +restore_monitor() { + log "恢复 monitor..." + download_and_extract "monitor" "$TIMESTAMP" + local src="${RESTORE_DIR}/backup-monitor/backup-monitor" + [[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-monitor" + for f in docker-compose.yml .env; do + [[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/monitor-ai/ + done + [[ -f "$src/data/monitor.db" ]] && ssh txjp "docker cp $src/data/monitor.db monitor-ai:/app/data/monitor.db" + ssh txjp "cd $REMOTE_BASE/monitor-ai && docker compose down 2>/dev/null || true && docker compose up -d --build" + log "monitor 恢复完成" +} + +restore_generic() { + local site="$1" + log "恢复 ${site}(通用模式)..." + download_and_extract "${site}" "$TIMESTAMP" + local src="${RESTORE_DIR}/backup-${site}/backup-${site}" + [[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-${site}" + for f in docker-compose.yml .env Dockerfile; do + [[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/${site}/ + done + ssh txjp "cd $REMOTE_BASE/${site} && docker compose down 2>/dev/null || true && docker compose up -d" 2>/dev/null || warn "${site} 容器重启失败" +} + +# === 模式 2:数据库恢复 === +restore_db() { + local service="$1" file="$2" + [[ -z "$service" || -z "$file" ]] && error "用法: bash restore.sh --db <文件名>" + + local container="" dbpath="" project="" + case "$service" in + assets) project="assets-ai"; container="assets-ai"; dbpath="/app/data/assets.db" ;; + issue) project="issue-ai"; container="issue-ai"; dbpath="/app/data/issue.db" ;; + ldap) project="ldap-ai"; container="lldap"; dbpath="/data/users.db" ;; + gitea) project="gitea-ai"; container="gitea-ai"; dbpath="/data/gitea.db" ;; + monitor) project="monitor-ai"; container="monitor-ai"; dbpath="/app/data/monitor.db" ;; + *) error "服务名必须是: assets, issue, ldap, gitea, monitor" ;; + esac + + local src="$BACKUP_DIR/$file" + [[ ! -f "$src" ]] && src="$file" + + log "恢复 ${service} 数据库: ${src}..." + scp "$src" "txjp:/tmp/db-restore.db" + ssh txjp "docker cp /tmp/db-restore.db ${container}:${dbpath} && rm /tmp/db-restore.db" + ssh txjp "cd $REMOTE_BASE/$project && docker compose restart" 2>/dev/null || true + log "${service} 数据库恢复完成" +} + +# === 模式 3:灾难恢复 === +restore_full() { + local file="$1" + [[ -z "$file" ]] && error "用法: bash restore.sh --full " + + local src="$BACKUP_DIR/$file" + [[ ! -f "$src" ]] && src="$file" + [[ ! -f "$src" ]] && error "备份文件不存在: $file" + + log "完整恢复: $file" + + rm -rf "$RESTORE_DIR/full"; mkdir -p "$RESTORE_DIR/full" + tar xzf "$src" -C "$RESTORE_DIR/full" + local content="$RESTORE_DIR/full/full-backup" + + echo ""; echo " 1) assets 2) issue 3) ldap 4) gitea" + echo " 5) monitor 6) nginx 7) 全部恢复" + echo ""; printf "请选择 (1-7): "; read choice + + case "$choice" in + 1) restore_assets_from_full "$content" ;; + 2) restore_issue_from_full "$content" ;; + 3) restore_ldap_from_full "$content" ;; + 4) restore_gitea_from_full "$content" ;; + 5) restore_monitor_from_full "$content" ;; + 6) restore_nginx_from_full "$content" ;; + 7) restore_all_from_full "$content" ;; + *) error "无效选择" ;; + esac +} + +restore_assets_from_full() { + log "恢复 assets..." + [[ -f "$1/databases/assets.db" ]] && ssh txjp "docker cp $1/databases/assets.db assets-ai:/app/data/assets.db" + ssh txjp "cd $REMOTE_BASE/assets-ai && docker compose down && docker compose up -d"; log "完成" +} +restore_issue_from_full() { + log "恢复 issue..." + [[ -f "$1/databases/issue.db" ]] && ssh txjp "docker cp $1/databases/issue.db issue-ai:/app/data/issue.db" + ssh txjp "cd $REMOTE_BASE/issue-ai && docker compose down && docker compose up -d"; log "完成" +} +restore_ldap_from_full() { + log "恢复 ldap..." + [[ -f "$1/databases/ldap.db" ]] && ssh txjp "docker cp $1/databases/ldap.db lldap:/data/users.db" + ssh txjp "cd $REMOTE_BASE/ldap-ai && docker compose down && docker compose up -d"; log "完成" +} +restore_gitea_from_full() { + log "恢复 gitea..." + [[ -f "$1/databases/gitea.db" ]] && ssh txjp "docker cp $1/databases/gitea.db gitea-ai:/data/gitea.db" + ssh txjp "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d"; log "完成" +} +restore_monitor_from_full() { + log "恢复 monitor..." + [[ -f "$1/databases/monitor.db" ]] && ssh txjp "docker cp $1/databases/monitor.db monitor-ai:/app/data/monitor.db" + ssh txjp "cd $REMOTE_BASE/monitor-ai && docker compose down && docker compose up -d --build"; log "完成" +} +restore_nginx_from_full() { + log "恢复 nginx..." + [[ -d "$1/nginx" ]] && ssh txjp "mkdir -p $REMOTE_BASE/nginx-proxy-ai/conf.d" && scp "$1/nginx/"* txjp:$REMOTE_BASE/nginx-proxy-ai/conf.d/ + ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload"; log "完成" +} +restore_all_from_full() { + local c="$1" + restore_assets_from_full "$c" + restore_issue_from_full "$c" + restore_ldap_from_full "$c" + restore_gitea_from_full "$c" + restore_monitor_from_full "$c" + restore_nginx_from_full "$c" +} + +# === 主流程 === +case "${1:-}" in + --db) + restore_db "$2" "$3" ;; + --full) + restore_full "$2" ;; + --help|-h) + echo "用法: bash restore.sh [--db|--full] [参数...]" + echo " 无参数 交互式站点恢复" + echo " restore.sh TIMESTAMP 指定时间戳恢复" + echo " --db 数据库恢复" + echo " --full 灾难恢复" + ;; + *) + # 交互式站点恢复 + TIMESTAMP="${1:-}" + if [[ -z "$TIMESTAMP" ]]; then + echo ""; echo "可用的备份时间戳:"; echo "" + timestamps=$(ls ${BACKUP_DIR}/assets-*.tar.gz 2>/dev/null | sed 's/.*assets-\(.*\)\.tar\.gz/\1/' | sort -r | head -10) + [[ -z "$timestamps" ]] && error "未找到任何备份文件" + declare -A ts_list; index=1 + while IFS= read -r ts; do echo " $index) $ts"; ts_list[$index]="$ts"; ((index++)); done <<< "$timestamps" + echo ""; printf "请选择 (1-$((index-1))): "; read choice + TIMESTAMP="${ts_list[$choice]}" + [[ -z "$TIMESTAMP" ]] && error "无效选择" + fi + + echo ""; echo "可恢复的站点:"; echo "" + declare -A site_status; index=1 + for site in $ALL_SITES; do + local bk=$(check_backup "$site" "$TIMESTAMP") + [[ -n "$bk" ]] && echo " $index) $site" && site_list[$index]="$site" || echo " -) $site (无备份)" + ((index++)) + done + echo ""; echo " a) 全部恢复"; printf "请选择 (1-$((index-1)) 或 a): "; read s_choice + + declare -a restore_list + if [[ "$s_choice" == "a" ]]; then + for idx in "${!site_list[@]}"; do restore_list+=("${site_list[$idx]}"); done + else + restore_list=("${site_list[$s_choice]}") + [[ -z "${restore_list[0]}" ]] && error "无效选择或无备份" + fi + + for site in "${restore_list[@]}"; do + case "$site" in + shared) restore_shared ;; + assets) restore_assets ;; + issue) restore_issue ;; + ldap) restore_ldap ;; + gitea) restore_gitea ;; + oa) restore_oa ;; + monitor) restore_monitor ;; + *) warn "${site}: 无专用恢复逻辑"; restore_generic "$site" ;; + esac + done + + ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || true + rm -rf "$RESTORE_DIR" + echo ""; log "恢复完成!" + ssh txjp "docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E '(assets|issue|ldap|gitea|oa|monitor|nginx|sso)'" + ;; +esac