From b0ae6af3b1a897b922303ba94a7f3d9183f0a601 Mon Sep 17 00:00:00 2001 From: zhangtao <9480807882@qq.com> Date: Sat, 4 Apr 2026 01:21:59 +0800 Subject: [PATCH] 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. --- .gitignore | 2 + backend/app/alembic/env.py | 4 + .../app/api/v1/module_common/file/schema.py | 8 + .../api/v1/module_monitor/cache/controller.py | 6 + .../api/v1/module_monitor/resource/schema.py | 37 + .../api/v1/module_monitor/resource/service.py | 22 +- .../api/v1/module_system/auth/controller.py | 3 + .../app/api/v1/module_system/auth/schema.py | 9 + backend/app/api/v1/module_system/dept/crud.py | 10 +- .../app/api/v1/module_system/dept/schema.py | 24 + .../app/api/v1/module_system/dept/service.py | 2 +- backend/app/api/v1/module_system/dict/crud.py | 18 +- .../app/api/v1/module_system/dict/schema.py | 33 + .../app/api/v1/module_system/dict/service.py | 28 +- backend/app/api/v1/module_system/log/crud.py | 14 +- backend/app/api/v1/module_system/log/model.py | 10 + .../app/api/v1/module_system/log/service.py | 14 +- backend/app/api/v1/module_system/menu/crud.py | 10 +- .../app/api/v1/module_system/menu/schema.py | 9 + .../app/api/v1/module_system/menu/service.py | 28 + .../app/api/v1/module_system/notice/crud.py | 5 +- .../api/v1/module_system/notice/service.py | 30 +- .../app/api/v1/module_system/params/crud.py | 7 +- .../api/v1/module_system/params/service.py | 14 +- .../app/api/v1/module_system/position/crud.py | 7 +- .../api/v1/module_system/position/service.py | 16 +- backend/app/api/v1/module_system/role/crud.py | 11 +- .../app/api/v1/module_system/role/schema.py | 25 +- .../app/api/v1/module_system/role/service.py | 14 +- backend/app/api/v1/module_system/user/crud.py | 15 +- .../app/api/v1/module_system/user/schema.py | 90 ++ .../app/api/v1/module_system/user/service.py | 16 +- backend/app/common/constant.py | 18 +- backend/app/common/dataclasses.py | 18 + backend/app/common/enums.py | 16 +- backend/app/config/setting.py | 55 +- backend/app/core/ap_scheduler.py | 230 +++- backend/app/core/base_crud.py | 9 + backend/app/core/base_model.py | 10 +- backend/app/core/database.py | 17 +- backend/app/core/docs.py | 16 +- backend/app/core/http_limit.py | 24 +- backend/app/core/logger.py | 23 +- backend/app/core/middlewares.py | 10 + backend/app/core/permission.py | 10 +- backend/app/plugin/module_ai/chat/crud.py | 52 +- backend/app/plugin/module_ai/chat/service.py | 82 +- backend/app/plugin/module_ai/chat/utils.py | 13 +- backend/app/plugin/module_ai/chat/ws.py | 10 +- .../plugin/module_application/myapp/crud.py | 6 + .../module_application/myapp/service.py | 14 +- .../app/plugin/module_example/demo/schema.py | 13 +- .../plugin/module_example/demo01/schema.py | 13 +- .../module_generator/gencode/controller.py | 10 + .../plugin/module_generator/gencode/crud.py | 17 + .../plugin/module_generator/gencode/model.py | 56 +- .../plugin/module_generator/gencode/schema.py | 63 +- .../module_generator/gencode/service.py | 77 +- .../gencode/tools/jinja2_template_util.py | 58 +- .../module_task/cronjob/job/controller.py | 40 +- .../plugin/module_task/cronjob/job/crud.py | 8 +- .../plugin/module_task/cronjob/job/service.py | 20 +- .../module_task/cronjob/node/controller.py | 6 + .../plugin/module_task/cronjob/node/crud.py | 9 + .../cronjob/node/handlers/demo_handler.py | 16 +- .../module_task/cronjob/node/service.py | 20 +- .../workflow/definition/controller.py | 73 ++ .../module_task/workflow/definition/crud.py | 58 + .../workflow/definition/service.py | 105 +- .../workflow/engine/prefect_engine.py | 65 +- .../workflow/node_type/controller.py | 61 + .../module_task/workflow/node_type/crud.py | 65 +- .../module_task/workflow/node_type/service.py | 88 +- backend/app/scripts/initialize.py | 14 +- backend/app/utils/banner.py | 8 +- backend/app/utils/captcha_util.py | 87 +- backend/app/utils/common_util.py | 86 +- backend/app/utils/console.py | 23 +- backend/app/utils/excel_util.py | 4 +- backend/app/utils/import_util.py | 50 +- backend/app/utils/ip_local_util.py | 12 +- backend/app/utils/xss_util.py | 9 +- backend/main.py | 37 +- backend/tests/conftest.py | 6 + backend/tests/test_main.py | 25 +- .../src/views/module_system/menu/index.vue | 9 +- frontend/修改的文件/PermissonDrawer.vue | 1012 +++++++++++++++++ frontend/修改的文件/auth的schema.py | 84 ++ frontend/修改的文件/crud.py | 199 ++++ frontend/修改的文件/dependencies.py | 301 +++++ frontend/修改的文件/model.py | 137 +++ frontend/修改的文件/permission.py | 428 +++++++ frontend/修改的文件/role.ts | 127 +++ frontend/修改的文件/schema.py | 116 ++ frontend/修改的文件/service.py | 219 ++++ frontend/修改的文件/validator.py | 285 +++++ 96 files changed, 5089 insertions(+), 304 deletions(-) create mode 100644 frontend/修改的文件/PermissonDrawer.vue create mode 100644 frontend/修改的文件/auth的schema.py create mode 100644 frontend/修改的文件/crud.py create mode 100644 frontend/修改的文件/dependencies.py create mode 100644 frontend/修改的文件/model.py create mode 100644 frontend/修改的文件/permission.py create mode 100644 frontend/修改的文件/role.ts create mode 100644 frontend/修改的文件/schema.py create mode 100644 frontend/修改的文件/service.py create mode 100644 frontend/修改的文件/validator.py diff --git a/.gitignore b/.gitignore index 756a52c1..82ff8502 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ __pycache__ +*.pyc +*.pyo .vscode .idea .DS_Store diff --git a/backend/app/alembic/env.py b/backend/app/alembic/env.py index d751cdb3..620d0c75 100644 --- a/backend/app/alembic/env.py +++ b/backend/app/alembic/env.py @@ -61,6 +61,8 @@ def run_migrations_offline() -> None: Calls to context.execute() here emit the given string to the script output. + 返回: + - None """ url = alembic_config.get_main_option("sqlalchemy.url") # 确保URL不为None @@ -84,6 +86,8 @@ def run_migrations_online() -> None: In this scenario we need to create an Engine and associate a connection with the context. + 返回: + - None """ url = alembic_config.get_main_option("sqlalchemy.url") # 确保URL不为None diff --git a/backend/app/api/v1/module_common/file/schema.py b/backend/app/api/v1/module_common/file/schema.py index fb34364b..82939ca2 100644 --- a/backend/app/api/v1/module_common/file/schema.py +++ b/backend/app/api/v1/module_common/file/schema.py @@ -3,6 +3,10 @@ from pydantic.alias_generators import to_camel class ImportFieldModel(BaseModel): + """ + Excel 导入时单字段映射配置(数据库列、Excel 列、默认值、是否必选等)。 + """ + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) base_column: str | None = Field(description="数据库字段名", default=None) @@ -42,6 +46,10 @@ class ImportFieldModel(BaseModel): class ImportModel(BaseModel): + """ + Excel 导入请求体:目标表、Sheet、文件名及字段映射列表。 + """ + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) table_name: str | None = Field(description="表名", default=None) diff --git a/backend/app/api/v1/module_monitor/cache/controller.py b/backend/app/api/v1/module_monitor/cache/controller.py index 252c23c6..f2013b0e 100644 --- a/backend/app/api/v1/module_monitor/cache/controller.py +++ b/backend/app/api/v1/module_monitor/cache/controller.py @@ -29,6 +29,9 @@ async def get_monitor_cache_info_controller( """ 获取缓存监控统计信息 + 参数: + - redis (Redis): Redis 客户端对象 + 返回: - JSONResponse: 包含缓存监控统计信息的JSON响应 """ @@ -180,6 +183,9 @@ async def clear_monitor_cache_all_controller( """ 清除所有缓存 + 参数: + - redis (Redis): Redis 客户端对象 + 返回: - JSONResponse: 包含清除结果的JSON响应 """ diff --git a/backend/app/api/v1/module_monitor/resource/schema.py b/backend/app/api/v1/module_monitor/resource/schema.py index 08ef77e1..497ba434 100644 --- a/backend/app/api/v1/module_monitor/resource/schema.py +++ b/backend/app/api/v1/module_monitor/resource/schema.py @@ -93,6 +93,18 @@ class ResourceMoveSchema(BaseModel): @field_validator("source_path", "target_path") @classmethod def validate_paths(cls, value: str): + """ + 校验移动/复制涉及的源路径与目标路径非空并去首尾空格。 + + 参数: + - value (str): 路径字段当前值。 + + 返回: + - str: 去空格后的路径。 + + 异常: + - ValueError: 路径为空时抛出。 + """ if not value or len(value.strip()) == 0: raise ValueError("路径不能为空") return value.strip() @@ -113,6 +125,18 @@ class ResourceRenameSchema(BaseModel): @field_validator("old_path", "new_name") @classmethod def validate_inputs(cls, value: str): + """ + 校验重命名所需的原路径与新名称非空并去首尾空格。 + + 参数: + - value (str): 字段当前值。 + + 返回: + - str: 去空格后的值。 + + 异常: + - ValueError: 值为空时抛出。 + """ if not value or len(value.strip()) == 0: raise ValueError("参数不能为空") return value.strip() @@ -137,6 +161,19 @@ class ResourceCreateDirSchema(BaseModel): @field_validator("parent_path", "dir_name") @classmethod def validate_inputs(cls, value: str, info): + """ + 校验创建目录的父路径与目录名,防止路径遍历等不安全输入。 + + 参数: + - value (str): 当前字段值。 + - info: Pydantic 校验上下文(含 `field_name`)。 + + 返回: + - str: 规范化后的字段值。 + + 异常: + - ValueError: 含不安全字符或目录名为空时抛出。 + """ # 对于parent_path允许为空字符串(表示根目录)或 '/',其他情况必须非空 if info.field_name == "parent_path": # 对于parent_path仍然严格检查路径遍历 diff --git a/backend/app/api/v1/module_monitor/resource/service.py b/backend/app/api/v1/module_monitor/resource/service.py index 22072194..4c65cfff 100644 --- a/backend/app/api/v1/module_monitor/resource/service.py +++ b/backend/app/api/v1/module_monitor/resource/service.py @@ -77,6 +77,15 @@ class ResourceService: root_static_prefix = f"{root_prefix}{static_prefix}" if root_prefix else static_prefix def strip_prefix(p: str) -> str: + """ + 去掉静态资源 URL 前缀,得到相对 upload 的路径片段。 + + 参数: + - p (str): 原始路径或 URL 路径段。 + + 返回: + - str: 去掉已知前缀后的路径。 + """ if p.startswith(root_static_prefix): return p[len(root_static_prefix) :].lstrip("/") if p.startswith(static_prefix): @@ -560,6 +569,15 @@ class ResourceService: if isinstance(sort_conditions, list): # 构建排序键函数 def sort_key(item): + """ + 按多条排序条件从资源项中抽取比较键(支持时间字段转 datetime)。 + + 参数: + - item (dict): 单条资源详情字典。 + + 返回: + - list: 用于 `sorted` 的多字段键列表。 + """ keys = [] for cond in sort_conditions: 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: raise CustomException(msg="删除失败,删除路径不能为空") diff --git a/backend/app/api/v1/module_system/auth/controller.py b/backend/app/api/v1/module_system/auth/controller.py index 362c377c..080c3e6a 100644 --- a/backend/app/api/v1/module_system/auth/controller.py +++ b/backend/app/api/v1/module_system/auth/controller.py @@ -42,6 +42,7 @@ async def login_for_access_token_controller( 参数: - request (Request): FastAPI请求对象 + - redis (Redis): Redis 客户端对象 - login_form (CustomOAuth2PasswordRequestForm): 登录表单数据 - db (AsyncSession): 数据库会话对象 @@ -82,6 +83,8 @@ async def get_new_token_controller( 参数: - request (Request): FastAPI请求对象 - payload (RefreshTokenPayloadSchema): 刷新令牌负载模型 + - db (AsyncSession): 数据库会话对象 + - redis (Redis): Redis 客户端对象 返回: - JWTOutSchema: 包含新的访问令牌和刷新令牌的响应模型 diff --git a/backend/app/api/v1/module_system/auth/schema.py b/backend/app/api/v1/module_system/auth/schema.py index 8a23a6b3..36a772c4 100644 --- a/backend/app/api/v1/module_system/auth/schema.py +++ b/backend/app/api/v1/module_system/auth/schema.py @@ -25,6 +25,15 @@ class JWTPayloadSchema(BaseModel): @model_validator(mode="after") def validate_fields(self): + """ + 校验 JWT 载荷字段的基本合法性。 + + 返回: + - JWTPayloadSchema: 校验后的载荷实例。 + + 异常: + - ValueError: 必填字段为空或格式不正确时抛出。 + """ if not self.sub or len(self.sub.strip()) == 0: raise ValueError("会话编号不能为空") return self diff --git a/backend/app/api/v1/module_system/dept/crud.py b/backend/app/api/v1/module_system/dept/crud.py index 110f6fed..57e4276c 100644 --- a/backend/app/api/v1/module_system/dept/crud.py +++ b/backend/app/api/v1/module_system/dept/crud.py @@ -11,7 +11,15 @@ class DeptCRUD(CRUDBase[DeptModel, DeptCreateSchema, DeptUpdateSchema]): """部门模块数据层""" def __init__(self, auth: AuthSchema) -> None: - """初始化部门CRUD""" + """ + 初始化部门数据层。 + + 参数: + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None + """ self.auth = auth super().__init__(model=DeptModel, auth=auth) diff --git a/backend/app/api/v1/module_system/dept/schema.py b/backend/app/api/v1/module_system/dept/schema.py index d00f9a2d..ae44df40 100644 --- a/backend/app/api/v1/module_system/dept/schema.py +++ b/backend/app/api/v1/module_system/dept/schema.py @@ -22,6 +22,18 @@ class DeptCreateSchema(BaseModel): @field_validator("name") @classmethod def validate_name(cls, value: str): + """ + 校验并规范化部门名称(去空格、非空)。 + + 参数: + - value (str): 部门名称。 + + 返回: + - str: 规范化后的部门名称。 + + 异常: + - ValueError: 部门名称为空时抛出。 + """ if not value or len(value.strip()) == 0: raise ValueError("部门名称不能为空") value = value.replace(" ", "") @@ -30,6 +42,18 @@ class DeptCreateSchema(BaseModel): @field_validator("code") @classmethod def validate_code(cls, value: str | None): + """ + 校验部门编码:字母开头,仅包含字母/数字/下划线;空字符串视为 None。 + + 参数: + - value (str | None): 部门编码。 + + 返回: + - str | None: 规范化后的部门编码或 None。 + + 异常: + - ValueError: 编码不满足格式要求时抛出。 + """ if value is None: return value v = value.strip() diff --git a/backend/app/api/v1/module_system/dept/service.py b/backend/app/api/v1/module_system/dept/service.py index e38a601b..575b4fd0 100644 --- a/backend/app/api/v1/module_system/dept/service.py +++ b/backend/app/api/v1/module_system/dept/service.py @@ -126,7 +126,7 @@ class DeptService: 参数: - auth (AuthSchema): 认证对象。 - - ids (List[int]): 部门 ID 列表。 + - ids (list[int]): 部门 ID 列表。 返回: - None diff --git a/backend/app/api/v1/module_system/dict/crud.py b/backend/app/api/v1/module_system/dict/crud.py index fc51db6c..4514954f 100644 --- a/backend/app/api/v1/module_system/dict/crud.py +++ b/backend/app/api/v1/module_system/dict/crud.py @@ -16,10 +16,13 @@ class DictTypeCRUD(CRUDBase[DictTypeModel, DictTypeCreateSchema, DictTypeUpdateS def __init__(self, auth: AuthSchema) -> None: """ - 初始化数据字典类型CRUD + 初始化数据字典类型数据层。 参数: - - auth (AuthSchema): 认证信息模型 + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None """ self.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: 删除的记录数量 @@ -133,10 +136,13 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS def __init__(self, auth: AuthSchema) -> None: """ - 初始化数据字典数据CRUD + 初始化数据字典项数据层。 参数: - - auth (AuthSchema): 认证信息模型 + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None """ self.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 返回: diff --git a/backend/app/api/v1/module_system/dict/schema.py b/backend/app/api/v1/module_system/dict/schema.py index 49ae21c3..5a045ba2 100644 --- a/backend/app/api/v1/module_system/dict/schema.py +++ b/backend/app/api/v1/module_system/dict/schema.py @@ -26,12 +26,36 @@ class DictTypeCreateSchema(BaseModel): @field_validator("dict_name") def validate_dict_name(cls, value: str): + """ + 校验字典名称为非空字符串。 + + 参数: + - value (str): 字典名称。 + + 返回: + - str: 去首尾空格后的字典名称。 + + 异常: + - ValueError: 字典名称为空时抛出。 + """ if not value or value.strip() == "": raise ValueError("字典名称不能为空") return value.strip() @field_validator("dict_type") def validate_dict_type(cls, value: str): + """ + 校验字典类型:小写字母开头,仅包含小写字母/数字/下划线。 + + 参数: + - value (str): 字典类型。 + + 返回: + - str: 去首尾空格后的字典类型。 + + 异常: + - ValueError: 字典类型为空或不满足格式要求时抛出。 + """ if not value or value.strip() == "": raise ValueError("字典类型不能为空") regexp = r"^[a-z][a-z0-9_]*$" @@ -105,6 +129,15 @@ class DictDataCreateSchema(BaseModel): @model_validator(mode="after") def validate_after(self): + """ + 校验并规范化字典数据字段(标签/键值/类型/类型ID)。 + + 返回: + - DictDataCreateSchema: 校验与去空格后的同一实例。 + + 异常: + - ValueError: 必填字段为空或类型ID非法时抛出。 + """ if not self.dict_label or not self.dict_label.strip(): raise ValueError("字典标签不能为空") if not self.dict_value or not self.dict_value.strip(): diff --git a/backend/app/api/v1/module_system/dict/service.py b/backend/app/api/v1/module_system/dict/service.py index edbe0e9b..ab7977ce 100644 --- a/backend/app/api/v1/module_system/dict/service.py +++ b/backend/app/api/v1/module_system/dict/service.py @@ -76,7 +76,19 @@ class DictTypeService: search: DictTypeQueryParam | None = None, order_by: list[dict] | None = None, ) -> 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 return await DictTypeCRUD(auth).page( offset=offset, @@ -336,7 +348,19 @@ class DictDataService: search: DictDataQueryParam | None = None, order_by: list[dict] | None = None, ) -> 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 return await DictDataCRUD(auth).page( offset=offset, diff --git a/backend/app/api/v1/module_system/log/crud.py b/backend/app/api/v1/module_system/log/crud.py index 30018d1d..b8ef593f 100644 --- a/backend/app/api/v1/module_system/log/crud.py +++ b/backend/app/api/v1/module_system/log/crud.py @@ -16,7 +16,13 @@ class OperationLogCRUD( def __init__(self, auth: AuthSchema) -> None: """ - 初始化操作日志CRUD。 + 初始化操作日志数据层。 + + 参数: + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None """ self.auth = auth super().__init__(model=OperationLogModel, auth=auth) @@ -58,9 +64,9 @@ class OperationLogCRUD( 获取操作日志列表。 参数: - - search (Dict | None): 搜索条件字典。 - - order_by (List[Dict[str, str]] | None): 排序字段列表。 - - preload (Optional[List[Union[str, Any]]]): 预加载关系,未提供时使用模型默认项 + - search (dict | None): 搜索条件字典。 + - order_by (list | None): 排序字段列表。 + - preload (list | None): 预加载关系,未提供时使用模型默认项 返回: - Sequence[OperationLogModel]: 操作日志列表。 diff --git a/backend/app/api/v1/module_system/log/model.py b/backend/app/api/v1/module_system/log/model.py index 90eda9ef..fb26a4a8 100644 --- a/backend/app/api/v1/module_system/log/model.py +++ b/backend/app/api/v1/module_system/log/model.py @@ -6,12 +6,22 @@ from app.core.base_model import ModelMixin, UserMixin def get_log_text_column_type(): + """ + 根据数据库类型选择适合存储大文本的列类型。 + + MySQL 使用 LONGTEXT,PostgreSQL 使用 TEXT,其它数据库回退到 SQLAlchemy 的 Text。 + + 返回: + - type: SQLAlchemy 列类型(可用于 mapped_column)。 + """ db_type = settings.DATABASE_TYPE if db_type == "mysql": from sqlalchemy.dialects.mysql import LONGTEXT + return LONGTEXT elif db_type == "postgres": from sqlalchemy.dialects.postgresql import TEXT + return TEXT else: return Text diff --git a/backend/app/api/v1/module_system/log/service.py b/backend/app/api/v1/module_system/log/service.py index d7546cd3..6b24f029 100644 --- a/backend/app/api/v1/module_system/log/service.py +++ b/backend/app/api/v1/module_system/log/service.py @@ -65,7 +65,19 @@ class OperationLogService: search: OperationLogQueryParam | None = None, order_by: list | None = None, ) -> 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 return await OperationLogCRUD(auth).page( offset=offset, diff --git a/backend/app/api/v1/module_system/menu/crud.py b/backend/app/api/v1/module_system/menu/crud.py index 31f0da40..5e150263 100644 --- a/backend/app/api/v1/module_system/menu/crud.py +++ b/backend/app/api/v1/module_system/menu/crud.py @@ -11,7 +11,15 @@ class MenuCRUD(CRUDBase[MenuModel, MenuCreateSchema, MenuUpdateSchema]): """菜单模块数据层""" def __init__(self, auth: AuthSchema) -> None: - """初始化菜单CRUD""" + """ + 初始化菜单数据层。 + + 参数: + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None + """ self.auth = auth super().__init__(model=MenuModel, auth=auth) diff --git a/backend/app/api/v1/module_system/menu/schema.py b/backend/app/api/v1/module_system/menu/schema.py index 5e328d0e..9fd78df5 100644 --- a/backend/app/api/v1/module_system/menu/schema.py +++ b/backend/app/api/v1/module_system/menu/schema.py @@ -72,6 +72,15 @@ class MenuCreateSchema(BaseModel): @model_validator(mode="after") def validate_fields(self): + """ + 统一校验菜单请求字段(委托到 `menu_request_validator`)。 + + 返回: + - MenuCreateSchema: 校验后的同一实例。 + + 异常: + - CustomException: 字段不满足菜单类型约束时抛出。 + """ return menu_request_validator(self) diff --git a/backend/app/api/v1/module_system/menu/service.py b/backend/app/api/v1/module_system/menu/service.py index 3c46e727..c243041d 100644 --- a/backend/app/api/v1/module_system/menu/service.py +++ b/backend/app/api/v1/module_system/menu/service.py @@ -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 async def get_menu_detail_service(cls, auth: AuthSchema, id: int) -> dict: """ @@ -93,6 +118,8 @@ class MenuService: if menu: 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_dict = MenuOutSchema.model_validate(new_menu).model_dump() return new_menu_dict @@ -113,6 +140,7 @@ class MenuService: menu = await MenuCRUD(auth).get_by_id_crud(id=id) if not menu: raise CustomException(msg="更新失败,该菜单不存在") + await cls._validate_parent_child_type(auth, data.parent_id, data.type) search: dict[str, Any] = {"title": data.title} if data.parent_id is not None: search["parent_id"] = data.parent_id diff --git a/backend/app/api/v1/module_system/notice/crud.py b/backend/app/api/v1/module_system/notice/crud.py index dce0dbc2..536cf3bf 100644 --- a/backend/app/api/v1/module_system/notice/crud.py +++ b/backend/app/api/v1/module_system/notice/crud.py @@ -15,7 +15,10 @@ class NoticeCRUD(CRUDBase[NoticeModel, NoticeCreateSchema, NoticeUpdateSchema]): 初始化公告数据层。 参数: - - auth (AuthSchema): 认证信息模型。 + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None """ self.auth = auth super().__init__(model=NoticeModel, auth=auth) diff --git a/backend/app/api/v1/module_system/notice/service.py b/backend/app/api/v1/module_system/notice/service.py index 06c0a771..756cab14 100644 --- a/backend/app/api/v1/module_system/notice/service.py +++ b/backend/app/api/v1/module_system/notice/service.py @@ -84,7 +84,19 @@ class NoticeService: search: NoticeQueryParam | None = None, order_by: list[dict] | None = None, ) -> 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 return await NoticeCRUD(auth).page( offset=offset, @@ -96,7 +108,15 @@ class NoticeService: @classmethod 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( offset=0, limit=10, @@ -164,6 +184,9 @@ class NoticeService: 异常: - CustomException: 删除失败,删除对象不能为空或该公告通知不存在。 + + 返回: + - None """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") @@ -184,6 +207,9 @@ class NoticeService: 异常: - CustomException: 批量设置失败,该公告通知不存在。 + + 返回: + - None """ await NoticeCRUD(auth).set_available_crud(ids=data.ids, status=data.status) diff --git a/backend/app/api/v1/module_system/params/crud.py b/backend/app/api/v1/module_system/params/crud.py index c93181af..4ac1e947 100644 --- a/backend/app/api/v1/module_system/params/crud.py +++ b/backend/app/api/v1/module_system/params/crud.py @@ -12,10 +12,13 @@ class ParamsCRUD(CRUDBase[ParamsModel, ParamsCreateSchema, ParamsUpdateSchema]): def __init__(self, auth: AuthSchema) -> None: """ - 初始化配置CRUD + 初始化系统参数配置数据层。 参数: - - auth (AuthSchema): 认证信息模型 + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None """ self.auth = auth super().__init__(model=ParamsModel, auth=auth) diff --git a/backend/app/api/v1/module_system/params/service.py b/backend/app/api/v1/module_system/params/service.py index 1f0cde20..755f40dd 100644 --- a/backend/app/api/v1/module_system/params/service.py +++ b/backend/app/api/v1/module_system/params/service.py @@ -112,7 +112,19 @@ class ParamsService: search: ParamsQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 return await ParamsCRUD(auth).page( offset=offset, diff --git a/backend/app/api/v1/module_system/position/crud.py b/backend/app/api/v1/module_system/position/crud.py index 79e5931c..34d39040 100644 --- a/backend/app/api/v1/module_system/position/crud.py +++ b/backend/app/api/v1/module_system/position/crud.py @@ -13,10 +13,13 @@ class PositionCRUD(CRUDBase[PositionModel, PositionCreateSchema, PositionUpdateS def __init__(self, auth: AuthSchema) -> None: """ - 初始化岗位CRUD + 初始化岗位数据层。 参数: - - auth (AuthSchema): 认证信息模型 + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None """ self.auth = auth super().__init__(model=PositionModel, auth=auth) diff --git a/backend/app/api/v1/module_system/position/service.py b/backend/app/api/v1/module_system/position/service.py index 13141ff3..b6e38913 100644 --- a/backend/app/api/v1/module_system/position/service.py +++ b/backend/app/api/v1/module_system/position/service.py @@ -64,7 +64,19 @@ class PositionService: search: PositionQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 return await PositionCRUD(auth).page( offset=offset, @@ -84,7 +96,7 @@ class PositionService: - data (PositionCreateSchema): 岗位创建模型 返回: - - Dict: 创建的岗位对象 + - dict: 创建的岗位详情字典 """ position = await PositionCRUD(auth).get(name=data.name) if position: diff --git a/backend/app/api/v1/module_system/role/crud.py b/backend/app/api/v1/module_system/role/crud.py index 39984bb3..cf88f2d1 100644 --- a/backend/app/api/v1/module_system/role/crud.py +++ b/backend/app/api/v1/module_system/role/crud.py @@ -14,10 +14,13 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): def __init__(self, auth: AuthSchema) -> None: """ - 初始化角色模块数据层 + 初始化角色数据层。 参数: - - auth (AuthSchema): 认证信息模型 + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None """ self.auth = auth super().__init__(model=RoleModel, auth=auth) @@ -59,8 +62,8 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): 设置角色的菜单权限 参数: - - role_ids (List[int]): 角色ID列表 - - menu_ids (List[int]): 菜单ID列表 + - role_ids (list[int]): 角色ID列表 + - menu_ids (list[int]): 菜单ID列表 返回: - None diff --git a/backend/app/api/v1/module_system/role/schema.py b/backend/app/api/v1/module_system/role/schema.py index 77e3e0d7..bdc199c0 100644 --- a/backend/app/api/v1/module_system/role/schema.py +++ b/backend/app/api/v1/module_system/role/schema.py @@ -34,6 +34,18 @@ class RoleCreateSchema(BaseModel): @field_validator("code") @classmethod def validate_code(cls, value: str | None): + """ + 校验角色编码(委托到 `code_validator`)。 + + 参数: + - value (str | None): 角色编码。 + + 返回: + - str | None: 校验后的角色编码。 + + 异常: + - CustomException: 不满足编码规则时抛出。 + """ return code_validator(value) @@ -50,7 +62,18 @@ class RolePermissionSettingSchema(BaseModel): @model_validator(mode="after") def validate_fields(self): - """验证权限配置字段""" + """ + 校验角色权限配置字段(数据范围与关联 ID 等)。 + + 参数: + - self: 当前模型实例(校验后状态)。 + + 返回: + - RolePermissionSettingSchema: 通过 `role_permission_request_validator` 校验后的同一实例。 + + 异常: + - CustomException: 不满足权限配置约束时抛出。 + """ return role_permission_request_validator(self) diff --git a/backend/app/api/v1/module_system/role/service.py b/backend/app/api/v1/module_system/role/service.py index 7d125e45..1afc0b62 100644 --- a/backend/app/api/v1/module_system/role/service.py +++ b/backend/app/api/v1/module_system/role/service.py @@ -63,7 +63,19 @@ class RoleService: search: RoleQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 return await RoleCRUD(auth).page( offset=offset, diff --git a/backend/app/api/v1/module_system/user/crud.py b/backend/app/api/v1/module_system/user/crud.py index 1282b84d..588df65e 100644 --- a/backend/app/api/v1/module_system/user/crud.py +++ b/backend/app/api/v1/module_system/user/crud.py @@ -20,10 +20,13 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): def __init__(self, auth: AuthSchema) -> None: """ - 初始化用户CRUD + 初始化用户数据层。 参数: - - auth (AuthSchema): 认证信息模型 + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None """ self.auth = auth super().__init__(model=UserModel, auth=auth) @@ -123,10 +126,10 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): 参数: - ids (list[int]): 用户ID列表 - - status (bool): 可用状态 + - status (str): 可用状态(与表字段一致,如 "0"/"1") 返回: - - None: + - None """ await self.set(ids=ids, status=status) @@ -139,7 +142,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): - role_ids (list[int]): 角色ID列表 返回: - - None: + - None """ user_objs = await self.list(search={"id": ("in", user_ids)}) if role_ids: @@ -162,7 +165,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): - position_ids (list[int]): 岗位ID列表 返回: - - None: + - None """ user_objs = await self.list(search={"id": ("in", user_ids)}) if position_ids: diff --git a/backend/app/api/v1/module_system/user/schema.py b/backend/app/api/v1/module_system/user/schema.py index c2d65ade..e8f054ef 100644 --- a/backend/app/api/v1/module_system/user/schema.py +++ b/backend/app/api/v1/module_system/user/schema.py @@ -29,11 +29,35 @@ class CurrentUserUpdateSchema(BaseModel): @field_validator("mobile") @classmethod def validate_mobile(cls, value: str | None): + """ + 校验手机号格式(委托到 `mobile_validator`)。 + + 参数: + - value (str | None): 手机号。 + + 返回: + - str | None: 校验后的手机号。 + + 异常: + - CustomException: 手机号格式非法时抛出。 + """ return mobile_validator(value) @field_validator("email") @classmethod def validate_email(cls, value: str | None): + """ + 校验邮箱格式(为空则跳过;否则委托到 `email_validator`)。 + + 参数: + - value (str | None): 邮箱。 + + 返回: + - str | None: 校验后的邮箱。 + + 异常: + - CustomException: 邮箱格式非法时抛出。 + """ if not value: return value return email_validator(value) @@ -41,6 +65,18 @@ class CurrentUserUpdateSchema(BaseModel): @field_validator("avatar") @classmethod def validate_avatar(cls, value: str | None): + """ + 校验头像地址为合法的 HTTP/HTTPS URL。 + + 参数: + - value (str | None): 头像 URL。 + + 返回: + - str | None: 校验后的头像 URL。 + + 异常: + - ValueError: 头像 URL 非法时抛出。 + """ if not value: return value parsed = urlparse(value) @@ -50,6 +86,15 @@ class CurrentUserUpdateSchema(BaseModel): @model_validator(mode="after") def check_model(self): + """ + 校验基础用户信息的长度约束。 + + 返回: + - CurrentUserUpdateSchema: 校验后的同一实例。 + + 异常: + - ValueError: 字段长度超限时抛出。 + """ if self.name and len(self.name) > 32: raise ValueError("名称长度不能超过32个字符") return self @@ -69,11 +114,35 @@ class UserRegisterSchema(BaseModel): @field_validator("mobile") @classmethod def validate_mobile(cls, value: str | None): + """ + 校验手机号格式(委托到 `mobile_validator`)。 + + 参数: + - value (str | None): 手机号。 + + 返回: + - str | None: 校验后的手机号。 + + 异常: + - CustomException: 手机号格式非法时抛出。 + """ return mobile_validator(value) @field_validator("username") @classmethod def validate_username(cls, value: str): + """ + 校验并规范化账号:字母开头,长度 3-32,仅含字母/数字/_ . -。 + + 参数: + - value (str): 账号。 + + 返回: + - str: 规范化后的账号。 + + 异常: + - ValueError: 账号为空或不满足格式约束时抛出。 + """ v = value.strip() if not v: raise ValueError("账号不能为空") @@ -86,6 +155,15 @@ class UserRegisterSchema(BaseModel): @model_validator(mode="after") def check_model(self): + """ + 校验注册信息的长度约束。 + + 返回: + - UserRegisterSchema: 校验后的同一实例。 + + 异常: + - ValueError: 任一字段长度超限时抛出。 + """ if self.name and len(self.name) > 32: raise ValueError("名称长度不能超过32个字符") if self.username and len(self.username) > 32: @@ -107,6 +185,18 @@ class UserForgetPasswordSchema(BaseModel): @field_validator("mobile") @classmethod def validate_mobile(cls, value: str | None): + """ + 校验手机号格式(委托到 `mobile_validator`)。 + + 参数: + - value (str | None): 手机号。 + + 返回: + - str | None: 校验后的手机号。 + + 异常: + - CustomException: 手机号格式非法时抛出。 + """ return mobile_validator(value) diff --git a/backend/app/api/v1/module_system/user/service.py b/backend/app/api/v1/module_system/user/service.py index b58afdd4..c23de382 100644 --- a/backend/app/api/v1/module_system/user/service.py +++ b/backend/app/api/v1/module_system/user/service.py @@ -95,7 +95,19 @@ class UserService: search: UserQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 return await UserCRUD(auth).page( offset=offset, @@ -677,7 +689,7 @@ class UserService: 导出用户列表为Excel文件 参数: - - user_list (List[Dict[str, Any]]): 用户列表 + - user_list (list[dict[str, Any]]): 用户列表 返回: - bytes: Excel文件字节流 diff --git a/backend/app/common/constant.py b/backend/app/common/constant.py index c23b2a69..e1ec48fd 100644 --- a/backend/app/common/constant.py +++ b/backend/app/common/constant.py @@ -193,12 +193,22 @@ class RET(Enum): @property def code(self) -> int: - """获取错误码""" + """ + 获取错误码。 + + 返回: + - int: 错误码数值。 + """ return self._code @property def msg(self) -> str: - """获取错误信息""" + """ + 获取错误信息。 + + 返回: + - str: 错误信息文本。 + """ return self._msg @@ -766,6 +776,10 @@ class GenConstant: class TypedContextProtocol(Protocol): + """ + 请求上下文中与日志/鉴权相关的结构化字段协议(供类型检查使用)。 + """ + perf_time: float ip: str diff --git a/backend/app/common/dataclasses.py b/backend/app/common/dataclasses.py index 76cf3221..3da95e77 100644 --- a/backend/app/common/dataclasses.py +++ b/backend/app/common/dataclasses.py @@ -6,6 +6,8 @@ from fastapi import Response @dataclasses.dataclass class IpInfo: + """IP 归属地解析结果。""" + ip: str country: str | None region: str | None @@ -14,6 +16,8 @@ class IpInfo: @dataclasses.dataclass class UserAgentInfo: + """User-Agent 解析结果(操作系统、浏览器、设备)。""" + user_agent: str os: str | None browser: str | None @@ -22,6 +26,8 @@ class UserAgentInfo: @dataclasses.dataclass class RequestCallNext: + """请求链路 call_next 封装结果(状态码、消息、异常、响应)。""" + code: str msg: str err: Exception | None @@ -30,6 +36,8 @@ class RequestCallNext: @dataclasses.dataclass class AccessToken: + """访问令牌及过期时间、会话 UUID。""" + access_token: str access_token_expire_time: datetime session_uuid: str @@ -37,12 +45,16 @@ class AccessToken: @dataclasses.dataclass class RefreshToken: + """刷新令牌及过期时间。""" + refresh_token: str refresh_token_expire_time: datetime @dataclasses.dataclass class NewToken: + """刷新后的一对访问/刷新令牌及会话 UUID。""" + new_access_token: str new_access_token_expire_time: datetime new_refresh_token: str @@ -52,6 +64,8 @@ class NewToken: @dataclasses.dataclass class TokenPayload: + """JWT/会话载荷中的用户与会话标识。""" + id: int session_uuid: str expire_time: datetime @@ -59,11 +73,15 @@ class TokenPayload: @dataclasses.dataclass class UploadUrl: + """上传完成后的访问 URL。""" + url: str @dataclasses.dataclass class SnowflakeInfo: + """雪花 ID 拆解后的各段信息。""" + timestamp: int datetime: str cluster_id: int diff --git a/backend/app/common/enums.py b/backend/app/common/enums.py index 92d7ddb5..d258f67f 100644 --- a/backend/app/common/enums.py +++ b/backend/app/common/enums.py @@ -3,6 +3,8 @@ from enum import Enum, unique @unique class EnvironmentEnum(str, Enum): + """应用运行环境(开发 / 生产)。""" + DEV = "dev" PROD = "prod" @@ -52,12 +54,22 @@ class RedisInitKeyConfig(Enum): @property def key(self) -> str: - """获取Redis键名""" + """ + 获取 Redis 键名。 + + 返回: + - str: 键名字符串。 + """ return self.value.get("key", "") @property def remark(self) -> str: - """获取Redis键名说明""" + """ + 获取 Redis 键说明。 + + 返回: + - str: 说明文案。 + """ return self.value.get("remark", "") diff --git a/backend/app/config/setting.py b/backend/app/config/setting.py index ebc1632b..ff675f7d 100755 --- a/backend/app/config/setting.py +++ b/backend/app/config/setting.py @@ -197,7 +197,12 @@ class Settings(BaseSettings): # ================================================= # @property def MIDDLEWARE_LIST(self) -> list[str | None]: - """获取项目根目录""" + """ + 根据开关组装的中间件类路径列表(未启用的项为 None)。 + + 返回: + - list[str | None]: 中间件 import 路径或 None。 + """ # 中间件列表 MIDDLEWARES: list[str | None] = [ "app.core.middlewares.CustomCORSMiddleware" if self.CORS_ORIGIN_ENABLE else None, @@ -208,7 +213,12 @@ class Settings(BaseSettings): @property def EVENT_LIST(self) -> list[str | None]: - """获取事件列表""" + """ + 应用启动时加载的全局异步事件模块路径列表。 + + 返回: + - list[str | None]: 事件模块路径或 None。 + """ EVENTS: list[str | None] = [ "app.core.database.redis_connect" if self.REDIS_ENABLE else None, ] @@ -216,7 +226,15 @@ class Settings(BaseSettings): @property def ASYNC_DB_URI(self) -> str: - """获取异步数据库连接""" + """ + 异步 SQLAlchemy 数据库 URL。 + + 返回: + - str: 异步驱动连接串。 + + 异常: + - ValueError: 数据库类型不支持时抛出。 + """ if self.DATABASE_TYPE not in ("mysql", "postgres", "sqlite"): raise ValueError( f"数据库驱动不支持: {self.DATABASE_TYPE}, 异步数据库请选择 mysql、postgres、sqlite" @@ -232,7 +250,15 @@ class Settings(BaseSettings): @property def DB_URI(self) -> str: - """获取同步数据库连接""" + """ + 同步 SQLAlchemy 数据库 URL。 + + 返回: + - str: 同步驱动连接串。 + + 异常: + - ValueError: 数据库类型不支持时抛出。 + """ if self.DATABASE_TYPE not in ("mysql", "postgres", "sqlite"): raise ValueError( f"数据库驱动不支持: {self.DATABASE_TYPE}, 同步数据库请选择 mysql、postgres、sqlite" @@ -248,12 +274,22 @@ class Settings(BaseSettings): @property 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}" @property def FASTAPI_CONFIG(self) -> dict[str, Any]: - """获取FastAPI应用属性""" + """ + 创建 FastAPI 应用实例时使用的关键字参数子集。 + + 返回: + - dict[str, Any]: debug、title、responses 等配置。 + """ return { "debug": self.DEBUG, "title": self.TITLE, @@ -277,7 +313,12 @@ class Settings(BaseSettings): @lru_cache(maxsize=1) def get_settings() -> Settings: - """获取配置实例""" + """ + 获取全局 Settings 单例(lru_cache 缓存)。 + + 返回: + - Settings: 配置实例。 + """ return Settings() diff --git a/backend/app/core/ap_scheduler.py b/backend/app/core/ap_scheduler.py index 0837946e..0b9a73ca 100644 --- a/backend/app/core/ap_scheduler.py +++ b/backend/app/core/ap_scheduler.py @@ -86,8 +86,13 @@ class SchedulerUtil: @classmethod def scheduler_event_listener(cls, event: JobEvent | JobExecutionEvent) -> None: """ - 监听任务执行事件,记录执行日志 - 每次执行都创建新记录,保留所有历史执行记录 + 监听任务执行事件,记录执行日志;每次执行新建日志行,保留历史。 + + 参数: + - event (JobEvent | JobExecutionEvent): APScheduler 事件对象。 + + 返回: + - None """ try: # 事件处理器映射 @@ -628,7 +633,13 @@ class SchedulerUtil: @classmethod async def init_scheduler(cls, redis: Redis | None = None) -> None: """ - 应用启动时初始化定时任务。 + 应用启动时初始化定时任务调度器。 + + 参数: + - redis (Redis | None): 可选 Redis 实例,供任务侧使用。 + + 返回: + - None """ if redis: cls.redis_instance = redis @@ -646,6 +657,12 @@ class SchedulerUtil: import types def run_sync_handler(): + """ + 在独立模块命名空间中执行代码块并调用 handler。 + + 返回: + - Any: handler 返回值;无代码块时为 None。 + """ if not code_block: return None @@ -683,6 +700,15 @@ class SchedulerUtil: state = job.__getstate__() def serialize_value(obj): + """ + 将 job state 中的嵌套对象转为可 JSON 化的 Python 结构。 + + 参数: + - obj (Any): 任意嵌套对象。 + + 返回: + - Any: 标量、dict、list 或简化后的描述。 + """ if obj is None: return None if isinstance(obj, (str, int, float, bool)): @@ -728,6 +754,15 @@ class SchedulerUtil: return None def serialize_value(obj: Any) -> Any: + """ + 递归反序列化 BLOB 中的嵌套结构为可 JSON 化数据。 + + 参数: + - obj (Any): 节点对象。 + + 返回: + - Any: 标量、dict、list 或字符串化结果。 + """ if obj is None: return None if isinstance(obj, (str, int, float, bool)): @@ -954,7 +989,13 @@ class SchedulerUtil: @classmethod 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)) if not job: @@ -972,7 +1013,13 @@ class SchedulerUtil: @classmethod def add_and_run_job_now(cls, job_info: NodeModel) -> Job: """ - 立即执行任务(添加到调度器并立即运行) + 立即执行任务(加入调度器并尽快触发一次)。 + + 参数: + - job_info (NodeModel): 节点/任务配置。 + + 返回: + - Job: APScheduler Job 对象。 """ # 使用稍微延迟的时间,确保事件监听器能够捕获事件 from datetime import timedelta @@ -990,13 +1037,16 @@ class SchedulerUtil: end_date: str | None = None, ) -> Job: """ - 创建Cron定时任务 + 创建 Cron 定时任务。 参数: - - job_info: 任务信息 - - trigger_args: Cron表达式 - - start_date: 开始时间 - - end_date: 结束时间 + - job_info (NodeModel): 任务信息。 + - trigger_args (str | None): Cron 表达式,默认取节点配置。 + - start_date (str | None): 开始时间。 + - end_date (str | None): 结束时间。 + + 返回: + - Job: 已注册的 APScheduler Job。 """ cron_expr = trigger_args or job_info.trigger_args if not cron_expr: @@ -1040,13 +1090,16 @@ class SchedulerUtil: end_date: str | None = None, ) -> Job: """ - 创建间隔执行任务 + 创建间隔执行任务。 参数: - - job_info: 任务信息 - - trigger_args: 间隔参数 (秒 分 时 天 周) - - start_date: 开始时间 - - end_date: 结束时间 + - job_info (NodeModel): 任务信息。 + - trigger_args (str | None): 间隔参数「秒 分 时 天 周」,默认取节点配置。 + - start_date (str | None): 开始时间。 + - end_date (str | None): 结束时间。 + + 返回: + - Job: 已注册的 APScheduler Job。 """ interval_args = trigger_args or job_info.trigger_args if not interval_args: @@ -1074,11 +1127,14 @@ class SchedulerUtil: @classmethod def add_date_job(cls, job_info: NodeModel, run_date: str | None = None) -> Job: """ - 创建指定时间执行任务 + 创建指定时刻执行一次的任务。 参数: - - job_info: 任务信息 - - run_date: 执行时间 + - job_info (NodeModel): 任务信息。 + - run_date (str | None): 执行时间字符串,默认取节点 trigger 配置。 + + 返回: + - Job: 已注册的 APScheduler Job。 """ date_str = run_date or job_info.trigger_args if not date_str: @@ -1151,30 +1207,83 @@ class SchedulerUtil: @classmethod def start(cls, paused: bool = False) -> None: + """ + 启动全局调度器。 + + 参数: + - paused (bool): 是否以暂停状态启动。 + + 返回: + - None + """ scheduler.start(paused=paused) @classmethod async def shutdown(cls, wait: bool = False): + """ + 关闭调度器。 + + 参数: + - wait (bool): 是否等待当前任务结束。 + + 返回: + - 与 APScheduler shutdown 返回值一致。 + """ return scheduler.shutdown(wait=wait) @classmethod 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) @classmethod def pause(cls) -> None: + """ + 暂停调度器。 + + 返回: + - None + """ scheduler.pause() @classmethod def resume(cls) -> None: + """ + 恢复调度器。 + + 返回: + - None + """ scheduler.resume() @classmethod def is_running(cls) -> bool: + """ + 调度器是否处于运行态。 + + 返回: + - bool: 是否 running。 + """ return scheduler.running @classmethod def get_scheduler_state(cls) -> str: + """ + 将调度器内部 state 码映射为中文状态。 + + 返回: + - str: 停止 / 运行中 / 暂停 / 未知。 + """ if scheduler.state == 0: return "停止" if scheduler.state == 1: @@ -1185,22 +1294,63 @@ class SchedulerUtil: @classmethod 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) @classmethod def get_jobs(cls, jobstore: str | None = None) -> list[Job]: + """ + 列出指定存储器中的任务。 + + 参数: + - jobstore (str | None): 存储器别名,None 表示默认存储。 + + 返回: + - list[Job]: 任务列表。 + """ return scheduler.get_jobs(jobstore) @classmethod def get_all_jobs(cls) -> list[Job]: + """ + 列出所有存储器中的任务。 + + 返回: + - list[Job]: 任务列表。 + """ return scheduler.get_jobs() @classmethod 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) @classmethod def clear_jobs(cls) -> None: + """ + 移除所有存储器中的全部任务。 + + 返回: + - None + """ scheduler.remove_all_jobs() @classmethod @@ -1259,23 +1409,61 @@ class SchedulerUtil: @classmethod 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) @classmethod 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) @classmethod 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) @classmethod 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) if not job: diff --git a/backend/app/core/base_crud.py b/backend/app/core/base_crud.py index c9cfbd68..f9d33edb 100644 --- a/backend/app/core/base_crud.py +++ b/backend/app/core/base_crud.py @@ -299,6 +299,9 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 参数: - ids (List[int]): 对象ID列表 + 返回: + - None + 异常: - CustomException: 删除失败时抛出异常 """ @@ -321,6 +324,9 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): """ 清空对象表 + 返回: + - None + 异常: - CustomException: 清空失败时抛出异常 """ @@ -339,6 +345,9 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): - ids (List[int]): 对象ID列表 - **kwargs: 更新的属性及值 + 返回: + - None + 异常: - CustomException: 更新失败时抛出异常 """ diff --git a/backend/app/core/base_model.py b/backend/app/core/base_model.py index f78ff196..63857abe 100644 --- a/backend/app/core/base_model.py +++ b/backend/app/core/base_model.py @@ -140,7 +140,10 @@ class UserMixin(MappedBase): @declared_attr def created_by(self) -> Mapped[Optional["UserModel"]]: """ - 创建人关联关系(延迟加载,避免循环依赖) + 创建人关联关系(延迟加载,避免循环依赖)。 + + 返回: + - Mapped[Optional[UserModel]]: 创建人 ORM 关系。 """ return relationship( "UserModel", @@ -152,7 +155,10 @@ class UserMixin(MappedBase): @declared_attr def updated_by(self) -> Mapped[Optional["UserModel"]]: """ - 更新人关联关系(延迟加载,避免循环依赖) + 更新人关联关系(延迟加载,避免循环依赖)。 + + 返回: + - Mapped[Optional[UserModel]]: 更新人 ORM 关系。 """ return relationship( "UserModel", diff --git a/backend/app/core/database.py b/backend/app/core/database.py index d8178c5b..ca867e56 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -56,6 +56,9 @@ def create_async_engine_and_session( """ 获取异步数据库会话连接。 + 参数: + - db_url (str): 异步数据库 URL,默认取配置项 ASYNC_DB_URI。 + 返回: - 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: - """创建数据库表""" + """ + 创建数据库表(根据 ORM metadata)。 + + 返回: + - None + """ async with async_engine.begin() as coon: await coon.run_sync(MappedBase.metadata.create_all) async def drop_tables() -> None: - """删除数据库表""" + """ + 删除数据库表(根据 ORM metadata)。 + + 返回: + - None + """ async with async_engine.begin() as conn: await conn.run_sync(MappedBase.metadata.drop_all) diff --git a/backend/app/core/docs.py b/backend/app/core/docs.py index 3c871182..b41b5818 100644 --- a/backend/app/core/docs.py +++ b/backend/app/core/docs.py @@ -55,15 +55,17 @@ def get_custom_ui_html( ] = "https://fastapi.tiangolo.com/img/favicon.png", ) -> HTMLResponse: """ - Generate and return the HTML that loads Swagger UI for the interactive - API docs (normally served at `/docs`). + 生成加载 Swagger UI 的 HTML 页面(与 FastAPI 默认 `/docs` 行为一致,可自定义静态资源 URL)。 - 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/) - and the [FastAPI docs for Custom Docs UI Static Assets (Self-Hosting)](https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/). + 返回: + - HTMLResponse: 可直接返回给浏览器的 HTML 响应。 """ html = f""" diff --git a/backend/app/core/http_limit.py b/backend/app/core/http_limit.py index a7d5770d..b8d0a16e 100644 --- a/backend/app/core/http_limit.py +++ b/backend/app/core/http_limit.py @@ -9,12 +9,15 @@ from app.core.exceptions import CustomException def http_limit_callback(request: Request, response: Response, expire: int) -> NoReturn: """ - 请求限制时的默认回调函数 + HTTP 触发限流时的默认回调:抛出 429。 - :param request: FastAPI 请求对象 - :param response: FastAPI 响应对象 - :param expire: 剩余毫秒数 - :return: + 参数: + - request (Request): 当前请求。 + - response (Response): 当前响应(未直接使用,保留与限流器签名一致)。 + - expire (int): 剩余冷却毫秒数。 + + 返回: + - 无(始终抛出 CustomException)。 """ expires = ceil(expire / 30) 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: """ - WebSocket请求限制时的默认回调函数 + WebSocket 触发限流时的默认回调:关闭连接。 - :param ws: WebSocket连接对象 - :param expire: 剩余毫秒数 - :return: + 参数: + - ws (WebSocket): 当前 WebSocket。 + - expire (int): 剩余冷却毫秒数。 + + 返回: + - None """ expires = ceil(expire / 30) await ws.close(code=1008, reason=f"请求过于频繁,请稍后重试!{expires} 秒后重试") diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py index afe5f77c..c09a5375 100644 --- a/backend/app/core/logger.py +++ b/backend/app/core/logger.py @@ -24,6 +24,15 @@ class InterceptHandler(logging.Handler): @override def emit(self, record: logging.LogRecord) -> None: + """ + 将标准库 LogRecord 转发到 Loguru。 + + 参数: + - record (logging.LogRecord): 标准库日志记录。 + + 返回: + - None + """ # 尝试获取日志级别名称 try: level = logger.level(record.levelname).name @@ -42,8 +51,10 @@ class InterceptHandler(logging.Handler): def cleanup_logging() -> None: """ - 清理日志资源 - 在程序退出时调用,确保所有日志处理器被正确关闭 + 清理日志资源;在程序退出时调用,移除已注册的 Loguru 处理器。 + + 返回: + - None """ global _logger_handlers @@ -59,12 +70,10 @@ def cleanup_logging() -> None: def setup_logging() -> None: """ - 配置日志系统 + 配置日志系统:控制台彩色输出、文件轮转、错误日志分文件。 - 功能: - 1. 控制台彩色输出 - 2. 文件日志轮转 - 3. 错误日志单独存储 + 返回: + - None """ global _logger_handlers diff --git a/backend/app/core/middlewares.py b/backend/app/core/middlewares.py index c6487bae..55466c44 100644 --- a/backend/app/core/middlewares.py +++ b/backend/app/core/middlewares.py @@ -85,6 +85,16 @@ class RequestLogMiddleware(BaseHTTPMiddleware): return None async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """ + 记录请求日志并透传响应。 + + 参数: + - request (Request): 当前请求。 + - call_next (RequestResponseEndpoint): 下一层 ASGI 可调用对象。 + + 返回: + - Response: 下游中间件/路由产生的响应。 + """ start_time = time.time() # 尝试提取session_id diff --git a/backend/app/core/permission.py b/backend/app/core/permission.py index 4dce2e9e..61eea73a 100644 --- a/backend/app/core/permission.py +++ b/backend/app/core/permission.py @@ -40,13 +40,13 @@ class Permission: async def filter_query(self, query: Any) -> Any: """ - 异步过滤查询对象 + 按数据权限为 SQLAlchemy 查询追加 WHERE 条件。 - Args: - query: SQLAlchemy查询对象 + 参数: + - query (Any): SQLAlchemy 查询对象。 - Returns: - 过滤后的查询对象 + 返回: + - Any: 附加条件后的查询对象(无权限条件时原样返回)。 """ condition = await self.__permission_condition() return query.where(condition) if condition is not None else query diff --git a/backend/app/plugin/module_ai/chat/crud.py b/backend/app/plugin/module_ai/chat/crud.py index 002ac52d..7c60c690 100644 --- a/backend/app/plugin/module_ai/chat/crud.py +++ b/backend/app/plugin/module_ai/chat/crud.py @@ -47,7 +47,15 @@ class ChatSessionCRUD: self.db.close() async def get_by_id_crud(self, session_id: str) -> TeamSession | None: - """获取会话详情""" + """ + 获取会话详情。 + + 参数: + - session_id (str): 会话 ID。 + + 返回: + - TeamSession | None: 会话对象;失败或不存在时为 None。 + """ try: return self.db.get_session( session_id=session_id, @@ -63,7 +71,16 @@ class ChatSessionCRUD: search: dict[str, Any] | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[TeamSession]: - """列表查询 - 获取所有会话""" + """ + 列表查询,获取当前用户的所有会话。 + + 参数: + - search (dict[str, Any] | None): 预留查询条件(当前实现未使用)。 + - order_by (list[dict[str, str]] | None): 预留排序(当前实现未使用)。 + + 返回: + - list[TeamSession]: 会话列表;失败时为空列表。 + """ try: result = self.db.get_sessions( session_type=self.SESSION_TYPE, @@ -77,7 +94,15 @@ class ChatSessionCRUD: return [] async def create_crud(self, data: ChatSessionCreateSchema) -> TeamSession | None: - """创建会话 - Team 会在运行时自动创建和管理 session""" + """ + 创建会话(Team 在运行时自动创建并管理 session)。 + + 参数: + - data (ChatSessionCreateSchema): 创建参数(如标题)。 + + 返回: + - TeamSession | None: 新建会话;失败时为 None。 + """ import time import uuid @@ -108,7 +133,16 @@ class ChatSessionCRUD: return None async def update_crud(self, session_id: str, data: ChatSessionUpdateSchema) -> bool: - """更新会话""" + """ + 更新会话(如重命名)。 + + 参数: + - session_id (str): 会话 ID。 + - data (ChatSessionUpdateSchema): 更新数据。 + + 返回: + - bool: 是否成功。 + """ try: self.db.rename_session( session_id=session_id, @@ -122,7 +156,15 @@ class ChatSessionCRUD: return False async def delete_crud(self, session_ids: list[str]) -> bool: - """批量删除会话""" + """ + 批量删除会话。 + + 参数: + - session_ids (list[str]): 会话 ID 列表。 + + 返回: + - bool: 是否全部处理成功(任一出错则记日志并返回 False)。 + """ try: for session_id in session_ids: self.db.delete_session( diff --git a/backend/app/plugin/module_ai/chat/service.py b/backend/app/plugin/module_ai/chat/service.py index 664f6ea4..819c24c1 100644 --- a/backend/app/plugin/module_ai/chat/service.py +++ b/backend/app/plugin/module_ai/chat/service.py @@ -130,7 +130,16 @@ class ChatService: async def chat_query( cls, query: ChatQuerySchema, auth: AuthSchema ) -> AsyncGenerator[str, None]: - """处理聊天查询并返回流式响应""" + """ + 处理聊天查询并流式返回文本片段。 + + 参数: + - query (ChatQuerySchema): 用户消息与会话等查询参数。 + - auth (AuthSchema): 当前用户认证信息。 + + 返回: + - AsyncGenerator[str, None]: 逐段输出的回复文本。 + """ try: # 创建 CRUD 实例获取数据库连接 crud = ChatSessionCRUD(auth) @@ -171,7 +180,17 @@ class ChatService: async def chat_non_stream( cls, message: str, session_id: str | None, auth: AuthSchema ) -> dict[str, Any]: - """处理聊天查询并返回非流式响应""" + """ + 处理聊天查询并返回非流式 JSON 结构(含 session_id、操作建议等)。 + + 参数: + - message (str): 用户输入文本。 + - session_id (str | None): 已有会话 ID;为空则新建会话。 + - auth (AuthSchema): 当前用户认证信息。 + + 返回: + - dict[str, Any]: 包含 response、session_id、action 等字段的字典。 + """ try: # 创建 CRUD 实例获取数据库连接 crud = ChatSessionCRUD(auth) @@ -298,7 +317,16 @@ class ChatService: async def create_service( cls, auth: AuthSchema, data: ChatSessionCreateSchema ) -> dict[str, Any] | None: - """创建会话""" + """ + 创建会话。 + + 参数: + - auth (AuthSchema): 认证信息。 + - data (ChatSessionCreateSchema): 创建参数。 + + 返回: + - dict[str, Any] | None: 格式化后的会话字典;失败为 None。 + """ crud = ChatSessionCRUD(auth) session = await crud.create_crud(data=data) if session: @@ -309,7 +337,16 @@ class ChatService: async def get_session_service( cls, auth: AuthSchema, session_id: str ) -> dict[str, Any] | None: - """获取单个会话详情""" + """ + 获取单个会话详情。 + + 参数: + - auth (AuthSchema): 认证信息。 + - session_id (str): 会话 ID。 + + 返回: + - dict[str, Any] | None: 格式化后的会话字典;不存在为 None。 + """ crud = ChatSessionCRUD(auth) session: TeamSession | None = await crud.get_by_id_crud(session_id=session_id) if session: @@ -325,7 +362,19 @@ class ChatService: search: ChatSessionQueryParam, order_by: list[dict[str, str]] | None = None, ) -> 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]: 分页结果(含 items、total 等)。 + """ crud = ChatSessionCRUD(auth) # 获取所有会话 sessions = await crud.list_crud() @@ -346,12 +395,31 @@ class ChatService: async def update_service( cls, auth: AuthSchema, session_id: str, data: ChatSessionUpdateSchema ) -> bool: - """更新会话""" + """ + 更新会话。 + + 参数: + - auth (AuthSchema): 认证信息。 + - session_id (str): 会话 ID。 + - data (ChatSessionUpdateSchema): 更新数据。 + + 返回: + - bool: 是否成功。 + """ crud = ChatSessionCRUD(auth) success = await crud.update_crud(session_id=session_id, data=data) return success @classmethod 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) diff --git a/backend/app/plugin/module_ai/chat/utils.py b/backend/app/plugin/module_ai/chat/utils.py index cfba6fa0..b48814fd 100644 --- a/backend/app/plugin/module_ai/chat/utils.py +++ b/backend/app/plugin/module_ai/chat/utils.py @@ -23,7 +23,18 @@ class AgnoFactory: session_id: str, db: Any | None = None ) -> Team: - """创建 Team 实例""" + """ + 创建带 Agent 的 Team 实例。 + + 参数: + - user_id (str): 用户标识。 + - dept_id (str): 部门/团队标识。 + - session_id (str): 会话 ID。 + - db (Any | None): Agno 持久化数据库实例,可选。 + + 返回: + - Team: 配置好的 Team。 + """ # 创建 Agent fastapiadmin_agent = Agent( diff --git a/backend/app/plugin/module_ai/chat/ws.py b/backend/app/plugin/module_ai/chat/ws.py index 051df973..a119c47e 100644 --- a/backend/app/plugin/module_ai/chat/ws.py +++ b/backend/app/plugin/module_ai/chat/ws.py @@ -22,11 +22,17 @@ async def websocket_chat_controller( websocket: WebSocket, ) -> None: """ - WebSocket聊天接口 + WebSocket 聊天接口。 + + 参数: + - websocket (WebSocket): WebSocket 连接。 + + 返回: + - None: 长连接处理完毕或关闭后无返回值。 支持两种消息格式: 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 """ diff --git a/backend/app/plugin/module_application/myapp/crud.py b/backend/app/plugin/module_application/myapp/crud.py index 8c7af054..cdc06852 100644 --- a/backend/app/plugin/module_application/myapp/crud.py +++ b/backend/app/plugin/module_application/myapp/crud.py @@ -86,6 +86,9 @@ class ApplicationCRUD(CRUDBase[ApplicationModel, ApplicationCreateSchema, Applic 参数: - ids (list[int]): 应用ID列表 + + 返回: + - None """ return await self.delete(ids=ids) @@ -96,5 +99,8 @@ class ApplicationCRUD(CRUDBase[ApplicationModel, ApplicationCreateSchema, Applic 参数: - ids (list[int]): 应用ID列表 - status (str): 可用状态,True为可用,False为不可用 + + 返回: + - None """ return await self.set(ids=ids, status=status) diff --git a/backend/app/plugin/module_application/myapp/service.py b/backend/app/plugin/module_application/myapp/service.py index bfa183a7..cc79c3b4 100644 --- a/backend/app/plugin/module_application/myapp/service.py +++ b/backend/app/plugin/module_application/myapp/service.py @@ -65,7 +65,19 @@ class ApplicationService: search: ApplicationQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 search_dict = search.__dict__ if search else {} return await ApplicationCRUD(auth).page( diff --git a/backend/app/plugin/module_example/demo/schema.py b/backend/app/plugin/module_example/demo/schema.py index f364e013..f9e8be1d 100644 --- a/backend/app/plugin/module_example/demo/schema.py +++ b/backend/app/plugin/module_example/demo/schema.py @@ -33,7 +33,18 @@ class DemoCreateSchema(BaseModel): @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - """验证名称字段的格式和内容""" + """ + 验证名称字段的格式和内容。 + + 参数: + - v (str): 原始名称。 + + 返回: + - str: 去空白后的名称。 + + 异常: + - ValueError: 名称为空时抛出。 + """ # 去除首尾空格 v = v.strip() if not v: diff --git a/backend/app/plugin/module_example/demo01/schema.py b/backend/app/plugin/module_example/demo01/schema.py index 3fe4f970..6174c175 100644 --- a/backend/app/plugin/module_example/demo01/schema.py +++ b/backend/app/plugin/module_example/demo01/schema.py @@ -25,7 +25,18 @@ class Demo01CreateSchema(BaseModel): @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: - """验证名称字段的格式和内容""" + """ + 验证名称字段的格式和内容。 + + 参数: + - v (str): 原始名称。 + + 返回: + - str: 去空白后的名称。 + + 异常: + - ValueError: 名称为空时抛出。 + """ # 去除首尾空格 v = v.strip() if not v: diff --git a/backend/app/plugin/module_generator/gencode/controller.py b/backend/app/plugin/module_generator/gencode/controller.py index 4c1a0063..684edb69 100644 --- a/backend/app/plugin/module_generator/gencode/controller.py +++ b/backend/app/plugin/module_generator/gencode/controller.py @@ -348,5 +348,15 @@ async def sync_db_preview_controller( table_name: Annotated[str, Path(description="表名")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_generator:db:sync"]))], ) -> JSONResponse: + """ + 同步数据库前差异预览(主表 + 可选子表),不落库。 + + 参数: + - table_name (str): 物理表名。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为预览结构。 + """ result = await GenTableService.sync_db_preview_service(auth, table_name) return SuccessResponse(msg="获取同步差异预览成功", data=result) diff --git a/backend/app/plugin/module_generator/gencode/crud.py b/backend/app/plugin/module_generator/gencode/crud.py index 6baa060b..80a8d80a 100644 --- a/backend/app/plugin/module_generator/gencode/crud.py +++ b/backend/app/plugin/module_generator/gencode/crud.py @@ -131,6 +131,9 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): 参数: - ids (list[int]): 业务表ID列表。 + + 返回: + - None """ await self.delete(ids=ids) @@ -198,6 +201,14 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): - 旧实现使用 SQLAlchemy Inspector 全量遍历再内存分页,表多时非常慢。 - 这里按方言走系统表(MySQL information_schema / Postgres pg_catalog)进行分页与过滤。 - 若方言不支持,则回退到旧的全量遍历。 + + 参数: + - search (GenTableQueryParam | None): 表名/注释过滤条件。 + - offset (int): 偏移量。 + - limit (int): 每页条数。 + + 返回: + - tuple[list[dict], int]: 当前页表信息列表与总条数。 """ database_name = settings.DATABASE_NAME db_type = (settings.DATABASE_TYPE or "").lower() @@ -342,6 +353,12 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): async def get_db_table_comment(self, table_name: str) -> str: """ 获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。 + + 参数: + - table_name (str): 物理表名。 + + 返回: + - str: 表注释;表不存在或失败时为空字符串。 """ from app.core.database import engine diff --git a/backend/app/plugin/module_generator/gencode/model.py b/backend/app/plugin/module_generator/gencode/model.py index 50c94667..c148d290 100644 --- a/backend/app/plugin/module_generator/gencode/model.py +++ b/backend/app/plugin/module_generator/gencode/model.py @@ -59,14 +59,38 @@ class GenTableModel(ModelMixin, UserMixin): @validates("table_name") 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(): raise ValueError("表名称不能为空") return table_name.strip() @validates("class_name") 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(): raise ValueError("实体类名称不能为空") return class_name.strip() @@ -190,14 +214,38 @@ class GenTableColumnModel(ModelMixin, UserMixin): @validates("column_name") 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(): raise ValueError("列名称不能为空") return column_name.strip() @validates("column_type") 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(): raise ValueError("列类型不能为空") return column_type.strip() diff --git a/backend/app/plugin/module_generator/gencode/schema.py b/backend/app/plugin/module_generator/gencode/schema.py index 3c0c0dfa..4fb4ea79 100644 --- a/backend/app/plugin/module_generator/gencode/schema.py +++ b/backend/app/plugin/module_generator/gencode/schema.py @@ -142,7 +142,18 @@ class GenTableSchema(BaseModel): @field_validator("table_name") @classmethod def table_name_update(cls, v: str) -> str: - """更新表名称""" + """ + 校验并规范化表名称。 + + 参数: + - v (str): 原始表名。 + + 返回: + - str: 去空白后的表名。 + + 异常: + - ValueError: 表名为空时抛出。 + """ if not v: raise ValueError("表名称不能为空") return v.strip() @@ -156,7 +167,15 @@ class GenTableSchema(BaseModel): ) @classmethod def strip_optional_text_fields(cls, v: str | None) -> str | None: - """文本类字段统一去首尾空格;空串视为 None。""" + """ + 文本类字段统一去首尾空格;空串视为 None。 + + 参数: + - v (str | None): 原始值。 + + 返回: + - str | None: 非空字符串或 None。 + """ if v is None: return None s = str(v).strip() @@ -165,7 +184,15 @@ class GenTableSchema(BaseModel): @field_validator("package_name", mode="before") @classmethod def normalize_package_name(cls, v: str | None) -> str | None: - """包名规范:必须是 module_xxx 形态(工程约定)。""" + """ + 包名规范:必须是 module_xxx 形态(工程约定)。 + + 参数: + - v (str | None): 原始包名。 + + 返回: + - str | None: 规范化后的包名或 None。 + """ if v is None: return None s = cls._normalize_slug_segment(str(v), allow_slash=False) @@ -176,7 +203,15 @@ class GenTableSchema(BaseModel): @field_validator("module_name", mode="before") @classmethod def normalize_module_name(cls, v: str | None) -> str | None: - """模块名规范:示例模式下要求不带 module_ 前缀;统一按 slug 规范。""" + """ + 模块名规范:不带 module_ 前缀;统一按 slug 规范。 + + 参数: + - v (str | None): 原始模块名。 + + 返回: + - str | None: 规范化后的模块名或 None。 + """ if v is None: return None s = cls._normalize_slug_segment(str(v), allow_slash=False) @@ -187,7 +222,15 @@ class GenTableSchema(BaseModel): @field_validator("business_name", mode="before") @classmethod 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: return None 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") @classmethod def strip_optional_sub_fields(cls, v: str | None) -> str | None: - """主子表字段去首尾空格,空串视为未填。""" + """ + 主子表字段去首尾空格,空串视为未填。 + + 参数: + - v (str | None): 原始值。 + + 返回: + - str | None: 非空字符串或 None。 + """ if v is None: return None s = str(v).strip() diff --git a/backend/app/plugin/module_generator/gencode/service.py b/backend/app/plugin/module_generator/gencode/service.py index a420cd1f..3b294e59 100644 --- a/backend/app/plugin/module_generator/gencode/service.py +++ b/backend/app/plugin/module_generator/gencode/service.py @@ -41,6 +41,16 @@ from .tools.jinja2_template_util import Jinja2TemplateUtil def handle_service_exception(func: Callable) -> Callable: + """ + 服务层异步方法装饰器:透传 CustomException,其余异常包装为 CustomException。 + + 参数: + - func (Callable): 被装饰的异步可调用对象。 + + 返回: + - Callable: 包装后的可调用对象(异步)。 + """ + async def wrapper(*args, **kwargs): try: return await func(*args, **kwargs) @@ -93,7 +103,6 @@ class GenTableService: raise CustomException(msg="包名不能为空") return pn if pn.startswith("module_") else f"module_{pn}" - @classmethod @classmethod async def _assert_parent_menu_is_catalog(cls, auth: AuthSchema, parent_menu_id: int | None) -> None: """上级菜单仅允许目录:与前端树只展示目录一致,避免挂到菜单/按钮下。""" @@ -208,7 +217,18 @@ class GenTableService: @classmethod def normalize_and_validate_master_sub(cls, data: GenTableSchema) -> None: - """主子表业务规则:两字段同填或同空;子表表名不得与主表相同。""" + """ + 主子表业务规则:子表表名与外键列同填或同空;子表表名不得与主表相同。 + + 参数: + - data (GenTableSchema): 主表配置。 + + 返回: + - None + + 异常: + - CustomException: 规则不满足时抛出。 + """ sn = data.sub_table_name fk = data.sub_table_fk_name if bool(sn) ^ bool(fk): @@ -260,7 +280,19 @@ class GenTableService: search: GenTableQueryParam, order_by: list[dict[str, str]] | None = None, ) -> 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 order = order_by or [{"created_time": "desc"}] return await GenTableCRUD(auth=auth).page( @@ -297,7 +329,18 @@ class GenTableService: page_size: int, search: GenTableQueryParam, ) -> dict[str, Any]: - """数据库表列表分页(数据库侧 OFFSET/LIMIT)。""" + """ + 数据库表列表分页(数据库侧 OFFSET/LIMIT)。 + + 参数: + - auth (AuthSchema): 认证信息。 + - page_no (int): 页码。 + - page_size (int): 每页条数。 + - search (GenTableQueryParam): 查询条件。 + + 返回: + - dict[str, Any]: 含 items、total、has_next 等字段。 + """ offset = (page_no - 1) * page_size items, total = await GenTableCRUD(auth=auth).get_db_table_page( search=search, offset=offset, limit=page_size @@ -1041,9 +1084,17 @@ class GenTableService: @classmethod 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_table、master_sub_hint 等)。 + + 返回: + - None """ gen_table.master_sub_hint = None sub_name_raw = (gen_table.sub_table_name or "").strip() @@ -1210,7 +1261,19 @@ class GenTableService: async def sync_db_preview_service( cls, auth: AuthSchema, table_name: str ) -> dict[str, Any]: - """同步数据库前差异预览(主表 + 可选子表)。""" + """ + 同步数据库前差异预览(主表 + 可选子表)。 + + 参数: + - auth (AuthSchema): 认证信息。 + - table_name (str): 主表物理表名。 + + 返回: + - dict[str, Any]: 预览差异结构(可序列化)。 + + 异常: + - CustomException: 表名无效或业务表不存在等。 + """ if not table_name or not table_name.strip(): raise CustomException(msg="表名不能为空") gen_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name, preload=["columns"]) diff --git a/backend/app/plugin/module_generator/gencode/tools/jinja2_template_util.py b/backend/app/plugin/module_generator/gencode/tools/jinja2_template_util.py index 898f537f..c2be25aa 100644 --- a/backend/app/plugin/module_generator/gencode/tools/jinja2_template_util.py +++ b/backend/app/plugin/module_generator/gencode/tools/jinja2_template_util.py @@ -25,6 +25,12 @@ class Jinja2TemplateUtil: def normalize_db_column_type_for_mapping(cls, column_type: str | None) -> str: """ 与 ``GenUtils.get_db_type`` 一致地去掉 COLLATE / UNSIGNED,便于与 ``DB_TO_SQLALCHEMY`` 键匹配。 + + 参数: + - column_type (str | None): 原始列类型字符串。 + + 返回: + - str: 规范化后的类型片段;空输入返回空字符串。 """ ct = (column_type or "").strip() if not ct: @@ -91,12 +97,17 @@ class Jinja2TemplateUtil: """ return cls.get_env().get_template(template_path) - @classmethod @classmethod def business_name_to_slug(cls, business_name: str | None) -> str: """ 业务路径可含斜杠(如 ``demo/demo01``)用于目录与路由前缀; Python 函数/方法名仅使用最后一段并规范为合法 snake_case 片段。 + + 参数: + - business_name (str | None): 业务路径或名称。 + + 返回: + - str: 用于 Python 标识的 slug,默认 entity。 """ s = (business_name or "").strip().strip("/") if not s: @@ -117,6 +128,12 @@ class Jinja2TemplateUtil: 约定:`business_name` 允许 `a/b/c` 表示多级菜单目录。 - 目录/路由:使用完整多段 - 文件名/route_name:使用最后一段 slug(见 `business_name_to_slug`) + + 参数: + - business_name (str | None): 业务路径或名称。 + + 返回: + - str: 多段路径字符串(小写 slug),默认 entity。 """ s = (business_name or "").strip().strip("/") if not s: @@ -230,6 +247,12 @@ class Jinja2TemplateUtil: 前端页面路由首段(与写入菜单 ``route_path`` 第一段一致):始终为 ``module_xxx``。 懒加载 ``GenTableService`` 避免与 ``service`` 模块循环依赖。 + + 参数: + - gen_table (GenTableOutSchema): 生成表配置。 + + 返回: + - str: 路由首段(module_xxx)。 """ from app.plugin.module_generator.gencode.service import GenTableService @@ -246,6 +269,13 @@ class Jinja2TemplateUtil: ) -> dict[str, Any]: """ 子表业务代码渲染上下文(与主表同模块、独立业务目录)。 + + 参数: + - parent (GenTableOutSchema): 主表配置。 + - sub (GenTableOutSchema): 子表配置。 + + 返回: + - dict[str, Any]: 子表模板上下文字典。 """ ctx = cls.prepare_context(sub) scn = (sub.class_name or GenUtils.convert_class_name(sub.table_name or "")).strip() @@ -351,10 +381,13 @@ class Jinja2TemplateUtil: @classmethod 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 [] import_list = set() @@ -418,11 +451,14 @@ class Jinja2TemplateUtil: cls, gen_table: GenTableOutSchema, *, is_sub_entity: bool = False ) -> list[str]: """ - 获取do模板导入包列表 + 获取 model 模板所需的 Python 导入语句列表(含合并后的 sqlalchemy 导入)。 - :param gen_table: 生成表的配置信息 - :param is_sub_entity: 是否为子表独立生成(含外键与 relationship) - :return: 导入包列表 + 参数: + - gen_table (GenTableOutSchema): 生成表配置。 + - is_sub_entity (bool): 是否为子表独立生成(含外键与 relationship)。 + + 返回: + - list[str]: 导入语句列表。 """ columns = gen_table.columns or [] import_list = set() @@ -620,6 +656,12 @@ class Jinja2TemplateUtil: 将列上的 Python 类型(`get_db_type` + `DB_TO_PYTHON` 映射结果)转为前端 TS 类型片段。 与 JSON 序列化习惯一致:Decimal、日期时间多为字符串;dict/list 用宽松类型。 + + 参数: + - python_type (str | None): Python 类型名。 + + 返回: + - str: 前端 TypeScript 类型片段。 """ if not python_type or not str(python_type).strip(): return "string" diff --git a/backend/app/plugin/module_task/cronjob/job/controller.py b/backend/app/plugin/module_task/cronjob/job/controller.py index 046469d4..73077c8c 100644 --- a/backend/app/plugin/module_task/cronjob/job/controller.py +++ b/backend/app/plugin/module_task/cronjob/job/controller.py @@ -65,7 +65,10 @@ async def get_scheduler_jobs_controller() -> JSONResponse: ) async def start_scheduler_controller() -> JSONResponse: """ - 启动调度器 + 启动调度器。 + + 返回: + - JSONResponse: 成功提示响应。 """ SchedulerUtil.start() log.info("调度器已启动") @@ -81,7 +84,10 @@ async def start_scheduler_controller() -> JSONResponse: ) async def pause_scheduler_controller() -> JSONResponse: """ - 暂停调度器 + 暂停调度器。 + + 返回: + - JSONResponse: 成功提示响应。 """ SchedulerUtil.pause() log.info("调度器已暂停") @@ -97,7 +103,10 @@ async def pause_scheduler_controller() -> JSONResponse: ) async def resume_scheduler_controller() -> JSONResponse: """ - 恢复调度器 + 恢复调度器。 + + 返回: + - JSONResponse: 成功提示响应。 """ SchedulerUtil.resume() log.info("调度器已恢复") @@ -113,7 +122,10 @@ async def resume_scheduler_controller() -> JSONResponse: ) async def shutdown_scheduler_controller() -> JSONResponse: """ - 关闭调度器 + 关闭调度器。 + + 返回: + - JSONResponse: 成功提示响应。 """ await SchedulerUtil.shutdown() log.info("调度器已关闭") @@ -129,7 +141,10 @@ async def shutdown_scheduler_controller() -> JSONResponse: ) async def clear_jobs_controller() -> JSONResponse: """ - 清空调度器中的所有任务 + 清空调度器中的所有任务。 + + 返回: + - JSONResponse: 成功提示响应。 """ SchedulerUtil.clear_jobs() log.info("已清空所有任务") @@ -191,6 +206,9 @@ async def pause_job_controller( 参数: - job_id (str): 调度器任务ID + + 返回: + - JSONResponse: 成功提示响应。 """ SchedulerUtil.pause_job(job_id=job_id) log.info(f"暂停任务成功: {job_id}") @@ -212,6 +230,9 @@ async def resume_job_controller( 参数: - job_id (str): 调度器任务ID + + 返回: + - JSONResponse: 成功提示响应。 """ SchedulerUtil.resume_job(job_id=job_id) log.info(f"恢复任务成功: {job_id}") @@ -233,6 +254,9 @@ async def run_job_controller( 参数: - job_id (str): 调度器任务ID + + 返回: + - JSONResponse: 成功提示响应。 """ SchedulerUtil.run_job_now(job_id=job_id) log.info(f"立即执行任务成功: {job_id}") @@ -254,6 +278,9 @@ async def remove_job_controller( 参数: - job_id (str): 调度器任务ID + + 返回: + - JSONResponse: 成功提示响应。 """ SchedulerUtil.remove_job(job_id=job_id) log.info(f"移除任务成功: {job_id}") @@ -340,6 +367,9 @@ async def delete_job_log_controller( 参数: - ids (list[int]): ID列表 - auth (AuthSchema): 认证信息模型 + + 返回: + - JSONResponse: 成功提示响应。 """ await JobService.delete_job_log_service(auth=auth, ids=ids) log.info(f"删除执行日志成功: {ids}") diff --git a/backend/app/plugin/module_task/cronjob/job/crud.py b/backend/app/plugin/module_task/cronjob/job/crud.py index ef954c74..eb958296 100644 --- a/backend/app/plugin/module_task/cronjob/job/crud.py +++ b/backend/app/plugin/module_task/cronjob/job/crud.py @@ -86,11 +86,17 @@ class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]): 参数: - ids (list[int]): 日志ID列表 + + 返回: + - None """ return await self.delete(ids=ids) async def clear_obj_crud(self) -> None: """ - 清空所有执行日志 + 清空所有执行日志。 + + 返回: + - None """ return await self.clear() diff --git a/backend/app/plugin/module_task/cronjob/job/service.py b/backend/app/plugin/module_task/cronjob/job/service.py index cd157c17..3bf665b5 100644 --- a/backend/app/plugin/module_task/cronjob/job/service.py +++ b/backend/app/plugin/module_task/cronjob/job/service.py @@ -67,7 +67,19 @@ class JobService: search: JobQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 ob = order_by or [{"created_time": "desc"}] return await JobCRUD(auth).page( @@ -149,6 +161,9 @@ class JobService: 参数: - auth (AuthSchema): 认证信息模型 - ids (list[int]): 日志ID列表 + + 返回: + - None """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") @@ -161,6 +176,9 @@ class JobService: 参数: - auth (AuthSchema): 认证信息模型 + + 返回: + - None """ await JobCRUD(auth).clear_obj_crud() diff --git a/backend/app/plugin/module_task/cronjob/node/controller.py b/backend/app/plugin/module_task/cronjob/node/controller.py index 5843cbe1..7ee33463 100644 --- a/backend/app/plugin/module_task/cronjob/node/controller.py +++ b/backend/app/plugin/module_task/cronjob/node/controller.py @@ -33,6 +33,12 @@ async def get_node_options_controller( ) -> JSONResponse: """ 获取数据库中的定时任务节点定义(task_node),与编排节点类型无关。 + + 参数: + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为选项列表。 """ result = await NodeService.get_node_options_service(auth=auth) log.info("获取定时任务节点选项成功") diff --git a/backend/app/plugin/module_task/cronjob/node/crud.py b/backend/app/plugin/module_task/cronjob/node/crud.py index ac69c4c3..18c9cb65 100644 --- a/backend/app/plugin/module_task/cronjob/node/crud.py +++ b/backend/app/plugin/module_task/cronjob/node/crud.py @@ -89,6 +89,9 @@ class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]): 参数: - ids (list[int]): 节点ID列表 + + 返回: + - None """ return await self.delete(ids=ids) @@ -99,6 +102,9 @@ class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]): 参数: - ids (list[int]): 节点ID列表 - kwargs: 其他要设置的字段,例如 available=True 或 available=False + + 返回: + - None """ return await self.set(ids=ids, **kwargs) @@ -108,5 +114,8 @@ class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]): 注意: - 此操作会删除所有节点日志,请谨慎操作 + + 返回: + - None """ return await self.clear() diff --git a/backend/app/plugin/module_task/cronjob/node/handlers/demo_handler.py b/backend/app/plugin/module_task/cronjob/node/handlers/demo_handler.py index 6fd22508..b6619872 100644 --- a/backend/app/plugin/module_task/cronjob/node/handlers/demo_handler.py +++ b/backend/app/plugin/module_task/cronjob/node/handlers/demo_handler.py @@ -8,7 +8,12 @@ from datetime import datetime def demo_handler(*args, **kwargs) -> dict: - """示例处理器""" + """ + 示例处理器(演示节点调用形态)。 + + 返回: + - dict: 包含 message、入参快照与时间戳。 + """ return { "message": "Hello from demo_handler!", "args": args, @@ -19,9 +24,14 @@ def demo_handler(*args, **kwargs) -> dict: def process_data(data: list, operation: str = "sum") -> dict: """ - 简单数据处理 + 简单数值列表聚合。 - operation: sum, avg, max, min, count + 参数: + - data (list): 数值列表。 + - operation (str): sum、avg、max、min、count 之一。 + + 返回: + - dict: 含 operation 与 result,或 error 说明。 """ if not data: return {"error": "数据为空"} diff --git a/backend/app/plugin/module_task/cronjob/node/service.py b/backend/app/plugin/module_task/cronjob/node/service.py index b0845c11..da2c8f8c 100644 --- a/backend/app/plugin/module_task/cronjob/node/service.py +++ b/backend/app/plugin/module_task/cronjob/node/service.py @@ -96,7 +96,19 @@ class NodeService: search: NodeQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 return await NodeCRUD(auth).page( offset=offset, @@ -157,6 +169,9 @@ class NodeService: 参数: - auth (AuthSchema): 认证信息模型 - ids (list[int]): 节点ID列表 + + 返回: + - None """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") @@ -178,6 +193,9 @@ class NodeService: 参数: - auth (AuthSchema): 认证信息模型 + + 返回: + - None """ SchedulerUtil.clear_jobs() await NodeCRUD(auth).clear_obj_crud() diff --git a/backend/app/plugin/module_task/workflow/definition/controller.py b/backend/app/plugin/module_task/workflow/definition/controller.py index 3bfc1a34..dfbd73f2 100644 --- a/backend/app/plugin/module_task/workflow/definition/controller.py +++ b/backend/app/plugin/module_task/workflow/definition/controller.py @@ -33,6 +33,16 @@ async def get_workflow_detail_controller( id: Annotated[int, Path(description="工作流ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:detail"]))], ) -> JSONResponse: + """ + 根据 ID 获取工作流详情(含画布 nodes/edges)。 + + 参数: + - id (int): 工作流 ID。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为详情字典。 + """ result_dict = await WorkflowService.get_workflow_detail_service(auth=auth, id=id) log.info(f"获取工作流详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取工作流详情成功") @@ -49,6 +59,17 @@ async def get_workflow_list_controller( search: Annotated[WorkflowQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:query"]))], ) -> JSONResponse: + """ + 分页查询工作流列表。 + + 参数: + - page (PaginationQueryParam): 分页与排序参数。 + - search (WorkflowQueryParam): 查询条件。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为分页结果。 + """ result_dict = await WorkflowService.get_workflow_page_service( auth=auth, page_no=page.page_no, @@ -70,6 +91,16 @@ async def create_workflow_controller( data: WorkflowCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:create"]))], ) -> JSONResponse: + """ + 创建草稿工作流。 + + 参数: + - data (WorkflowCreateSchema): 创建体。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为新建工作流。 + """ result_dict = await WorkflowService.create_workflow_service(auth=auth, data=data) log.info("创建工作流成功") return SuccessResponse(data=result_dict, msg="创建工作流成功") @@ -86,6 +117,17 @@ async def update_workflow_controller( data: WorkflowUpdateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:update"]))], ) -> JSONResponse: + """ + 更新工作流及画布。 + + 参数: + - id (int): 工作流 ID。 + - data (WorkflowUpdateSchema): 更新体。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为更新后的工作流。 + """ result_dict = await WorkflowService.update_workflow_service(auth=auth, id=id, data=data) log.info(f"更新工作流成功 {id}") return SuccessResponse(data=result_dict, msg="更新工作流成功") @@ -101,6 +143,16 @@ async def delete_workflow_controller( ids: Annotated[list[int], Body(description="ID列表")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:delete"]))], ) -> JSONResponse: + """ + 批量删除工作流。 + + 参数: + - ids (list[int]): 工作流 ID 列表。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功提示响应。 + """ await WorkflowService.delete_workflow_service(auth=auth, ids=ids) log.info(f"删除工作流成功 {ids}") return SuccessResponse(msg="删除工作流成功") @@ -117,6 +169,17 @@ async def publish_workflow_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:update"]))], body: Annotated[WorkflowPublishSchema | None, Body()] = None, ) -> 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) log.info(f"发布工作流成功 {id}") return SuccessResponse(data=result_dict, msg="发布工作流成功") @@ -132,6 +195,16 @@ async def execute_workflow_controller( body: WorkflowExecuteSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:definition:execute"]))], ) -> JSONResponse: + """ + 使用 Prefect 按拓扑顺序执行已发布工作流。 + + 参数: + - body (WorkflowExecuteSchema): 工作流 ID 与变量等。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为执行结果摘要。 + """ result_dict = await WorkflowService.execute_workflow_service(auth=auth, body=body) log.info(f"执行工作流完成 workflow_id={body.workflow_id}") return SuccessResponse(data=result_dict, msg="执行工作流完成") diff --git a/backend/app/plugin/module_task/workflow/definition/crud.py b/backend/app/plugin/module_task/workflow/definition/crud.py index 81c3d6eb..8c462952 100644 --- a/backend/app/plugin/module_task/workflow/definition/crud.py +++ b/backend/app/plugin/module_task/workflow/definition/crud.py @@ -12,12 +12,31 @@ class WorkflowCRUD(CRUDBase[WorkflowModel, WorkflowCreateSchema, WorkflowUpdateS """工作流数据层""" def __init__(self, auth: AuthSchema) -> None: + """ + 初始化工作流 CRUD。 + + 参数: + - auth (AuthSchema): 认证信息。 + + 返回: + - None + """ self.auth = auth super().__init__(model=WorkflowModel, auth=auth) async def get_obj_by_id_crud( self, id: int, preload: list[str | Any] | None = None ) -> WorkflowModel | None: + """ + 按主键查询工作流。 + + 参数: + - id (int): 工作流 ID。 + - preload (list[str | Any] | None): 预加载关系。 + + 返回: + - WorkflowModel | None: 实体或 None。 + """ return await self.get(id=id, preload=preload) async def get_obj_list_crud( @@ -26,13 +45,52 @@ class WorkflowCRUD(CRUDBase[WorkflowModel, WorkflowCreateSchema, WorkflowUpdateS order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None, ) -> 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) async def create_obj_crud(self, data: WorkflowCreateSchema) -> WorkflowModel | None: + """ + 创建工作流。 + + 参数: + - data (WorkflowCreateSchema): 创建模型。 + + 返回: + - WorkflowModel | None: 新建实体或 None。 + """ return await self.create(data=data) 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) async def delete_obj_crud(self, ids: list[int]) -> None: + """ + 批量删除工作流。 + + 参数: + - ids (list[int]): ID 列表。 + + 返回: + - None + """ await self.delete(ids=ids) diff --git a/backend/app/plugin/module_task/workflow/definition/service.py b/backend/app/plugin/module_task/workflow/definition/service.py index 521667bc..1ef91d6c 100644 --- a/backend/app/plugin/module_task/workflow/definition/service.py +++ b/backend/app/plugin/module_task/workflow/definition/service.py @@ -27,6 +27,19 @@ class WorkflowService: @classmethod 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) if not obj: raise CustomException(msg="工作流不存在") @@ -39,6 +52,17 @@ class WorkflowService: search: WorkflowQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[dict]: + """ + 获取工作流列表(非分页)。 + + 参数: + - auth (AuthSchema): 认证信息。 + - search (WorkflowQueryParam | None): 查询条件。 + - order_by (list[dict[str, str]] | None): 排序。 + + 返回: + - list[dict]: 工作流字典列表。 + """ if order_by is None: order_by = [{"updated_time": "desc"}] obj_list = await WorkflowCRUD(auth).get_obj_list_crud( @@ -56,7 +80,19 @@ class WorkflowService: search: WorkflowQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 order = order_by or [{"updated_time": "desc"}] result = await WorkflowCRUD(auth).page( @@ -73,6 +109,19 @@ class WorkflowService: @classmethod 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) if exist: raise CustomException(msg="流程编码已存在") @@ -85,6 +134,20 @@ class WorkflowService: async def update_workflow_service( cls, auth: AuthSchema, id: int, data: WorkflowUpdateSchema ) -> dict: + """ + 更新工作流。 + + 参数: + - auth (AuthSchema): 认证信息。 + - id (int): 工作流 ID。 + - data (WorkflowUpdateSchema): 更新体。 + + 返回: + - dict: 更新后工作流字典。 + + 异常: + - CustomException: 不存在、编码冲突或更新失败。 + """ exist = await WorkflowCRUD(auth).get_obj_by_id_crud(id=id) if not exist: raise CustomException(msg="工作流不存在") @@ -99,6 +162,19 @@ class WorkflowService: @classmethod async def delete_workflow_service(cls, auth: AuthSchema, ids: list[int]) -> None: + """ + 批量删除工作流。 + + 参数: + - auth (AuthSchema): 认证信息。 + - ids (list[int]): ID 列表。 + + 返回: + - None + + 异常: + - CustomException: ID 为空时抛出。 + """ if not ids: raise CustomException(msg="删除ID不能为空") await WorkflowCRUD(auth).delete_obj_crud(ids=ids) @@ -107,6 +183,20 @@ class WorkflowService: async def publish_workflow_service( cls, auth: AuthSchema, id: int, body: WorkflowPublishSchema | None = None ) -> dict: + """ + 校验 DAG 后发布工作流。 + + 参数: + - auth (AuthSchema): 认证信息。 + - id (int): 工作流 ID。 + - body (WorkflowPublishSchema | None): 可选附加参数。 + + 返回: + - dict: 发布后工作流字典。 + + 异常: + - CustomException: 不存在、图无效或发布失败。 + """ obj = await WorkflowCRUD(auth).get_obj_by_id_crud(id=id) if not obj: raise CustomException(msg="工作流不存在") @@ -135,6 +225,19 @@ class WorkflowService: async def execute_workflow_service( cls, auth: AuthSchema, body: WorkflowExecuteSchema ) -> dict: + """ + 执行已发布工作流(Prefect 同步入口在线程池中运行)。 + + 参数: + - auth (AuthSchema): 认证信息。 + - body (WorkflowExecuteSchema): 工作流 ID 与变量。 + + 返回: + - dict: 执行结果摘要(成功或失败结构)。 + + 异常: + - CustomException: 未发布、缺节点、节点类型未注册等。 + """ obj = await WorkflowCRUD(auth).get_obj_by_id_crud(id=body.workflow_id) if not obj: raise CustomException(msg="工作流不存在") diff --git a/backend/app/plugin/module_task/workflow/engine/prefect_engine.py b/backend/app/plugin/module_task/workflow/engine/prefect_engine.py index 5dceab77..8f9913d0 100644 --- a/backend/app/plugin/module_task/workflow/engine/prefect_engine.py +++ b/backend/app/plugin/module_task/workflow/engine/prefect_engine.py @@ -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: - """校验图有效且无环。""" + """ + 校验画布图有效且无环。 + + 参数: + - nodes (list[dict]): 节点列表(须含 id)。 + - edges (list[dict]): 边列表(source/target)。 + + 返回: + - None + + 异常: + - ValueError: 图为空、边引用非法或存在环。 + """ if not nodes: raise ValueError("工作流至少需要一个节点") 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]: + """ + 按拓扑顺序返回节点(调用方须先保证无环)。 + + 参数: + - nodes (list[dict]): 节点列表。 + - edges (list[dict]): 边列表。 + + 返回: + - list[dict]: 拓扑有序节点。 + """ id_to_node = {n["id"]: n for n in nodes} in_degree: dict[str, int] = {n["id"]: 0 for n in nodes} adj: dict[str, list[str]] = defaultdict(list) @@ -88,6 +110,21 @@ def prefect_node_task( upstream: dict[str, Any], flow_variables: dict[str, 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}" args = _parse_args(args_str) kw = _parse_kwargs(kwargs_str) @@ -104,7 +141,16 @@ def run_workflow_prefect_flow( flow_variables: 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_results、status 等。 """ results: dict[str, Any] = {} for node in ordered_nodes: @@ -148,6 +194,15 @@ def run_prefect_workflow_sync( ) -> dict[str, Any]: """ 同步入口:校验 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) ordered = topological_sort(nodes, edges) @@ -160,4 +215,10 @@ def run_prefect_workflow_sync( def utc_now_iso() -> str: + """ + 当前 UTC 时间的 ISO 8601 字符串。 + + 返回: + - str: ISO 格式时间戳。 + """ return datetime.now(timezone.utc).isoformat() diff --git a/backend/app/plugin/module_task/workflow/node_type/controller.py b/backend/app/plugin/module_task/workflow/node_type/controller.py index eac7927f..ff176199 100644 --- a/backend/app/plugin/module_task/workflow/node_type/controller.py +++ b/backend/app/plugin/module_task/workflow/node_type/controller.py @@ -34,6 +34,15 @@ WorkflowNodeTypeRouter = APIRouter( async def get_workflow_node_type_options_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))], ) -> JSONResponse: + """ + 获取画布用编排节点类型选项(仅启用项)。 + + 参数: + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为选项列表。 + """ result = await WorkflowNodeTypeService.get_options_service(auth=auth) log.info("获取编排节点类型选项成功") return SuccessResponse(data=result, msg="获取编排节点类型选项成功") @@ -48,6 +57,16 @@ async def get_workflow_node_type_detail_controller( id: Annotated[int, Path(description="ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))], ) -> JSONResponse: + """ + 获取编排节点类型详情。 + + 参数: + - id (int): 主键。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为详情。 + """ result_dict = await WorkflowNodeTypeService.get_detail_service(auth=auth, id=id) log.info(f"获取编排节点类型详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取编排节点类型详情成功") @@ -63,6 +82,17 @@ async def get_workflow_node_type_list_controller( search: Annotated[WorkflowNodeTypeQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:query"]))], ) -> JSONResponse: + """ + 分页查询编排节点类型列表。 + + 参数: + - page (PaginationQueryParam): 分页与排序。 + - search (WorkflowNodeTypeQueryParam): 查询条件。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为分页结果。 + """ order_by = [{"sort_order": "asc"}, {"id": "asc"}] if page.order_by: order_by = page.order_by @@ -86,6 +116,16 @@ async def create_workflow_node_type_controller( data: WorkflowNodeTypeCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:create"]))], ) -> JSONResponse: + """ + 创建编排节点类型。 + + 参数: + - data (WorkflowNodeTypeCreateSchema): 创建体。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为新记录。 + """ result_dict = await WorkflowNodeTypeService.create_service(auth=auth, data=data) log.info("创建编排节点类型成功") return SuccessResponse(data=result_dict, msg="创建编排节点类型成功") @@ -101,6 +141,17 @@ async def update_workflow_node_type_controller( data: WorkflowNodeTypeUpdateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:update"]))], ) -> JSONResponse: + """ + 更新编排节点类型。 + + 参数: + - id (int): 主键。 + - data (WorkflowNodeTypeUpdateSchema): 更新体。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功响应,data 为更新后记录。 + """ result_dict = await WorkflowNodeTypeService.update_service(auth=auth, id=id, data=data) log.info(f"更新编排节点类型成功 {id}") return SuccessResponse(data=result_dict, msg="更新编排节点类型成功") @@ -115,6 +166,16 @@ async def delete_workflow_node_type_controller( ids: Annotated[list[int], Body(description="ID列表")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:workflow:node-type:delete"]))], ) -> JSONResponse: + """ + 批量删除编排节点类型。 + + 参数: + - ids (list[int]): ID 列表。 + - auth (AuthSchema): 认证信息。 + + 返回: + - JSONResponse: 成功提示响应。 + """ await WorkflowNodeTypeService.delete_service(auth=auth, ids=ids) log.info(f"删除编排节点类型成功 {ids}") return SuccessResponse(msg="删除编排节点类型成功") diff --git a/backend/app/plugin/module_task/workflow/node_type/crud.py b/backend/app/plugin/module_task/workflow/node_type/crud.py index 754cfe07..8e90f5dc 100644 --- a/backend/app/plugin/module_task/workflow/node_type/crud.py +++ b/backend/app/plugin/module_task/workflow/node_type/crud.py @@ -13,12 +13,31 @@ class WorkflowNodeTypeCRUD(CRUDBase[WorkflowNodeTypeModel, WorkflowNodeTypeCreat """编排节点类型 CRUD""" def __init__(self, auth: AuthSchema) -> None: + """ + 初始化编排节点类型 CRUD。 + + 参数: + - auth (AuthSchema): 认证信息。 + + 返回: + - None + """ self.auth = auth super().__init__(model=WorkflowNodeTypeModel, auth=auth) async def get_obj_by_id_crud( self, id: int, preload: list[str | Any] | None = None ) -> WorkflowNodeTypeModel | None: + """ + 按主键查询节点类型。 + + 参数: + - id (int): 主键。 + - preload (list[str | Any] | None): 预加载关系。 + + 返回: + - WorkflowNodeTypeModel | None: 实体或 None。 + """ return await self.get(id=id, preload=preload) async def get_obj_list_crud( @@ -27,19 +46,63 @@ class WorkflowNodeTypeCRUD(CRUDBase[WorkflowNodeTypeModel, WorkflowNodeTypeCreat order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None, ) -> 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) async def create_obj_crud(self, data: WorkflowNodeTypeCreateSchema) -> WorkflowNodeTypeModel | None: + """ + 创建节点类型。 + + 参数: + - data (WorkflowNodeTypeCreateSchema): 创建模型。 + + 返回: + - WorkflowNodeTypeModel | None: 新建实体或 None。 + """ return await self.create(data=data) 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) async def delete_obj_crud(self, ids: list[int]) -> None: + """ + 批量删除节点类型。 + + 参数: + - ids (list[int]): ID 列表。 + + 返回: + - None + """ await self.delete(ids=ids) async def list_active_options_crud(self) -> Sequence[WorkflowNodeTypeModel]: - """画布:仅启用的类型,按 sort_order、id 排序""" + """ + 画布用:仅启用的类型,按 sort_order、id 排序。 + + 返回: + - Sequence[WorkflowNodeTypeModel]: 启用中的节点类型列表。 + """ return await self.get_obj_list_crud( search={"is_active": (QueueEnum.eq.value, True)}, order_by=[{"sort_order": "asc"}, {"id": "asc"}], diff --git a/backend/app/plugin/module_task/workflow/node_type/service.py b/backend/app/plugin/module_task/workflow/node_type/service.py index d953ee25..4d9066bc 100644 --- a/backend/app/plugin/module_task/workflow/node_type/service.py +++ b/backend/app/plugin/module_task/workflow/node_type/service.py @@ -19,7 +19,15 @@ class WorkflowNodeTypeService: @classmethod 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() return [ { @@ -35,6 +43,19 @@ class WorkflowNodeTypeService: @classmethod 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) if not obj: raise CustomException(msg="编排节点类型不存在") @@ -47,6 +68,17 @@ class WorkflowNodeTypeService: search: WorkflowNodeTypeQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[dict]: + """ + 获取编排节点类型列表(非分页)。 + + 参数: + - auth (AuthSchema): 认证信息。 + - search (WorkflowNodeTypeQueryParam | None): 查询条件。 + - order_by (list[dict[str, str]] | None): 排序。 + + 返回: + - list[dict]: 字典列表。 + """ if order_by is None: order_by = [{"sort_order": "asc"}, {"id": "asc"}] obj_list = await WorkflowNodeTypeCRUD(auth).get_obj_list_crud( @@ -64,7 +96,19 @@ class WorkflowNodeTypeService: search: WorkflowNodeTypeQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> 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 order = order_by or [{"sort_order": "asc"}, {"id": "asc"}] result = await WorkflowNodeTypeCRUD(auth).page( @@ -82,6 +126,19 @@ class WorkflowNodeTypeService: @classmethod async def create_service(cls, auth: AuthSchema, data: WorkflowNodeTypeCreateSchema) -> dict: + """ + 创建编排节点类型。 + + 参数: + - auth (AuthSchema): 认证信息。 + - data (WorkflowNodeTypeCreateSchema): 创建体。 + + 返回: + - dict: 新建记录字典。 + + 异常: + - CustomException: 编码重复或创建失败。 + """ exist = await WorkflowNodeTypeCRUD(auth).get(code=data.code) if exist: raise CustomException(msg="节点编码已存在") @@ -92,6 +149,20 @@ class WorkflowNodeTypeService: @classmethod 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) if not exist: raise CustomException(msg="编排节点类型不存在") @@ -106,6 +177,19 @@ class WorkflowNodeTypeService: @classmethod async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + """ + 批量删除编排节点类型。 + + 参数: + - auth (AuthSchema): 认证信息。 + - ids (list[int]): ID 列表。 + + 返回: + - None + + 异常: + - CustomException: ID 为空时抛出。 + """ if not ids: raise CustomException(msg="删除ID不能为空") await WorkflowNodeTypeCRUD(auth).delete_obj_crud(ids=ids) diff --git a/backend/app/scripts/initialize.py b/backend/app/scripts/initialize.py index 8e7ac94e..5b3cf651 100644 --- a/backend/app/scripts/initialize.py +++ b/backend/app/scripts/initialize.py @@ -131,6 +131,15 @@ class InitializeData: """ def create_object(obj_data: dict) -> Any: + """ + 由单条 dict 递归构建模型实例(含 children)。 + + 参数: + - obj_data (dict): 行数据,可含嵌套 children。 + + 返回: + - Any: SQLAlchemy 模型实例。 + """ # 分离 children 数据 children_data = obj_data.pop("children", []) @@ -173,7 +182,10 @@ class InitializeData: async def init_db(self) -> None: """ - 执行完整初始化流程 + 执行完整初始化流程:建表并导入种子数据。 + + 返回: + - None """ # 先创建表结构 await self.__init_create_table() diff --git a/backend/app/utils/banner.py b/backend/app/utils/banner.py index 330acd80..aa00f5f5 100644 --- a/backend/app/utils/banner.py +++ b/backend/app/utils/banner.py @@ -4,7 +4,13 @@ from app.core.logger import log def worship(env: str) -> None: """ - 获取项目启动Banner(优先读取 banner.txt) + 读取并打印启动 Banner(优先 `banner.txt`,并附带当前环境名)。 + + 参数: + - env (str): 当前运行环境标识。 + + 返回: + - None """ if BANNER_FILE.exists(): banner = BANNER_FILE.read_text(encoding="utf-8") diff --git a/backend/app/utils/captcha_util.py b/backend/app/utils/captcha_util.py index 3fd7c18e..dfe614e5 100644 --- a/backend/app/utils/captcha_util.py +++ b/backend/app/utils/captcha_util.py @@ -19,7 +19,7 @@ class CaptchaUtil: 生成带有噪声和干扰的验证码图片(4位随机字符)。 返回: - - Tuple[str, str]: [base64图片字符串, 验证码值]。 + - tuple[str, str]: Base64 PNG 字符串与验证码明文。 """ # 生成4位随机验证码 chars = string.digits + string.ascii_letters @@ -82,40 +82,35 @@ class CaptchaUtil: @classmethod 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 = { - "easy": {"num_range": (1, 9), "operators": ["+", "-"], "noise_density": 0.01, "line_count": 3}, - "medium": {"num_range": (1, 15), "operators": ["+", "-", "*"], "noise_density": 0.015, "line_count": 4}, - "hard": {"num_range": (1, 20), "operators": ["+", "-", "*"], "noise_density": 0.02, "line_count": 6} + "easy": {"num_range": (1, 9), "operators": ["+", "-"]}, + "medium": {"num_range": (1, 15), "operators": ["+", "-", "*"]}, + "hard": {"num_range": (1, 20), "operators": ["+", "-", "*"]}, } config = difficulty_config.get(difficulty, difficulty_config["medium"]) - # 生成运算数字和运算符 operators = config["operators"] operator = random.choice(operators) num_range = config["num_range"] - # 对于减法,确保num1大于num2 if operator == "-": num1 = random.randint(num_range[0] + 5, num_range[1]) num2 = random.randint(num_range[0], num1 - 1) elif operator == "*": - # 对于乘法,限制数字大小避免结果过大 num1 = 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]) num2 = random.randint(num_range[0], num_range[1]) - # 计算结果 result_map = { "+": lambda x, y: x + y, "-": lambda x, y: x - y, @@ -123,73 +118,19 @@ class CaptchaUtil: } captcha_value = result_map[operator](num1, num2) - # 创建空白图像,使用随机浅色背景 width, height = 160, 60 - background_color = tuple(random.randint(230, 255) for _ in range(3)) - image = Image.new("RGB", (width, height), color=background_color) + image = Image.new("RGB", (width, height), color=(248, 249, 250)) draw = ImageDraw.Draw(image) - # 设置字体 font = ImageFont.truetype(font=settings.CAPTCHA_FONT_PATH, size=settings.CAPTCHA_FONT_SIZE) - # 绘制文本,使用深色增加对比度 text = f"{num1} {operator} {num2} = ?" - text_bbox = draw.textbbox((0, 0), text, font=font) - text_width = text_bbox[2] - text_bbox[0] - x = (width - text_width) // 2 - y = 15 + tb = draw.textbbox((0, 0), text, font=font) + tw, th = tb[2] - tb[0], tb[3] - tb[1] + x = (width - tw) // 2 + 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() image.save(buffer, format="PNG", optimize=True) base64_string = base64.b64encode(buffer.getvalue()).decode() diff --git a/backend/app/utils/common_util.py b/backend/app/utils/common_util.py index 922bc497..d7385926 100644 --- a/backend/app/utils/common_util.py +++ b/backend/app/utils/common_util.py @@ -78,7 +78,12 @@ def get_random_character() -> str: def uuid4_str() -> str: - """数据库引擎 UUID 类型兼容性解决方案""" + """ + 数据库引擎 UUID 类型兼容:返回无连字符的 UUID 字符串。 + + 返回: + - str: UUID 字符串。 + """ 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]: - """生成文件响应""" + """ + 将字节内容封装为单块流式生成器,供文件下载响应使用。 + + 参数: + - bytes_info (bytes): 文件二进制内容。 + + 返回: + - Generator[bytes, Any, None]: 仅 yield 一次的字节生成器。 + """ yield bytes_info @@ -286,11 +299,14 @@ class SqlalchemyUtil: 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' - :return: 字典结果 + 参数: + - obj (DeclarativeBase | dict[str, Any]): 模型实例或字典。 + - transform_case (Literal[...]): no_case / snake_to_camel / camel_to_snake。 + + 返回: + - dict[str, Any]: 扁平字典结果。 """ if isinstance(obj, DeclarativeBase): base_dict = obj.__dict__.copy() @@ -314,11 +330,14 @@ class SqlalchemyUtil: 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' - :return: 序列化结果 + 参数: + - result (Any): ORM 对象、列表、Row 等。 + - transform_case (Literal[...]): 键名转换策略。 + + 返回: + - Any: 序列化后的 Python 内置类型或嵌套结构。 """ if isinstance(result, (DeclarativeBase, dict)): return cls.base_to_dict(result, transform_case) @@ -342,11 +361,14 @@ class SqlalchemyUtil: cls, dialect_name: str, need_explicit_null: bool = True ) -> Null | None: """ - 根据数据库方言动态返回值为null的server_default + 按方言返回列默认值中的 NULL 表达(PostgreSQL 可显式 DEFAULT NULL)。 - :param dialect_name: 数据库方言名称 - :param need_explicit_null: 是否需要显式DEFAULT NULL - :return: 不同数据库方言对应的null_server_default + 参数: + - dialect_name (str): 数据库方言名。 + - need_explicit_null (bool): 是否生成显式 NULL 默认值。 + + 返回: + - Null | None: SQLAlchemy null() 或 None。 """ if need_explicit_null and dialect_name == "postgres": return null() @@ -361,10 +383,13 @@ class CamelCaseUtil: @classmethod 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("_") @@ -376,10 +401,13 @@ class CamelCaseUtil: @classmethod 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") @@ -392,10 +420,13 @@ class SnakeCaseUtil: @classmethod 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) @@ -404,9 +435,12 @@ class SnakeCaseUtil: @classmethod 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") diff --git a/backend/app/utils/console.py b/backend/app/utils/console.py index 49ddcd89..ac91d4a7 100644 --- a/backend/app/utils/console.py +++ b/backend/app/utils/console.py @@ -20,7 +20,21 @@ def console_run( scheduler_ready: bool | None = None, limiter_ready: bool | 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}" base_url = f"{url}{settings.ROOT_PATH}" @@ -85,7 +99,12 @@ def console_run( def console_close() -> None: - """显示关闭信息""" + """ + 在终端输出服务关闭提示面板。 + + 返回: + - None + """ shutdown_content = Text() shutdown_content.append("🛑 ", style="bold red") shutdown_content.append("FastapiAdmin 服务关闭") diff --git a/backend/app/utils/excel_util.py b/backend/app/utils/excel_util.py index 3756982c..e28552f1 100644 --- a/backend/app/utils/excel_util.py +++ b/backend/app/utils/excel_util.py @@ -9,7 +9,9 @@ from openpyxl.worksheet.datavalidation import DataValidation class ExcelUtil: - """Excel文件处理工具类""" + """ + Excel 模板生成与列表导出(openpyxl / pandas)。 + """ @classmethod def __mapping_list(cls, list_data: list[dict[str, Any]], mapping_dict: dict) -> list: diff --git a/backend/app/utils/import_util.py b/backend/app/utils/import_util.py index c52b3c20..8611696d 100644 --- a/backend/app/utils/import_util.py +++ b/backend/app/utils/import_util.py @@ -12,23 +12,31 @@ from app.core.exceptions import CustomException class ImportUtil: + """ + 扫描工程中的 ORM 模型文件并做有效性校验的辅助类。 + """ + @classmethod def find_project_root(cls) -> Path: """ - 查找项目根目录 + 返回项目根目录(与配置中的 `BASE_DIR` 一致)。 - :return: 项目根目录路径 + 返回: + - Path: 项目根路径。 """ return BASE_DIR @classmethod def is_valid_model(cls, obj: Any, base_class: type) -> bool: """ - 验证是否为有效的SQLAlchemy模型类 + 判断是否为可映射的 SQLAlchemy 模型类(含表名与非空列)。 - :param obj: 待验证的对象 - :param base_class: SQLAlchemy的基类 - :return: 验证结果 + 参数: + - obj (Any): 待验证对象(一般为类)。 + - base_class (type): ORM 声明基类。 + + 返回: + - bool: 是否为有效模型类。 """ # 必须继承自base_class且不是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) 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 = [] # 按类对象去重 @@ -156,12 +171,19 @@ class ImportUtil: seen_tables: set[str], ) -> None: """ - 专门查找APScheduler相关的模型 + 尝试从调度相关模块补充 `apscheduler_jobs` 表对应模型。 - :param base_class: SQLAlchemy的Base类 - :param models: 模型列表 - :param seen_models: 已处理的模型集合 - :param seen_tables: 已处理的表名集合 + 参数: + - base_class (type): ORM 声明基类。 + - models (list[Any]): 已收集模型列表(就地追加)。 + - seen_models (set[Any]): 已见模型对象集合。 + - seen_tables (set[str]): 已见表名集合。 + + 返回: + - None + + 异常: + - CustomException: 扫描过程出现未预期错误。 """ # 尝试从apscheduler相关模块导入 try: diff --git a/backend/app/utils/ip_local_util.py b/backend/app/utils/ip_local_util.py index 9d6a3405..1504143d 100644 --- a/backend/app/utils/ip_local_util.py +++ b/backend/app/utils/ip_local_util.py @@ -44,10 +44,16 @@ class IpLocalUtil: @classmethod async def resolve_location_for_log(cls, ip: str | None) -> str | None: """ - 登录与操作日志写入 ``login_location`` 时统一入口。 + 登录与操作日志写入 ``login_location`` 时的统一解析入口。 - 与 ``settings.LOGIN_RESOLVE_IP_LOCATION`` 一致:为 ``False`` 时不请求外网, - 避免登录 POST 在 ``OperationLogRoute`` 收尾阶段仍触发 IP 归属地查询导致变慢。 + 与 ``settings.LOGIN_RESOLVE_IP_LOCATION`` 联动:关闭时不请求外网,仅返回占位描述, + 避免登录 POST 在 ``OperationLogRoute`` 收尾阶段因外网查询变慢。 + + 参数: + - ip (str | None): 客户端 IP,可为空。 + + 返回: + - str | None: 展示用归属地文案;无需解析时可能为 ``None``。 """ if not ip: return None diff --git a/backend/app/utils/xss_util.py b/backend/app/utils/xss_util.py index 7899c730..df18938b 100644 --- a/backend/app/utils/xss_util.py +++ b/backend/app/utils/xss_util.py @@ -119,13 +119,16 @@ def sanitize_html(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: return content diff --git a/backend/main.py b/backend/main.py index 1c65e072..d94ece14 100755 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,12 @@ alembic_cfg = Config("alembic.ini") def create_app() -> FastAPI: - """创建 FastAPI 应用实例""" + """ + 创建 FastAPI 应用实例并完成日志、中间件、路由与静态资源注册。 + + 返回: + - FastAPI: 已配置生命周期的应用对象。 + """ from app.config.setting import settings from app.plugin.init_app import ( lifespan, @@ -56,7 +61,15 @@ def run( EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)") ] = EnvironmentEnum.DEV, ) -> None: - """启动FastAPI服务""" + """ + 按指定环境加载配置并启动 Uvicorn(开发环境开启 reload)。 + + 参数: + - env (EnvironmentEnum): 运行环境,对应 `--env`。 + + 返回: + - None + """ try: # 设置环境变量 @@ -104,7 +117,15 @@ def revision( EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)") ] = EnvironmentEnum.DEV, ) -> None: - """生成新的 Alembic 迁移脚本""" + """ + 使用 Alembic 自动生成迁移脚本(autogenerate)。 + + 参数: + - env (EnvironmentEnum): 运行环境,用于加载对应数据库模型元数据。 + + 返回: + - None + """ os.environ["ENVIRONMENT"] = env.value command.revision(alembic_cfg, autogenerate=True, message="迁移脚本") typer.echo("迁移脚本已生成") @@ -119,7 +140,15 @@ def upgrade( EnvironmentEnum, typer.Option("--env", help="运行环境 (dev, prod)") ] = EnvironmentEnum.DEV, ) -> None: - """应用最新的 Alembic 迁移""" + """ + 将数据库升级到 Alembic 最新版本(head)。 + + 参数: + - env (EnvironmentEnum): 运行环境。 + + 返回: + - None + """ os.environ["ENVIRONMENT"] = env.value command.upgrade(alembic_cfg, "head") typer.echo("所有迁移已应用。") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 07204e86..8aae551c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -15,5 +15,11 @@ app = create_app() @pytest.fixture(scope="module") def test_client(): + """ + 模块级 HTTP 测试客户端(复用同一应用实例与生命周期)。 + + 返回: + - TestClient: 供用例发起的同步测试客户端(yield 注入)。 + """ with TestClient(app) as client: yield client diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index e114013f..e1bddbed 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -1,8 +1,8 @@ """ -测试文件 +后端接口测试入口。 -注意:使用普通的 def 定义测试函数,不要使用 async def -执行命令: pytest tests/test.py +注意:测试函数使用同步 `def`,由 TestClient 驱动;勿对用例本身使用 `async def`。 +执行示例: `pytest tests/test_main.py` 或 `pytest tests/` """ import pytest @@ -10,10 +10,23 @@ from fastapi.testclient import TestClient 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.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 # 运行所有测试 diff --git a/frontend/src/views/module_system/menu/index.vue b/frontend/src/views/module_system/menu/index.vue index 73391443..1e13745c 100644 --- a/frontend/src/views/module_system/menu/index.vue +++ b/frontend/src/views/module_system/menu/index.vue @@ -732,17 +732,18 @@ const fullMenuTree = ref([]); /** 从表格「在菜单下新增」进入时锁定父级(仅允许按钮) */ const createParentLocked = ref(false); +/** 目录下:目录、菜单、外链;菜单下:仅按钮 */ function typesAllowedUnderParent(parentType: MenuTypeEnum): MenuTypeEnum[] { switch (parentType) { case MenuTypeEnum.CATALOG: - return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK]; + return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.EXTLINK]; case MenuTypeEnum.MENU: return [MenuTypeEnum.BUTTON]; case MenuTypeEnum.BUTTON: case MenuTypeEnum.EXTLINK: return []; 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; 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); 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); }); diff --git a/frontend/修改的文件/PermissonDrawer.vue b/frontend/修改的文件/PermissonDrawer.vue new file mode 100644 index 00000000..27b7239b --- /dev/null +++ b/frontend/修改的文件/PermissonDrawer.vue @@ -0,0 +1,1012 @@ + + + + + + diff --git a/frontend/修改的文件/auth的schema.py b/frontend/修改的文件/auth的schema.py new file mode 100644 index 00000000..fd6b9b79 --- /dev/null +++ b/frontend/修改的文件/auth的schema.py @@ -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="用户信息") diff --git a/frontend/修改的文件/crud.py b/frontend/修改的文件/crud.py new file mode 100644 index 00000000..83dd23f2 --- /dev/null +++ b/frontend/修改的文件/crud.py @@ -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 diff --git a/frontend/修改的文件/dependencies.py b/frontend/修改的文件/dependencies.py new file mode 100644 index 00000000..be2f37ba --- /dev/null +++ b/frontend/修改的文件/dependencies.py @@ -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 diff --git a/frontend/修改的文件/model.py b/frontend/修改的文件/model.py new file mode 100644 index 00000000..2f28b4f6 --- /dev/null +++ b/frontend/修改的文件/model.py @@ -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" + ) diff --git a/frontend/修改的文件/permission.py b/frontend/修改的文件/permission.py new file mode 100644 index 00000000..64610064 --- /dev/null +++ b/frontend/修改的文件/permission.py @@ -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 diff --git a/frontend/修改的文件/role.ts b/frontend/修改的文件/role.ts new file mode 100644 index 00000000..cbda47f8 --- /dev/null +++ b/frontend/修改的文件/role.ts @@ -0,0 +1,127 @@ +import request from "@/utils/request"; + +const API_PATH = "/system/role"; + +const RoleAPI = { + listRole(query?: TablePageQuery) { + return request>>({ + url: `${API_PATH}/list`, + method: "get", + params: query, + }); + }, + + detailRole(query: number) { + return request>({ + url: `${API_PATH}/detail/${query}`, + method: "get", + }); + }, + + createRole(body: RoleForm) { + return request({ + url: `${API_PATH}/create`, + method: "post", + data: body, + }); + }, + + updateRole(id: number, body: RoleForm) { + return request({ + url: `${API_PATH}/update/${id}`, + method: "put", + data: body, + }); + }, + + deleteRole(body: number[]) { + return request({ + url: `${API_PATH}/delete`, + method: "delete", + data: body, + }); + }, + + batchRole(body: BatchType) { + return request({ + url: `${API_PATH}/available/setting`, + method: "patch", + data: body, + }); + }, + + setPermission(body: permissionDataType) { + return request({ + url: `${API_PATH}/permission/setting`, + method: "patch", + data: body, + }); + }, + + exportRole(body: TablePageQuery) { + return request({ + 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[]; +} diff --git a/frontend/修改的文件/schema.py b/frontend/修改的文件/schema.py new file mode 100644 index 00000000..6e9a77e8 --- /dev/null +++ b/frontend/修改的文件/schema.py @@ -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])) diff --git a/frontend/修改的文件/service.py b/frontend/修改的文件/service.py new file mode 100644 index 00000000..d79092c5 --- /dev/null +++ b/frontend/修改的文件/service.py @@ -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) diff --git a/frontend/修改的文件/validator.py b/frontend/修改的文件/validator.py new file mode 100644 index 00000000..9c9c847f --- /dev/null +++ b/frontend/修改的文件/validator.py @@ -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