init: 初始化 dpb 桃育种系统代码库

前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
34047007@qq.com
2026-08-06 00:17:49 +08:00
commit b95053c52c
1469 changed files with 322298 additions and 0 deletions
@@ -0,0 +1 @@
@@ -0,0 +1,162 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
from app.core.dependencies import AuthPermission, db_getter
from app.core.logger import logger
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from .schema import GenCreateTableSqlBody, GenDBTableSchema, GenSyncPreviewSchema, GenTableOutSchema, GenTableQueryParam, GenTableSchema
from .service import GenTableService
GenRouter = APIRouter(route_class=OperationLogRoute, prefix="/gencode", tags=["代码生成"])
@GenRouter.get("/list", summary="查询代码生成业务表列表", response_model=ResponseSchema[list[GenTableOutSchema]])
async def gen_table_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:query"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[GenTableQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
order_by = [{"created_time": "desc"}]
if page.order_by:
order_by = page.order_by
result_dict = await GenTableService(auth, db).get_gen_table_page(
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=order_by,
)
return SuccessResponse(data=result_dict, msg="获取代码生成业务表列表成功")
@GenRouter.get("/db/list", summary="查询数据库表列表", response_model=ResponseSchema[PageResultSchema[GenDBTableSchema]])
async def get_gen_db_table_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:dblist:query"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[GenTableQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result_dict = await GenTableService(auth, db).get_gen_db_table_page(
page_no=page.page_no,
page_size=page.page_size,
search=search,
)
return SuccessResponse(data=result_dict, msg="获取数据库表列表成功")
@GenRouter.post("/import", summary="导入表结构", response_model=ResponseSchema[bool])
async def import_gen_table_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:import"]))],
table_names: Annotated[list[str], Body(description="表名列表")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
svc = GenTableService(auth, db)
add_gen_table_list = await svc.get_gen_db_table_list_by_name(table_names)
result = await svc.import_gen_table(add_gen_table_list)
return SuccessResponse(msg="导入表结构成功", data=result)
@GenRouter.get("/detail/{table_id}", summary="获取业务表详细信息", response_model=ResponseSchema[GenTableOutSchema])
async def gen_table_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:query"]))],
table_id: Annotated[int, Path(description="业务表ID")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await GenTableService(auth, db).get_gen_table_detail(table_id)
return SuccessResponse(data=result, msg="获取业务表详细信息成功")
@GenRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建表结构", response_model=ResponseSchema[bool])
async def create_table_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:create"]))],
body: Annotated[GenCreateTableSqlBody, Body(description="创建表结构参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await GenTableService(auth, db).create_table(body.sql)
return SuccessResponse(msg="创建表结构成功", data=result)
@GenRouter.put("/update/{table_id}", summary="编辑业务表信息", response_model=ResponseSchema[GenTableOutSchema])
async def update_gen_table_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:update"]))],
table_id: Annotated[int, Path(description="业务表ID")],
data: Annotated[GenTableSchema, Body(description="业务表信息")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result_dict = await GenTableService(auth, db).update_gen_table(data, table_id)
return SuccessResponse(data=result_dict, msg="编辑业务表信息成功")
@GenRouter.delete("/delete", summary="删除业务表信息", response_model=ResponseSchema[None])
async def delete_gen_table_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:delete"]))],
ids: Annotated[list[int], Body(description="业务表ID列表")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await GenTableService(auth, db).delete_gen_table(ids)
return SuccessResponse(msg="删除业务表信息成功", data=result)
@GenRouter.patch("/batch/output", summary="批量生成代码")
async def batch_gen_code_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:operate"]))],
table_names: Annotated[list[str], Body(description="表名列表")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> StreamResponse:
batch_gen_code_result, failed_tables = await GenTableService(auth, db).batch_gen_code(table_names)
headers = {"Content-Disposition": "attachment; filename=code.zip"}
if failed_tables:
logger.warning(f"批量生成代码部分失败,跳过表: {failed_tables}")
headers["X-Skipped-Tables"] = ",".join(failed_tables)
return StreamResponse(
data=bytes2file_response(batch_gen_code_result),
media_type="application/zip",
headers=headers,
)
@GenRouter.post("/output/{table_name}", summary="生成代码到指定路径", response_model=ResponseSchema[bool])
async def gen_code_local_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:code"]))],
table_name: Annotated[str, Path(description="表名")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await GenTableService(auth, db).generate_code(table_name)
return SuccessResponse(msg="生成代码到指定路径成功", data=result)
@GenRouter.get("/preview/{table_id}", summary="预览代码", response_model=ResponseSchema[GenTableOutSchema])
async def preview_code_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:query"]))],
table_id: Annotated[int, Path(description="业务表ID")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await GenTableService(auth, db).preview_code(table_id)
return SuccessResponse(data=result, msg="预览代码成功")
@GenRouter.post("/sync_db/{table_name}", summary="同步数据库", response_model=ResponseSchema[None])
async def sync_db_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:db:sync"]))],
table_name: Annotated[str, Path(description="表名")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await GenTableService(auth, db).sync_db(table_name)
return SuccessResponse(msg="同步数据库成功", data=result)
@GenRouter.get("/sync_db/preview/{table_name}", summary="同步数据库差异预览", response_model=ResponseSchema[GenSyncPreviewSchema])
async def sync_db_preview_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:db:sync"]))],
table_name: Annotated[str, Path(description="表名")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await GenTableService(auth, db).sync_db_preview(table_name)
return SuccessResponse(msg="获取同步差异预览成功", data=result)
@@ -0,0 +1,594 @@
import asyncio
from collections.abc import Sequence
from typing import TYPE_CHECKING
from sqlalchemy import Inspector, 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 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
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
inspector: Inspector = inspect(engine)
table_names = inspector.get_table_names()
dict_data = []
for table_name in table_names:
try:
table_comment = inspector.get_table_comment(table_name)
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment
table_comment = comment or ""
except Exception as e:
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
table_comment = ""
# 统一处理 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
inspector: Inspector = inspect(engine)
all_table_names = set(inspector.get_table_names())
results = []
for table_name in table_names:
if table_name not in all_table_names:
continue
try:
table_comment = inspector.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 = ""
results.append(
GenDBTableSchema(
database_name=database_name,
table_name=table_name,
table_type=database_type,
table_comment=comment or "",
),
)
return results
async def check_table_exists(self, table_name: str) -> bool:
"""检查数据库中是否已存在指定表名的表。
参数:
- table_name (str): 要检查的表名。
返回:
- bool: 如果表存在返回True,否则返回False。
"""
inspector: Inspector = inspect(engine)
return inspector.has_table(table_name)
async def get_db_table_comment(self, table_name: str) -> str:
"""获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。
参数:
- table_name (str): 物理表名。
返回:
- str: 表注释;表不存在或失败时为空字符串。
"""
inspector: Inspector = inspect(engine)
if not inspector.has_table(table_name):
return ""
try:
table_comment = inspector.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 ""
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
def _sync_get_table_columns(database_type: str, table_name: str) -> list[dict]:
"""同步函数:获取数据库表的列信息
参数:
- database_type: 数据库类型
- table_name: 表名
返回:
- list: 列信息列表
"""
# 使用SQLAlchemy Inspector获取表列信息
inspector: Inspector = inspect(engine)
# 获取列信息
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
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:
# 在线程池中执行同步 inspect 操作,避免阻塞事件循环
columns_info = await asyncio.to_thread(
GenTableColumnCRUD._sync_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)
@@ -0,0 +1,309 @@
import re
from app.common.constant import GenConstant
from app.config.setting import settings
from app.utils.string_util import StringUtil
from .schema import (
GenTableColumnSchema,
GenTableOutSchema,
GenTableSchema,
)
class GenUtils:
"""代码生成器工具类"""
@classmethod
def init_table(cls, gen_table: GenTableSchema) -> None:
"""初始化表信息
参数:
- gen_table (GenTableSchema): 业务表对象。
返回:
- None
"""
gen_table.class_name = cls.convert_class_name(gen_table.table_name or "")
# 导入时给出"可用默认值",减少用户点开后看到空表单:
# - module_name:从表名推导(去掉 gen_/tb_ 前缀)
# - package_name:默认 module_{module_name}(仍可在前端改)
if gen_table.business_name is None:
gen_table.business_name = gen_table.table_name
if gen_table.module_name is None or not str(gen_table.module_name).strip():
tn = (gen_table.table_name or "").strip().lower()
if tn.startswith("gen_"):
tn = tn[4:]
elif tn.startswith("tb_"):
tn = tn[3:]
tn = re.sub(r"[^a-z0-9_]+", "_", tn)
tn = re.sub(r"_+", "_", tn).strip("_") or "module"
gen_table.module_name = tn
if gen_table.package_name is None or not str(gen_table.package_name).strip():
mn = (gen_table.module_name or "").strip()
if mn:
gen_table.package_name = mn if mn.startswith("module_") else f"module_{mn}"
fn = re.sub(r"(?:表|测试)", "", gen_table.table_comment or "")
fn = (fn or "").strip()
if not fn:
# 表注释为空时:用表名兜底,至少不为空
fn = (gen_table.table_name or "").strip()
gen_table.function_name = fn
@classmethod
def init_column_field(cls, column: GenTableColumnSchema, table: GenTableOutSchema) -> None:
"""初始化列属性字段
参数:
- column (GenTableColumnSchema): 业务表字段对象。
- table (GenTableOutSchema): 业务表对象。
返回:
- None
"""
data_type = cls.get_db_type(column.column_type or "")
column_name = column.column_name or ""
if table.id is None:
raise ValueError("业务表ID不能为空")
column.table_id = table.id
column.python_field = StringUtil.to_lower_camel_case(column_name)
# 特殊处理几何类型,根据数据库类型选择不同的映射
if data_type in [
"point",
"line",
"linestring",
"polygon",
"multipoint",
"multilinestring",
"multipolygon",
"geometrycollection",
"geometry",
]:
if settings.DATABASE_TYPE == "mysql":
column.python_type = "bytes"
elif settings.DATABASE_TYPE == "postgres":
column.python_type = "list"
else:
# 只有当python_type为None时才设置默认类型
column.python_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_PYTHON, data_type)
if column.column_length is None:
column.column_length = ""
if column.column_default is None:
column.column_default = ""
if column.html_type is None:
# 先按"字段名语义"推断(优先级高于通用字符串规则)
lower_name = column_name.lower()
if lower_name.endswith("status"):
column.html_type = GenConstant.HTML_RADIO
elif lower_name.endswith("type") or lower_name.endswith("sex"):
column.html_type = GenConstant.HTML_SELECT
elif lower_name.endswith("image"):
column.html_type = GenConstant.HTML_IMAGE_UPLOAD
elif lower_name.endswith("file"):
column.html_type = GenConstant.HTML_FILE_UPLOAD
elif lower_name.endswith("content"):
column.html_type = GenConstant.HTML_EDITOR
# 再按"数据类型"推断
elif cls.arrays_contains(GenConstant.COLUMNTYPE_TIME, data_type):
column.html_type = GenConstant.HTML_DATETIME
elif cls.arrays_contains(GenConstant.COLUMNTYPE_NUMBER, data_type):
column.html_type = GenConstant.HTML_INPUT
elif cls.arrays_contains(GenConstant.COLUMNTYPE_STR, data_type) or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type):
# 字符串长度超过500设置为文本域
column_length = cls.get_column_length(column.column_type or "")
column.html_type = GenConstant.HTML_TEXTAREA if column_length >= 500 or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type) else GenConstant.HTML_INPUT
else:
column.html_type = GenConstant.HTML_INPUT
# 默认新增字段:非主键且不在"新增不展示"黑名单中
# 说明:schema 默认值可能为 True/False;仅当调用方未显式配置时才做推断
if column.is_insert is None:
column.is_insert = bool((not column.is_pk) and (not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_ADD_SHOW, column_name)))
# 默认编辑字段:非主键且不在"不编辑"黑名单中
if column.is_edit is None:
column.is_edit = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_EDIT, column_name)) and (not column.is_pk))
# 默认列表字段:非主键且不在"不列表显示"黑名单中
if column.is_list is None:
column.is_list = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_LIST, column_name)) and (not column.is_pk))
# 默认查询字段:非主键且不在"不查询"黑名单中
if column.is_query is None:
column.is_query = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_QUERY, column_name)) and (not column.is_pk))
# 查询类型:仅当开启查询且 query_type 未显式配置时推断
if column.is_query:
if column.query_type is None:
if column_name.lower().endswith("name") or data_type in ["varchar", "char", "text"]:
column.query_type = GenConstant.QUERY_LIKE
else:
column.query_type = GenConstant.QUERY_EQ
else:
column.query_type = None
# 主键强约束:无论默认推断/历史配置如何,主键列不应出现在新增/编辑/列表/查询
if bool(column.is_pk):
column.is_insert = False
column.is_edit = False
column.is_list = False
column.is_query = False
column.query_type = None
@classmethod
def arrays_contains(cls, arr: list, target_value: str) -> bool:
"""检查目标值是否在数组中
注意:从根本上解决问题,现在确保传入的参数都是正确的类型:
- arr 是列表类型,且在GenConstant中定义
- target_value 不会是None
参数:
- arr: 数组类型
- target_value: 目标值
返回:
- bool: 如果目标值在数组中,返回True;否则返回False
"""
# 从根本上解决问题,不再需要复杂的防御性检查
# 因为现在我们确保传入的arr是GenConstant中定义的列表常量
# 并且target_value在调用前已经被处理过不会是None
# 移除 COLLATE 子句和 UNSIGNED 标记(不区分大小写)
target_str = str(target_value)
# 移除 COLLATE 子句
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
if collate_pattern.search(target_str):
target_str = collate_pattern.split(target_str)[0].strip()
# 移除 UNSIGNED 标记
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
if unsigned_pattern.search(target_str):
target_str = unsigned_pattern.sub("", target_str).strip()
# 转换为小写进行比较
target_str = target_str.lower()
# 对于包含括号的类型(如TINYINT(1)),需要特殊处理
# 先获取基本类型名称(不含括号)用于比较
target_base_type = target_str.split("(")[0] if "(" in target_str else target_str
for item in arr:
item_str = str(item).lower()
item_base_type = item_str.split("(")[0] if "(" in item_str else item_str
if target_base_type == item_base_type:
return True
return False
@classmethod
def convert_class_name(cls, table_name: str) -> str:
"""表名转换成 Python 类名
参数:
- table_name (str): 业务表名。
返回:
- str: Python 类名。
"""
return StringUtil.convert_to_camel_case(table_name)
@classmethod
def replace_first(cls, input_string: str, search_list: list[str]) -> str:
"""批量替换前缀
参数:
- input_string (str): 需要被替换的字符串。
- search_list (list[str]): 可替换的字符串列表。
返回:
- str: 替换后的字符串。
"""
for search_string in search_list:
if input_string.startswith(search_string):
return input_string.replace(search_string, "", 1)
return input_string
@classmethod
def get_db_type(cls, column_type: str) -> str:
"""获取数据库类型字段
参数:
- column_type (str): 字段类型。
返回:
- str: 数据库类型。
"""
# 移除 COLLATE 子句(处理带引号和不带引号的情况,不区分大小写)
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
if collate_pattern.search(column_type):
column_type = collate_pattern.split(column_type)[0].strip()
# 移除 UNSIGNED 标记(不区分大小写)
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
if unsigned_pattern.search(column_type):
column_type = unsigned_pattern.sub("", column_type).strip()
# 特殊处理tinyint(1),映射为boolean
if column_type.lower().startswith("tinyint(1)"):
return "boolean"
# 处理PostgreSQL数组类型(如 integer[], text[] 或 ARRAY[INTEGER]
if "[]" in column_type or column_type.upper().startswith("ARRAY["):
return "array"
# 提取基本类型:
# - 去掉括号参数:varchar(64) -> varchar
# - 去掉空格后的修饰:timestamp without time zone -> timestamp
# - 统一小写
base = column_type.split("(", 1)[0].strip()
if not base:
return ""
base = base.split(None, 1)[0].strip()
return base.lower()
@classmethod
def get_column_length(cls, column_type: str) -> int:
"""获取字段长度
参数:
- column_type (str): 字段类型,例如 'varchar(255)''decimal(10,2)'
返回:
- int: 字段长度(优先取第一个长度值,无法解析时返回0)。
"""
if not column_type:
return 0
if "(" not in column_type or ")" not in column_type:
return 0
# 形如 varchar(255) / decimal(10,2) / numeric(20, 0)
inner = column_type.split("(", 1)[1].split(")", 1)[0].strip()
if not inner:
return 0
first = inner.split(",", 1)[0].strip()
try:
return int(first)
except ValueError:
return 0
@classmethod
def split_column_type(cls, column_type: str) -> list[str]:
"""拆分列类型
参数:
- column_type (str): 字段类型。
返回:
- list[str]: 拆分结果。
"""
if "(" in column_type and ")" in column_type:
return column_type.split("(")[1].split(")")[0].split(",")
return []
@@ -0,0 +1,760 @@
import re
from datetime import datetime
from typing import Any
from jinja2 import Environment, FileSystemLoader, Template
from app.common.constant import GenConstant
from app.config.path_conf import TEMPLATE_DIR
from app.config.setting import settings
from app.utils.common_util import CamelCaseUtil, SnakeCaseUtil, compute_menu_route_first_segment
from app.utils.string_util import StringUtil
from .gen_util import GenUtils
from .schema import (
GenTableColumnOutSchema,
GenTableOutSchema,
)
class Jinja2TemplateUtil:
"""模板处理工具类
"""
@classmethod
def normalize_db_column_type_for_mapping(cls, column_type: str | None) -> str:
"""与 ``GenUtils.get_db_type`` 一致地去掉 COLLATE / UNSIGNED,便于与 ``DB_TO_SQLALCHEMY`` 键匹配。
参数:
- column_type (str | None): 原始列类型字符串。
返回:
- str: 规范化后的类型片段;空输入返回空字符串。
"""
ct = (column_type or "").strip()
if not ct:
return ""
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
if collate_pattern.search(ct):
ct = collate_pattern.split(ct)[0].strip()
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
if unsigned_pattern.search(ct):
ct = unsigned_pattern.sub("", ct).strip()
return ct
# 项目路径
FRONTEND_PROJECT_PATH = "frontend/web"
BACKEND_PROJECT_PATH = "backend"
# 环境对象
_env = None
@classmethod
def get_env(cls):
"""获取模板环境对象。
参数:
- 无
返回:
- Environment: Jinja2 环境对象。
"""
try:
if cls._env is None:
cls._env = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=False, # 自动转义HTML
trim_blocks=True, # 删除多余的空行
lstrip_blocks=True, # 删除行首空格
keep_trailing_newline=True, # 保留行尾换行符
enable_async=True, # 开启异步支持
)
cls._env.filters.update(
{
"camel_to_snake": SnakeCaseUtil.camel_to_snake,
"snake_to_camel": CamelCaseUtil.snake_to_camel,
"get_sqlalchemy_type": cls.get_sqlalchemy_type,
"python_to_ts_type": cls.python_type_to_ts_type,
},
)
return cls._env
except Exception as e:
raise RuntimeError(f"初始化Jinja2模板引擎失败: {e}")
@classmethod
def get_template(cls, template_path: str) -> Template:
"""获取模板。
参数:
- template_path (str): 模板路径。
返回:
- Template: Jinja2 模板对象。
异常:
- TemplateNotFound: 模板未找到时抛出。
"""
return cls.get_env().get_template(template_path)
@classmethod
def business_name_to_slug(cls, business_name: str | None) -> str:
"""业务路径可含斜杠(如 ``demo/subdir``)用于目录与路由前缀;
Python 函数/方法名仅使用最后一段并规范为合法 snake_case 片段。
参数:
- business_name (str | None): 业务路径或名称。
返回:
- str: 用于 Python 标识的 slug,默认 entity。
"""
s = (business_name or "").strip().strip("/")
if not s:
return "entity"
if "/" in s:
s = s.split("/")[-1]
s = re.sub(r"[^a-zA-Z0-9_]", "_", s)
if not s:
return "entity"
if s[0].isdigit():
s = "_" + s
return s
@classmethod
def business_name_to_path(cls, business_name: str | None) -> str:
"""把 business_name 规范为可用于目录/路由的多段路径(保留 `/`)。
约定:`business_name` 允许 `a/b/c` 表示多级菜单目录。
- 目录/路由:使用完整多段
- 文件名/route_name:使用最后一段 slug(见 `business_name_to_slug`
参数:
- business_name (str | None): 业务路径或名称。
返回:
- str: 多段路径字符串(小写 slug),默认 entity。
"""
s = (business_name or "").strip().strip("/")
if not s:
return "entity"
# 每段都做一次轻度规范(与 schema 的 slug 规则一致:a-z0-9_
segs = []
for raw in [p for p in s.split("/") if p]:
seg = re.sub(r"[^a-zA-Z0-9_]", "_", raw).lower()
seg = re.sub(r"_+", "_", seg).strip("_") or "entity"
if seg[0].isdigit():
seg = "_" + seg
segs.append(seg)
return "/".join(segs) if segs else "entity"
@classmethod
def prepare_context(cls, gen_table: GenTableOutSchema) -> dict[str, Any]:
"""准备模板变量。
参数:
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- Dict[str, Any]: 模板上下文字典。
"""
# 处理options为None的情况
# if not gen_table.options:
# raise ValueError('请先完善生成配置信息')
class_name = gen_table.class_name or ""
package_name = (gen_table.package_name or "").strip()
module_name = (gen_table.module_name or "").strip()
business_name = (gen_table.business_name or "").strip()
function_name = gen_table.function_name or ""
# 生成规则(对齐 module_example/demo):
# - 分系统根:package_name = module_xxx
# - 目录固定为:module_xxx / module_name(不再额外使用业务名作为目录层级)
# - 权限前缀固定为:module_xxx:module_name(操作在模板里再拼 :query/:create...
business_path = cls.business_name_to_path(business_name)
business_name_slug = cls.business_name_to_slug(business_name)
permission_prefix = ":".join([s for s in [package_name, module_name] if s])
api_route_prefix = cls.get_api_route_prefix(package_name)
_cols = gen_table.columns or []
table_column_names = frozenset(c.column_name for c in _cols if getattr(c, "column_name", None))
has_dict_column = any(getattr(c, "dict_type", None) for c in _cols)
has_image_column = any(getattr(c, "html_type", None) == "imageUpload" for c in _cols)
sub_class_name = ""
sub_model_class_name = ""
sub_rel_list_name = ""
parent_rel_name = ""
if gen_table.sub and gen_table.sub_table:
st = gen_table.sub_table
scn = (st.class_name or GenUtils.convert_class_name(gen_table.sub_table_name or "")).strip()
sub_class_name = scn
sub_model_class_name = f"{scn}Model"
sub_rel_list_name = f"{SnakeCaseUtil.camel_to_snake(scn)}_list"
parent_rel_name = SnakeCaseUtil.camel_to_snake(gen_table.class_name or "")
context = {
"table_name": gen_table.table_name or "",
"table_comment": gen_table.table_comment or "",
"function_name": function_name if StringUtil.is_not_empty(function_name) else "【请填写功能名称】",
"class_name": class_name,
"module_name": module_name,
"business_name": business_name,
"business_path": business_path,
"business_file": business_name_slug,
"business_name_slug": business_name_slug,
"base_package": cls.get_package_prefix(package_name),
"package_name": package_name,
"menu_route_first_segment": cls.get_menu_route_first_segment(gen_table),
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"pk_column": gen_table.pk_column,
"model_import_list": cls.get_model_import_list(gen_table),
"schema_import_list": cls.get_schema_import_list(gen_table),
"permission_prefix": permission_prefix,
"api_route_prefix": api_route_prefix,
"columns": gen_table.columns or [],
"table_column_names": table_column_names,
"table": gen_table,
"dicts": cls.get_dicts(gen_table),
"db_type": settings.DATABASE_TYPE,
"column_not_add_show": GenConstant.COLUMNNAME_NOT_ADD_SHOW,
"column_not_edit_show": GenConstant.COLUMNNAME_NOT_EDIT_SHOW,
"parent_menu_id": int(gen_table.parent_menu_id) if gen_table.parent_menu_id else None,
"is_sub_entity": False,
"sub_class_name": sub_class_name,
"sub_model_class_name": sub_model_class_name,
"sub_module_name": (gen_table.sub_table.module_name if gen_table.sub and gen_table.sub_table else ""),
"sub_rel_list_name": sub_rel_list_name,
"parent_rel_name": parent_rel_name,
"parent_list_rel_name": "",
"parent_table_name": "",
"parent_model_class_name": "",
# 数据表实际主键列名(用于生成前端行键等;ModelMixin 仍默认带 id 字段)
"pk_column_name": (gen_table.pk_column.column_name if gen_table.pk_column else None) or "id",
"parent_pk_column_name": (gen_table.pk_column.column_name if gen_table.pk_column else None) or "id",
"sub_table_fk_name": "",
"has_dict_column": has_dict_column,
"has_image_column": has_image_column,
}
return context
@classmethod
def get_menu_route_first_segment(cls, gen_table: GenTableOutSchema) -> str:
"""前端页面路由首段(与写入菜单 ``route_path`` 第一段一致):始终为 ``module_xxx``。"""
pid = int(gen_table.parent_menu_id) if gen_table.parent_menu_id is not None else None
return compute_menu_route_first_segment(
pid,
gen_table.package_name or "",
gen_table.module_name,
)
@classmethod
def prepare_sub_render_context(cls, parent: GenTableOutSchema, sub: GenTableOutSchema) -> dict[str, Any]:
"""子表业务代码渲染上下文(与主表同模块、独立业务目录)。
参数:
- parent (GenTableOutSchema): 主表配置。
- sub (GenTableOutSchema): 子表配置。
返回:
- dict[str, Any]: 子表模板上下文字典。
"""
ctx = cls.prepare_context(sub)
scn = (sub.class_name or GenUtils.convert_class_name(sub.table_name or "")).strip()
ctx["is_sub_entity"] = True
ctx["parent_class_name"] = parent.class_name or ""
ctx["parent_model_class_name"] = f"{parent.class_name}Model"
ctx["parent_table_name"] = parent.table_name or ""
ctx["parent_pk_column_name"] = (parent.pk_column.column_name if parent.pk_column else None) or "id"
ctx["parent_rel_name"] = SnakeCaseUtil.camel_to_snake(parent.class_name or "parent")
ctx["parent_list_rel_name"] = f"{SnakeCaseUtil.camel_to_snake(scn)}_list"
ctx["sub_table_fk_name"] = (parent.sub_table_fk_name or "").strip()
ctx["model_import_list"] = cls.get_model_import_list(sub, is_sub_entity=True)
ctx["schema_import_list"] = cls.get_schema_import_list(sub)
return ctx
@classmethod
def get_template_list(cls):
"""获取主表模板列表。
参数:
- 无
返回:
- List[str]: 模板路径列表。
"""
templates = [
"python/controller.py.jinja2",
"python/service.py.jinja2",
"python/crud.py.jinja2",
"python/schema.py.jinja2",
"python/model.py.jinja2",
"python/__init__.py.jinja2",
"ts/api.ts.jinja2",
"vue/index.vue.jinja2",
]
return templates
@classmethod
def get_sub_table_template_list(cls):
"""获取子表模板列表(仅 model / schema / __init__,不含 controller/service/crud/vue/api)。
参数:
- 无
返回:
- List[str]: 子表模板路径列表。
"""
return [
"python/model.py.jinja2",
"python/schema.py.jinja2",
"python/__init__.py.jinja2",
]
@classmethod
def get_file_name(cls, template: str, gen_table: GenTableOutSchema):
"""根据模板生成文件名。
参数:
- template (str): 模板路径字符串。
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- str: 模板生成的文件名。
异常:
- ValueError: 当无法生成有效文件名时抛出。
"""
package_name = (gen_table.package_name or "").strip()
module_name = (gen_table.module_name or "").strip()
if not package_name:
raise ValueError(f"无法为模板 {template} 生成文件名:包名未设置")
if not module_name:
raise ValueError(f"无法为模板 {template} 生成文件名:模块名未设置")
# 目录固定为:module_xxx/{module_name}
backend_base = f"{cls.BACKEND_PROJECT_PATH}/app/plugin/{package_name}"
frontend_view_base = f"{cls.FRONTEND_PROJECT_PATH}/src/views/{package_name}"
frontend_api_base = f"{cls.FRONTEND_PROJECT_PATH}/src/api/{package_name}"
backend_dir = f"{backend_base}/{module_name}"
view_dir = f"{frontend_view_base}/{module_name}"
api_path = f"{frontend_api_base}/{module_name}.ts"
template_mapping = {
"controller.py.jinja2": f"{backend_dir}/controller.py",
"service.py.jinja2": f"{backend_dir}/service.py",
"crud.py.jinja2": f"{backend_dir}/crud.py",
"schema.py.jinja2": f"{backend_dir}/schema.py",
"model.py.jinja2": f"{backend_dir}/model.py",
"__init__.py.jinja2": f"{backend_dir}/__init__.py",
"api.ts.jinja2": api_path,
"index.vue.jinja2": f"{view_dir}/index.vue",
}
# 查找匹配的模板路径
for key, path in template_mapping.items():
if key in template:
return path
# 遍历完所有映射都没找到匹配项,才抛出异常
raise ValueError(f"未找到模板 '{template}' 的路径映射")
@classmethod
def get_package_prefix(cls, package_name: str) -> str:
"""获取包前缀。
参数:
- package_name (str): 包名。
返回:
- str: 包前缀。
"""
# 修复:当包名中不存在'.'时,直接返回原包名
return package_name[: package_name.rfind(".")] if "." in package_name else package_name
@classmethod
def get_schema_import_list(cls, gen_table: GenTableOutSchema):
"""获取 schema 模板所需的 Python 导入语句集合。
参数:
- gen_table (GenTableOutSchema): 生成表配置(含主子表列)。
返回:
- set[str]: 导入语句字符串集合。
"""
columns = gen_table.columns or []
import_list = set()
has_datetime_import = False
has_date_import = False
has_time_import = False
for column in columns:
# 处理datetime类型的导入
if column.python_type and column.python_type in GenConstant.TYPE_DATE:
if column.python_type == "datetime":
has_datetime_import = True
elif column.python_type == "date":
has_date_import = True
elif column.python_type == "time":
has_time_import = True
elif column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add("from decimal import Decimal")
if gen_table.sub and gen_table.sub_table and gen_table.sub_table.columns:
sub_columns = gen_table.sub_table.columns or []
for sub_column in sub_columns:
# 处理datetime类型的导入
if sub_column.python_type and sub_column.python_type in GenConstant.TYPE_DATE:
if sub_column.python_type == "datetime":
has_datetime_import = True
elif sub_column.python_type == "date":
has_date_import = True
elif sub_column.python_type == "time":
has_time_import = True
elif sub_column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add("from decimal import Decimal")
# 添加datetime导入
if has_datetime_import:
import_list.add("from datetime import datetime")
if has_date_import:
import_list.add("from datetime import date")
if has_time_import:
import_list.add("from datetime import time")
return import_list
@classmethod
def get_model_import_list(cls, gen_table: GenTableOutSchema, *, is_sub_entity: bool = False) -> list[str]:
"""获取 model 模板所需的 Python 导入语句列表(含合并后的 sqlalchemy 导入)。
参数:
- gen_table (GenTableOutSchema): 生成表配置。
- is_sub_entity (bool): 是否为子表独立生成(含外键与 relationship)。
返回:
- list[str]: 导入语句列表。
"""
columns = gen_table.columns or []
import_list = set()
has_datetime_import = False
has_date_import = False
has_time_import = False
# 基类 ModelMixin/UserMixin 已定义的列,无需导入 SQLAlchemy 类型
_BASE_MODEL_COLUMNS = {
"id",
"uuid",
"created_time",
"updated_time",
"created_id",
"updated_id",
"is_deleted",
"deleted_time",
"deleted_id",
}
for column in columns:
if column.column_name in _BASE_MODEL_COLUMNS:
continue
if column.column_type:
data_type = cls.get_db_type(column.column_type)
if data_type in GenConstant.COLUMNTYPE_GEOMETRY:
import_list.add("from geoalchemy2 import Geometry")
import_list.add(f"from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}")
# 处理datetime类型的导入
if column.python_type and column.python_type in GenConstant.TYPE_DATE:
if column.python_type == "datetime":
has_datetime_import = True
elif column.python_type == "date":
has_date_import = True
elif column.python_type == "time":
has_time_import = True
# 处理Decimal类型的导入
elif column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add("from decimal import Decimal")
if gen_table.sub or is_sub_entity:
import_list.add("from sqlalchemy import ForeignKey")
if gen_table.sub and not is_sub_entity and gen_table.sub_table and gen_table.sub_table.columns:
sub_columns = gen_table.sub_table.columns or []
for sub_column in sub_columns:
if sub_column.column_type:
data_type = cls.get_db_type(sub_column.column_type)
import_list.add(f"from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}")
# 处理datetime类型的导入
if sub_column.python_type and sub_column.python_type in GenConstant.TYPE_DATE:
if sub_column.python_type == "datetime":
has_datetime_import = True
elif sub_column.python_type == "date":
has_date_import = True
elif sub_column.python_type == "time":
has_time_import = True
# 处理Decimal类型的导入
elif sub_column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add("from decimal import Decimal")
# 添加datetime导入
if has_datetime_import:
import_list.add("from datetime import datetime")
if has_date_import:
import_list.add("from datetime import date")
if has_time_import:
import_list.add("from datetime import time")
merged = cls.merge_same_imports(list(import_list), "from sqlalchemy import")
if gen_table.sub or is_sub_entity:
merged.append("from sqlalchemy.orm import relationship")
return merged
@classmethod
def get_db_type(cls, column_type: str) -> str:
"""获取数据库字段类型。
参数:
- column_type (str): 字段类型字符串。
返回:
- str: 数据库类型(去除长度等修饰)。
"""
# 移除 COLLATE 子句(处理带引号和不带引号的情况,不区分大小写)
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
if collate_pattern.search(column_type):
column_type = collate_pattern.split(column_type)[0].strip()
# 移除 UNSIGNED 标记(不区分大小写)
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
if unsigned_pattern.search(column_type):
column_type = unsigned_pattern.sub("", column_type).strip()
# 处理PostgreSQL数组类型(如 integer[], text[]
if "[]" in column_type:
return "array"
# 提取基本类型
if "(" in column_type:
return column_type.split("(")[0]
return column_type
@classmethod
def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]:
"""合并相同的导入语句。
参数:
- imports (list[str]): 导入语句列表。
- import_start (str): 导入语句的起始字符串。
返回:
- list[str]: 合并后的导入语句列表。
"""
merged_imports = []
imports_ = []
for import_stmt in imports:
if import_stmt.startswith(import_start):
imported_items = import_stmt.split("import")[1].strip()
imports_.extend(imported_items.split(", "))
else:
merged_imports.append(import_stmt)
if imports_:
# 去重并过滤空字符串,然后用逗号连接
unique_imports = [item for item in imports_ if item]
if len(unique_imports) > 0:
merged_datetime_import = f"{import_start} {', '.join(unique_imports)}"
merged_imports.append(merged_datetime_import)
return merged_imports
@classmethod
def get_dicts(cls, gen_table: GenTableOutSchema):
"""获取字典列表。
参数:
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- str: 以逗号分隔的字典类型字符串。
"""
columns = gen_table.columns or []
dicts = set()
cls.add_dicts(dicts, columns)
# 处理sub_table为None的情况
if gen_table.sub_table is not None:
# 处理sub_table.columns为None的情况
sub_columns = gen_table.sub_table.columns or []
cls.add_dicts(dicts, sub_columns)
return ", ".join(dicts)
@classmethod
def add_dicts(cls, dicts: set[str], columns: list[GenTableColumnOutSchema]) -> None:
"""添加字典类型到集合。
参数:
- dicts (set[str]): 字典类型集合。
- columns (list[GenTableColumnOutSchema]): 字段列表。
返回:
- set[str]: 更新后的字典类型集合。
"""
for column in columns:
super_column = column.super_column if column.super_column is not None else "0"
dict_type = column.dict_type or ""
html_type = column.html_type or ""
if (
not super_column
and StringUtil.is_not_empty(dict_type)
and StringUtil.equals_any_ignore_case(
html_type,
[
GenConstant.HTML_SELECT,
GenConstant.HTML_RADIO,
GenConstant.HTML_CHECKBOX,
],
)
):
dicts.add(f"'{dict_type}'")
@classmethod
def get_permission_prefix(cls, module_name: str | None, business_name: str | None) -> str:
"""获取权限前缀。
参数:
- module_name (str | None): 模块名。
- business_name (str | None): 业务名。
返回:
- str: 权限前缀字符串。
"""
mn = (module_name or "").strip()
bn = (business_name or "").strip().replace("/", ":")
if not bn:
return mn
return f"{mn}:{bn}"
@classmethod
def python_type_to_ts_type(cls, python_type: str | None) -> str:
"""将列上的 Python 类型(`get_db_type` + `DB_TO_PYTHON` 映射结果)转为前端 TS 类型片段。
与 JSON 序列化习惯一致:Decimal、日期时间多为字符串;dict/list 用宽松类型。
参数:
- python_type (str | None): Python 类型名。
返回:
- str: 前端 TypeScript 类型片段。
"""
if not python_type or not str(python_type).strip():
return "string"
p = str(python_type).strip()
mapping: dict[str, str] = {
"int": "number",
"float": "number",
"bool": "boolean",
"Decimal": "string",
"date": "string",
"time": "string",
"datetime": "string",
"timedelta": "string",
"dict": "Record<string, unknown>",
"list": "unknown[]",
"bytes": "string",
"str": "string",
}
return mapping.get(p, "string")
@classmethod
def get_api_route_prefix(cls, module_name: str | None) -> str:
"""获取前端 API 路径首段,与 `discover` 中插件路由前缀一致(`module_xxx` → `xxx`)。
参数:
- module_name (str | None): 模块名,如 ``module_example``。
返回:
- str: 路由前缀,如 ``example``。
"""
if not module_name:
return ""
if module_name.startswith("module_"):
return module_name[7:]
return module_name
@classmethod
def get_sqlalchemy_type(cls, column: Any) -> str:
"""获取 SQLAlchemy 类型。
参数:
- column (Any): 列对象或列类型字符串。
返回:
- str: SQLAlchemy 类型字符串。
"""
# 获取column_type和column_length
column_type = column
column_length = None
# 检查是否是对象
if hasattr(column, "column_type"):
column_type = column.column_type or ""
column_length = column.column_length or None
column_type = cls.normalize_db_column_type_for_mapping(column_type)
# MySQL:仅 tinyint(1) 映射为 Boolean;其余 tinyint 走 SmallInteger(见 GenConstant.DB_TO_SQLALCHEMY
ct_norm = (column_type or "").strip()
if settings.DATABASE_TYPE != "postgres" and ct_norm:
ct_lower = ct_norm.lower()
if ct_lower.startswith("tinyint(1)"):
return "Boolean"
# 首先尝试匹配完整类型(包括括号)
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, column_type)
# 特殊处理PostgreSQL类型
if settings.DATABASE_TYPE == "postgres":
if column_type.upper() == "BOOLEAN":
return "Boolean"
if column_type.upper() == "REAL" or column_type.upper() == "DOUBLE PRECISION":
return "Float"
if column_type.upper() == "TIMESTAMP":
return "DateTime"
if column_type.upper() == "JSONB":
return "JSONB"
if column_type.upper() == "UUID":
return "Uuid"
if column_type.upper() == "BYTEA":
return "LargeBinary"
# get_mapping_value_by_key_ignore_case 未命中时返回 "",须与 None 同样视为未匹配
if not sqlalchemy_type and "(" in column_type:
# 如果没有匹配到,再尝试剥离括号
column_type_list = column_type.split("(")
col_type = column_type_list[0]
# 将 'character' 映射为 'char' 以匹配常量定义
if col_type.lower() == "character":
col_type = "char"
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
# 如果是字符串类型且包含括号参数,保持原参数
if sqlalchemy_type in ["String", "CHAR"] or sqlalchemy_type in ["Numeric", "DECIMAL"]:
sqlalchemy_type += "(" + column_type_list[1]
elif not sqlalchemy_type:
# 处理没有括号的类型
col_type = column_type
# 将 'character' 映射为 'char' 以匹配常量定义
if col_type.lower() == "character":
col_type = "char"
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
# 如果是字符串类型且没有指定长度,使用column_length或默认255
if sqlalchemy_type in ["String", "CHAR"]:
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type += f"({length})"
# 对于已经匹配到的类型,如果是字符串类型且column有长度信息,添加长度
elif sqlalchemy_type in ["String", "CHAR"] and "(" not in sqlalchemy_type:
# 检查column_length是否有效
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type += f"({length})"
# 如果没有找到匹配的类型,使用String(column_length)或String(255)作为默认类型
if not sqlalchemy_type:
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type = f"String({length})"
return sqlalchemy_type
@@ -0,0 +1,88 @@
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from sqlalchemy.sql import expression
from app.config.setting import settings
from app.core.base_model import ModelMixin, UserMixin
from app.utils.common_util import SqlalchemyUtil
class GenTableModel(ModelMixin, UserMixin):
"""代码生成表
"""
__tablename__: str = "gen_table"
__table_args__: dict[str, str] = {"comment": "代码生成表"}
table_name: Mapped[str] = mapped_column(String(200), nullable=False, default="", index=True, comment="表名称")
table_comment: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="表描述")
class_name: Mapped[str] = mapped_column(String(100), nullable=False, default="", comment="实体类名称")
package_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="生成包路径")
module_name: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="生成模块名")
business_name: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="生成业务名")
function_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="生成功能名")
sub_table_name: Mapped[str | None] = mapped_column(String(64), nullable=True, server_default=SqlalchemyUtil.get_server_default_null(settings.DATABASE_TYPE), comment="关联子表的表名")
sub_table_fk_name: Mapped[str | None] = mapped_column(String(64), nullable=True, server_default=SqlalchemyUtil.get_server_default_null(settings.DATABASE_TYPE), comment="子表关联的外键名")
parent_menu_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="父菜单ID")
columns: Mapped[list["GenTableColumnModel"]] = relationship(order_by="GenTableColumnModel.sort", back_populates="table", cascade="all, delete-orphan")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@validates("table_name")
def validate_table_name(self, key: str, table_name: str) -> str:
"""验证表名非空并去首尾空格。"""
if not table_name or not table_name.strip():
raise ValueError("表名称不能为空")
return table_name.strip()
@validates("class_name")
def validate_class_name(self, key: str, class_name: str) -> str:
"""验证实体类名非空并去首尾空格。"""
if not class_name or not class_name.strip():
raise ValueError("实体类名称不能为空")
return class_name.strip()
class GenTableColumnModel(ModelMixin, UserMixin):
"""代码生成表字段"""
__tablename__: str = "gen_table_column"
__table_args__: dict[str, str] = {"comment": "代码生成表字段"}
column_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True, comment="列名称")
column_comment: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="列描述")
column_type: Mapped[str] = mapped_column(String(100), nullable=False, comment="列类型")
column_length: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="列长度")
column_default: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="列默认值")
is_pk: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment="是否主键")
is_increment: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment="是否自增")
is_nullable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment="是否允许为空")
is_unique: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment="是否唯一")
python_type: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="Python类型")
python_field: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="Python字段名")
is_insert: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment="是否为新增字段")
is_edit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment="是否编辑字段")
is_list: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment="是否列表字段")
is_query: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment="是否查询字段")
query_type: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None, comment="查询方式")
html_type: Mapped[str | None] = mapped_column(String(100), nullable=True, default="input", comment="前端显示类型")
dict_type: Mapped[str | None] = mapped_column(String(200), nullable=True, default="", comment="前端对应字典类型")
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
table_id: Mapped[int] = mapped_column(Integer, ForeignKey("gen_table.id", ondelete="CASCADE"), nullable=False, index=True, comment="归属表编号")
table: Mapped["GenTableModel"] = relationship(back_populates="columns")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@validates("column_name")
def validate_column_name(self, key: str, column_name: str) -> str:
"""验证列名非空并去首尾空格。"""
if not column_name or not column_name.strip():
raise ValueError("列名称不能为空")
return column_name.strip()
@validates("column_type")
def validate_column_type(self, key: str, column_type: str) -> str:
"""验证列类型非空并去首尾空格。"""
if not column_type or not column_type.strip():
raise ValueError("列类型不能为空")
return column_type.strip()
@@ -0,0 +1,292 @@
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class GenDBTableSchema(BaseModel):
"""数据库中的表信息(跨方言统一结构)。
- 供“导入表结构”与“同步结构”环节使用。
"""
model_config = ConfigDict(from_attributes=True)
database_name: str | None = Field(default=None, description="数据库名称")
table_name: str | None = Field(default=None, description="表名称")
table_type: str | None = Field(default=None, description="表类型")
table_comment: str | None = Field(default=None, description="表描述")
class GenCreateTableSqlBody(BaseModel):
"""从代码生成页提交的建表 SQL(JSON 对象,便于与前端 axios 一致)。"""
sql: str = Field(..., description="CREATE TABLE 等 DDL,可多条语句")
class GenTableColumnSchema(BaseModel):
"""代码生成业务表字段创建模型(原始字段+生成配置)。
- 从根本上解决问题:所有字段都设置了合理的默认值,避免None值问题
"""
model_config = ConfigDict(from_attributes=True)
table_id: int = Field(default=0, description="归属表编号")
column_name: str = Field(default="", description="列名称")
column_comment: str | None = Field(default="", description="列描述")
column_type: str = Field(default="varchar(255)", description="列类型")
column_length: str | None = Field(default="", description="列长度")
column_default: str | None = Field(default="", description="列默认值")
is_pk: bool = Field(default=False, description="是否主键(True是 False否)")
is_increment: bool = Field(default=False, description="是否自增(True是 False否)")
is_nullable: bool = Field(default=True, description="是否允许为空(True是 False否)")
is_unique: bool = Field(default=False, description="是否唯一(True是 False否)")
python_type: str = Field(default="str", description="python类型")
python_field: str = Field(default="", description="python字段名")
# 这些开关若默认 True,会导致导入/同步时无法触发自动推断(如主键 id 误入新增/编辑/列表/查询)
# 约定:None 表示“未配置”,由 GenUtils.init_column_field 推断填充
is_insert: bool | None = Field(default=None, description="是否为新增字段(True是 False否)")
is_edit: bool | None = Field(default=None, description="是否编辑字段(True是 False否)")
is_list: bool | None = Field(default=None, description="是否列表字段(True是 False否)")
is_query: bool | None = Field(default=None, description="是否查询字段(True是 False否)")
query_type: str | None = Field(default=None, description="查询方式(等于、不等于、大于、小于、范围)")
# html_type 若给默认值会导致导入/同步时无法触发自动推断(全部变成 input)
# 约定:None 表示“未配置”,由 GenUtils.init_column_field 推断填充
html_type: str | None = Field(
default=None,
description="显示类型(文本框、文本域、下拉框、复选框、单选框、日期控件)",
)
dict_type: str | None = Field(default="", description="字典类型")
sort: int = Field(default=0, description="排序")
class GenTableColumnOutSchema(GenTableColumnSchema, BaseSchema):
"""业务表字段输出模型
"""
model_config = ConfigDict(from_attributes=True)
super_column: str | None = Field(default="0", description="是否为基类字段(1是 0否)")
class GenTableSchema(BaseModel):
"""代码生成业务表更新模型(扩展聚合字段)。
- 聚合:`columns`字段包含字段列表;`pk_column`主键字段;子表结构`sub_table`。
"""
"""代码生成业务表基础模型(创建/更新共享字段)。
- 说明:`params`为前端结构体,后端持久化为`options`的JSON。
"""
model_config = ConfigDict(from_attributes=True)
table_name: str = Field(..., description="表名称")
table_comment: str | None = Field(default=None, description="表描述")
class_name: str | None = Field(default=None, description="实体类名称")
package_name: str | None = Field(default=None, description="生成包路径")
module_name: str | None = Field(default=None, description="生成模块名")
business_name: str | None = Field(
default=None,
description=("功能子目录/路由段;导入时默认表名;同 module_name 下多表须不同。可含斜杠表示嵌套,参考 module_exampledemo、demo/subdir、gen_demo。"),
)
function_name: str | None = Field(default=None, description="生成功能名")
sub_table_name: str | None = Field(default=None, description="关联子表的表名")
sub_table_fk_name: str | None = Field(default=None, description="子表关联的外键名")
parent_menu_id: int | None = Field(
default=None,
description=(
"写入本地须选目录类型。有值:侧栏 上级/短包名/功能/按钮,页面路由 /包名/业务名。"
"留空:侧栏 module_包名/功能/按钮,页面路由 /module_包名/业务名(与 plugin 一致);"
"后端 HTTP 接口仍为 /短名(module_xxx→/xxx)。"
),
)
description: str | None = Field(default=None, max_length=255, description="描述")
columns: list["GenTableColumnOutSchema"] | None = Field(default=None, description="表列信息")
@staticmethod
def _normalize_slug_segment(v: str, *, allow_slash: bool = False) -> str:
"""将输入规范成工程约定的路径片段:
- 小写
- 只保留 a-z 0-9 _
- 连续 _ 合并
- 首字符不能是数字(则前置 _)
- allow_slash=True 时允许多段 path(每段分别规范)
"""
raw = (v or "").strip()
if not raw:
return ""
if allow_slash:
segs = [s for s in raw.strip("/").split("/") if s.strip()]
norm = [GenTableSchema._normalize_slug_segment(s, allow_slash=False) for s in segs]
norm = [s for s in norm if s]
return "/".join(norm)
s = raw.lower()
s = re.sub(r"[^a-z0-9_]+", "_", s)
s = re.sub(r"_+", "_", s).strip("_")
if not s:
return ""
if s[0].isdigit():
s = "_" + s
return s
@field_validator("table_name")
@classmethod
def table_name_update(cls, v: str) -> str:
"""校验并规范化表名称。
参数:
- v (str): 原始表名。
返回:
- str: 去空白后的表名。
异常:
- ValueError: 表名为空时抛出。
"""
if not v:
raise ValueError("表名称不能为空")
return v.strip()
@field_validator(
"table_comment",
"class_name",
"function_name",
"description",
mode="before",
)
@classmethod
def strip_optional_text_fields(cls, v: str | None) -> str | None:
"""文本类字段统一去首尾空格;空串视为 None。
参数:
- v (str | None): 原始值。
返回:
- str | None: 非空字符串或 None。
"""
if v is None:
return None
s = str(v).strip()
return s if s else None
@field_validator("package_name", mode="before")
@classmethod
def normalize_package_name(cls, v: str | None) -> str | None:
"""包名规范:必须是 module_xxx 形态(工程约定)。
参数:
- v (str | None): 原始包名。
返回:
- str | None: 规范化后的包名或 None。
"""
if v is None:
return None
s = cls._normalize_slug_segment(str(v), allow_slash=False)
if not s:
return None
return s if s.startswith("module_") else f"module_{s}"
@field_validator("module_name", mode="before")
@classmethod
def normalize_module_name(cls, v: str | None) -> str | None:
"""模块名规范:不带 module_ 前缀;统一按 slug 规范。
参数:
- v (str | None): 原始模块名。
返回:
- str | None: 规范化后的模块名或 None。
"""
if v is None:
return None
s = cls._normalize_slug_segment(str(v), allow_slash=False)
if not s:
return None
return s.removeprefix("module_")
@field_validator("business_name", mode="before")
@classmethod
def normalize_business_name(cls, v: str | None) -> str | None:
"""业务名允许多段(如 demo/subdir);统一按 slug 规范。
参数:
- v (str | None): 原始业务名。
返回:
- str | None: 规范化后的业务名或 None。
"""
if v is None:
return None
s = cls._normalize_slug_segment(str(v), allow_slash=True)
return s if s else None
@field_validator("sub_table_name", "sub_table_fk_name", mode="before")
@classmethod
def strip_optional_sub_fields(cls, v: str | None) -> str | None:
"""主子表字段去首尾空格,空串视为未填。
参数:
- v (str | None): 原始值。
返回:
- str | None: 非空字符串或 None。
"""
if v is None:
return None
s = str(v).strip()
return s if s else None
class GenTableOutSchema(GenTableSchema, BaseSchema, UserBySchema):
"""业务表输出模型(面向控制器/前端)。"""
model_config = ConfigDict(from_attributes=True)
pk_column: GenTableColumnOutSchema | None = Field(default=None, description="主键信息")
# 子表同样需要携带 columns/pk_column 等输出字段,使用 OutSchema 便于模板与类型检查
sub_table: "GenTableOutSchema | None" = Field(default=None, description="子表信息")
sub: bool | None = Field(default=None, description="是否为子表")
master_sub_hint: str | None = Field(
default=None,
description="主子表配置说明或异常提示(仅接口输出,不落库)",
)
class GenSyncColumnChange(BaseModel):
"""同步差异:单个字段的变化项(用于预览,不落库)。"""
model_config = ConfigDict(from_attributes=True)
column_name: str = Field(..., description="列名")
change_fields: list[str] = Field(default_factory=list, description="变化字段名列表")
before: dict = Field(default_factory=dict, description="同步前(当前 gen 配置)摘要")
after: dict = Field(default_factory=dict, description="同步后(来自 DB)摘要")
class GenSyncPreviewSchema(BaseModel):
"""同步数据库前的差异预览(主表 + 可选子表)。"""
model_config = ConfigDict(from_attributes=True)
table_name: str = Field(..., description="表名")
added: list[str] = Field(default_factory=list, description="新增列(DB有、gen无)")
removed: list[str] = Field(default_factory=list, description="删除列(gen有、DB无)")
changed: list[GenSyncColumnChange] = Field(default_factory=list, description="变更列(同名但属性变化)")
unchanged: int = Field(default=0, description="未变化列数(同名且关键属性一致)")
sub_table_name: str | None = Field(default=None, description="子表表名")
sub: "GenSyncPreviewSchema | None" = Field(default=None, description="子表差异(若配置了主子表)")
class GenTableQueryParam(BaseQueryParam, UserByQueryParam):
"""代码生成业务表查询参数
- 支持按`table_name`、`table_comment`进行模糊检索(由CRUD层实现like)。
- 空值将被忽略,不参与过滤。
"""
table_name: str | None = Field(None, description="表名称", json_schema_extra={"q": "like"})
table_comment: str | None = Field(None, description="表注释", json_schema_extra={"q": "like"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
File diff suppressed because it is too large Load Diff