dwyeapi 0.7.1__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/PKG-INFO +1 -1
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/pyproject.toml +1 -1
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/aliyun.py +18 -3
- dwyeapi-0.8.0/src/dwyeapi/providers/email/base.py +193 -0
- dwyeapi-0.8.0/src/dwyeapi/providers/email/config.py +112 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/factory.py +9 -1
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/resend.py +24 -7
- dwyeapi-0.8.0/src/dwyeapi/providers/sms/config.py +64 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/PKG-INFO +1 -1
- dwyeapi-0.7.1/src/dwyeapi/providers/email/base.py +0 -71
- dwyeapi-0.7.1/src/dwyeapi/providers/email/config.py +0 -42
- dwyeapi-0.7.1/src/dwyeapi/providers/sms/config.py +0 -33
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/README.md +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/setup.cfg +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/__init__.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/cache.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/config.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/database.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/dependencies.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/dt.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/exceptions.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/health.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/logger.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/masking.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/pagination.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/__init__.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/__init__.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/mock.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/protocol.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/__init__.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/aliyun.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/base.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/factory.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/mock.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/protocol.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/response.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/security.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/__init__.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/context.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/model.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/pool.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/registry.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/router.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/schema.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/service.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/worker.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/SOURCES.txt +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/requires.txt +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/top_level.txt +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_cache.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_config.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_database.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_dependencies.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_exceptions.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_health.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_logger.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_masking.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_pagination.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_response.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_security.py +0 -0
- {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_tasks.py +0 -0
|
@@ -11,7 +11,8 @@ from dwyeapi.providers.email.base import DEFAULT_CODE_LENGTH, DEFAULT_CODE_TTL,
|
|
|
11
11
|
class AliyunEmailProvider(EmailProviderBase):
|
|
12
12
|
"""基于阿里云邮件推送的 Email Provider(占位)。
|
|
13
13
|
|
|
14
|
-
首次接入项目时基于 `alibabacloud_dm20151123` SDK 实现 `_send()
|
|
14
|
+
首次接入项目时基于 `alibabacloud_dm20151123` SDK 实现 `_send()`,
|
|
15
|
+
调用时使用 `self._render_code_html(code)` / `self._render_code_text(code)` 复用品牌模板。
|
|
15
16
|
"""
|
|
16
17
|
|
|
17
18
|
def __init__(
|
|
@@ -23,6 +24,11 @@ class AliyunEmailProvider(EmailProviderBase):
|
|
|
23
24
|
subject: str = "验证码",
|
|
24
25
|
code_ttl: int = DEFAULT_CODE_TTL,
|
|
25
26
|
code_length: int = DEFAULT_CODE_LENGTH,
|
|
27
|
+
brand_name: str = "",
|
|
28
|
+
brand_tagline: str = "",
|
|
29
|
+
brand_url: str = "",
|
|
30
|
+
brand_slogan: str = "",
|
|
31
|
+
support_email: str = "",
|
|
26
32
|
redis: aioredis.Redis | None = None,
|
|
27
33
|
) -> None:
|
|
28
34
|
"""初始化。
|
|
@@ -30,7 +36,16 @@ class AliyunEmailProvider(EmailProviderBase):
|
|
|
30
36
|
Raises:
|
|
31
37
|
ImportError: 未安装 alibabacloud_dm20151123 时抛出。
|
|
32
38
|
"""
|
|
33
|
-
super().__init__(
|
|
39
|
+
super().__init__(
|
|
40
|
+
code_ttl=code_ttl,
|
|
41
|
+
code_length=code_length,
|
|
42
|
+
brand_name=brand_name,
|
|
43
|
+
brand_tagline=brand_tagline,
|
|
44
|
+
brand_url=brand_url,
|
|
45
|
+
brand_slogan=brand_slogan,
|
|
46
|
+
support_email=support_email,
|
|
47
|
+
redis=redis,
|
|
48
|
+
)
|
|
34
49
|
try:
|
|
35
50
|
import alibabacloud_dm20151123 # noqa: F401
|
|
36
51
|
except ImportError as e:
|
|
@@ -38,7 +53,7 @@ class AliyunEmailProvider(EmailProviderBase):
|
|
|
38
53
|
self._access_key_id = access_key_id
|
|
39
54
|
self._access_key_secret = access_key_secret
|
|
40
55
|
self._account_name = account_name
|
|
41
|
-
self._from_alias = from_alias
|
|
56
|
+
self._from_alias = from_alias or brand_name
|
|
42
57
|
self._subject = subject
|
|
43
58
|
|
|
44
59
|
async def _send(self, target: str, code: str) -> bool:
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Email Provider 基类 -- 封装 Redis 验证码存取 + 品牌化 HTML/text 渲染。"""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from html import escape
|
|
6
|
+
|
|
7
|
+
import redis.asyncio as aioredis
|
|
8
|
+
|
|
9
|
+
from dwyeapi.cache import get_redis
|
|
10
|
+
|
|
11
|
+
CODE_KEY_PREFIX = "dwyeapi:email:code:"
|
|
12
|
+
DEFAULT_CODE_TTL = 300
|
|
13
|
+
DEFAULT_CODE_LENGTH = 6
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmailProviderBase(ABC):
|
|
17
|
+
"""Email Provider 抽象基类。
|
|
18
|
+
|
|
19
|
+
共享验证码生成 + Redis 存取 + 一次性校验逻辑;子类只实现 `_send()` 发送动作。
|
|
20
|
+
同时提供 `_render_code_html()` / `_render_code_text()` 让所有子类共用同一套品牌化模板。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
code_ttl: int = DEFAULT_CODE_TTL,
|
|
26
|
+
code_length: int = DEFAULT_CODE_LENGTH,
|
|
27
|
+
brand_name: str = "",
|
|
28
|
+
brand_tagline: str = "",
|
|
29
|
+
brand_url: str = "",
|
|
30
|
+
brand_slogan: str = "",
|
|
31
|
+
support_email: str = "",
|
|
32
|
+
redis: aioredis.Redis | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""初始化。
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
code_ttl: 验证码有效期(秒),默认 300。
|
|
38
|
+
code_length: 验证码位数,默认 6。
|
|
39
|
+
brand_name: 品牌名,展示在邮件页眉与页脚版权;空串走通用模板。
|
|
40
|
+
brand_tagline: 品牌副标语(英文/口号),展示在页眉右上;空串则不显示。
|
|
41
|
+
brand_url: 品牌官网,展示在页脚链接;空串则不显示。
|
|
42
|
+
brand_slogan: 页脚版权下方一行说明文案;空串则不显示。
|
|
43
|
+
support_email: 客服邮箱,展示在邮件正文底部;空串则不显示。
|
|
44
|
+
redis: 可选显式注入的 Redis 连接;为 None 时 fallback 到 dwyeapi.cache.get_redis()。
|
|
45
|
+
"""
|
|
46
|
+
self._ttl = code_ttl
|
|
47
|
+
self._length = code_length
|
|
48
|
+
self._brand_name = brand_name
|
|
49
|
+
self._brand_tagline = brand_tagline
|
|
50
|
+
self._brand_url = brand_url
|
|
51
|
+
self._brand_slogan = brand_slogan
|
|
52
|
+
self._support_email = support_email
|
|
53
|
+
self._redis = redis
|
|
54
|
+
|
|
55
|
+
async def _get_redis(self) -> aioredis.Redis:
|
|
56
|
+
"""获取 Redis 连接,优先使用注入的,否则取全局单例。"""
|
|
57
|
+
if self._redis is not None:
|
|
58
|
+
return self._redis
|
|
59
|
+
return await get_redis()
|
|
60
|
+
|
|
61
|
+
def _generate_code(self) -> str:
|
|
62
|
+
"""生成指定位数的数字验证码。"""
|
|
63
|
+
return "".join(secrets.choice("0123456789") for _ in range(self._length))
|
|
64
|
+
|
|
65
|
+
async def send_code(self, target: str) -> bool:
|
|
66
|
+
"""生成验证码存 Redis 并调用 `_send()` 发送。"""
|
|
67
|
+
code = self._generate_code()
|
|
68
|
+
redis = await self._get_redis()
|
|
69
|
+
await redis.set(f"{CODE_KEY_PREFIX}{target}", code, ex=self._ttl)
|
|
70
|
+
return await self._send(target, code)
|
|
71
|
+
|
|
72
|
+
async def verify_code(self, target: str, code: str) -> bool:
|
|
73
|
+
"""从 Redis 读取存储的验证码比对,成功则删除 key(一次性)。"""
|
|
74
|
+
redis = await self._get_redis()
|
|
75
|
+
key = f"{CODE_KEY_PREFIX}{target}"
|
|
76
|
+
stored = await redis.get(key)
|
|
77
|
+
if stored is None:
|
|
78
|
+
return False
|
|
79
|
+
stored_str = stored if isinstance(stored, str) else stored.decode()
|
|
80
|
+
if stored_str != code:
|
|
81
|
+
return False
|
|
82
|
+
await redis.delete(key)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def _render_code_html(self, code: str) -> str:
|
|
86
|
+
"""渲染品牌化验证码 HTML 邮件 -- 主流邮件客户端兼容的 inline CSS 模板。
|
|
87
|
+
|
|
88
|
+
使用 table 布局 + inline style,适配 Gmail / Outlook / 163 / QQ / Apple Mail。
|
|
89
|
+
所有动态文案经 `html.escape` 转义,避免 brand_name 含特殊字符时破坏结构。
|
|
90
|
+
"""
|
|
91
|
+
minutes = max(self._ttl // 60, 1)
|
|
92
|
+
brand = escape(self._brand_name) or "验证码"
|
|
93
|
+
tagline = escape(self._brand_tagline)
|
|
94
|
+
url = escape(self._brand_url)
|
|
95
|
+
slogan = escape(self._brand_slogan)
|
|
96
|
+
support = escape(self._support_email)
|
|
97
|
+
code_safe = escape(code)
|
|
98
|
+
|
|
99
|
+
header_right = (
|
|
100
|
+
f'<td align="right" style="color:#94a3b8;font-size:13px;letter-spacing:1px;">{tagline}</td>'
|
|
101
|
+
if tagline
|
|
102
|
+
else ""
|
|
103
|
+
)
|
|
104
|
+
support_block = (
|
|
105
|
+
f'<p style="margin:0 0 8px 0;color:#475569;font-size:13px;line-height:1.7;">'
|
|
106
|
+
f"如有疑问,请联系客服:"
|
|
107
|
+
f'<a href="mailto:{support}" style="color:#1e3a8a;text-decoration:none;">{support}</a>'
|
|
108
|
+
"</p>"
|
|
109
|
+
if support
|
|
110
|
+
else ""
|
|
111
|
+
)
|
|
112
|
+
slogan_block = f"<br/>{slogan}" if slogan else ""
|
|
113
|
+
footer_link = (
|
|
114
|
+
f'<a href="{url}" style="color:#1e3a8a;text-decoration:none;font-weight:500;">'
|
|
115
|
+
f"{url.replace('https://', '').replace('http://', '')} →"
|
|
116
|
+
"</a>"
|
|
117
|
+
if url
|
|
118
|
+
else ""
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return f"""<!DOCTYPE html>
|
|
122
|
+
<html lang="zh-CN">
|
|
123
|
+
<head>
|
|
124
|
+
<meta charset="UTF-8">
|
|
125
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
126
|
+
<title>{brand} 验证码</title>
|
|
127
|
+
</head>
|
|
128
|
+
<body style="margin:0;padding:0;background-color:#f4f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;-webkit-font-smoothing:antialiased;">
|
|
129
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f4f6fa;padding:40px 16px;">
|
|
130
|
+
<tr><td align="center">
|
|
131
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="560" style="max-width:560px;width:100%;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(15,23,42,0.06);">
|
|
132
|
+
<tr>
|
|
133
|
+
<td style="background:linear-gradient(135deg,#0f172a 0%,#1e3a8a 100%);padding:32px 40px;">
|
|
134
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
135
|
+
<tr>
|
|
136
|
+
<td style="color:#ffffff;font-size:20px;font-weight:600;letter-spacing:1px;">{brand}</td>
|
|
137
|
+
{header_right}
|
|
138
|
+
</tr>
|
|
139
|
+
</table>
|
|
140
|
+
</td>
|
|
141
|
+
</tr>
|
|
142
|
+
<tr>
|
|
143
|
+
<td style="padding:40px 40px 8px 40px;">
|
|
144
|
+
<h1 style="margin:0 0 12px 0;color:#0f172a;font-size:22px;font-weight:600;line-height:1.4;">您正在进行身份验证</h1>
|
|
145
|
+
<p style="margin:0 0 28px 0;color:#475569;font-size:14px;line-height:1.7;">
|
|
146
|
+
您好,请使用以下验证码完成本次操作。该验证码 <strong style="color:#0f172a;">{minutes} 分钟内有效</strong>,请勿向任何人泄露。
|
|
147
|
+
</p>
|
|
148
|
+
<div style="margin:0 0 28px 0;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:24px;text-align:center;">
|
|
149
|
+
<div style="color:#64748b;font-size:12px;letter-spacing:2px;margin-bottom:12px;">VERIFICATION CODE</div>
|
|
150
|
+
<div style="color:#0f172a;font-size:36px;font-weight:700;letter-spacing:10px;font-family:'SF Mono','Menlo','Consolas',monospace;">{code_safe}</div>
|
|
151
|
+
</div>
|
|
152
|
+
<p style="margin:0 0 8px 0;color:#475569;font-size:13px;line-height:1.7;">若非本人操作,请忽略此邮件,您的账户依然安全。</p>
|
|
153
|
+
{support_block}
|
|
154
|
+
<p style="margin:0;color:#94a3b8;font-size:12px;line-height:1.7;">本邮件由系统自动发送,请勿直接回复。</p>
|
|
155
|
+
</td>
|
|
156
|
+
</tr>
|
|
157
|
+
<tr>
|
|
158
|
+
<td style="padding:24px 40px 32px 40px;border-top:1px solid #f1f5f9;">
|
|
159
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
160
|
+
<tr>
|
|
161
|
+
<td style="color:#94a3b8;font-size:12px;line-height:1.6;">© {brand}{slogan_block}</td>
|
|
162
|
+
<td align="right" style="color:#94a3b8;font-size:12px;">{footer_link}</td>
|
|
163
|
+
</tr>
|
|
164
|
+
</table>
|
|
165
|
+
</td>
|
|
166
|
+
</tr>
|
|
167
|
+
</table>
|
|
168
|
+
</td></tr>
|
|
169
|
+
</table>
|
|
170
|
+
</body>
|
|
171
|
+
</html>"""
|
|
172
|
+
|
|
173
|
+
def _render_code_text(self, code: str) -> str:
|
|
174
|
+
"""渲染纯文本备份 -- 给不支持 HTML 的客户端,同时降低被反垃圾误判的概率。"""
|
|
175
|
+
minutes = max(self._ttl // 60, 1)
|
|
176
|
+
brand = self._brand_name or "验证码"
|
|
177
|
+
lines = [
|
|
178
|
+
f"【{brand}】您正在进行身份验证。",
|
|
179
|
+
"",
|
|
180
|
+
f"您的验证码是:{code},{minutes} 分钟内有效,请勿向任何人泄露。",
|
|
181
|
+
"",
|
|
182
|
+
"若非本人操作,请忽略此邮件。",
|
|
183
|
+
]
|
|
184
|
+
if self._support_email:
|
|
185
|
+
lines.append(f"如有疑问,请联系客服:{self._support_email}")
|
|
186
|
+
if self._brand_url:
|
|
187
|
+
lines.append(f"— {self._brand_name or ''} {self._brand_url}".strip())
|
|
188
|
+
return "\n".join(lines)
|
|
189
|
+
|
|
190
|
+
@abstractmethod
|
|
191
|
+
async def _send(self, target: str, code: str) -> bool:
|
|
192
|
+
"""子类实现发送动作。"""
|
|
193
|
+
...
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Email Provider 配置模型。"""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ResendConfig(BaseModel):
|
|
9
|
+
"""Resend Email Provider 配置。
|
|
10
|
+
|
|
11
|
+
EMAIL__PROVIDER=resend 时生效;空串默认仅为 pydantic 嵌套实例化需要,
|
|
12
|
+
真正启用时 api_key / from_email 必须通过 .env 显式提供。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
api_key: str = Field(
|
|
16
|
+
default="",
|
|
17
|
+
description="[必填: resend] Resend API Key,形如 re_xxx",
|
|
18
|
+
)
|
|
19
|
+
from_email: str = Field(
|
|
20
|
+
default="",
|
|
21
|
+
description="[必填: resend] 发件人邮箱,必须是已在 Resend 验证过的域名邮箱",
|
|
22
|
+
)
|
|
23
|
+
subject: str = Field(
|
|
24
|
+
default="验证码",
|
|
25
|
+
description="验证码邮件主题",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AliyunEmailConfig(BaseModel):
|
|
30
|
+
"""阿里云邮件推送 Provider 配置。
|
|
31
|
+
|
|
32
|
+
EMAIL__PROVIDER=aliyun 时生效;空串默认仅为 pydantic 嵌套实例化需要,
|
|
33
|
+
真正启用时凭证 + account_name 必须通过 .env 显式提供。
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
access_key_id: str = Field(
|
|
37
|
+
default="",
|
|
38
|
+
description="[必填: aliyun] 阿里云 AccessKey ID",
|
|
39
|
+
)
|
|
40
|
+
access_key_secret: str = Field(
|
|
41
|
+
default="",
|
|
42
|
+
description="[必填: aliyun] 阿里云 AccessKey Secret",
|
|
43
|
+
)
|
|
44
|
+
account_name: str = Field(
|
|
45
|
+
default="",
|
|
46
|
+
description="[必填: aliyun] 发信地址,需在阿里云邮件推送控制台预先配置",
|
|
47
|
+
)
|
|
48
|
+
from_alias: str = Field(
|
|
49
|
+
default="",
|
|
50
|
+
description="发件人显示名,留空则展示 account_name",
|
|
51
|
+
)
|
|
52
|
+
subject: str = Field(
|
|
53
|
+
default="验证码",
|
|
54
|
+
description="验证码邮件主题",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EmailSettings(BaseModel):
|
|
59
|
+
"""Email 模块配置 -- 业务项目嵌入 Settings 的入口。
|
|
60
|
+
|
|
61
|
+
用法:
|
|
62
|
+
class Settings(DwyBaseSettings):
|
|
63
|
+
email: EmailSettings = EmailSettings()
|
|
64
|
+
|
|
65
|
+
`.env` 自动识别(双下划线嵌套):
|
|
66
|
+
EMAIL__PROVIDER=resend
|
|
67
|
+
EMAIL__BRAND_NAME=宽舟科技
|
|
68
|
+
EMAIL__BRAND_URL=https://quantzone.tech
|
|
69
|
+
EMAIL__SUPPORT_EMAIL=support@quantzone.tech
|
|
70
|
+
EMAIL__RESEND__API_KEY=re_xxx
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
provider: Literal["mock", "resend", "aliyun"] = Field(
|
|
74
|
+
default="mock",
|
|
75
|
+
description='Email Provider 选择:"mock"(dev 限定,打印到日志)/ "resend" / "aliyun"',
|
|
76
|
+
)
|
|
77
|
+
code_ttl: int = Field(
|
|
78
|
+
default=300,
|
|
79
|
+
description="验证码有效期(秒),默认 5 分钟",
|
|
80
|
+
)
|
|
81
|
+
code_length: int = Field(
|
|
82
|
+
default=6,
|
|
83
|
+
description="验证码位数,默认 6 位数字",
|
|
84
|
+
)
|
|
85
|
+
brand_name: str = Field(
|
|
86
|
+
default="",
|
|
87
|
+
description="品牌名,展示在邮件页眉与页脚版权,留空则使用通用模板",
|
|
88
|
+
)
|
|
89
|
+
brand_tagline: str = Field(
|
|
90
|
+
default="",
|
|
91
|
+
description="品牌副标语(英文/口号),展示在页眉右上,留空则不显示",
|
|
92
|
+
)
|
|
93
|
+
brand_url: str = Field(
|
|
94
|
+
default="",
|
|
95
|
+
description="品牌官网,展示在页脚链接,留空则不显示",
|
|
96
|
+
)
|
|
97
|
+
brand_slogan: str = Field(
|
|
98
|
+
default="",
|
|
99
|
+
description="页脚版权下方一行说明文案,留空则不显示",
|
|
100
|
+
)
|
|
101
|
+
support_email: str = Field(
|
|
102
|
+
default="",
|
|
103
|
+
description="客服邮箱,展示在邮件正文底部,留空则不显示",
|
|
104
|
+
)
|
|
105
|
+
resend: ResendConfig = Field(
|
|
106
|
+
default_factory=ResendConfig,
|
|
107
|
+
description="Resend 配置,provider=resend 时必填",
|
|
108
|
+
)
|
|
109
|
+
aliyun: AliyunEmailConfig = Field(
|
|
110
|
+
default_factory=AliyunEmailConfig,
|
|
111
|
+
description="阿里云邮件推送配置,provider=aliyun 时必填",
|
|
112
|
+
)
|
|
@@ -18,7 +18,15 @@ def make_email_provider(settings: EmailSettings) -> EmailProvider:
|
|
|
18
18
|
ValueError: 未知 provider、必填配置缺失,或 prod 环境下选择 mock provider。
|
|
19
19
|
ImportError: provider 对应的 extra 未安装。
|
|
20
20
|
"""
|
|
21
|
-
common = {
|
|
21
|
+
common = {
|
|
22
|
+
"code_ttl": settings.code_ttl,
|
|
23
|
+
"code_length": settings.code_length,
|
|
24
|
+
"brand_name": settings.brand_name,
|
|
25
|
+
"brand_tagline": settings.brand_tagline,
|
|
26
|
+
"brand_url": settings.brand_url,
|
|
27
|
+
"brand_slogan": settings.brand_slogan,
|
|
28
|
+
"support_email": settings.support_email,
|
|
29
|
+
}
|
|
22
30
|
|
|
23
31
|
match settings.provider:
|
|
24
32
|
case "mock":
|
|
@@ -25,6 +25,11 @@ class ResendEmailProvider(EmailProviderBase):
|
|
|
25
25
|
subject: str = "验证码",
|
|
26
26
|
code_ttl: int = DEFAULT_CODE_TTL,
|
|
27
27
|
code_length: int = DEFAULT_CODE_LENGTH,
|
|
28
|
+
brand_name: str = "",
|
|
29
|
+
brand_tagline: str = "",
|
|
30
|
+
brand_url: str = "",
|
|
31
|
+
brand_slogan: str = "",
|
|
32
|
+
support_email: str = "",
|
|
28
33
|
redis: aioredis.Redis | None = None,
|
|
29
34
|
) -> None:
|
|
30
35
|
"""初始化 Resend Provider。
|
|
@@ -35,31 +40,43 @@ class ResendEmailProvider(EmailProviderBase):
|
|
|
35
40
|
subject: 邮件主题。
|
|
36
41
|
code_ttl: 验证码有效期(秒)。
|
|
37
42
|
code_length: 验证码位数。
|
|
43
|
+
brand_name: 品牌名,影响发件人显示名("brand_name <from_email>")与 HTML/text 模板。
|
|
44
|
+
brand_tagline: 品牌副标语。
|
|
45
|
+
brand_url: 品牌官网。
|
|
46
|
+
brand_slogan: 页脚说明。
|
|
47
|
+
support_email: 客服邮箱。
|
|
38
48
|
redis: 可选显式注入的 Redis 连接。
|
|
39
49
|
|
|
40
50
|
Raises:
|
|
41
51
|
ImportError: 未安装 resend 包时抛出,提示用户安装对应 extra。
|
|
42
52
|
"""
|
|
43
|
-
super().__init__(
|
|
53
|
+
super().__init__(
|
|
54
|
+
code_ttl=code_ttl,
|
|
55
|
+
code_length=code_length,
|
|
56
|
+
brand_name=brand_name,
|
|
57
|
+
brand_tagline=brand_tagline,
|
|
58
|
+
brand_url=brand_url,
|
|
59
|
+
brand_slogan=brand_slogan,
|
|
60
|
+
support_email=support_email,
|
|
61
|
+
redis=redis,
|
|
62
|
+
)
|
|
44
63
|
try:
|
|
45
64
|
import resend
|
|
46
65
|
except ImportError as e:
|
|
47
66
|
raise ImportError("使用 ResendEmailProvider 需要安装: pip install dwyeapi[email-resend]") from e
|
|
48
67
|
resend.api_key = api_key
|
|
49
68
|
self._resend = resend
|
|
50
|
-
self._from = from_email
|
|
69
|
+
self._from = f"{brand_name} <{from_email}>" if brand_name else from_email
|
|
51
70
|
self._subject = subject
|
|
52
71
|
|
|
53
72
|
async def _send(self, target: str, code: str) -> bool:
|
|
54
|
-
"""调用 Resend API
|
|
55
|
-
minutes = self._ttl // 60
|
|
73
|
+
"""调用 Resend API 发送邮件 -- 同时附 HTML 与 text,优化反垃圾评分。"""
|
|
56
74
|
params = {
|
|
57
75
|
"from": self._from,
|
|
58
76
|
"to": [target],
|
|
59
77
|
"subject": self._subject,
|
|
60
|
-
"html": (
|
|
61
|
-
|
|
62
|
-
),
|
|
78
|
+
"html": self._render_code_html(code),
|
|
79
|
+
"text": self._render_code_text(code),
|
|
63
80
|
}
|
|
64
81
|
try:
|
|
65
82
|
resp = await self._resend.Emails.send_async(params)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""SMS Provider 配置模型。"""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AliyunSmsConfig(BaseModel):
|
|
9
|
+
"""阿里云短信 Provider 配置。
|
|
10
|
+
|
|
11
|
+
SMS__PROVIDER=aliyun 时生效;空串默认仅为 pydantic 嵌套实例化需要,
|
|
12
|
+
provider=mock 场景下不会读取,真正启用时四项凭证必须通过 .env 显式提供。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
access_key_id: str = Field(
|
|
16
|
+
default="",
|
|
17
|
+
description="[必填: aliyun] 阿里云 AccessKey ID",
|
|
18
|
+
)
|
|
19
|
+
access_key_secret: str = Field(
|
|
20
|
+
default="",
|
|
21
|
+
description="[必填: aliyun] 阿里云 AccessKey Secret",
|
|
22
|
+
)
|
|
23
|
+
sign_name: str = Field(
|
|
24
|
+
default="",
|
|
25
|
+
description="[必填: aliyun] 短信签名(需在阿里云控制台预先申请)",
|
|
26
|
+
)
|
|
27
|
+
template_code: str = Field(
|
|
28
|
+
default="",
|
|
29
|
+
description="[必填: aliyun] 短信模板 code,例 SMS_123456789",
|
|
30
|
+
)
|
|
31
|
+
region_id: str = Field(
|
|
32
|
+
default="cn-hangzhou",
|
|
33
|
+
description="阿里云地域 ID,默认 cn-hangzhou",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SmsSettings(BaseModel):
|
|
38
|
+
"""SMS 模块配置 -- 业务项目嵌入 Settings 的入口。
|
|
39
|
+
|
|
40
|
+
用法:
|
|
41
|
+
class Settings(DwyBaseSettings):
|
|
42
|
+
sms: SmsSettings = SmsSettings()
|
|
43
|
+
|
|
44
|
+
`.env` 自动识别(双下划线嵌套):
|
|
45
|
+
SMS__PROVIDER=aliyun
|
|
46
|
+
SMS__ALIYUN__ACCESS_KEY_ID=LTAI...
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
provider: Literal["mock", "aliyun"] = Field(
|
|
50
|
+
default="mock",
|
|
51
|
+
description='SMS Provider 选择:"mock"(dev 限定,打印到日志)/ "aliyun"(生产)',
|
|
52
|
+
)
|
|
53
|
+
code_ttl: int = Field(
|
|
54
|
+
default=300,
|
|
55
|
+
description="验证码有效期(秒),默认 5 分钟",
|
|
56
|
+
)
|
|
57
|
+
code_length: int = Field(
|
|
58
|
+
default=6,
|
|
59
|
+
description="验证码位数,默认 6 位数字",
|
|
60
|
+
)
|
|
61
|
+
aliyun: AliyunSmsConfig = Field(
|
|
62
|
+
default_factory=AliyunSmsConfig,
|
|
63
|
+
description="阿里云短信凭证,provider=aliyun 时必填",
|
|
64
|
+
)
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
...
|
|
@@ -1,42 +0,0 @@
|
|
|
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()
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"""SMS Provider 配置模型。"""
|
|
2
|
-
|
|
3
|
-
from typing import Literal
|
|
4
|
-
|
|
5
|
-
from pydantic import BaseModel
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class AliyunSmsConfig(BaseModel):
|
|
9
|
-
"""阿里云短信 Provider 配置。"""
|
|
10
|
-
|
|
11
|
-
access_key_id: str = ""
|
|
12
|
-
access_key_secret: str = ""
|
|
13
|
-
sign_name: str = ""
|
|
14
|
-
template_code: str = ""
|
|
15
|
-
region_id: str = "cn-hangzhou"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class SmsSettings(BaseModel):
|
|
19
|
-
"""SMS 模块配置 -- 业务项目嵌入 Settings 的入口。
|
|
20
|
-
|
|
21
|
-
用法:
|
|
22
|
-
class Settings(DwyBaseSettings):
|
|
23
|
-
sms: SmsSettings = SmsSettings()
|
|
24
|
-
|
|
25
|
-
`.env` 自动识别(双下划线嵌套):
|
|
26
|
-
SMS__PROVIDER=aliyun
|
|
27
|
-
SMS__ALIYUN__ACCESS_KEY_ID=LTAI...
|
|
28
|
-
"""
|
|
29
|
-
|
|
30
|
-
provider: Literal["mock", "aliyun"] = "mock"
|
|
31
|
-
code_ttl: int = 300
|
|
32
|
-
code_length: int = 6
|
|
33
|
-
aliyun: AliyunSmsConfig = AliyunSmsConfig()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|