Files
2026-06-24 17:27:50 +08:00

233 lines
9.0 KiB
Python

from __future__ import annotations
import argparse
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
import sys
import tarfile
APP_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = APP_ROOT.parent
if str(APP_ROOT) not in sys.path:
sys.path.insert(0, str(APP_ROOT))
from app.db import SCHEMA_VERSION, connect, initialize_schema # noqa: E402
from app.repository import replace_database_from_paths, source_file_hashes # noqa: E402
EXPECTED_COUNTS = {
"records": 1548,
"accounts": 45,
"teachers": 13,
}
def sha256_path(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def data_paths(data_root: Path) -> dict[str, Path]:
return {
"classnotes_path": data_root / "classnotes.txt",
"accounts_path": data_root / "学生课时账户.md",
"teachers_path": data_root / "教师档案.md",
"tasks_path": data_root / "admin_tasks.json",
"summaries_root": data_root / "course_summaries",
"state_path": data_root / "course_summary_state.json",
"operation_logs_path": data_root / "operation_logs.jsonl",
}
def validate_counts(summary: dict, *, strict_expected: bool) -> list[str]:
errors: list[str] = []
for key in ["records", "accounts", "teachers", "admin_tasks", "operation_logs", "course_summaries"]:
if int(summary.get(key) or 0) < 0:
errors.append(f"{key} 数量异常")
if strict_expected:
for key, expected in EXPECTED_COUNTS.items():
actual = int(summary.get(key) or 0)
if actual != expected:
errors.append(f"{key} 数量应为 {expected},实际为 {actual}")
mismatch_count = len(summary.get("balance_mismatches") or [])
if mismatch_count != 4:
errors.append(f"余额差异应为 4 个学生,实际为 {mismatch_count}")
return errors
def validate_database(db_path: Path, summary: dict, *, strict_expected: bool) -> dict:
errors = validate_counts(summary, strict_expected=strict_expected)
with connect(db_path) as conn:
table_counts = {
"records": conn.execute("SELECT COUNT(*) FROM class_records").fetchone()[0],
"accounts": conn.execute("SELECT COUNT(*) FROM students").fetchone()[0],
"teachers": conn.execute("SELECT COUNT(*) FROM teachers").fetchone()[0],
"admin_tasks": conn.execute("SELECT COUNT(*) FROM admin_tasks").fetchone()[0],
"operation_logs": conn.execute("SELECT COUNT(*) FROM operation_logs").fetchone()[0],
"course_summaries": conn.execute("SELECT COUNT(*) FROM course_summaries").fetchone()[0],
}
for key, value in table_counts.items():
if int(summary.get(key) or 0) != int(value):
errors.append(f"SQLite {key} 数量不一致: source={summary.get(key)} sqlite={value}")
duplicate_records = conn.execute(
"""
SELECT record_key, COUNT(*) AS c
FROM class_records
GROUP BY record_key
HAVING c > 1
LIMIT 1
"""
).fetchone()
if duplicate_records:
errors.append(f"SQLite 中存在重复课程记录: {duplicate_records['record_key']}")
missing_account = conn.execute(
"""
SELECT student
FROM class_records
WHERE student_id IS NULL
LIMIT 1
"""
).fetchone()
if missing_account:
errors.append(f"SQLite 中存在没有账户的上课学生: {missing_account['student']}")
return {"ok": not errors, "errors": errors, "table_counts": table_counts}
def write_audit(conn: sqlite3.Connection, payload: dict) -> None:
conn.execute(
"INSERT INTO migration_audit(created_at, kind, payload_json) VALUES(?, ?, ?)",
(
datetime.now().isoformat(timespec="seconds"),
"text_to_sqlite",
json.dumps(payload, ensure_ascii=False, sort_keys=True),
),
)
def create_archive(data_root: Path, archives_root: Path, report_path: Path, timestamp: str) -> tuple[Path, Path]:
archives_root.mkdir(parents=True, exist_ok=True)
archive_path = archives_root / f"text-source-before-sqlite-{timestamp}.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
for name in [
"classnotes.txt",
"学生课时账户.md",
"教师档案.md",
"admin_tasks.json",
"operation_logs.jsonl",
"course_summary_state.json",
]:
path = data_root / name
if path.exists():
archive.add(path, arcname=name)
summaries_root = data_root / "course_summaries"
if summaries_root.exists():
archive.add(summaries_root, arcname="course_summaries")
archive.add(report_path, arcname=report_path.name)
sha_path = archive_path.with_suffix(archive_path.suffix + ".sha256")
sha_path.write_text(f"{sha256_path(archive_path)} {archive_path.name}\n", encoding="utf-8")
return archive_path, sha_path
def migrate(args: argparse.Namespace) -> dict:
data_root = args.data_root.resolve()
db_path = args.db_path.resolve()
tmp_path = Path(str(db_path) + ".tmp")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
report_path = data_root / f"sqlite_migration_report_{timestamp}.json"
if tmp_path.exists():
tmp_path.unlink()
if tmp_path.with_suffix(tmp_path.suffix + "-wal").exists():
tmp_path.with_suffix(tmp_path.suffix + "-wal").unlink()
if tmp_path.with_suffix(tmp_path.suffix + "-shm").exists():
tmp_path.with_suffix(tmp_path.suffix + "-shm").unlink()
with connect(tmp_path) as conn:
initialize_schema(conn)
conn.commit()
conn.execute("BEGIN IMMEDIATE")
try:
summary = replace_database_from_paths(
conn,
**data_paths(data_root),
allow_balance_adjustments=False,
)
conn.commit()
except Exception:
conn.rollback()
raise
validation = validate_database(tmp_path, summary, strict_expected=not args.no_strict_expected)
source_hashes = source_file_hashes(
[
data_root / "classnotes.txt",
data_root / "学生课时账户.md",
data_root / "教师档案.md",
data_root / "admin_tasks.json",
data_root / "operation_logs.jsonl",
data_root / "course_summary_state.json",
]
)
report = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"schema_version": SCHEMA_VERSION,
"data_root": str(data_root),
"db_path": str(db_path),
"summary": summary,
"validation": validation,
"source_hashes": source_hashes,
"dry_run": bool(args.dry_run),
}
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if not validation["ok"]:
raise SystemExit("迁移校验失败:\n" + "\n".join(validation["errors"]))
with connect(tmp_path) as conn:
write_audit(conn, {**report, "report_path": str(report_path)})
if args.dry_run:
print(json.dumps({**report, "tmp_db_path": str(tmp_path), "report_path": str(report_path)}, ensure_ascii=False, indent=2))
return report
db_path.parent.mkdir(parents=True, exist_ok=True)
if db_path.exists():
backup_db = db_path.with_name(f"{db_path.name}.before-sqlite-migration-{timestamp}")
shutil.copy2(db_path, backup_db)
report["previous_db_backup"] = str(backup_db)
os.replace(tmp_path, db_path)
for suffix in ["-wal", "-shm"]:
sidecar = Path(str(tmp_path) + suffix)
if sidecar.exists():
os.replace(sidecar, Path(str(db_path) + suffix))
archive_path, sha_path = create_archive(data_root, args.archives_root.resolve(), report_path, timestamp)
report["archive_path"] = str(archive_path)
report["archive_sha256_path"] = str(sha_path)
report["dry_run"] = False
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({**report, "report_path": str(report_path)}, ensure_ascii=False, indent=2))
return report
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="将新时空纯文本教务数据迁移到 SQLite")
parser.add_argument("--data-root", type=Path, default=REPO_ROOT / "data")
parser.add_argument("--db-path", type=Path, default=Path("/data/xsk_education.db"))
parser.add_argument("--archives-root", type=Path, default=REPO_ROOT / "archives")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--no-strict-expected", action="store_true", help="不校验当前生产数据的固定计数")
return parser.parse_args()
if __name__ == "__main__":
migrate(parse_args())