dwyeapi 0.5.1__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.
Files changed (69) hide show
  1. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/PKG-INFO +1 -1
  2. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/README.md +9 -2
  3. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/pyproject.toml +1 -1
  4. dwyeapi-0.7.0/src/dwyeapi/__init__.py +35 -0
  5. dwyeapi-0.7.0/src/dwyeapi/cache.py +49 -0
  6. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/config.py +12 -7
  7. dwyeapi-0.7.0/src/dwyeapi/database.py +73 -0
  8. dwyeapi-0.7.0/src/dwyeapi/dependencies.py +29 -0
  9. dwyeapi-0.7.0/src/dwyeapi/exceptions.py +203 -0
  10. dwyeapi-0.7.0/src/dwyeapi/health.py +56 -0
  11. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/logger.py +35 -37
  12. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/masking.py +27 -13
  13. dwyeapi-0.7.0/src/dwyeapi/pagination.py +42 -0
  14. dwyeapi-0.7.0/src/dwyeapi/response.py +87 -0
  15. dwyeapi-0.7.0/src/dwyeapi/security.py +92 -0
  16. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/__init__.py +24 -24
  17. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/context.py +45 -45
  18. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/model.py +9 -6
  19. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/pool.py +14 -15
  20. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/registry.py +18 -18
  21. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/router.py +40 -80
  22. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/schema.py +3 -10
  23. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/service.py +40 -32
  24. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/tasks/worker.py +17 -18
  25. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/PKG-INFO +1 -1
  26. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/SOURCES.txt +2 -0
  27. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_config.py +3 -2
  28. dwyeapi-0.7.0/tests/test_exceptions.py +190 -0
  29. dwyeapi-0.7.0/tests/test_health.py +77 -0
  30. dwyeapi-0.7.0/tests/test_response.py +113 -0
  31. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_tasks.py +11 -6
  32. dwyeapi-0.5.1/src/dwyeapi/__init__.py +0 -21
  33. dwyeapi-0.5.1/src/dwyeapi/cache.py +0 -30
  34. dwyeapi-0.5.1/src/dwyeapi/database.py +0 -42
  35. dwyeapi-0.5.1/src/dwyeapi/dependencies.py +0 -16
  36. dwyeapi-0.5.1/src/dwyeapi/exceptions.py +0 -75
  37. dwyeapi-0.5.1/src/dwyeapi/pagination.py +0 -25
  38. dwyeapi-0.5.1/src/dwyeapi/response.py +0 -19
  39. dwyeapi-0.5.1/src/dwyeapi/security.py +0 -50
  40. dwyeapi-0.5.1/tests/test_exceptions.py +0 -93
  41. dwyeapi-0.5.1/tests/test_response.py +0 -43
  42. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/setup.cfg +0 -0
  43. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/dt.py +0 -0
  44. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/__init__.py +0 -0
  45. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/__init__.py +0 -0
  46. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/aliyun.py +0 -0
  47. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/base.py +0 -0
  48. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/config.py +0 -0
  49. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/factory.py +0 -0
  50. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/mock.py +0 -0
  51. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/protocol.py +0 -0
  52. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/email/resend.py +0 -0
  53. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/__init__.py +0 -0
  54. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/aliyun.py +0 -0
  55. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/base.py +0 -0
  56. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/config.py +0 -0
  57. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/factory.py +0 -0
  58. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/mock.py +0 -0
  59. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi/providers/sms/protocol.py +0 -0
  60. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
  61. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/requires.txt +0 -0
  62. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/src/dwyeapi.egg-info/top_level.txt +0 -0
  63. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_cache.py +0 -0
  64. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_database.py +0 -0
  65. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_dependencies.py +0 -0
  66. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_logger.py +0 -0
  67. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_masking.py +0 -0
  68. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_pagination.py +0 -0
  69. {dwyeapi-0.5.1 → dwyeapi-0.7.0}/tests/test_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dwyeapi
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -23,14 +23,18 @@ pip install dwyeapi
23
23
 
24
24
  ## Environment (dev / prod)
25
25
 
26
- `BaseSettings` 暴露顶层 `environment` 字段,可选值 `"dev" | "prod"`,**默认 `"prod"`**(保守派,误配置时也按 prod 行为)。
26
+ `BaseSettings` 暴露顶层 `environment` 字段,可选值 `"dev" | "prod"`,**默认 `"prod"`**(保守派,误配置时也按 prod 行为)。eapi **不再提供 `debug` 字段** —— 所有"调试开关"统一判断 `is_dev()`,避免两个维度互相冲突。
27
27
 
28
28
  ```bash
29
29
  # .env
30
30
  ENVIRONMENT=dev # 开发环境;留空或 prod 即生产
31
31
  ```
32
32
 
33
- 业务代码通过 `dwyeapi.is_dev()` / `dwyeapi.is_prod()` / `dwyeapi.get_environment()` 读当前环境,典型用法:
33
+ 业务代码通过 `dwyeapi.is_dev()` / `dwyeapi.is_prod()` / `dwyeapi.get_environment()` 读当前环境。
34
+
35
+ ### FastAPI docs/redoc/openapi 仅 dev 暴露
36
+
37
+ 非 dev 环境必须关闭 Swagger/ReDoc/OpenAPI schema 三个端点,避免线上暴露路由和字段元信息:
34
38
 
