"""Read-only migration audit by default; apply creates a consistent backup first. Use backend/venv/Scripts/python.exe scripts/assistant_migrate.py --dry-run. Restore produces a separate recovered database, never overwrites post-release writes. """ import argparse import json import sqlite3 import sys from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "backend")) def audit(path): if not path.is_file(): return {"database": str(path), "exists": False, "action": "initialize-demo-instance", "dangling_references": []} with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db: tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} counts = {name: db.execute('SELECT count(*) FROM "' + name + '"').fetchone()[0] for name in tables if name in ("projects", "runs", "snapshots", "reports", "experiences", "assistant_objects", "assistant_jobs")} dangling = [] if "assistant_objects" in tables: objects = {(r[0], r[1]): json.loads(r[2]) for r in db.execute("SELECT kind,id,body FROM assistant_objects")} for (kind, key), body in objects.items(): for field, target in (("snapshot_id", "snapshot"), ("input_snapshot_id", "input_snapshot"), ("run_id", "run"), ("document_ids", "document")): if kind not in ("run", "report", "report_final", "project", "experience_application"): continue values = body.get(field) for value in (values if isinstance(values, list) else [values]): if value and (target, value) not in objects: dangling.append({"kind": kind, "id": key, "field": field, "missing": value}) return {"database": str(path), "exists": True, "counts": counts, "integrity": db.execute("PRAGMA integrity_check").fetchone()[0], "dangling_references": dangling} def backup(source, target): if target.exists() or source == target: raise ValueError("备份目标已存在或与源相同,拒绝覆盖") target.parent.mkdir(parents=True, exist_ok=True) original = sqlite3.connect(source.as_uri() + "?mode=ro", uri=True) output = sqlite3.connect(target) try: original.backup(output) finally: output.close() original.close() def main(): parser = argparse.ArgumentParser() parser.add_argument("--database", type=Path, default=ROOT / "backend/xingbang_data/project_assistant.sqlite3") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--apply", action="store_true") parser.add_argument("--restore-copy", type=Path, help="Output recovered DB alongside current DB; never replace live data") args = parser.parse_args() path = args.database.resolve() if not path.is_relative_to(ROOT): raise ValueError("迁移数据库须位于当前工作区") before = audit(path) if before.get("integrity", "ok") != "ok" or before["dangling_references"]: print(json.dumps(before, ensure_ascii=False, indent=2)) return 1 if args.restore_copy: target = args.restore_copy.resolve() if not target.is_relative_to(ROOT) or target == path: raise ValueError("恢复副本须为工作区内的新路径") backup(path, target) print(json.dumps({"restored_copy": str(target), "live_data_untouched": True, "audit": audit(target)}, ensure_ascii=False, indent=2)) return 0 if args.apply and not args.dry_run: if path.exists(): target = ROOT / ("_backup_pre_assistant_migration_" + datetime.now().strftime("%Y%m%d_%H%M%S")) / path.name backup(path, target) before["backup"] = str(target) from services.project_assistant import ProjectAssistantService ProjectAssistantService(path) result = {"before": before, "after": audit(path), "legacy_tables": "preserved-read-only"} else: result = {"mode": "dry-run", **before, "writes": 0} print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())