diff --git a/backend/app/common/exception/exception_handler.py b/backend/app/common/exception/exception_handler.py index ee105496..cd77f445 100644 --- a/backend/app/common/exception/exception_handler.py +++ b/backend/app/common/exception/exception_handler.py @@ -5,6 +5,7 @@ import json from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from pydantic import ValidationError +from pydantic.errors import EnumMemberError, WrongConstantError from starlette.exceptions import HTTPException from starlette.responses import JSONResponse from uvicorn.protocols.http.h11_impl import STATUS_PHRASES @@ -66,22 +67,30 @@ def register_exception(app: FastAPI): message = '' data = {} for raw_error in exc.raw_errors: - if isinstance(raw_error.exc, ValidationError): - exc = raw_error.exc - if hasattr(exc, 'model'): - fields = exc.model.__dict__.get('__fields__') + raw_exc = raw_error.exc + if isinstance(raw_exc, ValidationError): + if hasattr(raw_exc, 'model'): + fields = raw_exc.model.__dict__.get('__fields__') for field_key in fields.keys(): field_title = fields.get(field_key).field_info.title data[field_key] = field_title if field_title else field_key - errors_len = len(exc.errors()) - for error in exc.errors(): + # 处理特殊类型异常模板信息 -> backend/app/schemas/base.py: SCHEMA_ERROR_MSG_TEMPLATES + sub_raw_exc = raw_exc.raw_errors[0].exc + if isinstance(sub_raw_exc, (EnumMemberError, WrongConstantError)): + if getattr(sub_raw_exc, 'code') == 'enum': + sub_raw_exc.__dict__['permitted'] = ', '.join(repr(v.value) for v in sub_raw_exc.enum_values) # type: ignore # noqa: E501 + else: + sub_raw_exc.__dict__['permitted'] = ', '.join(repr(v) for v in sub_raw_exc.permitted) # type: ignore # noqa: E501 + # 处理异常信息 + errors_len = len(raw_exc.errors()) + for error in raw_exc.errors(): field = str(error.get('loc')[-1]) - _msg = error.get('msg') + msg = error.get('msg') errors_len = errors_len - 1 message += ( - f'{data.get(field, field) if field != "__root__" else ""} {_msg}' + ', ' + f'{data.get(field, field) if field != "__root__" else ""} {msg}' + ', ' if errors_len > 0 - else f'{data.get(field, field) if field != "__root__" else ""} {_msg}' + '.' + else f'{data.get(field, field) if field != "__root__" else ""} {msg}' + '.' ) elif isinstance(raw_error.exc, json.JSONDecodeError): message += 'json解析失败' diff --git a/backend/app/models/sys_dept.py b/backend/app/models/sys_dept.py index 314c74d4..f84565c5 100644 --- a/backend/app/models/sys_dept.py +++ b/backend/app/models/sys_dept.py @@ -22,10 +22,10 @@ class Dept(Base): email: Mapped[str | None] = mapped_column(String(50), default=None, comment='邮箱') status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)') del_flag: Mapped[bool] = mapped_column(default=False, comment='删除标志(0删除 1存在)') + # 父级部门一对多 parent_id: Mapped[int | None] = mapped_column( ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID' ) - # 父级部门一对多 parent: Mapped[Union['Dept', None]] = relationship(init=False, back_populates='children', remote_side=[id]) children: Mapped[list['Dept'] | None] = relationship(init=False, back_populates='parent') # 部门用户一对多 diff --git a/backend/app/models/sys_dict_data.py b/backend/app/models/sys_dict_data.py index 866c6108..fe6fe5f0 100644 --- a/backend/app/models/sys_dict_data.py +++ b/backend/app/models/sys_dict_data.py @@ -15,9 +15,9 @@ class DictData(Base): id: Mapped[id_key] = mapped_column(init=False) label: Mapped[str] = mapped_column(String(32), unique=True, comment='字典标签') value: Mapped[str] = mapped_column(String(32), unique=True, comment='字典值') - type_id: Mapped[int] = mapped_column(ForeignKey('sys_dict_type.id'), comment='字典类型id') sort: Mapped[int] = mapped_column(default=0, comment='排序') status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)') remark: Mapped[str | None] = mapped_column(LONGTEXT, default=None, comment='备注') # 字典类型一对多 + type_id: Mapped[int] = mapped_column(ForeignKey('sys_dict_type.id'), default=None, comment='字典类型关联ID') type: Mapped['DictType'] = relationship(init=False, back_populates='datas') # noqa: F821 diff --git a/backend/app/models/sys_menu.py b/backend/app/models/sys_menu.py index 971babba..04018872 100644 --- a/backend/app/models/sys_menu.py +++ b/backend/app/models/sys_menu.py @@ -26,10 +26,10 @@ class Menu(Base): perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识') status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)') remark: Mapped[str | None] = mapped_column(LONGTEXT, default=None, comment='备注') + # 父级菜单一对多 parent_id: Mapped[int | None] = mapped_column( ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID' ) - # 父级菜单一对多 parent: Mapped[Union['Menu', None]] = relationship(init=False, back_populates='children', remote_side=[id]) children: Mapped[list['Menu'] | None] = relationship(init=False, back_populates='parent') # 菜单角色多对多 diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index d81a0160..09cb4258 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -2,14 +2,15 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel, Field, validator +from pydantic import Field, validator from backend.app.common.enums import MethodType +from backend.app.schemas.base import SchemaBase -class ApiBase(BaseModel): +class ApiBase(SchemaBase): name: str - method: str = Field(default=MethodType.GET, description='请求方法') + method: MethodType = Field(default=MethodType.GET, description='请求方法') path: str = Field(..., description='api路径') remark: str | None = None @@ -17,9 +18,6 @@ class ApiBase(BaseModel): def method_validator(cls, v): if not v.isupper(): raise ValueError('请求方式必须大写') - allow_method = MethodType.get_member_values() - if v not in allow_method: - raise ValueError(f'请求方式不合法, 仅支持: {allow_method}') return v diff --git a/backend/app/schemas/base.py b/backend/app/schemas/base.py new file mode 100644 index 00000000..c811dfb5 --- /dev/null +++ b/backend/app/schemas/base.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from pydantic import BaseModel + +SCHEMA_ERROR_MSG_TEMPLATES: dict[str, str] = { + # Type Errors + 'type_error.arbitrary_type': '预期为 {expected_arbitrary_type} 的实例', + 'type_error.bool': '值不是有效的布尔值', + 'type_error.callable': '{value} 不可调用', + 'type_error.class': '预计将是一个类', + 'type_error.dataclass': '{class_name} 的实例,预期为元组或字典', + 'type_error.decimal': '值不是有效的小数(Decimal)', + 'type_error.deque': '值不是有效的双端队列', + 'type_error.dict': '值不是有效的字典', + 'type_error.enum_instance': '{value} 不是有效的枚举实例', + 'type_error.enum': '值不是有效的枚举成员; 允许:{permitted}', + 'type_error.float': '值不是有效的浮点数', + 'type_error.frozenset': '值不是有效的冻结集', + 'type_error.hashable': '值不是有效的哈希值', + 'type_error.int_enum_instance': '{value} 不是有效的 IntEnum 实例', + 'type_error.integer': '值不是有效的整数', + 'type_error.iterable': '值不是有效的可迭代对象', + 'type_error.json': 'JSON 对象必须是字符串,字节或字节数组', + 'type_error.list': '值不是有效的列表', + 'type_error.none.allowed': '值不是 None', + 'type_error.none.not_allowed': '不允许的值: None', + 'type_error.not_none': '值不是 None', + 'type_error.path': '值不是有效路径', + 'type_error.pyobject': '确保此值包含有效的导入路径或有效的可调用对象:{error_message}', + 'type_error.sequence': '值不是有效的序列', + 'type_error.set': '值不是有效的集合', + 'type_error.subclass': '预期 {expected_class} 的子类', + 'type_error.tuple': '值不是有效的元组', + 'type_error.uuid': '值不是有效的 UUID', + + # Value Errors + 'value_error.any_str.max_length': '确保此值最多包含 {limit_value} 个字符', + 'value_error.any_str.min_length': '确保此值至少包含 {limit_value} 个字符', + 'value_error.color': '值不是有效的颜色: {reason}', + 'value_error.const': '意外值; 允许: {permitted}', + 'value_error.date.not_in_the_future': '日期不在未来时间', + 'value_error.date.not_in_the_past': '日期不是过去时间', + 'value_error.decimal.max_digits': '确保总共不超过 {max_digits} 位数字', + 'value_error.decimal.max_places': '确保小数位数不超过 {decimal_places} 位', + 'value_error.decimal.not_finite': '值不是有效的小数(Decimal)', + 'value_error.decimal.whole_digits': '确保小数点前不超过 {whole_digits} 位', + 'value_error.discriminated_union.invalid_discriminator': '不匹配鉴别器 {discriminator_key!r} 和值 {discriminator_value!r}(允许的值:{allowed_values})', + 'value_error.discriminated_union.missing_discriminator': '鉴别器 {discriminator_key!r} 的值缺失', + 'value_error.extra': '不允许使用额外字段', + 'value_error.frozenset.max_items': '确保此值最多包含 {limit_value} 个项目', + 'value_error.frozenset.min_items': '确保此值至少包含 {limit_value} 个项目', + 'value_error.invalidbytesizeunit': '无法解释字节单位: {unit}', + 'value_error.list.max_items': '确保此值最多包含 {limit_value} 个项目', + 'value_error.list.min_items': '确保此值至少包含 {limit_value} 个项目', + 'value_error.list.unique_items': '列表包含重复项', + 'value_error.missing': '必填字段', + 'value_error.number.not_finite_number': '确保此值是有限数', + 'value_error.number.not_ge': '确保此值大于或等于 {limit_value}', + 'value_error.number.not_gt': '确保此值大于 {limit_value}', + 'value_error.number.not_le': '确保此值小于或等于 {limit_value}', + 'value_error.number.not_lt': '确保此值小于 {limit_value}。', + 'value_error.number.not_multiple': '确保此值是 {multiple_of} 的倍数。', + 'value_error.path.not_a_directory': '路径 "{path}" 没有指向一个目录', + 'value_error.path.not_a_file': '路径 "{path}" 没有指向一个文件', + 'value_error.path.not_exists': '路径为 "{path}" 的文件或目录不存在', + 'value_error.payment_card_number.digits': '卡号不全是数字', + 'value_error.payment_card_number.invalid_length_for_brand': '{brand} 卡的长度必须为 {required_length}', + 'value_error.payment_card_number.luhn_check': '卡号无效', + 'value_error.regex_pattern': '无效的正则表达式', + 'value_error.set.max_items': '确保此值最多有 {limit_value} 项', + 'value_error.set.min_items': '确保此值至少有 {limit_value} 项', + 'value_error.str.regex': '字符串不符合正则 "{pattern}" 的要求。', + 'value_error.tuple.length': '错误的元组长度 {actual_length},预期的 {expected_length}。', + 'value_error.url.extra': 'URL 无效,在有效的 URL 后发现额外的字符: [extra!r].', + 'value_error.url.host': 'URL 地址无效', + 'value_error.url.port': 'URL 端口无效,端口不能超过 65535', + 'value_error.url.scheme': '不允许的 URL 方案', + 'value_error.url.userinfo': 'URL 中需要用户信息,但缺少用户信息', + 'value_error.uuid.version': 'UUID 预期的版本 {required_version}', +} + + +class SchemaBase(BaseModel): + class Config: + use_enum_values = True + # 错误信息模板对于模型嵌套无效 + # https://github.com/pydantic/pydantic/issues/5651 + error_msg_templates = SCHEMA_ERROR_MSG_TEMPLATES diff --git a/backend/app/schemas/casbin_rule.py b/backend/app/schemas/casbin_rule.py index accce4c0..500776ea 100644 --- a/backend/app/schemas/casbin_rule.py +++ b/backend/app/schemas/casbin_rule.py @@ -1,22 +1,20 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from pydantic import BaseModel, Field, validator +from pydantic import Field, validator from backend.app.common.enums import MethodType +from backend.app.schemas.base import SchemaBase -class CreatePolicy(BaseModel): +class CreatePolicy(SchemaBase): sub: str = Field(..., description='用户uuid / 角色') path: str = Field(..., description='api 路径') - method: str = Field(default=MethodType.GET, description='请求方法') + method: MethodType = Field(default=MethodType.GET, description='请求方法') @validator('method') def method_validator(cls, v): if not v.isupper(): raise ValueError('请求方式必须大写') - allow_method = MethodType.get_member_values() - if v not in allow_method: - raise ValueError(f'请求方式不合法, 仅支持: {allow_method}') return v @@ -28,7 +26,7 @@ class DeletePolicy(CreatePolicy): pass -class CreateUserRole(BaseModel): +class CreateUserRole(SchemaBase): uuid: str = Field(..., description='用户 uuid') role: str = Field(..., description='角色') @@ -37,7 +35,7 @@ class DeleteUserRole(CreateUserRole): pass -class GetAllPolicy(BaseModel): +class GetAllPolicy(SchemaBase): id: int ptype: str = Field(..., description='规则类型, p 或 g') v0: str = Field(..., description='用户 uuid / 角色') diff --git a/backend/app/schemas/dept.py b/backend/app/schemas/dept.py index 6447120d..096400d9 100644 --- a/backend/app/schemas/dept.py +++ b/backend/app/schemas/dept.py @@ -2,13 +2,14 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel, Field, validator +from pydantic import Field, validator from backend.app.common.enums import StatusType +from backend.app.schemas.base import SchemaBase from backend.app.utils.re_verify import is_phone -class DeptBase(BaseModel): +class DeptBase(SchemaBase): name: str parent_id: int | None = Field(default=None, ge=1, description='菜单父级ID') sort: int = Field(default=0, ge=0, description='排序') diff --git a/backend/app/schemas/dict_data.py b/backend/app/schemas/dict_data.py index aa612c16..8ad33f2d 100644 --- a/backend/app/schemas/dict_data.py +++ b/backend/app/schemas/dict_data.py @@ -2,19 +2,20 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import Field from backend.app.common.enums import StatusType +from backend.app.schemas.base import SchemaBase from backend.app.schemas.dict_type import GetAllDictType -class DictDataBase(BaseModel): +class DictDataBase(SchemaBase): + type_id: int label: str value: str sort: int status: StatusType = Field(default=StatusType.enable) remark: str | None = None - type_id: int class CreateDictData(DictDataBase): diff --git a/backend/app/schemas/dict_type.py b/backend/app/schemas/dict_type.py index fd07b364..94264c81 100644 --- a/backend/app/schemas/dict_type.py +++ b/backend/app/schemas/dict_type.py @@ -2,12 +2,13 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import Field from backend.app.common.enums import StatusType +from backend.app.schemas.base import SchemaBase -class DictTypeBase(BaseModel): +class DictTypeBase(SchemaBase): name: str code: str status: StatusType = Field(default=StatusType.enable) diff --git a/backend/app/schemas/login_log.py b/backend/app/schemas/login_log.py index 2e7ee4d4..ac0d257a 100644 --- a/backend/app/schemas/login_log.py +++ b/backend/app/schemas/login_log.py @@ -2,10 +2,11 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel + +from backend.app.schemas.base import SchemaBase -class LoginLogBase(BaseModel): +class LoginLogBase(SchemaBase): user_uuid: str username: str status: int diff --git a/backend/app/schemas/menu.py b/backend/app/schemas/menu.py index 45e04d1a..8b5b1b2a 100644 --- a/backend/app/schemas/menu.py +++ b/backend/app/schemas/menu.py @@ -2,12 +2,13 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel, Field, validator +from pydantic import Field from backend.app.common.enums import MenuType, StatusType +from backend.app.schemas.base import SchemaBase -class MenuBase(BaseModel): +class MenuBase(SchemaBase): name: str parent_id: int = Field(default=None, ge=1, description='菜单父级ID') sort: int = Field(default=0, ge=0, description='排序') @@ -19,12 +20,6 @@ class MenuBase(BaseModel): status: StatusType = Field(default=StatusType.enable) remark: str | None = None - @validator('menu_type') - def menu_type_validator(cls, v): - if v not in MenuType.get_member_values(): - raise ValueError('菜单类型只能是 0,1,2') - return v - class CreateMenu(MenuBase): pass diff --git a/backend/app/schemas/opera_log.py b/backend/app/schemas/opera_log.py index e6aaba69..bbc46ab6 100644 --- a/backend/app/schemas/opera_log.py +++ b/backend/app/schemas/opera_log.py @@ -2,12 +2,13 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import Field from backend.app.common.enums import StatusType +from backend.app.schemas.base import SchemaBase -class OperaLogBase(BaseModel): +class OperaLogBase(SchemaBase): username: str | None = None method: str title: str diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py index 25068aa3..b4b1ef3c 100644 --- a/backend/app/schemas/role.py +++ b/backend/app/schemas/role.py @@ -2,24 +2,19 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel, Field, validator +from pydantic import Field from backend.app.common.enums import RoleDataScope, StatusType +from backend.app.schemas.base import SchemaBase from backend.app.schemas.menu import GetAllMenu -class RoleBase(BaseModel): +class RoleBase(SchemaBase): name: str data_scope: RoleDataScope = Field(default=RoleDataScope.custom, description='数据范围(1:全部数据权限 2:自定数据权限)') # noqa: E501 status: StatusType = Field(default=StatusType.enable) remark: str | None = None - @validator('data_scope') - def data_scope_validator(cls, v): - if v not in RoleDataScope.get_member_values(): - raise ValueError('数据范围只能是 1 或 2') - return v - class CreateRole(RoleBase): menus: list[int] diff --git a/backend/app/schemas/token.py b/backend/app/schemas/token.py index c55c7902..c4e4045f 100644 --- a/backend/app/schemas/token.py +++ b/backend/app/schemas/token.py @@ -2,18 +2,18 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import BaseModel +from backend.app.schemas.base import SchemaBase from backend.app.schemas.user import GetUserInfoNoRelation -class GetSwaggerToken(BaseModel): +class GetSwaggerToken(SchemaBase): access_token: str token_type: str = 'Bearer' user: GetUserInfoNoRelation -class AccessTokenBase(BaseModel): +class AccessTokenBase(SchemaBase): access_token: str access_token_type: str = 'Bearer' access_token_expire_time: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index e81cfc43..edba1207 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -3,14 +3,15 @@ from datetime import datetime from email_validator import validate_email, EmailNotValidError -from pydantic import BaseModel, HttpUrl, Field, validator +from pydantic import HttpUrl, Field, validator from backend.app.common.enums import StatusType +from backend.app.schemas.base import SchemaBase from backend.app.schemas.dept import GetAllDept from backend.app.schemas.role import GetAllRole -class Auth(BaseModel): +class Auth(SchemaBase): username: str password: str @@ -34,7 +35,7 @@ class CreateUser(Auth): return v -class _UserInfoBase(BaseModel): +class _UserInfoBase(SchemaBase): dept_id: int username: str nickname: str @@ -60,7 +61,7 @@ class UpdateUser(_UserInfoBase): roles: list[int] -class Avatar(BaseModel): +class Avatar(SchemaBase): url: HttpUrl = Field(..., description='头像 http 地址') @@ -87,7 +88,7 @@ class GetAllUserInfo(GetUserInfoNoRelation): orm_mode = True -class ResetPassword(BaseModel): +class ResetPassword(SchemaBase): old_password: str new_password: str confirm_password: str