Files
Cloud-Tour-to-Libo/scripts/migrate_data_center_postgres_to_mysql.py
3452078359-xuexue 3dd5731751 feat: streamline platform and secure data access
- move the relational data center to MySQL and a standalone workbench\n- add Interface Center API credentials, policies, logs, and DBeaver SSH guidance\n- harden authentication and deployment while retiring unused management surfaces
2026-08-25 02:06:28 -07:00

375 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Copy the legacy PostgreSQL-schema Data Center into real MySQL databases.
The command is dry-run by default. It never changes PostgreSQL source data and
will not overwrite an existing MySQL target unless both ``--execute`` and
``--replace-target`` are supplied.
"""
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime, timezone
import json
from pathlib import Path
import sys
from typing import Any
import uuid
from psycopg import sql
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
if str(REPOSITORY_ROOT) not in sys.path:
sys.path.insert(0, str(REPOSITORY_ROOT))
from app import db
from app.config import settings
from app.data_platform.mysql_db import close_data_pool, get_data_conn, init_data_pool
from app.data_platform.mysql_service import (
_create_physical_table,
delete_project_database,
ensure_platform_registry,
ensure_project_database,
get_project_database,
)
from app.data_platform.registry import TABLE_DEFINITIONS
from app.data_platform.schema import (
_custom_table_from_row,
_field_definitions_from_rows,
_field_row,
_table_with_identity,
)
SYSTEM_COLUMNS = (
"id",
"tenant_id",
"project_id",
"created_at",
"updated_at",
"deleted_at",
"deleted_by",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-id", action="append", help="仅迁移指定数据库编码,可重复")
parser.add_argument("--execute", action="store_true", help="实际执行;未提供时只输出迁移计划")
parser.add_argument(
"--replace-target",
action="store_true",
help="目标 MySQL 数据库已存在时先删除后重建(必须同时提供 --execute)",
)
parser.add_argument("--batch-size", type=int, default=1000)
return parser.parse_args()
def mysql_value(value: Any, *, json_field: bool = False) -> Any:
if value is None:
return None
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, datetime) and value.tzinfo is not None:
return value.astimezone(timezone.utc).replace(tzinfo=None)
if json_field and not isinstance(value, str):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
return value
async def legacy_databases(project_ids: set[str] | None) -> list[dict[str, Any]]:
async with db.get_conn() as conn:
async with conn.cursor() as cur:
query = sql.SQL(
"SELECT * FROM {}.project_databases WHERE status='ready'"
).format(sql.Identifier(settings.db_schema))
params: tuple[Any, ...] = ()
if project_ids:
query += sql.SQL(" AND project_id = ANY(%s)")
params = (list(project_ids),)
query += sql.SQL(" ORDER BY created_at, project_id")
await cur.execute(query, params)
return [dict(row) for row in await cur.fetchall()]
async def legacy_table_entries(
project_id: str,
) -> tuple[tuple[Any, str, str], ...]:
"""Read legacy metadata directly without running PostgreSQL DDL."""
async with db.get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
sql.SQL(
"SELECT * FROM {}.project_table_overrides WHERE project_id=%s"
).format(sql.Identifier(settings.db_schema)),
(project_id,),
)
overrides = {
str(row["source_code"]): dict(row) for row in await cur.fetchall()
}
await cur.execute(
sql.SQL(
"""
SELECT * FROM {}.project_table_definitions
WHERE project_id=%s AND status='active'
ORDER BY created_at, table_code
"""
).format(sql.Identifier(settings.db_schema)),
(project_id,),
)
custom_rows = [dict(row) for row in await cur.fetchall()]
entries: list[tuple[Any, str, str]] = []
for definition in TABLE_DEFINITIONS:
override = overrides.get(definition.code)
if override and str(override["status"]) == "deleted":
continue
effective = (
_table_with_identity(
definition,
code=str(override["table_code"]),
label=str(override["label"]),
fields=(
_field_definitions_from_rows(override["fields_jsonb"])
if override.get("fields_jsonb") is not None
else definition.fields
),
)
if override
else definition
)
entries.append((effective, "builtin", definition.code))
entries.extend(
(_custom_table_from_row(row), "custom", str(row["table_code"]))
for row in custom_rows
)
return tuple(entries)
async def target_has_database(project_id: str) -> bool:
return await get_project_database(project_id) is not None
async def rebuild_target_structure(
database: dict[str, Any],
entries: tuple[tuple[Any, str, str], ...],
) -> None:
project_id = str(database["project_id"])
database_name = str(database["database_name"])
async with get_data_conn(database_name) as conn:
async with conn.cursor() as cur:
await cur.execute("SET FOREIGN_KEY_CHECKS=0")
await cur.execute("SHOW TABLES")
for row in await cur.fetchall():
table_name = str(next(iter(row.values())))
if not table_name.replace("_", "a").isalnum():
raise RuntimeError(f"目标表名不安全:{table_name}")
await cur.execute(f"DROP TABLE `{table_name}`")
await cur.execute("SET FOREIGN_KEY_CHECKS=1")
await conn.commit()
async with get_data_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
"DELETE FROM project_table_definitions WHERE project_id=%s",
(project_id,),
)
await cur.execute(
"DELETE FROM data_change_logs WHERE project_id=%s",
(project_id,),
)
for order, (definition, origin, source_code) in enumerate(entries):
await cur.execute(
"""
INSERT INTO project_table_definitions (
project_id, source_code, table_code, label, group_name,
description, fields_json, origin, display_order,
allow_create, allow_update, allow_delete, created_by
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'migration')
""",
(
project_id,
source_code,
definition.code,
definition.label,
definition.group,
definition.description,
json.dumps([_field_row(field) for field in definition.fields], ensure_ascii=False),
origin,
order,
int(definition.allow_create),
int(definition.allow_update),
int(definition.allow_delete),
),
)
await conn.commit()
for definition, _origin, _source_code in entries:
await _create_physical_table(database_name, definition)
async def copy_table_rows(
source_schema: str,
target_database: str,
definition: Any,
batch_size: int,
) -> int:
business_columns = [field.code for field in definition.fields]
columns = [
"id",
"tenant_id",
"project_id",
*business_columns,
"created_at",
"updated_at",
"deleted_at",
"deleted_by",
]
json_fields = {field.code for field in definition.fields if field.data_type == "json"}
placeholders = ", ".join(["%s"] * len(columns))
target_query = (
f"INSERT INTO `{definition.code}` "
f"({', '.join(f'`{column}`' for column in columns)}) VALUES ({placeholders})"
)
copied = 0
async with db.get_conn() as source_conn:
async with source_conn.cursor() as source_cur:
await source_cur.execute(
sql.SQL("SELECT {} FROM {}.{} ORDER BY created_at, id").format(
sql.SQL(", ").join(sql.Identifier(column) for column in columns),
sql.Identifier(source_schema),
sql.Identifier(definition.code),
)
)
async with get_data_conn(target_database) as target_conn:
async with target_conn.cursor() as target_cur:
while True:
rows = await source_cur.fetchmany(batch_size)
if not rows:
break
values = [
tuple(
mysql_value(row[column], json_field=column in json_fields)
for column in columns
)
for row in rows
]
await target_cur.executemany(target_query, values)
copied += len(values)
await target_conn.commit()
return copied
async def copy_audit_logs(source_schema: str, project_id: str, batch_size: int) -> int:
columns = (
"tenant_id",
"project_id",
"table_code",
"record_id",
"operation",
"before_data",
"after_data",
"actor",
"created_at",
)
copied = 0
async with db.get_conn() as source_conn:
async with source_conn.cursor() as source_cur:
await source_cur.execute(
sql.SQL("SELECT {} FROM {}.data_change_logs ORDER BY created_at, id").format(
sql.SQL(", ").join(sql.Identifier(column) for column in columns),
sql.Identifier(source_schema),
)
)
async with get_data_conn() as target_conn:
async with target_conn.cursor() as target_cur:
while True:
rows = await source_cur.fetchmany(batch_size)
if not rows:
break
values = [
(
row["tenant_id"],
project_id,
row["table_code"],
str(row["record_id"]),
row["operation"],
mysql_value(row["before_data"], json_field=True),
mysql_value(row["after_data"], json_field=True),
row["actor"],
mysql_value(row["created_at"]),
)
for row in rows
]
await target_cur.executemany(
"""
INSERT INTO data_change_logs (
tenant_id, project_id, table_code, record_id,
operation, before_data, after_data, actor, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
values,
)
copied += len(values)
await target_conn.commit()
return copied
async def migrate(args: argparse.Namespace) -> None:
await db.init_pool()
if not await init_data_pool():
raise RuntimeError("无法连接 DATA_MYSQL_URL 指向的 MySQL 服务")
await ensure_platform_registry()
try:
requested = set(args.project_id or []) or None
databases = await legacy_databases(requested)
if not databases:
print("没有找到待迁移的 PostgreSQL 数据中心数据库。")
return
print(f"发现 {len(databases)} 个数据库;模式:{'执行' if args.execute else '仅预览'}")
for source in databases:
project_id = str(source["project_id"])
entries = await legacy_table_entries(project_id)
print(
f"- {source['display_name']} ({project_id}):{len(entries)} 张表,"
f"{source['schema_name']} → {project_id}"
)
if not args.execute:
continue
exists = await target_has_database(project_id)
if exists and not args.replace_target:
raise RuntimeError(
f"目标 {project_id} 已存在;确认可覆盖后追加 --replace-target"
)
if exists:
target = await get_project_database(project_id)
await delete_project_database(project_id, str(target["database_name"]))
target = await ensure_project_database(
project_id,
str(source["tenant_id"]),
str(source["display_name"]),
)
await rebuild_target_structure(target, entries)
total = 0
for definition, _origin, _source_code in entries:
count = await copy_table_rows(
str(source["schema_name"]),
str(target["database_name"]),
definition,
max(100, min(10_000, args.batch_size)),
)
total += count
print(f" · {definition.code}: {count:,} 条")
audit_count = await copy_audit_logs(
str(source["schema_name"]),
project_id,
max(100, min(10_000, args.batch_size)),
)
print(f" 完成:业务记录 {total:,} 条,审计记录 {audit_count:,} 条")
finally:
await close_data_pool()
await db.close_pool()
if __name__ == "__main__":
asyncio.run(migrate(parse_args()))