Files
dpb/backend/app/api/v1/module_generator/gencode/crud.py
T
34047007@qq.com 86126ea16f checkpoint: 架构治理四件套——统一 async / 测试真 PG / Redis 降级可选 / DPB 扶正
- A1 业务层统一 async:gencode 表内省改 run_sync 异步(不再阻塞事件循环)、
  check_db 改 async_engine、dict_util 启动预热异步载入;psycopg 降为
  APScheduler SQLAlchemyJobStore 专用同步孤岛(注释标注)
- A2 测试真 PG 化:conftest 弃 SQLite + mock Redis,改连真实 PG16(dpb_test)+
  Redis(number_gen advisory lock 路径真被测);新增 docker/test-compose.yaml
  测试依赖栈(postgres:16:5433 + redis:7:6380);run_ci 前置探测 + 容器兜底,
  pytest -m pg 10 套正式 tc 全过(真实 PG16 + Redis)
- B1 Redis 启动不强依赖:redis_connect 失败降级启动,缓存类回源 DB、存储类
  (会话/AI 配置/调度)经 require_redis 守卫返回 503;真机 3 阶段降级测试
  PASS=11 FAIL=0(独立 Redis 6390 + 后端 8091,不动共享 dev 服务)
- C 扶正 DPB:后端横幅/日志(dpb.log)/README/pyproject/ai_factory agent 名、
  前端 package.json/署名注释/链接文案/deploy.sh/docker 注释全部去 fastapiadmin;
  grep backend/app + frontend/web/src 零残留
- 验证:全量回归 pass=72 fail=0
2026-08-07 09:06:19 +08:00

