"""在同一测试服务器核对 SW 源副本、备份目标库并执行迁移;凭据只读取本地配置。""" import argparse import datetime as dt import gzip import hashlib import json import re import shutil import subprocess import tempfile from pathlib import Path import pymysql import yaml def identifier(value): if not re.fullmatch(r"[A-Za-z0-9_]+", value): raise ValueError("数据库和表名只能包含字母、数字、下划线") return "`" + value + "`" def write_json(path, data): path.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8") def connect(config): return pymysql.connect(host=config["host"], port=int(config["port"]), user=config["user"], password=config["password"], charset="utf8mb4", connect_timeout=10, read_timeout=180, autocommit=True, cursorclass=pymysql.cursors.DictCursor) def query(conn, sql, args=None): with conn.cursor() as cursor: cursor.execute(sql, args) return cursor.fetchall() def source_tables(sql): tables = re.findall(r"CREATE TABLE `bak_sw2kra_20261103_([^`]+)` LIKE", sql) if len(tables) != 26 or len(set(tables)) != 26: raise ValueError("迁移源表清单发生变化,请先复核备份范围") return tables def fingerprint(conn, database, table): columns = query(conn, "SELECT column_name,data_type FROM information_schema.columns WHERE table_schema=%s AND table_name=%s ORDER BY ordinal_position", (database, table)) keys = query(conn, "SELECT column_name FROM information_schema.statistics WHERE table_schema=%s AND table_name=%s AND index_name='PRIMARY' ORDER BY seq_in_index", (database, table)) if not keys: raise ValueError("备份核对要求稳定主键:" + table) order = ",".join(identifier(item["column_name"]) for item in keys) digest = hashlib.sha256() rows = query(conn, f"SELECT * FROM {identifier(database)}.{identifier(table)} ORDER BY {order}") json_columns = [item["column_name"] for item in columns if item["data_type"] == "json"] for row in rows: # JSON 的内部二进制布局在导入后可能不同,比较实际业务值而非 CHECKSUM 的存储字节。 for name in json_columns: if row[name] is not None: row[name] = json.loads(row[name]) digest.update(json.dumps(row, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")).encode()) digest.update(b"\n") return {"rows": len(rows), "sha256": digest.hexdigest()} def client_options(config, folder): # 使用独立选项文件,避免密码出现在进程命令行或输出中。 def escape(value): return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r") handle = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".cnf", dir=folder, delete=False) with handle: handle.write("[client]\n") for key in ("host", "port", "user", "password"): handle.write(f'{key}="{escape(config[key])}"\n') handle.write("default-character-set=utf8mb4\n") return Path(handle.name) def prepare(args, config, sql): if (args.state / (args.target + "-migration-source.sql.gz")).exists(): raise ValueError("目标备份已存在;请使用新的状态目录,保留原始备份") tables = source_tables(sql) manifest = {"source": args.source, "target": args.target, "created_at": dt.datetime.now(dt.timezone.utc), "sql_sha256": hashlib.sha256(sql.encode()).hexdigest(), "checks": []} with connect(config) as conn: manifest["server"] = query(conn, "SELECT @@hostname hostname,@@version version")[0] if query(conn, "SELECT 1 FROM information_schema.tables WHERE table_schema=%s AND table_name='sys_users'", (args.target,)): raise ValueError("目标已迁移,不能覆盖原始基线") for table in tables: checksums = query(conn, f"CHECKSUM TABLE {identifier(args.source)}.{identifier(table)}, {identifier(args.target)}.{identifier(table)} EXTENDED") values = [row["Checksum"] for row in checksums] fingerprints = [fingerprint(conn, db, table) for db in (args.source, args.target)] item = {"table": table, "rows": [item["rows"] for item in fingerprints], "checksums": values, "fingerprints": fingerprints, "passed": fingerprints[0] == fingerprints[1]} manifest["checks"].append(item) print(json.dumps(item, ensure_ascii=False), flush=True) if not all(item["passed"] for item in manifest["checks"]): write_json(args.state / "baseline.json", manifest) raise RuntimeError("源库与目标库框架数据不一致,未执行迁移") manifest["tables_before"] = query(conn, "SELECT table_name,engine FROM information_schema.tables WHERE table_schema=%s ORDER BY table_name", (args.target,)) schemas = {} for row in manifest["tables_before"]: table = row["table_name"] schemas[table] = query(conn, f"SHOW CREATE TABLE {identifier(args.target)}.{identifier(table)}")[0]["Create Table"] write_json(args.state / "schema-before.json", schemas) dump = Path(shutil.which("mysqldump") or Path(shutil.which("mysql")).with_name("mysqldump.exe")) options = client_options(config, args.state) backup = args.state / (args.target + "-migration-source.sql.gz") try: command = [str(dump), "--defaults-extra-file=" + str(options), "--single-transaction", "--quick", "--hex-blob", "--set-gtid-purged=OFF", "--skip-lock-tables", args.target, *tables] with (args.state / "backup.stderr.log").open("wb") as errors: process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=errors) with gzip.open(backup, "wb") as output: shutil.copyfileobj(process.stdout, output) if process.wait() != 0: raise RuntimeError("备份失败,详情仅保存在本地日志") # 完整解压检查,避免把未写完的压缩文件当作成功备份。 with gzip.open(backup, "rb") as stream: while stream.read(1024 * 1024): pass manifest["backup"] = {"path": str(backup.resolve()), "bytes": backup.stat().st_size, "sha256": hashlib.sha256(backup.read_bytes()).hexdigest()} write_json(args.state / "baseline.json", manifest) print(json.dumps({"backup": manifest["backup"], "passed": True}), flush=True) finally: options.unlink(missing_ok=True) def migrate(args, config, sql): manifest = json.loads((args.state / "baseline.json").read_text(encoding="utf-8")) if (manifest["source"], manifest["target"]) != (args.source, args.target): raise ValueError("备份对应的数据库与本次选择不符") backup = Path(manifest["backup"]["path"]) if hashlib.sha256(backup.read_bytes()).hexdigest() != manifest["backup"]["sha256"]: raise ValueError("备份文件校验失败") statements = re.findall(r"(?m)^USE `[^`]+`;", sql) if len(statements) != 1: raise ValueError("SQL 必须只有一个显式 USE 入口") prepared = sql.replace(statements[0], "USE " + identifier(args.target) + ";") (args.state / "executed-migration.sql").write_text(prepared, encoding="utf-8") with connect(config) as conn: actual_server = query(conn, "SELECT @@hostname hostname,@@version version")[0] if actual_server != manifest["server"]: raise ValueError("连接服务器与备份时不同") # 仅首次执行需要核对原始基线;重复执行由迁移 SQL 的完成账本保护。 migrated = query(conn, "SELECT 1 FROM information_schema.tables WHERE table_schema=%s AND table_name='sys_users'", (args.target,)) if not migrated: for item in manifest["checks"]: if fingerprint(conn, args.target, item["table"]) != item["fingerprints"][1]: raise ValueError("目标数据在备份后变化:" + item["table"]) options = client_options(config, args.state) try: with (args.state / "migration.stdout.log").open("wb") as output, (args.state / "migration.stderr.log").open("wb") as errors: result = subprocess.run([shutil.which("mysql"), "--defaults-extra-file=" + str(options), "--batch", "--binary-mode"], input=prepared.encode("utf-8"), stdout=output, stderr=errors) print(json.dumps({"target": args.target, "exit_code": result.returncode, "sql_sha256": hashlib.sha256(prepared.encode()).hexdigest()}), flush=True) if result.returncode: raise RuntimeError("迁移 SQL 失败,请检查本地日志;不会自动覆盖部分迁移状态") finally: options.unlink(missing_ok=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("action", choices=("prepare", "migrate")) parser.add_argument("--config", type=Path, default=Path("configs/config.yaml")) parser.add_argument("--sql", type=Path, default=Path("docs/sw6-to-kra/sw_to_kra_migrate.sql")) parser.add_argument("--source", default="sw8") parser.add_argument("--target", default="sw9") parser.add_argument("--state", type=Path, required=True) args = parser.parse_args() identifier(args.source) identifier(args.target) if args.source == args.target or args.target in ("sw", "sw6"): raise ValueError("演练目标必须为独立测试副本") 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"] sql = args.sql.read_text(encoding="utf-8") {"prepare": prepare, "migrate": migrate}[args.action](args, config, sql) if __name__ == "__main__": main()