db_upgrade.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. """dms-server.exe upgrade-db:生产库 alembic 迁移升级工具。
  2. 从数据库当前 alembic 版本升级到 head(0015_login_ip_restriction)。
  3. 0014/0015 的 upgrade 仅执行 CREATE TABLE / CREATE INDEX / CHECK 约束超集替换,
  4. 不删除、不修改既有业务数据;幂等,可重复执行。
  5. """
  6. from __future__ import annotations
  7. import os
  8. import sys
  9. from pathlib import Path
  10. from typing import Optional
  11. def resolve_migrations_dir() -> Path:
  12. """返回 migrations 目录:打包环境取 _MEIPASS 内置副本,源码环境取 backend/migrations。"""
  13. if getattr(sys, "frozen", False):
  14. base = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
  15. return base / "migrations"
  16. return Path(__file__).resolve().parents[1] / "migrations"
  17. def _mask_password(url: str) -> str:
  18. """脱敏数据库 URL:scheme://user:***@host:port/db。"""
  19. if "@" not in url:
  20. return url
  21. head, _, tail = url.rpartition("@")
  22. if "://" in head:
  23. scheme, userinfo = head.split("://", 1)
  24. prefix = f"{scheme}://"
  25. else:
  26. scheme, userinfo = "", head
  27. prefix = ""
  28. user = userinfo.split(":", 1)[0]
  29. return f"{prefix}{user}:***@{tail}"
  30. def _read_current_version(database_url: str) -> Optional[str]:
  31. """读取 alembic_version.version_num;无该表视为全新库返回 None。
  32. 连接失败让异常冒出,由调用方以退出码 2 终止——绝不把连不上误判为全新库。
  33. """
  34. import sqlalchemy as sa
  35. engine = sa.create_engine(database_url)
  36. try:
  37. with engine.connect() as conn:
  38. if not sa.inspect(conn).has_table("alembic_version"):
  39. return None
  40. row = conn.execute(sa.text("SELECT version_num FROM alembic_version")).fetchone()
  41. return row[0] if row else None
  42. finally:
  43. engine.dispose()
  44. def run_upgrade(assume_yes: bool = False) -> int:
  45. """编程式执行 alembic upgrade head,返回进程退出码(0 成功)。"""
  46. database_url = os.environ.get("DMS_DATABASE_URL", "").strip()
  47. if not database_url:
  48. print("错误:未配置 DMS_DATABASE_URL(请检查 dms-server.exe 同目录的 .env 文件)。")
  49. print("为避免误连错误数据库,升级命令拒绝在未明确指定数据库地址时执行。")
  50. return 2
  51. # 延迟导入:缺 URL 检查必须先于一切 alembic 初始化执行
  52. from alembic import command
  53. from alembic.config import Config
  54. from alembic.script import ScriptDirectory
  55. migrations_dir = resolve_migrations_dir()
  56. if not (migrations_dir / "env.py").exists():
  57. print(f"错误:未找到迁移目录 {migrations_dir}。")
  58. return 2
  59. cfg = Config()
  60. cfg.set_main_option("script_location", str(migrations_dir))
  61. cfg.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
  62. script = ScriptDirectory.from_config(cfg)
  63. head = script.get_current_head()
  64. try:
  65. current = _read_current_version(database_url)
  66. except Exception as exc: # noqa: BLE001 - 任意连接类错误统一转为退出码
  67. print(f"错误:无法连接数据库({_mask_password(database_url)}):{exc}")
  68. return 2
  69. print(f"数据库地址:{_mask_password(database_url)}")
  70. print(f"当前版本:{current or '(全新库,无 alembic_version 表)'}")
  71. print(f"目标版本:{head}")
  72. if current == head:
  73. print("数据库已是最新版本,无需升级。")
  74. return 0
  75. if current is None:
  76. revisions = [r.revision for r in reversed(list(script.walk_revisions("base", head)))]
  77. else:
  78. # walk_revisions 含 base 端点,切片去掉已执行的 current
  79. revisions = [r.revision for r in reversed(list(script.walk_revisions(current, head)))][1:]
  80. print("待执行迁移:")
  81. for rev in revisions:
  82. print(f" - {rev}")
  83. print("以上迁移仅新增表/索引/约束,不会删除或修改既有业务数据。")
  84. if not assume_yes:
  85. answer = input("确认执行升级?(y/N):").strip().lower()
  86. if answer not in ("y", "yes"):
  87. print("已取消。")
  88. return 1
  89. command.upgrade(cfg, "head")
  90. after = _read_current_version(database_url)
  91. print(f"升级完成,当前版本:{after}")
  92. return 0 if after == head else 2