dwyeapi 0.4.0__tar.gz → 0.5.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 (59) hide show
  1. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/PKG-INFO +13 -1
  2. dwyeapi-0.5.1/README.md +52 -0
  3. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/pyproject.toml +15 -1
  4. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/__init__.py +4 -3
  5. dwyeapi-0.5.1/src/dwyeapi/config.py +85 -0
  6. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/dt.py +9 -9
  7. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/logger.py +71 -8
  8. dwyeapi-0.5.1/src/dwyeapi/providers/__init__.py +5 -0
  9. dwyeapi-0.5.1/src/dwyeapi/providers/email/__init__.py +29 -0
  10. dwyeapi-0.5.1/src/dwyeapi/providers/email/aliyun.py +46 -0
  11. dwyeapi-0.5.1/src/dwyeapi/providers/email/base.py +71 -0
  12. dwyeapi-0.5.1/src/dwyeapi/providers/email/config.py +42 -0
  13. dwyeapi-0.5.1/src/dwyeapi/providers/email/factory.py +61 -0
  14. dwyeapi-0.5.1/src/dwyeapi/providers/email/mock.py +15 -0
  15. dwyeapi-0.5.1/src/dwyeapi/providers/email/protocol.py +34 -0
  16. dwyeapi-0.5.1/src/dwyeapi/providers/email/resend.py +70 -0
  17. dwyeapi-0.5.1/src/dwyeapi/providers/sms/__init__.py +28 -0
  18. dwyeapi-0.5.1/src/dwyeapi/providers/sms/aliyun.py +46 -0
  19. dwyeapi-0.5.1/src/dwyeapi/providers/sms/base.py +71 -0
  20. dwyeapi-0.5.1/src/dwyeapi/providers/sms/config.py +33 -0
  21. dwyeapi-0.5.1/src/dwyeapi/providers/sms/factory.py +48 -0
  22. dwyeapi-0.5.1/src/dwyeapi/providers/sms/mock.py +23 -0
  23. dwyeapi-0.5.1/src/dwyeapi/providers/sms/protocol.py +34 -0
  24. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/router.py +12 -2
  25. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/worker.py +4 -4
  26. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/PKG-INFO +13 -1
  27. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/SOURCES.txt +16 -0
  28. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/requires.txt +17 -0
  29. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_config.py +75 -1
  30. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_logger.py +63 -0
  31. dwyeapi-0.4.0/README.md +0 -21
  32. dwyeapi-0.4.0/src/dwyeapi/config.py +0 -39
  33. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/setup.cfg +0 -0
  34. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/cache.py +0 -0
  35. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/database.py +0 -0
  36. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/dependencies.py +0 -0
  37. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/exceptions.py +0 -0
  38. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/masking.py +0 -0
  39. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/pagination.py +0 -0
  40. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/response.py +0 -0
  41. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/security.py +2 -2
  42. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/__init__.py +0 -0
  43. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/context.py +0 -0
  44. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/model.py +0 -0
  45. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/pool.py +0 -0
  46. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/registry.py +0 -0
  47. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/schema.py +0 -0
  48. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/service.py +0 -0
  49. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
  50. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/top_level.txt +0 -0
  51. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_cache.py +0 -0
  52. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_database.py +0 -0
  53. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_dependencies.py +0 -0
  54. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_exceptions.py +0 -0
  55. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_masking.py +0 -0
  56. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_pagination.py +0 -0
  57. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_response.py +0 -0
  58. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_security.py +0 -0
  59. {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dwyeapi
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -14,6 +14,17 @@ Requires-Dist: redis>=5.0.0
14
14
  Requires-Dist: loguru>=0.7.3
15
15
  Provides-Extra: tasks
16
16
  Requires-Dist: arq>=0.26.0; extra == "tasks"
17
+ Provides-Extra: email-resend
18
+ Requires-Dist: resend[async]>=2.29.0; extra == "email-resend"
19
+ Provides-Extra: email-aliyun
20
+ Requires-Dist: alibabacloud_dm20151123>=1.0.0; extra == "email-aliyun"
21
+ Provides-Extra: sms-aliyun
22
+ Requires-Dist: alibabacloud_dysmsapi20170525>=2.0.0; extra == "sms-aliyun"
23
+ Provides-Extra: email
24
+ Requires-Dist: resend[async]>=2.29.0; extra == "email"
25
+ Requires-Dist: alibabacloud_dm20151123>=1.0.0; extra == "email"
26
+ Provides-Extra: sms
27
+ Requires-Dist: alibabacloud_dysmsapi20170525>=2.0.0; extra == "sms"
17
28
  Provides-Extra: dev
18
29
  Requires-Dist: pytest>=8.0.0; extra == "dev"
19
30
  Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
@@ -21,3 +32,4 @@ Requires-Dist: httpx>=0.27.0; extra == "dev"
21
32
  Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
22
33
  Requires-Dist: ruff>=0.6.0; extra == "dev"
23
34
  Requires-Dist: arq>=0.26.0; extra == "dev"
35
+ Requires-Dist: fakeredis>=2.0.0; extra == "dev"
@@ -0,0 +1,52 @@
1
+ # dwyeapi
2
+
3
+ Lightweight FastAPI infrastructure package for shared use across projects.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install dwyeapi
9
+ ```
10
+
11
+ ## Modules
12
+
13
+ - `config` — BaseSettings with required fields (database_url, redis_url, secret_key) + runtime environment API (`is_dev()` / `is_prod()` / `get_environment()`)
14
+ - `exceptions` — AppError hierarchy + FastAPI exception handlers
15
+ - `database` — Async SQLAlchemy engine/session factory + Base + TimestampMixin
16
+ - `dependencies` — `get_db()` FastAPI dependency
17
+ - `security` — JWT create/verify + bcrypt hash/verify (stateless)
18
+ - `cache` — Async Redis connection manager
19
+ - `response` — Unified API response helpers
20
+ - `pagination` — PaginationParams + paginate helper
21
+ - `logger` — Facade logger (loguru-backed) with daily + size rotation and stdlib interception; downstream code depends on `dwyeapi.logger.Logger`, not loguru directly
22
+ - `providers.email` / `providers.sms` — pluggable 验证码 Provider 工厂 (mock / resend / aliyun)
23
+
24
+ ## Environment (dev / prod)
25
+
26
+ `BaseSettings` 暴露顶层 `environment` 字段,可选值 `"dev" | "prod"`,**默认 `"prod"`**(保守派,误配置时也按 prod 行为)。
27
+
28
+ ```bash
29
+ # .env
30
+ ENVIRONMENT=dev # 开发环境;留空或 prod 即生产
31
+ ```
32
+
33
+ 业务代码通过 `dwyeapi.is_dev()` / `dwyeapi.is_prod()` / `dwyeapi.get_environment()` 读当前环境,典型用法:
34
+
35
+ ```python
36
+ from dwyeapi import is_dev
37
+ from fastapi import FastAPI
38
+ from app.config import Settings
39
+
40
+ settings = Settings() # 实例化会把 environment 同步到 dwyeapi 全局
41
+
42
+ app = FastAPI(
43
+ title="My API",
44
+ docs_url="/docs" if is_dev() else None,
45
+ redoc_url="/redoc" if is_dev() else None,
46
+ openapi_url="/openapi.json" if is_dev() else None,
47
+ )
48
+ ```
49
+
50
+ ### Mock provider 仅 dev 可用
51
+
52
+ `providers.email` / `providers.sms` 的 `provider="mock"` 只在 `ENVIRONMENT=dev` 下可用。prod 环境下调用 `make_email_provider()` / `make_sms_provider()` 会抛 `ValueError`,保证正式环境一旦误配置 `EMAIL__PROVIDER=mock` 会启动失败(fail fast),而不会静默吞掉真实验证码。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dwyeapi"
3
- version = "0.4.0"
3
+ version = "0.5.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"}
@@ -19,6 +19,19 @@ dependencies = [
19
19
  tasks = [
20
20
  "arq>=0.26.0",
21
21
  ]
22
+
23
+ # Email Provider 实现 — 按需安装
24
+ # resend[async] 会拉入 httpx 以启用 Emails.send_async 原生异步 API
25
+ email-resend = ["resend[async]>=2.29.0"]
26
+ email-aliyun = ["alibabacloud_dm20151123>=1.0.0"]
27
+
28
+ # SMS Provider 实现 — 按需安装
29
+ sms-aliyun = ["alibabacloud_dysmsapi20170525>=2.0.0"]
30
+
31
+ # 聚合 extras(按模块)
32
+ email = ["resend[async]>=2.29.0", "alibabacloud_dm20151123>=1.0.0"]
33
+ sms = ["alibabacloud_dysmsapi20170525>=2.0.0"]
34
+
22
35
  dev = [
23
36
  "pytest>=8.0.0",
24
37
  "pytest-asyncio>=0.24.0",
@@ -26,6 +39,7 @@ dev = [
26
39
  "aiosqlite>=0.20.0",
27
40
  "ruff>=0.6.0",
28
41
  "arq>=0.26.0",
42
+ "fakeredis>=2.0.0",
29
43
  ]
30
44
 
31
45
  [build-system]
@@ -1,7 +1,7 @@
1
1
  """dwyeapi — Lightweight FastAPI infrastructure.
2
2
 
3
3
  Modules:
4
- config — BaseSettings with required fields
4
+ config — BaseSettings + 运行环境识别 API (is_dev/is_prod/get_environment)
5
5
  exceptions — AppError hierarchy + FastAPI exception handlers
6
6
  database — Async SQLAlchemy engine/session factory + Base + TimestampMixin
7
7
  dependencies — get_db() FastAPI dependency factory
@@ -15,6 +15,7 @@ Modules:
15
15
  """
16
16
 
17
17
  from . import logger
18
+ from .config import BaseSettings, get_environment, is_dev, is_prod
18
19
 
19
- __all__ = ["logger"]
20
- __version__ = "0.4.0"
20
+ __all__ = ["BaseSettings", "get_environment", "is_dev", "is_prod", "logger"]
21
+ __version__ = "0.5.1"
@@ -0,0 +1,85 @@
1
+ """Base Pydantic Settings class for FastAPI projects.
2
+
3
+ 除基础字段外,还提供运行环境识别 API:
4
+
5
+ - ``environment`` 字段: ``"dev"`` | ``"prod"``,默认 ``"prod"`` (误配置时保守)
6
+ - ``get_environment()`` / ``is_dev()`` / ``is_prod()``: 业务代码读当前环境
7
+
8
+ 业务项目实例化 ``Settings()`` 时会自动把值写入模块级全局,之后任何地方 import 即可读。
9
+ """
10
+
11
+ from typing import Literal
12
+
13
+ from pydantic import Field, model_validator
14
+ from pydantic_settings import BaseSettings as PydanticBaseSettings
15
+ from pydantic_settings import SettingsConfigDict
16
+
17
+ Environment = Literal["dev", "prod"]
18
+
19
+ _current_environment: Environment = "prod"
20
+
21
+
22
+ def get_environment() -> Environment:
23
+ """返回当前运行环境。
24
+
25
+ 未实例化 Settings 前默认返回 ``"prod"`` (保守),业务 ``Settings()`` 加载
26
+ 后会同步为配置值。
27
+ """
28
+ return _current_environment
29
+
30
+
31
+ def is_dev() -> bool:
32
+ """当前是否为开发环境。"""
33
+ return _current_environment == "dev"
34
+
35
+
36
+ def is_prod() -> bool:
37
+ """当前是否为生产环境。"""
38
+ return _current_environment == "prod"
39
+
40
+
41
+ def set_current_environment(env: Environment) -> None:
42
+ """内部使用: 由 BaseSettings 校验钩子或测试显式切换时调用。"""
43
+ global _current_environment
44
+ _current_environment = env
45
+
46
+
47
+ class BaseSettings(PydanticBaseSettings):
48
+ """Base settings — subclass and add project-specific fields."""
49
+
50
+ database_url: str
51
+ redis_url: str
52
+ secret_key: str
53
+ jwt_algorithm: str = "HS256"
54
+ access_token_expire_minutes: int = 30
55
+ environment: Environment = "prod"
56
+ debug: bool = False
57
+ allowed_origins: list[str] = Field(default_factory=list)
58
+
59
+ # Task module settings (task_ prefix)
60
+ task_max_jobs: int = 5
61
+ task_job_timeout: int = 3600
62
+ task_failure_ttl: int = 86400
63
+
64
+ # Logger module settings (log_ prefix)
65
+ log_level: str = "INFO"
66
+ log_dir: str | None = None
67
+ log_filename: str = "app"
68
+ log_max_bytes: int = 100 * 1024 * 1024
69
+ log_retention: str = "30 days"
70
+ log_console: bool = True
71
+ log_serialize: bool = False
72
+ log_intercept_stdlib: bool = True
73
+
74
+ model_config = SettingsConfigDict(
75
+ env_file=".env",
76
+ env_file_encoding="utf-8",
77
+ env_nested_delimiter="__",
78
+ extra="ignore",
79
+ )
80
+
81
+ @model_validator(mode="after")
82
+ def _sync_environment_to_module(self) -> "BaseSettings":
83
+ """把 ``environment`` 写入模块全局,让 ``is_dev()`` / ``is_prod()`` 跨模块可读。"""
84
+ set_current_environment(self.environment)
85
+ return self
@@ -1,7 +1,7 @@
1
1
  """全局时间工具 — 所有日期操作的唯一入口。
2
2
 
3
- 业务时间统一 Asia/ShanghaiJWT 等协议场景提供 UTC 方法。
4
- 禁止在业务代码中直接使用 datetime.now(),统一通过本模块访问。
3
+ 业务时间统一 Asia/Shanghai, JWT 等协议场景提供 UTC 方法。
4
+ 禁止在业务代码中直接使用 datetime.now(), 统一通过本模块访问。
5
5
 
6
6
  Usage::
7
7
 
@@ -10,22 +10,22 @@ Usage::
10
10
  dt.now() # naive datetime, 中国时间, 用于数据库
11
11
  dt.now_str() # "2026-04-06 14:30:00"
12
12
  dt.today() # date(2026, 4, 6)
13
- dt.timestamp() # Unix 时间戳(秒)
13
+ dt.timestamp() # Unix 时间戳(秒)
14
14
  dt.utc_now() # aware datetime, UTC, 用于 JWT
15
15
  """
16
16
 
17
- from datetime import date, datetime, timedelta
17
+ from datetime import date, datetime
18
18
  from zoneinfo import ZoneInfo
19
19
 
20
20
  TZ = ZoneInfo("Asia/Shanghai")
21
21
  UTC = ZoneInfo("UTC")
22
22
 
23
23
 
24
- # ── 业务时间(Asia/Shanghai ──────────────────────────────────────────
24
+ # ── 业务时间(Asia/Shanghai) ──────────────────────────────────────────
25
25
 
26
26
 
27
27
  def now() -> datetime:
28
- """当前中国时间(naive,用于数据库存储)。"""
28
+ """当前中国时间(naive, 用于数据库存储)。"""
29
29
  return datetime.now(TZ).replace(tzinfo=None)
30
30
 
31
31
 
@@ -40,13 +40,13 @@ def today() -> date:
40
40
 
41
41
 
42
42
  def timestamp() -> float:
43
- """当前 Unix 时间戳(秒)。"""
43
+ """当前 Unix 时间戳(秒)。"""
44
44
  return datetime.now(TZ).timestamp()
45
45
 
46
46
 
47
- # ── UTCJWT 等协议场景) ──────────────────────────────────────────────
47
+ # ── UTC(JWT 等协议场景) ──────────────────────────────────────────────
48
48
 
49
49
 
50
50
  def utc_now() -> datetime:
51
- """当前 UTC 时间(aware,用于 JWT exp 等协议字段)。"""
51
+ """当前 UTC 时间(aware, 用于 JWT exp 等协议字段)。"""
52
52
  return datetime.now(UTC)
@@ -27,7 +27,7 @@ from typing import TYPE_CHECKING, Any
27
27
  from loguru import logger as _logger
28
28
 
29
29
  if TYPE_CHECKING:
30
- from loguru import Logger, Record
30
+ from loguru import Record
31
31
 
32
32
 
33
33
  _DEFAULT_CONSOLE_FORMAT = (
@@ -45,6 +45,69 @@ _handler_ids: list[int] = []
45
45
  _intercept_state: dict[str, list[logging.Handler]] = {}
46
46
 
47
47
 
48
+ class Logger:
49
+ """dwyeapi 日志门面,遵循 stdlib ``logging.Logger`` 语义。
50
+
51
+ - 消息用 ``%s`` / ``%d`` 等 stdlib 占位符,位置参数延迟格式化
52
+ - 支持 ``exc_info=True`` 记录当前异常堆栈
53
+ - 底层可为 loguru / stdlib logging / structlog,调用方无感知
54
+ """
55
+
56
+ __slots__ = ("_impl",)
57
+
58
+ def __init__(self, impl: Any) -> None:
59
+ """保存底层日志对象。
60
+
61
+ Args:
62
+ impl: 真正执行日志调用的底层对象。当前由 :func:`get_logger`
63
+ 注入 loguru logger,需支持 ``opt(exception=...)`` 与
64
+ ``debug`` / ``info`` / ``warning`` / ``error`` /
65
+ ``critical`` 方法。
66
+ """
67
+ self._impl = impl
68
+
69
+ def _emit(self, level: str, msg: str, args: tuple[Any, ...], exc_info: bool) -> None:
70
+ """格式化消息并转发到底层实现。
71
+
72
+ Args:
73
+ level: 日志级别方法名(``debug``/``info``/``warning``/
74
+ ``error``/``critical``)。
75
+ msg: 日志消息模板,支持 stdlib ``%`` 占位符。
76
+ args: 用于 ``%``-格式化的位置参数;为空时保留 ``msg`` 原样。
77
+ exc_info: 是否附带当前异常堆栈。
78
+ """
79
+ try:
80
+ formatted = msg % args if args else msg
81
+ except (TypeError, ValueError):
82
+ formatted = f"{msg} args={args!r}"
83
+ target = self._impl.opt(exception=True) if exc_info else self._impl
84
+ getattr(target, level)(formatted)
85
+
86
+ def debug(self, msg: str, *args: Any, exc_info: bool = False, **_: Any) -> None:
87
+ """记录 DEBUG 级别日志。"""
88
+ self._emit("debug", msg, args, exc_info)
89
+
90
+ def info(self, msg: str, *args: Any, exc_info: bool = False, **_: Any) -> None:
91
+ """记录 INFO 级别日志。"""
92
+ self._emit("info", msg, args, exc_info)
93
+
94
+ def warning(self, msg: str, *args: Any, exc_info: bool = False, **_: Any) -> None:
95
+ """记录 WARNING 级别日志。"""
96
+ self._emit("warning", msg, args, exc_info)
97
+
98
+ def error(self, msg: str, *args: Any, exc_info: bool = False, **_: Any) -> None:
99
+ """记录 ERROR 级别日志。"""
100
+ self._emit("error", msg, args, exc_info)
101
+
102
+ def exception(self, msg: str, *args: Any, **_: Any) -> None:
103
+ """记录 ERROR 级别日志并自动附带当前异常堆栈。"""
104
+ self._emit("error", msg, args, exc_info=True)
105
+
106
+ def critical(self, msg: str, *args: Any, exc_info: bool = False, **_: Any) -> None:
107
+ """记录 CRITICAL 级别日志。"""
108
+ self._emit("critical", msg, args, exc_info)
109
+
110
+
48
111
  class _InterceptHandler(logging.Handler):
49
112
  """Route standard ``logging`` records into the loguru sink chain."""
50
113
 
@@ -173,18 +236,18 @@ def _install_stdlib_intercept(level: str, targets: list[str] | None) -> None:
173
236
 
174
237
 
175
238
  def get_logger(name: str | None = None) -> Logger:
176
- """Return the shared logger, optionally bound to a module name.
239
+ """返回 dwyeapi ``Logger`` 门面,可选绑定模块名。
177
240
 
178
241
  Args:
179
- name: Logical module name recorded in the ``module`` extra field.
242
+ name: 记录到 ``module`` extra 字段的逻辑模块名。
180
243
 
181
244
  Returns:
182
- A loguru ``Logger`` proxy. If ``configure`` has not been called yet,
183
- returns the raw default logger (stderr at DEBUG).
245
+ ``Logger`` 门面实例。底层当前使用 loguru,但此细节对调用方
246
+ 不可见,以便后续替换为其他日志库(stdlib logging /
247
+ structlog 等)时无需改动调用点。
184
248
  """
185
- if name:
186
- return _logger.bind(module=name)
187
- return _logger
249
+ impl = _logger.bind(module=name) if name else _logger
250
+ return Logger(impl)
188
251
 
189
252
 
190
253
  def close() -> None:
@@ -0,0 +1,5 @@
1
+ """可插拔 Provider 扩展。
2
+
3
+ 业务相关但复用性强的外部服务抽象(邮件、短信等),通过 Protocol + 配置驱动切换。
4
+ 第三方 SDK 依赖一律走 optional-dependencies,业务项目 `pip install dwyeapi[email]` 按需启用。
5
+ """
@@ -0,0 +1,29 @@
1
+ """Email Provider 模块 -- 提供可插拔的邮件验证码发送能力。
2
+
3
+ 提供 Protocol 抽象 + 配置类 + 工厂函数 + 多种实现(mock/resend/aliyun)。
4
+ 业务项目通过 `pip install dwyeapi[email-resend]` 等 extras 按需安装 provider 依赖。
5
+
6
+ 典型用法:
7
+ from dwyeapi.providers.email import EmailSettings, make_email_provider
8
+
9
+ class Settings(DwyBaseSettings):
10
+ email: EmailSettings = EmailSettings()
11
+
12
+ settings = Settings()
13
+ provider = make_email_provider(settings.email)
14
+ await provider.send_code("user@example.com")
15
+ """
16
+
17
+ from dwyeapi.providers.email.base import EmailProviderBase
18
+ from dwyeapi.providers.email.config import AliyunEmailConfig, EmailSettings, ResendConfig
19
+ from dwyeapi.providers.email.factory import make_email_provider
20
+ from dwyeapi.providers.email.protocol import EmailProvider
21
+
22
+ __all__ = [
23
+ "AliyunEmailConfig",
24
+ "EmailProvider",
25
+ "EmailProviderBase",
26
+ "EmailSettings",
27
+ "ResendConfig",
28
+ "make_email_provider",
29
+ ]
@@ -0,0 +1,46 @@
1
+ """阿里云邮件推送 Email Provider -- 占位实现。
2
+
3
+ 需要 `pip install dwyeapi[email-aliyun]` 才能使用。实际调用待首次接入项目时补全。
4
+ """
5
+
6
+ import redis.asyncio as aioredis
7
+
8
+ from dwyeapi.providers.email.base import DEFAULT_CODE_LENGTH, DEFAULT_CODE_TTL, EmailProviderBase
9
+
10
+
11
+ class AliyunEmailProvider(EmailProviderBase):
12
+ """基于阿里云邮件推送的 Email Provider(占位)。
13
+
14
+ 首次接入项目时基于 `alibabacloud_dm20151123` SDK 实现 `_send()`。
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ access_key_id: str,
20
+ access_key_secret: str,
21
+ account_name: str,
22
+ from_alias: str = "",
23
+ subject: str = "验证码",
24
+ code_ttl: int = DEFAULT_CODE_TTL,
25
+ code_length: int = DEFAULT_CODE_LENGTH,
26
+ redis: aioredis.Redis | None = None,
27
+ ) -> None:
28
+ """初始化。
29
+
30
+ Raises:
31
+ ImportError: 未安装 alibabacloud_dm20151123 时抛出。
32
+ """
33
+ super().__init__(code_ttl=code_ttl, code_length=code_length, redis=redis)
34
+ try:
35
+ import alibabacloud_dm20151123 # noqa: F401
36
+ except ImportError as e:
37
+ raise ImportError("使用 AliyunEmailProvider 需要安装: pip install dwyeapi[email-aliyun]") from e
38
+ self._access_key_id = access_key_id
39
+ self._access_key_secret = access_key_secret
40
+ self._account_name = account_name
41
+ self._from_alias = from_alias
42
+ self._subject = subject
43
+
44
+ async def _send(self, target: str, code: str) -> bool:
45
+ """调用阿里云 DirectMail API 发送邮件。"""
46
+ raise NotImplementedError("AliyunEmailProvider 暂未实现,首次接入项目时补全 _send")
@@ -0,0 +1,71 @@
1
+ """Email Provider 基类 -- 封装 Redis 验证码存取逻辑。"""
2
+
3
+ import secrets
4
+ from abc import ABC, abstractmethod
5
+
6
+ import redis.asyncio as aioredis
7
+
8
+ from dwyeapi.cache import get_redis
9
+
10
+ CODE_KEY_PREFIX = "dwyeapi:email:code:"
11
+ DEFAULT_CODE_TTL = 300
12
+ DEFAULT_CODE_LENGTH = 6
13
+
14
+
15
+ class EmailProviderBase(ABC):
16
+ """Email Provider 抽象基类。
17
+
18
+ 共享验证码生成 + Redis 存取 + 一次性校验逻辑;子类只实现 `_send()` 发送动作。
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ code_ttl: int = DEFAULT_CODE_TTL,
24
+ code_length: int = DEFAULT_CODE_LENGTH,
25
+ redis: aioredis.Redis | None = None,
26
+ ) -> None:
27
+ """初始化。
28
+
29
+ Args:
30
+ code_ttl: 验证码有效期(秒),默认 300。
31
+ code_length: 验证码位数,默认 6。
32
+ redis: 可选显式注入的 Redis 连接;为 None 时 fallback 到 dwyeapi.cache.get_redis()。
33
+ """
34
+ self._ttl = code_ttl
35
+ self._length = code_length
36
+ self._redis = redis
37
+
38
+ async def _get_redis(self) -> aioredis.Redis:
39
+ """获取 Redis 连接,优先使用注入的,否则取全局单例。"""
40
+ if self._redis is not None:
41
+ return self._redis
42
+ return await get_redis()
43
+
44
+ def _generate_code(self) -> str:
45
+ """生成指定位数的数字验证码。"""
46
+ return "".join(secrets.choice("0123456789") for _ in range(self._length))
47
+
48
+ async def send_code(self, target: str) -> bool:
49
+ """生成验证码存 Redis 并调用 `_send()` 发送。"""
50
+ code = self._generate_code()
51
+ redis = await self._get_redis()
52
+ await redis.set(f"{CODE_KEY_PREFIX}{target}", code, ex=self._ttl)
53
+ return await self._send(target, code)
54
+
55
+ async def verify_code(self, target: str, code: str) -> bool:
56
+ """从 Redis 读取存储的验证码比对,成功则删除 key(一次性)。"""
57
+ redis = await self._get_redis()
58
+ key = f"{CODE_KEY_PREFIX}{target}"
59
+ stored = await redis.get(key)
60
+ if stored is None:
61
+ return False
62
+ stored_str = stored if isinstance(stored, str) else stored.decode()
63
+ if stored_str != code:
64
+ return False
65
+ await redis.delete(key)
66
+ return True
67
+
68
+ @abstractmethod
69
+ async def _send(self, target: str, code: str) -> bool:
70
+ """子类实现发送动作。"""
71
+ ...
@@ -0,0 +1,42 @@
1
+ """Email Provider 配置模型。"""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ResendConfig(BaseModel):
9
+ """Resend Email Provider 配置。"""
10
+
11
+ api_key: str = ""
12
+ from_email: str = "noreply@example.com"
13
+ subject: str = "验证码"
14
+
15
+
16
+ class AliyunEmailConfig(BaseModel):
17
+ """阿里云邮件推送 Provider 配置。"""
18
+
19
+ access_key_id: str = ""
20
+ access_key_secret: str = ""
21
+ account_name: str = ""
22
+ from_alias: str = ""
23
+ subject: str = "验证码"
24
+
25
+
26
+ class EmailSettings(BaseModel):
27
+ """Email 模块配置 -- 业务项目嵌入 Settings 的入口。
28
+
29
+ 用法:
30
+ class Settings(DwyBaseSettings):
31
+ email: EmailSettings = EmailSettings()
32
+
33
+ `.env` 自动识别(双下划线嵌套):
34
+ EMAIL__PROVIDER=resend
35
+ EMAIL__RESEND__API_KEY=re_xxx
36
+ """
37
+
38
+ provider: Literal["mock", "resend", "aliyun"] = "mock"
39
+ code_ttl: int = 300
40
+ code_length: int = 6
41
+ resend: ResendConfig = ResendConfig()
42
+ aliyun: AliyunEmailConfig = AliyunEmailConfig()
@@ -0,0 +1,61 @@
1
+ """Email Provider 工厂 -- 根据配置选择实现。"""
2
+
3
+ from dwyeapi.config import is_prod
4
+ from dwyeapi.providers.email.config import EmailSettings
5
+ from dwyeapi.providers.email.protocol import EmailProvider
6
+
7
+
8
+ def make_email_provider(settings: EmailSettings) -> EmailProvider:
9
+ """根据 `settings.provider` 构造对应的 Email Provider 实例。
10
+
11
+ Args:
12
+ settings: Email 模块配置(项目 Settings 的嵌套字段)。
13
+
14
+ Returns:
15
+ 实现了 `EmailProvider` Protocol 的实例。
16
+
17
+ Raises:
18
+ ValueError: 未知 provider、必填配置缺失,或 prod 环境下选择 mock provider。
19
+ ImportError: provider 对应的 extra 未安装。
20
+ """
21
+ common = {"code_ttl": settings.code_ttl, "code_length": settings.code_length}
22
+
23
+ match settings.provider:
24
+ case "mock":
25
+ if is_prod():
26
+ raise ValueError(
27
+ "EMAIL__PROVIDER=mock 在 prod 环境下被禁止;"
28
+ "请设置 ENVIRONMENT=dev 或改用真实 provider(resend/aliyun)"
29
+ )
30
+ from dwyeapi.providers.email.mock import MockEmailProvider
31
+
32
+ return MockEmailProvider(**common)
33
+
34
+ case "resend":
35
+ from dwyeapi.providers.email.resend import ResendEmailProvider
36
+
37
+ if not settings.resend.api_key:
38
+ raise ValueError("EMAIL__RESEND__API_KEY 未配置")
39
+ return ResendEmailProvider(
40
+ api_key=settings.resend.api_key,
41
+ from_email=settings.resend.from_email,
42
+ subject=settings.resend.subject,
43
+ **common,
44
+ )
45
+
46
+ case "aliyun":
47
+ from dwyeapi.providers.email.aliyun import AliyunEmailProvider
48
+
49
+ if not settings.aliyun.access_key_id or not settings.aliyun.access_key_secret:
50
+ raise ValueError("EMAIL__ALIYUN__ACCESS_KEY_ID/SECRET 未配置")
51
+ return AliyunEmailProvider(
52
+ access_key_id=settings.aliyun.access_key_id,
53
+ access_key_secret=settings.aliyun.access_key_secret,
54
+ account_name=settings.aliyun.account_name,
55
+ from_alias=settings.aliyun.from_alias,
56
+ subject=settings.aliyun.subject,
57
+ **common,
58
+ )
59
+
60
+ case _:
61
+ raise ValueError(f"未知 email provider: {settings.provider}")
@@ -0,0 +1,15 @@
1
+ """Mock Email Provider -- 开发/测试环境使用,日志打印验证码不真实发送。"""
2
+
3
+ from dwyeapi.logger import get_logger
4
+ from dwyeapi.providers.email.base import EmailProviderBase
5
+
6
+ logger = get_logger(__name__)
7
+
8
+
9
+ class MockEmailProvider(EmailProviderBase):
10
+ """Mock 邮箱验证码 Provider。"""
11
+
12
+ async def _send(self, target: str, code: str) -> bool:
13
+ """打印验证码到日志,始终返回 True。"""
14
+ logger.info("Mock Email sent: target=%s code=%s", target, code)
15
+ return True