606 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from collections.abc import Sequence
from typing import TYPE_CHECKING
from sqlalchemy import inspect, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config.setting import settings
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from app.core.database import async_engine
from app.core.logger import logger
from app.utils.common_util import search_to_dict
from .model import GenTableColumnModel, GenTableModel
from .schema import (
GenDBTableSchema,
GenTableColumnOutSchema,
GenTableColumnSchema,
GenTableQueryParam,
GenTableSchema,
)
if TYPE_CHECKING:
from sqlalchemy.engine.reflection import Inspector
async def _run_inspector(func, *args):
"""在异步引擎连接上以 run_sync 执行同步 Inspector 操作,避免阻塞事件循环。"""
async with async_engine.connect() as conn:
return await conn.run_sync(func, *args)
class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
"""代码生成业务表模块数据库操作层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
"""初始化CRUD操作层
参数:
- auth (AuthSchema): 认证信息模型
- db (AsyncSession): 数据库会话
"""
super().__init__(model=GenTableModel, auth=auth, db=db)
async def get_gen_table_by_id(self, table_id: int, preload: list | None = None) -> GenTableModel | None:
"""根据业务表ID获取需要生成的业务表信息。
参数:
- table_id (int): 业务表ID。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- GenTableModel | None: 业务表信息对象。
"""
return await self.get(id=table_id, preload=preload)
async def get_gen_table_by_name(self, table_name: str, preload: list | None = None) -> GenTableModel | None:
"""根据业务表名称获取需要生成的业务表信息。
参数:
- table_name (str): 业务表名称。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- GenTableModel | None: 业务表信息对象。
"""
return await self.get(table_name=table_name, preload=preload)
async def get_gen_table_all(self, preload: list | None = None) -> Sequence[GenTableModel]:
"""获取所有业务表信息。
参数:
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[GenTableModel]: 所有业务表信息列表。
"""
return await self.get_list(preload=preload)
async def get_gen_table_list(
self,
search: GenTableQueryParam | None = None,
preload: list | None = None,
) -> Sequence[GenTableModel]:
"""根据查询参数获取代码生成业务表列表信息。
参数:
- search (GenTableQueryParam | None): 查询参数对象。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[GenTableModel]: 业务表列表信息。
"""
return await self.get_list(
search=search_to_dict(search, {}),
order_by=[{"created_time": "desc"}],
preload=preload,
)
async def add_gen_table(self, add_model: GenTableSchema) -> GenTableModel:
"""新增业务表信息。
参数:
- add_model (GenTableSchema): 新增业务表信息模型。
返回:
- GenTableModel: 新增的业务表信息对象。
"""
return await self.create(data=add_model)
async def edit_gen_table(self, table_id: int, edit_model: GenTableSchema) -> GenTableModel:
"""修改业务表信息。
参数:
- table_id (int): 业务表ID。
- edit_model (GenTableSchema): 修改业务表信息模型。
返回:
- GenTableSchema: 修改后的业务表信息模型。
"""
# 排除嵌套对象字段,避免SQLAlchemy尝试直接将字典设置到模型实例上
return await self.update(
id=table_id,
data=GenTableSchema(**edit_model.model_dump(exclude_unset=True, exclude={"columns"})),
)
async def delete_gen_table(self, ids: list[int]) -> None:
"""删除业务表信息。除了系统表。
参数:
- ids (list[int]): 业务表ID列表。
返回:
- None
"""
await self.delete(ids=ids)
async def get_db_table_list(self, search: GenTableQueryParam | None = None) -> list[dict]:
"""根据查询参数获取数据库表列表信息。
参数:
- search (GenTableQueryParam | None): 查询参数对象。
返回:
- list[dict]: 数据库表列表信息(已转为可序列化字典)。
"""
database_name = settings.DATABASE_NAME
database_type = settings.DATABASE_TYPE
def _introspect(sync_conn) -> list[tuple[str, str]]:
insp = inspect(sync_conn)
out = []
for table_name in insp.get_table_names():
try:
table_comment = insp.get_table_comment(table_name)
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment
out.append((table_name, comment or ""))
except Exception as e:
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
out.append((table_name, ""))
return out
dict_data = []
for table_name, table_comment in await _run_inspector(_introspect):
# 统一处理 search 为 None 的情况,避免重复判断
if search:
# 表名过滤:忽略大小写,支持模糊匹配
if search.table_name and search.table_name[1] and search.table_name[1].lower() not in table_name.lower():
continue
# 表注释过滤:忽略大小写,支持模糊匹配;table_comment 为 None 时视为空字符串
if search.table_comment and search.table_comment[1] and search.table_comment[1] not in table_comment:
continue
table_info = {
"database_name": database_name,
"table_name": table_name,
"table_type": database_type,
"table_comment": table_comment,
}
dict_data.append(GenDBTableSchema(**table_info).model_dump())
return dict_data
async def get_db_table_page(
self,
search: GenTableQueryParam | None,
offset: int,
limit: int,
) -> tuple[list[dict], int]:
"""数据库侧分页获取物理表列表(用于导入表弹窗)。
说明:
- 旧实现使用 SQLAlchemy Inspector 全量遍历再内存分页,表多时非常慢。
- 这里按方言走系统表(MySQL information_schema / Postgres pg_catalog)进行分页与过滤。
- 若方言不支持,则回退到旧的全量遍历。
参数:
- search (GenTableQueryParam | None): 表名/注释过滤条件。
- offset (int): 偏移量。
- limit (int): 每页条数。
返回:
- tuple[list[dict], int]: 当前页表信息列表与总条数。
"""
database_name = settings.DATABASE_NAME
db_type = (settings.DATABASE_TYPE or "").lower()
# 解析 like 关键字(GenTableQueryParam 把字段包装成 ("like", value)
name_kw = None
comment_kw = None
if search:
try:
if search.table_name and search.table_name[1]:
name_kw = str(search.table_name[1]).strip()
if search.table_comment and search.table_comment[1]:
comment_kw = str(search.table_comment[1]).strip()
except Exception:
# 兜底:参数结构异常时忽略过滤
name_kw = None
comment_kw = None
# MySQL / MariaDB
if db_type in {"mysql", "mariadb"}:
where_sql = "WHERE table_schema = :db AND table_type = 'BASE TABLE'"
params: dict = {"db": database_name, "offset": offset, "limit": limit}
if name_kw:
where_sql += " AND table_name LIKE :name_kw"
params["name_kw"] = f"%{name_kw}%"
if comment_kw:
where_sql += " AND table_comment LIKE :comment_kw"
params["comment_kw"] = f"%{comment_kw}%"
count_sql = text(f"SELECT COUNT(1) AS cnt FROM information_schema.tables {where_sql}")
rows_sql = text(f"SELECT table_name, table_comment FROM information_schema.tables {where_sql} ORDER BY table_name ASC LIMIT :limit OFFSET :offset")
total_res = await self.db.execute(count_sql, params)
total = int(total_res.scalar() or 0)
res = await self.db.execute(rows_sql, params)
items: list[dict] = []
for r in res.fetchall():
# r may be Row/tuple depending on driver
table_name = r[0]
table_comment = r[1] or ""
items.append(
GenDBTableSchema(
database_name=database_name,
table_name=table_name,
table_type=settings.DATABASE_TYPE,
table_comment=table_comment,
).model_dump(),
)
return items, total
# PostgreSQL
if db_type in {"postgresql", "postgres"}:
# pg_description 需要通过 objsubid=0 获取 table comment
where_sql = "WHERE n.nspname NOT IN ('pg_catalog','information_schema') AND c.relkind = 'r'"
params = {"offset": offset, "limit": limit}
if name_kw:
where_sql += " AND c.relname ILIKE :name_kw"
params["name_kw"] = f"%{name_kw}%"
if comment_kw:
where_sql += " AND COALESCE(d.description,'') ILIKE :comment_kw"
params["comment_kw"] = f"%{comment_kw}%"
base_from = "FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_catalog.pg_description d ON d.objoid = c.oid AND d.objsubid = 0 "
count_sql = text(f"SELECT COUNT(1) AS cnt {base_from} {where_sql}")
rows_sql = text(f"SELECT c.relname AS table_name, COALESCE(d.description,'') AS table_comment {base_from} {where_sql} ORDER BY c.relname ASC LIMIT :limit OFFSET :offset")
total_res = await self.db.execute(count_sql, params)
total = int(total_res.scalar() or 0)
res = await self.db.execute(rows_sql, params)
items = []
for r in res.fetchall():
table_name = r[0]
table_comment = r[1] or ""
items.append(
GenDBTableSchema(
database_name=database_name,
table_name=table_name,
table_type=settings.DATABASE_TYPE,
table_comment=table_comment,
).model_dump(),
)
return items, total
# Fallback:回退旧逻辑(全量遍历再分页由上层处理)
all_items = await self.get_db_table_list(search)
total = len(all_items)
return all_items[offset : offset + limit], total
async def get_db_table_list_by_names(self, table_names: list[str]) -> list[GenDBTableSchema]:
"""根据业务表名称列表获取数据库表信息。
参数:
- table_names (list[str]): 业务表名称列表。
返回:
- list[GenDBTableSchema]: 数据库表信息对象列表。
"""
if not table_names:
return []
database_name = settings.DATABASE_NAME
database_type = settings.DATABASE_TYPE
def _introspect(sync_conn, names: list[str]) -> list[tuple[str, str]]:
insp = inspect(sync_conn)
all_table_names = set(insp.get_table_names())
out = []
for table_name in names:
if table_name not in all_table_names:
continue
try:
table_comment = insp.get_table_comment(table_name)
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
except Exception as e:
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
comment = ""
out.append((table_name, comment or ""))
return out
results = []
for table_name, comment in await _run_inspector(_introspect, table_names):
results.append(
GenDBTableSchema(
database_name=database_name,
table_name=table_name,
table_type=database_type,
table_comment=comment,
),
)
return results
async def check_table_exists(self, table_name: str) -> bool:
"""检查数据库中是否已存在指定表名的表。
参数:
- table_name (str): 要检查的表名。
返回:
- bool: 如果表存在返回True,否则返回False。
"""
return await _run_inspector(lambda sync_conn: inspect(sync_conn).has_table(table_name))
async def get_db_table_comment(self, table_name: str) -> str:
"""获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。
参数:
- table_name (str): 物理表名。
返回:
- str: 表注释;表不存在或失败时为空字符串。
"""
def _get_comment(sync_conn) -> str:
insp = inspect(sync_conn)
if not insp.has_table(table_name):
return ""
try:
table_comment = insp.get_table_comment(table_name)
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
return comment or ""
except Exception as e:
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
return ""
return await _run_inspector(_get_comment)
async def execute_sql(self, sql: str) -> bool:
"""执行SQL语句。
参数:
- sql (str): 要执行的SQL语句。
返回:
- bool: 是否执行成功。
"""
try:
# 执行SQL但不手动提交事务,由框架管理事务生命周期
await self.db.execute(text(sql))
return True
except Exception as e:
logger.error(f"执行SQL时发生错误: {e}")
return False
class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, GenTableColumnSchema]):
"""代码生成业务表字段模块数据库操作层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
"""初始化CRUD操作层
参数:
- auth (AuthSchema): 认证信息模型
- db (AsyncSession): 数据库会话
"""
super().__init__(model=GenTableColumnModel, auth=auth, db=db)
@staticmethod
async def _get_table_columns(database_type: str, table_name: str) -> list[dict]:
"""在异步引擎连接上内省数据库表的列信息(不阻塞事件循环)。
参数:
- database_type: 数据库类型
- table_name: 表名
返回:
- list: 列信息列表
"""
def _introspect(sync_conn) -> list[dict]:
# 使用SQLAlchemy Inspector获取表列信息
inspector = inspect(sync_conn)
# 获取列信息
columns = inspector.get_columns(table_name)
# 获取主键信息
try:
pk_constraint = inspector.get_pk_constraint(table_name)
primary_keys = set(pk_constraint.get("constrained_columns", [])) if pk_constraint else set()
except Exception:
primary_keys = set()
# 获取唯一约束信息
unique_columns = set()
unique_constraints = inspector.get_unique_constraints(table_name)
for constraint in unique_constraints:
unique_columns.update(constraint.get("column_names", []))
# 处理列信息
columns_list = []
for idx, column in enumerate(columns):
# 获取列的基本信息
column_name = column["name"]
column_type = str(column["type"])
is_nullable = column.get("nullable", True)
column_default = column.get("default", None)
# 获取列注释(如果有的话)
column_comment = column.get("comment", "")
# 判断是否为主键
is_pk = column_name in primary_keys
# 判断是否为唯一约束
is_unique = column_name in unique_columns
# 判断是否为自增列(基于数据库类型和列类型)
is_increment = column.get("autoincrement", False) in (True, "auto")
# 获取列长度(如果适用)
col_len = getattr(column["type"], "length", None)
column_length = str(col_len) if col_len is not None else ""
# 构造列信息字典
column_info = {
"column_name": column_name,
"column_comment": column_comment or "",
"column_type": column_type,
"column_length": column_length or "",
"column_default": str(column_default) if column_default is not None else "",
"sort": idx + 1, # 序号从1开始
"is_pk": bool(is_pk),
"is_increment": bool(is_increment),
"is_nullable": bool(is_nullable),
"is_unique": bool(is_unique),
}
columns_list.append(column_info)
return columns_list
return await _run_inspector(_introspect)
async def get_gen_table_column_by_id(self, id: int, preload: list | None = None) -> GenTableColumnModel | None:
"""根据业务表字段ID获取业务表字段信息。
参数:
- id (int): 业务表字段ID。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- GenTableColumnModel | None: 业务表字段信息对象。
"""
return await self.get(id=id, preload=preload)
async def get_gen_table_column_list_by_table_id(self, table_id: int, preload: list | None = None) -> GenTableColumnModel | None:
"""根据业务表ID获取业务表字段列表信息。
参数:
- table_id (int): 业务表ID。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- GenTableColumnModel | None: 业务表字段列表信息对象。
"""
return await self.get(table_id=table_id, preload=preload)
async def list_gen_table_column_crud_by_table_id(
self,
table_id: int,
order_by: list | None = None,
preload: list | None = None,
) -> Sequence[GenTableColumnModel]:
"""根据业务表ID查询业务表字段列表。
参数:
- table_id (int): 业务表ID。
- order_by (list | None): 排序字段列表,每个元素为{"field": "字段名", "order": "asc" | "desc"}。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[GenTableColumnModel]: 业务表字段列表信息对象序列。
"""
return await self.get_list(search={"table_id": table_id}, order_by=order_by, preload=preload)
async def get_gen_db_table_columns_by_name(self, table_name: str | None) -> list[GenTableColumnOutSchema]:
"""根据业务表名称获取业务表字段列表信息。
参数:
- table_name (str | None): 业务表名称。
返回:
- list[GenTableColumnOutSchema]: 业务表字段列表信息对象。
"""
# 检查表名是否为空
if not table_name:
raise ValueError("数据表名称不能为空")
try:
columns_info = await GenTableColumnCRUD._get_table_columns(settings.DATABASE_TYPE, table_name)
# 转换为GenTableColumnOutSchema对象列表
columns_list = [GenTableColumnOutSchema(**column_info) for column_info in columns_info]
return columns_list
except Exception as e:
logger.error(f"获取表{table_name}的字段列表时出错: {e!s}")
# 确保即使出错也返回空列表而不是None
raise
async def list_gen_table_column_crud(
self,
search: dict | None = None,
order_by: list | None = None,
preload: list | None = None,
) -> Sequence[GenTableColumnModel]:
"""根据业务表字段查询业务表字段列表。
参数:
- search (dict | None): 查询参数,例如{"table_id": 1}。
- order_by (list | None): 排序字段列表,每个元素为{"field": "字段名", "order": "asc" | "desc"}。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[GenTableColumnModel]: 业务表字段列表信息对象序列。
"""
return await self.get_list(search=search, order_by=order_by, preload=preload)
async def create_gen_table_column_crud(self, data: GenTableColumnSchema) -> GenTableColumnModel | None:
"""创建业务表字段。
参数:
- data (GenTableColumnSchema): 业务表字段模型。
返回:
- GenTableColumnModel | None: 业务表字段列表信息对象。
"""
return await self.create(data=data)
async def update_gen_table_column_crud(self, id: int, data: GenTableColumnSchema) -> GenTableColumnModel | None:
"""更新业务表字段。
参数:
- id (int): 业务表字段ID。
- data (GenTableColumnSchema): 业务表字段模型。
返回:
- GenTableColumnModel | None: 业务表字段列表信息对象。
"""
return await self.update(id=id, data=data)
async def delete_gen_table_column_by_table_id_crud(self, table_ids: list[int]) -> None:
"""根据业务表ID批量删除业务表字段。
参数:
- table_ids (list[int]): 业务表ID列表。
返回:
- None
"""
# 先查询出这些表ID对应的所有字段ID
query = select(GenTableColumnModel.id).where(GenTableColumnModel.table_id.in_(table_ids))
result = await self.db.execute(query)
column_ids = [row[0] for row in result.fetchall()]
# 如果有字段ID,则删除这些字段
if column_ids:
await self.delete(ids=column_ids)
async def delete_gen_table_column_by_column_id_crud(self, column_ids: list[int]) -> None:
"""根据业务表字段ID批量删除业务表字段。
参数:
- column_ids (list[int]): 业务表字段ID列表。
返回:
- None
"""
return await self.delete(ids=column_ids)