chore: update .gitignore and enhance docstrings across multiple files

- Added *.pyc and *.pyo to .gitignore to prevent compiled Python files from being tracked.
- Improved docstrings in various modules, providing clearer descriptions of functions, parameters, and return values to enhance code readability and maintainability.
This commit is contained in:
zhangtao
2026-04-04 01:21:59 +08:00
parent dcf8844453
commit b0ae6af3b1
96 changed files with 5089 additions and 304 deletions
+2
View File
@@ -1,4 +1,6 @@
__pycache__ __pycache__
*.pyc
*.pyo
.vscode .vscode
.idea .idea
.DS_Store .DS_Store
+4
View File
@@ -61,6 +61,8 @@ def run_migrations_offline() -> None:
Calls to context.execute() here emit the given string to the Calls to context.execute() here emit the given string to the
script output. script output.
返回:
- None
""" """
url = alembic_config.get_main_option("sqlalchemy.url") url = alembic_config.get_main_option("sqlalchemy.url")
# 确保URL不为None # 确保URL不为None
@@ -84,6 +86,8 @@ def run_migrations_online() -> None:
In this scenario we need to create an Engine In this scenario we need to create an Engine
and associate a connection with the context. and associate a connection with the context.
返回:
- None
""" """
url = alembic_config.get_main_option("sqlalchemy.url") url = alembic_config.get_main_option("sqlalchemy.url")
# 确保URL不为None # 确保URL不为None
@@ -3,6 +3,10 @@ from pydantic.alias_generators import to_camel
class ImportFieldModel(BaseModel): class ImportFieldModel(BaseModel):
"""
Excel 导入时单字段映射配置(数据库列、Excel 列、默认值、是否必选等)。
"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
base_column: str | None = Field(description="数据库字段名", default=None) base_column: str | None = Field(description="数据库字段名", default=None)
@@ -42,6 +46,10 @@ class ImportFieldModel(BaseModel):
class ImportModel(BaseModel): class ImportModel(BaseModel):
"""
Excel 导入请求体:目标表、Sheet、文件名及字段映射列表。
"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
table_name: str | None = Field(description="表名", default=None) table_name: str | None = Field(description="表名", default=None)
+6
View File
@@ -29,6 +29,9 @@ async def get_monitor_cache_info_controller(
""" """
获取缓存监控统计信息 获取缓存监控统计信息
参数:
- redis (Redis): Redis 客户端对象
返回: 返回:
- JSONResponse: 包含缓存监控统计信息的JSON响应 - JSONResponse: 包含缓存监控统计信息的JSON响应
""" """
@@ -180,6 +183,9 @@ async def clear_monitor_cache_all_controller(
""" """
清除所有缓存 清除所有缓存
参数:
- redis (Redis): Redis 客户端对象
返回: 返回:
- JSONResponse: 包含清除结果的JSON响应 - JSONResponse: 包含清除结果的JSON响应
""" """
@@ -93,6 +93,18 @@ class ResourceMoveSchema(BaseModel):
@field_validator("source_path", "target_path") @field_validator("source_path", "target_path")
@classmethod @classmethod
def validate_paths(cls, value: str): def validate_paths(cls, value: str):
"""
校验移动/复制涉及的源路径与目标路径非空并去首尾空格。
参数:
- value (str): 路径字段当前值。
返回:
- str: 去空格后的路径。
异常:
- ValueError: 路径为空时抛出。
"""
if not value or len(value.strip()) == 0: if not value or len(value.strip()) == 0:
raise ValueError("路径不能为空") raise ValueError("路径不能为空")
return value.strip() return value.strip()
@@ -113,6 +125,18 @@ class ResourceRenameSchema(BaseModel):
@field_validator("old_path", "new_name") @field_validator("old_path", "new_name")
@classmethod @classmethod
def validate_inputs(cls, value: str): def validate_inputs(cls, value: str):
"""
校验重命名所需的原路径与新名称非空并去首尾空格。
参数:
- value (str): 字段当前值。
返回:
- str: 去空格后的值。
异常:
- ValueError: 值为空时抛出。
"""
if not value or len(value.strip()) == 0: if not value or len(value.strip()) == 0:
raise ValueError("参数不能为空") raise ValueError("参数不能为空")
return value.strip() return value.strip()
@@ -137,6 +161,19 @@ class ResourceCreateDirSchema(BaseModel):
@field_validator("parent_path", "dir_name") @field_validator("parent_path", "dir_name")
@classmethod @classmethod
def validate_inputs(cls, value: str, info): def validate_inputs(cls, value: str, info):
"""
校验创建目录的父路径与目录名,防止路径遍历等不安全输入。
参数:
- value (str): 当前字段值。
- info: Pydantic 校验上下文(含 `field_name`)。
返回:
- str: 规范化后的字段值。
异常:
- ValueError: 含不安全字符或目录名为空时抛出。
"""
# 对于parent_path允许为空字符串(表示根目录)或 '/',其他情况必须非空 # 对于parent_path允许为空字符串(表示根目录)或 '/',其他情况必须非空
if info.field_name == "parent_path": if info.field_name == "parent_path":
# 对于parent_path仍然严格检查路径遍历 # 对于parent_path仍然严格检查路径遍历
@@ -77,6 +77,15 @@ class ResourceService:
root_static_prefix = f"{root_prefix}{static_prefix}" if root_prefix else static_prefix root_static_prefix = f"{root_prefix}{static_prefix}" if root_prefix else static_prefix
def strip_prefix(p: str) -> str: def strip_prefix(p: str) -> str:
"""
去掉静态资源 URL 前缀,得到相对 upload 的路径片段。
参数:
- p (str): 原始路径或 URL 路径段。
返回:
- str: 去掉已知前缀后的路径。
"""
if p.startswith(root_static_prefix): if p.startswith(root_static_prefix):
return p[len(root_static_prefix) :].lstrip("/") return p[len(root_static_prefix) :].lstrip("/")
if p.startswith(static_prefix): if p.startswith(static_prefix):
@@ -560,6 +569,15 @@ class ResourceService:
if isinstance(sort_conditions, list): if isinstance(sort_conditions, list):
# 构建排序键函数 # 构建排序键函数
def sort_key(item): def sort_key(item):
"""
按多条排序条件从资源项中抽取比较键(支持时间字段转 datetime)。
参数:
- item (dict): 单条资源详情字典。
返回:
- list: 用于 `sorted` 的多字段键列表。
"""
keys = [] keys = []
for cond in sort_conditions: for cond in sort_conditions:
field = cond.get("field", "name") field = cond.get("field", "name")
@@ -797,10 +815,10 @@ class ResourceService:
批量删除文件或目录 批量删除文件或目录
参数: 参数:
- paths (List[str]): 文件或目录路径列表。 - paths (list[str]): 文件或目录路径列表。
返回: 返回:
- Dict[str, List[str]]: 包含成功删除路径和失败删除路径的字典 - dict[str, list[str]]: 键 `success` / `failed` 对应成功与失败路径列表
""" """
if not paths: if not paths:
raise CustomException(msg="删除失败,删除路径不能为空") raise CustomException(msg="删除失败,删除路径不能为空")
@@ -42,6 +42,7 @@ async def login_for_access_token_controller(
参数: 参数:
- request (Request): FastAPI请求对象 - request (Request): FastAPI请求对象
- redis (Redis): Redis 客户端对象
- login_form (CustomOAuth2PasswordRequestForm): 登录表单数据 - login_form (CustomOAuth2PasswordRequestForm): 登录表单数据
- db (AsyncSession): 数据库会话对象 - db (AsyncSession): 数据库会话对象
@@ -82,6 +83,8 @@ async def get_new_token_controller(
参数: 参数:
- request (Request): FastAPI请求对象 - request (Request): FastAPI请求对象
- payload (RefreshTokenPayloadSchema): 刷新令牌负载模型 - payload (RefreshTokenPayloadSchema): 刷新令牌负载模型
- db (AsyncSession): 数据库会话对象
- redis (Redis): Redis 客户端对象
返回: 返回:
- JWTOutSchema: 包含新的访问令牌和刷新令牌的响应模型 - JWTOutSchema: 包含新的访问令牌和刷新令牌的响应模型
@@ -25,6 +25,15 @@ class JWTPayloadSchema(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_fields(self): def validate_fields(self):
"""
校验 JWT 载荷字段的基本合法性。
返回:
- JWTPayloadSchema: 校验后的载荷实例。
异常:
- ValueError: 必填字段为空或格式不正确时抛出。
"""
if not self.sub or len(self.sub.strip()) == 0: if not self.sub or len(self.sub.strip()) == 0:
raise ValueError("会话编号不能为空") raise ValueError("会话编号不能为空")
return self return self
@@ -11,7 +11,15 @@ class DeptCRUD(CRUDBase[DeptModel, DeptCreateSchema, DeptUpdateSchema]):
"""部门模块数据层""" """部门模块数据层"""
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
"""初始化部门CRUD""" """
初始化部门数据层。
参数:
- auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。
返回:
- None
"""
self.auth = auth self.auth = auth
super().__init__(model=DeptModel, auth=auth) super().__init__(model=DeptModel, auth=auth)
@@ -22,6 +22,18 @@ class DeptCreateSchema(BaseModel):
@field_validator("name") @field_validator("name")
@classmethod @classmethod
def validate_name(cls, value: str): def validate_name(cls, value: str):
"""
校验并规范化部门名称(去空格、非空)。
参数:
- value (str): 部门名称。
返回:
- str: 规范化后的部门名称。
异常:
- ValueError: 部门名称为空时抛出。
"""
if not value or len(value.strip()) == 0: if not value or len(value.strip()) == 0:
raise ValueError("部门名称不能为空") raise ValueError("部门名称不能为空")
value = value.replace(" ", "") value = value.replace(" ", "")
@@ -30,6 +42,18 @@ class DeptCreateSchema(BaseModel):
@field_validator("code") @field_validator("code")
@classmethod @classmethod
def validate_code(cls, value: str | None): def validate_code(cls, value: str | None):
"""
校验部门编码:字母开头,仅包含字母/数字/下划线;空字符串视为 None。
参数:
- value (str | None): 部门编码。
返回:
- str | None: 规范化后的部门编码或 None。
异常:
- ValueError: 编码不满足格式要求时抛出。
"""
if value is None: if value is None:
return value return value
v = value.strip() v = value.strip()
@@ -126,7 +126,7 @@ class DeptService:
参数: 参数:
- auth (AuthSchema): 认证对象。 - auth (AuthSchema): 认证对象。
- ids (List[int]): 部门 ID 列表。 - ids (list[int]): 部门 ID 列表。
返回: 返回:
- None - None
+12 -6
View File
@@ -16,10 +16,13 @@ class DictTypeCRUD(CRUDBase[DictTypeModel, DictTypeCreateSchema, DictTypeUpdateS
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
""" """
初始化数据字典类型CRUD 初始化数据字典类型数据层。
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。
返回:
- None
""" """
self.auth = auth self.auth = auth
super().__init__(model=DictTypeModel, auth=auth) super().__init__(model=DictTypeModel, auth=auth)
@@ -119,7 +122,7 @@ class DictTypeCRUD(CRUDBase[DictTypeModel, DictTypeCreateSchema, DictTypeUpdateS
批量删除数据字典类型 批量删除数据字典类型
参数: 参数:
- ids (List[int]): 数据字典类型ID列表 - ids (list[int]): 数据字典类型ID列表
返回: 返回:
- int: 删除的记录数量 - int: 删除的记录数量
@@ -133,10 +136,13 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
""" """
初始化数据字典数据CRUD 初始化数据字典数据层。
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。
返回:
- None
""" """
self.auth = auth self.auth = auth
super().__init__(model=DictDataModel, auth=auth) super().__init__(model=DictDataModel, auth=auth)
@@ -236,7 +242,7 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS
批量删除数据字典数据 批量删除数据字典数据
参数: 参数:
- ids (List[int]): 数据字典数据ID列表 - ids (list[int]): 数据字典数据ID列表
- exclude_system (bool): 是否排除系统默认数据,默认为True - exclude_system (bool): 是否排除系统默认数据,默认为True
返回: 返回:
@@ -26,12 +26,36 @@ class DictTypeCreateSchema(BaseModel):
@field_validator("dict_name") @field_validator("dict_name")
def validate_dict_name(cls, value: str): def validate_dict_name(cls, value: str):
"""
校验字典名称为非空字符串。
参数:
- value (str): 字典名称。
返回:
- str: 去首尾空格后的字典名称。
异常:
- ValueError: 字典名称为空时抛出。
"""
if not value or value.strip() == "": if not value or value.strip() == "":
raise ValueError("字典名称不能为空") raise ValueError("字典名称不能为空")
return value.strip() return value.strip()
@field_validator("dict_type") @field_validator("dict_type")
def validate_dict_type(cls, value: str): def validate_dict_type(cls, value: str):
"""
校验字典类型:小写字母开头,仅包含小写字母/数字/下划线。
参数:
- value (str): 字典类型。
返回:
- str: 去首尾空格后的字典类型。
异常:
- ValueError: 字典类型为空或不满足格式要求时抛出。
"""
if not value or value.strip() == "": if not value or value.strip() == "":
raise ValueError("字典类型不能为空") raise ValueError("字典类型不能为空")
regexp = r"^[a-z][a-z0-9_]*$" regexp = r"^[a-z][a-z0-9_]*$"
@@ -105,6 +129,15 @@ class DictDataCreateSchema(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_after(self): def validate_after(self):
"""
校验并规范化字典数据字段(标签/键值/类型/类型ID)。
返回:
- DictDataCreateSchema: 校验与去空格后的同一实例。
异常:
- ValueError: 必填字段为空或类型ID非法时抛出。
"""
if not self.dict_label or not self.dict_label.strip(): if not self.dict_label or not self.dict_label.strip():
raise ValueError("字典标签不能为空") raise ValueError("字典标签不能为空")
if not self.dict_value or not self.dict_value.strip(): if not self.dict_value or not self.dict_value.strip():
@@ -76,7 +76,19 @@ class DictTypeService:
search: DictTypeQueryParam | None = None, search: DictTypeQueryParam | None = None,
order_by: list[dict] | None = None, order_by: list[dict] | None = None,
) -> dict: ) -> dict:
"""分页查询字典类型(数据库 OFFSET/LIMIT)。""" """
分页查询字典类型(数据库 OFFSET/LIMIT)。
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码(从 1 开始)
- page_size (int): 每页条数
- search (DictTypeQueryParam | None): 查询条件
- order_by (list[dict] | None): 排序字段列表
返回:
- dict: 分页结果(结构由 `CRUD.page` 返回约定)
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await DictTypeCRUD(auth).page( return await DictTypeCRUD(auth).page(
offset=offset, offset=offset,
@@ -336,7 +348,19 @@ class DictDataService:
search: DictDataQueryParam | None = None, search: DictDataQueryParam | None = None,
order_by: list[dict] | None = None, order_by: list[dict] | None = None,
) -> dict: ) -> dict:
"""分页查询字典数据(数据库 OFFSET/LIMIT)。""" """
分页查询字典数据(数据库 OFFSET/LIMIT)。
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码(从 1 开始)
- page_size (int): 每页条数
- search (DictDataQueryParam | None): 查询条件
- order_by (list[dict] | None): 排序字段列表
返回:
- dict: 分页结果(结构由 `CRUD.page` 返回约定)
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await DictDataCRUD(auth).page( return await DictDataCRUD(auth).page(
offset=offset, offset=offset,
+10 -4
View File
@@ -16,7 +16,13 @@ class OperationLogCRUD(
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
""" """
初始化操作日志CRUD 初始化操作日志数据层
参数:
- auth (AuthSchema): 认证信息模型 DB 会话等上下文
返回:
- None
""" """
self.auth = auth self.auth = auth
super().__init__(model=OperationLogModel, auth=auth) super().__init__(model=OperationLogModel, auth=auth)
@@ -58,9 +64,9 @@ class OperationLogCRUD(
获取操作日志列表 获取操作日志列表
参数: 参数:
- search (Dict | None): 搜索条件字典 - search (dict | None): 搜索条件字典
- order_by (List[Dict[str, str]] | None): 排序字段列表 - order_by (list | None): 排序字段列表
- preload (Optional[List[Union[str, Any]]]): 预加载关系未提供时使用模型默认项 - preload (list | None): 预加载关系未提供时使用模型默认项
返回: 返回:
- Sequence[OperationLogModel]: 操作日志列表 - Sequence[OperationLogModel]: 操作日志列表
@@ -6,12 +6,22 @@ from app.core.base_model import ModelMixin, UserMixin
def get_log_text_column_type(): def get_log_text_column_type():
"""
根据数据库类型选择适合存储大文本的列类型
MySQL 使用 LONGTEXTPostgreSQL 使用 TEXT其它数据库回退到 SQLAlchemy Text
返回:
- type: SQLAlchemy 列类型可用于 mapped_column
"""
db_type = settings.DATABASE_TYPE db_type = settings.DATABASE_TYPE
if db_type == "mysql": if db_type == "mysql":
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
return LONGTEXT return LONGTEXT
elif db_type == "postgres": elif db_type == "postgres":
from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.dialects.postgresql import TEXT
return TEXT return TEXT
else: else:
return Text return Text
@@ -65,7 +65,19 @@ class OperationLogService:
search: OperationLogQueryParam | None = None, search: OperationLogQueryParam | None = None,
order_by: list | None = None, order_by: list | None = None,
) -> dict: ) -> dict:
"""分页查询操作日志(数据库 OFFSET/LIMIT)。""" """
分页查询操作日志数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码 1 开始
- page_size (int): 每页条数
- search (OperationLogQueryParam | None): 查询条件
- order_by (list | None): 排序字段列表
返回:
- dict: 分页结果结构由 `CRUD.page` 返回约定
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await OperationLogCRUD(auth).page( return await OperationLogCRUD(auth).page(
offset=offset, offset=offset,
@@ -11,7 +11,15 @@ class MenuCRUD(CRUDBase[MenuModel, MenuCreateSchema, MenuUpdateSchema]):
"""菜单模块数据层""" """菜单模块数据层"""
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
"""初始化菜单CRUD""" """
初始化菜单数据层
参数:
- auth (AuthSchema): 认证信息模型 DB 会话等上下文
返回:
- None
"""
self.auth = auth self.auth = auth
super().__init__(model=MenuModel, auth=auth) super().__init__(model=MenuModel, auth=auth)
@@ -72,6 +72,15 @@ class MenuCreateSchema(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_fields(self): def validate_fields(self):
"""
统一校验菜单请求字段委托到 `menu_request_validator`
返回:
- MenuCreateSchema: 校验后的同一实例
异常:
- CustomException: 字段不满足菜单类型约束时抛出
"""
return menu_request_validator(self) return menu_request_validator(self)
@@ -25,6 +25,31 @@ class MenuService:
菜单模块服务层 菜单模块服务层
""" """
@classmethod
async def _validate_parent_child_type(
cls, auth: AuthSchema, parent_id: int | None, child_type: int
) -> None:
"""
父子类型约束目录下仅允许目录/菜单/外链菜单下仅允许按钮按钮与外链下不可挂子级
无父级时仅允许目录菜单外链与前端一致
"""
if parent_id is None:
if child_type not in (1, 2, 4):
raise CustomException(msg="顶级菜单仅允许目录、菜单或外链类型")
return
parent = await MenuCRUD(auth).get_by_id_crud(id=parent_id)
if not parent:
raise CustomException(msg="父级菜单不存在")
pt = parent.type
if pt == 1:
if child_type not in (1, 2, 4):
raise CustomException(msg="目录下仅允许新增目录、菜单或外链")
elif pt == 2:
if child_type != 3:
raise CustomException(msg="菜单下仅允许新增按钮")
else:
raise CustomException(msg="菜单或链接类型下不允许新增子菜单")
@classmethod @classmethod
async def get_menu_detail_service(cls, auth: AuthSchema, id: int) -> dict: async def get_menu_detail_service(cls, auth: AuthSchema, id: int) -> dict:
""" """
@@ -93,6 +118,8 @@ class MenuService:
if menu: if menu:
raise CustomException(msg="创建失败,该菜单已存在") raise CustomException(msg="创建失败,该菜单已存在")
await cls._validate_parent_child_type(auth, data.parent_id, data.type)
new_menu = await MenuCRUD(auth).create(data=data) new_menu = await MenuCRUD(auth).create(data=data)
new_menu_dict = MenuOutSchema.model_validate(new_menu).model_dump() new_menu_dict = MenuOutSchema.model_validate(new_menu).model_dump()
return new_menu_dict return new_menu_dict
@@ -113,6 +140,7 @@ class MenuService:
menu = await MenuCRUD(auth).get_by_id_crud(id=id) menu = await MenuCRUD(auth).get_by_id_crud(id=id)
if not menu: if not menu:
raise CustomException(msg="更新失败,该菜单不存在") raise CustomException(msg="更新失败,该菜单不存在")
await cls._validate_parent_child_type(auth, data.parent_id, data.type)
search: dict[str, Any] = {"title": data.title} search: dict[str, Any] = {"title": data.title}
if data.parent_id is not None: if data.parent_id is not None:
search["parent_id"] = data.parent_id search["parent_id"] = data.parent_id
@@ -15,7 +15,10 @@ class NoticeCRUD(CRUDBase[NoticeModel, NoticeCreateSchema, NoticeUpdateSchema]):
初始化公告数据层 初始化公告数据层
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型 DB 会话等上下文
返回:
- None
""" """
self.auth = auth self.auth = auth
super().__init__(model=NoticeModel, auth=auth) super().__init__(model=NoticeModel, auth=auth)
@@ -84,7 +84,19 @@ class NoticeService:
search: NoticeQueryParam | None = None, search: NoticeQueryParam | None = None,
order_by: list[dict] | None = None, order_by: list[dict] | None = None,
) -> dict: ) -> dict:
"""分页查询公告(数据库 OFFSET/LIMIT)。""" """
分页查询公告数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码 1 开始
- page_size (int): 每页条数
- search (NoticeQueryParam | None): 查询条件
- order_by (list[dict] | None): 排序字段列表
返回:
- dict: 分页结果结构由 `CRUD.page` 返回约定
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await NoticeCRUD(auth).page( return await NoticeCRUD(auth).page(
offset=offset, offset=offset,
@@ -96,7 +108,15 @@ class NoticeService:
@classmethod @classmethod
async def get_notice_available_page_service(cls, auth: AuthSchema) -> dict: async def get_notice_available_page_service(cls, auth: AuthSchema) -> dict:
"""已启用公告分页(与历史行为一致:默认第 1 页、每页 10 条)。""" """
已启用公告分页与历史行为一致固定第 1 每页 10
参数:
- auth (AuthSchema): 认证信息模型
返回:
- dict: 分页结果结构由 `CRUD.page` 返回约定
"""
return await NoticeCRUD(auth).page( return await NoticeCRUD(auth).page(
offset=0, offset=0,
limit=10, limit=10,
@@ -164,6 +184,9 @@ class NoticeService:
异常: 异常:
- CustomException: 删除失败删除对象不能为空或该公告通知不存在 - CustomException: 删除失败删除对象不能为空或该公告通知不存在
返回:
- None
""" """
if len(ids) < 1: if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空") raise CustomException(msg="删除失败,删除对象不能为空")
@@ -184,6 +207,9 @@ class NoticeService:
异常: 异常:
- CustomException: 批量设置失败该公告通知不存在 - CustomException: 批量设置失败该公告通知不存在
返回:
- None
""" """
await NoticeCRUD(auth).set_available_crud(ids=data.ids, status=data.status) await NoticeCRUD(auth).set_available_crud(ids=data.ids, status=data.status)
@@ -12,10 +12,13 @@ class ParamsCRUD(CRUDBase[ParamsModel, ParamsCreateSchema, ParamsUpdateSchema]):
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
""" """
初始化配置CRUD 初始化系统参数配置数据层
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型 DB 会话等上下文
返回:
- None
""" """
self.auth = auth self.auth = auth
super().__init__(model=ParamsModel, auth=auth) super().__init__(model=ParamsModel, auth=auth)
@@ -112,7 +112,19 @@ class ParamsService:
search: ParamsQueryParam | None = None, search: ParamsQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询参数(数据库 OFFSET/LIMIT)。""" """
分页查询系统参数数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码 1 开始
- page_size (int): 每页条数
- search (ParamsQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序字段列表
返回:
- dict: 分页结果结构由 `CRUD.page` 返回约定
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await ParamsCRUD(auth).page( return await ParamsCRUD(auth).page(
offset=offset, offset=offset,
@@ -13,10 +13,13 @@ class PositionCRUD(CRUDBase[PositionModel, PositionCreateSchema, PositionUpdateS
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
""" """
初始化岗位CRUD 初始化岗位数据层
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型 DB 会话等上下文
返回:
- None
""" """
self.auth = auth self.auth = auth
super().__init__(model=PositionModel, auth=auth) super().__init__(model=PositionModel, auth=auth)
@@ -64,7 +64,19 @@ class PositionService:
search: PositionQueryParam | None = None, search: PositionQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询岗位(数据库 OFFSET/LIMIT)。""" """
分页查询岗位数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码 1 开始
- page_size (int): 每页条数
- search (PositionQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序字段列表
返回:
- dict: 分页结果结构由 `CRUD.page` 返回约定
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await PositionCRUD(auth).page( return await PositionCRUD(auth).page(
offset=offset, offset=offset,
@@ -84,7 +96,7 @@ class PositionService:
- data (PositionCreateSchema): 岗位创建模型 - data (PositionCreateSchema): 岗位创建模型
返回: 返回:
- Dict: 创建的岗位对象 - dict: 创建的岗位详情字典
""" """
position = await PositionCRUD(auth).get(name=data.name) position = await PositionCRUD(auth).get(name=data.name)
if position: if position:
@@ -14,10 +14,13 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
""" """
初始化角色模块数据层 初始化角色数据层
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型 DB 会话等上下文
返回:
- None
""" """
self.auth = auth self.auth = auth
super().__init__(model=RoleModel, auth=auth) super().__init__(model=RoleModel, auth=auth)
@@ -59,8 +62,8 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
设置角色的菜单权限 设置角色的菜单权限
参数: 参数:
- role_ids (List[int]): 角色ID列表 - role_ids (list[int]): 角色ID列表
- menu_ids (List[int]): 菜单ID列表 - menu_ids (list[int]): 菜单ID列表
返回: 返回:
- None - None
@@ -34,6 +34,18 @@ class RoleCreateSchema(BaseModel):
@field_validator("code") @field_validator("code")
@classmethod @classmethod
def validate_code(cls, value: str | None): def validate_code(cls, value: str | None):
"""
校验角色编码委托到 `code_validator`
参数:
- value (str | None): 角色编码
返回:
- str | None: 校验后的角色编码
异常:
- CustomException: 不满足编码规则时抛出
"""
return code_validator(value) return code_validator(value)
@@ -50,7 +62,18 @@ class RolePermissionSettingSchema(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_fields(self): def validate_fields(self):
"""验证权限配置字段""" """
校验角色权限配置字段数据范围与关联 ID
参数:
- self: 当前模型实例校验后状态
返回:
- RolePermissionSettingSchema: 通过 `role_permission_request_validator` 校验后的同一实例
异常:
- CustomException: 不满足权限配置约束时抛出
"""
return role_permission_request_validator(self) return role_permission_request_validator(self)
@@ -63,7 +63,19 @@ class RoleService:
search: RoleQueryParam | None = None, search: RoleQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询角色(数据库 OFFSET/LIMIT)。""" """
分页查询角色数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码 1 开始
- page_size (int): 每页条数
- search (RoleQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序字段列表
返回:
- dict: 分页结果结构由 `CRUD.page` 返回约定
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await RoleCRUD(auth).page( return await RoleCRUD(auth).page(
offset=offset, offset=offset,
@@ -20,10 +20,13 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
""" """
初始化用户CRUD 初始化用户数据层
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型 DB 会话等上下文
返回:
- None
""" """
self.auth = auth self.auth = auth
super().__init__(model=UserModel, auth=auth) super().__init__(model=UserModel, auth=auth)
@@ -123,10 +126,10 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
参数: 参数:
- ids (list[int]): 用户ID列表 - ids (list[int]): 用户ID列表
- status (bool): 可用状态 - status (str): 可用状态与表字段一致 "0"/"1"
返回: 返回:
- None: - None
""" """
await self.set(ids=ids, status=status) await self.set(ids=ids, status=status)
@@ -139,7 +142,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
- role_ids (list[int]): 角色ID列表 - role_ids (list[int]): 角色ID列表
返回: 返回:
- None: - None
""" """
user_objs = await self.list(search={"id": ("in", user_ids)}) user_objs = await self.list(search={"id": ("in", user_ids)})
if role_ids: if role_ids:
@@ -162,7 +165,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
- position_ids (list[int]): 岗位ID列表 - position_ids (list[int]): 岗位ID列表
返回: 返回:
- None: - None
""" """
user_objs = await self.list(search={"id": ("in", user_ids)}) user_objs = await self.list(search={"id": ("in", user_ids)})
if position_ids: if position_ids:
@@ -29,11 +29,35 @@ class CurrentUserUpdateSchema(BaseModel):
@field_validator("mobile") @field_validator("mobile")
@classmethod @classmethod
def validate_mobile(cls, value: str | None): def validate_mobile(cls, value: str | None):
"""
校验手机号格式委托到 `mobile_validator`
参数:
- value (str | None): 手机号
返回:
- str | None: 校验后的手机号
异常:
- CustomException: 手机号格式非法时抛出
"""
return mobile_validator(value) return mobile_validator(value)
@field_validator("email") @field_validator("email")
@classmethod @classmethod
def validate_email(cls, value: str | None): def validate_email(cls, value: str | None):
"""
校验邮箱格式为空则跳过否则委托到 `email_validator`
参数:
- value (str | None): 邮箱
返回:
- str | None: 校验后的邮箱
异常:
- CustomException: 邮箱格式非法时抛出
"""
if not value: if not value:
return value return value
return email_validator(value) return email_validator(value)
@@ -41,6 +65,18 @@ class CurrentUserUpdateSchema(BaseModel):
@field_validator("avatar") @field_validator("avatar")
@classmethod @classmethod
def validate_avatar(cls, value: str | None): def validate_avatar(cls, value: str | None):
"""
校验头像地址为合法的 HTTP/HTTPS URL
参数:
- value (str | None): 头像 URL
返回:
- str | None: 校验后的头像 URL
异常:
- ValueError: 头像 URL 非法时抛出
"""
if not value: if not value:
return value return value
parsed = urlparse(value) parsed = urlparse(value)
@@ -50,6 +86,15 @@ class CurrentUserUpdateSchema(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def check_model(self): def check_model(self):
"""
校验基础用户信息的长度约束
返回:
- CurrentUserUpdateSchema: 校验后的同一实例
异常:
- ValueError: 字段长度超限时抛出
"""
if self.name and len(self.name) > 32: if self.name and len(self.name) > 32:
raise ValueError("名称长度不能超过32个字符") raise ValueError("名称长度不能超过32个字符")
return self return self
@@ -69,11 +114,35 @@ class UserRegisterSchema(BaseModel):
@field_validator("mobile") @field_validator("mobile")
@classmethod @classmethod
def validate_mobile(cls, value: str | None): def validate_mobile(cls, value: str | None):
"""
校验手机号格式委托到 `mobile_validator`
参数:
- value (str | None): 手机号
返回:
- str | None: 校验后的手机号
异常:
- CustomException: 手机号格式非法时抛出
"""
return mobile_validator(value) return mobile_validator(value)
@field_validator("username") @field_validator("username")
@classmethod @classmethod
def validate_username(cls, value: str): def validate_username(cls, value: str):
"""
校验并规范化账号字母开头长度 3-32仅含字母/数字/_ . -
参数:
- value (str): 账号
返回:
- str: 规范化后的账号
异常:
- ValueError: 账号为空或不满足格式约束时抛出
"""
v = value.strip() v = value.strip()
if not v: if not v:
raise ValueError("账号不能为空") raise ValueError("账号不能为空")
@@ -86,6 +155,15 @@ class UserRegisterSchema(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def check_model(self): def check_model(self):
"""
校验注册信息的长度约束
返回:
- UserRegisterSchema: 校验后的同一实例
异常:
- ValueError: 任一字段长度超限时抛出
"""
if self.name and len(self.name) > 32: if self.name and len(self.name) > 32:
raise ValueError("名称长度不能超过32个字符") raise ValueError("名称长度不能超过32个字符")
if self.username and len(self.username) > 32: if self.username and len(self.username) > 32:
@@ -107,6 +185,18 @@ class UserForgetPasswordSchema(BaseModel):
@field_validator("mobile") @field_validator("mobile")
@classmethod @classmethod
def validate_mobile(cls, value: str | None): def validate_mobile(cls, value: str | None):
"""
校验手机号格式委托到 `mobile_validator`
参数:
- value (str | None): 手机号
返回:
- str | None: 校验后的手机号
异常:
- CustomException: 手机号格式非法时抛出
"""
return mobile_validator(value) return mobile_validator(value)
@@ -95,7 +95,19 @@ class UserService:
search: UserQueryParam | None = None, search: UserQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询用户(数据库 OFFSET/LIMIT)。""" """
分页查询用户数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码 1 开始
- page_size (int): 每页条数
- search (UserQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序字段列表
返回:
- dict: 分页结果结构由 `CRUD.page` 返回约定
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await UserCRUD(auth).page( return await UserCRUD(auth).page(
offset=offset, offset=offset,
@@ -677,7 +689,7 @@ class UserService:
导出用户列表为Excel文件 导出用户列表为Excel文件
参数: 参数:
- user_list (List[Dict[str, Any]]): 用户列表 - user_list (list[dict[str, Any]]): 用户列表
返回: 返回:
- bytes: Excel文件字节流 - bytes: Excel文件字节流
+16 -2
View File
@@ -193,12 +193,22 @@ class RET(Enum):
@property @property
def code(self) -> int: def code(self) -> int:
"""获取错误码""" """
获取错误码
返回:
- int: 错误码数值
"""
return self._code return self._code
@property @property
def msg(self) -> str: def msg(self) -> str:
"""获取错误信息""" """
获取错误信息
返回:
- str: 错误信息文本
"""
return self._msg return self._msg
@@ -766,6 +776,10 @@ class GenConstant:
class TypedContextProtocol(Protocol): class TypedContextProtocol(Protocol):
"""
请求上下文中与日志/鉴权相关的结构化字段协议供类型检查使用
"""
perf_time: float perf_time: float
ip: str ip: str
+18
View File
@@ -6,6 +6,8 @@ from fastapi import Response
@dataclasses.dataclass @dataclasses.dataclass
class IpInfo: class IpInfo:
"""IP 归属地解析结果。"""
ip: str ip: str
country: str | None country: str | None
region: str | None region: str | None
@@ -14,6 +16,8 @@ class IpInfo:
@dataclasses.dataclass @dataclasses.dataclass
class UserAgentInfo: class UserAgentInfo:
"""User-Agent 解析结果(操作系统、浏览器、设备)。"""
user_agent: str user_agent: str
os: str | None os: str | None
browser: str | None browser: str | None
@@ -22,6 +26,8 @@ class UserAgentInfo:
@dataclasses.dataclass @dataclasses.dataclass
class RequestCallNext: class RequestCallNext:
"""请求链路 call_next 封装结果(状态码、消息、异常、响应)。"""
code: str code: str
msg: str msg: str
err: Exception | None err: Exception | None
@@ -30,6 +36,8 @@ class RequestCallNext:
@dataclasses.dataclass @dataclasses.dataclass
class AccessToken: class AccessToken:
"""访问令牌及过期时间、会话 UUID。"""
access_token: str access_token: str
access_token_expire_time: datetime access_token_expire_time: datetime
session_uuid: str session_uuid: str
@@ -37,12 +45,16 @@ class AccessToken:
@dataclasses.dataclass @dataclasses.dataclass
class RefreshToken: class RefreshToken:
"""刷新令牌及过期时间。"""
refresh_token: str refresh_token: str
refresh_token_expire_time: datetime refresh_token_expire_time: datetime
@dataclasses.dataclass @dataclasses.dataclass
class NewToken: class NewToken:
"""刷新后的一对访问/刷新令牌及会话 UUID。"""
new_access_token: str new_access_token: str
new_access_token_expire_time: datetime new_access_token_expire_time: datetime
new_refresh_token: str new_refresh_token: str
@@ -52,6 +64,8 @@ class NewToken:
@dataclasses.dataclass @dataclasses.dataclass
class TokenPayload: class TokenPayload:
"""JWT/会话载荷中的用户与会话标识。"""
id: int id: int
session_uuid: str session_uuid: str
expire_time: datetime expire_time: datetime
@@ -59,11 +73,15 @@ class TokenPayload:
@dataclasses.dataclass @dataclasses.dataclass
class UploadUrl: class UploadUrl:
"""上传完成后的访问 URL。"""
url: str url: str
@dataclasses.dataclass @dataclasses.dataclass
class SnowflakeInfo: class SnowflakeInfo:
"""雪花 ID 拆解后的各段信息。"""
timestamp: int timestamp: int
datetime: str datetime: str
cluster_id: int cluster_id: int
+14 -2
View File
@@ -3,6 +3,8 @@ from enum import Enum, unique
@unique @unique
class EnvironmentEnum(str, Enum): class EnvironmentEnum(str, Enum):
"""应用运行环境(开发 / 生产)。"""
DEV = "dev" DEV = "dev"
PROD = "prod" PROD = "prod"
@@ -52,12 +54,22 @@ class RedisInitKeyConfig(Enum):
@property @property
def key(self) -> str: def key(self) -> str:
"""获取Redis键名""" """
获取 Redis 键名
返回:
- str: 键名字符串
"""
return self.value.get("key", "") return self.value.get("key", "")
@property @property
def remark(self) -> str: def remark(self) -> str:
"""获取Redis键名说明""" """
获取 Redis 键说明
返回:
- str: 说明文案
"""
return self.value.get("remark", "") return self.value.get("remark", "")
+48 -7
View File
@@ -197,7 +197,12 @@ class Settings(BaseSettings):
# ================================================= # # ================================================= #
@property @property
def MIDDLEWARE_LIST(self) -> list[str | None]: def MIDDLEWARE_LIST(self) -> list[str | None]:
"""获取项目根目录""" """
根据开关组装的中间件类路径列表未启用的项为 None
返回:
- list[str | None]: 中间件 import 路径或 None
"""
# 中间件列表 # 中间件列表
MIDDLEWARES: list[str | None] = [ MIDDLEWARES: list[str | None] = [
"app.core.middlewares.CustomCORSMiddleware" if self.CORS_ORIGIN_ENABLE else None, "app.core.middlewares.CustomCORSMiddleware" if self.CORS_ORIGIN_ENABLE else None,
@@ -208,7 +213,12 @@ class Settings(BaseSettings):
@property @property
def EVENT_LIST(self) -> list[str | None]: def EVENT_LIST(self) -> list[str | None]:
"""获取事件列表""" """
应用启动时加载的全局异步事件模块路径列表
返回:
- list[str | None]: 事件模块路径或 None
"""
EVENTS: list[str | None] = [ EVENTS: list[str | None] = [
"app.core.database.redis_connect" if self.REDIS_ENABLE else None, "app.core.database.redis_connect" if self.REDIS_ENABLE else None,
] ]
@@ -216,7 +226,15 @@ class Settings(BaseSettings):
@property @property
def ASYNC_DB_URI(self) -> str: def ASYNC_DB_URI(self) -> str:
"""获取异步数据库连接""" """
异步 SQLAlchemy 数据库 URL
返回:
- str: 异步驱动连接串
异常:
- ValueError: 数据库类型不支持时抛出
"""
if self.DATABASE_TYPE not in ("mysql", "postgres", "sqlite"): if self.DATABASE_TYPE not in ("mysql", "postgres", "sqlite"):
raise ValueError( raise ValueError(
f"数据库驱动不支持: {self.DATABASE_TYPE}, 异步数据库请选择 mysql、postgres、sqlite" f"数据库驱动不支持: {self.DATABASE_TYPE}, 异步数据库请选择 mysql、postgres、sqlite"
@@ -232,7 +250,15 @@ class Settings(BaseSettings):
@property @property
def DB_URI(self) -> str: def DB_URI(self) -> str:
"""获取同步数据库连接""" """
同步 SQLAlchemy 数据库 URL
返回:
- str: 同步驱动连接串
异常:
- ValueError: 数据库类型不支持时抛出
"""
if self.DATABASE_TYPE not in ("mysql", "postgres", "sqlite"): if self.DATABASE_TYPE not in ("mysql", "postgres", "sqlite"):
raise ValueError( raise ValueError(
f"数据库驱动不支持: {self.DATABASE_TYPE}, 同步数据库请选择 mysql、postgres、sqlite" f"数据库驱动不支持: {self.DATABASE_TYPE}, 同步数据库请选择 mysql、postgres、sqlite"
@@ -248,12 +274,22 @@ class Settings(BaseSettings):
@property @property
def REDIS_URI(self) -> str: def REDIS_URI(self) -> str:
"""获取Redis连接""" """
Redis 连接 URL
返回:
- str: redis:// 连接串
"""
return f"redis://{self.REDIS_USER}:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB_NAME}" return f"redis://{self.REDIS_USER}:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB_NAME}"
@property @property
def FASTAPI_CONFIG(self) -> dict[str, Any]: def FASTAPI_CONFIG(self) -> dict[str, Any]:
"""获取FastAPI应用属性""" """
创建 FastAPI 应用实例时使用的关键字参数子集
返回:
- dict[str, Any]: debugtitleresponses 等配置
"""
return { return {
"debug": self.DEBUG, "debug": self.DEBUG,
"title": self.TITLE, "title": self.TITLE,
@@ -277,7 +313,12 @@ class Settings(BaseSettings):
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:
"""获取配置实例""" """
获取全局 Settings 单例lru_cache 缓存
返回:
- Settings: 配置实例
"""
return Settings() return Settings()
+209 -21
View File
@@ -86,8 +86,13 @@ class SchedulerUtil:
@classmethod @classmethod
def scheduler_event_listener(cls, event: JobEvent | JobExecutionEvent) -> None: def scheduler_event_listener(cls, event: JobEvent | JobExecutionEvent) -> None:
""" """
监听任务执行事件记录执行日志 监听任务执行事件记录执行日志每次执行新建日志行保留历史
每次执行都创建新记录保留所有历史执行记录
参数:
- event (JobEvent | JobExecutionEvent): APScheduler 事件对象
返回:
- None
""" """
try: try:
# 事件处理器映射 # 事件处理器映射
@@ -628,7 +633,13 @@ class SchedulerUtil:
@classmethod @classmethod
async def init_scheduler(cls, redis: Redis | None = None) -> None: async def init_scheduler(cls, redis: Redis | None = None) -> None:
""" """
应用启动时初始化定时任务 应用启动时初始化定时任务调度器
参数:
- redis (Redis | None): 可选 Redis 实例供任务侧使用
返回:
- None
""" """
if redis: if redis:
cls.redis_instance = redis cls.redis_instance = redis
@@ -646,6 +657,12 @@ class SchedulerUtil:
import types import types
def run_sync_handler(): def run_sync_handler():
"""
在独立模块命名空间中执行代码块并调用 handler
返回:
- Any: handler 返回值无代码块时为 None
"""
if not code_block: if not code_block:
return None return None
@@ -683,6 +700,15 @@ class SchedulerUtil:
state = job.__getstate__() state = job.__getstate__()
def serialize_value(obj): def serialize_value(obj):
"""
job state 中的嵌套对象转为可 JSON 化的 Python 结构
参数:
- obj (Any): 任意嵌套对象
返回:
- Any: 标量dictlist 或简化后的描述
"""
if obj is None: if obj is None:
return None return None
if isinstance(obj, (str, int, float, bool)): if isinstance(obj, (str, int, float, bool)):
@@ -728,6 +754,15 @@ class SchedulerUtil:
return None return None
def serialize_value(obj: Any) -> Any: def serialize_value(obj: Any) -> Any:
"""
递归反序列化 BLOB 中的嵌套结构为可 JSON 化数据
参数:
- obj (Any): 节点对象
返回:
- Any: 标量dictlist 或字符串化结果
"""
if obj is None: if obj is None:
return None return None
if isinstance(obj, (str, int, float, bool)): if isinstance(obj, (str, int, float, bool)):
@@ -954,7 +989,13 @@ class SchedulerUtil:
@classmethod @classmethod
def get_job_status(cls, job_id: str | int) -> str: def get_job_status(cls, job_id: str | int) -> str:
""" """
获取单个任务的当前状态 获取单个任务的当前状态文案
参数:
- job_id (str | int): 调度器任务 ID
返回:
- str: 运行中 / 暂停中 / 已停止 / 未知
""" """
job = cls.get_job(job_id=str(job_id)) job = cls.get_job(job_id=str(job_id))
if not job: if not job:
@@ -972,7 +1013,13 @@ class SchedulerUtil:
@classmethod @classmethod
def add_and_run_job_now(cls, job_info: NodeModel) -> Job: def add_and_run_job_now(cls, job_info: NodeModel) -> Job:
""" """
立即执行任务添加到调度器并立即运行 立即执行任务加入调度器并尽快触发一次
参数:
- job_info (NodeModel): 节点/任务配置
返回:
- Job: APScheduler Job 对象
""" """
# 使用稍微延迟的时间,确保事件监听器能够捕获事件 # 使用稍微延迟的时间,确保事件监听器能够捕获事件
from datetime import timedelta from datetime import timedelta
@@ -990,13 +1037,16 @@ class SchedulerUtil:
end_date: str | None = None, end_date: str | None = None,
) -> Job: ) -> Job:
""" """
创建Cron定时任务 创建 Cron 定时任务
参数: 参数:
- job_info: 任务信息 - job_info (NodeModel): 任务信息
- trigger_args: Cron表达式 - trigger_args (str | None): Cron 表达式默认取节点配置
- start_date: 开始时间 - start_date (str | None): 开始时间
- end_date: 结束时间 - end_date (str | None): 结束时间
返回:
- Job: 已注册的 APScheduler Job
""" """
cron_expr = trigger_args or job_info.trigger_args cron_expr = trigger_args or job_info.trigger_args
if not cron_expr: if not cron_expr:
@@ -1040,13 +1090,16 @@ class SchedulerUtil:
end_date: str | None = None, end_date: str | None = None,
) -> Job: ) -> Job:
""" """
创建间隔执行任务 创建间隔执行任务
参数: 参数:
- job_info: 任务信息 - job_info (NodeModel): 任务信息
- trigger_args: 间隔参数 ( ) - trigger_args (str | None): 间隔参数 默认取节点配置
- start_date: 开始时间 - start_date (str | None): 开始时间
- end_date: 结束时间 - end_date (str | None): 结束时间
返回:
- Job: 已注册的 APScheduler Job
""" """
interval_args = trigger_args or job_info.trigger_args interval_args = trigger_args or job_info.trigger_args
if not interval_args: if not interval_args:
@@ -1074,11 +1127,14 @@ class SchedulerUtil:
@classmethod @classmethod
def add_date_job(cls, job_info: NodeModel, run_date: str | None = None) -> Job: def add_date_job(cls, job_info: NodeModel, run_date: str | None = None) -> Job:
""" """
创建指定时执行任务 创建指定时执行一次的任务
参数: 参数:
- job_info: 任务信息 - job_info (NodeModel): 任务信息
- run_date: 执行时间 - run_date (str | None): 执行时间字符串默认取节点 trigger 配置
返回:
- Job: 已注册的 APScheduler Job
""" """
date_str = run_date or job_info.trigger_args date_str = run_date or job_info.trigger_args
if not date_str: if not date_str:
@@ -1151,30 +1207,83 @@ class SchedulerUtil:
@classmethod @classmethod
def start(cls, paused: bool = False) -> None: def start(cls, paused: bool = False) -> None:
"""
启动全局调度器
参数:
- paused (bool): 是否以暂停状态启动
返回:
- None
"""
scheduler.start(paused=paused) scheduler.start(paused=paused)
@classmethod @classmethod
async def shutdown(cls, wait: bool = False): async def shutdown(cls, wait: bool = False):
"""
关闭调度器
参数:
- wait (bool): 是否等待当前任务结束
返回:
- APScheduler shutdown 返回值一致
"""
return scheduler.shutdown(wait=wait) return scheduler.shutdown(wait=wait)
@classmethod @classmethod
def configure(cls, gconfig: dict | None = None, prefix: str = "apscheduler.", **options) -> None: def configure(cls, gconfig: dict | None = None, prefix: str = "apscheduler.", **options) -> None:
"""
透传配置底层 APScheduler
参数:
- gconfig (dict | None): 全局配置字典
- prefix (str): 配置键前缀
- **options: 其它 configure 关键字参数
返回:
- None
"""
scheduler.configure(gconfig or {}, prefix, **options) scheduler.configure(gconfig or {}, prefix, **options)
@classmethod @classmethod
def pause(cls) -> None: def pause(cls) -> None:
"""
暂停调度器
返回:
- None
"""
scheduler.pause() scheduler.pause()
@classmethod @classmethod
def resume(cls) -> None: def resume(cls) -> None:
"""
恢复调度器
返回:
- None
"""
scheduler.resume() scheduler.resume()
@classmethod @classmethod
def is_running(cls) -> bool: def is_running(cls) -> bool:
"""
调度器是否处于运行态
返回:
- bool: 是否 running
"""
return scheduler.running return scheduler.running
@classmethod @classmethod
def get_scheduler_state(cls) -> str: def get_scheduler_state(cls) -> str:
"""
将调度器内部 state 码映射为中文状态
返回:
- str: 停止 / 运行中 / 暂停 / 未知
"""
if scheduler.state == 0: if scheduler.state == 0:
return "停止" return "停止"
if scheduler.state == 1: if scheduler.state == 1:
@@ -1185,22 +1294,63 @@ class SchedulerUtil:
@classmethod @classmethod
def get_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None: def get_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
"""
ID 获取单个任务
参数:
- job_id (str | int): 任务 ID
- jobstore (str | None): 存储器别名
返回:
- Job | None: 任务对象或不存在
"""
return scheduler.get_job(str(job_id), jobstore) return scheduler.get_job(str(job_id), jobstore)
@classmethod @classmethod
def get_jobs(cls, jobstore: str | None = None) -> list[Job]: def get_jobs(cls, jobstore: str | None = None) -> list[Job]:
"""
列出指定存储器中的任务
参数:
- jobstore (str | None): 存储器别名None 表示默认存储
返回:
- list[Job]: 任务列表
"""
return scheduler.get_jobs(jobstore) return scheduler.get_jobs(jobstore)
@classmethod @classmethod
def get_all_jobs(cls) -> list[Job]: def get_all_jobs(cls) -> list[Job]:
"""
列出所有存储器中的任务
返回:
- list[Job]: 任务列表
"""
return scheduler.get_jobs() return scheduler.get_jobs()
@classmethod @classmethod
def remove_job(cls, job_id: str | int, jobstore: str | None = None) -> None: def remove_job(cls, job_id: str | int, jobstore: str | None = None) -> None:
"""
从调度器移除指定任务
参数:
- job_id (str | int): 任务 ID
- jobstore (str | None): 存储器别名
返回:
- None
"""
scheduler.remove_job(str(job_id), jobstore) scheduler.remove_job(str(job_id), jobstore)
@classmethod @classmethod
def clear_jobs(cls) -> None: def clear_jobs(cls) -> None:
"""
移除所有存储器中的全部任务
返回:
- None
"""
scheduler.remove_all_jobs() scheduler.remove_all_jobs()
@classmethod @classmethod
@@ -1259,23 +1409,61 @@ class SchedulerUtil:
@classmethod @classmethod
def pause_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None: def pause_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
"""
暂停单个任务
参数:
- job_id (str | int): 任务 ID
- jobstore (str | None): 存储器别名
返回:
- Job | None: 暂停后的 Job None
"""
return scheduler.pause_job(str(job_id), jobstore) return scheduler.pause_job(str(job_id), jobstore)
@classmethod @classmethod
def resume_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None: def resume_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
"""
恢复单个任务
参数:
- job_id (str | int): 任务 ID
- jobstore (str | None): 存储器别名
返回:
- Job | None: 恢复后的 Job None
"""
return scheduler.resume_job(str(job_id), jobstore) return scheduler.resume_job(str(job_id), jobstore)
@classmethod @classmethod
def modify_job(cls, job_id: str | int, jobstore: str | None = None, **changes) -> Job | None: def modify_job(cls, job_id: str | int, jobstore: str | None = None, **changes) -> Job | None:
"""
修改已存在任务的属性
参数:
- job_id (str | int): 任务 ID
- jobstore (str | None): 存储器别名
- **changes: 传给 modify_job 的变更字段
返回:
- Job | None: 修改后的 Job None
"""
return scheduler.modify_job(str(job_id), jobstore, **changes) return scheduler.modify_job(str(job_id), jobstore, **changes)
@classmethod @classmethod
def run_job_now(cls, job_id: str | int, jobstore: str | None = None) -> Job | None: def run_job_now(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
""" """
立即执行任务 立即执行任务通过临时 Job不修改原任务 trigger
注意为了不改变原任务的触发器配置我们创建一个新的临时任务来执行 参数:
而不是修改原任务 trigger - job_id (str | int): 原任务 ID
- jobstore (str | None): 存储器别名
返回:
- Job | None: 临时任务对象原任务不存在时为 None
注意:
- 不改变原任务的触发器配置仅追加一次性执行
""" """
job = cls.get_job(job_id=job_id, jobstore=jobstore) job = cls.get_job(job_id=job_id, jobstore=jobstore)
if not job: if not job:
+9
View File
@@ -299,6 +299,9 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
参数: 参数:
- ids (List[int]): 对象ID列表 - ids (List[int]): 对象ID列表
返回:
- None
异常: 异常:
- CustomException: 删除失败时抛出异常 - CustomException: 删除失败时抛出异常
""" """
@@ -321,6 +324,9 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
""" """
清空对象表 清空对象表
返回:
- None
异常: 异常:
- CustomException: 清空失败时抛出异常 - CustomException: 清空失败时抛出异常
""" """
@@ -339,6 +345,9 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
- ids (List[int]): 对象ID列表 - ids (List[int]): 对象ID列表
- **kwargs: 更新的属性及值 - **kwargs: 更新的属性及值
返回:
- None
异常: 异常:
- CustomException: 更新失败时抛出异常 - CustomException: 更新失败时抛出异常
""" """
+8 -2
View File
@@ -140,7 +140,10 @@ class UserMixin(MappedBase):
@declared_attr @declared_attr
def created_by(self) -> Mapped[Optional["UserModel"]]: def created_by(self) -> Mapped[Optional["UserModel"]]:
""" """
创建人关联关系延迟加载避免循环依赖 创建人关联关系延迟加载避免循环依赖
返回:
- Mapped[Optional[UserModel]]: 创建人 ORM 关系
""" """
return relationship( return relationship(
"UserModel", "UserModel",
@@ -152,7 +155,10 @@ class UserMixin(MappedBase):
@declared_attr @declared_attr
def updated_by(self) -> Mapped[Optional["UserModel"]]: def updated_by(self) -> Mapped[Optional["UserModel"]]:
""" """
更新人关联关系延迟加载避免循环依赖 更新人关联关系延迟加载避免循环依赖
返回:
- Mapped[Optional[UserModel]]: 更新人 ORM 关系
""" """
return relationship( return relationship(
"UserModel", "UserModel",
+15 -2
View File
@@ -56,6 +56,9 @@ def create_async_engine_and_session(
""" """
获取异步数据库会话连接 获取异步数据库会话连接
参数:
- db_url (str): 异步数据库 URL默认取配置项 ASYNC_DB_URI
返回: 返回:
- tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: 异步数据库引擎和会话工厂 - tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: 异步数据库引擎和会话工厂
""" """
@@ -108,13 +111,23 @@ async_engine, async_db_session = create_async_engine_and_session(settings.ASYNC_
async def create_tables() -> None: async def create_tables() -> None:
"""创建数据库表""" """
创建数据库表根据 ORM metadata
返回:
- None
"""
async with async_engine.begin() as coon: async with async_engine.begin() as coon:
await coon.run_sync(MappedBase.metadata.create_all) await coon.run_sync(MappedBase.metadata.create_all)
async def drop_tables() -> None: async def drop_tables() -> None:
"""删除数据库表""" """
删除数据库表根据 ORM metadata
返回:
- None
"""
async with async_engine.begin() as conn: async with async_engine.begin() as conn:
await conn.run_sync(MappedBase.metadata.drop_all) await conn.run_sync(MappedBase.metadata.drop_all)
+9 -7
View File
@@ -55,15 +55,17 @@ def get_custom_ui_html(
] = "https://fastapi.tiangolo.com/img/favicon.png", ] = "https://fastapi.tiangolo.com/img/favicon.png",
) -> HTMLResponse: ) -> HTMLResponse:
""" """
Generate and return the HTML that loads Swagger UI for the interactive 生成加载 Swagger UI HTML 页面 FastAPI 默认 `/docs` 行为一致可自定义静态资源 URL
API docs (normally served at `/docs`).
You would only call this function yourself if you needed to override some parts, 参数:
for example the URLs to use to load Swagger UI's JavaScript and CSS. - openapi_url (str): OpenAPI JSON 地址
- title (str): 页面标题
- swagger_js_url (str): Swagger UI JS 地址
- swagger_css_url (str): Swagger UI CSS 地址
- swagger_favicon_url (str): 站点图标地址
Read more about it in the 返回:
[FastAPI docs for Configure Swagger UI](https://fastapi.tiangolo.com/how-to/configure-swagger-ui/) - HTMLResponse: 可直接返回给浏览器的 HTML 响应
and the [FastAPI docs for Custom Docs UI Static Assets (Self-Hosting)](https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/).
""" """
html = f""" html = f"""
<!DOCTYPE html> <!DOCTYPE html>
+15 -9
View File
@@ -9,12 +9,15 @@ from app.core.exceptions import CustomException
def http_limit_callback(request: Request, response: Response, expire: int) -> NoReturn: def http_limit_callback(request: Request, response: Response, expire: int) -> NoReturn:
""" """
请求限制时的默认回调函数 HTTP 触发限流时的默认回调抛出 429
:param request: FastAPI 请求对象 参数:
:param response: FastAPI 响应对象 - request (Request): 当前请求
:param expire: 剩余毫秒数 - response (Response): 当前响应未直接使用保留与限流器签名一致
:return: - expire (int): 剩余冷却毫秒数
返回:
- 始终抛出 CustomException
""" """
expires = ceil(expire / 30) expires = ceil(expire / 30)
raise CustomException( raise CustomException(
@@ -26,11 +29,14 @@ def http_limit_callback(request: Request, response: Response, expire: int) -> No
async def ws_limit_callback(ws: WebSocket, expire: int) -> None: async def ws_limit_callback(ws: WebSocket, expire: int) -> None:
""" """
WebSocket请求限制时的默认回调函数 WebSocket 触发限流时的默认回调关闭连接
:param ws: WebSocket连接对象 参数:
:param expire: 剩余毫秒数 - ws (WebSocket): 当前 WebSocket
:return: - expire (int): 剩余冷却毫秒数
返回:
- None
""" """
expires = ceil(expire / 30) expires = ceil(expire / 30)
await ws.close(code=1008, reason=f"请求过于频繁,请稍后重试!{expires} 秒后重试") await ws.close(code=1008, reason=f"请求过于频繁,请稍后重试!{expires} 秒后重试")
+16 -7
View File
@@ -24,6 +24,15 @@ class InterceptHandler(logging.Handler):
@override @override
def emit(self, record: logging.LogRecord) -> None: def emit(self, record: logging.LogRecord) -> None:
"""
将标准库 LogRecord 转发到 Loguru
参数:
- record (logging.LogRecord): 标准库日志记录
返回:
- None
"""
# 尝试获取日志级别名称 # 尝试获取日志级别名称
try: try:
level = logger.level(record.levelname).name level = logger.level(record.levelname).name
@@ -42,8 +51,10 @@ class InterceptHandler(logging.Handler):
def cleanup_logging() -> None: def cleanup_logging() -> None:
""" """
清理日志资源 清理日志资源在程序退出时调用移除已注册的 Loguru 处理器
在程序退出时调用,确保所有日志处理器被正确关闭
返回:
- None
""" """
global _logger_handlers global _logger_handlers
@@ -59,12 +70,10 @@ def cleanup_logging() -> None:
def setup_logging() -> None: def setup_logging() -> None:
""" """
配置日志系统 配置日志系统控制台彩色输出文件轮转错误日志分文件
功能 返回:
1. 控制台彩色输出 - None
2. 文件日志轮转
3. 错误日志单独存储
""" """
global _logger_handlers global _logger_handlers
+10
View File
@@ -85,6 +85,16 @@ class RequestLogMiddleware(BaseHTTPMiddleware):
return None return None
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
"""
记录请求日志并透传响应
参数:
- request (Request): 当前请求
- call_next (RequestResponseEndpoint): 下一层 ASGI 可调用对象
返回:
- Response: 下游中间件/路由产生的响应
"""
start_time = time.time() start_time = time.time()
# 尝试提取session_id # 尝试提取session_id
+5 -5
View File
@@ -40,13 +40,13 @@ class Permission:
async def filter_query(self, query: Any) -> Any: async def filter_query(self, query: Any) -> Any:
""" """
异步过滤查询对象 按数据权限为 SQLAlchemy 查询追加 WHERE 条件
Args: 参数:
query: SQLAlchemy查询对象 - query (Any): SQLAlchemy 查询对象
Returns: 返回:
过滤后的查询对象 - Any: 附加条件后的查询对象无权限条件时原样返回
""" """
condition = await self.__permission_condition() condition = await self.__permission_condition()
return query.where(condition) if condition is not None else query return query.where(condition) if condition is not None else query
+47 -5
View File
@@ -47,7 +47,15 @@ class ChatSessionCRUD:
self.db.close() self.db.close()
async def get_by_id_crud(self, session_id: str) -> TeamSession | None: async def get_by_id_crud(self, session_id: str) -> TeamSession | None:
"""获取会话详情""" """
获取会话详情
参数:
- session_id (str): 会话 ID
返回:
- TeamSession | None: 会话对象失败或不存在时为 None
"""
try: try:
return self.db.get_session( return self.db.get_session(
session_id=session_id, session_id=session_id,
@@ -63,7 +71,16 @@ class ChatSessionCRUD:
search: dict[str, Any] | None = None, search: dict[str, Any] | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> list[TeamSession]: ) -> list[TeamSession]:
"""列表查询 - 获取所有会话""" """
列表查询获取当前用户的所有会话
参数:
- search (dict[str, Any] | None): 预留查询条件当前实现未使用
- order_by (list[dict[str, str]] | None): 预留排序当前实现未使用
返回:
- list[TeamSession]: 会话列表失败时为空列表
"""
try: try:
result = self.db.get_sessions( result = self.db.get_sessions(
session_type=self.SESSION_TYPE, session_type=self.SESSION_TYPE,
@@ -77,7 +94,15 @@ class ChatSessionCRUD:
return [] return []
async def create_crud(self, data: ChatSessionCreateSchema) -> TeamSession | None: async def create_crud(self, data: ChatSessionCreateSchema) -> TeamSession | None:
"""创建会话 - Team 会在运行时自动创建和管理 session""" """
创建会话Team 在运行时自动创建并管理 session
参数:
- data (ChatSessionCreateSchema): 创建参数如标题
返回:
- TeamSession | None: 新建会话失败时为 None
"""
import time import time
import uuid import uuid
@@ -108,7 +133,16 @@ class ChatSessionCRUD:
return None return None
async def update_crud(self, session_id: str, data: ChatSessionUpdateSchema) -> bool: async def update_crud(self, session_id: str, data: ChatSessionUpdateSchema) -> bool:
"""更新会话""" """
更新会话如重命名
参数:
- session_id (str): 会话 ID
- data (ChatSessionUpdateSchema): 更新数据
返回:
- bool: 是否成功
"""
try: try:
self.db.rename_session( self.db.rename_session(
session_id=session_id, session_id=session_id,
@@ -122,7 +156,15 @@ class ChatSessionCRUD:
return False return False
async def delete_crud(self, session_ids: list[str]) -> bool: async def delete_crud(self, session_ids: list[str]) -> bool:
"""批量删除会话""" """
批量删除会话
参数:
- session_ids (list[str]): 会话 ID 列表
返回:
- bool: 是否全部处理成功任一出错则记日志并返回 False
"""
try: try:
for session_id in session_ids: for session_id in session_ids:
self.db.delete_session( self.db.delete_session(
+75 -7
View File
@@ -130,7 +130,16 @@ class ChatService:
async def chat_query( async def chat_query(
cls, query: ChatQuerySchema, auth: AuthSchema cls, query: ChatQuerySchema, auth: AuthSchema
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""处理聊天查询并返回流式响应""" """
处理聊天查询并流式返回文本片段
参数:
- query (ChatQuerySchema): 用户消息与会话等查询参数
- auth (AuthSchema): 当前用户认证信息
返回:
- AsyncGenerator[str, None]: 逐段输出的回复文本
"""
try: try:
# 创建 CRUD 实例获取数据库连接 # 创建 CRUD 实例获取数据库连接
crud = ChatSessionCRUD(auth) crud = ChatSessionCRUD(auth)
@@ -171,7 +180,17 @@ class ChatService:
async def chat_non_stream( async def chat_non_stream(
cls, message: str, session_id: str | None, auth: AuthSchema cls, message: str, session_id: str | None, auth: AuthSchema
) -> dict[str, Any]: ) -> dict[str, Any]:
"""处理聊天查询并返回非流式响应""" """
处理聊天查询并返回非流式 JSON 结构 session_id操作建议等
参数:
- message (str): 用户输入文本
- session_id (str | None): 已有会话 ID为空则新建会话
- auth (AuthSchema): 当前用户认证信息
返回:
- dict[str, Any]: 包含 responsesession_idaction 等字段的字典
"""
try: try:
# 创建 CRUD 实例获取数据库连接 # 创建 CRUD 实例获取数据库连接
crud = ChatSessionCRUD(auth) crud = ChatSessionCRUD(auth)
@@ -298,7 +317,16 @@ class ChatService:
async def create_service( async def create_service(
cls, auth: AuthSchema, data: ChatSessionCreateSchema cls, auth: AuthSchema, data: ChatSessionCreateSchema
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""创建会话""" """
创建会话
参数:
- auth (AuthSchema): 认证信息
- data (ChatSessionCreateSchema): 创建参数
返回:
- dict[str, Any] | None: 格式化后的会话字典失败为 None
"""
crud = ChatSessionCRUD(auth) crud = ChatSessionCRUD(auth)
session = await crud.create_crud(data=data) session = await crud.create_crud(data=data)
if session: if session:
@@ -309,7 +337,16 @@ class ChatService:
async def get_session_service( async def get_session_service(
cls, auth: AuthSchema, session_id: str cls, auth: AuthSchema, session_id: str
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""获取单个会话详情""" """
获取单个会话详情
参数:
- auth (AuthSchema): 认证信息
- session_id (str): 会话 ID
返回:
- dict[str, Any] | None: 格式化后的会话字典不存在为 None
"""
crud = ChatSessionCRUD(auth) crud = ChatSessionCRUD(auth)
session: TeamSession | None = await crud.get_by_id_crud(session_id=session_id) session: TeamSession | None = await crud.get_by_id_crud(session_id=session_id)
if session: if session:
@@ -325,7 +362,19 @@ class ChatService:
search: ChatSessionQueryParam, search: ChatSessionQueryParam,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""分页获取会话列表。会话由 Agno 存储,无统一 SQL 分页接口,仅能对内存列表切片。""" """
分页获取会话列表会话由 Agno 存储无统一 SQL 分页仅对内存列表切片
参数:
- auth (AuthSchema): 认证信息
- page_no (int): 页码
- page_size (int): 每页条数
- search (ChatSessionQueryParam): 查询条件
- order_by (list[dict[str, str]] | None): 排序可选
返回:
- dict[str, Any]: 分页结果 itemstotal
"""
crud = ChatSessionCRUD(auth) crud = ChatSessionCRUD(auth)
# 获取所有会话 # 获取所有会话
sessions = await crud.list_crud() sessions = await crud.list_crud()
@@ -346,12 +395,31 @@ class ChatService:
async def update_service( async def update_service(
cls, auth: AuthSchema, session_id: str, data: ChatSessionUpdateSchema cls, auth: AuthSchema, session_id: str, data: ChatSessionUpdateSchema
) -> bool: ) -> bool:
"""更新会话""" """
更新会话
参数:
- auth (AuthSchema): 认证信息
- session_id (str): 会话 ID
- data (ChatSessionUpdateSchema): 更新数据
返回:
- bool: 是否成功
"""
crud = ChatSessionCRUD(auth) crud = ChatSessionCRUD(auth)
success = await crud.update_crud(session_id=session_id, data=data) success = await crud.update_crud(session_id=session_id, data=data)
return success return success
@classmethod @classmethod
async def delete_service(cls, auth: AuthSchema, session_ids: list[str]) -> None: async def delete_service(cls, auth: AuthSchema, session_ids: list[str]) -> None:
"""删除会话""" """
删除会话
参数:
- auth (AuthSchema): 认证信息
- session_ids (list[str]): 待删除会话 ID 列表
返回:
- None
"""
await ChatSessionCRUD(auth).delete_crud(session_ids=session_ids) await ChatSessionCRUD(auth).delete_crud(session_ids=session_ids)
+12 -1
View File
@@ -23,7 +23,18 @@ class AgnoFactory:
session_id: str, session_id: str,
db: Any | None = None db: Any | None = None
) -> Team: ) -> Team:
"""创建 Team 实例""" """
创建带 Agent Team 实例
参数:
- user_id (str): 用户标识
- dept_id (str): 部门/团队标识
- session_id (str): 会话 ID
- db (Any | None): Agno 持久化数据库实例可选
返回:
- Team: 配置好的 Team
"""
# 创建 Agent # 创建 Agent
fastapiadmin_agent = Agent( fastapiadmin_agent = Agent(
+8 -2
View File
@@ -22,11 +22,17 @@ async def websocket_chat_controller(
websocket: WebSocket, websocket: WebSocket,
) -> None: ) -> None:
""" """
WebSocket聊天接口 WebSocket 聊天接口
参数:
- websocket (WebSocket): WebSocket 连接
返回:
- None: 长连接处理完毕或关闭后无返回值
支持两种消息格式 支持两种消息格式
1. 纯文本直接发送消息内容 1. 纯文本直接发送消息内容
2. JSON格式{"message": "消息内容", "session_id": "会话ID", "files": [...]} 2. JSON 格式{"message": "消息内容", "session_id": "会话ID", "files": [...]}
ws://127.0.0.1:8001/api/v1/ai/chat/ws?token=xxx ws://127.0.0.1:8001/api/v1/ai/chat/ws?token=xxx
""" """
@@ -86,6 +86,9 @@ class ApplicationCRUD(CRUDBase[ApplicationModel, ApplicationCreateSchema, Applic
参数: 参数:
- ids (list[int]): 应用ID列表 - ids (list[int]): 应用ID列表
返回:
- None
""" """
return await self.delete(ids=ids) return await self.delete(ids=ids)
@@ -96,5 +99,8 @@ class ApplicationCRUD(CRUDBase[ApplicationModel, ApplicationCreateSchema, Applic
参数: 参数:
- ids (list[int]): 应用ID列表 - ids (list[int]): 应用ID列表
- status (str): 可用状态,True为可用,False为不可用 - status (str): 可用状态,True为可用,False为不可用
返回:
- None
""" """
return await self.set(ids=ids, status=status) return await self.set(ids=ids, status=status)
@@ -65,7 +65,19 @@ class ApplicationService:
search: ApplicationQueryParam | None = None, search: ApplicationQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询应用(数据库 OFFSET/LIMIT)。""" """
分页查询应用数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息
- page_no (int): 页码
- page_size (int): 每页条数
- search (ApplicationQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
返回:
- dict: 分页结果
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
search_dict = search.__dict__ if search else {} search_dict = search.__dict__ if search else {}
return await ApplicationCRUD(auth).page( return await ApplicationCRUD(auth).page(
@@ -33,7 +33,18 @@ class DemoCreateSchema(BaseModel):
@field_validator("name") @field_validator("name")
@classmethod @classmethod
def validate_name(cls, v: str) -> str: def validate_name(cls, v: str) -> str:
"""验证名称字段的格式和内容""" """
验证名称字段的格式和内容
参数:
- v (str): 原始名称
返回:
- str: 去空白后的名称
异常:
- ValueError: 名称为空时抛出
"""
# 去除首尾空格 # 去除首尾空格
v = v.strip() v = v.strip()
if not v: if not v:
@@ -25,7 +25,18 @@ class Demo01CreateSchema(BaseModel):
@field_validator("name") @field_validator("name")
@classmethod @classmethod
def validate_name(cls, v: str) -> str: def validate_name(cls, v: str) -> str:
"""验证名称字段的格式和内容""" """
验证名称字段的格式和内容
参数:
- v (str): 原始名称
返回:
- str: 去空白后的名称
异常:
- ValueError: 名称为空时抛出
"""
# 去除首尾空格 # 去除首尾空格
v = v.strip() v = v.strip()
if not v: if not v:
@@ -348,5 +348,15 @@ async def sync_db_preview_controller(
table_name: Annotated[str, Path(description="表名")], table_name: Annotated[str, Path(description="表名")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_generator:db:sync"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_generator:db:sync"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
同步数据库前差异预览主表 + 可选子表不落库
参数:
- table_name (str): 物理表名
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为预览结构
"""
result = await GenTableService.sync_db_preview_service(auth, table_name) result = await GenTableService.sync_db_preview_service(auth, table_name)
return SuccessResponse(msg="获取同步差异预览成功", data=result) return SuccessResponse(msg="获取同步差异预览成功", data=result)
@@ -131,6 +131,9 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
参数: 参数:
- ids (list[int]): 业务表ID列表 - ids (list[int]): 业务表ID列表
返回:
- None
""" """
await self.delete(ids=ids) await self.delete(ids=ids)
@@ -198,6 +201,14 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
- 旧实现使用 SQLAlchemy Inspector 全量遍历再内存分页表多时非常慢 - 旧实现使用 SQLAlchemy Inspector 全量遍历再内存分页表多时非常慢
- 这里按方言走系统表MySQL information_schema / Postgres pg_catalog进行分页与过滤 - 这里按方言走系统表MySQL information_schema / Postgres pg_catalog进行分页与过滤
- 若方言不支持则回退到旧的全量遍历 - 若方言不支持则回退到旧的全量遍历
参数:
- search (GenTableQueryParam | None): 表名/注释过滤条件
- offset (int): 偏移量
- limit (int): 每页条数
返回:
- tuple[list[dict], int]: 当前页表信息列表与总条数
""" """
database_name = settings.DATABASE_NAME database_name = settings.DATABASE_NAME
db_type = (settings.DATABASE_TYPE or "").lower() db_type = (settings.DATABASE_TYPE or "").lower()
@@ -342,6 +353,12 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
async def get_db_table_comment(self, table_name: str) -> str: async def get_db_table_comment(self, table_name: str) -> str:
""" """
获取数据库中指定表的注释用于主子表场景下从库中加载子表元信息 获取数据库中指定表的注释用于主子表场景下从库中加载子表元信息
参数:
- table_name (str): 物理表名
返回:
- str: 表注释表不存在或失败时为空字符串
""" """
from app.core.database import engine from app.core.database import engine
@@ -59,14 +59,38 @@ class GenTableModel(ModelMixin, UserMixin):
@validates("table_name") @validates("table_name")
def validate_table_name(self, key: str, table_name: str) -> str: def validate_table_name(self, key: str, table_name: str) -> str:
"""验证表名不为空""" """
验证表名非空并去首尾空格
参数:
- key (str): 字段名
- table_name (str): 表名
返回:
- str: 规范化后的表名
异常:
- ValueError: 表名为空时抛出
"""
if not table_name or not table_name.strip(): if not table_name or not table_name.strip():
raise ValueError("表名称不能为空") raise ValueError("表名称不能为空")
return table_name.strip() return table_name.strip()
@validates("class_name") @validates("class_name")
def validate_class_name(self, key: str, class_name: str) -> str: def validate_class_name(self, key: str, class_name: str) -> str:
"""验证类名不为空""" """
验证实体类名非空并去首尾空格
参数:
- key (str): 字段名
- class_name (str): 类名
返回:
- str: 规范化后的类名
异常:
- ValueError: 类名为空时抛出
"""
if not class_name or not class_name.strip(): if not class_name or not class_name.strip():
raise ValueError("实体类名称不能为空") raise ValueError("实体类名称不能为空")
return class_name.strip() return class_name.strip()
@@ -190,14 +214,38 @@ class GenTableColumnModel(ModelMixin, UserMixin):
@validates("column_name") @validates("column_name")
def validate_column_name(self, key: str, column_name: str) -> str: def validate_column_name(self, key: str, column_name: str) -> str:
"""验证列名不为空""" """
验证列名非空并去首尾空格
参数:
- key (str): 字段名
- column_name (str): 列名
返回:
- str: 规范化后的列名
异常:
- ValueError: 列名为空时抛出
"""
if not column_name or not column_name.strip(): if not column_name or not column_name.strip():
raise ValueError("列名称不能为空") raise ValueError("列名称不能为空")
return column_name.strip() return column_name.strip()
@validates("column_type") @validates("column_type")
def validate_column_type(self, key: str, column_type: str) -> str: def validate_column_type(self, key: str, column_type: str) -> str:
"""验证列类型不为空""" """
验证列类型非空并去首尾空格
参数:
- key (str): 字段名
- column_type (str): 列类型字符串
返回:
- str: 规范化后的列类型
异常:
- ValueError: 列类型为空时抛出
"""
if not column_type or not column_type.strip(): if not column_type or not column_type.strip():
raise ValueError("列类型不能为空") raise ValueError("列类型不能为空")
return column_type.strip() return column_type.strip()
@@ -142,7 +142,18 @@ class GenTableSchema(BaseModel):
@field_validator("table_name") @field_validator("table_name")
@classmethod @classmethod
def table_name_update(cls, v: str) -> str: def table_name_update(cls, v: str) -> str:
"""更新表名称""" """
校验并规范化表名称
参数:
- v (str): 原始表名
返回:
- str: 去空白后的表名
异常:
- ValueError: 表名为空时抛出
"""
if not v: if not v:
raise ValueError("表名称不能为空") raise ValueError("表名称不能为空")
return v.strip() return v.strip()
@@ -156,7 +167,15 @@ class GenTableSchema(BaseModel):
) )
@classmethod @classmethod
def strip_optional_text_fields(cls, v: str | None) -> str | None: def strip_optional_text_fields(cls, v: str | None) -> str | None:
"""文本类字段统一去首尾空格;空串视为 None。""" """
文本类字段统一去首尾空格空串视为 None
参数:
- v (str | None): 原始值
返回:
- str | None: 非空字符串或 None
"""
if v is None: if v is None:
return None return None
s = str(v).strip() s = str(v).strip()
@@ -165,7 +184,15 @@ class GenTableSchema(BaseModel):
@field_validator("package_name", mode="before") @field_validator("package_name", mode="before")
@classmethod @classmethod
def normalize_package_name(cls, v: str | None) -> str | None: def normalize_package_name(cls, v: str | None) -> str | None:
"""包名规范:必须是 module_xxx 形态(工程约定)。""" """
包名规范必须是 module_xxx 形态工程约定
参数:
- v (str | None): 原始包名
返回:
- str | None: 规范化后的包名或 None
"""
if v is None: if v is None:
return None return None
s = cls._normalize_slug_segment(str(v), allow_slash=False) s = cls._normalize_slug_segment(str(v), allow_slash=False)
@@ -176,7 +203,15 @@ class GenTableSchema(BaseModel):
@field_validator("module_name", mode="before") @field_validator("module_name", mode="before")
@classmethod @classmethod
def normalize_module_name(cls, v: str | None) -> str | None: def normalize_module_name(cls, v: str | None) -> str | None:
"""模块名规范:示例模式下要求不带 module_ 前缀;统一按 slug 规范。""" """
模块名规范不带 module_ 前缀统一按 slug 规范
参数:
- v (str | None): 原始模块名
返回:
- str | None: 规范化后的模块名或 None
"""
if v is None: if v is None:
return None return None
s = cls._normalize_slug_segment(str(v), allow_slash=False) s = cls._normalize_slug_segment(str(v), allow_slash=False)
@@ -187,7 +222,15 @@ class GenTableSchema(BaseModel):
@field_validator("business_name", mode="before") @field_validator("business_name", mode="before")
@classmethod @classmethod
def normalize_business_name(cls, v: str | None) -> str | None: def normalize_business_name(cls, v: str | None) -> str | None:
"""业务名允许多段:demo/demo01;统一按 slug 规范。""" """
业务名允许多段 demo/demo01统一按 slug 规范
参数:
- v (str | None): 原始业务名
返回:
- str | None: 规范化后的业务名或 None
"""
if v is None: if v is None:
return None return None
s = cls._normalize_slug_segment(str(v), allow_slash=True) s = cls._normalize_slug_segment(str(v), allow_slash=True)
@@ -196,7 +239,15 @@ class GenTableSchema(BaseModel):
@field_validator("sub_table_name", "sub_table_fk_name", mode="before") @field_validator("sub_table_name", "sub_table_fk_name", mode="before")
@classmethod @classmethod
def strip_optional_sub_fields(cls, v: str | None) -> str | None: def strip_optional_sub_fields(cls, v: str | None) -> str | None:
"""主子表字段去首尾空格,空串视为未填。""" """
主子表字段去首尾空格空串视为未填
参数:
- v (str | None): 原始值
返回:
- str | None: 非空字符串或 None
"""
if v is None: if v is None:
return None return None
s = str(v).strip() s = str(v).strip()
@@ -41,6 +41,16 @@ from .tools.jinja2_template_util import Jinja2TemplateUtil
def handle_service_exception(func: Callable) -> Callable: def handle_service_exception(func: Callable) -> Callable:
"""
服务层异步方法装饰器透传 CustomException其余异常包装为 CustomException
参数:
- func (Callable): 被装饰的异步可调用对象
返回:
- Callable: 包装后的可调用对象异步
"""
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
try: try:
return await func(*args, **kwargs) return await func(*args, **kwargs)
@@ -93,7 +103,6 @@ class GenTableService:
raise CustomException(msg="包名不能为空") raise CustomException(msg="包名不能为空")
return pn if pn.startswith("module_") else f"module_{pn}" return pn if pn.startswith("module_") else f"module_{pn}"
@classmethod
@classmethod @classmethod
async def _assert_parent_menu_is_catalog(cls, auth: AuthSchema, parent_menu_id: int | None) -> None: async def _assert_parent_menu_is_catalog(cls, auth: AuthSchema, parent_menu_id: int | None) -> None:
"""上级菜单仅允许目录:与前端树只展示目录一致,避免挂到菜单/按钮下。""" """上级菜单仅允许目录:与前端树只展示目录一致,避免挂到菜单/按钮下。"""
@@ -208,7 +217,18 @@ class GenTableService:
@classmethod @classmethod
def normalize_and_validate_master_sub(cls, data: GenTableSchema) -> None: def normalize_and_validate_master_sub(cls, data: GenTableSchema) -> None:
"""主子表业务规则:两字段同填或同空;子表表名不得与主表相同。""" """
主子表业务规则子表表名与外键列同填或同空子表表名不得与主表相同
参数:
- data (GenTableSchema): 主表配置
返回:
- None
异常:
- CustomException: 规则不满足时抛出
"""
sn = data.sub_table_name sn = data.sub_table_name
fk = data.sub_table_fk_name fk = data.sub_table_fk_name
if bool(sn) ^ bool(fk): if bool(sn) ^ bool(fk):
@@ -260,7 +280,19 @@ class GenTableService:
search: GenTableQueryParam, search: GenTableQueryParam,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询代码生成业务表(数据库 OFFSET/LIMIT)。""" """
分页查询代码生成业务表数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息
- page_no (int): 页码
- page_size (int): 每页条数
- search (GenTableQueryParam): 查询条件
- order_by (list[dict[str, str]] | None): 排序
返回:
- dict: 分页结果
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
order = order_by or [{"created_time": "desc"}] order = order_by or [{"created_time": "desc"}]
return await GenTableCRUD(auth=auth).page( return await GenTableCRUD(auth=auth).page(
@@ -297,7 +329,18 @@ class GenTableService:
page_size: int, page_size: int,
search: GenTableQueryParam, search: GenTableQueryParam,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""数据库表列表分页(数据库侧 OFFSET/LIMIT)。""" """
数据库表列表分页数据库侧 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息
- page_no (int): 页码
- page_size (int): 每页条数
- search (GenTableQueryParam): 查询条件
返回:
- dict[str, Any]: itemstotalhas_next 等字段
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
items, total = await GenTableCRUD(auth=auth).get_db_table_page( items, total = await GenTableCRUD(auth=auth).get_db_table_page(
search=search, offset=offset, limit=page_size search=search, offset=offset, limit=page_size
@@ -1041,9 +1084,17 @@ class GenTableService:
@classmethod @classmethod
async def hydrate_sub_table(cls, auth: AuthSchema, gen_table: GenTableOutSchema) -> None: async def hydrate_sub_table(cls, auth: AuthSchema, gen_table: GenTableOutSchema) -> None:
"""主子表:优先使用“已导入的子表配置”,否则回退 DB 结构只读。 """
主子表优先使用已导入的子表配置否则回退为只读 DB 结构
对齐 RuoYi 的更佳体验子表应当是一个可配置的 gen_table可单独编辑字段主表引用 对齐 RuoYi子表宜为独立 gen_table主表引用
参数:
- auth (AuthSchema): 认证信息
- gen_table (GenTableOutSchema): 主表输出模型原地填充 sub_tablemaster_sub_hint
返回:
- None
""" """
gen_table.master_sub_hint = None gen_table.master_sub_hint = None
sub_name_raw = (gen_table.sub_table_name or "").strip() sub_name_raw = (gen_table.sub_table_name or "").strip()
@@ -1210,7 +1261,19 @@ class GenTableService:
async def sync_db_preview_service( async def sync_db_preview_service(
cls, auth: AuthSchema, table_name: str cls, auth: AuthSchema, table_name: str
) -> dict[str, Any]: ) -> dict[str, Any]:
"""同步数据库前差异预览(主表 + 可选子表)。""" """
同步数据库前差异预览主表 + 可选子表
参数:
- auth (AuthSchema): 认证信息
- table_name (str): 主表物理表名
返回:
- dict[str, Any]: 预览差异结构可序列化
异常:
- CustomException: 表名无效或业务表不存在等
"""
if not table_name or not table_name.strip(): if not table_name or not table_name.strip():
raise CustomException(msg="表名不能为空") raise CustomException(msg="表名不能为空")
gen_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name, preload=["columns"]) gen_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name, preload=["columns"])
@@ -25,6 +25,12 @@ class Jinja2TemplateUtil:
def normalize_db_column_type_for_mapping(cls, column_type: str | None) -> str: def normalize_db_column_type_for_mapping(cls, column_type: str | None) -> str:
""" """
``GenUtils.get_db_type`` 一致地去掉 COLLATE / UNSIGNED便于与 ``DB_TO_SQLALCHEMY`` 键匹配 ``GenUtils.get_db_type`` 一致地去掉 COLLATE / UNSIGNED便于与 ``DB_TO_SQLALCHEMY`` 键匹配
参数:
- column_type (str | None): 原始列类型字符串
返回:
- str: 规范化后的类型片段空输入返回空字符串
""" """
ct = (column_type or "").strip() ct = (column_type or "").strip()
if not ct: if not ct:
@@ -91,12 +97,17 @@ class Jinja2TemplateUtil:
""" """
return cls.get_env().get_template(template_path) return cls.get_env().get_template(template_path)
@classmethod
@classmethod @classmethod
def business_name_to_slug(cls, business_name: str | None) -> str: def business_name_to_slug(cls, business_name: str | None) -> str:
""" """
业务路径可含斜杠 ``demo/demo01``用于目录与路由前缀 业务路径可含斜杠 ``demo/demo01``用于目录与路由前缀
Python 函数/方法名仅使用最后一段并规范为合法 snake_case 片段 Python 函数/方法名仅使用最后一段并规范为合法 snake_case 片段
参数:
- business_name (str | None): 业务路径或名称
返回:
- str: 用于 Python 标识的 slug默认 entity
""" """
s = (business_name or "").strip().strip("/") s = (business_name or "").strip().strip("/")
if not s: if not s:
@@ -117,6 +128,12 @@ class Jinja2TemplateUtil:
约定`business_name` 允许 `a/b/c` 表示多级菜单目录 约定`business_name` 允许 `a/b/c` 表示多级菜单目录
- 目录/路由使用完整多段 - 目录/路由使用完整多段
- 文件名/route_name使用最后一段 slug `business_name_to_slug` - 文件名/route_name使用最后一段 slug `business_name_to_slug`
参数:
- business_name (str | None): 业务路径或名称
返回:
- str: 多段路径字符串小写 slug默认 entity
""" """
s = (business_name or "").strip().strip("/") s = (business_name or "").strip().strip("/")
if not s: if not s:
@@ -230,6 +247,12 @@ class Jinja2TemplateUtil:
前端页面路由首段与写入菜单 ``route_path`` 第一段一致始终为 ``module_xxx`` 前端页面路由首段与写入菜单 ``route_path`` 第一段一致始终为 ``module_xxx``
懒加载 ``GenTableService`` 避免与 ``service`` 模块循环依赖 懒加载 ``GenTableService`` 避免与 ``service`` 模块循环依赖
参数:
- gen_table (GenTableOutSchema): 生成表配置
返回:
- str: 路由首段module_xxx
""" """
from app.plugin.module_generator.gencode.service import GenTableService from app.plugin.module_generator.gencode.service import GenTableService
@@ -246,6 +269,13 @@ class Jinja2TemplateUtil:
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
子表业务代码渲染上下文与主表同模块独立业务目录 子表业务代码渲染上下文与主表同模块独立业务目录
参数:
- parent (GenTableOutSchema): 主表配置
- sub (GenTableOutSchema): 子表配置
返回:
- dict[str, Any]: 子表模板上下文字典
""" """
ctx = cls.prepare_context(sub) ctx = cls.prepare_context(sub)
scn = (sub.class_name or GenUtils.convert_class_name(sub.table_name or "")).strip() scn = (sub.class_name or GenUtils.convert_class_name(sub.table_name or "")).strip()
@@ -351,10 +381,13 @@ class Jinja2TemplateUtil:
@classmethod @classmethod
def get_schema_import_list(cls, gen_table: GenTableOutSchema): def get_schema_import_list(cls, gen_table: GenTableOutSchema):
""" """
获取schema模板导入包列表 获取 schema 模板所需的 Python 导入语句集合
:param gen_table: 生成表的配置信息 参数:
:return: 导入包列表 - gen_table (GenTableOutSchema): 生成表配置含主子表列
返回:
- set[str]: 导入语句字符串集合
""" """
columns = gen_table.columns or [] columns = gen_table.columns or []
import_list = set() import_list = set()
@@ -418,11 +451,14 @@ class Jinja2TemplateUtil:
cls, gen_table: GenTableOutSchema, *, is_sub_entity: bool = False cls, gen_table: GenTableOutSchema, *, is_sub_entity: bool = False
) -> list[str]: ) -> list[str]:
""" """
获取do模板导入包列表 获取 model 模板所需的 Python 导入语句列表含合并后的 sqlalchemy 导入
:param gen_table: 生成表的配置信息 参数:
:param is_sub_entity: 是否为子表独立生成含外键与 relationship - gen_table (GenTableOutSchema): 生成表配置
:return: 导入包列表 - is_sub_entity (bool): 是否为子表独立生成含外键与 relationship
返回:
- list[str]: 导入语句列表
""" """
columns = gen_table.columns or [] columns = gen_table.columns or []
import_list = set() import_list = set()
@@ -620,6 +656,12 @@ class Jinja2TemplateUtil:
将列上的 Python 类型`get_db_type` + `DB_TO_PYTHON` 映射结果转为前端 TS 类型片段 将列上的 Python 类型`get_db_type` + `DB_TO_PYTHON` 映射结果转为前端 TS 类型片段
JSON 序列化习惯一致Decimal日期时间多为字符串dict/list 用宽松类型 JSON 序列化习惯一致Decimal日期时间多为字符串dict/list 用宽松类型
参数:
- python_type (str | None): Python 类型名
返回:
- str: 前端 TypeScript 类型片段
""" """
if not python_type or not str(python_type).strip(): if not python_type or not str(python_type).strip():
return "string" return "string"
@@ -65,7 +65,10 @@ async def get_scheduler_jobs_controller() -> JSONResponse:
) )
async def start_scheduler_controller() -> JSONResponse: async def start_scheduler_controller() -> JSONResponse:
""" """
启动调度器 启动调度器
返回:
- JSONResponse: 成功提示响应
""" """
SchedulerUtil.start() SchedulerUtil.start()
log.info("调度器已启动") log.info("调度器已启动")
@@ -81,7 +84,10 @@ async def start_scheduler_controller() -> JSONResponse:
) )
async def pause_scheduler_controller() -> JSONResponse: async def pause_scheduler_controller() -> JSONResponse:
""" """
暂停调度器 暂停调度器
返回:
- JSONResponse: 成功提示响应
""" """
SchedulerUtil.pause() SchedulerUtil.pause()
log.info("调度器已暂停") log.info("调度器已暂停")
@@ -97,7 +103,10 @@ async def pause_scheduler_controller() -> JSONResponse:
) )
async def resume_scheduler_controller() -> JSONResponse: async def resume_scheduler_controller() -> JSONResponse:
""" """
恢复调度器 恢复调度器
返回:
- JSONResponse: 成功提示响应
""" """
SchedulerUtil.resume() SchedulerUtil.resume()
log.info("调度器已恢复") log.info("调度器已恢复")
@@ -113,7 +122,10 @@ async def resume_scheduler_controller() -> JSONResponse:
) )
async def shutdown_scheduler_controller() -> JSONResponse: async def shutdown_scheduler_controller() -> JSONResponse:
""" """
关闭调度器 关闭调度器
返回:
- JSONResponse: 成功提示响应
""" """
await SchedulerUtil.shutdown() await SchedulerUtil.shutdown()
log.info("调度器已关闭") log.info("调度器已关闭")
@@ -129,7 +141,10 @@ async def shutdown_scheduler_controller() -> JSONResponse:
) )
async def clear_jobs_controller() -> JSONResponse: async def clear_jobs_controller() -> JSONResponse:
""" """
清空调度器中的所有任务 清空调度器中的所有任务
返回:
- JSONResponse: 成功提示响应
""" """
SchedulerUtil.clear_jobs() SchedulerUtil.clear_jobs()
log.info("已清空所有任务") log.info("已清空所有任务")
@@ -191,6 +206,9 @@ async def pause_job_controller(
参数: 参数:
- job_id (str): 调度器任务ID - job_id (str): 调度器任务ID
返回:
- JSONResponse: 成功提示响应
""" """
SchedulerUtil.pause_job(job_id=job_id) SchedulerUtil.pause_job(job_id=job_id)
log.info(f"暂停任务成功: {job_id}") log.info(f"暂停任务成功: {job_id}")
@@ -212,6 +230,9 @@ async def resume_job_controller(
参数: 参数:
- job_id (str): 调度器任务ID - job_id (str): 调度器任务ID
返回:
- JSONResponse: 成功提示响应
""" """
SchedulerUtil.resume_job(job_id=job_id) SchedulerUtil.resume_job(job_id=job_id)
log.info(f"恢复任务成功: {job_id}") log.info(f"恢复任务成功: {job_id}")
@@ -233,6 +254,9 @@ async def run_job_controller(
参数: 参数:
- job_id (str): 调度器任务ID - job_id (str): 调度器任务ID
返回:
- JSONResponse: 成功提示响应
""" """
SchedulerUtil.run_job_now(job_id=job_id) SchedulerUtil.run_job_now(job_id=job_id)
log.info(f"立即执行任务成功: {job_id}") log.info(f"立即执行任务成功: {job_id}")
@@ -254,6 +278,9 @@ async def remove_job_controller(
参数: 参数:
- job_id (str): 调度器任务ID - job_id (str): 调度器任务ID
返回:
- JSONResponse: 成功提示响应
""" """
SchedulerUtil.remove_job(job_id=job_id) SchedulerUtil.remove_job(job_id=job_id)
log.info(f"移除任务成功: {job_id}") log.info(f"移除任务成功: {job_id}")
@@ -340,6 +367,9 @@ async def delete_job_log_controller(
参数: 参数:
- ids (list[int]): ID列表 - ids (list[int]): ID列表
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 成功提示响应
""" """
await JobService.delete_job_log_service(auth=auth, ids=ids) await JobService.delete_job_log_service(auth=auth, ids=ids)
log.info(f"删除执行日志成功: {ids}") log.info(f"删除执行日志成功: {ids}")
@@ -86,11 +86,17 @@ class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]):
参数: 参数:
- ids (list[int]): 日志ID列表 - ids (list[int]): 日志ID列表
返回:
- None
""" """
return await self.delete(ids=ids) return await self.delete(ids=ids)
async def clear_obj_crud(self) -> None: async def clear_obj_crud(self) -> None:
""" """
清空所有执行日志 清空所有执行日志
返回:
- None
""" """
return await self.clear() return await self.clear()
@@ -67,7 +67,19 @@ class JobService:
search: JobQueryParam | None = None, search: JobQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询执行日志(数据库 OFFSET/LIMIT)。""" """
分页查询执行日志数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息
- page_no (int): 页码
- page_size (int): 每页条数
- search (JobQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
返回:
- dict: 分页结果
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
ob = order_by or [{"created_time": "desc"}] ob = order_by or [{"created_time": "desc"}]
return await JobCRUD(auth).page( return await JobCRUD(auth).page(
@@ -149,6 +161,9 @@ class JobService:
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型
- ids (list[int]): 日志ID列表 - ids (list[int]): 日志ID列表
返回:
- None
""" """
if len(ids) < 1: if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空") raise CustomException(msg="删除失败,删除对象不能为空")
@@ -161,6 +176,9 @@ class JobService:
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型
返回:
- None
""" """
await JobCRUD(auth).clear_obj_crud() await JobCRUD(auth).clear_obj_crud()
@@ -33,6 +33,12 @@ async def get_node_options_controller(
) -> JSONResponse: ) -> JSONResponse:
""" """
获取数据库中的定时任务节点定义task_node与编排节点类型无关 获取数据库中的定时任务节点定义task_node与编排节点类型无关
参数:
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为选项列表
""" """
result = await NodeService.get_node_options_service(auth=auth) result = await NodeService.get_node_options_service(auth=auth)
log.info("获取定时任务节点选项成功") log.info("获取定时任务节点选项成功")
@@ -89,6 +89,9 @@ class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]):
参数: 参数:
- ids (list[int]): 节点ID列表 - ids (list[int]): 节点ID列表
返回:
- None
""" """
return await self.delete(ids=ids) return await self.delete(ids=ids)
@@ -99,6 +102,9 @@ class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]):
参数: 参数:
- ids (list[int]): 节点ID列表 - ids (list[int]): 节点ID列表
- kwargs: 其他要设置的字段,例如 available=True available=False - kwargs: 其他要设置的字段,例如 available=True available=False
返回:
- None
""" """
return await self.set(ids=ids, **kwargs) return await self.set(ids=ids, **kwargs)
@@ -108,5 +114,8 @@ class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]):
注意: 注意:
- 此操作会删除所有节点日志,请谨慎操作 - 此操作会删除所有节点日志,请谨慎操作
返回:
- None
""" """
return await self.clear() return await self.clear()
@@ -8,7 +8,12 @@ from datetime import datetime
def demo_handler(*args, **kwargs) -> dict: def demo_handler(*args, **kwargs) -> dict:
"""示例处理器""" """
示例处理器演示节点调用形态
返回:
- dict: 包含 message入参快照与时间戳
"""
return { return {
"message": "Hello from demo_handler!", "message": "Hello from demo_handler!",
"args": args, "args": args,
@@ -19,9 +24,14 @@ def demo_handler(*args, **kwargs) -> dict:
def process_data(data: list, operation: str = "sum") -> dict: def process_data(data: list, operation: str = "sum") -> dict:
""" """
简单数据处理 简单数值列表聚合
operation: sum, avg, max, min, count 参数:
- data (list): 数值列表
- operation (str): sumavgmaxmincount 之一
返回:
- dict: operation result error 说明
""" """
if not data: if not data:
return {"error": "数据为空"} return {"error": "数据为空"}
@@ -96,7 +96,19 @@ class NodeService:
search: NodeQueryParam | None = None, search: NodeQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询定时任务节点(数据库 OFFSET/LIMIT)。""" """
分页查询定时任务节点数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息
- page_no (int): 页码
- page_size (int): 每页条数
- search (NodeQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
返回:
- dict: 分页结果
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
return await NodeCRUD(auth).page( return await NodeCRUD(auth).page(
offset=offset, offset=offset,
@@ -157,6 +169,9 @@ class NodeService:
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型
- ids (list[int]): 节点ID列表 - ids (list[int]): 节点ID列表
返回:
- None
""" """
if len(ids) < 1: if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空") raise CustomException(msg="删除失败,删除对象不能为空")
@@ -178,6 +193,9 @@ class NodeService:
参数: 参数:
- auth (AuthSchema): 认证信息模型 - auth (AuthSchema): 认证信息模型
返回:
- None
""" """
SchedulerUtil.clear_jobs() SchedulerUtil.clear_jobs()
await NodeCRUD(auth).clear_obj_crud() await NodeCRUD(auth).clear_obj_crud()
@@ -33,6 +33,16 @@ async def get_workflow_detail_controller(
id: Annotated[int, Path(description="工作流ID")], id: Annotated[int, Path(description="工作流ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:detail"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:detail"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
根据 ID 获取工作流详情含画布 nodes/edges
参数:
- id (int): 工作流 ID
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为详情字典
"""
result_dict = await WorkflowService.get_workflow_detail_service(auth=auth, id=id) result_dict = await WorkflowService.get_workflow_detail_service(auth=auth, id=id)
log.info(f"获取工作流详情成功 {id}") log.info(f"获取工作流详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取工作流详情成功") return SuccessResponse(data=result_dict, msg="获取工作流详情成功")
@@ -49,6 +59,17 @@ async def get_workflow_list_controller(
search: Annotated[WorkflowQueryParam, Depends()], search: Annotated[WorkflowQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:query"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:query"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
分页查询工作流列表
参数:
- page (PaginationQueryParam): 分页与排序参数
- search (WorkflowQueryParam): 查询条件
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为分页结果
"""
result_dict = await WorkflowService.get_workflow_page_service( result_dict = await WorkflowService.get_workflow_page_service(
auth=auth, auth=auth,
page_no=page.page_no, page_no=page.page_no,
@@ -70,6 +91,16 @@ async def create_workflow_controller(
data: WorkflowCreateSchema, data: WorkflowCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:create"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:create"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
创建草稿工作流
参数:
- data (WorkflowCreateSchema): 创建体
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为新建工作流
"""
result_dict = await WorkflowService.create_workflow_service(auth=auth, data=data) result_dict = await WorkflowService.create_workflow_service(auth=auth, data=data)
log.info("创建工作流成功") log.info("创建工作流成功")
return SuccessResponse(data=result_dict, msg="创建工作流成功") return SuccessResponse(data=result_dict, msg="创建工作流成功")
@@ -86,6 +117,17 @@ async def update_workflow_controller(
data: WorkflowUpdateSchema, data: WorkflowUpdateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:update"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:update"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
更新工作流及画布
参数:
- id (int): 工作流 ID
- data (WorkflowUpdateSchema): 更新体
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为更新后的工作流
"""
result_dict = await WorkflowService.update_workflow_service(auth=auth, id=id, data=data) result_dict = await WorkflowService.update_workflow_service(auth=auth, id=id, data=data)
log.info(f"更新工作流成功 {id}") log.info(f"更新工作流成功 {id}")
return SuccessResponse(data=result_dict, msg="更新工作流成功") return SuccessResponse(data=result_dict, msg="更新工作流成功")
@@ -101,6 +143,16 @@ async def delete_workflow_controller(
ids: Annotated[list[int], Body(description="ID列表")], ids: Annotated[list[int], Body(description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:delete"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:delete"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
批量删除工作流
参数:
- ids (list[int]): 工作流 ID 列表
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功提示响应
"""
await WorkflowService.delete_workflow_service(auth=auth, ids=ids) await WorkflowService.delete_workflow_service(auth=auth, ids=ids)
log.info(f"删除工作流成功 {ids}") log.info(f"删除工作流成功 {ids}")
return SuccessResponse(msg="删除工作流成功") return SuccessResponse(msg="删除工作流成功")
@@ -117,6 +169,17 @@ async def publish_workflow_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:update"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:update"]))],
body: Annotated[WorkflowPublishSchema | None, Body()] = None, body: Annotated[WorkflowPublishSchema | None, Body()] = None,
) -> JSONResponse: ) -> JSONResponse:
"""
校验 DAG 无环后发布工作流
参数:
- id (int): 工作流 ID
- auth (AuthSchema): 认证信息
- body (WorkflowPublishSchema | None): 可选发布附加参数
返回:
- JSONResponse: 成功响应data 为发布后工作流
"""
result_dict = await WorkflowService.publish_workflow_service(auth=auth, id=id, body=body) result_dict = await WorkflowService.publish_workflow_service(auth=auth, id=id, body=body)
log.info(f"发布工作流成功 {id}") log.info(f"发布工作流成功 {id}")
return SuccessResponse(data=result_dict, msg="发布工作流成功") return SuccessResponse(data=result_dict, msg="发布工作流成功")
@@ -132,6 +195,16 @@ async def execute_workflow_controller(
body: WorkflowExecuteSchema, body: WorkflowExecuteSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:execute"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:execute"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
使用 Prefect 按拓扑顺序执行已发布工作流
参数:
- body (WorkflowExecuteSchema): 工作流 ID 与变量等
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为执行结果摘要
"""
result_dict = await WorkflowService.execute_workflow_service(auth=auth, body=body) result_dict = await WorkflowService.execute_workflow_service(auth=auth, body=body)
log.info(f"执行工作流完成 workflow_id={body.workflow_id}") log.info(f"执行工作流完成 workflow_id={body.workflow_id}")
return SuccessResponse(data=result_dict, msg="执行工作流完成") return SuccessResponse(data=result_dict, msg="执行工作流完成")
@@ -12,12 +12,31 @@ class WorkflowCRUD(CRUDBase[WorkflowModel, WorkflowCreateSchema, WorkflowUpdateS
"""工作流数据层""" """工作流数据层"""
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
"""
初始化工作流 CRUD
参数:
- auth (AuthSchema): 认证信息
返回:
- None
"""
self.auth = auth self.auth = auth
super().__init__(model=WorkflowModel, auth=auth) super().__init__(model=WorkflowModel, auth=auth)
async def get_obj_by_id_crud( async def get_obj_by_id_crud(
self, id: int, preload: list[str | Any] | None = None self, id: int, preload: list[str | Any] | None = None
) -> WorkflowModel | None: ) -> WorkflowModel | None:
"""
按主键查询工作流
参数:
- id (int): 工作流 ID
- preload (list[str | Any] | None): 预加载关系
返回:
- WorkflowModel | None: 实体或 None
"""
return await self.get(id=id, preload=preload) return await self.get(id=id, preload=preload)
async def get_obj_list_crud( async def get_obj_list_crud(
@@ -26,13 +45,52 @@ class WorkflowCRUD(CRUDBase[WorkflowModel, WorkflowCreateSchema, WorkflowUpdateS
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
preload: list[str | Any] | None = None, preload: list[str | Any] | None = None,
) -> Sequence[WorkflowModel]: ) -> Sequence[WorkflowModel]:
"""
条件列表查询工作流
参数:
- search (dict | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
- preload (list[str | Any] | None): 预加载关系
返回:
- Sequence[WorkflowModel]: 工作流列表
"""
return await self.list(search=search, order_by=order_by, preload=preload) return await self.list(search=search, order_by=order_by, preload=preload)
async def create_obj_crud(self, data: WorkflowCreateSchema) -> WorkflowModel | None: async def create_obj_crud(self, data: WorkflowCreateSchema) -> WorkflowModel | None:
"""
创建工作流
参数:
- data (WorkflowCreateSchema): 创建模型
返回:
- WorkflowModel | None: 新建实体或 None
"""
return await self.create(data=data) return await self.create(data=data)
async def update_obj_crud(self, id: int, data: WorkflowUpdateSchema) -> WorkflowModel | None: async def update_obj_crud(self, id: int, data: WorkflowUpdateSchema) -> WorkflowModel | None:
"""
更新工作流
参数:
- id (int): 工作流 ID
- data (WorkflowUpdateSchema): 更新模型
返回:
- WorkflowModel | None: 更新后实体或 None
"""
return await self.update(id=id, data=data) return await self.update(id=id, data=data)
async def delete_obj_crud(self, ids: list[int]) -> None: async def delete_obj_crud(self, ids: list[int]) -> None:
"""
批量删除工作流
参数:
- ids (list[int]): ID 列表
返回:
- None
"""
await self.delete(ids=ids) await self.delete(ids=ids)
@@ -27,6 +27,19 @@ class WorkflowService:
@classmethod @classmethod
async def get_workflow_detail_service(cls, auth: AuthSchema, id: int) -> dict: async def get_workflow_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取工作流详情
参数:
- auth (AuthSchema): 认证信息
- id (int): 工作流 ID
返回:
- dict: 序列化后的工作流详情
异常:
- CustomException: 不存在时抛出
"""
obj = await WorkflowCRUD(auth).get_obj_by_id_crud(id=id) obj = await WorkflowCRUD(auth).get_obj_by_id_crud(id=id)
if not obj: if not obj:
raise CustomException(msg="工作流不存在") raise CustomException(msg="工作流不存在")
@@ -39,6 +52,17 @@ class WorkflowService:
search: WorkflowQueryParam | None = None, search: WorkflowQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> list[dict]: ) -> list[dict]:
"""
获取工作流列表非分页
参数:
- auth (AuthSchema): 认证信息
- search (WorkflowQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
返回:
- list[dict]: 工作流字典列表
"""
if order_by is None: if order_by is None:
order_by = [{"updated_time": "desc"}] order_by = [{"updated_time": "desc"}]
obj_list = await WorkflowCRUD(auth).get_obj_list_crud( obj_list = await WorkflowCRUD(auth).get_obj_list_crud(
@@ -56,7 +80,19 @@ class WorkflowService:
search: WorkflowQueryParam | None = None, search: WorkflowQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询工作流(数据库 OFFSET/LIMIT)。""" """
分页查询工作流数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息
- page_no (int): 页码
- page_size (int): 每页条数
- search (WorkflowQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
返回:
- dict: 分页结果items JSON 友好序列化
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
order = order_by or [{"updated_time": "desc"}] order = order_by or [{"updated_time": "desc"}]
result = await WorkflowCRUD(auth).page( result = await WorkflowCRUD(auth).page(
@@ -73,6 +109,19 @@ class WorkflowService:
@classmethod @classmethod
async def create_workflow_service(cls, auth: AuthSchema, data: WorkflowCreateSchema) -> dict: async def create_workflow_service(cls, auth: AuthSchema, data: WorkflowCreateSchema) -> dict:
"""
创建工作流草稿
参数:
- auth (AuthSchema): 认证信息
- data (WorkflowCreateSchema): 创建体
返回:
- dict: 新建工作流字典
异常:
- CustomException: 编码重复或创建失败
"""
exist = await WorkflowCRUD(auth).get(code=data.code) exist = await WorkflowCRUD(auth).get(code=data.code)
if exist: if exist:
raise CustomException(msg="流程编码已存在") raise CustomException(msg="流程编码已存在")
@@ -85,6 +134,20 @@ class WorkflowService:
async def update_workflow_service( async def update_workflow_service(
cls, auth: AuthSchema, id: int, data: WorkflowUpdateSchema cls, auth: AuthSchema, id: int, data: WorkflowUpdateSchema
) -> dict: ) -> dict:
"""
更新工作流
参数:
- auth (AuthSchema): 认证信息
- id (int): 工作流 ID
- data (WorkflowUpdateSchema): 更新体
返回:
- dict: 更新后工作流字典
异常:
- CustomException: 不存在编码冲突或更新失败
"""
exist = await WorkflowCRUD(auth).get_obj_by_id_crud(id=id) exist = await WorkflowCRUD(auth).get_obj_by_id_crud(id=id)
if not exist: if not exist:
raise CustomException(msg="工作流不存在") raise CustomException(msg="工作流不存在")
@@ -99,6 +162,19 @@ class WorkflowService:
@classmethod @classmethod
async def delete_workflow_service(cls, auth: AuthSchema, ids: list[int]) -> None: async def delete_workflow_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
批量删除工作流
参数:
- auth (AuthSchema): 认证信息
- ids (list[int]): ID 列表
返回:
- None
异常:
- CustomException: ID 为空时抛出
"""
if not ids: if not ids:
raise CustomException(msg="删除ID不能为空") raise CustomException(msg="删除ID不能为空")
await WorkflowCRUD(auth).delete_obj_crud(ids=ids) await WorkflowCRUD(auth).delete_obj_crud(ids=ids)
@@ -107,6 +183,20 @@ class WorkflowService:
async def publish_workflow_service( async def publish_workflow_service(
cls, auth: AuthSchema, id: int, body: WorkflowPublishSchema | None = None cls, auth: AuthSchema, id: int, body: WorkflowPublishSchema | None = None
) -> dict: ) -> dict:
"""
校验 DAG 后发布工作流
参数:
- auth (AuthSchema): 认证信息
- id (int): 工作流 ID
- body (WorkflowPublishSchema | None): 可选附加参数
返回:
- dict: 发布后工作流字典
异常:
- CustomException: 不存在图无效或发布失败
"""
obj = await WorkflowCRUD(auth).get_obj_by_id_crud(id=id) obj = await WorkflowCRUD(auth).get_obj_by_id_crud(id=id)
if not obj: if not obj:
raise CustomException(msg="工作流不存在") raise CustomException(msg="工作流不存在")
@@ -135,6 +225,19 @@ class WorkflowService:
async def execute_workflow_service( async def execute_workflow_service(
cls, auth: AuthSchema, body: WorkflowExecuteSchema cls, auth: AuthSchema, body: WorkflowExecuteSchema
) -> dict: ) -> dict:
"""
执行已发布工作流Prefect 同步入口在线程池中运行
参数:
- auth (AuthSchema): 认证信息
- body (WorkflowExecuteSchema): 工作流 ID 与变量
返回:
- dict: 执行结果摘要成功或失败结构
异常:
- CustomException: 未发布缺节点节点类型未注册等
"""
obj = await WorkflowCRUD(auth).get_obj_by_id_crud(id=body.workflow_id) obj = await WorkflowCRUD(auth).get_obj_by_id_crud(id=body.workflow_id)
if not obj: if not obj:
raise CustomException(msg="工作流不存在") raise CustomException(msg="工作流不存在")
@@ -34,7 +34,19 @@ def _parse_kwargs(kwargs_str: str | None) -> dict[str, Any]:
def validate_workflow_graph(nodes: list[dict], edges: list[dict]) -> None: def validate_workflow_graph(nodes: list[dict], edges: list[dict]) -> None:
"""校验图有效且无环。""" """
校验画布图有效且无环
参数:
- nodes (list[dict]): 节点列表须含 id
- edges (list[dict]): 边列表source/target
返回:
- None
异常:
- ValueError: 图为空边引用非法或存在环
"""
if not nodes: if not nodes:
raise ValueError("工作流至少需要一个节点") raise ValueError("工作流至少需要一个节点")
ids = {n["id"] for n in nodes} ids = {n["id"] for n in nodes}
@@ -60,6 +72,16 @@ def validate_workflow_graph(nodes: list[dict], edges: list[dict]) -> None:
def topological_sort(nodes: list[dict], edges: list[dict]) -> list[dict]: def topological_sort(nodes: list[dict], edges: list[dict]) -> list[dict]:
"""
按拓扑顺序返回节点调用方须先保证无环
参数:
- nodes (list[dict]): 节点列表
- edges (list[dict]): 边列表
返回:
- list[dict]: 拓扑有序节点
"""
id_to_node = {n["id"]: n for n in nodes} id_to_node = {n["id"]: n for n in nodes}
in_degree: dict[str, int] = {n["id"]: 0 for n in nodes} in_degree: dict[str, int] = {n["id"]: 0 for n in nodes}
adj: dict[str, list[str]] = defaultdict(list) adj: dict[str, list[str]] = defaultdict(list)
@@ -88,6 +110,21 @@ def prefect_node_task(
upstream: dict[str, Any], upstream: dict[str, Any],
flow_variables: dict[str, Any], flow_variables: dict[str, Any],
) -> Any: ) -> Any:
"""
单个画布节点的 Prefect Task通过 SchedulerUtil 执行用户代码块
参数:
- vue_node_id (str): 画布节点 id
- node_type_code (str): 节点类型编码
- code_block (str): 可执行代码字符串
- args_str (str | None): 逗号分隔位置参数说明
- kwargs_str (str | None): JSON 关键字参数
- upstream (dict[str, Any]): 上游节点输出
- flow_variables (dict[str, Any]): 流程级变量
返回:
- Any: 任务执行结果
"""
job_id = f"wfnode-{vue_node_id}" job_id = f"wfnode-{vue_node_id}"
args = _parse_args(args_str) args = _parse_args(args_str)
kw = _parse_kwargs(kwargs_str) kw = _parse_kwargs(kwargs_str)
@@ -104,7 +141,16 @@ def run_workflow_prefect_flow(
flow_variables: dict[str, Any], flow_variables: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
node_templates: code -> {func, args, kwargs} 来自 task_workflow_node_type Prefect Flow按拓扑顺序提交并收集各节点结果
参数:
- ordered_nodes (list[dict]): 已排序节点列表
- edges (list[dict]): 边列表
- node_templates (dict[str, dict[str, Any]]): 类型编码到 {func, args, kwargs}来自 task_workflow_node_type
- flow_variables (dict[str, Any]): 流程变量
返回:
- dict[str, Any]: node_resultsstatus
""" """
results: dict[str, Any] = {} results: dict[str, Any] = {}
for node in ordered_nodes: for node in ordered_nodes:
@@ -148,6 +194,15 @@ def run_prefect_workflow_sync(
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
同步入口校验 DAG拓扑排序后执行 Prefect Flow 同步入口校验 DAG拓扑排序后执行 Prefect Flow
参数:
- nodes (list[dict]): 画布节点
- edges (list[dict]): 画布边
- node_templates (dict[str, dict[str, Any]]): 节点类型模板
- flow_variables (dict[str, Any]): 流程变量
返回:
- dict[str, Any]: Flow 执行汇总结果
""" """
validate_workflow_graph(nodes, edges) validate_workflow_graph(nodes, edges)
ordered = topological_sort(nodes, edges) ordered = topological_sort(nodes, edges)
@@ -160,4 +215,10 @@ def run_prefect_workflow_sync(
def utc_now_iso() -> str: def utc_now_iso() -> str:
"""
当前 UTC 时间的 ISO 8601 字符串
返回:
- str: ISO 格式时间戳
"""
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
@@ -34,6 +34,15 @@ WorkflowNodeTypeRouter = APIRouter(
async def get_workflow_node_type_options_controller( async def get_workflow_node_type_options_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
获取画布用编排节点类型选项仅启用项
参数:
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为选项列表
"""
result = await WorkflowNodeTypeService.get_options_service(auth=auth) result = await WorkflowNodeTypeService.get_options_service(auth=auth)
log.info("获取编排节点类型选项成功") log.info("获取编排节点类型选项成功")
return SuccessResponse(data=result, msg="获取编排节点类型选项成功") return SuccessResponse(data=result, msg="获取编排节点类型选项成功")
@@ -48,6 +57,16 @@ async def get_workflow_node_type_detail_controller(
id: Annotated[int, Path(description="ID")], id: Annotated[int, Path(description="ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
获取编排节点类型详情
参数:
- id (int): 主键
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为详情
"""
result_dict = await WorkflowNodeTypeService.get_detail_service(auth=auth, id=id) result_dict = await WorkflowNodeTypeService.get_detail_service(auth=auth, id=id)
log.info(f"获取编排节点类型详情成功 {id}") log.info(f"获取编排节点类型详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取编排节点类型详情成功") return SuccessResponse(data=result_dict, msg="获取编排节点类型详情成功")
@@ -63,6 +82,17 @@ async def get_workflow_node_type_list_controller(
search: Annotated[WorkflowNodeTypeQueryParam, Depends()], search: Annotated[WorkflowNodeTypeQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
分页查询编排节点类型列表
参数:
- page (PaginationQueryParam): 分页与排序
- search (WorkflowNodeTypeQueryParam): 查询条件
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为分页结果
"""
order_by = [{"sort_order": "asc"}, {"id": "asc"}] order_by = [{"sort_order": "asc"}, {"id": "asc"}]
if page.order_by: if page.order_by:
order_by = page.order_by order_by = page.order_by
@@ -86,6 +116,16 @@ async def create_workflow_node_type_controller(
data: WorkflowNodeTypeCreateSchema, data: WorkflowNodeTypeCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:create"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:create"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
创建编排节点类型
参数:
- data (WorkflowNodeTypeCreateSchema): 创建体
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为新记录
"""
result_dict = await WorkflowNodeTypeService.create_service(auth=auth, data=data) result_dict = await WorkflowNodeTypeService.create_service(auth=auth, data=data)
log.info("创建编排节点类型成功") log.info("创建编排节点类型成功")
return SuccessResponse(data=result_dict, msg="创建编排节点类型成功") return SuccessResponse(data=result_dict, msg="创建编排节点类型成功")
@@ -101,6 +141,17 @@ async def update_workflow_node_type_controller(
data: WorkflowNodeTypeUpdateSchema, data: WorkflowNodeTypeUpdateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:update"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:update"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
更新编排节点类型
参数:
- id (int): 主键
- data (WorkflowNodeTypeUpdateSchema): 更新体
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功响应data 为更新后记录
"""
result_dict = await WorkflowNodeTypeService.update_service(auth=auth, id=id, data=data) result_dict = await WorkflowNodeTypeService.update_service(auth=auth, id=id, data=data)
log.info(f"更新编排节点类型成功 {id}") log.info(f"更新编排节点类型成功 {id}")
return SuccessResponse(data=result_dict, msg="更新编排节点类型成功") return SuccessResponse(data=result_dict, msg="更新编排节点类型成功")
@@ -115,6 +166,16 @@ async def delete_workflow_node_type_controller(
ids: Annotated[list[int], Body(description="ID列表")], ids: Annotated[list[int], Body(description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:delete"]))], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:delete"]))],
) -> JSONResponse: ) -> JSONResponse:
"""
批量删除编排节点类型
参数:
- ids (list[int]): ID 列表
- auth (AuthSchema): 认证信息
返回:
- JSONResponse: 成功提示响应
"""
await WorkflowNodeTypeService.delete_service(auth=auth, ids=ids) await WorkflowNodeTypeService.delete_service(auth=auth, ids=ids)
log.info(f"删除编排节点类型成功 {ids}") log.info(f"删除编排节点类型成功 {ids}")
return SuccessResponse(msg="删除编排节点类型成功") return SuccessResponse(msg="删除编排节点类型成功")
@@ -13,12 +13,31 @@ class WorkflowNodeTypeCRUD(CRUDBase[WorkflowNodeTypeModel, WorkflowNodeTypeCreat
"""编排节点类型 CRUD""" """编排节点类型 CRUD"""
def __init__(self, auth: AuthSchema) -> None: def __init__(self, auth: AuthSchema) -> None:
"""
初始化编排节点类型 CRUD
参数:
- auth (AuthSchema): 认证信息
返回:
- None
"""
self.auth = auth self.auth = auth
super().__init__(model=WorkflowNodeTypeModel, auth=auth) super().__init__(model=WorkflowNodeTypeModel, auth=auth)
async def get_obj_by_id_crud( async def get_obj_by_id_crud(
self, id: int, preload: list[str | Any] | None = None self, id: int, preload: list[str | Any] | None = None
) -> WorkflowNodeTypeModel | None: ) -> WorkflowNodeTypeModel | None:
"""
按主键查询节点类型
参数:
- id (int): 主键
- preload (list[str | Any] | None): 预加载关系
返回:
- WorkflowNodeTypeModel | None: 实体或 None
"""
return await self.get(id=id, preload=preload) return await self.get(id=id, preload=preload)
async def get_obj_list_crud( async def get_obj_list_crud(
@@ -27,19 +46,63 @@ class WorkflowNodeTypeCRUD(CRUDBase[WorkflowNodeTypeModel, WorkflowNodeTypeCreat
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
preload: list[str | Any] | None = None, preload: list[str | Any] | None = None,
) -> Sequence[WorkflowNodeTypeModel]: ) -> Sequence[WorkflowNodeTypeModel]:
"""
条件列表查询节点类型
参数:
- search (dict | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
- preload (list[str | Any] | None): 预加载关系
返回:
- Sequence[WorkflowNodeTypeModel]: 列表
"""
return await self.list(search=search, order_by=order_by, preload=preload) return await self.list(search=search, order_by=order_by, preload=preload)
async def create_obj_crud(self, data: WorkflowNodeTypeCreateSchema) -> WorkflowNodeTypeModel | None: async def create_obj_crud(self, data: WorkflowNodeTypeCreateSchema) -> WorkflowNodeTypeModel | None:
"""
创建节点类型
参数:
- data (WorkflowNodeTypeCreateSchema): 创建模型
返回:
- WorkflowNodeTypeModel | None: 新建实体或 None
"""
return await self.create(data=data) return await self.create(data=data)
async def update_obj_crud(self, id: int, data: WorkflowNodeTypeUpdateSchema) -> WorkflowNodeTypeModel | None: async def update_obj_crud(self, id: int, data: WorkflowNodeTypeUpdateSchema) -> WorkflowNodeTypeModel | None:
"""
更新节点类型
参数:
- id (int): 主键
- data (WorkflowNodeTypeUpdateSchema): 更新模型
返回:
- WorkflowNodeTypeModel | None: 更新后实体或 None
"""
return await self.update(id=id, data=data) return await self.update(id=id, data=data)
async def delete_obj_crud(self, ids: list[int]) -> None: async def delete_obj_crud(self, ids: list[int]) -> None:
"""
批量删除节点类型
参数:
- ids (list[int]): ID 列表
返回:
- None
"""
await self.delete(ids=ids) await self.delete(ids=ids)
async def list_active_options_crud(self) -> Sequence[WorkflowNodeTypeModel]: async def list_active_options_crud(self) -> Sequence[WorkflowNodeTypeModel]:
"""画布:仅启用的类型,按 sort_order、id 排序""" """
画布用仅启用的类型 sort_orderid 排序
返回:
- Sequence[WorkflowNodeTypeModel]: 启用中的节点类型列表
"""
return await self.get_obj_list_crud( return await self.get_obj_list_crud(
search={"is_active": (QueueEnum.eq.value, True)}, search={"is_active": (QueueEnum.eq.value, True)},
order_by=[{"sort_order": "asc"}, {"id": "asc"}], order_by=[{"sort_order": "asc"}, {"id": "asc"}],
@@ -19,7 +19,15 @@ class WorkflowNodeTypeService:
@classmethod @classmethod
async def get_options_service(cls, auth: AuthSchema) -> list[dict]: async def get_options_service(cls, auth: AuthSchema) -> list[dict]:
"""画布左侧 palette:仅返回启用项,结构与前端原 Node options 对齐""" """
画布左侧 palette仅返回启用项结构与前端 Node options 对齐
参数:
- auth (AuthSchema): 认证信息
返回:
- list[dict]: 选项字典列表
"""
objs = await WorkflowNodeTypeCRUD(auth).list_active_options_crud() objs = await WorkflowNodeTypeCRUD(auth).list_active_options_crud()
return [ return [
{ {
@@ -35,6 +43,19 @@ class WorkflowNodeTypeService:
@classmethod @classmethod
async def get_detail_service(cls, auth: AuthSchema, id: int) -> dict: async def get_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取编排节点类型详情
参数:
- auth (AuthSchema): 认证信息
- id (int): 主键
返回:
- dict: 序列化后的详情
异常:
- CustomException: 不存在时抛出
"""
obj = await WorkflowNodeTypeCRUD(auth).get_obj_by_id_crud(id=id) obj = await WorkflowNodeTypeCRUD(auth).get_obj_by_id_crud(id=id)
if not obj: if not obj:
raise CustomException(msg="编排节点类型不存在") raise CustomException(msg="编排节点类型不存在")
@@ -47,6 +68,17 @@ class WorkflowNodeTypeService:
search: WorkflowNodeTypeQueryParam | None = None, search: WorkflowNodeTypeQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> list[dict]: ) -> list[dict]:
"""
获取编排节点类型列表非分页
参数:
- auth (AuthSchema): 认证信息
- search (WorkflowNodeTypeQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
返回:
- list[dict]: 字典列表
"""
if order_by is None: if order_by is None:
order_by = [{"sort_order": "asc"}, {"id": "asc"}] order_by = [{"sort_order": "asc"}, {"id": "asc"}]
obj_list = await WorkflowNodeTypeCRUD(auth).get_obj_list_crud( obj_list = await WorkflowNodeTypeCRUD(auth).get_obj_list_crud(
@@ -64,7 +96,19 @@ class WorkflowNodeTypeService:
search: WorkflowNodeTypeQueryParam | None = None, search: WorkflowNodeTypeQueryParam | None = None,
order_by: list[dict[str, str]] | None = None, order_by: list[dict[str, str]] | None = None,
) -> dict: ) -> dict:
"""分页查询编排节点类型(数据库 OFFSET/LIMIT)。""" """
分页查询编排节点类型数据库 OFFSET/LIMIT
参数:
- auth (AuthSchema): 认证信息
- page_no (int): 页码
- page_size (int): 每页条数
- search (WorkflowNodeTypeQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序
返回:
- dict: 分页结果items JSON 友好序列化
"""
offset = (page_no - 1) * page_size offset = (page_no - 1) * page_size
order = order_by or [{"sort_order": "asc"}, {"id": "asc"}] order = order_by or [{"sort_order": "asc"}, {"id": "asc"}]
result = await WorkflowNodeTypeCRUD(auth).page( result = await WorkflowNodeTypeCRUD(auth).page(
@@ -82,6 +126,19 @@ class WorkflowNodeTypeService:
@classmethod @classmethod
async def create_service(cls, auth: AuthSchema, data: WorkflowNodeTypeCreateSchema) -> dict: async def create_service(cls, auth: AuthSchema, data: WorkflowNodeTypeCreateSchema) -> dict:
"""
创建编排节点类型
参数:
- auth (AuthSchema): 认证信息
- data (WorkflowNodeTypeCreateSchema): 创建体
返回:
- dict: 新建记录字典
异常:
- CustomException: 编码重复或创建失败
"""
exist = await WorkflowNodeTypeCRUD(auth).get(code=data.code) exist = await WorkflowNodeTypeCRUD(auth).get(code=data.code)
if exist: if exist:
raise CustomException(msg="节点编码已存在") raise CustomException(msg="节点编码已存在")
@@ -92,6 +149,20 @@ class WorkflowNodeTypeService:
@classmethod @classmethod
async def update_service(cls, auth: AuthSchema, id: int, data: WorkflowNodeTypeUpdateSchema) -> dict: async def update_service(cls, auth: AuthSchema, id: int, data: WorkflowNodeTypeUpdateSchema) -> dict:
"""
更新编排节点类型
参数:
- auth (AuthSchema): 认证信息
- id (int): 主键
- data (WorkflowNodeTypeUpdateSchema): 更新体
返回:
- dict: 更新后字典
异常:
- CustomException: 不存在编码冲突或更新失败
"""
exist = await WorkflowNodeTypeCRUD(auth).get_obj_by_id_crud(id=id) exist = await WorkflowNodeTypeCRUD(auth).get_obj_by_id_crud(id=id)
if not exist: if not exist:
raise CustomException(msg="编排节点类型不存在") raise CustomException(msg="编排节点类型不存在")
@@ -106,6 +177,19 @@ class WorkflowNodeTypeService:
@classmethod @classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
批量删除编排节点类型
参数:
- auth (AuthSchema): 认证信息
- ids (list[int]): ID 列表
返回:
- None
异常:
- CustomException: ID 为空时抛出
"""
if not ids: if not ids:
raise CustomException(msg="删除ID不能为空") raise CustomException(msg="删除ID不能为空")
await WorkflowNodeTypeCRUD(auth).delete_obj_crud(ids=ids) await WorkflowNodeTypeCRUD(auth).delete_obj_crud(ids=ids)
+13 -1
View File
@@ -131,6 +131,15 @@ class InitializeData:
""" """
def create_object(obj_data: dict) -> Any: def create_object(obj_data: dict) -> Any:
"""
由单条 dict 递归构建模型实例 children
参数:
- obj_data (dict): 行数据可含嵌套 children
返回:
- Any: SQLAlchemy 模型实例
"""
# 分离 children 数据 # 分离 children 数据
children_data = obj_data.pop("children", []) children_data = obj_data.pop("children", [])
@@ -173,7 +182,10 @@ class InitializeData:
async def init_db(self) -> None: async def init_db(self) -> None:
""" """
执行完整初始化流程 执行完整初始化流程建表并导入种子数据
返回:
- None
""" """
# 先创建表结构 # 先创建表结构
await self.__init_create_table() await self.__init_create_table()
+7 -1
View File
@@ -4,7 +4,13 @@ from app.core.logger import log
def worship(env: str) -> None: def worship(env: str) -> None:
""" """
获取项目启动Banner优先读取 banner.txt 读取并打印启动 Banner优先 `banner.txt`并附带当前环境名
参数:
- env (str): 当前运行环境标识
返回:
- None
""" """
if BANNER_FILE.exists(): if BANNER_FILE.exists():
banner = BANNER_FILE.read_text(encoding="utf-8") banner = BANNER_FILE.read_text(encoding="utf-8")
+14 -73
View File
@@ -19,7 +19,7 @@ class CaptchaUtil:
生成带有噪声和干扰的验证码图片4位随机字符 生成带有噪声和干扰的验证码图片4位随机字符
返回: 返回:
- Tuple[str, str]: [base64图片字符串, 验证码值] - tuple[str, str]: Base64 PNG 字符串与验证码明文
""" """
# 生成4位随机验证码 # 生成4位随机验证码
chars = string.digits + string.ascii_letters chars = string.digits + string.ascii_letters
@@ -82,40 +82,35 @@ class CaptchaUtil:
@classmethod @classmethod
def captcha_arithmetic(cls, difficulty: str = "medium") -> tuple[str, int]: def captcha_arithmetic(cls, difficulty: str = "medium") -> tuple[str, int]:
""" """
创建验证码图片加减乘运算 创建算术验证码图片加减乘运算浅色底居中算式无旋转与干扰线/噪点
参数: 参数:
- difficulty (str): 难度级别 (easy, medium, hard) - difficulty (str): 难度级别easy / medium / hard控制数字范围与可用运算符
返回: 返回:
- Tuple[str, int]: [base64图片字符串, 计算结果] - tuple[str, int]: base64 编码的 PNG 图片字符串与正确答案整数
""" """
# 根据难度级别调整参数
difficulty_config = { difficulty_config = {
"easy": {"num_range": (1, 9), "operators": ["+", "-"], "noise_density": 0.01, "line_count": 3}, "easy": {"num_range": (1, 9), "operators": ["+", "-"]},
"medium": {"num_range": (1, 15), "operators": ["+", "-", "*"], "noise_density": 0.015, "line_count": 4}, "medium": {"num_range": (1, 15), "operators": ["+", "-", "*"]},
"hard": {"num_range": (1, 20), "operators": ["+", "-", "*"], "noise_density": 0.02, "line_count": 6} "hard": {"num_range": (1, 20), "operators": ["+", "-", "*"]},
} }
config = difficulty_config.get(difficulty, difficulty_config["medium"]) config = difficulty_config.get(difficulty, difficulty_config["medium"])
# 生成运算数字和运算符
operators = config["operators"] operators = config["operators"]
operator = random.choice(operators) operator = random.choice(operators)
num_range = config["num_range"] num_range = config["num_range"]
# 对于减法,确保num1大于num2
if operator == "-": if operator == "-":
num1 = random.randint(num_range[0] + 5, num_range[1]) num1 = random.randint(num_range[0] + 5, num_range[1])
num2 = random.randint(num_range[0], num1 - 1) num2 = random.randint(num_range[0], num1 - 1)
elif operator == "*": elif operator == "*":
# 对于乘法,限制数字大小避免结果过大
num1 = random.randint(num_range[0], min(10, num_range[1])) num1 = random.randint(num_range[0], min(10, num_range[1]))
num2 = random.randint(num_range[0], min(10, num_range[1])) num2 = random.randint(num_range[0], min(10, num_range[1]))
else: # 加法 else:
num1 = random.randint(num_range[0], num_range[1]) num1 = random.randint(num_range[0], num_range[1])
num2 = random.randint(num_range[0], num_range[1]) num2 = random.randint(num_range[0], num_range[1])
# 计算结果
result_map = { result_map = {
"+": lambda x, y: x + y, "+": lambda x, y: x + y,
"-": lambda x, y: x - y, "-": lambda x, y: x - y,
@@ -123,73 +118,19 @@ class CaptchaUtil:
} }
captcha_value = result_map[operator](num1, num2) captcha_value = result_map[operator](num1, num2)
# 创建空白图像,使用随机浅色背景
width, height = 160, 60 width, height = 160, 60
background_color = tuple(random.randint(230, 255) for _ in range(3)) image = Image.new("RGB", (width, height), color=(248, 249, 250))
image = Image.new("RGB", (width, height), color=background_color)
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
# 设置字体
font = ImageFont.truetype(font=settings.CAPTCHA_FONT_PATH, size=settings.CAPTCHA_FONT_SIZE) font = ImageFont.truetype(font=settings.CAPTCHA_FONT_PATH, size=settings.CAPTCHA_FONT_SIZE)
# 绘制文本,使用深色增加对比度
text = f"{num1} {operator} {num2} = ?" text = f"{num1} {operator} {num2} = ?"
text_bbox = draw.textbbox((0, 0), text, font=font) tb = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0] tw, th = tb[2] - tb[0], tb[3] - tb[1]
x = (width - text_width) // 2 x = (width - tw) // 2
y = 15 y = (height - th) // 2 - tb[1]
draw.text((x, y), text, fill=(55, 65, 81), font=font)
# 随机偏移和旋转文本
rotation = random.uniform(-5, 5)
text_image = Image.new("RGBA", (width, height), (255, 255, 255, 0))
text_draw = ImageDraw.Draw(text_image)
text_draw.text((x, y), text, fill=(0, 0, 139), font=font)
text_image = text_image.rotate(rotation, expand=1)
# 计算粘贴位置,使旋转后的文本居中
text_width, text_height = text_image.size
paste_x = (width - text_width) // 2
paste_y = (height - text_height) // 2
image.paste(text_image, (paste_x, paste_y), text_image)
# 添加干扰线
for _ in range(config["line_count"]):
line_color = tuple(random.randint(120, 180) for _ in range(3))
# 生成随机曲线
points = []
for i in range(0, width, 15):
points.append((i, int(random.uniform(0, height))))
draw.line(points, fill=line_color, width=2)
# 添加随机噪点
noise_count = int(width * height * config["noise_density"])
for _ in range(noise_count):
point_color = tuple(random.randint(0, 255) for _ in range(3))
draw.point(
(random.randint(0, width), random.randint(0, height)),
fill=point_color,
)
# 添加背景纹理
for _ in range(15):
texture_color = tuple(random.randint(200, 240) for _ in range(3))
radius = random.randint(3, 10)
x0 = random.randint(0, width - radius)
y0 = random.randint(0, height - radius)
x1 = x0 + radius
y1 = y0 + radius
draw.ellipse(
(
x0,
y0,
x1,
y1
),
fill=texture_color,
outline=None
)
# 将图像数据保存到内存中并转换为base64
buffer = BytesIO() buffer = BytesIO()
image.save(buffer, format="PNG", optimize=True) image.save(buffer, format="PNG", optimize=True)
base64_string = base64.b64encode(buffer.getvalue()).decode() base64_string = base64.b64encode(buffer.getvalue()).decode()
+60 -26
View File
@@ -78,7 +78,12 @@ def get_random_character() -> str:
def uuid4_str() -> str: def uuid4_str() -> str:
"""数据库引擎 UUID 类型兼容性解决方案""" """
数据库引擎 UUID 类型兼容返回无连字符的 UUID 字符串
返回:
- str: UUID 字符串
"""
return str(uuid.uuid4()) return str(uuid.uuid4())
@@ -251,7 +256,15 @@ def bytes2human(n: int, format_str: str = "%(value).1f%(symbol)s") -> str:
def bytes2file_response(bytes_info: bytes) -> Generator[bytes, Any, None]: def bytes2file_response(bytes_info: bytes) -> Generator[bytes, Any, None]:
"""生成文件响应""" """
将字节内容封装为单块流式生成器供文件下载响应使用
参数:
- bytes_info (bytes): 文件二进制内容
返回:
- Generator[bytes, Any, None]: yield 一次的字节生成器
"""
yield bytes_info yield bytes_info
@@ -286,11 +299,14 @@ class SqlalchemyUtil:
transform_case: Literal["no_case", "snake_to_camel", "camel_to_snake"] = "no_case", transform_case: Literal["no_case", "snake_to_camel", "camel_to_snake"] = "no_case",
): ):
""" """
sqlalchemy模型对象转换为字典 SQLAlchemy 模型或字典转为普通 dict并可做键名大小写转换
:param obj: sqlalchemy模型对象或普通字典 参数:
:param transform_case: 转换得到的结果形式可选的有'no_case'(不转换)'snake_to_camel'(下划线转小驼峰)'camel_to_snake'(小驼峰转下划线)默认为'no_case' - obj (DeclarativeBase | dict[str, Any]): 模型实例或字典
:return: 字典结果 - transform_case (Literal[...]): no_case / snake_to_camel / camel_to_snake
返回:
- dict[str, Any]: 扁平字典结果
""" """
if isinstance(obj, DeclarativeBase): if isinstance(obj, DeclarativeBase):
base_dict = obj.__dict__.copy() base_dict = obj.__dict__.copy()
@@ -314,11 +330,14 @@ class SqlalchemyUtil:
transform_case: Literal["no_case", "snake_to_camel", "camel_to_snake"] = "no_case", transform_case: Literal["no_case", "snake_to_camel", "camel_to_snake"] = "no_case",
): ):
""" """
sqlalchemy查询结果序列化 SQLAlchemy 查询结果模型列表Row 递归序列化为可 JSON 化结构
:param result: sqlalchemy查询结果 参数:
:param transform_case: 'no_case'(不转换)'snake_to_camel'(下划线转小驼峰)'camel_to_snake'(小驼峰转下划线)默认为'no_case' - result (Any): ORM 对象列表Row
:return: 序列化结果 - transform_case (Literal[...]): 键名转换策略
返回:
- Any: 序列化后的 Python 内置类型或嵌套结构
""" """
if isinstance(result, (DeclarativeBase, dict)): if isinstance(result, (DeclarativeBase, dict)):
return cls.base_to_dict(result, transform_case) return cls.base_to_dict(result, transform_case)
@@ -342,11 +361,14 @@ class SqlalchemyUtil:
cls, dialect_name: str, need_explicit_null: bool = True cls, dialect_name: str, need_explicit_null: bool = True
) -> Null | None: ) -> Null | None:
""" """
根据数据库方言动态返回值为null的server_default 按方言返回列默认值中的 NULL 表达PostgreSQL 可显式 DEFAULT NULL
:param dialect_name: 数据库方言名称 参数:
:param need_explicit_null: 是否需要显式DEFAULT NULL - dialect_name (str): 数据库方言名
:return: 不同数据库方言对应的null_server_default - need_explicit_null (bool): 是否生成显式 NULL 默认值
返回:
- Null | None: SQLAlchemy null() None
""" """
if need_explicit_null and dialect_name == "postgres": if need_explicit_null and dialect_name == "postgres":
return null() return null()
@@ -361,10 +383,13 @@ class CamelCaseUtil:
@classmethod @classmethod
def snake_to_camel(cls, snake_str: str): def snake_to_camel(cls, snake_str: str):
""" """
下划线形式字符串(snake_case)为小驼峰形式字符串(camelCase) 下划线形式 (snake_case) 转为小驼峰形式 (camelCase)
:param snake_str: 下划线形式字符串 参数:
:return: 小驼峰形式字符串 - snake_str (str): 下划线分隔字符串
返回:
- str: 合并首字母大写后的驼峰字符串
""" """
# 分割字符串 # 分割字符串
words = snake_str.split("_") words = snake_str.split("_")
@@ -376,10 +401,13 @@ class CamelCaseUtil:
@classmethod @classmethod
def transform_result(cls, result: Any): def transform_result(cls, result: Any):
""" """
针对不同类型将下划线形式(snake_case)批量转换为小驼峰形式(camelCase)方法 将查询结果递归序列化并将键名转为小驼峰
:param result: 输入数据 参数:
:return: 小驼峰形式结果 - result (Any): ORM 查询结果或嵌套结构
返回:
- Any: 小驼峰键名的序列化结果
""" """
return SqlalchemyUtil.serialize_result(result=result, transform_case="snake_to_camel") return SqlalchemyUtil.serialize_result(result=result, transform_case="snake_to_camel")
@@ -392,10 +420,13 @@ class SnakeCaseUtil:
@classmethod @classmethod
def camel_to_snake(cls, camel_str: str): def camel_to_snake(cls, camel_str: str):
""" """
小驼峰形式字符串(camelCase)为下划线形式字符串(snake_case) 小驼峰形式 (camelCase) 转为下划线形式 (snake_case)
:param camel_str: 小驼峰形式字符串 参数:
:return: 下划线形式字符串 - camel_str (str): 驼峰字符串
返回:
- str: 下划线分隔且全小写
""" """
# 在大写字母前添加一个下划线,然后将整个字符串转为小写 # 在大写字母前添加一个下划线,然后将整个字符串转为小写
words = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", camel_str) words = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", camel_str)
@@ -404,9 +435,12 @@ class SnakeCaseUtil:
@classmethod @classmethod
def transform_result(cls, result: Any): def transform_result(cls, result: Any):
""" """
针对不同类型将下划线形式(snake_case)批量转换为小驼峰形式(camelCase)方法 将查询结果递归序列化并将键名转为下划线形式
:param result: 输入数据 参数:
:return: 小驼峰形式结果 - result (Any): ORM 查询结果或嵌套结构
返回:
- Any: 下划线键名的序列化结果
""" """
return SqlalchemyUtil.serialize_result(result=result, transform_case="camel_to_snake") return SqlalchemyUtil.serialize_result(result=result, transform_case="camel_to_snake")
+21 -2
View File
@@ -20,7 +20,21 @@ def console_run(
scheduler_ready: bool | None = None, scheduler_ready: bool | None = None,
limiter_ready: bool | None = None, limiter_ready: bool | None = None,
) -> None: ) -> None:
"""显示启动信息面板""" """
在终端输出 Rich 面板服务信息组件就绪状态与文档链接
参数:
- host (str): 监听主机
- port (int): 监听端口
- reload (bool): 是否开启热重载
- database_ready (bool | None): 数据库是否就绪
- redis_ready (bool | None): Redis 是否就绪
- scheduler_ready (bool | None): 调度器是否就绪
- limiter_ready (bool | None): 限流器是否就绪
返回:
- None
"""
url = f"http://{host}:{port}" url = f"http://{host}:{port}"
base_url = f"{url}{settings.ROOT_PATH}" base_url = f"{url}{settings.ROOT_PATH}"
@@ -85,7 +99,12 @@ def console_run(
def console_close() -> None: def console_close() -> None:
"""显示关闭信息""" """
在终端输出服务关闭提示面板
返回:
- None
"""
shutdown_content = Text() shutdown_content = Text()
shutdown_content.append("🛑 ", style="bold red") shutdown_content.append("🛑 ", style="bold red")
shutdown_content.append("FastapiAdmin 服务关闭") shutdown_content.append("FastapiAdmin 服务关闭")
+3 -1
View File
@@ -9,7 +9,9 @@ from openpyxl.worksheet.datavalidation import DataValidation
class ExcelUtil: class ExcelUtil:
"""Excel文件处理工具类""" """
Excel 模板生成与列表导出openpyxl / pandas
"""
@classmethod @classmethod
def __mapping_list(cls, list_data: list[dict[str, Any]], mapping_dict: dict) -> list: def __mapping_list(cls, list_data: list[dict[str, Any]], mapping_dict: dict) -> list:
+36 -14
View File
@@ -12,23 +12,31 @@ from app.core.exceptions import CustomException
class ImportUtil: class ImportUtil:
"""
扫描工程中的 ORM 模型文件并做有效性校验的辅助类
"""
@classmethod @classmethod
def find_project_root(cls) -> Path: def find_project_root(cls) -> Path:
""" """
查找项目根目录 返回项目根目录与配置中的 `BASE_DIR` 一致
:return: 项目根目录路径 返回:
- Path: 项目根路径
""" """
return BASE_DIR return BASE_DIR
@classmethod @classmethod
def is_valid_model(cls, obj: Any, base_class: type) -> bool: def is_valid_model(cls, obj: Any, base_class: type) -> bool:
""" """
验证是否为有效的SQLAlchemy模型类 判断是否为可映射的 SQLAlchemy 模型类含表名与非空列
:param obj: 待验证的对象 参数:
:param base_class: SQLAlchemy的基类 - obj (Any): 待验证对象一般为类
:return: 验证结果 - base_class (type): ORM 声明基类
返回:
- bool: 是否为有效模型类
""" """
# 必须继承自base_class且不是base_class本身 # 必须继承自base_class且不是base_class本身
if not (inspect.isclass(obj) and issubclass(obj, base_class) and obj is not base_class): if not (inspect.isclass(obj) and issubclass(obj, base_class) and obj is not base_class):
@@ -48,10 +56,17 @@ class ImportUtil:
@lru_cache(maxsize=256) @lru_cache(maxsize=256)
def find_models(cls, base_class: type) -> list[Any]: def find_models(cls, base_class: type) -> list[Any]:
""" """
查找并过滤有效模型类避免重复和无效定义 遍历工程内 `model.py` / `models.py`收集去重后的有效模型类
:param base_class: SQLAlchemy的Base类用于验证模型类 参数:
:return: 有效模型类列表 - base_class (type): SQLAlchemy 声明基类
返回:
- list[Any]: 模型类列表
异常:
- ImportError: 模块导入失败无法从某名导入类警告
- CustomException: 处理模块时发生未预期错误
""" """
models = [] models = []
# 按类对象去重 # 按类对象去重
@@ -156,12 +171,19 @@ class ImportUtil:
seen_tables: set[str], seen_tables: set[str],
) -> None: ) -> None:
""" """
专门查找APScheduler相关的模型 尝试从调度相关模块补充 `apscheduler_jobs` 表对应模型
:param base_class: SQLAlchemy的Base类 参数:
:param models: 模型列表 - base_class (type): ORM 声明基类
:param seen_models: 已处理的模型集合 - models (list[Any]): 已收集模型列表就地追加
:param seen_tables: 已处理的表名集合 - seen_models (set[Any]): 已见模型对象集合
- seen_tables (set[str]): 已见表名集合
返回:
- None
异常:
- CustomException: 扫描过程出现未预期错误
""" """
# 尝试从apscheduler相关模块导入 # 尝试从apscheduler相关模块导入
try: try:
+9 -3
View File
@@ -44,10 +44,16 @@ class IpLocalUtil:
@classmethod @classmethod
async def resolve_location_for_log(cls, ip: str | None) -> str | None: async def resolve_location_for_log(cls, ip: str | None) -> str | None:
""" """
登录与操作日志写入 ``login_location`` 时统一入口 登录与操作日志写入 ``login_location`` 统一解析入口
``settings.LOGIN_RESOLVE_IP_LOCATION`` 一致 ``False`` 时不请求外网 ``settings.LOGIN_RESOLVE_IP_LOCATION`` 联动关闭时不请求外网仅返回占位描述
避免登录 POST ``OperationLogRoute`` 收尾阶段仍触发 IP 归属地查询导致变慢 避免登录 POST ``OperationLogRoute`` 收尾阶段因外网查询变慢
参数:
- ip (str | None): 客户端 IP可为空
返回:
- str | None: 展示用归属地文案无需解析时可能为 ``None``
""" """
if not ip: if not ip:
return None return None
+6 -3
View File
@@ -119,13 +119,16 @@ def sanitize_html(content: str) -> str:
def sanitize_html_with_styles(content: str) -> str: def sanitize_html_with_styles(content: str) -> str:
""" """
清理 HTML 内容包含样式移除潜在的 XSS 攻击代码 清理 HTML 内容标签与属性白名单与 `sanitize_html` 一致
参数: 参数:
- content (str): 需要清理的 HTML 内容 - content (str): 需要清理的 HTML 内容
返回: 返回:
- str: 清理后的安全 HTML 内容 - str: 清理后的 HTML 字符串
说明:
- `ALLOWED_STYLES` 供后续接入 bleach 样式清洗时使用当前实现与 `sanitize_html` 相同
""" """
if not content: if not content:
return content return content
+33 -4
View File
@@ -14,7 +14,12 @@ alembic_cfg = Config("alembic.ini")
def create_app() -> FastAPI: def create_app() -> FastAPI:
"""创建 FastAPI 应用实例""" """
创建 FastAPI 应用实例并完成日志中间件路由与静态资源注册
返回:
- FastAPI: 已配置生命周期的应用对象
"""
from app.config.setting import settings from app.config.setting import settings
from app.plugin.init_app import ( from app.plugin.init_app import (
lifespan, lifespan,
@@ -56,7 +61,15 @@ def run(
EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)") EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)")
] = EnvironmentEnum.DEV, ] = EnvironmentEnum.DEV,
) -> None: ) -> None:
"""启动FastAPI服务""" """
按指定环境加载配置并启动 Uvicorn开发环境开启 reload
参数:
- env (EnvironmentEnum): 运行环境对应 `--env`
返回:
- None
"""
try: try:
# 设置环境变量 # 设置环境变量
@@ -104,7 +117,15 @@ def revision(
EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)") EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)")
] = EnvironmentEnum.DEV, ] = EnvironmentEnum.DEV,
) -> None: ) -> None:
"""生成新的 Alembic 迁移脚本""" """
使用 Alembic 自动生成迁移脚本autogenerate
参数:
- env (EnvironmentEnum): 运行环境用于加载对应数据库模型元数据
返回:
- None
"""
os.environ["ENVIRONMENT"] = env.value os.environ["ENVIRONMENT"] = env.value
command.revision(alembic_cfg, autogenerate=True, message="迁移脚本") command.revision(alembic_cfg, autogenerate=True, message="迁移脚本")
typer.echo("迁移脚本已生成") typer.echo("迁移脚本已生成")
@@ -119,7 +140,15 @@ def upgrade(
EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)") EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)")
] = EnvironmentEnum.DEV, ] = EnvironmentEnum.DEV,
) -> None: ) -> None:
"""应用最新的 Alembic 迁移""" """
将数据库升级到 Alembic 最新版本head
参数:
- env (EnvironmentEnum): 运行环境
返回:
- None
"""
os.environ["ENVIRONMENT"] = env.value os.environ["ENVIRONMENT"] = env.value
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
typer.echo("所有迁移已应用。") typer.echo("所有迁移已应用。")
+6
View File
@@ -15,5 +15,11 @@ app = create_app()
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def test_client(): def test_client():
"""
模块级 HTTP 测试客户端复用同一应用实例与生命周期
返回:
- TestClient: 供用例发起的同步测试客户端yield 注入
"""
with TestClient(app) as client: with TestClient(app) as client:
yield client yield client
+19 -6
View File
@@ -1,8 +1,8 @@
""" """
测试文件 后端接口测试入口
注意使用普通的 def 定义测试函数不要使用 async def 注意测试函数使用同步 `def` TestClient 驱动勿对用例本身使用 `async def`
执行命令: pytest tests/test.py 执行示例: `pytest tests/test_main.py` `pytest tests/`
""" """
import pytest import pytest
@@ -10,10 +10,23 @@ from fastapi.testclient import TestClient
def test_check_health(test_client: TestClient) -> None: def test_check_health(test_client: TestClient) -> None:
"""测试健康检查接口""" """
response = test_client.get("/common/health") 校验 `/common/health/` 返回统一成功响应结构
参数:
- test_client (TestClient): pytest 注入的客户端
返回:
- None
"""
response = test_client.get("/common/health/")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"msg": "Healthy"} body = response.json()
assert body["success"] is True
assert body["code"] == 0
assert body["msg"] == "系统健康"
assert body["data"] is True
assert body["status_code"] == 200
# 运行所有测试 # 运行所有测试
@@ -732,17 +732,18 @@ const fullMenuTree = ref<MenuTable[]>([]);
/** 从表格「在菜单下新增」进入时锁定父级(仅允许按钮) */ /** 从表格「在菜单下新增」进入时锁定父级(仅允许按钮) */
const createParentLocked = ref(false); const createParentLocked = ref(false);
/** 目录下:目录、菜单、外链;菜单下:仅按钮 */
function typesAllowedUnderParent(parentType: MenuTypeEnum): MenuTypeEnum[] { function typesAllowedUnderParent(parentType: MenuTypeEnum): MenuTypeEnum[] {
switch (parentType) { switch (parentType) {
case MenuTypeEnum.CATALOG: case MenuTypeEnum.CATALOG:
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK]; return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.EXTLINK];
case MenuTypeEnum.MENU: case MenuTypeEnum.MENU:
return [MenuTypeEnum.BUTTON]; return [MenuTypeEnum.BUTTON];
case MenuTypeEnum.BUTTON: case MenuTypeEnum.BUTTON:
case MenuTypeEnum.EXTLINK: case MenuTypeEnum.EXTLINK:
return []; return [];
default: default:
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK]; return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.EXTLINK];
} }
} }
@@ -768,11 +769,11 @@ const allowedMenuTypeValues = computed((): MenuTypeEnum[] => {
} }
const pid = formData.parent_id; const pid = formData.parent_id;
if (pid == null || pid === undefined) { if (pid == null || pid === undefined) {
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK]; return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.EXTLINK];
} }
const parentNode = findMenuNodeById(pid); const parentNode = findMenuNodeById(pid);
if (!parentNode?.type) { if (!parentNode?.type) {
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK]; return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.EXTLINK];
} }
return typesAllowedUnderParent(parentNode.type as MenuTypeEnum); return typesAllowedUnderParent(parentNode.type as MenuTypeEnum);
}); });
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.user.model import UserModel
class AuthSchema(BaseModel):
"""权限认证模型"""
model_config = ConfigDict(arbitrary_types_allowed=True)
user: UserModel | None = Field(default=None, description="用户信息")
check_data_scope: bool = Field(default=True, description="是否检查数据权限")
db: AsyncSession = Field(description="数据库会话")
current_permission: str | None = Field(default=None, description="当前请求的权限标识")
class JWTPayloadSchema(BaseModel):
"""JWT载荷模型"""
sub: str = Field(..., description="用户登录信息")
is_refresh: bool = Field(default=False, description="是否刷新token")
exp: datetime | int = Field(..., description="过期时间")
@model_validator(mode="after")
def validate_fields(self):
if not self.sub or len(self.sub.strip()) == 0:
raise ValueError("会话编号不能为空")
return self
class JWTOutSchema(BaseModel):
"""JWT响应模型"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., min_length=1, description="访问token")
refresh_token: str = Field(..., min_length=1, description="刷新token")
token_type: str = Field(default="Bearer", description="token类型")
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
class RefreshTokenPayloadSchema(BaseModel):
"""刷新Token载荷模型"""
refresh_token: str = Field(..., min_length=1, description="刷新token")
class LogoutPayloadSchema(BaseModel):
"""退出登录载荷模型"""
token: str = Field(..., min_length=1, description="token")
class CaptchaOutSchema(BaseModel):
"""验证码响应模型"""
model_config = ConfigDict(from_attributes=True)
enable: bool = Field(default=True, description="是否启用验证码")
key: str = Field(..., min_length=1, description="验证码唯一标识")
img_base: str = Field(..., min_length=1, description="Base64编码的验证码图片")
class AutoLoginUserSchema(BaseModel):
"""免登录用户信息模型"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="用户ID")
username: str = Field(..., description="用户名")
name: str = Field(..., description="用户姓名")
avatar: str | None = Field(default=None, description="头像")
class AutoLoginTokenSchema(BaseModel):
"""免登录Token响应模型"""
model_config = ConfigDict(from_attributes=True)
token: str = Field(..., description="免登录Token")
user: AutoLoginUserSchema = Field(..., description="用户信息")
+199
View File
@@ -0,0 +1,199 @@
from collections.abc import Sequence
from sqlalchemy import delete, select, update
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_system.dept.crud import DeptCRUD
from app.api.v1.module_system.menu.crud import MenuCRUD
from app.core.base_crud import CRUDBase
from .model import RoleMenuDeptsModel, RoleMenusModel, RoleModel
from .schema import MenuDataScopeItem, RoleCreateSchema, RoleUpdateSchema
class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
"""角色模块数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化角色模块数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
self.auth = auth
super().__init__(model=RoleModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list | None = None) -> RoleModel | None:
"""
根据id获取角色信息
参数:
- id (int): 角色ID
- preload (list | None): 预加载选项
返回:
- RoleModel | None: 角色模型对象
"""
return await self.get(id=id, preload=preload)
async def get_list_crud(
self,
search: dict | None = None,
order_by: list | None = None,
preload: list | None = None,
) -> Sequence[RoleModel]:
"""
获取角色列表
参数:
- search (dict | None): 查询参数
- order_by (list | None): 排序参数
- preload (list | None): 预加载选项
返回:
- Sequence[RoleModel]: 角色模型对象列表
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def set_role_menus_crud(self, role_ids: list[int], menu_ids: list[int]) -> None:
"""
设置角色的菜单权限
参数:
- role_ids (List[int]): 角色ID列表
- menu_ids (List[int]): 菜单ID列表
返回:
- None
"""
roles = await self.list(search={"id": ("in", role_ids)})
menus = await MenuCRUD(self.auth).get_list_crud(search={"id": ("in", menu_ids)})
for obj in roles:
relationship = obj.menus
relationship.clear()
relationship.extend(menus)
await self.auth.db.flush()
async def set_role_data_scope_crud(self, role_ids: list[int], data_scope: int) -> None:
"""
设置角色的数据范围
参数:
- role_ids (list[int]): 角色ID列表
- data_scope (int): 数据范围
返回:
- None
"""
await self.set(ids=role_ids, data_scope=data_scope)
async def set_role_depts_crud(self, role_ids: list[int], dept_ids: list[int]) -> None:
"""
设置角色的部门权限
参数:
- role_ids (list[int]): 角色ID列表
- dept_ids (list[int]): 部门ID列表
返回:
- None
"""
roles = await self.list(search={"id": ("in", role_ids)})
depts = await DeptCRUD(self.auth).get_list_crud(search={"id": ("in", dept_ids)})
for obj in roles:
relationship = obj.depts
relationship.clear()
relationship.extend(depts)
await self.auth.db.flush()
async def set_available_crud(self, ids: list[int], status: str) -> None:
"""
设置角色的可用状态
参数:
- ids (list[int]): 角色ID列表
- status (str): 可用状态
返回:
- None
"""
await self.set(ids=ids, status=status)
async def set_role_menu_data_scopes_crud(
self, role_id: int, menu_data_scopes: list[MenuDataScopeItem]
) -> None:
"""
设置角色的菜单级数据权限
参数:
- role_id (int): 角色ID
- menu_data_scopes (list[MenuDataScopeItem]): 菜单级数据权限配置列表
返回:
- None
"""
for item in menu_data_scopes:
# 更新 sys_role_menus 中的 data_scope
await self.auth.db.execute(
update(RoleMenusModel)
.where(RoleMenusModel.role_id == role_id, RoleMenusModel.menu_id == item.menu_id)
.values(data_scope=item.data_scope)
)
# 先清除该(角色, 菜单)对的旧自定义部门记录
await self.auth.db.execute(
delete(RoleMenuDeptsModel).where(
RoleMenuDeptsModel.role_id == role_id,
RoleMenuDeptsModel.menu_id == item.menu_id,
)
)
# 如果 data_scope=5,写入新的自定义部门记录
if item.data_scope == 5 and item.dept_ids:
for dept_id in item.dept_ids:
self.auth.db.add(
RoleMenuDeptsModel(role_id=role_id, menu_id=item.menu_id, dept_id=dept_id)
)
await self.auth.db.flush()
async def get_role_menu_data_scopes_crud(self, role_id: int) -> list[dict]:
"""
获取角色的菜单级数据权限配置
参数:
- role_id (int): 角色ID
返回:
- list[dict]: 菜单级数据权限配置列表
"""
# 查询所有设置了菜单级 data_scope 的记录
result = await self.auth.db.execute(
select(RoleMenusModel.menu_id, RoleMenusModel.data_scope).where(
RoleMenusModel.role_id == role_id,
RoleMenusModel.data_scope.isnot(None),
)
)
rows = result.all()
items = []
for row in rows:
dept_ids: list[int] = []
if row.data_scope == 5:
# 查询自定义部门
dept_result = await self.auth.db.execute(
select(RoleMenuDeptsModel.dept_id).where(
RoleMenuDeptsModel.role_id == role_id,
RoleMenuDeptsModel.menu_id == row.menu_id,
)
)
dept_ids = [r.dept_id for r in dept_result.all()]
items.append(
{"menu_id": row.menu_id, "data_scope": row.data_scope, "dept_ids": dept_ids}
)
return items
+301
View File
@@ -0,0 +1,301 @@
import json
from collections.abc import AsyncGenerator
from fastapi import Depends, Query, Request
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_system.user.crud import UserCRUD
from app.api.v1.module_system.user.model import UserModel
from app.common.enums import RedisInitKeyConfig
from app.config.setting import settings
from app.core.database import async_db_session
from app.core.exceptions import CustomException
from app.core.logger import log
from app.core.redis_crud import RedisCURD
from app.core.security import OAuth2Schema, decode_access_token
async def db_getter() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话连接
返回:
- AsyncSession: 数据库会话连接
"""
async with async_db_session() as session:
async with session.begin():
yield session
async def redis_getter(request: Request) -> Redis:
"""获取Redis连接
参数:
- request (Request): 请求对象
返回:
- Redis: Redis连接
"""
return request.app.state.redis
async def get_current_user(
request: Request,
db: AsyncSession = Depends(db_getter),
redis: Redis = Depends(redis_getter),
token: str = Depends(OAuth2Schema),
) -> AuthSchema:
"""获取当前用户
参数:
- request (Request): 请求对象
- db (AsyncSession): 数据库会话
- redis (Redis): Redis连接
- token (str): 访问令牌
返回:
- AuthSchema: 认证信息模型
"""
if not token:
raise CustomException(msg="认证已失效", code=10401, status_code=401)
# 处理Bearer token
if token.startswith("Bearer"):
token = token.split(" ")[1]
payload = decode_access_token(token)
if not payload or not hasattr(payload, "is_refresh") or payload.is_refresh:
raise CustomException(msg="非法凭证", code=10401, status_code=401)
online_user_info = payload.sub
# 从Redis中获取用户信息
user_info = json.loads(online_user_info) # 确保是字典类型
session_id = user_info.get("session_id")
if not session_id:
raise CustomException(msg="认证已失效", code=10401, status_code=401)
# 检查用户是否在线
online_ok = await RedisCURD(redis).exists(
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}"
)
if not online_ok:
raise CustomException(msg="认证已失效", code=10401, status_code=401)
# 如果启用了滑动过期,自动续期token
if settings.TOKEN_SLIDING_EXPIRE:
await RedisCURD(redis).expire(
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
expire=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
await RedisCURD(redis).expire(
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
expire=settings.REFRESH_TOKEN_EXPIRE_MINUTES,
)
# 关闭数据权限过滤,避免当前用户查询被拦截
auth = AuthSchema(db=db, check_data_scope=False)
username = user_info.get("user_name")
if not username:
raise CustomException(msg="认证已失效", code=10401, status_code=401)
# 获取用户信息,使用深层预加载确保RoleModel.creator被正确加载
user = await UserCRUD(auth).get_by_username_crud(
username=username,
preload=[
"dept",
selectinload(UserModel.roles),
"positions",
"created_by",
],
)
if not user:
raise CustomException(msg="用户不存在", code=10401, status_code=401)
if user.status == "1":
raise CustomException(msg="用户已被停用", code=10401, status_code=401)
# 设置请求上下文
request.scope["user_id"] = user.id
request.scope["user_username"] = user.username
# 过滤可用的角色和职位
if hasattr(user, "roles"):
user.roles = [role for role in user.roles if role and role.status]
if hasattr(user, "positions"):
user.positions = [pos for pos in user.positions if pos and pos.status]
auth.user = user
return auth
async def get_current_user_ws(
token: str = Query(..., description="认证token"),
db: AsyncSession = Depends(db_getter),
redis: Redis = Depends(redis_getter),
) -> AuthSchema:
"""获取当前用户(WebSocket专用,从查询参数获取token)
参数:
- token (str): 认证token
- db (AsyncSession): 数据库会话
- redis (Redis): Redis连接
返回:
- AuthSchema: 认证信息模型
"""
return await _verify_token(token, db, redis)
async def _verify_token(
token: str,
db: AsyncSession,
redis: Redis,
) -> AuthSchema:
"""验证token并返回用户信息
参数:
- token (str): 认证token
- db (AsyncSession): 数据库会话
- redis (Redis): Redis连接
返回:
- AuthSchema: 认证信息模型
"""
if not token:
raise CustomException(msg="认证已失效", code=10401, status_code=401)
# 处理Bearer token(如果通过查询参数传递时包含Bearer前缀)
if token.startswith("Bearer"):
token = token.split(" ")[1]
payload = decode_access_token(token)
if not payload or not hasattr(payload, "is_refresh") or payload.is_refresh:
raise CustomException(msg="非法凭证", code=10401, status_code=401)
online_user_info = payload.sub
# 从Redis中获取用户信息
user_info = json.loads(online_user_info) # 确保是字典类型
session_id = user_info.get("session_id")
if not session_id:
raise CustomException(msg="认证已失效", code=10401, status_code=401)
# 检查用户是否在线
online_ok = await RedisCURD(redis).exists(
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}"
)
if not online_ok:
raise CustomException(msg="认证已失效", code=10401, status_code=401)
# 如果启用了滑动过期,自动续期token
if settings.TOKEN_SLIDING_EXPIRE:
await RedisCURD(redis).expire(
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
expire=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
await RedisCURD(redis).expire(
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
expire=settings.REFRESH_TOKEN_EXPIRE_MINUTES,
)
# 关闭数据权限过滤,避免当前用户查询被拦截
auth = AuthSchema(db=db, check_data_scope=False)
username = user_info.get("user_name")
if not username:
raise CustomException(msg="认证已失效", code=10401, status_code=401)
# 获取用户信息,使用深层预加载确保RoleModel.creator被正确加载
user = await UserCRUD(auth).get_by_username_crud(
username=username,
preload=[
"dept",
selectinload(UserModel.roles),
"positions",
"created_by",
],
)
if not user:
raise CustomException(msg="用户不存在", code=10401, status_code=401)
if user.status == "1":
raise CustomException(msg="用户已被停用", code=10401, status_code=401)
# 设置请求上下文
# request.scope["user_id"] = user.id
# request.scope["user_username"] = user.username
# 过滤可用的角色和职位
if hasattr(user, "roles"):
user.roles = [role for role in user.roles if role and role.status]
if hasattr(user, "positions"):
user.positions = [pos for pos in user.positions if pos and pos.status]
auth.user = user
return auth
class AuthPermission:
"""权限验证类"""
def __init__(
self,
permissions: list[str] | None = None,
check_data_scope: bool = True,
) -> None:
"""
初始化权限验证
参数:
- permissions (list[str] | None): 权限标识列表
- check_data_scope (bool): 是否启用严格模式校验
"""
self.permissions = permissions or []
self.check_data_scope = check_data_scope
async def __call__(self, auth: AuthSchema = Depends(get_current_user)) -> AuthSchema:
"""
调用权限验证
参数:
- auth (AuthSchema): 认证信息对象
返回:
- AuthSchema: 认证信息对象
"""
auth.check_data_scope = self.check_data_scope
# 超级管理员直接通过
if auth.user and auth.user.is_superuser:
return auth
# 无需验证权限
if not self.permissions:
return auth
# 超级管理员权限标识
if "*" in self.permissions or "*:*:*" in self.permissions:
return auth
# 检查用户是否有角色
if not auth.user or not auth.user.roles:
raise CustomException(msg="无权限操作", code=10403, status_code=403)
# 获取用户权限集合
user_permissions = {
menu.permission
for role in auth.user.roles
for menu in role.menus
if role.status == "0" and menu.permission and menu.status == "0"
}
# 权限验证 - 满足任一权限即可
if not any(perm in user_permissions for perm in self.permissions):
log.error(f"用户缺少任何所需的权限: {self.permissions}")
raise CustomException(msg="无权限操作", code=10403, status_code=403)
# 记录匹配到的权限标识,供数据权限引擎使用
for perm in self.permissions:
if perm in user_permissions:
auth.current_permission = perm
break
return auth
+137
View File
@@ -0,0 +1,137 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.common.enums import PermissionFilterStrategy
from app.core.base_model import MappedBase, ModelMixin
if TYPE_CHECKING:
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.menu.model import MenuModel
from app.api.v1.module_system.user.model import UserModel
class RoleMenusModel(MappedBase):
"""
角色菜单关联表
定义角色与菜单的多对多关系用于权限控制
支持菜单级数据权限覆盖data_scope NULL 时继承角色级 data_scope
"""
__tablename__: str = "sys_role_menus"
__table_args__: dict[str, str] = {"comment": "角色菜单关联表"}
role_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_role.id", ondelete="CASCADE", onupdate="CASCADE"),
primary_key=True,
comment="角色ID",
)
menu_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_menu.id", ondelete="CASCADE", onupdate="CASCADE"),
primary_key=True,
comment="菜单ID",
)
data_scope: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
default=None,
comment="菜单级数据权限范围(NULL则继承角色级, 1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)",
)
class RoleDeptsModel(MappedBase):
"""
角色部门关联表
定义角色与部门的多对多关系用于数据权限控制
仅当角色的data_scope=5(自定义数据权限)时使用此表
"""
__tablename__: str = "sys_role_depts"
__table_args__: dict[str, str] = {"comment": "角色部门关联表"}
role_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_role.id", ondelete="CASCADE", onupdate="CASCADE"),
primary_key=True,
comment="角色ID",
)
dept_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_dept.id", ondelete="CASCADE", onupdate="CASCADE"),
primary_key=True,
comment="部门ID",
)
class RoleMenuDeptsModel(MappedBase):
"""
角色菜单部门关联表
定义角色菜单级别的自定义部门数据权限
仅当 sys_role_menus.data_scope=5(自定义数据权限)时使用此表
"""
__tablename__: str = "sys_role_menu_depts"
__table_args__: dict[str, str] = {"comment": "角色菜单部门关联表(菜单级自定义数据权限)"}
role_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_role.id", ondelete="CASCADE", onupdate="CASCADE"),
primary_key=True,
comment="角色ID",
)
menu_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_menu.id", ondelete="CASCADE", onupdate="CASCADE"),
primary_key=True,
comment="菜单ID",
)
dept_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_dept.id", ondelete="CASCADE", onupdate="CASCADE"),
primary_key=True,
comment="部门ID",
)
class RoleModel(ModelMixin):
"""
角色模型
角色列表只显示当前用户绑定的角色
"""
__tablename__: str = "sys_role"
__table_args__: dict[str, str] = {"comment": "角色表"}
__loader_options__: list[str] = ["menus", "depts"]
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.USER_ROLE
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称")
code: Mapped[str | None] = mapped_column(
String(16), nullable=True, index=True, comment="角色编码"
)
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
data_scope: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
comment="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)",
)
menus: Mapped[list["MenuModel"]] = relationship(
secondary="sys_role_menus",
back_populates="roles",
lazy="selectin",
order_by="MenuModel.order",
)
depts: Mapped[list["DeptModel"]] = relationship(
secondary="sys_role_depts", back_populates="roles", lazy="selectin"
)
users: Mapped[list["UserModel"]] = relationship(
secondary="sys_user_roles", back_populates="roles", lazy="selectin"
)
+428
View File
@@ -0,0 +1,428 @@
from typing import Any
from sqlalchemy import and_, or_, select
from sqlalchemy.sql.elements import ColumnElement
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.user.model import UserModel
from app.common.enums import PermissionFilterStrategy
from app.utils.common_util import get_child_id_map, get_child_recursion
class Permission:
"""
为业务模型提供数据权限过滤功能
使用策略模式根据模型的 __permission_strategy__ 属性选择合适的过滤策略
"""
# 数据权限常量定义,提高代码可读性
DATA_SCOPE_SELF = 1 # 仅本人数据
DATA_SCOPE_DEPT = 2 # 本部门数据
DATA_SCOPE_DEPT_AND_CHILD = 3 # 本部门及以下数据
DATA_SCOPE_ALL = 4 # 全部数据
DATA_SCOPE_CUSTOM = 5 # 自定义数据
def __init__(self, model: Any, auth: AuthSchema) -> None:
"""
初始化权限过滤器实例
Args:
db: 数据库会话
model: 数据模型类
current_user: 当前用户对象
auth: 认证信息对象
"""
self.model = model
self.auth = auth
self.conditions: list[ColumnElement] = [] # 权限条件列表
async def filter_query(self, query: Any) -> Any:
"""
异步过滤查询对象
Args:
query: SQLAlchemy查询对象
Returns:
过滤后的查询对象
"""
condition = await self.__permission_condition()
return query.where(condition) if condition is not None else query
async def __permission_condition(self) -> ColumnElement | None:
"""
应用数据范围权限隔离
根据模型的权限过滤策略选择合适的过滤方法
"""
# 如果不需要检查数据权限,则不限制
if not self.auth.user:
return None
# 如果检查数据权限为False,则不限制
if not self.auth.check_data_scope:
return None
# 超级管理员可以查看所有数据
if self.auth.user.is_superuser:
return None
# 获取模型的权限过滤策略
strategy = getattr(self.model, "__permission_strategy__", PermissionFilterStrategy.DATA_SCOPE)
# 根据策略选择过滤方法
if strategy == PermissionFilterStrategy.ROLE_BASED:
return await self.__filter_by_role_based()
elif strategy == PermissionFilterStrategy.DEPT_BASED:
return await self.__filter_by_dept_based()
elif strategy == PermissionFilterStrategy.SELF_ONLY:
return await self.__filter_by_self_only()
elif strategy == PermissionFilterStrategy.USER_ROLE:
return await self.__filter_by_user_role()
else:
return await self.__filter_by_data_scope()
async def __filter_by_role_based(self) -> ColumnElement | None:
"""
基于角色授权的权限过滤适用于菜单等
只显示用户角色授权的菜单
"""
roles = getattr(self.auth.user, "roles", []) or []
if not roles:
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
return id_attr == -1
return None
menu_ids = set()
for role in roles:
if hasattr(role, "menus") and role.menus:
menu_ids.update(menu.id for menu in role.menus if menu.status == "0")
if menu_ids:
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
return id_attr.in_(list(menu_ids))
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
return id_attr == -1
return None
async def __filter_by_user_role(self) -> ColumnElement | None:
"""
基于当前用户绑定角色的权限过滤适用于角色列表
只显示当前用户绑定的角色
"""
roles = getattr(self.auth.user, "roles", []) or []
if not roles:
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
return id_attr == -1
return None
role_ids = [role.id for role in roles]
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
return id_attr.in_(role_ids)
return None
async def __filter_by_dept_based(self) -> ColumnElement | None:
"""
基于部门关联的权限过滤适用于部门角色等
根据用户的部门权限范围过滤数据
"""
# 如果用户没有角色,则只能查看自己部门的数据
roles = getattr(self.auth.user, "roles", []) or []
if not roles:
user_dept_id = getattr(self.auth.user, "dept_id", None)
if user_dept_id is not None and hasattr(self.model, "id"):
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
return id_attr == user_dept_id
return None
# 获取用户所有角色的权限范围(支持菜单/按钮级覆盖)
data_scopes, custom_dept_ids = await self.__resolve_menu_data_scope(
roles, self.auth.current_permission
)
# 全部数据权限最高优先级
if self.DATA_SCOPE_ALL in data_scopes:
return None
# 收集所有可访问的部门ID
accessible_dept_ids = await self.__get_accessible_dept_ids(data_scopes, custom_dept_ids)
# 根据模型类型过滤
if self.model.__name__ == "DeptModel":
return self.__filter_dept_model(accessible_dept_ids)
elif self.model.__name__ == "UserModel":
return self.__filter_user_model(accessible_dept_ids)
else:
return None
async def __filter_by_self_only(self) -> ColumnElement | None:
"""
仅本人数据权限过滤
"""
created_id_attr = getattr(self.model, "created_id", None)
if created_id_attr is not None and self.auth.user:
return created_id_attr == self.auth.user.id
return None
async def __filter_by_data_scope(self) -> ColumnElement | None:
"""
基于数据范围权限的通用过滤默认策略
适用于大多数业务模型
"""
# 如果模型没有创建人created_id字段,则不限制
if not hasattr(self.model, "created_id"):
return None
# 如果用户没有角色,则只能查看自己的数据
roles = getattr(self.auth.user, "roles", []) or []
if not roles:
created_id_attr = getattr(self.model, "created_id", None)
if created_id_attr is not None and self.auth.user:
return created_id_attr == self.auth.user.id
return None
# 获取用户所有角色的权限范围(支持菜单/按钮级覆盖)
data_scopes, custom_dept_ids = await self.__resolve_menu_data_scope(
roles, self.auth.current_permission
)
# 全部数据权限最高优先级
if self.DATA_SCOPE_ALL in data_scopes:
return None
# 收集所有可访问的部门ID
accessible_dept_ids = await self.__get_accessible_dept_ids(data_scopes, custom_dept_ids)
# 如果有部门权限,使用部门过滤
if accessible_dept_ids:
# 特殊处理:如果模型本身就是UserModel,直接过滤用户的dept_id
if self.model.__name__ == "UserModel" and hasattr(self.model, "dept_id"):
dept_id_attr = getattr(self.model, "dept_id", None)
if dept_id_attr is not None:
return dept_id_attr.in_(list(accessible_dept_ids))
# 其他模型:通过created_by关系过滤创建人的部门
creator_rel = getattr(self.model, "created_by", None)
if creator_rel is not None and hasattr(UserModel, "dept_id"):
return creator_rel.has(UserModel.dept_id.in_(list(accessible_dept_ids)))
# 降级方案:只能查看自己的数据
created_id_attr = getattr(self.model, "created_id", None)
if created_id_attr is not None and self.auth.user:
return created_id_attr == self.auth.user.id
return None
# 处理仅本人数据权限
if self.DATA_SCOPE_SELF in data_scopes:
created_id_attr = getattr(self.model, "created_id", None)
if created_id_attr is not None and self.auth.user:
return created_id_attr == self.auth.user.id
return None
# 默认情况:只能查看自己的数据
created_id_attr = getattr(self.model, "created_id", None)
if created_id_attr is not None and self.auth.user:
return created_id_attr == self.auth.user.id
return None
async def __resolve_menu_data_scope(
self, roles: list, current_permission: str | None
) -> tuple[set[int], set[int]]:
"""
解析数据权限范围支持菜单/按钮级覆盖
三级继承链路按钮自身 data_scope 父菜单 data_scope 角色 data_scope
多角色场景采用并集策略权限最大化
Args:
roles: 用户的角色列表
current_permission: 当前请求的权限标识 "module_system:user:query"
Returns:
(data_scopes, custom_dept_ids) 元组
"""
data_scopes: set[int] = set()
custom_dept_ids: set[int] = set()
if not current_permission:
# 回退:使用角色级 data_scope(现有行为)
for role in roles:
data_scopes.add(role.data_scope)
if role.data_scope == self.DATA_SCOPE_CUSTOM and hasattr(role, "depts") and role.depts:
custom_dept_ids.update(dept.id for dept in role.depts)
return data_scopes, custom_dept_ids
# 在所有角色中查找匹配 current_permission 的菜单,同时记录菜单对象以获取 type 和 parent_id
role_menu_pairs: list[tuple[int, int, Any, Any]] = [] # (role_id, menu_id, role, menu)
for role in roles:
for menu in (getattr(role, "menus", None) or []):
if (
getattr(menu, "permission", None) == current_permission
and getattr(menu, "status", None) == "0"
):
role_menu_pairs.append((role.id, menu.id, role, menu))
break # 每个角色只取一个匹配
if not role_menu_pairs:
# 没有角色包含此权限——回退到角色级
for role in roles:
data_scopes.add(role.data_scope)
if role.data_scope == self.DATA_SCOPE_CUSTOM and hasattr(role, "depts") and role.depts:
custom_dept_ids.update(dept.id for dept in role.depts)
return data_scopes, custom_dept_ids
# 批量查询 RoleMenusModel 中的菜单级 data_scope
from app.api.v1.module_system.role.model import RoleMenuDeptsModel, RoleMenusModel
pairs_filter = or_(
*[
and_(RoleMenusModel.role_id == rid, RoleMenusModel.menu_id == mid)
for rid, mid, _, _ in role_menu_pairs
]
)
result = await self.auth.db.execute(
select(RoleMenusModel.role_id, RoleMenusModel.menu_id, RoleMenusModel.data_scope).where(
pairs_filter
)
)
per_menu_scopes = {(row.role_id, row.menu_id): row.data_scope for row in result}
# 对 type=3 且自身 data_scope 为 NULL 的按钮,收集需要查询父菜单 data_scope 的对
parent_lookups: list[tuple[int, int]] = [] # (role_id, parent_menu_id)
for role_id, menu_id, _, menu in role_menu_pairs:
scope = per_menu_scopes.get((role_id, menu_id))
parent_id = getattr(menu, "parent_id", None)
if scope is None and getattr(menu, "type", None) == 3 and parent_id:
parent_lookups.append((role_id, parent_id))
# 批量查询父菜单 data_scope
parent_scopes: dict[tuple[int, int], int | None] = {}
if parent_lookups:
parent_filter = or_(
*[
and_(RoleMenusModel.role_id == rid, RoleMenusModel.menu_id == pid)
for rid, pid in parent_lookups
]
)
parent_result = await self.auth.db.execute(
select(
RoleMenusModel.role_id, RoleMenusModel.menu_id, RoleMenusModel.data_scope
).where(parent_filter)
)
parent_scopes = {(row.role_id, row.menu_id): row.data_scope for row in parent_result}
# 解析每个角色在该权限下的实际 data_scope(三级继承)
for role_id, menu_id, role, menu in role_menu_pairs:
scope = per_menu_scopes.get((role_id, menu_id))
# 记录 scope 实际来源的 menu_id(用于查找自定义部门)
scope_menu_id: int | None = menu_id
# 三级继承:按钮自身 → 父菜单 → 角色
if scope is None and getattr(menu, "type", None) == 3 and getattr(menu, "parent_id", None):
parent_scope = parent_scopes.get((role_id, menu.parent_id))
if parent_scope is not None:
scope = parent_scope
scope_menu_id = menu.parent_id # scope 来源于父菜单
if scope is None:
scope = role.data_scope # 最终回退到角色级
scope_menu_id = None # scope 来源于角色级
data_scopes.add(scope)
if scope == self.DATA_SCOPE_CUSTOM:
if scope_menu_id is not None:
# 从 scope 来源的菜单级查找自定义部门
dept_result = await self.auth.db.execute(
select(RoleMenuDeptsModel.dept_id).where(
RoleMenuDeptsModel.role_id == role_id,
RoleMenuDeptsModel.menu_id == scope_menu_id,
)
)
menu_dept_ids = {row.dept_id for row in dept_result}
if menu_dept_ids:
custom_dept_ids.update(menu_dept_ids)
elif hasattr(role, "depts") and role.depts:
# 菜单级没有配置部门,回退到角色级自定义部门
custom_dept_ids.update(dept.id for dept in role.depts)
elif hasattr(role, "depts") and role.depts:
# scope 来源于角色级,直接使用角色级自定义部门
custom_dept_ids.update(dept.id for dept in role.depts)
return data_scopes, custom_dept_ids
async def __get_accessible_dept_ids(
self, data_scopes: set, custom_dept_ids: set
) -> set[int]:
"""
获取用户可访问的所有部门ID
Args:
data_scopes: 用户角色的数据权限范围集合
custom_dept_ids: 自定义权限关联的部门ID集合
Returns:
可访问的部门ID集合
"""
accessible_dept_ids = set()
user_dept_id = getattr(self.auth.user, "dept_id", None)
# 处理自定义数据权限(5
if self.DATA_SCOPE_CUSTOM in data_scopes:
accessible_dept_ids.update(custom_dept_ids)
# 处理本部门数据权限(2
if self.DATA_SCOPE_DEPT in data_scopes and user_dept_id is not None:
accessible_dept_ids.add(user_dept_id)
# 处理本部门及以下数据权限(3
if self.DATA_SCOPE_DEPT_AND_CHILD in data_scopes and user_dept_id is not None:
try:
dept_sql = select(DeptModel)
dept_result = await self.auth.db.execute(dept_sql)
dept_objs = dept_result.scalars().all()
id_map = get_child_id_map(dept_objs)
dept_with_children_ids = get_child_recursion(id=user_dept_id, id_map=id_map)
accessible_dept_ids.update(dept_with_children_ids)
except Exception:
accessible_dept_ids.add(user_dept_id)
return accessible_dept_ids
def __filter_dept_model(self, accessible_dept_ids: set[int]) -> ColumnElement | None:
"""
过滤部门模型
"""
if accessible_dept_ids:
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
return id_attr.in_(list(accessible_dept_ids))
user_dept_id = getattr(self.auth.user, "dept_id", None)
if user_dept_id is not None:
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
return id_attr == user_dept_id
return None
def __filter_user_model(self, accessible_dept_ids: set[int]) -> ColumnElement | None:
"""
过滤用户模型
"""
if accessible_dept_ids:
dept_id_attr = getattr(self.model, "dept_id", None)
if dept_id_attr is not None:
return dept_id_attr.in_(list(accessible_dept_ids))
return None
+127
View File
@@ -0,0 +1,127 @@
import request from "@/utils/request";
const API_PATH = "/system/role";
const RoleAPI = {
listRole(query?: TablePageQuery) {
return request<ApiResponse<PageResult<RoleTable[]>>>({
url: `${API_PATH}/list`,
method: "get",
params: query,
});
},
detailRole(query: number) {
return request<ApiResponse<RoleTable>>({
url: `${API_PATH}/detail/${query}`,
method: "get",
});
},
createRole(body: RoleForm) {
return request<ApiResponse>({
url: `${API_PATH}/create`,
method: "post",
data: body,
});
},
updateRole(id: number, body: RoleForm) {
return request<ApiResponse>({
url: `${API_PATH}/update/${id}`,
method: "put",
data: body,
});
},
deleteRole(body: number[]) {
return request<ApiResponse>({
url: `${API_PATH}/delete`,
method: "delete",
data: body,
});
},
batchRole(body: BatchType) {
return request<ApiResponse>({
url: `${API_PATH}/available/setting`,
method: "patch",
data: body,
});
},
setPermission(body: permissionDataType) {
return request<ApiResponse>({
url: `${API_PATH}/permission/setting`,
method: "patch",
data: body,
});
},
exportRole(body: TablePageQuery) {
return request<Blob>({
url: `${API_PATH}/export`,
method: "post",
data: body,
responseType: "blob",
});
},
};
export default RoleAPI;
export interface TablePageQuery extends PageQuery {
name?: string;
status?: string;
created_time?: string[];
updated_time?: string[];
}
export interface RoleTable extends BaseType {
id: number;
name: string;
order?: number;
code?: string;
data_scope?: number;
menus?: permissionMenuType[];
depts?: permissionDeptType[];
menu_data_scopes?: MenuDataScopeItem[];
}
export interface RoleForm extends BaseFormType {
name?: string;
order?: number;
code?: string;
}
export interface MenuDataScopeItem {
menu_id: number;
data_scope: number | null; // null = 继承角色默认
dept_ids: number[];
}
export interface permissionDataType {
data_scope: number;
role_ids: RoleTable["id"][];
menu_ids: permissionMenuType["id"][];
dept_ids: permissionDeptType["id"][];
menu_data_scopes: MenuDataScopeItem[];
}
export interface permissionDeptType {
id: number;
name: string;
parent_id: number;
children: permissionDeptType[];
}
export interface permissionMenuType {
id: number;
name: string;
type: number;
permission: string;
parent_id?: number;
status: string;
description?: string;
children?: permissionMenuType[];
}
+116
View File
@@ -0,0 +1,116 @@
from fastapi import Query
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
from app.api.v1.module_system.dept.schema import DeptOutSchema
from app.api.v1.module_system.menu.schema import MenuOutSchema
from app.common.enums import QueueEnum
from app.core.base_schema import BaseSchema
from app.core.validator import (
DateTimeStr,
code_validator,
role_permission_request_validator,
)
class RoleCreateSchema(BaseModel):
"""角色创建模型"""
name: str = Field(..., max_length=64, description="角色名称")
code: str | None = Field(default=None, max_length=16, description="角色编码")
order: int | None = Field(default=1, ge=1, description="显示排序")
data_scope: int | None = Field(
default=1,
description="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)",
)
status: str = Field(default="0", description="是否启用")
description: str | None = Field(default=None, max_length=255, description="描述")
@field_validator("code")
@classmethod
def validate_code(cls, value: str | None):
return code_validator(value)
class MenuDataScopeItem(BaseModel):
"""菜单级数据权限配置项"""
menu_id: int = Field(..., description="菜单ID")
data_scope: int | None = Field(default=None, description="数据权限范围(NULL继承角色级)")
dept_ids: list[int] = Field(default_factory=list, description="自定义部门ID列表(data_scope=5时)")
class RolePermissionSettingSchema(BaseModel):
"""角色权限配置模型"""
data_scope: int = Field(
default=1,
description="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)",
)
role_ids: list[int] = Field(default_factory=list, description="角色ID列表")
menu_ids: list[int] = Field(default_factory=list, description="菜单ID列表")
dept_ids: list[int] = Field(default_factory=list, description="部门ID列表")
menu_data_scopes: list[MenuDataScopeItem] = Field(
default_factory=list, description="菜单级数据权限配置列表"
)
@model_validator(mode="after")
def validate_fields(self):
"""验证权限配置字段"""
return role_permission_request_validator(self)
class RoleUpdateSchema(RoleCreateSchema):
"""角色更新模型"""
class RoleOutSchema(RoleCreateSchema, BaseSchema):
"""角色信息响应模型"""
model_config = ConfigDict(from_attributes=True)
menus: list[MenuOutSchema] = Field(default_factory=list, description="角色菜单列表")
depts: list[DeptOutSchema] = Field(default_factory=list, description="角色部门列表")
menu_data_scopes: list[MenuDataScopeItem] = Field(
default_factory=list, description="菜单级数据权限配置列表"
)
class RoleQueryParam:
"""角色管理查询参数"""
def __init__(
self,
name: str | None = Query(None, description="角色名称"),
description: str | None = Query(None, description="描述"),
status: str | None = Query(None, description="是否启用"),
created_time: list[DateTimeStr] | None = Query(
None,
description="创建时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
updated_time: list[DateTimeStr] | None = Query(
None,
description="更新时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
) -> None:
# 模糊查询字段
self.name = (QueueEnum.like.value, name)
if description:
self.description = (QueueEnum.like.value, description)
# 精确查询字段
if status:
self.status = (QueueEnum.eq.value, status)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1]))
+219
View File
@@ -0,0 +1,219 @@
from typing import Any
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from .crud import RoleCRUD
from .schema import (
RoleCreateSchema,
RoleOutSchema,
RolePermissionSettingSchema,
RoleQueryParam,
RoleUpdateSchema,
)
class RoleService:
"""角色模块服务层"""
@classmethod
async def get_role_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取角色详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 角色ID
返回:
- dict: 角色详情字典
"""
role = await RoleCRUD(auth).get_by_id_crud(id=id)
role_dict = RoleOutSchema.model_validate(role).model_dump()
# 加载菜单级数据权限配置
role_dict["menu_data_scopes"] = await RoleCRUD(auth).get_role_menu_data_scopes_crud(
role_id=id
)
return role_dict
@classmethod
async def get_role_list_service(
cls,
auth: AuthSchema,
search: RoleQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[dict]:
"""
获取角色列表
参数:
- auth (AuthSchema): 认证信息模型
- search (RoleQueryParam | None): 查询参数模型
- order_by (list[dict[str, str]] | None): 排序参数列表
返回:
- list[dict]: 角色详情字典列表
"""
role_list = await RoleCRUD(auth).get_list_crud(search=search.__dict__, order_by=order_by)
return [RoleOutSchema.model_validate(role).model_dump() for role in role_list]
@classmethod
async def create_role_service(cls, auth: AuthSchema, data: RoleCreateSchema) -> dict:
"""
创建角色
参数:
- auth (AuthSchema): 认证信息模型
- data (RoleCreateSchema): 创建角色模型
返回:
- dict: 新创建的角色详情字典
"""
role = await RoleCRUD(auth).get(name=data.name)
if role:
raise CustomException(msg="创建失败,该角色已存在")
obj = await RoleCRUD(auth).get(code=data.code)
if obj:
raise CustomException(msg="创建失败,编码已存在")
new_role = await RoleCRUD(auth).create(data=data)
return RoleOutSchema.model_validate(new_role).model_dump()
@classmethod
async def update_role_service(cls, auth: AuthSchema, id: int, data: RoleUpdateSchema) -> dict:
"""
更新角色
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 角色ID
- data (RoleUpdateSchema): 更新角色模型
返回:
- dict: 更新后的角色详情字典
"""
role = await RoleCRUD(auth).get_by_id_crud(id=id)
if not role:
raise CustomException(msg="更新失败,该角色不存在")
exist_role = await RoleCRUD(auth).get(name=data.name)
if exist_role and exist_role.id != id:
raise CustomException(msg="更新失败,角色名称重复")
updated_role = await RoleCRUD(auth).update(id=id, data=data)
return RoleOutSchema.model_validate(updated_role).model_dump()
@classmethod
async def delete_role_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除角色
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 角色ID列表
返回:
- None
"""
if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空")
for id in ids:
role = await RoleCRUD(auth).get_by_id_crud(id=id)
if not role:
raise CustomException(msg="删除失败,该角色不存在")
await RoleCRUD(auth).delete(ids=ids)
@classmethod
async def set_role_permission_service(
cls, auth: AuthSchema, data: RolePermissionSettingSchema
) -> None:
"""
设置角色权限
参数:
- auth (AuthSchema): 认证信息模型
- data (RolePermissionSettingSchema): 角色权限设置模型
返回:
- None
"""
# 设置角色菜单权限
await RoleCRUD(auth).set_role_menus_crud(role_ids=data.role_ids, menu_ids=data.menu_ids)
# 设置数据权限范围
await RoleCRUD(auth).set_role_data_scope_crud(
role_ids=data.role_ids, data_scope=data.data_scope
)
# 设置自定义数据权限部门
if data.data_scope == 5 and data.dept_ids:
await RoleCRUD(auth).set_role_depts_crud(role_ids=data.role_ids, dept_ids=data.dept_ids)
else:
await RoleCRUD(auth).set_role_depts_crud(role_ids=data.role_ids, dept_ids=[])
# 设置菜单级数据权限
if data.menu_data_scopes:
for role_id in data.role_ids:
await RoleCRUD(auth).set_role_menu_data_scopes_crud(
role_id=role_id, menu_data_scopes=data.menu_data_scopes
)
@classmethod
async def set_role_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""
设置角色可用状态
参数:
- auth (AuthSchema): 认证信息模型
- data (BatchSetAvailable): 批量设置可用状态模型
返回:
- None
"""
await RoleCRUD(auth).set_available_crud(ids=data.ids, status=data.status)
@classmethod
async def export_role_list_service(cls, role_list: list[dict[str, Any]]) -> bytes:
"""
导出角色列表
参数:
- role_list (list[dict[str, Any]]): 角色详情字典列表
返回:
- bytes: Excel文件字节流
"""
# 字段映射配置
mapping_dict = {
"id": "角色编号",
"name": "角色名称",
"order": "显示顺序",
"data_scope": "数据权限",
"status": "状态",
"description": "备注",
"created_time": "创建时间",
"updated_time": "更新时间",
"created_id": "创建者ID",
"updated_id": "更新者ID",
}
# 数据权限映射
data_scope_map = {
1: "仅本人数据权限",
2: "本部门数据权限",
3: "本部门及以下数据权限",
4: "全部数据权限",
5: "自定义数据权限",
}
# 处理数据
data = role_list.copy()
for item in data:
item["status"] = "启用" if item.get("status") == "0" else "停用"
item["data_scope"] = data_scope_map.get(item.get("data_scope", 1), "")
item["creator"] = (
item.get("creator", {}).get("name", "未知")
if isinstance(item.get("creator"), dict)
else "未知"
)
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
+285
View File
@@ -0,0 +1,285 @@
import re
from datetime import date, datetime, time
from typing import Annotated, Any
from pydantic import AfterValidator, PlainSerializer, WithJsonSchema
from app.common.constant import RET
from app.core.exceptions import CustomException
# 自定义日期时间字符串类型
DateTimeStr = Annotated[
datetime,
AfterValidator(lambda x: datetime_validator(x)),
PlainSerializer(
lambda x: x.strftime("%Y-%m-%d %H:%M:%S") if isinstance(x, datetime) else str(x),
return_type=str,
),
WithJsonSchema({"type": "string"}, mode="serialization"),
]
# 自定义日期字符串类型
DateStr = Annotated[
date,
AfterValidator(lambda x: date_validator(x)),
PlainSerializer(
lambda x: x.strftime("%Y-%m-%d") if isinstance(x, date) else str(x),
return_type=str,
),
WithJsonSchema({"type": "string"}, mode="serialization"),
]
# 自定义时间字符串类型
TimeStr = Annotated[
time,
AfterValidator(lambda x: time_validator(x)),
PlainSerializer(
lambda x: x.strftime("%H:%M:%S") if isinstance(x, time) else str(x),
return_type=str,
),
WithJsonSchema({"type": "string"}, mode="serialization"),
]
# 自定义手机号类型
Telephone = Annotated[
str,
AfterValidator(lambda x: mobile_validator(x)),
PlainSerializer(lambda x: x, return_type=str),
WithJsonSchema({"type": "string"}, mode="serialization"),
]
# 自定义邮箱类型
Email = Annotated[
str,
AfterValidator(lambda x: email_validator(x)),
PlainSerializer(lambda x: x, return_type=str),
WithJsonSchema({"type": "string"}, mode="serialization"),
]
def datetime_validator(value: str | datetime) -> datetime:
"""
日期格式验证器
参数:
- value (str | datetime): 日期值
返回:
- datetime: 格式化后的日期
异常:
- CustomException: 日期格式无效时抛出
"""
pattern = "%Y-%m-%d %H:%M:%S"
try:
if isinstance(value, str):
return datetime.strptime(value, pattern)
if isinstance(value, datetime):
return value
except Exception:
raise CustomException(code=RET.ERROR.code, msg="无效的日期格式")
def date_validator(value: str | date) -> date:
"""
日期格式验证器
参数:
- value (str | date): 日期值
返回:
- date: 格式化后的日期
异常:
- CustomException: 日期格式无效时抛出
"""
try:
if isinstance(value, str):
return datetime.strptime(value, "%Y-%m-%d").date()
if isinstance(value, date):
return value
except Exception:
raise CustomException(code=RET.ERROR.code, msg="无效的日期格式")
def time_validator(value: str | time) -> time:
"""
时间格式验证器
参数:
- value (str | time): 时间值
返回:
- time: 格式化后的时间
异常:
- CustomException: 时间格式无效时抛出
"""
try:
if isinstance(value, str):
return datetime.strptime(value, "%H:%M:%S").time()
if isinstance(value, time):
return value
except Exception:
raise CustomException(code=RET.ERROR.code, msg="无效的时间格式")
def email_validator(value: str) -> str:
"""
邮箱地址验证器
参数:
- value (str): 邮箱地址
返回:
- str: 验证后的邮箱地址
异常:
- CustomException: 邮箱格式无效时抛出
"""
if not value:
raise CustomException(code=RET.ERROR.code, msg="邮箱地址不能为空")
regex = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(regex, value):
raise CustomException(code=RET.ERROR.code, msg="邮箱地址格式不正确")
return value
def mobile_validator(value: str | None) -> str | None:
"""
手机号验证器
参数:
- value (str | None): 手机号
返回:
- str | None: 验证后的手机号
异常:
- CustomException: 手机号格式无效时抛出
"""
if not value:
return value
if len(value) != 11 or not value.isdigit():
raise CustomException(code=RET.ERROR.code, msg="手机号格式不正确")
regex = r"^1(3\d|4[4-9]|5[0-35-9]|6[67]|7[013-8]|8[0-9]|9[0-9])\d{8}$"
if not re.match(regex, value):
raise CustomException(code=RET.ERROR.code, msg="手机号格式不正确")
return value
def code_validator(value: str | None) -> str | None:
"""
编码验证器
参数:
- value (str | None): 编码
返回:
- str | None: 验证后的编码
异常:
- CustomException: 编码格式无效时抛出
"""
if not value:
return value
v = value.strip()
if not re.match(r"^[A-Za-z][A-Za-z0-9_]{1,15}$", v):
raise CustomException(
code=RET.ERROR.code,
msg="编码需字母开头,允许字母/数字/下划线,长度2-16",
)
return v
def menu_request_validator(data: Any) -> Any:
"""
菜单请求数据验证器
参数:
- data (Any): 请求数据
返回:
- Any: 验证后的请求数据
异常:
- CustomException: 请求数据无效时抛出
"""
menu_types = {1: "目录", 2: "功能", 3: "权限", 4: "外链"}
if data.type not in menu_types:
raise CustomException(
code=RET.ERROR.code,
msg=f"菜单类型必须为: {','.join(map(str, menu_types.keys()))}",
)
if data.type in [1, 2]:
if not data.route_name:
raise CustomException(code=RET.ERROR.code, msg="路由名称不能为空")
if not data.route_path:
raise CustomException(code=RET.ERROR.code, msg="路由路径不能为空")
if data.type == 2 and not data.component_path:
raise CustomException(code=RET.ERROR.code, msg="组件路径不能为空")
return data
def role_permission_request_validator(data: Any) -> Any:
"""
角色权限设置数据验证器
参数:
- data (Any): 请求数据
返回:
- Any: 验证后的请求数据
异常:
- CustomException: 请求数据无效时抛出
"""
data_scopes = {
1: "仅本人数据权限",
2: "本部门数据权限",
3: "本部门及以下数据权限",
4: "全部数据权限",
5: "自定义数据权限",
}
if data.data_scope not in data_scopes:
raise CustomException(
code=RET.ERROR.code,
msg=f"数据权限范围必须为: {','.join(map(str, data_scopes.keys()))}",
)
if not data.role_ids:
raise CustomException(code=RET.ERROR.code, msg="角色不能为空")
# 验证菜单级数据权限配置
if hasattr(data, "menu_data_scopes") and data.menu_data_scopes:
menu_id_set = set(data.menu_ids) if data.menu_ids else set()
for item in data.menu_data_scopes:
if item.data_scope is not None and item.data_scope not in data_scopes:
raise CustomException(
code=RET.ERROR.code,
msg=f"菜单级数据权限范围必须为: {','.join(map(str, data_scopes.keys()))} 或 NULL",
)
if item.data_scope != 5 and item.dept_ids:
raise CustomException(
code=RET.ERROR.code,
msg="仅当数据权限范围为自定义(5)时才能配置部门列表",
)
if menu_id_set and item.menu_id not in menu_id_set:
raise CustomException(
code=RET.ERROR.code,
msg=f"菜单级数据权限中的菜单ID {item.menu_id} 不在已分配的菜单列表中",
)
return data