dwyeapi 0.5.2__tar.gz → 0.7.1__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.
Files changed (70) hide show
  1. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/PKG-INFO +1 -1
  2. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/pyproject.toml +1 -1
  3. dwyeapi-0.7.1/src/dwyeapi/__init__.py +35 -0
  4. dwyeapi-0.7.1/src/dwyeapi/cache.py +49 -0
  5. dwyeapi-0.7.1/src/dwyeapi/config.py +141 -0
  6. dwyeapi-0.7.1/src/dwyeapi/database.py +73 -0
  7. dwyeapi-0.7.1/src/dwyeapi/dependencies.py +29 -0
  8. dwyeapi-0.7.1/src/dwyeapi/exceptions.py +203 -0
  9. dwyeapi-0.7.1/src/dwyeapi/health.py +56 -0
  10. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/logger.py +35 -37
  11. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/masking.py +27 -13
  12. dwyeapi-0.7.1/src/dwyeapi/pagination.py +42 -0
  13. dwyeapi-0.7.1/src/dwyeapi/response.py +87 -0
  14. dwyeapi-0.7.1/src/dwyeapi/security.py +92 -0
  15. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/__init__.py +24 -24
  16. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/context.py +45 -45
  17. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/model.py +9 -6
  18. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/pool.py +14 -15
  19. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/registry.py +18 -18
  20. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/router.py +40 -80
  21. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/schema.py +3 -10
  22. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/service.py +40 -32
  23. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/tasks/worker.py +17 -18
  24. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi.egg-info/PKG-INFO +1 -1
  25. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi.egg-info/SOURCES.txt +2 -0
  26. dwyeapi-0.7.1/tests/test_exceptions.py +190 -0
  27. dwyeapi-0.7.1/tests/test_health.py +77 -0
  28. dwyeapi-0.7.1/tests/test_response.py +113 -0
  29. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_tasks.py +11 -6
  30. dwyeapi-0.5.2/src/dwyeapi/__init__.py +0 -21
  31. dwyeapi-0.5.2/src/dwyeapi/cache.py +0 -30
  32. dwyeapi-0.5.2/src/dwyeapi/config.py +0 -84
  33. dwyeapi-0.5.2/src/dwyeapi/database.py +0 -42
  34. dwyeapi-0.5.2/src/dwyeapi/dependencies.py +0 -16
  35. dwyeapi-0.5.2/src/dwyeapi/exceptions.py +0 -75
  36. dwyeapi-0.5.2/src/dwyeapi/pagination.py +0 -25
  37. dwyeapi-0.5.2/src/dwyeapi/response.py +0 -19
  38. dwyeapi-0.5.2/src/dwyeapi/security.py +0 -50
  39. dwyeapi-0.5.2/tests/test_exceptions.py +0 -93
  40. dwyeapi-0.5.2/tests/test_response.py +0 -43
  41. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/README.md +0 -0
  42. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/setup.cfg +0 -0
  43. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/dt.py +0 -0
  44. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/__init__.py +0 -0
  45. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/email/__init__.py +0 -0
  46. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/email/aliyun.py +0 -0
  47. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/email/base.py +0 -0
  48. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/email/config.py +0 -0
  49. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/email/factory.py +0 -0
  50. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/email/mock.py +0 -0
  51. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/email/protocol.py +0 -0
  52. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/email/resend.py +0 -0
  53. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/sms/__init__.py +0 -0
  54. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/sms/aliyun.py +0 -0
  55. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/sms/base.py +0 -0
  56. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/sms/config.py +0 -0
  57. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/sms/factory.py +0 -0
  58. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/sms/mock.py +0 -0
  59. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi/providers/sms/protocol.py +0 -0
  60. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
  61. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi.egg-info/requires.txt +0 -0
  62. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/src/dwyeapi.egg-info/top_level.txt +0 -0
  63. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_cache.py +0 -0
  64. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_config.py +0 -0
  65. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_database.py +0 -0
  66. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_dependencies.py +0 -0
  67. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_logger.py +0 -0
  68. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_masking.py +0 -0
  69. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_pagination.py +0 -0
  70. {dwyeapi-0.5.2 → dwyeapi-0.7.1}/tests/test_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dwyeapi
3
- Version: 0.5.2
3
+ Version: 0.7.1
4
4
  Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dwyeapi"
3
- version = "0.5.2"
3
+ version = "0.7.1"
4
4
  description = "Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger"
5
5
  requires-python = ">=3.11"
6
6
  license = {text = "MIT"}
