dwyeapi 0.5.2__tar.gz → 0.7.0__tar.gz
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.
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/PKG-INFO +1 -1
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/pyproject.toml +1 -1
- dwyeapi-0.7.0/src/dwyeapi/__init__.py +35 -0
- dwyeapi-0.7.0/src/dwyeapi/cache.py +49 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/config.py +12 -6
- dwyeapi-0.7.0/src/dwyeapi/database.py +73 -0
- dwyeapi-0.7.0/src/dwyeapi/dependencies.py +29 -0
- dwyeapi-0.7.0/src/dwyeapi/exceptions.py +203 -0
- dwyeapi-0.7.0/src/dwyeapi/health.py +56 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/logger.py +35 -37
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/masking.py +27 -13
- dwyeapi-0.7.0/src/dwyeapi/pagination.py +42 -0
- dwyeapi-0.7.0/src/dwyeapi/response.py +87 -0
- dwyeapi-0.7.0/src/dwyeapi/security.py +92 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/__init__.py +24 -24
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/context.py +45 -45
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/model.py +9 -6
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/pool.py +14 -15
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/registry.py +18 -18
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/router.py +40 -80
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/schema.py +3 -10
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/service.py +40 -32
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/tasks/worker.py +17 -18
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/PKG-INFO +1 -1
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/SOURCES.txt +2 -0
- dwyeapi-0.7.0/tests/test_exceptions.py +190 -0
- dwyeapi-0.7.0/tests/test_health.py +77 -0
- dwyeapi-0.7.0/tests/test_response.py +113 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_tasks.py +11 -6
- dwyeapi-0.5.2/src/dwyeapi/__init__.py +0 -21
- dwyeapi-0.5.2/src/dwyeapi/cache.py +0 -30
- dwyeapi-0.5.2/src/dwyeapi/database.py +0 -42
- dwyeapi-0.5.2/src/dwyeapi/dependencies.py +0 -16
- dwyeapi-0.5.2/src/dwyeapi/exceptions.py +0 -75
- dwyeapi-0.5.2/src/dwyeapi/pagination.py +0 -25
- dwyeapi-0.5.2/src/dwyeapi/response.py +0 -19
- dwyeapi-0.5.2/src/dwyeapi/security.py +0 -50
- dwyeapi-0.5.2/tests/test_exceptions.py +0 -93
- dwyeapi-0.5.2/tests/test_response.py +0 -43
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/README.md +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/setup.cfg +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/dt.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/__init__.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/__init__.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/aliyun.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/base.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/config.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/factory.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/mock.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/protocol.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/resend.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/__init__.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/aliyun.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/base.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/config.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/factory.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/mock.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/protocol.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/requires.txt +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/top_level.txt +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_cache.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_config.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_database.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_dependencies.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_logger.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_masking.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_pagination.py +0 -0
- {dwyeapi-0.5.2 → dwyeapi-0.7.0}/tests/test_security.py +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""dwyeapi — 轻量 FastAPI 基础设施包。
|
|
2
|
+
|
|
3
|
+
业务项目只通过本包的公开 API 访问配置、数据库、缓存、安全等基础能力,
|
|
4
|
+
避免在各项目中重复手写相同逻辑。
|
|
5
|
+
|
|
6
|
+
Modules:
|
|
7
|
+
config — BaseSettings + 运行环境识别 API(is_dev/is_prod/get_environment)
|
|
8
|
+
exceptions — AppError 异常体系 + FastAPI 全局 handler 注册
|
|
9
|
+
database — 异步 SQLAlchemy engine/session 工厂 + Base + TimestampMixin
|
|
10
|
+
dependencies — FastAPI 依赖注入工厂(如 get_db)
|
|
11
|
+
security — JWT 令牌与 bcrypt 密码哈希工具(无状态)
|
|
12
|
+
cache — 异步 Redis 连接管理
|
|
13
|
+
response — 统一 API 响应信封(ApiResponse[T] + PageData[T])
|
|
14
|
+
pagination — 分页参数与 offset/limit 计算
|
|
15
|
+
logger — 基于 loguru 的全局日志,支持按天与按大小轮转、stdlib 拦截
|
|
16
|
+
tasks — 异步任务系统(基于 ARQ,需安装 [tasks] extra)
|
|
17
|
+
masking — PII 数据脱敏工具
|
|
18
|
+
health — 健康检查路由工厂(只探活,不探依赖)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from . import health, logger
|
|
22
|
+
from .config import BaseSettings, get_environment, is_dev, is_prod
|
|
23
|
+
from .response import ApiResponse, PageData
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ApiResponse",
|
|
27
|
+
"BaseSettings",
|
|
28
|
+
"PageData",
|
|
29
|
+
"get_environment",
|
|
30
|
+
"health",
|
|
31
|
+
"is_dev",
|
|
32
|
+
"is_prod",
|
|
33
|
+
"logger",
|
|
34
|
+
]
|
|
35
|
+
__version__ = "0.7.0"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""异步 Redis 连接管理。
|
|
2
|
+
|
|
3
|
+
模块级单例的懒连接:``configure`` 阶段只记录 URL,首次 ``get_redis`` 调用才真正建立连接,
|
|
4
|
+
``close_redis`` 负责在应用关闭时释放资源,避免连接泄漏。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import redis.asyncio as aioredis
|
|
8
|
+
|
|
9
|
+
_redis_url: str | None = None
|
|
10
|
+
_redis: aioredis.Redis | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def configure(redis_url: str) -> None:
|
|
14
|
+
"""设置全局 Redis 连接 URL。
|
|
15
|
+
|
|
16
|
+
通常在 FastAPI 的 ``lifespan`` 启动阶段调用一次。
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
redis_url: 标准 Redis 连接串,如 ``redis://localhost:6379/0``。
|
|
20
|
+
"""
|
|
21
|
+
global _redis_url
|
|
22
|
+
_redis_url = redis_url
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def get_redis() -> aioredis.Redis:
|
|
26
|
+
"""获取共享的异步 Redis 连接,首次调用时懒创建。
|
|
27
|
+
|
|
28
|
+
``decode_responses=True`` 让返回值直接为 ``str``,避免业务代码反复 decode。
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
全局复用的 ``aioredis.Redis`` 实例。
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
RuntimeError: 未先调用 ``configure`` 时抛出,提示装配缺失。
|
|
35
|
+
"""
|
|
36
|
+
if _redis_url is None:
|
|
37
|
+
raise RuntimeError("dwyeapi.cache not configured — call cache.configure(redis_url) first")
|
|
38
|
+
global _redis
|
|
39
|
+
if _redis is None:
|
|
40
|
+
_redis = aioredis.from_url(_redis_url, decode_responses=True)
|
|
41
|
+
return _redis
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def close_redis() -> None:
|
|
45
|
+
"""关闭全局 Redis 连接,未连接时也可安全重复调用。"""
|
|
46
|
+
global _redis
|
|
47
|
+
if _redis is not None:
|
|
48
|
+
await _redis.close()
|
|
49
|
+
_redis = None
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""FastAPI 项目通用 Pydantic Settings 基类。
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
除了 database_url / redis_url / secret_key 等基础字段外,还提供运行环境识别 API:
|
|
4
4
|
|
|
5
|
-
- ``environment`` 字段: ``"dev"`` | ``"prod"``,默认 ``"prod"``
|
|
6
|
-
- ``get_environment()`` / ``is_dev()`` / ``is_prod()
|
|
5
|
+
- ``environment`` 字段: ``"dev"`` | ``"prod"``,默认 ``"prod"``(误配置时偏保守)
|
|
6
|
+
- ``get_environment()`` / ``is_dev()`` / ``is_prod()``:业务代码读取当前环境
|
|
7
7
|
|
|
8
|
-
业务项目实例化 ``Settings()``
|
|
8
|
+
业务项目实例化 ``Settings()`` 时会自动把 environment 值写入模块级全局变量,
|
|
9
|
+
之后任何地方 import 即可读,避免把 Settings 实例到处传递。
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from typing import Literal
|
|
@@ -45,7 +46,12 @@ def set_current_environment(env: Environment) -> None:
|
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
class BaseSettings(PydanticBaseSettings):
|
|
48
|
-
"""
|
|
49
|
+
"""FastAPI 项目共用的配置基类。
|
|
50
|
+
|
|
51
|
+
业务项目通过继承并追加项目专属字段使用,内置的 database_url / redis_url /
|
|
52
|
+
secret_key 等字段覆盖最常用的基础设施需求,不需要每个项目重复声明。
|
|
53
|
+
配置来源默认为 ``.env`` 文件,嵌套字段使用双下划线 ``__`` 分隔。
|
|
54
|
+
"""
|
|
49
55
|
|
|
50
56
|
database_url: str
|
|
51
57
|
redis_url: str
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""异步 SQLAlchemy 引擎/会话工厂、声明式 Base 与时间戳 Mixin。
|
|
2
|
+
|
|
3
|
+
集中收敛异步数据库接入方式,业务项目通过 ``create_async_engine_factory``
|
|
4
|
+
和 ``create_session_factory`` 装配引擎,继承 ``Base`` 与 ``TimestampMixin`` 写模型。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import func
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
11
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Base(DeclarativeBase):
|
|
15
|
+
"""全局 ORM 声明式基类,所有业务模型直接继承此类以共享元数据。"""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TimestampMixin:
|
|
21
|
+
"""为 ORM 模型补齐 ``created_at`` / ``updated_at`` 时间戳字段的 Mixin。
|
|
22
|
+
|
|
23
|
+
时间值由数据库层生成(``server_default`` + ``onupdate``),避免应用时钟漂移
|
|
24
|
+
影响排序,也保证事务回滚时时间戳与数据保持一致。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
28
|
+
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_async_engine_factory(
|
|
32
|
+
database_url: str,
|
|
33
|
+
pool_size: int = 5,
|
|
34
|
+
max_overflow: int = 10,
|
|
35
|
+
echo: bool = False,
|
|
36
|
+
) -> AsyncEngine:
|
|
37
|
+
"""创建异步 SQLAlchemy 引擎。
|
|
38
|
+
|
|
39
|
+
SQLite 驱动不支持连接池参数,因此针对 ``sqlite`` 前缀 URL 单独走无池路径;
|
|
40
|
+
其他数据库统一按传入的 pool_size/max_overflow 构建连接池。
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
database_url: 异步驱动的连接串,例如 ``postgresql+asyncpg://...``。
|
|
44
|
+
pool_size: 常驻连接数(SQLite 忽略)。
|
|
45
|
+
max_overflow: 突发时允许的额外连接数(SQLite 忽略)。
|
|
46
|
+
echo: 是否把 SQL 语句打到日志,一般仅调试开启。
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
构造好的 ``AsyncEngine`` 实例。
|
|
50
|
+
"""
|
|
51
|
+
if database_url.startswith("sqlite"):
|
|
52
|
+
return create_async_engine(database_url, echo=echo)
|
|
53
|
+
return create_async_engine(
|
|
54
|
+
database_url,
|
|
55
|
+
pool_size=pool_size,
|
|
56
|
+
max_overflow=max_overflow,
|
|
57
|
+
echo=echo,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
|
62
|
+
"""基于引擎构造异步 Session 工厂。
|
|
63
|
+
|
|
64
|
+
``expire_on_commit=False`` 让 commit 后仍可访问 ORM 对象的属性,
|
|
65
|
+
符合 FastAPI "请求内多次访问对象字段" 的典型用法。
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
engine: 已创建的 ``AsyncEngine``。
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
绑定到该引擎的 ``async_sessionmaker``。
|
|
72
|
+
"""
|
|
73
|
+
return async_sessionmaker(engine, expire_on_commit=False)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""FastAPI 依赖注入工厂。
|
|
2
|
+
|
|
3
|
+
基础设施不直接持有 session 工厂,而是通过工厂函数让业务项目装配,
|
|
4
|
+
保证每个项目的 session_factory 由各自的 Settings/engine 决定。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_get_db(session_factory: async_sessionmaker[AsyncSession]):
|
|
13
|
+
"""基于 session 工厂创建 ``get_db`` FastAPI 依赖。
|
|
14
|
+
|
|
15
|
+
返回的闭包可直接用作 ``Depends(get_db)``,请求结束时自动关闭 session。
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
session_factory: 业务侧 ``async_sessionmaker`` 实例。
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
一个可作为 FastAPI 依赖的异步生成器函数。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
async def get_db() -> AsyncGenerator[AsyncSession]:
|
|
25
|
+
"""产出一次请求期间使用的数据库会话,退出时自动关闭。"""
|
|
26
|
+
async with session_factory() as session:
|
|
27
|
+
yield session
|
|
28
|
+
|
|
29
|
+
return get_db
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""AppError 异常体系以及 FastAPI 全局 handler 注册。
|
|
2
|
+
|
|
3
|
+
service 层统一抛业务异常(AppError 子类),不感知 HTTP 概念,
|
|
4
|
+
由本模块注册的 handler 统一转换为 ``ApiResponse`` 信封格式的 JSON 响应,
|
|
5
|
+
保持错误响应和成功响应的结构一致。
|
|
6
|
+
|
|
7
|
+
本模块除了映射 AppError 子类外,还会接管 FastAPI 默认的非业务异常:
|
|
8
|
+
|
|
9
|
+
- ``RequestValidationError`` — 请求参数校验失败,返回结构化字段错误列表。
|
|
10
|
+
- ``HTTPException`` — 第三方库(如 OAuth2)主动抛出的 HTTP 异常,透传状态码与 headers。
|
|
11
|
+
- 未捕获的 ``Exception`` — 兜底 500,堆栈打日志,生产脱敏、开发含详情。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, HTTPException, Request, status
|
|
17
|
+
from fastapi.exceptions import RequestValidationError
|
|
18
|
+
from fastapi.responses import JSONResponse
|
|
19
|
+
|
|
20
|
+
from dwyeapi.config import is_dev
|
|
21
|
+
from dwyeapi.logger import get_logger
|
|
22
|
+
from dwyeapi.response import ApiResponse
|
|
23
|
+
|
|
24
|
+
_logger = get_logger("dwyeapi.exceptions")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AppError(Exception):
|
|
28
|
+
"""应用级异常基类。
|
|
29
|
+
|
|
30
|
+
所有业务异常都应直接或间接继承本类,便于 ``register_exception_handlers``
|
|
31
|
+
按类型统一捕获并返回规范的 ``ApiResponse`` 信封 JSON 响应。
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, code: str = "UNKNOWN_ERROR") -> None:
|
|
35
|
+
"""初始化异常。
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
message: 面向前端/调用方的提示信息,不得回显用户原始输入。
|
|
39
|
+
code: 机器可读的错误码,供前端做分支处理,默认 ``"UNKNOWN_ERROR"``。
|
|
40
|
+
"""
|
|
41
|
+
self.message = message
|
|
42
|
+
self.code = code
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotFoundError(AppError):
|
|
46
|
+
"""资源不存在异常,handler 会转换为 HTTP 404 响应。"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, resource: str) -> None:
|
|
49
|
+
"""初始化资源不存在异常。
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
resource: 资源名(如 "用户"、"任务"),会被拼接为"{resource}不存在"的消息。
|
|
53
|
+
"""
|
|
54
|
+
super().__init__(message=f"{resource}不存在", code="NOT_FOUND")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BusinessError(AppError):
|
|
58
|
+
"""业务规则校验失败异常,handler 会转换为 HTTP 422 响应。
|
|
59
|
+
|
|
60
|
+
调用方通常传入自定义 ``code``(如 ``INSUFFICIENT_BALANCE``)以便前端区分具体原因。
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PermissionDeniedError(AppError):
|
|
67
|
+
"""权限不足异常,handler 会转换为 HTTP 403 响应。"""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
"""初始化权限不足异常,使用固定中文提示。"""
|
|
71
|
+
super().__init__(message="权限不足", code="PERMISSION_DENIED")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AuthenticationError(AppError):
|
|
75
|
+
"""认证失败或缺失异常,handler 会转换为 HTTP 401 响应。"""
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
"""初始化认证失败异常,使用固定中文提示。"""
|
|
79
|
+
super().__init__(message="认证失败", code="AUTHENTICATION_FAILED")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _error_response(
|
|
83
|
+
status_code: int,
|
|
84
|
+
code: str,
|
|
85
|
+
message: str,
|
|
86
|
+
data: Any | None = None,
|
|
87
|
+
headers: dict[str, str] | None = None,
|
|
88
|
+
) -> JSONResponse:
|
|
89
|
+
"""构造错误响应的 JSONResponse。
|
|
90
|
+
|
|
91
|
+
错误响应与 ``ApiResponse.ok()`` 结构对齐:``{code, message, data, timestamp}``,
|
|
92
|
+
仅 ``code`` 由业务错误码填入,``data`` 默认为 ``None``,特殊场景(如校验错误)
|
|
93
|
+
可携带结构化详情。
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
status_code: HTTP 状态码。
|
|
97
|
+
code: 业务错误码(字符串)。
|
|
98
|
+
message: 错误提示信息。
|
|
99
|
+
data: 可选的错误详情载荷,默认 ``None``。校验错误用它携带字段级错误列表。
|
|
100
|
+
headers: 可选的响应头。用于透传 ``HTTPException.headers`` (如 OAuth2 的
|
|
101
|
+
``WWW-Authenticate``),避免客户端协商丢失。
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
符合 ``ApiResponse`` 信封格式的 JSON 错误响应。
|
|
105
|
+
"""
|
|
106
|
+
envelope = ApiResponse(code=code, message=message, data=data)
|
|
107
|
+
return JSONResponse(
|
|
108
|
+
status_code=status_code,
|
|
109
|
+
content=envelope.model_dump(),
|
|
110
|
+
headers=headers,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
115
|
+
"""为 FastAPI 应用注册异常到 ``ApiResponse`` 信封的统一 handler。
|
|
116
|
+
|
|
117
|
+
覆盖两类异常:
|
|
118
|
+
|
|
119
|
+
1. **业务异常**(AppError 子类):``NotFoundError`` / ``BusinessError`` /
|
|
120
|
+
``PermissionDeniedError`` / ``AuthenticationError``,分别映射到 404/422/403/401。
|
|
121
|
+
2. **框架异常**:``RequestValidationError`` / ``HTTPException`` / 未捕获 ``Exception``,
|
|
122
|
+
避免 FastAPI 默认的 ``{"detail": ...}`` 格式绕过 ``ApiResponse`` 信封。
|
|
123
|
+
|
|
124
|
+
调用一次即可。FastAPI 按异常类型精确匹配,AppError 子类 handler 不会被 ``Exception``
|
|
125
|
+
fallback 截获。
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
app: 要挂载 handler 的 FastAPI 实例。
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
@app.exception_handler(NotFoundError)
|
|
132
|
+
async def not_found_handler(request: Request, exc: NotFoundError) -> JSONResponse:
|
|
133
|
+
"""NotFoundError → 404 的 handler。"""
|
|
134
|
+
return _error_response(status.HTTP_404_NOT_FOUND, exc.code, exc.message)
|
|
135
|
+
|
|
136
|
+
@app.exception_handler(BusinessError)
|
|
137
|
+
async def business_error_handler(request: Request, exc: BusinessError) -> JSONResponse:
|
|
138
|
+
"""BusinessError → 422 的 handler。"""
|
|
139
|
+
return _error_response(status.HTTP_422_UNPROCESSABLE_ENTITY, exc.code, exc.message)
|
|
140
|
+
|
|
141
|
+
@app.exception_handler(PermissionDeniedError)
|
|
142
|
+
async def permission_denied_handler(request: Request, exc: PermissionDeniedError) -> JSONResponse:
|
|
143
|
+
"""PermissionDeniedError → 403 的 handler。"""
|
|
144
|
+
return _error_response(status.HTTP_403_FORBIDDEN, exc.code, exc.message)
|
|
145
|
+
|
|
146
|
+
@app.exception_handler(AuthenticationError)
|
|
147
|
+
async def auth_error_handler(request: Request, exc: AuthenticationError) -> JSONResponse:
|
|
148
|
+
"""AuthenticationError → 401 的 handler。"""
|
|
149
|
+
return _error_response(status.HTTP_401_UNAUTHORIZED, exc.code, exc.message)
|
|
150
|
+
|
|
151
|
+
@app.exception_handler(RequestValidationError)
|
|
152
|
+
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
153
|
+
"""RequestValidationError → 422 的 handler。
|
|
154
|
+
|
|
155
|
+
FastAPI 默认把 pydantic 错误原样塞进 ``{"detail": [...]}``,字段路径
|
|
156
|
+
是带 ``body`` / ``query`` / ``path`` 前缀的 tuple,对前端不友好。
|
|
157
|
+
这里把每条错误展平为 ``{field, message}``,field 保留完整路径
|
|
158
|
+
(包括 ``body`` / ``query`` 等段,便于前端精确定位来源)。
|
|
159
|
+
"""
|
|
160
|
+
errors: list[dict[str, str]] = [
|
|
161
|
+
{
|
|
162
|
+
"field": ".".join(str(x) for x in e.get("loc", ())),
|
|
163
|
+
"message": e.get("msg", ""),
|
|
164
|
+
}
|
|
165
|
+
for e in exc.errors()
|
|
166
|
+
]
|
|
167
|
+
return _error_response(
|
|
168
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
169
|
+
"VALIDATION_ERROR",
|
|
170
|
+
"请求参数校验失败",
|
|
171
|
+
data={"errors": errors},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@app.exception_handler(HTTPException)
|
|
175
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
176
|
+
"""HTTPException → 透传状态码 + headers 的 handler。
|
|
177
|
+
|
|
178
|
+
透传 ``exc.headers`` 是为了保留 OAuth2 的 ``WWW-Authenticate: Bearer`` 等
|
|
179
|
+
协商头,丢失会导致标准客户端无法按规范重试。
|
|
180
|
+
"""
|
|
181
|
+
return _error_response(
|
|
182
|
+
exc.status_code,
|
|
183
|
+
f"HTTP_{exc.status_code}",
|
|
184
|
+
str(exc.detail),
|
|
185
|
+
data=None,
|
|
186
|
+
headers=exc.headers,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@app.exception_handler(Exception)
|
|
190
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
191
|
+
"""未捕获 Exception → 500 的 fallback handler。
|
|
192
|
+
|
|
193
|
+
生产环境不暴露 ``str(exc)`` 以免泄露字段名、文件路径、SQL 片段等内部信息;
|
|
194
|
+
开发环境附带 ``str(exc)`` 方便定位。两种环境都通过 ``logger.exception``
|
|
195
|
+
把完整 traceback 写到日志,确保排障证据不丢失。
|
|
196
|
+
"""
|
|
197
|
+
_logger.exception("未捕获异常")
|
|
198
|
+
message = f"服务器内部错误: {exc!s}" if is_dev() else "服务器内部错误"
|
|
199
|
+
return _error_response(
|
|
200
|
+
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
201
|
+
"INTERNAL_ERROR",
|
|
202
|
+
message,
|
|
203
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""健康检查路由工厂 — 只探活,不探依赖。
|
|
2
|
+
|
|
3
|
+
设计取舍:
|
|
4
|
+
端点**只**报告进程是否存活(liveness),不连接 PostgreSQL / Redis 等依赖。
|
|
5
|
+
理由是健康端点通常对公网开放,若内置 readiness / dependency check,攻击者
|
|
6
|
+
可以用低频高并发请求放大到数据库/缓存层,变成 DoS 放大器。
|
|
7
|
+
|
|
8
|
+
需要 readiness 语义的业务(如 k8s readinessProbe)应在业务项目里自行实现,
|
|
9
|
+
并把该端点限制在内网或挂鉴权。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter
|
|
13
|
+
|
|
14
|
+
from dwyeapi.response import ApiResponse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_health_router(
|
|
18
|
+
*,
|
|
19
|
+
service_name: str,
|
|
20
|
+
version: str,
|
|
21
|
+
path: str = "/health",
|
|
22
|
+
include_in_schema: bool = True,
|
|
23
|
+
) -> APIRouter:
|
|
24
|
+
"""构造只探活的健康检查路由。
|
|
25
|
+
|
|
26
|
+
返回的 router 仅挂 1 个 ``GET {path}`` 端点,响应 ``ApiResponse[dict]`` 信封,
|
|
27
|
+
载荷为 ``{"service": service_name, "version": version, "status": "alive"}``。
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
service_name: 服务名,通常取自业务 ``Settings.service_name``。
|
|
31
|
+
version: 服务版本号,通常取自业务包 ``__version__`` 或 ``pyproject.toml``。
|
|
32
|
+
path: 端点路径,默认 ``"/health"``。业务可改为 ``"/api/healthz"`` 等。
|
|
33
|
+
include_in_schema: 是否在 OpenAPI 文档暴露,默认 ``True``。内网专用时可设 ``False``。
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
已挂载端点的 ``APIRouter`` 实例,业务项目 ``app.include_router(router)`` 即可。
|
|
37
|
+
"""
|
|
38
|
+
router = APIRouter(tags=["health"])
|
|
39
|
+
|
|
40
|
+
@router.get(
|
|
41
|
+
path,
|
|
42
|
+
response_model=ApiResponse[dict],
|
|
43
|
+
include_in_schema=include_in_schema,
|
|
44
|
+
summary="进程存活检查",
|
|
45
|
+
)
|
|
46
|
+
async def liveness() -> ApiResponse[dict]:
|
|
47
|
+
"""返回进程存活标志及服务元信息,不触达任何外部依赖。"""
|
|
48
|
+
return ApiResponse.ok(
|
|
49
|
+
{
|
|
50
|
+
"service": service_name,
|
|
51
|
+
"version": version,
|
|
52
|
+
"status": "alive",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return router
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Optional interception of the standard ``logging`` module so libraries like
|
|
14
|
-
uvicorn, SQLAlchemy and third-party packages route through the same sinks.
|
|
1
|
+
"""基于 loguru 的全局日志,支持按天 + 按大小轮转,并可拦截 stdlib logging。
|
|
2
|
+
|
|
3
|
+
对外暴露一组生命周期 API(``configure`` / ``get_logger`` / ``close``),
|
|
4
|
+
与 ``cache.py`` 保持一致的模式:应用启动阶段调用一次 ``configure``,关闭阶段调用 ``close``。
|
|
5
|
+
|
|
6
|
+
特性:
|
|
7
|
+
* 彩色控制台输出(stderr)。
|
|
8
|
+
* 文件输出按天滚动(``{filename}_YYYY-MM-DD.log``),同时支持单文件大小上限,
|
|
9
|
+
两个条件任一触发即滚动。
|
|
10
|
+
* 自动清理过期日志(默认保留 30 天)。
|
|
11
|
+
* 可选 JSON 序列化,便于日志聚合系统解析。
|
|
12
|
+
* 可选拦截标准 ``logging`` 模块,让 uvicorn、SQLAlchemy 等三方库共享同一套输出 sink。
|
|
15
13
|
"""
|
|
16
14
|
|
|
17
15
|
from __future__ import annotations
|
|
@@ -109,10 +107,10 @@ class Logger:
|
|
|
109
107
|
|
|
110
108
|
|
|
111
109
|
class _InterceptHandler(logging.Handler):
|
|
112
|
-
"""
|
|
110
|
+
"""把标准 ``logging`` 记录转发到 loguru sink 链路的拦截 Handler。"""
|
|
113
111
|
|
|
114
112
|
def emit(self, record: logging.LogRecord) -> None:
|
|
115
|
-
"""
|
|
113
|
+
"""转发 stdlib 记录到 loguru,保留原始 level、异常信息以及调用栈深度。"""
|
|
116
114
|
try:
|
|
117
115
|
level: str | int = _logger.level(record.levelname).name
|
|
118
116
|
except ValueError:
|
|
@@ -128,13 +126,13 @@ class _InterceptHandler(logging.Handler):
|
|
|
128
126
|
|
|
129
127
|
|
|
130
128
|
def _make_rotator(max_bytes: int) -> Any:
|
|
131
|
-
"""
|
|
129
|
+
"""构造 loguru ``rotation`` 回调,按日期变更或大小超限触发滚动。
|
|
132
130
|
|
|
133
131
|
Args:
|
|
134
|
-
max_bytes:
|
|
132
|
+
max_bytes: 单文件最大字节数,超出后新开文件。
|
|
135
133
|
|
|
136
134
|
Returns:
|
|
137
|
-
|
|
135
|
+
符合 loguru ``rotation`` 协议的回调函数。
|
|
138
136
|
"""
|
|
139
137
|
state: dict[str, date | None] = {"date": None}
|
|
140
138
|
|
|
@@ -166,22 +164,22 @@ def configure(
|
|
|
166
164
|
intercept_stdlib: bool = True,
|
|
167
165
|
intercept_loggers: list[str] | None = None,
|
|
168
166
|
) -> None:
|
|
169
|
-
"""
|
|
167
|
+
"""配置全局日志,应用启动时调用一次即可。
|
|
170
168
|
|
|
171
169
|
Args:
|
|
172
|
-
level:
|
|
173
|
-
log_dir:
|
|
174
|
-
filename:
|
|
175
|
-
max_bytes:
|
|
176
|
-
retention:
|
|
177
|
-
console:
|
|
178
|
-
console_format:
|
|
179
|
-
file_format:
|
|
180
|
-
serialize:
|
|
181
|
-
enqueue:
|
|
182
|
-
intercept_stdlib:
|
|
183
|
-
intercept_loggers:
|
|
184
|
-
|
|
170
|
+
level: 最低日志级别(``DEBUG``/``INFO``/``WARNING``/``ERROR``/``CRITICAL``)。
|
|
171
|
+
log_dir: 日志文件目录,``None`` 表示关闭文件输出。
|
|
172
|
+
filename: 文件基础名,最终文件形如 ``{filename}_YYYY-MM-DD.log``。
|
|
173
|
+
max_bytes: 单文件大小上限(字节),默认 100 MB。
|
|
174
|
+
retention: loguru 保留策略(``"30 days"``、``"1 week"``、整数个数等)。
|
|
175
|
+
console: 是否输出带颜色的日志到 stderr。
|
|
176
|
+
console_format: 自定义控制台格式字符串。
|
|
177
|
+
file_format: 自定义文件格式字符串。
|
|
178
|
+
serialize: 文件 sink 是否按 JSON Lines 序列化,便于日志聚合系统使用。
|
|
179
|
+
enqueue: 是否通过队列异步写入,非阻塞且多进程安全。
|
|
180
|
+
intercept_stdlib: 是否把 stdlib ``logging`` 的日志重定向到 loguru。
|
|
181
|
+
intercept_loggers: 拦截时限定的 logger 名前缀(如 ``["uvicorn", "sqlalchemy"]``),
|
|
182
|
+
``None`` 表示拦截 root logger(捕获全部)。
|
|
185
183
|
"""
|
|
186
184
|
close()
|
|
187
185
|
|
|
@@ -223,7 +221,7 @@ def configure(
|
|
|
223
221
|
|
|
224
222
|
|
|
225
223
|
def _install_stdlib_intercept(level: str, targets: list[str] | None) -> None:
|
|
226
|
-
"""
|
|
224
|
+
"""把 stdlib logger 的 handlers 替换为 ``_InterceptHandler``,并记住原始 handlers 以便恢复。"""
|
|
227
225
|
handler = _InterceptHandler()
|
|
228
226
|
roots = targets or [""]
|
|
229
227
|
|
|
@@ -251,12 +249,12 @@ def get_logger(name: str | None = None) -> Logger:
|
|
|
251
249
|
|
|
252
250
|
|
|
253
251
|
def close() -> None:
|
|
254
|
-
"""
|
|
252
|
+
"""移除全部 handlers、恢复 stdlib logger 原状并 flush 未落盘日志。
|
|
255
253
|
|
|
256
|
-
|
|
254
|
+
可重复调用,调用后可再次 ``configure`` 重新装配。
|
|
257
255
|
"""
|
|
258
256
|
for handler_id in _handler_ids:
|
|
259
|
-
# Handler
|
|
257
|
+
# Handler 已被移除的情况为正常情形,忽略即可。
|
|
260
258
|
with contextlib.suppress(ValueError):
|
|
261
259
|
_logger.remove(handler_id)
|
|
262
260
|
_handler_ids.clear()
|