项目代码提交

This commit is contained in:
zhouxing
2026-09-21 17:30:02 +08:00
parent 70f7ebd0a0
commit 456d3fe316
96 changed files with 6173 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
"""Restore a verified export only into an empty compose.cloud.yaml db service.
The SQL's adjacent .manifest.json is mandatory. --env-file must explicitly
declare COMPOSE_PROJECT_NAME. Existing tables/views are never overwritten;
failed imports are left intact for inspection, without automatic DROP/reset.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
TARGET_DATABASE = "student_management_system"
MYSQL_COMMAND = (
'[ "$MYSQL_DATABASE" = "student_management_system" ] && '
'[ "$MYSQL_USER" = "wolin" ] || exit 64; '
'MYSQL_PWD="$MYSQL_PASSWORD" exec mysql --protocol=TCP --host=127.0.0.1 '
'--user="$MYSQL_USER" --database="$MYSQL_DATABASE" '
'--default-character-set=utf8mb4 --batch --raw --skip-column-names --binary-mode'
)
class MigrationError(Exception):
pass
def compose_command(env_file: Path) -> tuple[list[str], dict[str, str]]:
docker = shutil.which("docker")
if not docker:
raise MigrationError("找不到 docker 命令。")
if not env_file.is_file():
raise MigrationError("--env-file 指定的文件不存在。")
entries = {}
for line in env_file.read_text(encoding="utf-8-sig").splitlines():
match = re.match(r"\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$", line)
if match:
entries[match[1]] = match[2].strip()
project = entries.get("COMPOSE_PROJECT_NAME", "")
if project.startswith(('"', "'")) and project[-1:] == project[:1]:
project = project[1:-1]
else:
project = project.split(" #", 1)[0].strip()
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", project):
raise MigrationError("env 文件必须明确设置合法的 COMPOSE_PROJECT_NAME,禁止使用空值或变量插值。")
# --env-file normally loses to shell variables. Make this chosen file
# authoritative so an inherited project/password cannot select another stack.
environment = os.environ.copy()
for name in entries:
environment.pop(name, None)
environment["COMPOSE_PROJECT_NAME"] = project
command = [docker, "compose", "--env-file", str(env_file),
"-f", str(ROOT / "compose.cloud.yaml"), "exec", "-T", "db",
"sh", "-c", MYSQL_COMMAND]
return command, environment
def run_mysql(command: list[str], environment: dict[str, str], *, sql: str | None = None,
source=None, importing: bool = False) -> str:
arguments = {"input": sql.encode("utf-8")} if sql is not None else {"stdin": source}
result = subprocess.run(command, env=environment, cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=False, **arguments)
if result.returncode:
detail = ("导入可能已部分完成,未执行任何清理或覆盖;请保留目标库并检查。"
if importing else "请检查指定 Compose 项目、db 健康状态与账号权限。")
raise MigrationError(f"目标数据库操作失败(退出码 {result.returncode})。{detail}")
return result.stdout.decode("utf-8").strip()
def load_manifest(sql_path: Path) -> dict:
manifest_path = sql_path.with_suffix(".manifest.json")
if not sql_path.is_file() or not manifest_path.is_file():
raise MigrationError("SQL 文件及同名 .manifest.json 必须同时存在。")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict) or manifest.get("format_version") != 1:
raise MigrationError("不支持的 manifest 格式。")
if manifest.get("sql_file") != sql_path.name:
raise MigrationError("manifest 中的 SQL 文件名不匹配。")
if not re.fullmatch(r"[0-9a-f]{64}", str(manifest.get("sha256", ""))):
raise MigrationError("manifest 的 SHA-256 无效。")
tables = manifest.get("tables")
if (not isinstance(tables, dict) or not tables
or any(not isinstance(name, str) or not name or "\x00" in name
or "\n" in name or "\r" in name
or type(count) is not int or count < 0 for name, count in tables.items())):
raise MigrationError("manifest 表清单或行数无效。")
return manifest
def restore(sql_path: Path, env_file: Path) -> tuple[int, int]:
manifest = load_manifest(sql_path)
with sql_path.open("rb") as sql_file:
digest = hashlib.sha256()
for block in iter(lambda: sql_file.read(1024 * 1024), b""):
digest.update(block)
if digest.hexdigest() != manifest["sha256"]:
raise MigrationError("SQL 文件的 SHA-256 与 manifest 不一致,已拒绝导入。")
command, environment = compose_command(env_file)
table_query = ("SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME;")
existing = run_mysql(command, environment, sql=table_query)
if existing:
raise MigrationError("目标数据库已有表或视图,已拒绝导入;不会覆盖、删除或重置现有数据。")
print(f"已通过 SHA-256 与空库检查,正在导入 {TARGET_DATABASE}。")
sql_file.seek(0)
run_mysql(command, environment, source=sql_file, importing=True)
actual_tables = run_mysql(command, environment, sql=table_query).splitlines()
expected = manifest["tables"]
names = sorted(expected)
if sorted(actual_tables) != names:
raise MigrationError("导入后的表/视图清单与 manifest 不一致;保留目标数据,请检查后再使用。")
quote = lambda name: "`" + name.replace("`", "``") + "`"
count_query = " UNION ALL ".join(
f"SELECT {index}, COUNT(*) FROM {quote(name)}" for index, name in enumerate(names)
) + ";"
counts = run_mysql(command, environment, sql=count_query).splitlines()
try:
actual_counts = {int(parts[0]): int(parts[1])
for parts in (line.split("\t") for line in counts)}
except (ValueError, IndexError):
raise MigrationError("无法解析导入后的行数;保留目标数据,请检查。") from None
if (set(actual_counts) != set(range(len(names)))
or any(actual_counts[index] != expected[name] for index, name in enumerate(names))):
raise MigrationError("导入后的逐表行数与 manifest 不一致;保留目标数据,请检查后再使用。")
return len(names), sum(expected.values())
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("sql", type=Path, help="export_database.py 生成的 SQL 文件")
parser.add_argument("--env-file", required=True, type=Path, help="目标 Compose 项目的云端/预览环境文件")
args = parser.parse_args()
try:
tables, rows = restore(args.sql.resolve(), args.env_file.resolve())
except MigrationError as error:
print(f"迁移失败:{error}", file=sys.stderr)
return 1
except Exception as error:
print(f"迁移失败({type(error).__name__}):请检查文件、Docker 和数据库状态。"
"不会自动删除或重置任何数据库。", file=sys.stderr)
return 1
print(f"迁移完成:{tables} 个表/视图、{rows} 行,逐表行数与 SHA-256 校验通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())