From b678588d13b4e3bae53f723aad30f71b2e310bab Mon Sep 17 00:00:00 2001 From: zhangtao <9480807882@qq.com> Date: Tue, 11 Nov 2025 23:34:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(schema):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=E6=A8=A1=E5=9E=8B=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=B9=B6=E7=AE=80=E5=8C=96=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一移除各模块schema中重复的model_validator前置处理逻辑,改用更简洁的字段验证方式 优化字段验证逻辑,移除冗余的字符串处理和类型转换代码 --- .../api/v1/module_application/ai/schema.py | 2 +- .../api/v1/module_application/job/schema.py | 26 +------- .../api/v1/module_application/myapp/schema.py | 27 +------- .../app/api/v1/module_common/file/schema.py | 13 ---- .../api/v1/module_generator/demo/schema.py | 66 +++++-------------- .../app/api/v1/module_system/dept/schema.py | 30 +-------- .../app/api/v1/module_system/dict/schema.py | 25 +++---- .../app/api/v1/module_system/log/schema.py | 19 ------ .../app/api/v1/module_system/notice/schema.py | 21 ------ .../app/api/v1/module_system/params/schema.py | 29 +------- .../api/v1/module_system/position/schema.py | 29 +------- .../app/api/v1/module_system/role/schema.py | 44 ------------- .../app/api/v1/module_system/tenant/schema.py | 53 +-------------- .../app/api/v1/module_system/user/schema.py | 49 +------------- backend/requirements.txt | 2 +- 15 files changed, 34 insertions(+), 401 deletions(-) diff --git a/backend/app/api/v1/module_application/ai/schema.py b/backend/app/api/v1/module_application/ai/schema.py index 4de29572..28e8909f 100644 --- a/backend/app/api/v1/module_application/ai/schema.py +++ b/backend/app/api/v1/module_application/ai/schema.py @@ -4,7 +4,7 @@ from pydantic import ConfigDict, Field, HttpUrl, BaseModel from app.core.base_schema import BaseSchema -from app.common.enums import McpLLMProvider, McpType +from app.common.enums import McpType class ChatQuerySchema(BaseModel): diff --git a/backend/app/api/v1/module_application/job/schema.py b/backend/app/api/v1/module_application/job/schema.py index fb7aeb23..f416159f 100644 --- a/backend/app/api/v1/module_application/job/schema.py +++ b/backend/app/api/v1/module_application/job/schema.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing import Optional -import re + from app.core.base_schema import BaseSchema from app.core.validator import DateTimeStr, datetime_validator @@ -26,30 +26,6 @@ class JobCreateSchema(BaseModel): description: Optional[str] = Field(default=None, max_length=255, description='描述') status: Optional[bool] = Field(default=False, description='任务状态:启动,停止') - @model_validator(mode='before') - @classmethod - def _normalize(cls, data): - """前置归一化:字符串去空格、布尔/数字兼容转换。""" - if isinstance(data, dict): - for key in ('name', 'func', 'trigger', 'args', 'kwargs', 'jobstore', 'executor', 'trigger_args', 'start_date', 'end_date', 'description'): - val = data.get(key) - if isinstance(val, str): - data[key] = val.strip() - for bkey in ('coalesce', 'status'): - val = data.get(bkey) - if isinstance(val, str): - lowered = val.strip().lower() - if lowered in {'true', '1', 'y', 'yes'}: - data[bkey] = True - elif lowered in {'false', '0', 'n', 'no'}: - data[bkey] = False - elif isinstance(val, int): - data[bkey] = bool(val) - val = data.get('max_instances') - if isinstance(val, str) and val.strip().isdigit(): - data['max_instances'] = int(val.strip()) - return data - @field_validator('trigger') @classmethod def _validate_trigger(cls, v: str) -> str: diff --git a/backend/app/api/v1/module_application/myapp/schema.py b/backend/app/api/v1/module_application/myapp/schema.py index 8edd9cc0..817d11e2 100644 --- a/backend/app/api/v1/module_application/myapp/schema.py +++ b/backend/app/api/v1/module_application/myapp/schema.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.base_schema import BaseSchema from urllib.parse import urlparse @@ -15,31 +15,6 @@ class ApplicationCreateSchema(BaseModel): status: bool = Field(True, description="是否启用(True:启用 False:禁用)") description: Optional[str] = Field(default=None, max_length=255, description="描述") - @model_validator(mode="before") - @classmethod - def _normalize(cls, data): - """模型级前置处理:去除首尾空格,空字符串转为 None(可选字段),并规范布尔。""" - if isinstance(data, dict): - for key in ("name", "access_url", "icon_url", "description"): - val = data.get(key) - if isinstance(val, str): - val = val.strip() - # 将可选字段的空字符串转换为 None - if key in ("icon_url", "description") and val == "": - val = None - data[key] = val - # 规范布尔字符串/数字为布尔值 - status_val = data.get("status") - if isinstance(status_val, str): - lowered = status_val.strip().lower() - if lowered in {"true", "1", "y", "yes"}: - data["status"] = True - elif lowered in {"false", "0", "n", "no"}: - data["status"] = False - elif isinstance(status_val, int): - data["status"] = bool(status_val) - return data - @field_validator('name') @classmethod def _validate_name_length(cls, v: str) -> str: diff --git a/backend/app/api/v1/module_common/file/schema.py b/backend/app/api/v1/module_common/file/schema.py index 29b352a9..5d1add78 100644 --- a/backend/app/api/v1/module_common/file/schema.py +++ b/backend/app/api/v1/module_common/file/schema.py @@ -48,19 +48,6 @@ class ImportModel(BaseModel): filed_info: Optional[list[ImportFieldModel]] = Field(description='字段关联表') file_name: Optional[str] = Field(description='文件名') - @model_validator(mode='before') - @classmethod - def _normalize(cls, data): - if isinstance(data, dict): - for key in ('table_name', 'sheet_name', 'file_name'): - val = data.get(key) - if isinstance(val, str): - val = val.strip() - if val == '': - val = None - data[key] = val - return data - @model_validator(mode='after') def _validate(self): # excel_column 不重复(忽略 None) diff --git a/backend/app/api/v1/module_generator/demo/schema.py b/backend/app/api/v1/module_generator/demo/schema.py index d8f15aa9..2c0ae60d 100644 --- a/backend/app/api/v1/module_generator/demo/schema.py +++ b/backend/app/api/v1/module_generator/demo/schema.py @@ -8,68 +8,34 @@ class DemoCreateSchema(BaseModel): """新增模型""" - name: str = Field(..., max_length=50, description='名称') + name: str = Field(..., min_length=2, max_length=50, description='名称') status: bool = Field(True, description="是否启用(True:启用 False:禁用)") description: Optional[str] = Field(default=None, max_length=255, description="描述") @field_validator('name') @classmethod - def _validate_name(cls, v: str) -> str: + def validate_name(cls, v: str) -> str: + """验证名称字段的格式和内容""" + # 去除首尾空格 v = v.strip() if not v: raise ValueError('名称不能为空') return v - @model_validator(mode='before') - @classmethod - def _normalize(cls, data): - if isinstance(data, dict): - for key in ('name', 'description'): - val = data.get(key) - if isinstance(val, str): - val = val.strip() - if key == 'description' and val == '': - val = None - data[key] = val - # status兼容 - val = data.get('status') - if isinstance(val, str): - lowered = val.strip().lower() - if lowered in {'true', '1', 'y', 'yes'}: - data['status'] = True - elif lowered in {'false', '0', 'n', 'no'}: - data['status'] = False - elif isinstance(val, int): - data['status'] = bool(val) - return data - - @model_validator(mode='wrap') - @classmethod - def _wrap(cls, data, handler): - # 进一步处理:压缩名称/描述中的多余空白,并支持更多 status 同义词 - if isinstance(data, dict): - name = data.get('name') - if isinstance(name, str): - data['name'] = ' '.join(name.split()) - status_val = data.get('status') - if isinstance(status_val, str): - lowered = status_val.strip().lower() - if lowered in {'enabled', 'enable', 'on'}: - data['status'] = True - elif lowered in {'disabled', 'disable', 'off'}: - data['status'] = False - desc = data.get('description') - if isinstance(desc, str): - data['description'] = ' '.join(desc.split()) - result = handler(data) - return result - @model_validator(mode='after') - def _check_disabled_requires_description(self): - # 业务示例:禁用时必须填写描述 - if self.status is False and (self.description is None or (isinstance(self.description, str) and self.description.strip() == '')): - raise ValueError('禁用时必须填写描述') + def _after_validation(self): + """ + 核心业务规则校验 + """ + # 长度校验:名称最小长度 + if len(self.name) < 2 or len(self.name) > 50: + raise ValueError('名称长度必须在2-50个字符之间') + # 格式校验:名称只能包含字母、数字、下划线和中划线 + if not self.name.isalnum() and not all(c in '-_' for c in self.name): + raise ValueError('名称只能包含字母、数字、下划线和中划线') + return self + class DemoUpdateSchema(DemoCreateSchema): diff --git a/backend/app/api/v1/module_system/dept/schema.py b/backend/app/api/v1/module_system/dept/schema.py index 913ee4ab..84fc3731 100644 --- a/backend/app/api/v1/module_system/dept/schema.py +++ b/backend/app/api/v1/module_system/dept/schema.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from typing import Optional, List -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.base_schema import BaseSchema @@ -36,32 +36,6 @@ def validate_code(cls, value: Optional[str]): raise ValueError("部门编码必须以字母开头,且仅包含字母/数字/下划线") return v - @model_validator(mode='before') - @classmethod - def _normalize(cls, data): - if isinstance(data, dict): - for key in ('code', 'description'): - val = data.get(key) - if isinstance(val, str): - val = val.strip() - if key == 'description' and val == '': - val = None - data[key] = val - pid = data.get('parent_id') - if isinstance(pid, str) and pid.strip().isdigit(): - data['parent_id'] = int(pid.strip()) - # status兼容 - status_val = data.get('status') - if isinstance(status_val, str): - lowered = status_val.strip().lower() - if lowered in {'true', '1', 'y', 'yes'}: - data['status'] = True - elif lowered in {'false', '0', 'n', 'no'}: - data['status'] = False - elif isinstance(status_val, int): - data['status'] = bool(status_val) - return data - class DeptUpdateSchema(DeptCreateSchema): """部门更新模型""" diff --git a/backend/app/api/v1/module_system/dict/schema.py b/backend/app/api/v1/module_system/dict/schema.py index 274c8454..35bb9b1f 100644 --- a/backend/app/api/v1/module_system/dict/schema.py +++ b/backend/app/api/v1/module_system/dict/schema.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing import Optional from app.core.base_schema import BaseSchema @@ -54,25 +54,16 @@ class DictDataCreateSchema(BaseModel): is_default: Optional[bool] = Field(default=None, description='是否默认(Y是 N否)') status: Optional[bool] = Field(default=None, description='状态(1正常 0停用)') description: Optional[str] = Field(default=None, max_length=255, description="描述") - - @field_validator('dict_label') - @classmethod - def validate_dict_label(cls, value: str): - if not value or value.strip() == '': + + @model_validator(mode='after') + def validate_after(self): + if self.dict_label is None or self.dict_label.strip() == '': raise ValueError('字典标签不能为空') - return value - - @field_validator('dict_value') - def validate_dict_value(cls, value: str): - if not value or value.strip() == '': + if self.dict_value is None or self.dict_value.strip() == '': raise ValueError('字典键值不能为空') - return value - - @field_validator('dict_type') - def validate_dict_type(cls, value: str): - if not value or value.strip() == '': + if self.dict_type is None or self.dict_type.strip() == '': raise ValueError('字典类型不能为空') - return value + return self class DictDataUpdateSchema(DictDataCreateSchema): diff --git a/backend/app/api/v1/module_system/log/schema.py b/backend/app/api/v1/module_system/log/schema.py index a404b85d..8c2bba2d 100644 --- a/backend/app/api/v1/module_system/log/schema.py +++ b/backend/app/api/v1/module_system/log/schema.py @@ -23,25 +23,6 @@ class OperationLogCreateSchema(BaseModel): description: Optional[str] = Field(default=None, max_length=255, description="描述") creator_id: Optional[int] = Field(default=None, description="创建人ID") - @model_validator(mode='before') - @classmethod - def _normalize(cls, values): - if isinstance(values, dict): - # 字符串去空格 - for k in ["request_path", "request_method", "request_payload", "request_ip", "login_location", "request_os", "request_browser", "response_json", "process_time", "description"]: - if k in values and isinstance(values[k], str): - values[k] = values[k].strip() or None if values[k].strip() == "" and k in {"request_payload", "response_json", "description"} else values[k].strip() - # 方法大写 - if "request_method" in values and isinstance(values["request_method"], str): - values["request_method"] = values["request_method"].strip().upper() - # 响应码转整数 - if "response_code" in values and isinstance(values["response_code"], str): - try: - values["response_code"] = int(values["response_code"].strip()) - except Exception: - pass - return values - @field_validator("type") @classmethod def _validate_type(cls, value: Optional[int]): diff --git a/backend/app/api/v1/module_system/notice/schema.py b/backend/app/api/v1/module_system/notice/schema.py index 80fdd12b..908924ac 100644 --- a/backend/app/api/v1/module_system/notice/schema.py +++ b/backend/app/api/v1/module_system/notice/schema.py @@ -14,25 +14,6 @@ class NoticeCreateSchema(BaseModel): status: bool = Field(default=True, description="是否启用(True:启用 False:禁用)") description: Optional[str] = Field(default=None, max_length=255, description="描述") - @model_validator(mode='before') - @classmethod - def _normalize(cls, values): - if isinstance(values, dict): - # 字符串去空格 - for k in ["notice_title", "notice_type", "notice_content", "description"]: - if k in values and isinstance(values[k], str): - values[k] = values[k].strip() or None if values[k].strip() == "" and k == "description" else values[k].strip() - # 布尔兼容 - if "status" in values and isinstance(values["status"], str): - values["status"] = values["status"].strip().lower() in {"true", "1", "yes", "y"} - # 类型映射 - mapping = {"1": "1", "2": "2", "通知": "1", "公告": "2", "notice": "1", "announcement": "2"} - if "notice_type" in values and isinstance(values["notice_type"], str): - v = values["notice_type"].strip().lower() - if v in mapping: - values["notice_type"] = mapping[v] - return values - @field_validator("notice_type") @classmethod def _validate_notice_type(cls, value: str): @@ -46,8 +27,6 @@ def _validate_after(self): raise ValueError("公告标题不能为空") if not self.notice_content.strip(): raise ValueError("公告内容不能为空") - if self.status is False and (not self.description or not str(self.description).strip()): - raise ValueError("禁用状态下必须填写描述") return self diff --git a/backend/app/api/v1/module_system/params/schema.py b/backend/app/api/v1/module_system/params/schema.py index 43d59832..50750940 100644 --- a/backend/app/api/v1/module_system/params/schema.py +++ b/backend/app/api/v1/module_system/params/schema.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.base_schema import BaseSchema @@ -15,33 +15,6 @@ class ParamsCreateSchema(BaseModel): status: bool = Field(default=True, description="状态(True:正常 False:停用)") description: Optional[str] = Field(default=None, max_length=500, description="描述") - @model_validator(mode='before') - @classmethod - def _normalize(cls, data): - """前置归一化:字符串去空格、空串转 None、布尔兼容转换,并规范键为小写。""" - if isinstance(data, dict): - for key in ('config_name', 'config_key', 'config_value', 'description'): - val = data.get(key) - if isinstance(val, str): - val = val.strip() - if key in ('config_value', 'description') and val == '': - val = None - data[key] = val - # 规范键为小写 - if isinstance(data.get('config_key'), str): - data['config_key'] = data['config_key'].lower() - # 规范布尔 - for bkey in ('config_type', 'status'): - val = data.get(bkey) - if isinstance(val, str): - lowered = val.strip().lower() - if lowered in {'true', '1', 'y', 'yes'}: - data[bkey] = True - elif lowered in {'false', '0', 'n', 'no'}: - data[bkey] = False - elif isinstance(val, int): - data[bkey] = bool(val) - return data @field_validator('config_key') @classmethod diff --git a/backend/app/api/v1/module_system/position/schema.py b/backend/app/api/v1/module_system/position/schema.py index f58d3c3f..479dcbd7 100644 --- a/backend/app/api/v1/module_system/position/schema.py +++ b/backend/app/api/v1/module_system/position/schema.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.base_schema import BaseSchema from app.core.validator import DateTimeStr @@ -21,33 +21,6 @@ def _validate_name(cls, v: str) -> str: raise ValueError('岗位名称不能为空') return v - @model_validator(mode='before') - @classmethod - def _normalize(cls, data): - if isinstance(data, dict): - for key in ('name', 'description'): - val = data.get(key) - if isinstance(val, str): - val = val.strip() - if key == 'description' and val == '': - val = None - data[key] = val - # order字符串转为整数 - order_val = data.get('order') - if isinstance(order_val, str) and order_val.strip().isdigit(): - data['order'] = int(order_val.strip()) - # status兼容 - status_val = data.get('status') - if isinstance(status_val, str): - lowered = status_val.strip().lower() - if lowered in {'true', '1', 'y', 'yes'}: - data['status'] = True - elif lowered in {'false', '0', 'n', 'no'}: - data['status'] = False - elif isinstance(status_val, int): - data['status'] = bool(status_val) - return data - class PositionUpdateSchema(PositionCreateSchema): """岗位更新模型""" diff --git a/backend/app/api/v1/module_system/role/schema.py b/backend/app/api/v1/module_system/role/schema.py index ba7b7d3b..e0a137c0 100644 --- a/backend/app/api/v1/module_system/role/schema.py +++ b/backend/app/api/v1/module_system/role/schema.py @@ -30,38 +30,6 @@ def validate_code(cls, value: Optional[str]): raise ValueError("角色编码需字母开头,允许字母/数字/下划线,长度2-40") return v - @field_validator("name") - @classmethod - def validate_name(cls, value: str): - v = value.strip() - if not v: - raise ValueError("角色名称不能为空") - return v - - @model_validator(mode='before') - @classmethod - def _normalize(cls, values): - if isinstance(values, dict): - for k in ["name", "code", "description"]: - if k in values and isinstance(values[k], str): - values[k] = values[k].strip() or None if values[k].strip() == "" else values[k].strip() - # bool 兼容 - if "status" in values and isinstance(values["status"], str): - values["status"] = values["status"].strip().lower() in {"true", "1", "yes", "y"} - # 数字兼容 - if "order" in values and isinstance(values["order"], str): - try: - values["order"] = int(values["order"].strip()) - except Exception: - pass - return values - - @model_validator(mode='after') - def _validate_after(self): - if self.status is False and (not self.description or not str(self.description).strip()): - raise ValueError("禁用状态下必须填写描述") - return self - class RolePermissionSettingSchema(BaseModel): """角色权限配置模型""" @@ -70,18 +38,6 @@ class RolePermissionSettingSchema(BaseModel): menu_ids: List[int] = Field(default_factory=list, description='菜单ID列表') dept_ids: List[int] = Field(default_factory=list, description='部门ID列表') - @model_validator(mode='before') - @classmethod - def _normalize(cls, values): - if isinstance(values, dict): - for k in ["role_ids", "menu_ids", "dept_ids"]: - if k in values and values[k] is not None: - try: - values[k] = list({int(x) for x in values[k]}) - except Exception: - pass - return values - @model_validator(mode='after') def validate_fields(self): """验证权限配置字段""" diff --git a/backend/app/api/v1/module_system/tenant/schema.py b/backend/app/api/v1/module_system/tenant/schema.py index 17a9dea1..9a683536 100644 --- a/backend/app/api/v1/module_system/tenant/schema.py +++ b/backend/app/api/v1/module_system/tenant/schema.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.base_schema import BaseSchema @@ -20,57 +20,6 @@ def _validate_name(cls, v: str) -> str: raise ValueError('名称不能为空') return v - @model_validator(mode='before') - @classmethod - def _normalize(cls, data): - if isinstance(data, dict): - for key in ('name', 'description'): - val = data.get(key) - if isinstance(val, str): - val = val.strip() - if key == 'description' and val == '': - val = None - data[key] = val - # status兼容 - val = data.get('status') - if isinstance(val, str): - lowered = val.strip().lower() - if lowered in {'true', '1', 'y', 'yes'}: - data['status'] = True - elif lowered in {'false', '0', 'n', 'no'}: - data['status'] = False - elif isinstance(val, int): - data['status'] = bool(val) - return data - - @model_validator(mode='wrap') - @classmethod - def _wrap(cls, data, handler): - # 进一步处理:压缩名称/描述中的多余空白,并支持更多 status 同义词 - if isinstance(data, dict): - name = data.get('name') - if isinstance(name, str): - data['name'] = ' '.join(name.split()) - status_val = data.get('status') - if isinstance(status_val, str): - lowered = status_val.strip().lower() - if lowered in {'enabled', 'enable', 'on'}: - data['status'] = True - elif lowered in {'disabled', 'disable', 'off'}: - data['status'] = False - desc = data.get('description') - if isinstance(desc, str): - data['description'] = ' '.join(desc.split()) - result = handler(data) - return result - - @model_validator(mode='after') - def _check_disabled_requires_description(self): - # 业务示例:禁用时必须填写描述 - if self.status is False and (self.description is None or (isinstance(self.description, str) and self.description.strip() == '')): - raise ValueError('禁用时必须填写描述') - return self - class TenantUpdateSchema(TenantCreateSchema): """更新模型""" diff --git a/backend/app/api/v1/module_system/user/schema.py b/backend/app/api/v1/module_system/user/schema.py index aee98e5f..9084f5cb 100644 --- a/backend/app/api/v1/module_system/user/schema.py +++ b/backend/app/api/v1/module_system/user/schema.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from typing import Optional, List -from pydantic import BaseModel, ConfigDict, Field, EmailStr, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, EmailStr, field_validator from app.core.validator import DateTimeStr, mobile_validator from app.core.base_schema import BaseSchema, CommonSchema @@ -59,24 +59,6 @@ def validate_username(cls, value: str): raise ValueError("账号需字母开头,3-32位,仅含字母/数字/_ . -") return v - @model_validator(mode='before') - @classmethod - def _normalize(cls, values): - if isinstance(values, dict): - for k in ["name", "username", "password", "description"]: - if k in values and isinstance(values[k], str): - values[k] = values[k].strip() or values[k] - # role_ids 去重并转为 int - if "role_ids" in values and values["role_ids"] is not None: - try: - values["role_ids"] = list[int]({int(x) for x in values["role_ids"]}) - except Exception: - pass - # mobile 空串转 None - if "mobile" in values and isinstance(values["mobile"], str) and values["mobile"].strip() == "": - values["mobile"] = None - return values - class UserForgetPasswordSchema(BaseModel): """忘记密码""" @@ -116,35 +98,6 @@ class UserCreateSchema(CurrentUserUpdateSchema): role_ids: Optional[List[int]] = Field(default=[], description='角色ID') position_ids: Optional[List[int]] = Field(default=[], description='岗位ID') - @model_validator(mode='before') - @classmethod - def _normalize(cls, values): - if isinstance(values, dict): - # 字符串去空格和空串转 None - for k in ["username", "password", "description", "name"]: - if k in values and isinstance(values[k], str): - values[k] = values[k].strip() or None if values[k].strip() == "" else values[k].strip() - # bool 兼容 - for k in ["status", "is_superuser"]: - if k in values: - v = values[k] - if isinstance(v, str): - values[k] = v.strip().lower() in {"true", "1", "yes", "y"} - # 列表转 int 去重 - for k in ["role_ids", "position_ids"]: - if k in values and values[k] is not None: - try: - values[k] = list({int(x) for x in values[k]}) - except Exception: - pass - return values - - @model_validator(mode='after') - def _validate_after(self): - if self.status is False and (not self.description or not str(self.description).strip()): - raise ValueError("禁用状态下必须填写备注描述") - return self - class UserUpdateSchema(UserCreateSchema): """更新""" diff --git a/backend/requirements.txt b/backend/requirements.txt index ec2e9da9..a1a09e70 100755 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -33,4 +33,4 @@ openai==1.55.2 # ai 大模型 rich==13.9.4 # 终端打印美化 sqlglot[rs]==27.8.0 # sql 解析 pydantic_validation_decorator==0.1.4 # 模型验证 -loguru \ No newline at end of file +loguru==0.7.3 \ No newline at end of file From 8b16da0062e9811feaa521b77d92d19479658a94 Mon Sep 17 00:00:00 2001 From: zhangtao <9480807882@qq.com> Date: Thu, 13 Nov 2025 00:38:06 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(frontend):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E9=AB=98=E5=BA=A6=E5=B1=9E=E6=80=A7=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(backend): 修复租户管理菜单图标错误 style(backend): 优化启动banner显示格式 refactor(backend): 重构日志系统使用loguru替代原生logging feat(backend): 增加租户名称校验规则 refactor(backend): 优化应用启动流程和日志初始化 docs(frontend): 更新租户管理页面标题和描述 --- .../app/api/v1/module_system/tenant/schema.py | 15 +- backend/app/config/setting.py | 42 +- backend/app/core/logger.py | 190 ++++--- backend/app/plugin/init_app.py | 12 +- backend/app/scripts/data/system_menu.json | 2 +- backend/app/utils/common_util.py | 4 +- backend/banner.txt | 3 +- backend/main.py | 20 +- backend/templates/vue/index.vue.j2 | 2 +- .../views/module_application/job/index.vue | 2 +- .../module_application/workflow/index.vue | 2 +- .../views/module_generator/backcode/index.vue | 2 +- .../src/views/module_generator/demo/index.vue | 2 +- .../src/views/module_monitor/cache/index.vue | 2 +- .../src/views/module_monitor/online/index.vue | 2 +- .../views/module_monitor/resource/index.vue | 2 +- .../src/views/module_system/dept/index.vue | 2 +- .../src/views/module_system/dict/index.vue | 2 +- .../src/views/module_system/log/index.vue | 2 +- .../src/views/module_system/menu/index.vue | 2 +- .../src/views/module_system/notice/index.vue | 2 +- .../src/views/module_system/param/index.vue | 2 +- .../views/module_system/position/index.vue | 2 +- .../src/views/module_system/role/index.vue | 2 +- .../src/views/module_system/tenant/index.vue | 504 +++++++++--------- .../src/views/module_system/user/index.vue | 2 +- 26 files changed, 415 insertions(+), 411 deletions(-) diff --git a/backend/app/api/v1/module_system/tenant/schema.py b/backend/app/api/v1/module_system/tenant/schema.py index 9a683536..d2f5bb47 100644 --- a/backend/app/api/v1/module_system/tenant/schema.py +++ b/backend/app/api/v1/module_system/tenant/schema.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.core.base_schema import BaseSchema @@ -20,6 +20,19 @@ def _validate_name(cls, v: str) -> str: raise ValueError('名称不能为空') return v + @model_validator(mode='after') + def _after_validation(self): + """ + 核心业务规则校验 + """ + # 长度校验:名称最小长度 + if len(self.name) < 2 or len(self.name) > 50: + raise ValueError('名称长度必须在2-50个字符之间') + # 格式校验:名称只能包含字母、数字、下划线和中划线 + if not self.name.isalnum() and not all(c in '-_' for c in self.name): + raise ValueError('名称只能包含字母、数字、下划线和中划线') + + return self class TenantUpdateSchema(TenantCreateSchema): """更新模型""" diff --git a/backend/app/config/setting.py b/backend/app/config/setting.py index 0bc0e145..a83dd129 100755 --- a/backend/app/config/setting.py +++ b/backend/app/config/setting.py @@ -272,47 +272,7 @@ def UVICORN_CONFIG(self) -> Dict[str, Any]: "host": self.SERVER_HOST, "port": self.SERVER_PORT, "reload": self.RELOAD, - "log_config": { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "()": "uvicorn.logging.DefaultFormatter", - "fmt": self.LOGGER_FORMAT, - "use_colors": None, - }, - "access": { - "()": "uvicorn.logging.AccessFormatter", - "fmt": self.LOGGER_FORMAT, - }, - }, - "handlers": { - "console": { - "formatter": "default", - "class": "logging.StreamHandler", - "stream": "ext://sys.stderr", - }, - "access_console": { - "formatter": "access", - "class": "logging.StreamHandler", - "stream": "ext://sys.stdout", - }, - "file": { - "formatter": "default", - "class": "logging.handlers.TimedRotatingFileHandler", - "filename": str(self.LOGGER_DIR.joinpath("info.log")), - "when": self.WHEN, - "interval": self.INTERVAL, - "backupCount": self.BACKUPCOUNT, - "encoding": self.ENCODING, - }, - }, - "loggers": { - "uvicorn": {"handlers": ["file", "console"], "level": self.LOGGER_LEVEL, "propagate": False}, - "uvicorn.error": {"handlers": ["file", "console"], "level": self.LOGGER_LEVEL, "propagate": False}, - "uvicorn.access": {"handlers": ["file", "access_console"], "level": self.LOGGER_LEVEL, "propagate": False}, - }, - }, + "log_config": None, "workers": self.WORKERS, "limit_concurrency": self.LIMIT_CONCURRENCY, "backlog": self.BACKLOG, diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py index 527ced91..d04312fb 100644 --- a/backend/app/core/logger.py +++ b/backend/app/core/logger.py @@ -1,90 +1,116 @@ # -*- coding: utf-8 -*- import logging -from logging.handlers import TimedRotatingFileHandler +import sys from pathlib import Path -import re +from loguru import logger from app.config.setting import settings - - -class AppLogger: - """应用级日志管理器:一次性配置 + 获取。""" - - def __init__(self) -> None: - self._logger = logging.getLogger(__name__) - self._configured = False - - def _create_file_handler(self, stem: str, level: int, log_dir: Path, formatter: logging.Formatter) -> TimedRotatingFileHandler: - file_path = log_dir / f"{stem}.log" - handler = TimedRotatingFileHandler( - filename=str(file_path), - when=settings.WHEN, - interval=settings.INTERVAL, - backupCount=settings.BACKUPCOUNT, - encoding=settings.ENCODING, +from app.utils.common_util import worship + +class InterceptHandler(logging.Handler): + """ + 日志拦截处理器:将所有 Python 标准日志重定向到 Loguru + + 工作原理: + 1. 继承自 logging.Handler + 2. 重写 emit 方法处理日志记录 + 3. 将标准库日志转换为 Loguru 格式 + """ + def emit(self, record: logging.LogRecord) -> None: + # 尝试获取日志级别名称 + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # 获取调用帧信息,增加None检查 + frame, depth = logging.currentframe(), 2 + if frame is not None: + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + # 使用 Loguru 记录日志 + logger.opt(depth=depth, exception=record.exc_info).log( + level, + record.getMessage() ) - handler.setLevel(level) - handler.setFormatter(formatter) - handler.suffix = "_%Y-%m-%d.log" # 设置正确的后缀格式 - - def namer(default_name: str) -> str: - # 统一处理轮转后的文件名,确保格式为stem_YYYY-MM-DD.log - file_name = Path(default_name).name - # 提取日期部分 - base_name = file_name.split('.')[0] # 获取基本名称(不包含扩展名) - date_match = re.search(r'(\d{4}-\d{2}-\d{2})', base_name) - if date_match: - date_part = date_match.group(1) - return f"{stem}_{date_part}.log" - - # 如果没有找到日期部分,返回原始名称 - return default_name - - def rotator(source: str, dest: str) -> None: - # 确保目录存在 - Path(dest).parent.mkdir(parents=True, exist_ok=True) - # 重命名文件 - Path(source).rename(dest) - - handler.namer = namer - handler.rotator = rotator - return handler - - def configure(self) -> logging.Logger: - if self._configured: - return self._logger - - # 基础设置 - self._logger.setLevel(settings.LOGGER_LEVEL) - self._logger.handlers.clear() - self._logger.propagate = False - - # 目录与格式 - log_dir = Path(settings.LOGGER_DIR) - log_dir.mkdir(parents=True, exist_ok=True) - formatter = logging.Formatter(settings.LOGGER_FORMAT) - - # 文件处理器 - self._logger.addHandler(self._create_file_handler("info", logging.INFO, log_dir, formatter)) - self._logger.addHandler(self._create_file_handler("error", logging.ERROR, log_dir, formatter)) - - # 控制台处理器 - console = logging.StreamHandler() - console.setLevel(settings.LOGGER_LEVEL) - console.setFormatter(formatter) - self._logger.addHandler(console) - - self._configured = True - return self._logger - - def get_logger(self) -> logging.Logger: - return self.configure() - -def get_logger() -> logging.Logger: - AL = AppLogger() - return AL.get_logger() - -# 模块级兼容实例 -logger = get_logger() +def setup_logging(): + """ + 配置日志系统 + + 功能: + 1. 控制台彩色输出 + 2. 文件日志轮转 + 3. 错误日志单独存储 + 4. 异步日志记录 + """ + # 添加上下文信息 + logger.configure(extra={"app_name": "FastapiAdmin"}) + # 步骤1:移除默认处理器 + logger.remove() + + # 步骤2:定义日志格式 + log_format = ( + # 时间信息 + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + # 日志级别,居中对齐 + "{level: <8} | " + # 文件、函数和行号 + "{name}:{function}:{line} - " + # 日志消息 + "{message}" + ) + + # 步骤3:配置控制台输出 + logger.add( + sys.stdout, + format=log_format, + level="DEBUG" if settings.DEBUG else "INFO", + enqueue=True, # 启用异步写入 + backtrace=True, # 显示完整的异常回溯 + diagnose=True, # 显示变量值等诊断信息 + colorize=True # 启用彩色输出 + ) + + # 步骤4:创建日志目录 + log_dir = Path(settings.LOGGER_DIR) + # 确保日志目录存在,如果不存在则创建 + log_dir.mkdir(parents=True, exist_ok=True) + + # 步骤5:配置常规日志文件 + logger.add( + str(log_dir / "info.log"), + format=log_format, + level="INFO", + rotation="00:00", # 每天午夜轮转 + retention=settings.LOG_RETENTION_DAYS, + compression="gz", + encoding=settings.ENCODING, + enqueue=True + ) + + # 步骤6:配置错误日志文件 + logger.add( + str(log_dir / "error.log"), + format=log_format, + level="ERROR", + rotation="00:00", # 每天午夜轮转 + retention=settings.LOG_RETENTION_DAYS, + compression="gz", + encoding=settings.ENCODING, + enqueue=True, + backtrace=True, + diagnose=True + ) + + # 步骤7:配置标准库日志 + logging.basicConfig(handlers=[InterceptHandler()], level=settings.LOGGER_LEVEL, force=True) + logger_name_list = [name for name in logging.root.manager.loggerDict] + # 步骤8:配置第三方库日志 + for logger_name in logger_name_list: + _logger = logging.getLogger(logger_name) + _logger.handlers = [InterceptHandler()] + _logger.propagate = False diff --git a/backend/app/plugin/init_app.py b/backend/app/plugin/init_app.py index f4ade57c..570a5149 100644 --- a/backend/app/plugin/init_app.py +++ b/backend/app/plugin/init_app.py @@ -34,8 +34,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: 返回: - AsyncGenerator[Any, Any]: 生命周期上下文生成器。 """ - logger.info(worship()) - await InitializeData().init_db() logger.info(f"✅️ 初始化 {settings.DATABASE_TYPE} 数据库初始化完成...") await import_modules_async(modules=settings.EVENT_LIST, desc="全局事件", app=app, status=True) @@ -46,19 +44,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: logger.info('✅️ 初始化Redis数据字典完成...') await SchedulerUtil.init_system_scheduler() logger.info('✅️ 初始化定时任务完成...') - scheduler_status = SchedulerUtil.get_job_status() - scheduler_jobs = len(SchedulerUtil.get_all_jobs()) - - logger.info(f'✅️ {settings.TITLE} 服务成功启动...') - # 控制台输出优化:展示服务信息与文档地址 + console_run( host=settings.SERVER_HOST, port=settings.SERVER_PORT, reload=settings.RELOAD, workers=settings.WORKERS, redis_ready=True, - scheduler_jobs=scheduler_jobs, - scheduler_status=scheduler_status, + scheduler_jobs=len(SchedulerUtil.get_all_jobs()), + scheduler_status=SchedulerUtil.get_job_status(), ) yield diff --git a/backend/app/scripts/data/system_menu.json b/backend/app/scripts/data/system_menu.json index 1f555ce7..da331e2d 100644 --- a/backend/app/scripts/data/system_menu.json +++ b/backend/app/scripts/data/system_menu.json @@ -158,7 +158,7 @@ { "name": "租户管理", "type": 2, - "icon": "el-icon-DataLine", + "icon": "el-icon-Avatar", "order": 3, "permission": "module_system:tenant:query", "route_name": "Tenant", diff --git a/backend/app/utils/common_util.py b/backend/app/utils/common_util.py index c819c658..a1929e47 100644 --- a/backend/app/utils/common_util.py +++ b/backend/app/utils/common_util.py @@ -15,7 +15,7 @@ from app.core.exceptions import CustomException -def worship() -> str | None: +def worship() -> None: """ 获取项目启动Banner(优先读取 banner.txt) """ @@ -24,7 +24,7 @@ def worship() -> str | None: raw = banner_file.read_text(encoding='utf-8') # 支持文件内使用 {TITLE} / {VERSION} 等占位符 banner = raw.format(TITLE=settings.TITLE, VERSION=settings.VERSION) - return banner + logger.info(banner) def import_module(module: str, desc: str) -> Any: """ diff --git a/backend/banner.txt b/backend/banner.txt index 8b88f962..def025c7 100644 --- a/backend/banner.txt +++ b/backend/banner.txt @@ -1,3 +1,4 @@ +✅️ {TITLE} (v{VERSION}) 服务开始启动... ___ _ _ __ _ .' ..] / |_ (_) | ] (_) @@ -6,5 +7,3 @@ | | // | |, `'.'. | |,// | |,| \__/ | | | // | |,| \__/ | | | | | | | | | | | | | [___] \'-;__/[\__) )\__/\'-;__/| ;.__/ [___]\'-;__/ '.__.;__][___||__||__][___][___||__] [__| - - :: {TITLE} :: ({VERSION}) 服务开始启动... diff --git a/backend/main.py b/backend/main.py index aabc86b5..44a3b6ea 100755 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,12 @@ from app.common.enums import EnvironmentEnum from app.config.setting import settings +from app.core.logger import setup_logging +from app.utils.common_util import worship + + +# 全局标志,用于跟踪日志是否已初始化 +LOGGING_INITIALIZED = False shell_app = typer.Typer() @@ -17,6 +23,7 @@ alembic_cfg = Config("alembic.ini") def create_app() -> FastAPI: + global LOGGING_INITIALIZED from app.plugin.init_app import ( register_middlewares, register_exceptions, @@ -25,6 +32,10 @@ def create_app() -> FastAPI: reset_api_docs, lifespan ) + # 初始化日志系统(确保每个工作进程都会初始化日志,但避免重复初始化) + if not LOGGING_INITIALIZED: + setup_logging() + LOGGING_INITIALIZED = True # 创建FastAPI应用 app = FastAPI(**settings.FASTAPI_CONFIG, lifespan=lifespan) @@ -46,11 +57,12 @@ def run(env: EnvironmentEnum = typer.Option(EnvironmentEnum.DEV, "--env", help=" typer.echo("项目启动中..") # 设置环境变量 os.environ["ENVIRONMENT"] = env.value + # 初始化日志系统(确保uvicorn主进程的日志也被正确配置) + setup_logging() + worship() + # 启动uvicorn服务 - uvicorn.run( - app='main:create_app', - **settings.UVICORN_CONFIG - ) + uvicorn.run(app=f'main:create_app', **settings.UVICORN_CONFIG) @shell_app.command() def revision(env: EnvironmentEnum = typer.Option(EnvironmentEnum.DEV, "--env", help="运行环境 (dev, prod)")) -> None: diff --git a/backend/templates/vue/index.vue.j2 b/backend/templates/vue/index.vue.j2 index ce467f38..24ca3937 100644 --- a/backend/templates/vue/index.vue.j2 +++ b/backend/templates/vue/index.vue.j2 @@ -141,7 +141,7 @@ :data="pageTableData" highlight-current-row class="data-table__content" - height="450" + :height="450" border stripe @selection-change="handleSelectionChange" diff --git a/frontend/src/views/module_application/job/index.vue b/frontend/src/views/module_application/job/index.vue index b0229bb2..17750f9d 100644 --- a/frontend/src/views/module_application/job/index.vue +++ b/frontend/src/views/module_application/job/index.vue @@ -145,7 +145,7 @@ :data="pageTableData" class="data-table__content" highlight-current-row - height="450" + :height="450" border stripe @selection-change="handleSelectionChange" diff --git a/frontend/src/views/module_application/workflow/index.vue b/frontend/src/views/module_application/workflow/index.vue index 5be7719b..baf490a2 100644 --- a/frontend/src/views/module_application/workflow/index.vue +++ b/frontend/src/views/module_application/workflow/index.vue @@ -92,7 +92,7 @@ - + diff --git a/frontend/src/views/module_generator/backcode/index.vue b/frontend/src/views/module_generator/backcode/index.vue index ba8b6492..b6e21b19 100644 --- a/frontend/src/views/module_generator/backcode/index.vue +++ b/frontend/src/views/module_generator/backcode/index.vue @@ -76,7 +76,7 @@ :data="tableList" highlight-current-row class="data-table__content" - height="450" + :height="450" border stripe @selection-change="handleTableSelectionChange" diff --git a/frontend/src/views/module_generator/demo/index.vue b/frontend/src/views/module_generator/demo/index.vue index 3cb0d05a..7520b638 100644 --- a/frontend/src/views/module_generator/demo/index.vue +++ b/frontend/src/views/module_generator/demo/index.vue @@ -143,7 +143,7 @@ - + diff --git a/frontend/src/views/module_monitor/cache/index.vue b/frontend/src/views/module_monitor/cache/index.vue index a742f297..bd6df96d 100644 --- a/frontend/src/views/module_monitor/cache/index.vue +++ b/frontend/src/views/module_monitor/cache/index.vue @@ -452,6 +452,6 @@ onUnmounted(() => { diff --git a/frontend/src/views/module_monitor/online/index.vue b/frontend/src/views/module_monitor/online/index.vue index b7bf846d..502dc1d2 100644 --- a/frontend/src/views/module_monitor/online/index.vue +++ b/frontend/src/views/module_monitor/online/index.vue @@ -67,7 +67,7 @@ - + diff --git a/frontend/src/views/module_monitor/resource/index.vue b/frontend/src/views/module_monitor/resource/index.vue index 4f430239..1f83699f 100644 --- a/frontend/src/views/module_monitor/resource/index.vue +++ b/frontend/src/views/module_monitor/resource/index.vue @@ -104,7 +104,7 @@ :data="fileList" row-key="file_url" class="data-table__content" - height="450" + :height="450" border stripe @selection-change="handleSelectionChange" diff --git a/frontend/src/views/module_system/dept/index.vue b/frontend/src/views/module_system/dept/index.vue index 6a2679cf..0b6d4cff 100644 --- a/frontend/src/views/module_system/dept/index.vue +++ b/frontend/src/views/module_system/dept/index.vue @@ -102,7 +102,7 @@ - + diff --git a/frontend/src/views/module_system/dict/index.vue b/frontend/src/views/module_system/dict/index.vue index 45253010..8f056820 100644 --- a/frontend/src/views/module_system/dict/index.vue +++ b/frontend/src/views/module_system/dict/index.vue @@ -119,7 +119,7 @@ - + diff --git a/frontend/src/views/module_system/log/index.vue b/frontend/src/views/module_system/log/index.vue index 50805bf0..e2a8431d 100644 --- a/frontend/src/views/module_system/log/index.vue +++ b/frontend/src/views/module_system/log/index.vue @@ -88,7 +88,7 @@ - + diff --git a/frontend/src/views/module_system/menu/index.vue b/frontend/src/views/module_system/menu/index.vue index 09abbeab..55842243 100644 --- a/frontend/src/views/module_system/menu/index.vue +++ b/frontend/src/views/module_system/menu/index.vue @@ -90,7 +90,7 @@ - + diff --git a/frontend/src/views/module_system/notice/index.vue b/frontend/src/views/module_system/notice/index.vue index f75e084f..43cd2145 100644 --- a/frontend/src/views/module_system/notice/index.vue +++ b/frontend/src/views/module_system/notice/index.vue @@ -128,7 +128,7 @@ - + diff --git a/frontend/src/views/module_system/param/index.vue b/frontend/src/views/module_system/param/index.vue index a61e81ed..49e1d5e3 100644 --- a/frontend/src/views/module_system/param/index.vue +++ b/frontend/src/views/module_system/param/index.vue @@ -105,7 +105,7 @@ - + diff --git a/frontend/src/views/module_system/position/index.vue b/frontend/src/views/module_system/position/index.vue index b9fdbe0e..f3d827b7 100644 --- a/frontend/src/views/module_system/position/index.vue +++ b/frontend/src/views/module_system/position/index.vue @@ -114,7 +114,7 @@ - + diff --git a/frontend/src/views/module_system/role/index.vue b/frontend/src/views/module_system/role/index.vue index b10d02b1..55cb8d0e 100644 --- a/frontend/src/views/module_system/role/index.vue +++ b/frontend/src/views/module_system/role/index.vue @@ -115,7 +115,7 @@ - + diff --git a/frontend/src/views/module_system/tenant/index.vue b/frontend/src/views/module_system/tenant/index.vue index cc6fe518..c35aa2a6 100644 --- a/frontend/src/views/module_system/tenant/index.vue +++ b/frontend/src/views/module_system/tenant/index.vue @@ -1,260 +1,260 @@ - +