#!/usr/bin/env bash # FLNXI customer backup, restore, and verify helper for NAS/Linux hosts. set -euo pipefail ACTION="backup" PROJECT_DIR="." COMPOSE_FILE="docker-compose.prod.yml" ENV_FILE=".env" BACKUP_DIR="backups" BACKUP_PATH="" APP_SERVICE="app" POSTGRES_SERVICE="postgres" MINIO_SERVICE="minio" HELPER_IMAGE="busybox:1.36" SKIP_STOP_APP_FOR_BACKUP=0 SKIP_DATABASE_BACKUP=0 SKIP_OBJECT_STORAGE_BACKUP=0 SKIP_OBJECT_STORAGE_RESTORE=0 RESTORE_ENV=0 HEALTH_TIMEOUT_SECONDS=120 CONFIRM_RESTORE="" RESTORE_CONFIRM_PHRASE="RESTORE_FLNXI_DATA" info() { printf '[INFO] %s\n' "$1" } ok() { printf '[OK] %s\n' "$1" } warn() { printf '[WARN] %s\n' "$1" >&2 } fail() { printf '[ERROR] %s\n' "$1" >&2 exit 1 } usage() { cat <<'EOF' FLNXI NAS/Linux 备份、恢复和验收脚本 用法: bash scripts/backup-restore.sh --action backup|restore|verify [选项] 常用选项: --project-dir 部署目录,默认当前目录 --compose-file Compose 文件名,默认 docker-compose.prod.yml --env-file 环境文件名,默认 .env --backup-dir 备份目录名,默认 backups --backup-path 恢复时指定备份目录 --helper-image 备份/恢复 MinIO 卷用的小型镜像,默认 busybox:1.36 --skip-stop-app-for-backup 备份时不暂停 app 写入 --skip-database-backup 跳过数据库备份 --skip-object-storage-backup 跳过对象存储备份 --skip-object-storage-restore 恢复时跳过对象存储 --restore-env 恢复备份中的 .env --confirm-restore RESTORE_FLNXI_DATA 恢复确认短语 --health-timeout-seconds 健康检查等待秒数,默认 120 -h, --help 显示帮助 说明: 备份包包含 .env、数据库和客户素材,必须限权或加密保存,不能提交到 Git。 如果当前账号没有 Docker 权限,请用 sudo 运行本脚本。 EOF } normalize_action() { case "$1" in backup|Backup) printf 'backup' ;; restore|Restore) printf 'restore' ;; verify|Verify) printf 'verify' ;; *) fail "未知动作:$1" ;; esac } while [[ $# -gt 0 ]]; do case "$1" in --action) ACTION="$(normalize_action "${2:?--action requires a value}")" shift 2 ;; --project-dir) PROJECT_DIR="${2:?--project-dir requires a value}" shift 2 ;; --compose-file) COMPOSE_FILE="${2:?--compose-file requires a value}" shift 2 ;; --env-file) ENV_FILE="${2:?--env-file requires a value}" shift 2 ;; --backup-dir) BACKUP_DIR="${2:?--backup-dir requires a value}" shift 2 ;; --backup-path) BACKUP_PATH="${2:?--backup-path requires a value}" shift 2 ;; --app-service) APP_SERVICE="${2:?--app-service requires a value}" shift 2 ;; --postgres-service) POSTGRES_SERVICE="${2:?--postgres-service requires a value}" shift 2 ;; --minio-service) MINIO_SERVICE="${2:?--minio-service requires a value}" shift 2 ;; --helper-image) HELPER_IMAGE="${2:?--helper-image requires a value}" shift 2 ;; --skip-stop-app-for-backup) SKIP_STOP_APP_FOR_BACKUP=1 shift ;; --skip-database-backup) SKIP_DATABASE_BACKUP=1 shift ;; --skip-object-storage-backup) SKIP_OBJECT_STORAGE_BACKUP=1 shift ;; --skip-object-storage-restore) SKIP_OBJECT_STORAGE_RESTORE=1 shift ;; --restore-env) RESTORE_ENV=1 shift ;; --confirm-restore) CONFIRM_RESTORE="${2:?--confirm-restore requires a value}" shift 2 ;; --health-timeout-seconds) HEALTH_TIMEOUT_SECONDS="${2:?--health-timeout-seconds requires a value}" shift 2 ;; -h|--help) usage exit 0 ;; *) fail "未知参数:$1" ;; esac done read_env_value() { local key="$1" local path="$2" local line if [[ ! -f "$path" ]]; then return 0 fi line="$(grep -E "^[[:space:]]*${key}=" "$path" | tail -n 1 || true)" [[ -z "$line" ]] && return 0 printf '%s' "$line" | sed -E "s/^[^=]*=//;s/\r$//;s/^['\"]//;s/['\"]$//" } json_escape() { printf '%s' "${1:-}" | sed -E 's/\\/\\\\/g;s/"/\\"/g' } is_external_database() { local database_url host database_url="$(read_env_value "DATABASE_URL" "$ENV_PATH")" [[ -z "$database_url" ]] && return 1 host="$(printf '%s' "$database_url" | sed -E 's#^[a-zA-Z0-9+.-]+://[^@]*@([^/:?]+).*#\1#')" [[ "$host" != "postgres" && "$host" != "$database_url" ]] } is_external_object_storage() { local endpoint host endpoint="$(read_env_value "S3_ENDPOINT" "$ENV_PATH")" [[ -z "$endpoint" ]] && return 1 host="$(printf '%s' "$endpoint" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')" [[ "$host" != "minio" ]] } run_docker() { docker "$@" } run_compose() { run_docker "${COMPOSE_ARGS[@]}" "$@" } service_container_id() { local service="$1" local container_id container_id="$(run_compose ps -q "$service" | tr -d '\r')" [[ -n "$container_id" ]] || fail "未找到服务容器:$service。请先确认服务已启动。" printf '%s' "$container_id" } optional_service_container_id() { local service="$1" run_compose ps -q "$service" 2>/dev/null | tr -d '\r' || true } service_running() { local service="$1" local container_id status container_id="$(optional_service_container_id "$service")" [[ -n "$container_id" ]] || return 1 status="$(run_docker inspect "$container_id" --format '{{.State.Status}}' 2>/dev/null || true)" [[ "$status" == "running" ]] } stop_app_for_backup() { if [[ "$SKIP_STOP_APP_FOR_BACKUP" -eq 1 ]]; then warn "已跳过备份停写入步骤;请确认客户现场没有人正在上传、编辑或删除素材。" return 1 fi if ! service_running "$APP_SERVICE"; then info "$APP_SERVICE 服务未运行或不存在,跳过停写入步骤。" return 1 fi info "进入备份维护窗口:暂时停止 $APP_SERVICE 服务,避免备份期间继续写入数据库或素材文件。" run_compose stop "$APP_SERVICE" return 0 } restart_app_after_backup() { info "备份维护窗口结束:恢复启动 $APP_SERVICE 服务。" run_compose up -d "$APP_SERVICE" } save_command_output() { local path="$1" shift set +e "$@" >"$path" 2>&1 local status=$? set -e if [[ "$status" -ne 0 ]]; then printf '命令执行失败,退出码:%s\n' "$status" >>"$path" fi } backup_database() { local output_path="$1" info "备份 Compose 内置 PostgreSQL" run_compose up -d "$POSTGRES_SERVICE" local container_id container_id="$(service_container_id "$POSTGRES_SERVICE")" run_compose exec -T "$POSTGRES_SERVICE" sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges -f /tmp/flnxi-backup.sql && test -s /tmp/flnxi-backup.sql' run_docker cp "${container_id}:/tmp/flnxi-backup.sql" "$output_path" [[ -s "$output_path" ]] || fail "数据库备份复制失败:$output_path" run_compose exec -T "$POSTGRES_SERVICE" sh -c 'rm -f /tmp/flnxi-backup.sql' ok "数据库备份完成:$output_path" } restore_database() { local input_path="$1" info "恢复 Compose 内置 PostgreSQL" run_compose up -d "$POSTGRES_SERVICE" local container_id container_id="$(service_container_id "$POSTGRES_SERVICE")" run_docker cp "$input_path" "${container_id}:/tmp/flnxi-restore.sql" run_compose exec -T "$POSTGRES_SERVICE" sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/flnxi-restore.sql' run_compose exec -T "$POSTGRES_SERVICE" sh -c 'rm -f /tmp/flnxi-restore.sql' ok "数据库恢复完成" } backup_object_storage() { local volume_name="$1" local output_path="$2" local backup_dir [[ -n "$volume_name" ]] || fail "MINIO_VOLUME_NAME 为空,无法备份内置对象存储卷。" backup_dir="$(dirname "$output_path")" info "备份 Compose 内置 MinIO 数据卷:$volume_name" run_docker volume inspect "$volume_name" >/dev/null run_docker run --rm -v "${volume_name}:/source:ro" -v "${backup_dir}:/backup" "$HELPER_IMAGE" sh -c "cd /source && tar -czf /backup/minio-data.tar.gz ." [[ -s "$output_path" ]] || fail "对象存储备份失败。请确认 helper 镜像可用:$HELPER_IMAGE" ok "对象存储备份完成:$output_path" } restore_object_storage() { local volume_name="$1" local input_path="$2" local backup_dir [[ -n "$volume_name" ]] || fail "MINIO_VOLUME_NAME 为空,无法恢复内置对象存储卷。" backup_dir="$(dirname "$input_path")" info "恢复 Compose 内置 MinIO 数据卷:$volume_name" if ! run_docker volume inspect "$volume_name" >/dev/null 2>&1; then run_docker volume create "$volume_name" >/dev/null fi run_docker run --rm -v "${volume_name}:/target" -v "${backup_dir}:/backup:ro" "$HELPER_IMAGE" sh -c "rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/minio-data.tar.gz" ok "对象存储恢复完成" } backup_local_directory() { local name="$1" local output_path="$2" [[ -d "$PROJECT_DIR_ABS/$name" ]] || return 1 tar -czf "$output_path" -C "$PROJECT_DIR_ABS" "$name" ok "本地目录备份完成:$name -> $output_path" return 0 } restore_local_directory() { local name="$1" local input_path="$2" [[ -f "$input_path" ]] || return 0 local target_path before_path target_path="$PROJECT_DIR_ABS/$name" if [[ -d "$target_path" ]]; then before_path="$PROJECT_DIR_ABS/$name.before-restore-$(date +%Y%m%d-%H%M%S)" mv "$target_path" "$before_path" ok "$name 恢复前副本:$before_path" fi tar -xzf "$input_path" -C "$PROJECT_DIR_ABS" ok "本地目录恢复完成:$name" } wait_health() { local health_url="$1" local timeout="$2" local deadline deadline=$(( $(date +%s) + timeout )) while [[ "$(date +%s)" -le "$deadline" ]]; do if curl -fsS "$health_url" >/dev/null 2>&1; then ok "/api/health 正常" return 0 fi sleep 5 done fail "/api/health 在 $timeout 秒内没有通过:$health_url" } verify_install() { info "检查 Compose 配置" run_compose config --quiet info "查看容器状态" run_compose ps local public_url public_url="$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")" if [[ -z "$public_url" ]]; then public_url="$(read_env_value "NEXTAUTH_URL" "$ENV_PATH")" fi if [[ -z "$public_url" ]]; then warn "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,跳过 /api/health HTTP 验收。" return 0 fi info "检查健康接口:${public_url%/}/api/health" wait_health "${public_url%/}/api/health" "$HEALTH_TIMEOUT_SECONDS" } write_manifest() { local target_dir="$1" local minio_volume_name="$2" local external_db="$3" local external_object_storage="$4" local data_backed="$5" local uploads_backed="$6" local app_stopped="$7" cat >"$target_dir/manifest.json" <"$target_dir/README-SENSITIVE.txt" <<'EOF' This backup may contain .env secrets, database data, license cache, and customer materials. Store it in a controlled location. Do not commit it to Git. EOF save_command_output "$target_dir/compose-config.txt" docker "${COMPOSE_ARGS[@]}" config save_command_output "$target_dir/compose-ps.txt" docker "${COMPOSE_ARGS[@]}" ps save_command_output "$target_dir/docker-images.txt" docker images --digests if is_external_database; then external_db=true; else external_db=false; fi if [[ "$SKIP_DATABASE_BACKUP" -eq 1 ]]; then printf '已按参数跳过数据库备份。请确认外部备份已存在。\n' >"$target_dir/DATABASE_BACKUP_SKIPPED.txt" elif [[ "$external_db" == true ]]; then printf '检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。\n' >"$target_dir/EXTERNAL_DATABASE_BACKUP_REQUIRED.txt" else backup_database "$target_dir/postgres.sql" fi minio_volume_name="$(read_env_value "MINIO_VOLUME_NAME" "$ENV_PATH")" minio_volume_name="${minio_volume_name:-flnxi-minio-data}" if is_external_object_storage; then external_object_storage=true; else external_object_storage=false; fi if [[ "$SKIP_OBJECT_STORAGE_BACKUP" -eq 1 ]]; then printf '已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。\n' >"$target_dir/OBJECT_STORAGE_BACKUP_SKIPPED.txt" elif [[ "$external_object_storage" == true ]]; then printf '检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。\n' >"$target_dir/EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt" else backup_object_storage "$minio_volume_name" "$target_dir/minio-data.tar.gz" fi local data_backed uploads_backed data_backed=false uploads_backed=false if backup_local_directory "data" "$target_dir/data.tar.gz"; then data_backed=true; fi if backup_local_directory "uploads" "$target_dir/uploads.tar.gz"; then uploads_backed=true; fi write_manifest "$target_dir" "$minio_volume_name" "$external_db" "$external_object_storage" "$data_backed" "$uploads_backed" "$APP_STOPPED_FOR_BACKUP" ok "备份目录:$target_dir" } backup_action() { APP_STOPPED_FOR_BACKUP=false if stop_app_for_backup; then APP_STOPPED_FOR_BACKUP=true fi set +e backup_action_body local status=$? set -e if [[ "$APP_STOPPED_FOR_BACKUP" == true ]]; then restart_app_after_backup if [[ "$status" -eq 0 ]]; then verify_install status=$? fi fi return "$status" } restore_action() { [[ -n "$BACKUP_PATH" ]] || fail "恢复必须传入 --backup-path。" [[ "$CONFIRM_RESTORE" == "$RESTORE_CONFIRM_PHRASE" ]] || fail "恢复会覆盖客户数据。请显式传入 --confirm-restore $RESTORE_CONFIRM_PHRASE。" local source_dir db_backup object_backup env_backup_path minio_volume_name source_dir="$(cd "$BACKUP_PATH" && pwd)" db_backup="$source_dir/postgres.sql" object_backup="$source_dir/minio-data.tar.gz" warn "即将恢复备份目录:$source_dir" run_compose stop "$APP_SERVICE" || true if [[ "$RESTORE_ENV" -eq 1 && -f "$source_dir/.env" ]]; then env_backup_path="$PROJECT_DIR_ABS/.env.before-restore-$(date +%Y%m%d-%H%M%S)" [[ -f "$ENV_PATH" ]] && cp "$ENV_PATH" "$env_backup_path" cp "$source_dir/.env" "$ENV_PATH" ok ".env 已恢复,恢复前副本:$env_backup_path" fi if [[ -f "$db_backup" ]]; then restore_database "$db_backup" else warn "备份目录没有 postgres.sql,跳过数据库恢复。" fi if is_external_object_storage; then warn "检测到当前 .env 使用外部对象存储,脚本不会恢复 Compose 内置 MinIO 卷;请按客户现场对象存储备份方案恢复。" elif [[ "$SKIP_OBJECT_STORAGE_RESTORE" -eq 0 && -f "$object_backup" ]]; then run_compose stop "$MINIO_SERVICE" || true minio_volume_name="$(read_env_value "MINIO_VOLUME_NAME" "$ENV_PATH")" minio_volume_name="${minio_volume_name:-flnxi-minio-data}" restore_object_storage "$minio_volume_name" "$object_backup" elif [[ "$SKIP_OBJECT_STORAGE_RESTORE" -eq 1 ]]; then warn "已按参数跳过对象存储恢复。" else warn "备份目录没有 minio-data.tar.gz,跳过对象存储恢复。" fi restore_local_directory "data" "$source_dir/data.tar.gz" restore_local_directory "uploads" "$source_dir/uploads.tar.gz" run_compose up -d verify_install ok "恢复和基础验收完成" } command -v docker >/dev/null 2>&1 || fail "未找到 docker 命令" docker compose version >/dev/null 2>&1 || fail "当前 Docker 不支持 compose v2,请先安装 Docker Compose V2" command -v curl >/dev/null 2>&1 || fail "未找到 curl 命令" command -v tar >/dev/null 2>&1 || fail "未找到 tar 命令" PROJECT_DIR_ABS="$(cd "$PROJECT_DIR" && pwd)" COMPOSE_PATH="$PROJECT_DIR_ABS/$COMPOSE_FILE" ENV_PATH="$PROJECT_DIR_ABS/$ENV_FILE" [[ -f "$COMPOSE_PATH" ]] || fail "Compose 文件不存在:$COMPOSE_PATH" cd "$PROJECT_DIR_ABS" COMPOSE_ARGS=(compose -f "$COMPOSE_PATH") if [[ -f "$ENV_PATH" ]]; then COMPOSE_ARGS+=(--env-file "$ENV_PATH") fi case "$ACTION" in backup) backup_action ;; restore) restore_action ;; verify) verify_install ;; esac