#!/usr/bin/env bash # 在无网络临时 MySQL 容器中恢复 staging dump,并与存储审计逐表对账。 # 本脚本永不连接或回灌原 staging 数据库;任何失败都销毁临时容器并阻止激活。 set -euo pipefail DUMP_FILE="${DUMP_FILE:-}" EXPECTED_AUDIT_FILE="${EXPECTED_AUDIT_FILE:-}" MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:8.4.8}" DOCKER_BIN="${DOCKER_BIN:-docker}" RESTORE_VERIFY_TIMEOUT_SEC="${RESTORE_VERIFY_TIMEOUT_SEC:-120}" RESTORE_VERIFY_DRY_RUN="${RESTORE_VERIFY_DRY_RUN:-0}" VERIFY_CONTAINER_NAME="${VERIFY_CONTAINER_NAME:-gda-mysql-restore-verify-$$}" if [ -z "$DUMP_FILE" ] || [ ! -s "$DUMP_FILE" ]; then printf 'RESTORE_VERIFY_FAIL DUMP_FILE 不存在或为空。\n' >&2 exit 2 fi if [ -z "$EXPECTED_AUDIT_FILE" ] || [ ! -s "$EXPECTED_AUDIT_FILE" ]; then printf 'RESTORE_VERIFY_FAIL EXPECTED_AUDIT_FILE 不存在或为空。\n' >&2 exit 2 fi if [[ ! "$RESTORE_VERIFY_TIMEOUT_SEC" =~ ^[0-9]+$ ]] || [ "$RESTORE_VERIFY_TIMEOUT_SEC" -lt 10 ]; then printf 'RESTORE_VERIFY_FAIL timeout 必须是至少 10 秒的整数。\n' >&2 exit 2 fi gzip -t "$DUMP_FILE" value_of() { local key="$1" file="$2" awk -F= -v wanted="$key" '$1 == wanted {print substr($0, length($1) + 2); found=1} END {exit !found}' "$file" } compare_count() { local key="$1" actual_file="$2" expected actual expected="$(value_of "$key" "$EXPECTED_AUDIT_FILE")" || { printf 'RESTORE_VERIFY_FAIL 审计缺字段:%s\n' "$key" >&2 exit 3 } actual="$(value_of "$key" "$actual_file")" || { printf 'RESTORE_VERIFY_FAIL 恢复库缺字段:%s\n' "$key" >&2 exit 3 } [ "$expected" = "$actual" ] || { printf 'RESTORE_VERIFY_FAIL 行数不一致 %s expected=%s actual=%s\n' "$key" "$expected" "$actual" >&2 exit 3 } } if [ "$RESTORE_VERIFY_DRY_RUN" = "1" ]; then # dry-run 只验证输入可读和审计契约,绝不签发真实恢复通过。 [ "$(value_of schema "$EXPECTED_AUDIT_FILE")" = "staging-storage-audit/1" ] || { printf 'RESTORE_VERIFY_FAIL dry-run 审计 schema 非法。\n' >&2 exit 3 } [ "$(value_of object_storage_status "$EXPECTED_AUDIT_FILE")" = "DB_ONLY_NA" ] || { printf 'RESTORE_VERIFY_FAIL dry-run 只接受 DB_ONLY_NA 审计。\n' >&2 exit 3 } for key in game_version_rows game_version_package_url_nonempty \ game_runtime_package_rows game_runtime_package_url_nonempty game_runtime_package_payload_rows \ game_source_project_rows game_source_project_url_nonempty game_source_project_payload_rows \ game_aigc_task_rows game_aigc_result_package_url_nonempty infra_file_rows; do value="$(value_of "$key" "$EXPECTED_AUDIT_FILE")" || { printf 'RESTORE_VERIFY_FAIL dry-run 审计缺字段:%s\n' "$key" >&2 exit 3 } [[ "$value" =~ ^[0-9]+$ ]] || exit 3 done printf 'RESTORE_VERIFY_DRY_RUN_PASS(未启动容器,不可作为激活证据)\n' exit 0 fi password="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" actual_file="$(mktemp)" container_started=0 cleanup() { rm -f "$actual_file" if [ "$container_started" -eq 1 ]; then "$DOCKER_BIN" stop -t 1 "$VERIFY_CONTAINER_NAME" >/dev/null 2>&1 || true fi } trap cleanup EXIT "$DOCKER_BIN" run -d --rm --network none --name "$VERIFY_CONTAINER_NAME" \ -e "MYSQL_ROOT_PASSWORD=$password" "$MYSQL_IMAGE" >/dev/null container_started=1 deadline=$((SECONDS + RESTORE_VERIFY_TIMEOUT_SEC)) # `mysqladmin ping` 在鉴权失败时也可能返回 0,只能证明端口活,不能证明官方镜像已完成 root 口令初始化。 # 用真实鉴权查询作为就绪门,避免在临时初始化服务器阶段过早灌入 dump。 until "$DOCKER_BIN" exec -e "MYSQL_PWD=$password" "$VERIFY_CONTAINER_NAME" \ mysql -uroot -N -B -e 'SELECT 1' >/dev/null 2>&1; do [ "$SECONDS" -lt "$deadline" ] || { printf 'RESTORE_VERIFY_FAIL 临时 MySQL 未在时限内就绪。\n' >&2 exit 4 } sleep 1 done gzip -dc "$DUMP_FILE" | "$DOCKER_BIN" exec -i -e "MYSQL_PWD=$password" \ "$VERIFY_CONTAINER_NAME" mysql -uroot "$DOCKER_BIN" exec -i -e "MYSQL_PWD=$password" "$VERIFY_CONTAINER_NAME" \ mysql -uroot -N -B 'ruoyi-vue-pro' >"$actual_file" <<'SQL' SELECT CONCAT('flyway_failed_rows=', COUNT(*)) FROM flyway_schema_history WHERE success<>1; SELECT CONCAT('game_version_rows=', COUNT(*)) FROM game_version WHERE deleted=0; SELECT CONCAT('game_version_package_url_nonempty=', COALESCE(SUM(package_url<>''),0)) FROM game_version WHERE deleted=0; SELECT CONCAT('game_runtime_package_rows=', COUNT(*)) FROM game_runtime_package WHERE deleted=0; SELECT CONCAT('game_runtime_package_url_nonempty=', COALESCE(SUM(package_url<>''),0)) FROM game_runtime_package WHERE deleted=0; SELECT CONCAT('game_runtime_package_payload_rows=', COALESCE(SUM(package_json IS NOT NULL AND CHAR_LENGTH(package_json)>0),0)) FROM game_runtime_package WHERE deleted=0; SELECT CONCAT('game_source_project_rows=', COUNT(*)) FROM game_source_project WHERE deleted=0; SELECT CONCAT('game_source_project_url_nonempty=', COALESCE(SUM(source_url<>''),0)) FROM game_source_project WHERE deleted=0; SELECT CONCAT('game_source_project_payload_rows=', COALESCE(SUM(source_json IS NOT NULL AND CHAR_LENGTH(source_json)>0),0)) FROM game_source_project WHERE deleted=0; SELECT CONCAT('game_aigc_task_rows=', COUNT(*)) FROM game_aigc_task WHERE deleted=0; SELECT CONCAT('game_aigc_result_package_url_nonempty=', COALESCE(SUM(result_package_url<>''),0)) FROM game_aigc_task WHERE deleted=0; SELECT CONCAT('infra_file_rows=', COUNT(*)) FROM infra_file WHERE deleted=0; SQL [ "$(value_of flyway_failed_rows "$actual_file")" = "0" ] || { printf 'RESTORE_VERIFY_FAIL 恢复库存在失败的 Flyway 记录。\n' >&2 exit 5 } for key in game_version_rows game_version_package_url_nonempty \ game_runtime_package_rows game_runtime_package_url_nonempty game_runtime_package_payload_rows \ game_source_project_rows game_source_project_url_nonempty game_source_project_payload_rows \ game_aigc_task_rows game_aigc_result_package_url_nonempty infra_file_rows; do compare_count "$key" "$actual_file" done printf 'RESTORE_VERIFY_PASS container=%s(临时容器将立即销毁)\n' "$VERIFY_CONTAINER_NAME"