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.
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/PKG-INFO +13 -1
- dwyeapi-0.5.1/README.md +52 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/pyproject.toml +15 -1
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/__init__.py +4 -3
- dwyeapi-0.5.1/src/dwyeapi/config.py +85 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/dt.py +9 -9
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/logger.py +71 -8
- dwyeapi-0.5.1/src/dwyeapi/providers/__init__.py +5 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/email/__init__.py +29 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/email/aliyun.py +46 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/email/base.py +71 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/email/config.py +42 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/email/factory.py +61 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/email/mock.py +15 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/email/protocol.py +34 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/email/resend.py +70 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/sms/__init__.py +28 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/sms/aliyun.py +46 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/sms/base.py +71 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/sms/config.py +33 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/sms/factory.py +48 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/sms/mock.py +23 -0
- dwyeapi-0.5.1/src/dwyeapi/providers/sms/protocol.py +34 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/router.py +12 -2
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/worker.py +4 -4
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/PKG-INFO +13 -1
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/SOURCES.txt +16 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/requires.txt +17 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_config.py +75 -1
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_logger.py +63 -0
- dwyeapi-0.4.0/README.md +0 -21
- dwyeapi-0.4.0/src/dwyeapi/config.py +0 -39
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/setup.cfg +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/cache.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/database.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/dependencies.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/exceptions.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/masking.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/pagination.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/response.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/security.py +2 -2
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/__init__.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/context.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/model.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/pool.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/registry.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/schema.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi/tasks/service.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/src/dwyeapi.egg-info/top_level.txt +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_cache.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_database.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_dependencies.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_exceptions.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_masking.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_pagination.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_response.py +0 -0
- {dwyeapi-0.4.0 → dwyeapi-0.5.1}/tests/test_security.py +0 -0
- {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.
|
|
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"
|
dwyeapi-0.5.1/README.md
ADDED
|
@@ -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.
|
|
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
|
|
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.
|
|
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/Shanghai
|
|
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
|
|
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
|
-
# ──
|
|
24
|
+
# ── 业务时间(Asia/Shanghai) ──────────────────────────────────────────
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def now() -> datetime:
|
|
28
|
-
"""
|
|
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
|
-
# ── UTC
|
|
47
|
+
# ── UTC(JWT 等协议场景) ──────────────────────────────────────────────
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def utc_now() -> datetime:
|
|
51
|
-
"""当前 UTC
|
|
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
|
|
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
|
-
"""
|
|
239
|
+
"""返回 dwyeapi ``Logger`` 门面,可选绑定模块名。
|
|
177
240
|
|
|
178
241
|
Args:
|
|
179
|
-
name:
|
|
242
|
+
name: 记录到 ``module`` extra 字段的逻辑模块名。
|
|
180
243
|
|
|
181
244
|
Returns:
|
|
182
|
-
|
|
183
|
-
|
|
245
|
+
``Logger`` 门面实例。底层当前使用 loguru,但此细节对调用方
|
|
246
|
+
不可见,以便后续替换为其他日志库(stdlib logging /
|
|
247
|
+
structlog 等)时无需改动调用点。
|
|
184
248
|
"""
|
|
185
|
-
if name
|
|
186
|
-
|
|
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,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
|