266 lines
8.8 KiB
Python
266 lines
8.8 KiB
Python
|
|
"""数据库自动初始化模块。
|
|||
|
|
|
|||
|
|
在应用启动时自动执行:
|
|||
|
|
1. 检查并创建数据库(如果不存在)
|
|||
|
|
2. 执行 Alembic 迁移,确保表结构最新
|
|||
|
|
3. 补全 ORM 模型中定义但数据库不存在的表
|
|||
|
|
4. 补全已有表中缺失的字段(只 ADD COLUMN,不 MODIFY/DELETE)
|
|||
|
|
5. 执行种子数据初始化
|
|||
|
|
|
|||
|
|
被调用方:backend.app.main 的 lifespan 函数。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from sqlalchemy import create_engine, text
|
|||
|
|
from sqlalchemy.exc import OperationalError
|
|||
|
|
|
|||
|
|
from backend.app.core.config import get_settings
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def auto_init_database() -> None:
|
|||
|
|
"""自动初始化数据库。
|
|||
|
|
|
|||
|
|
启动时调用,按顺序执行:
|
|||
|
|
1. 检查数据库是否存在,不存在则创建
|
|||
|
|
2. 执行 Alembic 迁移
|
|||
|
|
3. 检查并补全所有表(Alembic 迁移可能遗漏新增模型)
|
|||
|
|
4. 执行种子数据初始化
|
|||
|
|
"""
|
|||
|
|
settings = get_settings()
|
|||
|
|
|
|||
|
|
# 1. 确保数据库存在
|
|||
|
|
_ensure_database_exists(settings)
|
|||
|
|
|
|||
|
|
# 2. 执行 Alembic 迁移
|
|||
|
|
_run_alembic_migrations()
|
|||
|
|
|
|||
|
|
# 3. 补全检查:确保所有 ORM 模型对应的表都已创建
|
|||
|
|
_create_tables_directly()
|
|||
|
|
|
|||
|
|
# 4. 补全已有表的缺失字段
|
|||
|
|
_sync_columns()
|
|||
|
|
|
|||
|
|
# 5. 初始化种子数据
|
|||
|
|
_run_bootstrap_data()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _ensure_database_exists(settings) -> None:
|
|||
|
|
"""检查数据库是否存在,不存在则创建。"""
|
|||
|
|
# 先尝试连接目标数据库
|
|||
|
|
db_url = (
|
|||
|
|
f"mysql+pymysql://{settings.mysql_user}:{settings.mysql_password}"
|
|||
|
|
f"@{settings.mysql_host}:{settings.mysql_port}/{settings.mysql_database}"
|
|||
|
|
)
|
|||
|
|
engine = create_engine(db_url)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
with engine.connect() as conn:
|
|||
|
|
conn.execute(text("SELECT 1"))
|
|||
|
|
logger.info("数据库 '%s' 已存在,连接正常", settings.mysql_database)
|
|||
|
|
engine.dispose()
|
|||
|
|
return
|
|||
|
|
except OperationalError as e:
|
|||
|
|
# 如果是"未知数据库"错误,则创建数据库
|
|||
|
|
if "Unknown database" in str(e) or "1049" in str(e):
|
|||
|
|
logger.info("数据库 '%s' 不存在,正在创建...", settings.mysql_database)
|
|||
|
|
engine.dispose()
|
|||
|
|
_create_database(settings)
|
|||
|
|
else:
|
|||
|
|
logger.error("数据库连接失败: %s", e)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _create_database(settings) -> None:
|
|||
|
|
"""创建数据库。"""
|
|||
|
|
# 连接到 MySQL 服务器(不指定数据库)
|
|||
|
|
server_url = (
|
|||
|
|
f"mysql+pymysql://{settings.mysql_user}:{settings.mysql_password}"
|
|||
|
|
f"@{settings.mysql_host}:{settings.mysql_port}"
|
|||
|
|
)
|
|||
|
|
engine = create_engine(server_url)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
with engine.connect() as conn:
|
|||
|
|
conn.execute(text(
|
|||
|
|
f"CREATE DATABASE IF NOT EXISTS `{settings.mysql_database}` "
|
|||
|
|
f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
|||
|
|
))
|
|||
|
|
conn.commit()
|
|||
|
|
logger.info("数据库 '%s' 创建成功", settings.mysql_database)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error("创建数据库失败: %s", e)
|
|||
|
|
raise
|
|||
|
|
finally:
|
|||
|
|
engine.dispose()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_alembic_migrations() -> None:
|
|||
|
|
"""执行 Alembic 迁移,确保表结构最新。"""
|
|||
|
|
logger.info("正在执行数据库迁移...")
|
|||
|
|
|
|||
|
|
alembic_ini = PROJECT_ROOT / "backend" / "alembic.ini"
|
|||
|
|
if not alembic_ini.exists():
|
|||
|
|
logger.warning("未找到 alembic.ini,尝试使用 SQLAlchemy 直接创建表")
|
|||
|
|
_create_tables_directly()
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
|||
|
|
cwd=str(PROJECT_ROOT / "backend"),
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
timeout=60,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result.returncode == 0:
|
|||
|
|
logger.info("数据库迁移完成")
|
|||
|
|
if result.stdout.strip():
|
|||
|
|
logger.debug("迁移输出: %s", result.stdout.strip())
|
|||
|
|
else:
|
|||
|
|
logger.error("数据库迁移失败,尝试直接创建表: %s", result.stderr[:200])
|
|||
|
|
_create_tables_directly()
|
|||
|
|
except subprocess.TimeoutExpired:
|
|||
|
|
logger.error("数据库迁移超时,尝试直接创建表")
|
|||
|
|
_create_tables_directly()
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error("执行迁移异常,尝试直接创建表: %s", e)
|
|||
|
|
_create_tables_directly()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _create_tables_directly() -> None:
|
|||
|
|
"""检查并补全所有 ORM 模型对应的数据库表。
|
|||
|
|
|
|||
|
|
Base.metadata.create_all 默认 checkfirst=True,
|
|||
|
|
只创建不存在的表,已存在的表不会被修改或删除。
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from sqlalchemy import inspect
|
|||
|
|
|
|||
|
|
from backend.app.db import Base, engine
|
|||
|
|
# 显式导入所有模型类,确保每个 ORM 类都注册到 Base.metadata
|
|||
|
|
from backend.app.models import ( # noqa: F401
|
|||
|
|
AIRecognitionLog,
|
|||
|
|
AuditLog,
|
|||
|
|
Customer,
|
|||
|
|
CustomerArrears,
|
|||
|
|
FileAttachment,
|
|||
|
|
LogisticsTask,
|
|||
|
|
LogisticsTrace,
|
|||
|
|
LogisticsWaybill,
|
|||
|
|
Menu,
|
|||
|
|
OrderSupplierTextLog,
|
|||
|
|
PerformanceStatCache,
|
|||
|
|
Product,
|
|||
|
|
ProductCategory,
|
|||
|
|
ProductPriceTier,
|
|||
|
|
ProductPricingRule,
|
|||
|
|
Role,
|
|||
|
|
RoleMenu,
|
|||
|
|
SalesOrder,
|
|||
|
|
SalesOrderApproveLog,
|
|||
|
|
SalesOrderItem,
|
|||
|
|
Supplier,
|
|||
|
|
SupplierProductCost,
|
|||
|
|
SystemConfig,
|
|||
|
|
SystemReminder,
|
|||
|
|
User,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
orm_tables = set(Base.metadata.tables.keys())
|
|||
|
|
logger.info("ORM 已注册 %d 张表: %s", len(orm_tables), sorted(orm_tables))
|
|||
|
|
|
|||
|
|
# 记录创建前已存在的表
|
|||
|
|
inspector = inspect(engine)
|
|||
|
|
before = set(inspector.get_table_names())
|
|||
|
|
|
|||
|
|
# create_all: 已存在的跳过,不存在的创建
|
|||
|
|
Base.metadata.create_all(engine, checkfirst=True)
|
|||
|
|
|
|||
|
|
# 对比前后差异,确认结果
|
|||
|
|
after = set(inspect(engine).get_table_names())
|
|||
|
|
newly_created = (orm_tables - before) & after
|
|||
|
|
still_missing = orm_tables - after
|
|||
|
|
|
|||
|
|
if newly_created:
|
|||
|
|
logger.info("新创建 %d 张表: %s", len(newly_created), sorted(newly_created))
|
|||
|
|
if still_missing:
|
|||
|
|
logger.error("以下表仍然缺失,请检查: %s", sorted(still_missing))
|
|||
|
|
else:
|
|||
|
|
logger.info("所有 %d 张表均已就绪", len(orm_tables))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error("检查/创建表失败: %s", e)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _sync_columns() -> None:
|
|||
|
|
"""检查已有表是否缺少 ORM 模型中定义的字段,自动补全。
|
|||
|
|
|
|||
|
|
只做 ADD COLUMN,不做 MODIFY/DELETE,确保安全。
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from sqlalchemy import inspect, text
|
|||
|
|
|
|||
|
|
from backend.app.db import Base, engine
|
|||
|
|
|
|||
|
|
inspector = inspect(engine)
|
|||
|
|
existing_tables = set(inspector.get_table_names())
|
|||
|
|
added_count = 0
|
|||
|
|
|
|||
|
|
for table_name, table in Base.metadata.tables.items():
|
|||
|
|
if table_name not in existing_tables:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
db_columns = {col["name"] for col in inspector.get_columns(table_name)}
|
|||
|
|
|
|||
|
|
for col in table.columns:
|
|||
|
|
if col.name not in db_columns:
|
|||
|
|
# 生成 ALTER TABLE ADD COLUMN 语句
|
|||
|
|
col_type = col.type.compile(dialect=engine.dialect)
|
|||
|
|
nullable = "NULL" if col.nullable else "NOT NULL"
|
|||
|
|
default = ""
|
|||
|
|
if col.default is not None:
|
|||
|
|
default_val = col.default.arg
|
|||
|
|
if callable(default_val):
|
|||
|
|
# 函数默认值(如 now())跳过,让数据库处理
|
|||
|
|
default = ""
|
|||
|
|
elif isinstance(default_val, str):
|
|||
|
|
default = f"DEFAULT '{default_val}'"
|
|||
|
|
else:
|
|||
|
|
default = f"DEFAULT {default_val}"
|
|||
|
|
|
|||
|
|
sql = f"ALTER TABLE `{table_name}` ADD COLUMN `{col.name}` {col_type} {nullable} {default}"
|
|||
|
|
logger.info("补字段: %s.%s", table_name, col.name)
|
|||
|
|
|
|||
|
|
with engine.connect() as conn:
|
|||
|
|
conn.execute(text(sql))
|
|||
|
|
conn.commit()
|
|||
|
|
added_count += 1
|
|||
|
|
|
|||
|
|
if added_count:
|
|||
|
|
logger.info("共补全 %d 个缺失字段", added_count)
|
|||
|
|
else:
|
|||
|
|
logger.info("所有表字段均已同步")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error("同步表字段失败: %s", e)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_bootstrap_data() -> None:
|
|||
|
|
"""执行种子数据初始化。"""
|
|||
|
|
logger.info("正在初始化种子数据...")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from backend.scripts.bootstrap_data import main as bootstrap_main
|
|||
|
|
bootstrap_main()
|
|||
|
|
logger.info("种子数据初始化完成")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error("种子数据初始化失败: %s", e)
|
|||
|
|
# 种子数据失败不阻止启动
|