35
39
  ```python
36
40
  from dwyeapi import is_dev
@@ -45,6 +49,9 @@ app = FastAPI(
45
49
  redoc_url="/redoc" if is_dev() else None,
46
50
  openapi_url="/openapi.json" if is_dev() else None,
47
51
  )
52
+
53
+ # SQL echo、慢路由 profiling、中间件堆栈打印等调试性质开关也统一用 is_dev() 守护
54
+ engine = create_async_engine_factory(settings.database_url, echo=is_dev())
48
55
  ```
49
56
 
50
57
  ### Mock provider 仅 dev 可用
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dwyeapi"
3
- version = "0.5.1"
3
+ version = "0.7.0"
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
@@ -1,11 +1,12 @@
1
- """Base Pydantic Settings class for FastAPI projects.
1
+ """FastAPI 项目通用 Pydantic Settings 基类。
2
2
 
3
- 除基础字段外,还提供运行环境识别 API:
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()`` 时会自动把值写入模块级全局,之后任何地方 import 即可读。
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
- """Base settings — subclass and add project-specific fields."""
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
@@ -53,7 +59,6 @@ class BaseSettings(PydanticBaseSettings):
53
59
  jwt_algorithm: str = "HS256"
54
60
  access_token_expire_minutes: int = 30
55
61
  environment: Environment = "prod"
56
- debug: bool = False
57
62
  allowed_origins: list[str] = Field(default_factory=list)
58
63
 
59
64
  # Task module settings (task_ prefix)
@@ -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
- """Loguru-based global logger with daily + size rotation and stdlib interception.
2
-
3
- Exposes a single lifecycle API (``configure``/``get_logger``/``close``) that mirrors
4
- the pattern used by ``cache.py``. A typical application calls ``configure`` once at
5
- startup (from ``lifespan``) and ``close`` once at shutdown.
6
-
7
- Features:
8
- * Colored console output (stderr).
9
- * File output with daily rollover (``{filename}_YYYY-MM-DD.log``) and a
10
- configurable per-file size ceiling — whichever triggers first.
11
- * Old log retention (default 30 days) with automatic cleanup.
12
- * Optional JSON serialization for log aggregators.
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
- """Route standard ``logging`` records into the loguru sink chain."""
110
+ """把标准 ``logging`` 记录转发到 loguru sink 链路的拦截 Handler。"""
113
111
 
114
112
  def emit(self, record: logging.LogRecord) -> None:
115
- """Forward a stdlib record to loguru preserving level, exception and frame depth."""
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
- """Build a loguru ``rotation`` callable that rolls on date change or size cap.
129
+ """构造 loguru ``rotation`` 回调,按日期变更或大小超限触发滚动。
132
130
 
133
131
  Args:
134
- max_bytes: Maximum bytes per file before a new file is created.
132
+ max_bytes: 单文件最大字节数,超出后新开文件。
135
133
 
136
134
  Returns:
137
- A function matching the loguru ``rotation`` callable signature.
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
- """Configure the global logger. Call once at application startup.
167
+ """配置全局日志,应用启动时调用一次即可。
170
168
 
171
169
  Args:
172
- level: Minimum log level (``DEBUG``/``INFO``/``WARNING``/``ERROR``/``CRITICAL``).
173
- log_dir: Directory for log files. ``None`` disables file output.
174
- filename: Base filename; final files look like ``{filename}_YYYY-MM-DD.log``.
175
- max_bytes: Per-file size cap before rotation (default 100 MB).
176
- retention: Loguru retention spec (``"30 days"``, ``"1 week"``, int count, ...).
177
- console: Whether to write colored logs to stderr.
178
- console_format: Override for the console format string.
179
- file_format: Override for the file format string.
180
- serialize: Emit JSON lines to the file sink for log aggregators.
181
- enqueue: Write asynchronously via a queue — non-blocking and multi-process safe.
182
- intercept_stdlib: Route stdlib ``logging`` records through loguru.
183
- intercept_loggers: When intercepting, scope to these logger name prefixes
184
- (e.g. ``["uvicorn", "sqlalchemy"]``). ``None`` means root (catch-all).
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
- """Replace handlers on stdlib loggers with ``_InterceptHandler``, remembering originals."""
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
- """Remove all handlers, restore stdlib loggers, and flush pending records.
252
+ """移除全部 handlers、恢复 stdlib logger 原状并 flush 未落盘日志。
255
253
 
256
- Safe to call repeatedly. After ``close`` the module can be reconfigured.
254
+ 可重复调用,调用后可再次 ``configure`` 重新装配。
257
255
  """
258
256
  for handler_id in _handler_ids:
259
- # Handler already removed — benign.
257
+ # Handler 已被移除的情况为正常情形,忽略即可。
260
258
  with contextlib.suppress(ValueError):
261
259
  _logger.remove(handler_id)
262
260
  _handler_ids.clear()