191 lines
6.2 KiB
Python
191 lines
6.2 KiB
Python
"""迁移健康检查(独立脚本,需在真实库上运行)。
|
||
|
||
为什么不是 pytest 用例:pytest-django 的测试库按**模型**建表并绕过迁移状态,
|
||
`MigrationAutodetector` 在 pytest 进程里返回空——"模型改了没生成迁移"这类问题
|
||
在单元测试里**测不出来**(实测过,会假通过)。
|
||
|
||
本脚本用真实 settings 跑,等价于 `makemigrations --check` + `migrate --check`,
|
||
但额外做几件 pytest 做不到的事:
|
||
1. 检查模型字段与**真实库**列是否一致(能抓到"迁移生成了但没 migrate")
|
||
2. 检查新 app 的表是否真存在
|
||
3. 退出码非 0,可直接接 CI / 部署前钩子
|
||
|
||
用法:
|
||
python scripts/check_migrations.py # 用 DJANGO_SETTINGS_MODULE(默认 dev)
|
||
DJANGO_SETTINGS_MODULE=config.settings.prod python scripts/check_migrations.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# 让脚本能 import 到项目(backend/ 为根)
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
|
||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
|
||
|
||
import django # noqa: E402
|
||
|
||
django.setup()
|
||
|
||
from django.apps import apps as django_apps # noqa: E402
|
||
from django.core.management import call_command # noqa: E402
|
||
from django.db import connection # noqa: E402
|
||
from io import StringIO # noqa: E402
|
||
|
||
|
||
OWN_APPS = [
|
||
"core", "catalog", "partner", "inventory", "finance",
|
||
"purchase", "sales", "report", "channel", "notify",
|
||
"openapi", "printing", "ai", "billing", "website", "storefront",
|
||
]
|
||
|
||
RED = "\033[31m"
|
||
GREEN = "\033[32m"
|
||
YELLOW = "\033[33m"
|
||
RESET = "\033[0m"
|
||
|
||
|
||
def ok(msg: str):
|
||
print(f" {GREEN}✓{RESET} {msg}")
|
||
|
||
|
||
def bad(msg: str):
|
||
print(f" {RED}✗{RESET} {msg}")
|
||
|
||
|
||
def warn(msg: str):
|
||
print(f" {YELLOW}!{RESET} {msg}")
|
||
|
||
|
||
def check_pending_migrations() -> int:
|
||
"""1. 模型改了但没 makemigrations。"""
|
||
print("\n[1] 待生成的迁移(makemigrations --check)")
|
||
out = StringIO()
|
||
try:
|
||
call_command("makemigrations", "--check", "--dry-run",
|
||
stdout=out, stderr=out, verbosity=1)
|
||
ok("无待生成迁移")
|
||
return 0
|
||
except SystemExit as exc:
|
||
if exc.code == 0:
|
||
ok("无待生成迁移")
|
||
return 0
|
||
text = out.getvalue().strip()
|
||
bad("存在未生成的迁移:")
|
||
for line in text.splitlines():
|
||
print(" " + line)
|
||
print(f"\n 修复:python manage.py makemigrations")
|
||
return 1
|
||
|
||
|
||
def check_unapplied_migrations() -> int:
|
||
"""2. 有迁移文件但没执行 migrate。"""
|
||
print("\n[2] 未应用的迁移(migrate --check)")
|
||
out = StringIO()
|
||
try:
|
||
call_command("migrate", "--check", stdout=out, stderr=out, verbosity=0)
|
||
ok("所有迁移已应用")
|
||
return 0
|
||
except SystemExit as exc:
|
||
if exc.code == 0:
|
||
ok("所有迁移已应用")
|
||
return 0
|
||
text = out.getvalue().strip()
|
||
bad("存在未应用的迁移:")
|
||
for line in text.splitlines()[:20]:
|
||
print(" " + line)
|
||
print(f"\n 修复:python manage.py migrate")
|
||
return 1
|
||
|
||
|
||
def check_model_columns() -> int:
|
||
"""3. 模型字段 vs 真实库列(能抓到 schema 漂移)。"""
|
||
print("\n[3] 模型字段与数据库列一致性")
|
||
failures = []
|
||
checked = 0
|
||
# 引擎无关:用 Django 的 introspection API(SQLite/PG/MySQL 都支持)
|
||
with connection.cursor() as cur:
|
||
for app_label in OWN_APPS:
|
||
try:
|
||
config = django_apps.get_app_config(app_label)
|
||
except LookupError:
|
||
continue
|
||
for model in config.get_models():
|
||
table = model._meta.db_table
|
||
try:
|
||
desc = connection.introspection.get_table_description(cur, table)
|
||
except Exception:
|
||
failures.append(f"{app_label}.{model.__name__}: 表 {table} 不存在")
|
||
continue
|
||
cols = {c.name for c in desc}
|
||
checked += 1
|
||
for field in model._meta.concrete_fields:
|
||
if field.column not in cols:
|
||
failures.append(
|
||
f"{app_label}.{model.__name__}: 缺列 {field.column}"
|
||
)
|
||
if failures:
|
||
bad(f"{len(failures)} 处不一致:")
|
||
for f in failures[:30]:
|
||
print(" " + f)
|
||
print("\n 修复:python manage.py makemigrations && python manage.py migrate")
|
||
return 1
|
||
ok(f"{checked} 个模型的字段与数据库一致")
|
||
return 0
|
||
|
||
|
||
def check_new_app_tables() -> int:
|
||
"""4. 关键新表存在性(新 app 最容易漏迁移)。"""
|
||
print("\n[4] 关键表存在性")
|
||
required = [
|
||
("ai", "AiUsage"),
|
||
("billing", "Plan"),
|
||
("billing", "Subscription"),
|
||
("storefront", "StorefrontAccount"),
|
||
("storefront", "CustomerProductAuth"),
|
||
("storefront", "StorefrontOrder"),
|
||
("storefront", "StorefrontOrderLine"),
|
||
]
|
||
missing = []
|
||
for app_label, model_name in required:
|
||
try:
|
||
model = django_apps.get_model(app_label, model_name)
|
||
except LookupError:
|
||
missing.append(f"{app_label}.{model_name} 模型未注册")
|
||
continue
|
||
try:
|
||
model.objects.exists()
|
||
except Exception as exc:
|
||
missing.append(f"{app_label}.{model_name}: {type(exc).__name__}")
|
||
if missing:
|
||
bad("以下关键表不可用:")
|
||
for m in missing:
|
||
print(" " + m)
|
||
return 1
|
||
ok(f"{len(required)} 张关键表可查")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
db = connection.settings_dict
|
||
print(f"迁移健康检查 · 库={db.get('NAME')} · 引擎={db.get('ENGINE')}")
|
||
failures = 0
|
||
failures += check_pending_migrations()
|
||
failures += check_unapplied_migrations()
|
||
failures += check_model_columns()
|
||
failures += check_new_app_tables()
|
||
|
||
print()
|
||
if failures:
|
||
print(f"{RED}检查未通过:{failures} 项问题{RESET}")
|
||
return 1
|
||
print(f"{GREEN}全部通过{RESET}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|