fastapi-augment 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_augment/__init__.py +24 -0
- fastapi_augment/common/__init__.py +61 -0
- fastapi_augment/common/constants.py +26 -0
- fastapi_augment/common/exception_handlers.py +178 -0
- fastapi_augment/common/exceptions.py +162 -0
- fastapi_augment/common/utils/__init__.py +5 -0
- fastapi_augment/common/utils/strings.py +175 -0
- fastapi_augment/config/__init__.py +8 -0
- fastapi_augment/config/settings.py +104 -0
- fastapi_augment/db/__init__.py +5 -0
- fastapi_augment/db/sqlalchemy/__init__.py +20 -0
- fastapi_augment/db/sqlalchemy/alembic/__init__.py +5 -0
- fastapi_augment/db/sqlalchemy/alembic/env.py +141 -0
- fastapi_augment/db/sqlalchemy/base.py +9 -0
- fastapi_augment/db/sqlalchemy/crud_base.py +426 -0
- fastapi_augment/db/sqlalchemy/engine.py +238 -0
- fastapi_augment/db/sqlalchemy/migrate.py +356 -0
- fastapi_augment/db/sqlalchemy/mixins/__init__.py +18 -0
- fastapi_augment/db/sqlalchemy/mixins/audit.py +61 -0
- fastapi_augment/db/sqlalchemy/mixins/soft_delete.py +80 -0
- fastapi_augment/db/sqlalchemy/mixins/timestamp.py +48 -0
- fastapi_augment/db/sqlalchemy/model_base.py +47 -0
- fastapi_augment/db/sqlalchemy/session.py +160 -0
- fastapi_augment/factory.py +238 -0
- fastapi_augment/health/__init__.py +34 -0
- fastapi_augment/health/checker.py +101 -0
- fastapi_augment/health/checkers.py +109 -0
- fastapi_augment/health/router.py +87 -0
- fastapi_augment/lifespan.py +450 -0
- fastapi_augment/log/__init__.py +26 -0
- fastapi_augment/log/config.py +201 -0
- fastapi_augment/log/factory.py +32 -0
- fastapi_augment/log/filters.py +23 -0
- fastapi_augment/log/handlers.py +81 -0
- fastapi_augment/middlewares/__init__.py +20 -0
- fastapi_augment/middlewares/base.py +79 -0
- fastapi_augment/middlewares/request_id.py +82 -0
- fastapi_augment/openapi.py +110 -0
- fastapi_augment/py.typed +0 -0
- fastapi_augment/schemas/__init__.py +29 -0
- fastapi_augment/schemas/base.py +32 -0
- fastapi_augment/schemas/pagination.py +46 -0
- fastapi_augment/schemas/request.py +28 -0
- fastapi_augment/schemas/response.py +139 -0
- fastapi_augment/schemas/types.py +11 -0
- fastapi_augment-0.1.0.dist-info/METADATA +654 -0
- fastapi_augment-0.1.0.dist-info/RECORD +50 -0
- fastapi_augment-0.1.0.dist-info/WHEEL +5 -0
- fastapi_augment-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_augment-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/8/31
|
|
4
|
+
@Description : fastapi-augment — FastAPI 通用代码工具包,跨项目复用
|
|
5
|
+
"""
|
|
6
|
+
from .factory import create_app
|
|
7
|
+
from .lifespan import (
|
|
8
|
+
HookFunc,
|
|
9
|
+
HookRegistry,
|
|
10
|
+
core_registry,
|
|
11
|
+
fastapi_lifespan,
|
|
12
|
+
clear_hooks
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
# factory
|
|
17
|
+
'create_app',
|
|
18
|
+
# lifespan
|
|
19
|
+
'HookFunc',
|
|
20
|
+
'HookRegistry',
|
|
21
|
+
'core_registry',
|
|
22
|
+
'fastapi_lifespan',
|
|
23
|
+
'clear_hooks'
|
|
24
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/9/4
|
|
4
|
+
@Description : 通用模块 — 异常 / 异常处理器 / 常量
|
|
5
|
+
"""
|
|
6
|
+
from .constants import DEFAULT_ERR_MSG
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
BaseHttpError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
UnauthorizedError,
|
|
11
|
+
PaymentRequiredError,
|
|
12
|
+
ForbiddenError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
MethodNotAllowedError,
|
|
15
|
+
NotAcceptableError,
|
|
16
|
+
RequestTimeoutError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
GoneError,
|
|
19
|
+
PreconditionFailedError,
|
|
20
|
+
PayloadTooLargeError,
|
|
21
|
+
URITooLongError,
|
|
22
|
+
UnsupportedMediaTypeError,
|
|
23
|
+
LockedError,
|
|
24
|
+
TooManyRequestsError
|
|
25
|
+
)
|
|
26
|
+
from .exception_handlers import (
|
|
27
|
+
base_http_error_handler,
|
|
28
|
+
http_exception_handler,
|
|
29
|
+
validation_exception_handler,
|
|
30
|
+
general_exception_handler,
|
|
31
|
+
register_exception_handlers
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# constants
|
|
36
|
+
'DEFAULT_ERR_MSG',
|
|
37
|
+
# exceptions
|
|
38
|
+
'BaseHttpError',
|
|
39
|
+
'BadRequestError',
|
|
40
|
+
'UnauthorizedError',
|
|
41
|
+
'PaymentRequiredError',
|
|
42
|
+
'ForbiddenError',
|
|
43
|
+
'NotFoundError',
|
|
44
|
+
'MethodNotAllowedError',
|
|
45
|
+
'NotAcceptableError',
|
|
46
|
+
'RequestTimeoutError',
|
|
47
|
+
'ConflictError',
|
|
48
|
+
'GoneError',
|
|
49
|
+
'PreconditionFailedError',
|
|
50
|
+
'PayloadTooLargeError',
|
|
51
|
+
'URITooLongError',
|
|
52
|
+
'UnsupportedMediaTypeError',
|
|
53
|
+
'LockedError',
|
|
54
|
+
'TooManyRequestsError',
|
|
55
|
+
# exception handlers
|
|
56
|
+
'base_http_error_handler',
|
|
57
|
+
'http_exception_handler',
|
|
58
|
+
'validation_exception_handler',
|
|
59
|
+
'general_exception_handler',
|
|
60
|
+
'register_exception_handlers'
|
|
61
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/9/4
|
|
4
|
+
@Description :
|
|
5
|
+
"""
|
|
6
|
+
from starlette import status
|
|
7
|
+
|
|
8
|
+
# ===================== 统一默认文案常量(便于统一修改/国际化) =====================
|
|
9
|
+
DEFAULT_ERR_MSG: dict[int, str] = {
|
|
10
|
+
status.HTTP_400_BAD_REQUEST: 'Bad request',
|
|
11
|
+
status.HTTP_401_UNAUTHORIZED: 'Unauthorized',
|
|
12
|
+
status.HTTP_402_PAYMENT_REQUIRED: 'Payment required',
|
|
13
|
+
status.HTTP_403_FORBIDDEN: 'Forbidden',
|
|
14
|
+
status.HTTP_404_NOT_FOUND: 'Not found',
|
|
15
|
+
status.HTTP_405_METHOD_NOT_ALLOWED: 'Method not allowed',
|
|
16
|
+
status.HTTP_406_NOT_ACCEPTABLE: 'Not acceptable',
|
|
17
|
+
status.HTTP_408_REQUEST_TIMEOUT: 'Request timeout',
|
|
18
|
+
status.HTTP_409_CONFLICT: 'Conflict',
|
|
19
|
+
status.HTTP_410_GONE: 'Gone',
|
|
20
|
+
status.HTTP_412_PRECONDITION_FAILED: 'Precondition failed',
|
|
21
|
+
status.HTTP_413_CONTENT_TOO_LARGE: 'Payload too large',
|
|
22
|
+
status.HTTP_414_URI_TOO_LONG: 'URI too long',
|
|
23
|
+
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE: 'Unsupported media type',
|
|
24
|
+
status.HTTP_423_LOCKED: 'Locked',
|
|
25
|
+
status.HTTP_429_TOO_MANY_REQUESTS: 'Too many requests',
|
|
26
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/9/4
|
|
4
|
+
@Description : 全局统一异常处理器
|
|
5
|
+
- 将各类异常转换为统一的 APIResponse JSON 格式
|
|
6
|
+
- 提供 register_exception_handlers 一键注册到 FastAPI 应用
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from logging import getLogger
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from fastapi.exceptions import RequestValidationError
|
|
14
|
+
from fastapi.responses import JSONResponse
|
|
15
|
+
from starlette.exceptions import HTTPException
|
|
16
|
+
from starlette.requests import Request
|
|
17
|
+
|
|
18
|
+
from .exceptions import BaseHttpError
|
|
19
|
+
from ..schemas.response import response_fail
|
|
20
|
+
|
|
21
|
+
_logger = getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ===================== 业务异常处理器 =====================
|
|
25
|
+
|
|
26
|
+
async def base_http_error_handler(
|
|
27
|
+
_request: Request,
|
|
28
|
+
exc: BaseHttpError,
|
|
29
|
+
) -> JSONResponse:
|
|
30
|
+
"""处理 BaseHttpError 及其所有子类(统一业务异常)。
|
|
31
|
+
|
|
32
|
+
将业务异常转换为统一 APIResponse 格式,
|
|
33
|
+
HTTP 状态码与 exc.status_code 一致,
|
|
34
|
+
body.code 同样使用 HTTP 状态码,body.message 使用 exc.detail。
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
_request: Starlette Request 对象
|
|
38
|
+
exc: 业务异常实例
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
统一格式的 JSON 响应
|
|
42
|
+
"""
|
|
43
|
+
body = response_fail(code=exc.status_code, message=exc.detail or '请求错误')
|
|
44
|
+
return JSONResponse(
|
|
45
|
+
status_code=exc.status_code,
|
|
46
|
+
content=body.model_dump(mode='json', by_alias=True),
|
|
47
|
+
headers=exc.headers,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ===================== 通用 HTTP 异常处理器 =====================
|
|
52
|
+
|
|
53
|
+
async def http_exception_handler(
|
|
54
|
+
_request: Request,
|
|
55
|
+
exc: HTTPException,
|
|
56
|
+
) -> JSONResponse:
|
|
57
|
+
"""处理 Starlette/FastAPI 原生 HTTPException。
|
|
58
|
+
|
|
59
|
+
覆盖 FastAPI 默认处理器,将响应格式统一为 APIResponse。
|
|
60
|
+
注意:BaseHttpError 继承自 HTTPException,但 FastAPI 会优先匹配
|
|
61
|
+
更具体的处理器(base_http_error_handler),所以此处不会拦截业务异常。
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
_request: Starlette Request 对象
|
|
65
|
+
exc: HTTPException 实例
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
统一格式的 JSON 响应
|
|
69
|
+
"""
|
|
70
|
+
body = response_fail(
|
|
71
|
+
code=exc.status_code,
|
|
72
|
+
message=exc.detail if isinstance(exc.detail, str) else str(exc.detail or ''),
|
|
73
|
+
)
|
|
74
|
+
return JSONResponse(
|
|
75
|
+
status_code=exc.status_code,
|
|
76
|
+
content=body.model_dump(mode='json', by_alias=True),
|
|
77
|
+
headers=getattr(exc, 'headers', None),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ===================== 请求校验异常处理器 =====================
|
|
82
|
+
|
|
83
|
+
async def validation_exception_handler(
|
|
84
|
+
_request: Request,
|
|
85
|
+
exc: RequestValidationError,
|
|
86
|
+
) -> JSONResponse:
|
|
87
|
+
"""处理 Pydantic 请求参数校验异常(422)。
|
|
88
|
+
|
|
89
|
+
将校验错误详情提取到 extra.errors 中,方便前端定位具体字段。
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
_request: Starlette Request 对象
|
|
93
|
+
exc: RequestValidationError 实例
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
统一格式的 JSON 响应(HTTP 422)
|
|
97
|
+
"""
|
|
98
|
+
errors = []
|
|
99
|
+
for error in exc.errors():
|
|
100
|
+
field = ' -> '.join(str(loc) for loc in error['loc'])
|
|
101
|
+
errors.append({
|
|
102
|
+
'field': field,
|
|
103
|
+
'message': error['msg'],
|
|
104
|
+
'type': error['type'],
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
body = response_fail(
|
|
108
|
+
code=422,
|
|
109
|
+
message='请求参数校验失败',
|
|
110
|
+
extra={'errors': errors},
|
|
111
|
+
)
|
|
112
|
+
return JSONResponse(
|
|
113
|
+
status_code=422,
|
|
114
|
+
content=body.model_dump(mode='json', by_alias=True),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ===================== 未知异常处理器 =====================
|
|
119
|
+
|
|
120
|
+
async def general_exception_handler(
|
|
121
|
+
_request: Request,
|
|
122
|
+
exc: Exception,
|
|
123
|
+
) -> JSONResponse:
|
|
124
|
+
"""处理所有未被捕获的异常(兜底)。
|
|
125
|
+
|
|
126
|
+
记录完整异常日志(含堆栈),但响应体只返回通用提示,
|
|
127
|
+
避免将内部实现细节(堆栈、SQL 等)暴露给客户端。
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
_request: Starlette Request 对象
|
|
131
|
+
exc: 未捕获的异常实例
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
统一格式的 JSON 响应(HTTP 500)
|
|
135
|
+
"""
|
|
136
|
+
_logger.exception(f'[未捕获异常] {type(exc).__name__}: {exc}')
|
|
137
|
+
body = response_fail(code=500, message='服务器内部错误')
|
|
138
|
+
return JSONResponse(
|
|
139
|
+
status_code=500,
|
|
140
|
+
content=body.model_dump(mode='json', by_alias=True),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ===================== 一键注册 =====================
|
|
145
|
+
|
|
146
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
147
|
+
"""将全部统一异常处理器注册到 FastAPI 应用。
|
|
148
|
+
|
|
149
|
+
注册后,以下异常会被转换为统一的 APIResponse 格式:
|
|
150
|
+
- BaseHttpError 及子类 → 对应 HTTP 状态码
|
|
151
|
+
- Starlette HTTPException → 对应 HTTP 状态码
|
|
152
|
+
- RequestValidationError → HTTP 422 + 字段级错误详情
|
|
153
|
+
- Exception(兜底) → HTTP 500
|
|
154
|
+
|
|
155
|
+
注意:create_app 工厂默认自动调用本函数(register_exceptions=True),
|
|
156
|
+
通常无需手动注册。仅在以下场景需要手动调用:
|
|
157
|
+
- 未使用 create_app 工厂,自行构建 FastAPI 实例时
|
|
158
|
+
- 传入 register_exceptions=False 禁用后,想选择性注册时
|
|
159
|
+
|
|
160
|
+
Example::
|
|
161
|
+
|
|
162
|
+
# 方式一:工厂自动注册(推荐)
|
|
163
|
+
app = create_app(title='My Service')
|
|
164
|
+
|
|
165
|
+
# 方式二:手动注册(未使用工厂时)
|
|
166
|
+
from fastapi import FastAPI
|
|
167
|
+
app = FastAPI()
|
|
168
|
+
register_exception_handlers(app)
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
app: FastAPI 应用实例
|
|
172
|
+
"""
|
|
173
|
+
# 注意注册顺序:BaseHttpError 必须在 HTTPException 之前注册,
|
|
174
|
+
# 因为 BaseHttpError 继承自 HTTPException,FastAPI 优先匹配更具体的异常类型
|
|
175
|
+
app.add_exception_handler(BaseHttpError, base_http_error_handler) # type: ignore
|
|
176
|
+
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
|
|
177
|
+
app.add_exception_handler(RequestValidationError, validation_exception_handler) # type: ignore
|
|
178
|
+
app.add_exception_handler(Exception, general_exception_handler) # type: ignore
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/9/4
|
|
4
|
+
@Description : 统一业务异常
|
|
5
|
+
"""
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from starlette import status
|
|
9
|
+
from starlette.exceptions import HTTPException
|
|
10
|
+
|
|
11
|
+
from .constants import DEFAULT_ERR_MSG
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ===================== 通用基类:统一封装 detail + headers 逻辑 =====================
|
|
15
|
+
class BaseHttpError(HTTPException):
|
|
16
|
+
"""统一HTTP异常基类,所有4xx异常继承此类,原生兼容starlette.HTTPException.
|
|
17
|
+
|
|
18
|
+
子类只需声明 ``_status_code`` 类变量,无需重写 __init__::
|
|
19
|
+
|
|
20
|
+
class NotFoundError(BaseHttpError):
|
|
21
|
+
_status_code = 404
|
|
22
|
+
|
|
23
|
+
raise NotFoundError() # 使用默认文案
|
|
24
|
+
raise NotFoundError(detail='用户不存在') # 自定义提示
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
_status_code: 子类声明的HTTP状态码
|
|
28
|
+
status_code: HTTP状态码(继承自HTTPException)
|
|
29
|
+
detail: 异常提示文案
|
|
30
|
+
headers: 响应附加http头
|
|
31
|
+
"""
|
|
32
|
+
__slots__ = ()
|
|
33
|
+
_status_code: int | None = None
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
status_code: int | None = None,
|
|
38
|
+
detail: str | None = None,
|
|
39
|
+
headers: dict[str, Any] | None = None,
|
|
40
|
+
):
|
|
41
|
+
"""初始化http业务异常.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
status_code: http响应状态码,不传则读取子类的 _status_code 类变量
|
|
45
|
+
detail: 自定义错误提示,不传使用内置默认文案
|
|
46
|
+
headers: 附加响应头
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
KeyError: 传入未在DEFAULT_ERR_MSG定义的status_code
|
|
50
|
+
"""
|
|
51
|
+
code = status_code if status_code is not None else self._status_code
|
|
52
|
+
if code is None:
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f'{type(self).__name__} 必须声明 _status_code 类变量或传入 status_code 参数'
|
|
55
|
+
)
|
|
56
|
+
if code not in DEFAULT_ERR_MSG:
|
|
57
|
+
raise KeyError(f'status_code {code} not defined in DEFAULT_ERR_MSG')
|
|
58
|
+
msg = detail or DEFAULT_ERR_MSG[code]
|
|
59
|
+
super().__init__(status_code=code, detail=msg, headers=headers)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ===================== 各类4xx异常子类(极简声明,无重复__init__) =====================
|
|
63
|
+
# 子类只需声明 _status_code 类变量,__init__ 由基类统一处理
|
|
64
|
+
class BadRequestError(BaseHttpError):
|
|
65
|
+
"""400 请求错误."""
|
|
66
|
+
_status_code = status.HTTP_400_BAD_REQUEST
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class UnauthorizedError(BaseHttpError):
|
|
70
|
+
"""401 未授权错误."""
|
|
71
|
+
_status_code = status.HTTP_401_UNAUTHORIZED
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PaymentRequiredError(BaseHttpError):
|
|
75
|
+
"""402 需要付费错误."""
|
|
76
|
+
_status_code = status.HTTP_402_PAYMENT_REQUIRED
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ForbiddenError(BaseHttpError):
|
|
80
|
+
"""403 禁止访问错误."""
|
|
81
|
+
_status_code = status.HTTP_403_FORBIDDEN
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class NotFoundError(BaseHttpError):
|
|
85
|
+
"""404 未找到资源."""
|
|
86
|
+
_status_code = status.HTTP_404_NOT_FOUND
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MethodNotAllowedError(BaseHttpError):
|
|
90
|
+
"""405 请求方法不允许."""
|
|
91
|
+
_status_code = status.HTTP_405_METHOD_NOT_ALLOWED
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NotAcceptableError(BaseHttpError):
|
|
95
|
+
"""406 客户端不支持返回格式."""
|
|
96
|
+
_status_code = status.HTTP_406_NOT_ACCEPTABLE
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RequestTimeoutError(BaseHttpError):
|
|
100
|
+
"""408 请求超时."""
|
|
101
|
+
_status_code = status.HTTP_408_REQUEST_TIMEOUT
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ConflictError(BaseHttpError):
|
|
105
|
+
"""409 资源冲突."""
|
|
106
|
+
_status_code = status.HTTP_409_CONFLICT
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class GoneError(BaseHttpError):
|
|
110
|
+
"""410 资源已永久删除."""
|
|
111
|
+
_status_code = status.HTTP_410_GONE
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PreconditionFailedError(BaseHttpError):
|
|
115
|
+
"""412 前置校验失败."""
|
|
116
|
+
_status_code = status.HTTP_412_PRECONDITION_FAILED
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PayloadTooLargeError(BaseHttpError):
|
|
120
|
+
"""413 请求体过大."""
|
|
121
|
+
_status_code = status.HTTP_413_CONTENT_TOO_LARGE
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class URITooLongError(BaseHttpError):
|
|
125
|
+
"""414 URI链接过长."""
|
|
126
|
+
_status_code = status.HTTP_414_URI_TOO_LONG
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class UnsupportedMediaTypeError(BaseHttpError):
|
|
130
|
+
"""415 不支持的请求媒体类型."""
|
|
131
|
+
_status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class LockedError(BaseHttpError):
|
|
135
|
+
"""423 资源锁定."""
|
|
136
|
+
_status_code = status.HTTP_423_LOCKED
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TooManyRequestsError(BaseHttpError):
|
|
140
|
+
"""429 请求过于频繁(限流专用,支持retry_after快捷参数)."""
|
|
141
|
+
_status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
detail: str | None = None,
|
|
146
|
+
retry_after: int | None = None,
|
|
147
|
+
headers: dict[str, Any] | None = None,
|
|
148
|
+
):
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
detail: 自定义错误提示
|
|
153
|
+
retry_after: 设置 Retry‑After 响应头,单位秒
|
|
154
|
+
headers: 自定义附加响应头
|
|
155
|
+
"""
|
|
156
|
+
final_headers: dict[str, Any] = dict(headers) if headers is not None else {}
|
|
157
|
+
if retry_after is not None:
|
|
158
|
+
final_headers['Retry-After'] = str(retry_after)
|
|
159
|
+
super().__init__(
|
|
160
|
+
detail=detail,
|
|
161
|
+
headers=final_headers if final_headers else None,
|
|
162
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/9/4
|
|
4
|
+
@Description :
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import string
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── 类型定义 ──
|
|
13
|
+
class SupportsWriteStr(Protocol):
|
|
14
|
+
def write(self, s: str) -> object: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SupportsReadBytes(Protocol):
|
|
18
|
+
def read(self) -> bytes: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── orjson 可选加载 ──
|
|
22
|
+
ORJSON_INSTALLED: bool = False
|
|
23
|
+
ORJSON_DEFAULT_OPTS: int = 0
|
|
24
|
+
_oj_dumps: Any = None
|
|
25
|
+
_oj_loads: Any = None
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import orjson as _orjson # type: ignore[import-untyped]
|
|
29
|
+
ORJSON_INSTALLED = True
|
|
30
|
+
ORJSON_DEFAULT_OPTS = _orjson.OPT_SERIALIZE_NUMPY | _orjson.OPT_UTC_Z
|
|
31
|
+
_oj_dumps = _orjson.dumps
|
|
32
|
+
_oj_loads = _orjson.loads
|
|
33
|
+
except ImportError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# orjson OPT_INDENT_2 的常量值,避免直接依赖 orjson 安装
|
|
38
|
+
_ORJSON_OPT_INDENT_2 = 0x04
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── 字符串转换 ──
|
|
42
|
+
def camel_to_snake(s: str) -> str:
|
|
43
|
+
"""驼峰转下划线(支持连续大写)"""
|
|
44
|
+
if not s:
|
|
45
|
+
return s
|
|
46
|
+
result: list[str] = []
|
|
47
|
+
prev_lower = False
|
|
48
|
+
for i, c in enumerate(s):
|
|
49
|
+
if c.isupper():
|
|
50
|
+
if i > 0 and (prev_lower or (i + 1 < len(s) and s[i + 1].islower())):
|
|
51
|
+
result.append('_')
|
|
52
|
+
result.append(c.lower())
|
|
53
|
+
prev_lower = False
|
|
54
|
+
else:
|
|
55
|
+
result.append(c)
|
|
56
|
+
prev_lower = c.islower()
|
|
57
|
+
return ''.join(result)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def snake_to_camel(s: str) -> str:
|
|
61
|
+
"""下划线转驼峰(首字母大写)"""
|
|
62
|
+
if not s:
|
|
63
|
+
return s
|
|
64
|
+
return ''.join(part.capitalize() for part in s.split('_'))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── 随机字符串 ──
|
|
68
|
+
def random_string(
|
|
69
|
+
length: int = 16,
|
|
70
|
+
chars: str | None = None,
|
|
71
|
+
exclude: str | None = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""生成随机字符串,支持自定义字符集
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: 字符集为空时(exclude 排除了所有字符)
|
|
77
|
+
"""
|
|
78
|
+
if chars is None:
|
|
79
|
+
chars = string.ascii_letters + string.digits
|
|
80
|
+
if exclude:
|
|
81
|
+
chars = ''.join(c for c in chars if c not in exclude)
|
|
82
|
+
if not chars:
|
|
83
|
+
raise ValueError('字符集为空,无法生成随机字符串(exclude 排除了所有字符)')
|
|
84
|
+
return ''.join(random.choices(chars, k=length))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── JSON 序列化 ──
|
|
88
|
+
def json_dumps(obj: Any, compact: bool = True) -> str:
|
|
89
|
+
"""高性能 JSON 序列化
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
obj: 要序列化的对象
|
|
93
|
+
compact: 是否压缩输出(去除空格)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
JSON 字符串
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
TypeError: 当对象不可序列化时
|
|
100
|
+
"""
|
|
101
|
+
if ORJSON_INSTALLED:
|
|
102
|
+
try:
|
|
103
|
+
opts = ORJSON_DEFAULT_OPTS
|
|
104
|
+
if not compact:
|
|
105
|
+
opts |= _ORJSON_OPT_INDENT_2
|
|
106
|
+
return _oj_dumps(obj, option=opts).decode('utf-8')
|
|
107
|
+
except (TypeError, ValueError) as e:
|
|
108
|
+
raise TypeError(f'JSON 序列化失败: {e}') from e
|
|
109
|
+
if compact:
|
|
110
|
+
return json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
|
|
111
|
+
return json.dumps(obj, ensure_ascii=False, indent=2)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def json_dump(obj: Any, fp: SupportsWriteStr, compact: bool = True) -> None:
|
|
115
|
+
"""高性能 JSON 写入文件
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
obj: 要序列化的对象
|
|
119
|
+
fp: 可写文件对象
|
|
120
|
+
compact: 是否压缩输出
|
|
121
|
+
"""
|
|
122
|
+
fp.write(json_dumps(obj, compact=compact))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def json_loads(s: str | bytes) -> Any:
|
|
126
|
+
"""高性能 JSON 反序列化
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
s: JSON 字符串或字节
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
反序列化后的 Python 对象
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
json.JSONDecodeError: JSON 解析失败时
|
|
136
|
+
"""
|
|
137
|
+
if ORJSON_INSTALLED:
|
|
138
|
+
try:
|
|
139
|
+
return _oj_loads(s)
|
|
140
|
+
except (TypeError, ValueError) as e:
|
|
141
|
+
text = s.decode('utf-8', errors='replace') if isinstance(s, bytes) else s
|
|
142
|
+
raise json.JSONDecodeError(str(e), text, 0) from e
|
|
143
|
+
if isinstance(s, bytes):
|
|
144
|
+
s = s.decode('utf-8')
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(s)
|
|
147
|
+
except json.JSONDecodeError:
|
|
148
|
+
raise
|
|
149
|
+
except (TypeError, ValueError) as e:
|
|
150
|
+
raise json.JSONDecodeError(str(e), s, 0) from e
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def json_load(fp: SupportsReadBytes) -> Any:
|
|
154
|
+
"""从文件读取 JSON
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
fp: 可读字节文件对象
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
反序列化后的 Python 对象
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
json.JSONDecodeError: JSON 解析失败时
|
|
164
|
+
OSError: 文件读取失败时
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
raw = fp.read()
|
|
168
|
+
except (OSError, IOError) as e:
|
|
169
|
+
raise OSError(f'文件读取失败: {e}') from e
|
|
170
|
+
if ORJSON_INSTALLED:
|
|
171
|
+
try:
|
|
172
|
+
return _oj_loads(raw)
|
|
173
|
+
except (TypeError, ValueError) as e:
|
|
174
|
+
raise json.JSONDecodeError(str(e), raw.decode('utf-8', errors='replace'), 0) from e
|
|
175
|
+
return json.loads(raw.decode('utf-8') if isinstance(raw, bytes) else raw)
|