Files
kra-new/scripts/sw_report_rehearsal.py
T

120 lines
6.6 KiB
Python

"""备份/比较/恢复 sw8 与 sw9 指定自然日的四张报表,不备份或改写原始业务流水。"""
import argparse
import datetime as dt
import gzip
import hashlib
import json
import shutil
import subprocess
from pathlib import Path
import yaml
from sw_database_rehearsal import client_options, connect, query, write_json
TABLES = ("tab_sw_report_day", "tab_sw_report_day_key", "tab_sw_report_server_day", "tab_sw_report_server_day_key")
def snapshot(conn, database, day, logical=False):
result = {}
for table in TABLES:
rows = query(conn, f"SELECT * FROM `{database}`.`{table}` WHERE stat_date=%s ORDER BY id", (day,))
if logical:
rows = [{key: value for key, value in row.items() if key not in ("id", "created_at", "updated_at")} for row in rows]
serialized = [json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str) for row in rows]
if logical:
serialized.sort()
result[table] = {"rows": len(rows), "sha256": hashlib.sha256("\n".join(serialized).encode()).hexdigest()}
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("backup", "verify", "restore"))
parser.add_argument("--config", type=Path, default=Path("configs/config.yaml"))
parser.add_argument("--date", default="2026-11-03")
parser.add_argument("--state", type=Path, required=True)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
dt.date.fromisoformat(args.date)
args.state = args.state.resolve()
if args.state.is_relative_to(Path(__file__).resolve().parents[1]):
raise ValueError("报表备份和身份键必须保存在仓库外")
args.state.mkdir(parents=True, exist_ok=True)
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["data"]["database"]
manifest_path = args.state / "report-baseline.json"
if args.action == "backup":
if manifest_path.exists():
raise ValueError("已有基线备份,禁止覆盖")
manifest = {"date": args.date, "databases": {}}
with connect(config) as conn:
manifest["server"] = query(conn, "SELECT @@hostname hostname,@@version version")[0]
for database in ("sw8", "sw9"):
baseline = snapshot(conn, database, args.date)
options = client_options(config, args.state)
file = args.state / (database + "-report-partition.sql.gz")
try:
with (args.state / (database + "-dump.stderr.log")).open("wb") as log:
process = subprocess.Popen([shutil.which("mysqldump"), "--defaults-extra-file=" + str(options), "--single-transaction", "--quick", "--hex-blob",
"--no-create-info", "--skip-triggers", "--skip-add-locks", "--skip-disable-keys", "--complete-insert", "--set-gtid-purged=OFF",
"--where=stat_date='" + args.date + "'", database, *TABLES], stdout=subprocess.PIPE, stderr=log)
with gzip.open(file, "wb") as target:
shutil.copyfileobj(process.stdout, target)
if process.wait() != 0:
raise RuntimeError("报表备份失败")
with gzip.open(file, "rb") as stream:
while stream.read(1024 * 1024):
pass
manifest["databases"][database] = {"tables": baseline, "backup": str(file), "sha256": hashlib.sha256(file.read_bytes()).hexdigest()}
finally:
options.unlink(missing_ok=True)
write_json(manifest_path, manifest)
print(json.dumps({"date": args.date, "tables": {db: value["tables"] for db, value in manifest["databases"].items()}}, ensure_ascii=False), flush=True)
return
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest["date"] != args.date:
raise ValueError("日期与备份不符")
with connect(config) as conn:
if query(conn, "SELECT @@hostname hostname,@@version version")[0] != manifest["server"]:
raise ValueError("数据库服务器与备份不符")
if args.action == "restore":
for database, value in manifest["databases"].items():
if database not in ("sw8", "sw9"):
raise ValueError("未知数据库")
file = Path(value["backup"])
if hashlib.sha256(file.read_bytes()).hexdigest() != value["sha256"]:
raise ValueError("备份校验失败")
content = gzip.decompress(file.read_bytes())
if b"LOCK TABLES" in content or b"ALTER TABLE" in content or b"CREATE TABLE" in content:
raise ValueError("分区恢复文件不允许包含 DDL 或隐式提交")
deletes = "\n".join(f"DELETE FROM `{table}` WHERE stat_date='{args.date}';" for table in TABLES)
script = ("START TRANSACTION;\n" + deletes + "\n").encode() + content + b"\nCOMMIT;\n"
options = client_options(config, args.state)
try:
completed = subprocess.run([shutil.which("mysql"), "--defaults-extra-file=" + str(options), "--batch", database], input=script, capture_output=True)
(args.state / (database + "-restore.stderr.log")).write_bytes(completed.stderr)
if completed.returncode:
raise RuntimeError("分区恢复失败")
finally:
options.unlink(missing_ok=True)
with connect(config) as conn:
checks = [{"database": db, "passed": snapshot(conn, db, args.date) == value["tables"]} for db, value in manifest["databases"].items()]
else:
with connect(config) as conn:
source, target = [snapshot(conn, db, args.date, logical=True) for db in ("sw8", "sw9")]
checks = [{"table": table, "source": source[table], "target": target[table], "passed": source[table] == target[table]} for table in TABLES]
for db in ("sw8", "sw9"):
for table in TABLES:
rows = query(conn, f"SELECT * FROM `{db}`.`{table}` WHERE stat_date=%s ORDER BY id", (args.date,))
write_json(args.state / (db + "-" + table + "-rebuilt.json"), rows)
result = {"action": args.action, "date": args.date, "checks": checks, "passed": all(item["passed"] for item in checks)}
write_json(args.output or args.state / (args.action + ".json"), result)
print(json.dumps(result, ensure_ascii=False), flush=True)
if not result["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()