diff --git a/src/shared/database/init_db.py b/src/shared/database/init_db.py index 9bd5126..c0c9a4c 100644 --- a/src/shared/database/init_db.py +++ b/src/shared/database/init_db.py @@ -184,6 +184,51 @@ async def create_admin_user(session): logger.info(f"创建了管理员账户: {settings.ADMIN_USERNAME}") +async def _verify_schema_coverage() -> None: + """启动期 schema 校验:模型声明的表/列必须真实存在于 DB。 + + 背景(2026-09-26 事故):AUTO_MIGRATE 启动迁移曾静默失败,prod 库缺 + stp_files.product_id 等列,上传全挂两天无启动日志线索。此校验保证 + 『迁移被跳过 / stamp 基线掩盖未应用增量』这类漂移在启动时即被点名。 + + 只查缺、不查多:DB 里的遗留列/表(如 users.is_superuser)是历史产物,不管。 + 发现缺失只记 error 不抛——服务照常起,但 docker logs 必有醒目线索。 + """ + import moldinsight.models # noqa: F401 + import inventory.models # noqa: F401 + from shared.models.base import Base + + async with db_manager.engine.connect() as conn: + rows = (await conn.execute(text( + "SELECT table_name, column_name FROM information_schema.columns " + "WHERE table_schema='public'" + ))).all() + db_cols: dict = {} + for tn, cn in rows: + db_cols.setdefault(tn, set()).add(cn) + + missing = [] + for tname, table in Base.metadata.tables.items(): + present = db_cols.get(tname) + if present is None: + missing.append(f"整表缺失: {tname}") + continue + absent = {c.name for c in table.columns} - present + if absent: + missing.append(f"{tname} 缺列: {sorted(absent)}") + + if missing: + logger.error( + "schema 校验失败:模型声明了 %d 处 DB 缺失(迁移链与实际 schema 不一致," + "请人工执行 alembic upgrade head 或核对基线):%s", + len(missing), "; ".join(missing), + ) + else: + logger.info( + "schema 校验通过:模型 %d 张表的全部列均存在于 DB", len(Base.metadata.tables) + ) + + async def init_database(keep_connected: bool = True): """初始化数据库""" try: @@ -195,6 +240,12 @@ async def init_database(keep_connected: bool = True): "AUTO_MIGRATE=false:跳过启动期 alembic 迁移," "schema 由部署流程单点执行(alembic CLI 或 python -m shared.database.init_db)" ) + + # 迁移后校验(只读、不抛):迁移链与实际 schema 脱节时在启动日志直接点名 + try: + await _verify_schema_coverage() + except Exception: + logger.exception("schema 校验自身异常(不影响启动流程)") async with db_manager.session() as session: perm_map = await init_permissions(session) @@ -223,7 +274,9 @@ async def init_database(keep_connected: bool = True): return True except Exception as e: - logger.error(f"数据库初始化失败: {e}") + # logger.exception 而非 error(f"{e}"):吞掉 traceback 曾让启动迁移静默 + # 失败两天无从排查(2026-09-26 事故) + logger.exception(f"数据库初始化失败: {e}") print(f"数据库初始化失败: {e}") return False finally: