#!/usr/bin/env bash # ============================================================================= # 法规知识库完整备份脚本 (backup_regulations.sh) # # 作用: # 1. 检查共享基础设施容器 shuimu-kb-shared 与数据库状态; # 2. 采用 pg_dump -Fc 高压缩二进制格式导出 regulations 数据库(含向量与索引); # 3. 镜像备份项目根目录的 .regulations 物理文件(支持目录克隆/同步及 tar 归档); # 4. 生成 manifest.json 记录基准元数据、统计指标及 SHA256 签名。 # # 用法: # ./scripts/backup_regulations.sh <目标备份目录> [选项] # # 选项: # --archive, -a 额外将 .regulations 打包为 regulations_files.tar.gz # --help, -h 显示帮助信息 # ============================================================================= set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" CONTAINER_NAME="shuimu-kb-shared" DB_NAME="regulations" DB_USER="regkb" show_help() { cat < [选项] 参数: 目标备份目录 必须指定,备份文件将保存至该目录下 选项: --archive, -a 额外将 .regulations 打包为 regulations_files.tar.gz 压缩包 --help, -h 显示本帮助信息 示例: $0 /Volumes/Backups/regulations_20260911 $0 ./backups/reg_backup --archive EOF exit 0 } if [[ $# -lt 1 ]]; then show_help fi TARGET_DIR="" ARCHIVE_MODE=0 while [[ $# -gt 0 ]]; do case "$1" in --help|-h) show_help ;; --archive|-a) ARCHIVE_MODE=1 shift ;; *) if [[ -z "${TARGET_DIR}" ]]; then TARGET_DIR="$1" else echo "[错误] 未知参数: $1" show_help fi shift ;; esac done if [[ -z "${TARGET_DIR}" ]]; then echo "[错误] 请指定目标备份目录!" exit 1 fi # 解析绝对路径 mkdir -p "${TARGET_DIR}" TARGET_DIR="$(cd "${TARGET_DIR}" && pwd)" echo "============================================================" echo ">>> 开始执行法规知识库完整备份" echo ">>> 项目根路径: ${ROOT_DIR}" echo ">>> 目标备份目录: ${TARGET_DIR}" echo "============================================================" # 1. 检查环境与 Docker 容器 if ! command -v docker >/dev/null 2>&1; then echo "[错误] 未找到 docker 命令,请先安装或启动 Docker!" exit 1 fi if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then echo "[警告] 容器 ${CONTAINER_NAME} 未运行,尝试启动共享基础设施..." if [[ -f "${ROOT_DIR}/scripts/env-service.sh" ]]; then "${ROOT_DIR}/scripts/env-service.sh" shared start else docker compose -f "${ROOT_DIR}/docker-compose.shared.yml" up -d fi fi # 等待数据库就绪 echo "[等待] 检查数据库连接状态..." for i in {1..30}; do if docker exec -i "${CONTAINER_NAME}" pg_isready -U "${DB_USER}" -d "${DB_NAME}" >/dev/null 2>&1; then echo "[就绪] 数据库连接正常。" break fi sleep 1 if [ "$i" -eq 30 ]; then echo "[错误] 数据库连接超时,备份中止!" exit 1 fi done # 2. 检查 .regulations 物理目录 REG_SOURCE_DIR="${ROOT_DIR}/.regulations" if [[ ! -d "${REG_SOURCE_DIR}" ]]; then echo "[错误] 项目根目录下未找到 .regulations 目录 (${REG_SOURCE_DIR})!" exit 1 fi # 3. 统计数据库基准指标 echo "[统计] 采集数据库元数据与统计指标..." DOC_COUNT=$(docker exec -i "${CONTAINER_NAME}" psql -U "${DB_USER}" -d "${DB_NAME}" -t -A -c "SELECT count(*) FROM reg_documents;" 2>/dev/null || echo "0") CHUNK_COUNT=$(docker exec -i "${CONTAINER_NAME}" psql -U "${DB_USER}" -d "${DB_NAME}" -t -A -c "SELECT count(*) FROM reg_chunks;" 2>/dev/null || echo "0") NODE_COUNT=$(docker exec -i "${CONTAINER_NAME}" psql -U "${DB_USER}" -d "${DB_NAME}" -t -A -c "SELECT count(*) FROM reg_nodes;" 2>/dev/null || echo "0") DB_SIZE=$(docker exec -i "${CONTAINER_NAME}" psql -U "${DB_USER}" -d "${DB_NAME}" -t -A -c "SELECT pg_size_pretty(pg_database_size('${DB_NAME}'));" 2>/dev/null || echo "0 MB") echo " • 法规文档数 (reg_documents): ${DOC_COUNT}" echo " • 法规切片数 (reg_chunks): ${CHUNK_COUNT}" echo " • 大纲节点数 (reg_nodes): ${NODE_COUNT}" echo " • 数据库当前体积: ${DB_SIZE}" # 4. 导出数据库 DUMP_FILENAME="regulations_db.dump" TMP_CONTAINER_DUMP="/tmp/${DUMP_FILENAME}" DEST_DUMP_PATH="${TARGET_DIR}/${DUMP_FILENAME}" echo "[导出] 正在执行 PostgreSQL 数据库导出 (pg_dump -Fc 高压缩格式)..." START_TIME=$(date +%s) docker exec "${CONTAINER_NAME}" pg_dump -U "${DB_USER}" -d "${DB_NAME}" -Fc -Z 6 -f "${TMP_CONTAINER_DUMP}" docker cp "${CONTAINER_NAME}:${TMP_CONTAINER_DUMP}" "${DEST_DUMP_PATH}" docker exec "${CONTAINER_NAME}" rm -f "${TMP_CONTAINER_DUMP}" DUMP_DURATION=$(( $(date +%s) - START_TIME )) DUMP_SIZE_HUMAN=$(ls -lh "${DEST_DUMP_PATH}" | awk '{print $5}') DUMP_SIZE_BYTES=$(stat -f "%z" "${DEST_DUMP_PATH}" 2>/dev/null || stat -c "%s" "${DEST_DUMP_PATH}" 2>/dev/null || echo "0") echo "[完成] 数据库导出成功,耗时: ${DUMP_DURATION} 秒,体积: ${DUMP_SIZE_HUMAN}" # 计算哈希 echo "[校验] 计算数据库 Dump 文件的 SHA256 校验和..." if command -v shasum >/dev/null 2>&1; then DUMP_SHA256=$(shasum -a 256 "${DEST_DUMP_PATH}" | awk '{print $1}') elif command -v sha256sum >/dev/null 2>&1; then DUMP_SHA256=$(sha256sum "${DEST_DUMP_PATH}" | awk '{print $1}') else DUMP_SHA256="unavailable" fi echo " • SHA256: ${DUMP_SHA256}" # 5. 备份物理目录 .regulations echo "[备份] 正在同步 .regulations 物理文档与解析工作区..." DEST_REG_DIR="${TARGET_DIR}/.regulations" mkdir -p "${DEST_REG_DIR}" # 统计原文件数量与体积 TOTAL_FILES=$(find "${REG_SOURCE_DIR}" -type f | wc -l | tr -d ' ') DIR_SIZE_HUMAN=$(du -sh "${REG_SOURCE_DIR}" | awk '{print $1}') echo " • 源文件总量: ${TOTAL_FILES} 个" echo " • 目录总大小: ${DIR_SIZE_HUMAN}" # 优先使用 macOS APFS clonefile (快速且零物理块拷贝) if cp -R -p -c "${REG_SOURCE_DIR}/." "${DEST_REG_DIR}/" 2>/dev/null; then echo "[完成] 已通过 APFS Copy-on-Write (零额外空间) 完成物理目录克隆镜像。" else # 跨磁盘或非 APFS 降级为 rsync / cp if command -v rsync >/dev/null 2>&1; then rsync -a --delete "${REG_SOURCE_DIR}/" "${DEST_REG_DIR}/" else cp -R -p "${REG_SOURCE_DIR}/." "${DEST_REG_DIR}/" fi echo "[完成] 物理目录同步完成。" fi ARCHIVE_TAR_FILE="" ARCHIVE_TAR_SHA256="" if [[ "${ARCHIVE_MODE}" -eq 1 ]]; then echo "[归档] 正在将 .regulations 物理目录打包为 tar.gz..." ARCHIVE_TAR_FILE="regulations_files.tar.gz" ARCHIVE_TAR_PATH="${TARGET_DIR}/${ARCHIVE_TAR_FILE}" tar -czf "${ARCHIVE_TAR_PATH}" -C "${ROOT_DIR}" .regulations if command -v shasum >/dev/null 2>&1; then ARCHIVE_TAR_SHA256=$(shasum -a 256 "${ARCHIVE_TAR_PATH}" | awk '{print $1}') fi echo "[完成] 归档包已生成: ${ARCHIVE_TAR_PATH}" fi # 6. 生成元数据清单 manifest.json MANIFEST_FILE="${TARGET_DIR}/manifest.json" echo "[元数据] 写入备份清单 ${MANIFEST_FILE}..." NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ") HOSTNAME_STR=$(hostname 2>/dev/null || echo "unknown") cat < "${MANIFEST_FILE}" { "manifest_version": "1.0", "created_at": "${NOW_ISO}", "project": "Chat-Agent-System", "source_hostname": "${HOSTNAME_STR}", "database": { "container_name": "${CONTAINER_NAME}", "database_name": "${DB_NAME}", "user": "${DB_USER}", "dump_file": "${DUMP_FILENAME}", "dump_sha256": "${DUMP_SHA256}", "dump_size_bytes": ${DUMP_SIZE_BYTES}, "dump_size_human": "${DUMP_SIZE_HUMAN}", "document_count": ${DOC_COUNT}, "chunk_count": ${CHUNK_COUNT}, "node_count": ${NODE_COUNT}, "database_size": "${DB_SIZE}" }, "regulations_files": { "relative_path": ".regulations", "total_files": ${TOTAL_FILES}, "directory_size": "${DIR_SIZE_HUMAN}", "subdirectories": [ "intake", "library", "quarantine", "work" ], "archive_file": "${ARCHIVE_TAR_FILE}", "archive_sha256": "${ARCHIVE_TAR_SHA256}" } } EOF echo "============================================================" echo ">>> [SUCCESS] 法规知识库完整备份成功!" echo ">>> 备份清单: ${MANIFEST_FILE}" echo ">>> 数据库 Dump: ${DEST_DUMP_PATH} (${DUMP_SIZE_HUMAN})" echo ">>> 物理文件目录: ${DEST_REG_DIR} (${TOTAL_FILES} 文件)" if [[ -n "${ARCHIVE_TAR_FILE}" ]]; then echo ">>> 物理文件归档: ${TARGET_DIR}/${ARCHIVE_TAR_FILE}" fi echo "============================================================" echo "恢复提示: 在任意目标机器上拉取本工程后,只需执行:" echo " ./scripts/restore_regulations.sh \"${TARGET_DIR}\"" echo "============================================================"