Files
server-core/scripts/migrate_sqlite_to_mysql.py
T

151 lines
7.0 KiB
Python
Raw Normal View History

# -*- 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())