migrate_minio_urls.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #!/usr/bin/env python3
  2. """批量替换数据库历史 URL 前缀(MinIO 域名迁移脚本)。
  3. 用法(在 backend 目录执行):
  4. 1) 先预览:
  5. python scripts/migrate_minio_urls.py --dry-run
  6. 2) 正式执行:
  7. python scripts/migrate_minio_urls.py
  8. 3) 自定义前缀:
  9. python scripts/migrate_minio_urls.py \
  10. --old-prefix "https://www.chengyantek.cn:20004" \
  11. --new-prefix "https://www.chengyantek.cn"
  12. """
  13. from __future__ import annotations
  14. import argparse
  15. import sys
  16. from datetime import datetime
  17. from pathlib import Path
  18. from sqlalchemy import text
  19. sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
  20. from app.core.database import engine # noqa: E402
  21. DEFAULT_OLD_PREFIX = "https://www.chengyantek.cn:20004"
  22. DEFAULT_NEW_PREFIX = "https://www.chengyantek.cn"
  23. TEXT_TYPES = (
  24. "char",
  25. "varchar",
  26. "tinytext",
  27. "text",
  28. "mediumtext",
  29. "longtext",
  30. )
  31. def parse_args() -> argparse.Namespace:
  32. parser = argparse.ArgumentParser(description="批量替换数据库中的 URL 前缀")
  33. parser.add_argument("--old-prefix", default=DEFAULT_OLD_PREFIX, help="旧 URL 前缀")
  34. parser.add_argument("--new-prefix", default=DEFAULT_NEW_PREFIX, help="新 URL 前缀")
  35. parser.add_argument(
  36. "--dry-run",
  37. action="store_true",
  38. help="仅预览受影响记录数,不执行更新",
  39. )
  40. return parser.parse_args()
  41. def quote_ident(name: str) -> str:
  42. """安全引用 MySQL 标识符。"""
  43. return f"`{name.replace('`', '``')}`"
  44. def get_text_columns(conn) -> list[tuple[str, str]]:
  45. sql = text(
  46. """
  47. SELECT table_name, column_name
  48. FROM information_schema.columns
  49. WHERE table_schema = DATABASE()
  50. AND data_type IN :types
  51. ORDER BY table_name, ordinal_position
  52. """
  53. )
  54. rows = conn.execute(sql, {"types": TEXT_TYPES}).fetchall()
  55. return [(r[0], r[1]) for r in rows]
  56. def count_affected(conn, table_name: str, column_name: str, old_prefix: str) -> int:
  57. sql = text(
  58. f"""
  59. SELECT COUNT(*) AS cnt
  60. FROM {quote_ident(table_name)}
  61. WHERE {quote_ident(column_name)} LIKE :pattern
  62. """
  63. )
  64. return int(conn.execute(sql, {"pattern": f"%{old_prefix}%"}).scalar() or 0)
  65. def update_column(
  66. conn,
  67. table_name: str,
  68. column_name: str,
  69. old_prefix: str,
  70. new_prefix: str,
  71. ) -> int:
  72. sql = text(
  73. f"""
  74. UPDATE {quote_ident(table_name)}
  75. SET {quote_ident(column_name)} = REPLACE({quote_ident(column_name)}, :old_prefix, :new_prefix)
  76. WHERE {quote_ident(column_name)} LIKE :pattern
  77. """
  78. )
  79. result = conn.execute(
  80. sql,
  81. {
  82. "old_prefix": old_prefix,
  83. "new_prefix": new_prefix,
  84. "pattern": f"%{old_prefix}%",
  85. },
  86. )
  87. return int(result.rowcount or 0)
  88. def make_backup_table_name(table_name: str, suffix: str) -> str:
  89. """生成备份表名,控制在 MySQL 64 字符限制内。"""
  90. max_table_len = 64
  91. reserve = len("_bak_") + len(suffix)
  92. max_base_len = max_table_len - reserve
  93. base = table_name[:max_base_len]
  94. return f"{base}_bak_{suffix}"
  95. def backup_table(conn, table_name: str, backup_table_name: str) -> None:
  96. """备份单张表(结构+数据)。"""
  97. conn.execute(
  98. text(
  99. f"CREATE TABLE {quote_ident(backup_table_name)} LIKE {quote_ident(table_name)}"
  100. )
  101. )
  102. conn.execute(
  103. text(
  104. f"INSERT INTO {quote_ident(backup_table_name)} SELECT * FROM {quote_ident(table_name)}"
  105. )
  106. )
  107. def main() -> None:
  108. args = parse_args()
  109. old_prefix = args.old_prefix.strip().rstrip("/")
  110. new_prefix = args.new_prefix.strip().rstrip("/")
  111. dry_run = args.dry_run
  112. if not old_prefix or not new_prefix:
  113. raise ValueError("old-prefix/new-prefix 不能为空")
  114. if old_prefix == new_prefix:
  115. raise ValueError("old-prefix 与 new-prefix 不能相同")
  116. print(f"数据库 URL 迁移开始,模式:{'预览(dry-run)' if dry_run else '正式执行'}")
  117. print(f"旧前缀: {old_prefix}")
  118. print(f"新前缀: {new_prefix}")
  119. total_rows = 0
  120. hit_columns = 0
  121. affected_items: list[tuple[str, str, int]] = []
  122. with engine.begin() as conn:
  123. columns = get_text_columns(conn)
  124. for table_name, column_name in columns:
  125. affected = count_affected(conn, table_name, column_name, old_prefix)
  126. if affected <= 0:
  127. continue
  128. affected_items.append((table_name, column_name, affected))
  129. if not affected_items:
  130. print("未发现需要替换的数据,已结束。")
  131. return
  132. if not dry_run:
  133. backup_suffix = datetime.now().strftime("%Y%m%d%H%M%S")
  134. backup_tables: dict[str, str] = {}
  135. for table_name, _, _ in affected_items:
  136. if table_name in backup_tables:
  137. continue
  138. backup_name = make_backup_table_name(table_name, backup_suffix)
  139. backup_table(conn, table_name, backup_name)
  140. backup_tables[table_name] = backup_name
  141. print(f"[BACKUP] {table_name} -> {backup_name}")
  142. print(f"备份完成:共备份 {len(backup_tables)} 张表。")
  143. for table_name, column_name, affected in affected_items:
  144. hit_columns += 1
  145. if dry_run:
  146. print(f"[DRY-RUN] {table_name}.{column_name} -> {affected} 行")
  147. total_rows += affected
  148. continue
  149. updated = update_column(conn, table_name, column_name, old_prefix, new_prefix)
  150. print(f"[UPDATED] {table_name}.{column_name} -> {updated} 行")
  151. total_rows += updated
  152. if dry_run:
  153. print(f"预览完成:共 {hit_columns} 个字段命中,预计影响 {total_rows} 行。")
  154. else:
  155. print(f"执行完成:共更新 {hit_columns} 个字段,影响 {total_rows} 行。")
  156. if __name__ == "__main__":
  157. main()