@@ -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
@@ -0,0 +1,141 @@
1
+ """FastAPI 项目通用 Pydantic Settings 基类。
2
+
3
+ 除了 database_url / redis_url / secret_key 等基础字段外,还提供运行环境识别 API:
4
+
5
+ - ``environment`` 字段: ``"dev"`` | ``"prod"``,默认 ``"prod"``(误配置时偏保守)
6
+ - ``get_environment()`` / ``is_dev()`` / ``is_prod()``:业务代码读取当前环境
7
+
8
+ 业务项目实例化 ``Settings()`` 时会自动把 environment 值写入模块级全局变量,
9
+ 之后任何地方 import 即可读,避免把 Settings 实例到处传递。
10
+ """
11
+
12
+ from typing import Literal
13
+
14
+ from pydantic import Field, model_validator
15
+ from pydantic_settings import BaseSettings as PydanticBaseSettings
16
+ from pydantic_settings import SettingsConfigDict
17
+
18
+ Environment = Literal["dev", "prod"]
19
+
20
+ _current_environment: Environment = "prod"
21
+
22
+
23
+ def get_environment() -> Environment:
24
+ """返回当前运行环境。
25
+
26
+ 未实例化 Settings 前默认返回 ``"prod"`` (保守),业务 ``Settings()`` 加载
27
+ 后会同步为配置值。
28
+ """
29
+ return _current_environment
30
+
31
+
32
+ def is_dev() -> bool:
33
+ """当前是否为开发环境。"""
34
+ return _current_environment == "dev"
35
+
36
+
37
+ def is_prod() -> bool:
38
+ """当前是否为生产环境。"""
39
+ return _current_environment == "prod"
40
+
41
+
42
+ def set_current_environment(env: Environment) -> None:
43
+ """内部使用: 由 BaseSettings 校验钩子或测试显式切换时调用。"""
44
+ global _current_environment
45
+ _current_environment = env
46
+
47
+
48
+ class BaseSettings(PydanticBaseSettings):
49
+ """FastAPI 项目共用的配置基类。
50
+
51
+ 业务项目通过继承并追加项目专属字段使用,内置的 database_url / redis_url /
52
+ secret_key 等字段覆盖最常用的基础设施需求,不需要每个项目重复声明。
53
+ 配置来源默认为 ``.env`` 文件,嵌套字段使用双下划线 ``__`` 分隔。
54
+ """
55
+
56
+ database_url: str = Field(
57
+ description="数据库连接串(异步驱动),例 postgresql+asyncpg://user:pass@host:5432/db",
58
+ )
59
+ redis_url: str = Field(
60
+ description="Redis 连接串,例 redis://localhost:6379/0",
61
+ )
62
+ secret_key: str = Field(
63
+ description="应用密钥,用于 JWT 签名与敏感数据加密,至少 32 位随机字符串",
64
+ )
65
+ jwt_algorithm: str = Field(
66
+ default="HS256",
67
+ description="JWT 签名算法,默认 HS256",
68
+ )
69
+ access_token_expire_minutes: int = Field(
70
+ default=30,
71
+ description="JWT access token 有效期(分钟),默认 30 分钟",
72
+ )
73
+ environment: Environment = Field(
74
+ default="prod",
75
+ description='运行环境,"dev" 开启调试特性 / 允许 mock providers,"prod" 关闭调试并禁用 mock',
76
+ )
77
+ allowed_origins: list[str] = Field(
78
+ default_factory=list,
79
+ description="CORS 允许的来源域名列表,生产环境禁止使用 ['*']",
80
+ )
81
+
82
+ # ── 异步任务配置(task_ 前缀)──
83
+ task_max_jobs: int = Field(
84
+ default=5,
85
+ description="ARQ worker 并发执行的最大任务数",
86
+ )
87
+ task_job_timeout: int = Field(
88
+ default=3600,
89
+ description="单个任务执行超时(秒),超时后被中止,默认 1 小时",
90
+ )
91
+ task_failure_ttl: int = Field(
92
+ default=86400,
93
+ description="失败任务结果在 Redis 中的保留时长(秒),默认 1 天",
94
+ )
95
+
96
+ # ── 日志配置(log_ 前缀)──
97
+ log_level: str = Field(
98
+ default="INFO",
99
+ description="最低日志级别:DEBUG / INFO / WARNING / ERROR / CRITICAL",
100
+ )
101
+ log_dir: str | None = Field(
102
+ default=".",
103
+ description='日志文件目录,默认 "." 即进程当前目录;显式设为 None 可关闭文件输出(仅控制台)',
104
+ )
105
+ log_filename: str = Field(
106
+ default="app",
107
+ description="日志文件基础名,最终文件形如 {filename}_YYYY-MM-DD.log",
108
+ )
109
+ log_max_bytes: int = Field(
110
+ default=100 * 1024 * 1024,
111
+ description="单个日志文件大小上限(字节),超出后滚动,默认 100 MB",
112
+ )
113
+ log_retention: str = Field(
114
+ default="30 days",
115
+ description='日志保留策略,loguru 语法,例 "30 days" / "1 week" / 整数个数',
116
+ )
117
+ log_console: bool = Field(
118
+ default=True,
119
+ description="是否输出带颜色的日志到 stderr 控制台",
120
+ )
121
+ log_serialize: bool = Field(
122
+ default=False,
123
+ description="文件 sink 是否按 JSON Lines 序列化,便于日志聚合系统解析",
124
+ )
125
+ log_intercept_stdlib: bool = Field(
126
+ default=True,
127
+ description="是否拦截 stdlib logging,让 uvicorn / SQLAlchemy / httpx 等共享同一套 sink",
128
+ )
129
+
130
+ model_config = SettingsConfigDict(
131
+ env_file=".env",
132
+ env_file_encoding="utf-8",
133
+ env_nested_delimiter="__",
134
+ extra="ignore",
135
+ )
136
+
137
+ @model_validator(mode="after")
138
+ def _sync_environment_to_module(self) -> "BaseSettings":
139
+ """把 ``environment`` 写入模块全局,让 ``is_dev()`` / ``is_prod()`` 跨模块可读。"""
140
+ set_current_environment(self.environment)
141
+ return self
@@ -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