diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 435b0dc9..9bcfce2a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: check-yaml - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.1.6 + rev: v0.1.8 hooks: - id: ruff args: diff --git a/README.md b/README.md index e22add82..a2f299a8 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ See a preview of some of the screenshots - [x] Global asynchronous design with async/await + asgiref - [x] Follows Restful API specification - [x] Global SQLAlchemy 2.0 syntax +- [x] Pydantic v1 and v2 (different branches) - [x] Casbin RBAC access control model - [x] Celery asynchronous tasks - [x] JWT middleware whitelist authentication diff --git a/README.zh-CN.md b/README.zh-CN.md index fa8e06da..c773b72c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,6 +40,7 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三 - [x] async/await + asgiref 的全局异步设计 - [x] 遵循 Restful API 规范 - [x] 全局 SQLAlchemy 2.0 语法 +- [x] Pydantic v1 和 v2 (不同分支) - [x] Casbin RBAC 访问控制模型 - [x] Celery 异步任务 - [x] JWT 中间件白名单认证 diff --git a/backend/app/common/exception/exception_handler.py b/backend/app/common/exception/exception_handler.py index 566f1280..d7aeaa19 100644 --- a/backend/app/common/exception/exception_handler.py +++ b/backend/app/common/exception/exception_handler.py @@ -1,11 +1,9 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from json import JSONDecodeError - from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from pydantic import ValidationError -from pydantic.errors import EnumMemberError, WrongConstantError # noqa: ignore +from pydantic.errors import PydanticUserError from starlette.exceptions import HTTPException from starlette.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse @@ -15,6 +13,48 @@ from backend.app.common.log import log from backend.app.common.response.response_code import CustomResponse, CustomResponseCode, StandardResponseCode from backend.app.common.response.response_schema import ResponseModel, response_base from backend.app.core.conf import settings +from backend.app.schemas.base import ( + CUSTOM_USAGE_ERROR_MESSAGES, + CUSTOM_VALIDATION_ERROR_MESSAGES, +) + + +async def _validation_exception_handler(request: Request, e: RequestValidationError | ValidationError): + """ + 数据验证异常处理 + + :param e: + :return: + """ + errors = [] + for error in e.errors(): + custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type']) + if custom_message: + ctx = error.get('ctx') + error['msg'] = custom_message.format(**ctx) if ctx else custom_message + errors.append(error) + error = errors[0] + if error.get('type') == 'json_invalid': + message = 'json解析失败' + else: + error_input = error.get('input') + field = str(error.get('loc')[-1]) + error_msg = error.get('msg') + message = f'{field} {error_msg},输入:{error_input}' + msg = f'请求参数非法: {message}' + data = {'errors': errors} if settings.ENVIRONMENT == 'dev' else None + content = ResponseModel( + code=StandardResponseCode.HTTP_422, + msg=msg, + ).model_dump() + request.state.__request_validation_exception__ = content # 用于在中间件中获取异常信息 + return JSONResponse( + status_code=422, + content=await response_base.fail( + res=CustomResponse(code=StandardResponseCode.HTTP_422, msg=msg), + data=data, + ), + ) def register_exception(app: FastAPI): @@ -27,7 +67,7 @@ def register_exception(app: FastAPI): :param exc: :return: """ - content = ResponseModel(code=exc.status_code, msg=exc.detail).dict() + content = ResponseModel(code=exc.status_code, msg=exc.detail).model_dump() request.state.__request_http_exception__ = content # 用于在中间件中获取异常信息 return JSONResponse( status_code=StandardResponseCode.HTTP_400, @@ -38,57 +78,40 @@ def register_exception(app: FastAPI): ) @app.exception_handler(RequestValidationError) - async def validation_exception_handler(request: Request, exc: RequestValidationError): + async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError): """ - 数据验证异常处理 + fastapi 数据验证异常处理 + + :param request: + :param exc: + :return: + """ + return await _validation_exception_handler(request, exc) + + @app.exception_handler(ValidationError) + async def pydantic_validation_exception_handler(request: Request, exc: ValidationError): + """ + pydantic 数据验证异常处理 + + :param request: + :param exc: + :return: + """ + return await _validation_exception_handler(request, exc) + + @app.exception_handler(PydanticUserError) + async def pydantic_user_error_handler(request: Request, exc: PydanticUserError): + """ + Pydantic 用户异常处理 :param request: :param exc: :return: """ - message = '' - data = {} - raw_error = exc.raw_errors[0] - raw_exc = raw_error.exc - if isinstance(raw_exc, JSONDecodeError): - message = 'json解析失败' - elif 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 - # 处理特殊类型异常模板信息 -> backend/app/schemas/base.py: SCHEMA_ERROR_MSG_TEMPLATES - for sub_raw_error in raw_exc.raw_errors: - sub_raw_exc = sub_raw_error.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 - ) - else: - sub_raw_exc.__dict__['permitted'] = ', '.join( - repr(v) - for v in sub_raw_exc.permitted # type: ignore - ) - # 处理异常信息 - error = raw_exc.errors()[0] - field = str(error.get('loc')[-1]) - msg = error.get('msg') - message = f'{data.get(field, field) if field != "__root__" else ""} {msg}.' - msg = '请求参数非法' if message == '' else f'请求参数非法: {message}' - data = {'errors': exc.errors()} if settings.ENVIRONMENT == 'dev' else None - content = ResponseModel( - code=StandardResponseCode.HTTP_422, - msg=msg, - ).dict() - request.state.__request_validation_exception__ = content # 用于在中间件中获取异常信息 return JSONResponse( - status_code=StandardResponseCode.HTTP_422, + status_code=StandardResponseCode.HTTP_500, content=await response_base.fail( - res=CustomResponse(code=StandardResponseCode.HTTP_422, msg=msg), - data=data, + res=CustomResponse(code=StandardResponseCode.HTTP_500, msg=CUSTOM_USAGE_ERROR_MESSAGES.get(exc.code)) ), ) @@ -127,7 +150,7 @@ def register_exception(app: FastAPI): code=exc.code, msg=str(exc.msg), data=exc.data if exc.data else None, - ).dict(), + ).model_dump(), background=exc.background, ) else: @@ -137,9 +160,9 @@ def register_exception(app: FastAPI): log.error(traceback.format_exc()) return JSONResponse( status_code=StandardResponseCode.HTTP_500, - content=ResponseModel(code=500, msg=str(exc)).dict() + content=ResponseModel(code=500, msg=str(exc)).model_dump() if settings.ENVIRONMENT == 'dev' - else await response_base.fail(res=CustomResponseCode.HTTP_500), + else await response_base.fail(CustomResponseCode.HTTP_500), ) if settings.MIDDLEWARE_CORS: @@ -156,10 +179,10 @@ def register_exception(app: FastAPI): :return: """ if isinstance(exc, BaseExceptionMixin): - content = ResponseModel(code=exc.code, msg=exc.msg, data=exc.data).dict() + content = ResponseModel(code=exc.code, msg=exc.msg, data=exc.data).model_dump() else: content = ( - ResponseModel(code=StandardResponseCode.HTTP_500, msg=str(exc)).dict() + ResponseModel(code=StandardResponseCode.HTTP_500, msg=str(exc)).model_dump() if settings.ENVIRONMENT == 'dev' else await response_base.fail(CustomResponseCode.HTTP_500) ) diff --git a/backend/app/common/pagination.py b/backend/app/common/pagination.py index 1e574215..5ae7074d 100644 --- a/backend/app/common/pagination.py +++ b/backend/app/common/pagination.py @@ -12,7 +12,6 @@ from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination.links.bases import create_links from pydantic import BaseModel -from pydantic.generics import GenericModel if TYPE_CHECKING: from sqlalchemy import Select @@ -61,12 +60,12 @@ class _Page(AbstractPage[T], Generic[T]): 'next': {'page': f'{page + 1}', 'size': f'{size}'} if (page + 1) <= total_pages else None, 'prev': {'page': f'{page - 1}', 'size': f'{size}'} if (page - 1) >= 1 else None, } - ).dict() + ).model_dump() return cls(items=items, total=total, page=params.page, size=params.size, total_pages=total_pages, links=links) -class _PageData(GenericModel, Generic[DataT]): +class _PageData(BaseModel, Generic[DataT]): page_data: DataT | None = None @@ -80,7 +79,7 @@ async def paging_data(db: AsyncSession, select: Select, page_data_schema: Schema :return: """ _paginate = await paginate(db, select) - page_data = _PageData[_Page[page_data_schema]](page_data=_paginate).dict()['page_data'] + page_data = _PageData[_Page[page_data_schema]](page_data=_paginate).model_dump()['page_data'] return page_data diff --git a/backend/app/common/rbac.py b/backend/app/common/rbac.py index 834fd9c0..fe2a5a7f 100644 --- a/backend/app/common/rbac.py +++ b/backend/app/common/rbac.py @@ -59,6 +59,7 @@ class RBAC: method = request.method if settings.MENU_PERMISSION: # 菜单权限校验 + # TODO: 改用流行方案,自定义接口权限字段标识 path_auth = path.split(f'{settings.API_V1_STR}/')[-1].replace('/', ':') + f':{method}' menu_perms = [] forbid_menu_perms = [] diff --git a/backend/app/common/response/response_schema.py b/backend/app/common/response/response_schema.py index 49fc4340..983f5280 100644 --- a/backend/app/common/response/response_schema.py +++ b/backend/app/common/response/response_schema.py @@ -3,11 +3,11 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, ConfigDict from backend.app.common.response.response_code import CustomResponse, CustomResponseCode from backend.app.core.conf import settings -from backend.app.utils.encoders import jsonable_encoder _ExcludeData = set[int | str] | dict[int | str, Any] @@ -39,13 +39,13 @@ class ResponseModel(BaseModel): return ResponseModel(code=res.code, msg=res.msg, data={'test': 'test'}) """ # noqa: E501 + # TODO: json_encoders 配置失效: https://github.com/tiangolo/fastapi/discussions/10252 + model_config = ConfigDict(json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)}) + code: int = CustomResponseCode.HTTP_200.code msg: str = CustomResponseCode.HTTP_200.msg data: Any | None = None - class Config: - json_encoders = {datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)} - class ResponseBase: """ @@ -82,6 +82,7 @@ class ResponseBase: :return: """ if data is not None: + # TODO: custom_encoder 配置失效: https://github.com/tiangolo/fastapi/discussions/10252 custom_encoder = {datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)} kwargs.update({'custom_encoder': custom_encoder}) data = jsonable_encoder(data, exclude=exclude, **kwargs) diff --git a/backend/app/core/conf.py b/backend/app/core/conf.py index bdf0bbdc..b3ccd26b 100644 --- a/backend/app/core/conf.py +++ b/backend/app/core/conf.py @@ -3,10 +3,13 @@ from functools import lru_cache from typing import Literal -from pydantic import BaseSettings, root_validator +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') + # Env Config ENVIRONMENT: Literal['dev', 'pro'] @@ -51,7 +54,8 @@ class Settings(BaseSettings): REDOCS_URL: str | None = f'{API_V1_STR}/redocs' OPENAPI_URL: str | None = f'{API_V1_STR}/openapi' - @root_validator + @model_validator(mode='before') + @classmethod def validate_openapi_url(cls, values): if values['ENVIRONMENT'] == 'pro': values['OPENAPI_URL'] = None @@ -145,7 +149,12 @@ class Settings(BaseSettings): f'{API_V1_STR}/auth/swagger_login', ] OPERA_LOG_ENCRYPT: int = 1 # 0: AES (性能损耗); 1: md5; 2: ItsDangerous; 3: 不加密, others: 替换为 ****** - OPERA_LOG_ENCRYPT_INCLUDE: list[str] = ['password', 'old_password', 'new_password', 'confirm_password'] + OPERA_LOG_ENCRYPT_INCLUDE: list[str] = [ + 'password', + 'old_password', + 'new_password', + 'confirm_password', + ] # Ip location IP_LOCATION_REDIS_PREFIX: str = 'fba_ip_location' @@ -164,21 +173,16 @@ class Settings(BaseSettings): }, } - @root_validator + @model_validator(mode='before') def validate_celery_broker(cls, values): if values['ENVIRONMENT'] == 'pro': values['CELERY_BROKER'] = 'rabbitmq' return values - class Config: - # https://docs.pydantic.dev/usage/settings/#dotenv-env-support - env_file = '.env' - env_file_encoding = 'utf-8' - @lru_cache def get_settings(): - """读取配置优化写法""" + """读取配置优化""" return Settings() diff --git a/backend/app/crud/base.py b/backend/app/crud/base.py index 7d2859d8..05f7288d 100644 --- a/backend/app/crud/base.py +++ b/backend/app/crud/base.py @@ -59,9 +59,9 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): :return: """ if user_id: - create_data = self.model(**obj_in.dict(), create_user=user_id) + create_data = self.model(**obj_in.model_dump(), create_user=user_id) else: - create_data = self.model(**obj_in.dict()) + create_data = self.model(**obj_in.model_dump()) db.add(create_data) async def update_( @@ -79,7 +79,7 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): if isinstance(obj_in, dict): update_data = obj_in else: - update_data = obj_in.dict(exclude_unset=True) + update_data = obj_in.model_dump(exclude_unset=True) if user_id: update_data.update({'update_user': user_id}) result = await db.execute(update(self.model).where(self.model.id == pk).values(**update_data)) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 708d870c..db963e56 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import Field, validator +from pydantic import ConfigDict, Field, field_validator from backend.app.common.enums import MethodType from backend.app.schemas.base import SchemaBase @@ -14,7 +14,8 @@ class ApiBase(SchemaBase): path: str = Field(..., description='api路径') remark: str | None = None - @validator('method') + @field_validator('method') + @classmethod def method_validator(cls, v): if not v.isupper(): raise ValueError('请求方式必须大写') @@ -30,9 +31,8 @@ class UpdateApi(ApiBase): class GetAllApi(ApiBase): + model_config = ConfigDict(from_attributes=True) + id: int created_time: datetime updated_time: datetime | None = None - - class Config: - orm_mode = True diff --git a/backend/app/schemas/base.py b/backend/app/schemas/base.py index f90e6ce3..6c92649d 100644 --- a/backend/app/schemas/base.py +++ b/backend/app/schemas/base.py @@ -1,87 +1,146 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict +from pydantic_extra_types.phone_numbers import PhoneNumber -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})', # noqa: E501 - '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}', +# 自定义验证错误信息不包含验证预期内容(也就是输入内容),受支持的预期内容字段参考以下链接 +# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 +# 替换预期内容字段方式,参考以下链接 +# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232 +CUSTOM_VALIDATION_ERROR_MESSAGES = { + 'arguments_type': '参数类型输入错误', + 'assertion_error': '断言执行错误', + 'bool_parsing': '布尔值输入解析错误', + 'bool_type': '布尔值类型输入错误', + 'bytes_too_long': '字节长度输入过长', + 'bytes_too_short': '字节长度输入过短', + 'bytes_type': '字节类型输入错误', + 'callable_type': '可调用对象类型输入错误', + 'dataclass_exact_type': '数据类实例类型输入错误', + 'dataclass_type': '数据类类型输入错误', + 'date_from_datetime_inexact': '日期分量输入非零', + 'date_from_datetime_parsing': '日期输入解析错误', + 'date_future': '日期输入非将来时', + 'date_parsing': '日期输入验证错误', + 'date_past': '日期输入非过去时', + 'date_type': '日期类型输入错误', + 'datetime_future': '日期时间输入非将来时间', + 'datetime_object_invalid': '日期时间输入对象无效', + 'datetime_parsing': '日期时间输入解析错误', + 'datetime_past': '日期时间输入非过去时间', + 'datetime_type': '日期时间类型输入错误', + 'decimal_max_digits': '小数位数输入过多', + 'decimal_max_places': '小数位数输入错误', + 'decimal_parsing': '小数输入解析错误', + 'decimal_type': '小数类型输入错误', + 'decimal_whole_digits': '小数位数输入错误', + 'dict_type': '字典类型输入错误', + 'enum': '枚举成员输入错误,允许 {expected}', + 'extra_forbidden': '禁止额外字段输入', + 'finite_number': '有限值输入错误', + 'float_parsing': '浮点数输入解析错误', + 'float_type': '浮点数类型输入错误', + 'frozen_field': '冻结字段输入错误', + 'frozen_instance': '冻结实例禁止修改', + 'frozen_set_type': '冻结类型禁止输入', + 'get_attribute_error': '获取属性错误', + 'greater_than': '输入值过大', + 'greater_than_equal': '输入值过大或相等', + 'int_from_float': '整数类型输入错误', + 'int_parsing': '整数输入解析错误', + 'int_parsing_size': '整数输入解析长度错误', + 'int_type': '整数类型输入错误', + 'invalid_key': '输入无效键值', + 'is_instance_of': '类型实例输入错误', + 'is_subclass_of': '类型子类输入错误', + 'iterable_type': '可迭代类型输入错误', + 'iteration_error': '迭代值输入错误', + 'json_invalid': 'JSON 字符串输入错误', + 'json_type': 'JSON 类型输入错误', + 'less_than': '输入值过小', + 'less_than_equal': '输入值过小或相等', + 'list_type': '列表类型输入错误', + 'literal_error': '字面值输入错误', + 'mapping_type': '映射类型输入错误', + 'missing': '缺少必填字段', + 'missing_argument': '缺少参数', + 'missing_keyword_only_argument': '缺少关键字参数', + 'missing_positional_only_argument': '缺少位置参数', + 'model_attributes_type': '模型属性类型输入错误', + 'model_type': '模型实例输入错误', + 'multiple_argument_values': '参数值输入过多', + 'multiple_of': '输入值非倍数', + 'no_such_attribute': '分配无效属性值', + 'none_required': '输入值必须为 None', + 'recursion_loop': '输入循环赋值', + 'set_type': '集合类型输入错误', + 'string_pattern_mismatch': '字符串约束模式输入不匹配', + 'string_sub_type': '字符串子类型(非严格实例)输入错误', + 'string_too_long': '字符串输入过长', + 'string_too_short': '字符串输入过短', + 'string_type': '字符串类型输入错误', + 'string_unicode': '字符串输入非 Unicode', + 'time_delta_parsing': '时间差输入解析错误', + 'time_delta_type': '时间差类型输入错误', + 'time_parsing': '时间输入解析错误', + 'time_type': '时间类型输入错误', + 'timezone_aware': '缺少时区输入信息', + 'timezone_naive': '禁止时区输入信息', + 'too_long': '输入过长', + 'too_short': '输入过短', + 'tuple_type': '元组类型输入错误', + 'unexpected_keyword_argument': '输入意外关键字参数', + 'unexpected_positional_argument': '输入意外位置参数', + 'union_tag_invalid': '联合类型字面值输入错误', + 'union_tag_not_found': '联合类型参数输入未找到', + 'url_parsing': 'URL 输入解析错误', + 'url_scheme': 'URL 输入方案错误', + 'url_syntax_violation': 'URL 输入语法错误', + 'url_too_long': 'URL 输入过长', + 'url_type': 'URL 类型输入错误', + 'uuid_parsing': 'UUID 输入解析错误', + 'uuid_type': 'UUID 类型输入错误', + 'uuid_version': 'UUID 版本类型输入错误', + 'value_error': '值输入错误', +} + +CUSTOM_USAGE_ERROR_MESSAGES = { + 'class-not-fully-defined': '类属性类型未完全定义', + 'custom-json-schema': '__modify_schema__ 方法在V2中已被弃用', + 'decorator-missing-field': '定义了无效字段验证器', + 'discriminator-no-field': '鉴别器字段未全部定义', + 'discriminator-alias-type': '鉴别器字段使用非字符串类型定义', + 'discriminator-needs-literal': '鉴别器字段需要使用字面值定义', + 'discriminator-alias': '鉴别器字段别名定义不一致', + 'discriminator-validator': '鉴别器字段禁止定义字段验证器', + 'model-field-overridden': '无类型定义字段禁止重写', + 'model-field-missing-annotation': '缺少字段类型定义', + 'config-both': '重复定义配置项', + 'removed-kwargs': '调用已移除的关键字配置参数', + 'invalid-for-json-schema': '存在无效的 JSON 类型', + 'base-model-instantiated': '禁止实例化基础模型', + 'undefined-annotation': '缺少类型定义', + 'schema-for-unknown-type': '未知类型定义', + 'create-model-field-definitions': '字段定义错误', + 'create-model-config-base': '配置项定义错误', + 'validator-no-fields': '字段验证器未指定字段', + 'validator-invalid-fields': '字段验证器字段定义错误', + 'validator-instance-method': '字段验证器必须为类方法', + 'model-serializer-instance-method': '序列化器必须为实例方法', + 'validator-v1-signature': 'V1字段验证器错误已被弃用', + 'validator-signature': '字段验证器签名错误', + 'field-serializer-signature': '字段序列化器签名无法识别', + 'model-serializer-signature': '模型序列化器签名无法识别', + 'multiple-field-serializers': '字段序列化器重复定义', + 'invalid_annotated_type': '无效的类型定义', + 'type-adapter-config-unused': '类型适配器配置项定义错误', + 'root-model-extra': '根模型禁止定义额外字段', } +class CustomPhoneNumber(PhoneNumber): + default_region_code = 'CN' + + class SchemaBase(BaseModel): - class Config: - use_enum_values = True - # 错误信息模板对于模型嵌套无效 - # https://github.com/pydantic/pydantic/issues/5651 - error_msg_templates = SCHEMA_ERROR_MSG_TEMPLATES + model_config = ConfigDict(use_enum_values=True) diff --git a/backend/app/schemas/casbin_rule.py b/backend/app/schemas/casbin_rule.py index f6042035..28f03efc 100644 --- a/backend/app/schemas/casbin_rule.py +++ b/backend/app/schemas/casbin_rule.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from pydantic import Field, validator +from pydantic import ConfigDict, Field, field_validator from backend.app.common.enums import MethodType from backend.app.schemas.base import SchemaBase @@ -11,7 +11,8 @@ class CreatePolicy(SchemaBase): path: str = Field(..., description='api 路径') method: MethodType = Field(default=MethodType.GET, description='请求方法') - @validator('method') + @field_validator('method') + @classmethod def method_validator(cls, v): if not v.isupper(): raise ValueError('请求方式必须大写') @@ -45,6 +46,8 @@ class DeleteAllUserRoles(SchemaBase): class GetAllPolicy(SchemaBase): + model_config = ConfigDict(from_attributes=True) + id: int ptype: str = Field(..., description='规则类型, p 或 g') v0: str = Field(..., description='用户 uuid / 角色') @@ -53,6 +56,3 @@ class GetAllPolicy(SchemaBase): v3: str | None = None v4: str | None = None v5: str | None = None - - class Config: - orm_mode = True diff --git a/backend/app/schemas/dept.py b/backend/app/schemas/dept.py index ab4bbe77..f9a859d0 100644 --- a/backend/app/schemas/dept.py +++ b/backend/app/schemas/dept.py @@ -2,11 +2,10 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import Field, validator +from pydantic import ConfigDict, EmailStr, Field from backend.app.common.enums import StatusType -from backend.app.schemas.base import SchemaBase -from backend.app.utils.re_verify import is_phone +from backend.app.schemas.base import CustomPhoneNumber, SchemaBase class DeptBase(SchemaBase): @@ -14,28 +13,10 @@ class DeptBase(SchemaBase): parent_id: int | None = Field(default=None, description='菜单父级ID') sort: int = Field(default=0, ge=0, description='排序') leader: str | None = None - phone: str | None = None - email: str | None = None + phone: CustomPhoneNumber | None = None + email: EmailStr | None = None status: StatusType = Field(default=StatusType.enable) - @validator('phone') - def phone_validator(cls, v): - if v is not None and not v.isdigit(): - if not is_phone(v): - raise ValueError('手机号码输入有误') - return v - - @validator('email') - def email_validator(cls, v): - if v is not None: - from email_validator import EmailNotValidError, validate_email - - try: - validate_email(v, check_deliverability=False).email - except EmailNotValidError: - raise ValueError('邮箱格式错误') - return v - class CreateDept(DeptBase): pass @@ -46,10 +27,9 @@ class UpdateDept(DeptBase): class GetAllDept(DeptBase): + model_config = ConfigDict(from_attributes=True) + id: int del_flag: bool created_time: datetime updated_time: datetime | None = None - - class Config: - orm_mode = True diff --git a/backend/app/schemas/dict_data.py b/backend/app/schemas/dict_data.py index 51c6776c..42862300 100644 --- a/backend/app/schemas/dict_data.py +++ b/backend/app/schemas/dict_data.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import Field +from pydantic import ConfigDict, Field from backend.app.common.enums import StatusType from backend.app.schemas.base import SchemaBase @@ -27,10 +27,9 @@ class UpdateDictData(DictDataBase): class GetAllDictData(DictDataBase): + model_config = ConfigDict(from_attributes=True) + id: int type: GetAllDictType created_time: datetime updated_time: datetime | None = None - - class Config: - orm_mode = True diff --git a/backend/app/schemas/dict_type.py b/backend/app/schemas/dict_type.py index 41de4599..be947bc5 100644 --- a/backend/app/schemas/dict_type.py +++ b/backend/app/schemas/dict_type.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import Field +from pydantic import ConfigDict, Field from backend.app.common.enums import StatusType from backend.app.schemas.base import SchemaBase @@ -24,9 +24,8 @@ class UpdateDictType(DictTypeBase): class GetAllDictType(DictTypeBase): + model_config = ConfigDict(from_attributes=True) + id: int created_time: datetime updated_time: datetime | None = None - - class Config: - orm_mode = True diff --git a/backend/app/schemas/login_log.py b/backend/app/schemas/login_log.py index d7556b8e..e88a255d 100644 --- a/backend/app/schemas/login_log.py +++ b/backend/app/schemas/login_log.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- from datetime import datetime +from pydantic import ConfigDict + from backend.app.schemas.base import SchemaBase @@ -30,8 +32,7 @@ class UpdateLoginLog(LoginLogBase): class GetAllLoginLog(LoginLogBase): + model_config = ConfigDict(from_attributes=True) + id: int created_time: datetime - - class Config: - orm_mode = True diff --git a/backend/app/schemas/menu.py b/backend/app/schemas/menu.py index 485fa988..4754a594 100644 --- a/backend/app/schemas/menu.py +++ b/backend/app/schemas/menu.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import Field +from pydantic import ConfigDict, Field from backend.app.common.enums import MenuType, StatusType from backend.app.schemas.base import SchemaBase @@ -33,9 +33,8 @@ class UpdateMenu(MenuBase): class GetAllMenu(MenuBase): + model_config = ConfigDict(from_attributes=True) + id: int created_time: datetime updated_time: datetime | None = None - - class Config: - orm_mode = True diff --git a/backend/app/schemas/opera_log.py b/backend/app/schemas/opera_log.py index 35553f98..e4bf3995 100644 --- a/backend/app/schemas/opera_log.py +++ b/backend/app/schemas/opera_log.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import Field +from pydantic import ConfigDict, Field from backend.app.common.enums import StatusType from backend.app.schemas.base import SchemaBase @@ -38,8 +38,7 @@ class UpdateOperaLog(OperaLogBase): class GetAllOperaLog(OperaLogBase): + model_config = ConfigDict(from_attributes=True) + id: int created_time: datetime - - class Config: - orm_mode = True diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py index 1f37c177..44b3b771 100644 --- a/backend/app/schemas/role.py +++ b/backend/app/schemas/role.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from datetime import datetime -from pydantic import Field +from pydantic import ConfigDict, Field from backend.app.common.enums import RoleDataScopeType, StatusType from backend.app.schemas.base import SchemaBase @@ -13,7 +13,7 @@ class RoleBase(SchemaBase): name: str data_scope: RoleDataScopeType = Field( default=RoleDataScopeType.custom, description='权限范围(1:全部数据权限 2:自定义数据权限)' - ) # E501 + ) status: StatusType = Field(default=StatusType.enable) remark: str | None = None @@ -31,10 +31,9 @@ class UpdateRoleMenu(SchemaBase): class GetAllRole(RoleBase): + model_config = ConfigDict(from_attributes=True) + id: int created_time: datetime updated_time: datetime | None = None menus: list[GetAllMenu] - - class Config: - orm_mode = True diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index a04cd9c5..f4d63d81 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -2,11 +2,10 @@ # -*- coding: utf-8 -*- from datetime import datetime -from email_validator import EmailNotValidError, validate_email -from pydantic import Field, HttpUrl, root_validator, validator +from pydantic import ConfigDict, EmailStr, Field, HttpUrl, model_validator from backend.app.common.enums import StatusType -from backend.app.schemas.base import SchemaBase +from backend.app.schemas.base import CustomPhoneNumber, SchemaBase from backend.app.schemas.dept import GetAllDept from backend.app.schemas.role import GetAllRole @@ -22,52 +21,22 @@ class AuthLogin(Auth): class RegisterUser(Auth): nickname: str | None = None - email: str = Field(..., example='user@example.com') - - @validator('email') - def email_validate(cls, v): - try: - validate_email(v, check_deliverability=False).email - except EmailNotValidError: - raise ValueError('邮箱格式错误') - return v + email: EmailStr = Field(..., example='user@example.com') class AddUser(Auth): dept_id: int roles: list[int] nickname: str | None = None - email: str = Field(..., example='user@example.com') - - @validator('email') - def email_validate(cls, v): - try: - validate_email(v, check_deliverability=False).email - except EmailNotValidError: - raise ValueError('邮箱格式错误') - return v + email: EmailStr = Field(..., example='user@example.com') class _UserInfoBase(SchemaBase): dept_id: int | None = None username: str nickname: str - email: str = Field(..., example='user@example.com') - phone: str | None = None - - @validator('email') - def email_validate(cls, v): - try: - validate_email(v, check_deliverability=False).email - except EmailNotValidError: - raise ValueError('邮箱格式错误') - return v - - @validator('phone') - def phone_validate(cls, v): - if v is not None and not v.isdigit(): - raise ValueError('手机号格式错误') - return v + email: EmailStr = Field(..., example='user@example.com') + phone: CustomPhoneNumber | None = None class UpdateUser(_UserInfoBase): @@ -83,6 +52,8 @@ class Avatar(SchemaBase): class GetUserInfoNoRelation(_UserInfoBase): + model_config = ConfigDict(from_attributes=True) + dept_id: int | None = None id: int uuid: str @@ -94,32 +65,27 @@ class GetUserInfoNoRelation(_UserInfoBase): join_time: datetime = None last_login_time: datetime | None = None - class Config: - orm_mode = True - class GetAllUserInfo(GetUserInfoNoRelation): + model_config = ConfigDict(from_attributes=True) + dept: GetAllDept | None = None roles: list[GetAllRole] - class Config: - orm_mode = True - class GetCurrentUserInfo(GetAllUserInfo): - @root_validator - def handel(cls, values): - """处理部门和角色""" - dept = values.get('dept') - if dept: - values['dept'] = dept.name - roles = values.get('roles') - if roles: - values['roles'] = [role.name for role in roles] - return values + model_config = ConfigDict(from_attributes=True) - class Config: - orm_mode = True + @model_validator(mode='after') + def handel(self, values): + """处理部门和角色""" + dept = self.dept + if dept: + self.dept = dept.name # type: ignore + roles = self.roles + if roles: + self.roles = [role.name for role in roles] # type: ignore + return values class ResetPassword(SchemaBase): diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 43492129..b5ae0ad9 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -182,6 +182,7 @@ class UserService: token = await get_token(request) user_id = request.user.id latest_multi_login = await UserDao.get_multi_login(db, pk) + # TODO: 删除用户 refresh token, 此操作需要传参,暂时不考虑实现 # 当前用户修改自身时(普通/超级),除当前token外,其他token失效 if pk == user_id: if not latest_multi_login: diff --git a/backend/app/utils/encoders.py b/backend/app/utils/encoders.py deleted file mode 100644 index 874ec946..00000000 --- a/backend/app/utils/encoders.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -import dataclasses - -from collections import defaultdict -from enum import Enum -from pathlib import PurePath -from types import GeneratorType -from typing import Any, Callable, Iterable - -from pydantic import BaseModel -from pydantic.json import ENCODERS_BY_TYPE - -SetIntStr = set[int | str] -DictIntStrAny = dict[int | str, Any] - -PRIMITIVE_TYPE = (str, bool, int, float, type(None)) -ARRAY_TYPES = (list, set, frozenset, GeneratorType, tuple) - - -def _generate_encoders_by_class_tuples( - type_encoder_map: dict[Any, Callable[[Any], Any]] -) -> dict[Callable[[Any], Any], tuple[Any, ...]]: - encoders_by_class_tuples: dict[Callable[[Any], Any], tuple[Any, ...]] = defaultdict(tuple) - for type_, encoder in type_encoder_map.items(): - encoders_by_class_tuples[encoder] += (type_,) - return encoders_by_class_tuples - - -encoders_by_class_tuples = _generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) - - -def jsonable_encoder( - obj: Any, - include: SetIntStr | DictIntStrAny | None = None, - exclude: SetIntStr | DictIntStrAny | None = None, - by_alias: bool = True, - exclude_unset: bool = False, - exclude_defaults: bool = False, - exclude_none: bool = False, - custom_encoder: dict[Any, Callable[[Any], Any]] | None = None, - sqlalchemy_safe: bool = True, -) -> Any: - custom_encoder = custom_encoder or {} - if custom_encoder: - if type(obj) in custom_encoder: - return custom_encoder[type(obj)](obj) - else: - for encoder_type, encoder_instance in custom_encoder.items(): - if isinstance(obj, encoder_type): - return encoder_instance(obj) - if include is not None and not isinstance(include, (set, dict)): - include = set(include) - if exclude is not None and not isinstance(exclude, (set, dict)): - exclude = set(exclude) - - def encode_dict(obj: Any) -> Any: - encoded_dict = {} - allowed_keys = set(obj.keys()) - if include is not None: - allowed_keys &= set(include) - if exclude is not None: - allowed_keys -= set(exclude) - - for key, value in obj.items(): - if ( - (not sqlalchemy_safe or (not isinstance(key, str)) or (not key.startswith('_sa'))) - and (value is not None or not exclude_none) - and key in allowed_keys - ): - if isinstance(key, PRIMITIVE_TYPE): - encoded_key = key - else: - encoded_key = jsonable_encoder( - key, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_none=exclude_none, - custom_encoder=custom_encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) - encoded_value = jsonable_encoder( - value, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_none=exclude_none, - custom_encoder=custom_encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) - encoded_dict[encoded_key] = encoded_value - return encoded_dict - - def encode_array(obj: Iterable[Any]) -> Any: - encoded_list = [] - for item in obj: - encoded_list.append( - jsonable_encoder( - item, - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - custom_encoder=custom_encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) - ) - return encoded_list - - def encode_base_model(obj: BaseModel) -> Any: - encoder = getattr(obj.__config__, 'json_encoders', {}) - if custom_encoder: - encoder.update(custom_encoder) - - obj_dict = obj.dict( - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_none=exclude_none, - exclude_defaults=exclude_defaults, - ) - if '__root__' in obj_dict: - obj_dict = obj_dict['__root__'] - - return jsonable_encoder( - obj_dict, - exclude_none=exclude_none, - exclude_defaults=exclude_defaults, - custom_encoder=encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) - - # Use type comparisons on common types before expensive isinstance checks - if type(obj) in PRIMITIVE_TYPE: - return obj - if isinstance(obj, dict): - return encode_dict(obj) - if type(obj) in ARRAY_TYPES: - return encode_array(obj) - - if isinstance(obj, BaseModel): - return encode_base_model(obj) - if dataclasses.is_dataclass(obj): - obj_dict = dataclasses.asdict(obj) - return encode_dict(obj_dict) - if isinstance(obj, Enum): - return obj.value - if isinstance(obj, PurePath): - return str(obj) - - # Back up for Inherited types - if isinstance(obj, PRIMITIVE_TYPE): - return obj - if isinstance(obj, dict): - return encode_dict(obj) - if isinstance(obj, ARRAY_TYPES): - return encode_array(obj) - - if type(obj) in ENCODERS_BY_TYPE: - return ENCODERS_BY_TYPE[type(obj)](obj) - for encoder, classes_tuple in encoders_by_class_tuples.items(): - if isinstance(obj, classes_tuple): - return encoder(obj) - - try: - data = dict(obj) - except Exception as e: - errors: list[Exception] = [e] - try: - data = vars(obj) - except Exception as e: - errors.append(e) - raise ValueError(errors) - - return encode_dict(data) diff --git a/requirements.txt b/requirements.txt index c6606903..96b7b17b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,40 +1,41 @@ -aiofiles==0.8.0 -aiosmtplib==1.1.6 -alembic==1.7.4 -APScheduler==3.8.1 +aiofiles==23.2.1 +aiosmtplib==3.0.1 +alembic==1.13.0 asgiref==3.7.2 -asyncmy==0.2.5 -bcrypt==3.2.2 -casbin==1.23.0 -casbin_async_sqlalchemy_adapter==1.3.0 -celery==5.3.4 -cryptography==41.0.6 -email-validator==1.1.3 -Faker==9.7.1 +asyncmy==0.2.9 +bcrypt==4.0.1 +casbin==1.33.0 +casbin-async-sqlalchemy-adapter==1.3.0 +celery==5.3.6 +cryptography==41.0.7 +email-validator==2.0.0 fast-captcha==0.2.1 -fastapi==0.99.0 +fastapi==0.105.0 fastapi-limiter==0.1.5 -fastapi-pagination==0.12.1 -gunicorn==20.1.0 -httpx==0.23.0 +fastapi-pagination==0.12.13 +gunicorn==21.2.0 +httpx==0.25.2 itsdangerous==2.1.2 -loguru==0.6.0 +loguru==0.7.2 passlib==1.7.4 path==15.1.2 +phonenumbers==8.13.27 pre-commit==3.2.2 -psutil==5.9.5 -pydantic==1.10.5 +psutil==5.9.6 +pydantic==2.5.2 +pydantic-extra-types==2.2.0 +pydantic-settings==2.1.0 pytest==7.2.2 pytest-pretty==1.2.0 python-jose==3.3.0 -python-multipart==0.0.5 +python-multipart==0.0.6 pytz==2023.3 redis[hiredis]==4.5.5 -ruff==0.0.262 -SQLAlchemy==2.0.8 +ruff==0.1.8 +SQLAlchemy==2.0.23 starlette==0.27.0 supervisor==4.2.5 -user_agents==2.2.0 -uvicorn[standard]==0.22.0 -wait-for-it==2.2.1 +user-agents==2.2.0 +uvicorn[standard]==0.24.0 +wait-for-it==2.2.2 XdbSearchIP==1.0.2