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.
Files changed (62) hide show
  1. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/PKG-INFO +1 -1
  2. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/pyproject.toml +1 -1
  3. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/aliyun.py +18 -3
  4. dwyeapi-0.8.0/src/dwyeapi/providers/email/base.py +193 -0
  5. dwyeapi-0.8.0/src/dwyeapi/providers/email/config.py +112 -0
  6. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/factory.py +9 -1
  7. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/resend.py +24 -7
  8. dwyeapi-0.8.0/src/dwyeapi/providers/sms/config.py +64 -0
  9. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/PKG-INFO +1 -1
  10. dwyeapi-0.7.1/src/dwyeapi/providers/email/base.py +0 -71
  11. dwyeapi-0.7.1/src/dwyeapi/providers/email/config.py +0 -42
  12. dwyeapi-0.7.1/src/dwyeapi/providers/sms/config.py +0 -33
  13. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/README.md +0 -0
  14. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/setup.cfg +0 -0
  15. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/__init__.py +0 -0
  16. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/cache.py +0 -0
  17. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/config.py +0 -0
  18. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/database.py +0 -0
  19. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/dependencies.py +0 -0
  20. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/dt.py +0 -0
  21. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/exceptions.py +0 -0
  22. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/health.py +0 -0
  23. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/logger.py +0 -0
  24. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/masking.py +0 -0
  25. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/pagination.py +0 -0
  26. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/__init__.py +0 -0
  27. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/__init__.py +0 -0
  28. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/mock.py +0 -0
  29. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/protocol.py +0 -0
  30. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/__init__.py +0 -0
  31. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/aliyun.py +0 -0
  32. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/base.py +0 -0
  33. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/factory.py +0 -0
  34. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/mock.py +0 -0
  35. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/protocol.py +0 -0
  36. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/response.py +0 -0
  37. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/security.py +0 -0
  38. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/__init__.py +0 -0
  39. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/context.py +0 -0
  40. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/model.py +0 -0
  41. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/pool.py +0 -0
  42. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/registry.py +0 -0
  43. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/router.py +0 -0
  44. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/schema.py +0 -0
  45. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/service.py +0 -0
  46. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi/tasks/worker.py +0 -0
  47. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/SOURCES.txt +0 -0
  48. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
  49. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/requires.txt +0 -0
  50. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/top_level.txt +0 -0
  51. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_cache.py +0 -0
  52. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_config.py +0 -0
  53. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_database.py +0 -0
  54. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_dependencies.py +0 -0
  55. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_exceptions.py +0 -0
  56. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_health.py +0 -0
  57. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_logger.py +0 -0
  58. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_masking.py +0 -0
  59. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_pagination.py +0 -0
  60. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_response.py +0 -0
  61. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_security.py +0 -0
  62. {dwyeapi-0.7.1 → dwyeapi-0.8.0}/tests/test_tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dwyeapi
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dwyeapi"
3
- version = "0.7.1"
3
+ version = "0.8.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"}
@@ -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__(code_ttl=code_ttl, code_length=code_length, redis=redis)
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 = {"code_ttl": settings.code_ttl, "code_length": settings.code_length}
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__(code_ttl=code_ttl, code_length=code_length, redis=redis)
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
- f"<p>您的验证码是 <strong>{code}</strong>,{minutes} 分钟内有效。</p><p>若非本人操作,请忽略此邮件。</p>"
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dwyeapi
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -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