# -*- coding: utf-8 -*- """SQLite → MySQL 一次性迁移脚本(离线执行,非运行时注入)。 用法: # 1) 在 .env 配置 PINEAGENTS_DEMO_DATABASE_URL 指向 MySQL(或用 --mysql 覆盖) # 2) 跑迁移(默认目标 = config.DATABASE_URL,源 = SQLite 数据目录 app.db) uv run python scripts/migrate_sqlite_to_mysql.py uv run python scripts/migrate_sqlite_to_mysql.py --drop-existing # 危险:先清空 MySQL 目标表 流程: 1. 按平台 Base.metadata(含 app.pay.models)在 MySQL create_all 建全量表 (MySQL 方言钩子:String 无长度→VARCHAR(255),Text→MEDIUMTEXT) 2. alembic stamp head(对齐迁移版本,后续 alembic upgrade 可用) 3. 从 SQLite 逐表拷贝(metadata 拓扑序,外键安全),自增表回填 AUTO_INCREMENT 4. 逐表行数校验,输出报告 幂等:表已存在且非空时默认跳过该表(增量续跑);--drop-existing 强制重建。 """ from __future__ import annotations import argparse import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app import config # noqa: E402 加载 .env from app.infrastructure.db import Base, make_sync_engine # noqa: E402 def _sqlite_url() -> str: p = Path(config.DATA_DIR) / "app.db" return f"sqlite:///{p}" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--sqlite", default=_sqlite_url(), help="源 SQLite URL") ap.add_argument("--mysql", default=config.DATABASE_URL, help="目标 MySQL URL(默认取 DATABASE_URL)") ap.add_argument("--drop-existing", action="store_true", help="先 DROP 目标表再重建(危险)") args = ap.parse_args() if args.mysql.startswith("sqlite"): print("[!] 目标仍是 SQLite——请先在 .env 配置 PINEAGENTS_DEMO_DATABASE_URL 指向 MySQL") return 2 from sqlalchemy import Boolean, Integer, Numeric, Text, inspect, text from app.infrastructure import models # noqa: F401 平台+培训 ORM 注册 import app.pay.models # noqa: F401 支付子应用 ORM 注册 src = make_sync_engine(args.sqlite) dst = make_sync_engine(args.mysql) insp = inspect(dst) # 1) 建表 --------------------------------------------------------------- existing = set(insp.get_table_names()) to_create = [t for t in Base.metadata.sorted_tables if t.name not in existing] if args.drop_existing: with dst.begin() as conn: conn.execute(text("SET FOREIGN_KEY_CHECKS=0")) for t in Base.metadata.sorted_tables: conn.execute(text(f"DROP TABLE IF EXISTS `{t.name}`")) conn.execute(text("SET FOREIGN_KEY_CHECKS=1")) existing, to_create = set(), list(Base.metadata.sorted_tables) print(f"[1/4] 已清空并重建目标({len(to_create)} 表)") else: print(f"[1/4] MySQL 建表:新建 {len(to_create)} / 共 {len(Base.metadata.tables)}(已有 {len(existing)})") if to_create: Base.metadata.create_all(dst, tables=to_create) # 2) alembic stamp head ------------------------------------------------- from alembic.config import Config as AlembicConfig from alembic import command ac = AlembicConfig(str(Path(__file__).resolve().parent.parent / "alembic.ini")) ac.set_main_option("sqlalchemy.url", args.mysql) try: command.stamp(ac, "head") print("[2/4] alembic stamp head 完成") except Exception as exc: # noqa: BLE001 print(f"[2/4] stamp 跳过:{exc}") # 3) 拷贝数据 ----------------------------------------------------------- from sqlalchemy import MetaData, Table copied, skipped = [], [] with src.connect() as sconn: with dst.begin() as dconn: dconn.execute(text("SET FOREIGN_KEY_CHECKS=0")) try: for t in Base.metadata.sorted_tables: # 行数(源为空则跳过) n_src = sconn.execute(text(f"SELECT COUNT(*) FROM `{t.name}`")).scalar() or 0 n_dst = dconn.execute(text(f"SELECT COUNT(*) FROM `{t.name}`")).scalar() or 0 if n_src == 0: skipped.append((t.name, "源为空")) continue if n_dst >= n_src: skipped.append((t.name, f"目标已有 {n_dst} 行")) continue rows = [dict(r._mapping) for r in sconn.execute(text(f"SELECT * FROM `{t.name}`"))] # NOT NULL 兜底归一:SQLite 历史数据可能存在 NULL(迁移期 NOT NULL 未强制), # MySQL 严格模式会拒收 → 按列类型补零值(str→"" / int→0 / bool→False / float→0.0) for row in rows: for c in t.columns: if row.get(c.name) is None and not c.nullable: if isinstance(c.type, Boolean): row[c.name] = False elif isinstance(c.type, Integer): row[c.name] = 0 elif isinstance(c.type, Numeric): row[c.name] = 0.0 elif isinstance(c.type, (Text, __import__("sqlalchemy").String)): row[c.name] = "" row[c.name] = 0.0 if rows: dconn.execute(t.insert(), rows) # 自增回填(MySQL ALTER 不允许子查询,先取 MAX 再写回) ai_col = next((c.name for c in t.columns if isinstance(c.type, Integer) and c.autoincrement and c.primary_key), None) if ai_col and rows: mx = dconn.execute(text(f"SELECT IFNULL(MAX(`{ai_col}`),0) FROM `{t.name}`")).scalar() or 0 dconn.execute(text(f"ALTER TABLE `{t.name}` AUTO_INCREMENT = {int(mx) + 1}")) copied.append((t.name, len(rows))) finally: dconn.execute(text("SET FOREIGN_KEY_CHECKS=1")) # 4) 校验 --------------------------------------------------------------- print("[3/4] 拷贝完成:") for name, n in copied: print(f" ✓ {name}: {n} 行") for name, why in skipped: print(f" - {name}: 跳过({why})") bad = [] with src.connect() as sconn, dst.connect() as dconn: for t in Base.metadata.sorted_tables: a = sconn.execute(text(f"SELECT COUNT(*) FROM `{t.name}`")).scalar() or 0 b = dconn.execute(text(f"SELECT COUNT(*) FROM `{t.name}`")).scalar() or 0 if a != b: bad.append((t.name, a, b)) print("[4/4] 行数校验:" + ("全部一致 ✓" if not bad else "不一致 ✗")) for name, a, b in bad: print(f" ✗ {name}: sqlite={a} mysql={b}") return 1 if bad else 0 if __name__ == "__main__": raise SystemExit(main())