dwyeapi 0.7.0__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 (63) hide show
  1. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/PKG-INFO +1 -1
  2. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/pyproject.toml +1 -1
  3. dwyeapi-0.8.0/src/dwyeapi/config.py +141 -0
  4. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/aliyun.py +18 -3
  5. dwyeapi-0.8.0/src/dwyeapi/providers/email/base.py +193 -0
  6. dwyeapi-0.8.0/src/dwyeapi/providers/email/config.py +112 -0
  7. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/factory.py +9 -1
  8. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/resend.py +24 -7
  9. dwyeapi-0.8.0/src/dwyeapi/providers/sms/config.py +64 -0
  10. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/PKG-INFO +1 -1
  11. dwyeapi-0.7.0/src/dwyeapi/config.py +0 -90
  12. dwyeapi-0.7.0/src/dwyeapi/providers/email/base.py +0 -71
  13. dwyeapi-0.7.0/src/dwyeapi/providers/email/config.py +0 -42
  14. dwyeapi-0.7.0/src/dwyeapi/providers/sms/config.py +0 -33
  15. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/README.md +0 -0
  16. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/setup.cfg +0 -0
  17. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/__init__.py +0 -0
  18. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/cache.py +0 -0
  19. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/database.py +0 -0
  20. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/dependencies.py +0 -0
  21. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/dt.py +0 -0
  22. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/exceptions.py +0 -0
  23. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/health.py +0 -0
  24. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/logger.py +0 -0
  25. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/masking.py +0 -0
  26. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/pagination.py +0 -0
  27. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/__init__.py +0 -0
  28. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/__init__.py +0 -0
  29. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/mock.py +0 -0
  30. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/email/protocol.py +0 -0
  31. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/__init__.py +0 -0
  32. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/aliyun.py +0 -0
  33. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/base.py +0 -0
  34. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/factory.py +0 -0
  35. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/mock.py +0 -0
  36. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/providers/sms/protocol.py +0 -0
  37. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/response.py +0 -0
  38. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/security.py +0 -0
  39. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/__init__.py +0 -0
  40. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/context.py +0 -0
  41. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/model.py +0 -0
  42. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/pool.py +0 -0
  43. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/registry.py +0 -0
  44. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/router.py +0 -0
  45. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/schema.py +0 -0
  46. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/service.py +0 -0
  47. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi/tasks/worker.py +0 -0
  48. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/SOURCES.txt +0 -0
  49. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
  50. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/requires.txt +0 -0
  51. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/src/dwyeapi.egg-info/top_level.txt +0 -0
  52. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_cache.py +0 -0
  53. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_config.py +0 -0
  54. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_database.py +0 -0
  55. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_dependencies.py +0 -0
  56. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_exceptions.py +0 -0
  57. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_health.py +0 -0
  58. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_logger.py +0 -0
  59. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_masking.py +0 -0
  60. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_pagination.py +0 -0
  61. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_response.py +0 -0
  62. {dwyeapi-0.7.0 → dwyeapi-0.8.0}/tests/test_security.py +0 -0
  63. {dwyeapi-0.7.0 → 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.0
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.0"
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"}
@@ -0,0 +1,141 @@
1
+ """FastAPI 项目通用 Pydantic Settings 基类。
2
+
3
+ 除了 database_url / redis_url / secret_key 等基础字段外,还提供运行环境识别 API:
4
+
5
+ - ``environment`` 字段: ``"dev"`` | ``"prod"``,默认 ``"prod"``(误配置时偏保守)
6
+ - ``get_environment()`` / ``is_dev()`` / ``is_prod()``:业务代码读取当前环境
7
+
8
+ 业务项目实例化 ``Settings()`` 时会自动把 environment 值写入模块级全局变量,
9
+ 之后任何地方 import 即可读,避免把 Settings 实例到处传递。
10
+ """
11
+
12
+ from typing import Literal
13
+
14
+ from pydantic import Field, model_validator
15
+ from pydantic_settings import BaseSettings as PydanticBaseSettings
16
+ from pydantic_settings import SettingsConfigDict
17
+
18
+ Environment = Literal["dev", "prod"]
19
+
20
+ _current_environment: Environment = "prod"
21
+
22
+
23
+ def get_environment() -> Environment:
24
+ """返回当前运行环境。
25
+
26
+ 未实例化 Settings 前默认返回 ``"prod"`` (保守),业务 ``Settings()`` 加载
27
+ 后会同步为配置值。
28
+ """
29
+ return _current_environment
30
+
31
+
32
+ def is_dev() -> bool:
33
+ """当前是否为开发环境。"""
34
+ return _current_environment == "dev"
35
+
36
+
37
+ def is_prod() -> bool:
38
+ """当前是否为生产环境。"""
39
+ return _current_environment == "prod"
40
+
41
+
42
+ def set_current_environment(env: Environment) -> None:
43
+ """内部使用: 由 BaseSettings 校验钩子或测试显式切换时调用。"""
44
+ global _current_environment
45
+ _current_environment = env
46
+
47
+
48
+ class BaseSettings(PydanticBaseSettings):
49
+ """FastAPI 项目共用的配置基类。
50
+
51
+ 业务项目通过继承并追加项目专属字段使用,内置的 database_url / redis_url /
52
+ secret_key 等字段覆盖最常用的基础设施需求,不需要每个项目重复声明。
53
+ 配置来源默认为 ``.env`` 文件,嵌套字段使用双下划线 ``__`` 分隔。
54
+ """
55
+
56
+ database_url: str = Field(
57
+ description="数据库连接串(异步驱动),例 postgresql+asyncpg://user:pass@host:5432/db",
58
+ )
59
+ redis_url: str = Field(
60
+ description="Redis 连接串,例 redis://localhost:6379/0",
61
+ )
62
+ secret_key: str = Field(
63
+ description="应用密钥,用于 JWT 签名与敏感数据加密,至少 32 位随机字符串",
64
+ )
65
+ jwt_algorithm: str = Field(
66
+ default="HS256",
67
+ description="JWT 签名算法,默认 HS256",
68
+ )
69
+ access_token_expire_minutes: int = Field(
70
+ default=30,
71
+ description="JWT access token 有效期(分钟),默认 30 分钟",
72
+ )
73
+ environment: Environment = Field(
74
+ default="prod",
75
+ description='运行环境,"dev" 开启调试特性 / 允许 mock providers,"prod" 关闭调试并禁用 mock',
76
+ )
77
+ allowed_origins: list[str] = Field(
78
+ default_factory=list,
79
+ description="CORS 允许的来源域名列表,生产环境禁止使用 ['*']",
80
+ )
81
+
82
+ # ── 异步任务配置(task_ 前缀)──
83
+ task_max_jobs: int = Field(
84
+ default=5,
85
+ description="ARQ worker 并发执行的最大任务数",
86
+ )
87
+ task_job_timeout: int = Field(
88
+ default=3600,
89
+ description="单个任务执行超时(秒),超时后被中止,默认 1 小时",
90
+ )
91
+ task_failure_ttl: int = Field(
92
+ default=86400,
93
+ description="失败任务结果在 Redis 中的保留时长(秒),默认 1 天",
94
+ )
95
+
96
+ # ── 日志配置(log_ 前缀)──
97
+ log_level: str = Field(
98
+ default="INFO",
99
+ description="最低日志级别:DEBUG / INFO / WARNING / ERROR / CRITICAL",
100
+ )
101
+ log_dir: str | None = Field(
102
+ default=".",
103
+ description='日志文件目录,默认 "." 即进程当前目录;显式设为 None 可关闭文件输出(仅控制台)',
104
+ )
105
+ log_filename: str = Field(
106
+ default="app",
107
+ description="日志文件基础名,最终文件形如 {filename}_YYYY-MM-DD.log",
108
+ )
109
+ log_max_bytes: int = Field(
110
+ default=100 * 1024 * 1024,
111
+ description="单个日志文件大小上限(字节),超出后滚动,默认 100 MB",
112
+ )
113
+ log_retention: str = Field(
114
+ default="30 days",
115
+ description='日志保留策略,loguru 语法,例 "30 days" / "1 week" / 整数个数',
116
+ )
117
+ log_console: bool = Field(
118
+ default=True,
119
+ description="是否输出带颜色的日志到 stderr 控制台",
120
+ )
121
+ log_serialize: bool = Field(
122
+ default=False,
123
+ description="文件 sink 是否按 JSON Lines 序列化,便于日志聚合系统解析",
124
+ )
125
+ log_intercept_stdlib: bool = Field(
126
+ default=True,
127
+ description="是否拦截 stdlib logging,让 uvicorn / SQLAlchemy / httpx 等共享同一套 sink",
128
+ )
129
+
130
+ model_config = SettingsConfigDict(
131
+ env_file=".env",
132
+ env_file_encoding="utf-8",
133
+ env_nested_delimiter="__",
134
+ extra="ignore",
135
+ )
136
+
137
+ @model_validator(mode="after")
138
+ def _sync_environment_to_module(self) -> "BaseSettings":
139
+ """把 ``environment`` 写入模块全局,让 ``is_dev()`` / ``is_prod()`` 跨模块可读。"""
140
+ set_current_environment(self.environment)
141
+ return self
@@ -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.0
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,90 +0,0 @@
1
- """FastAPI 项目通用 Pydantic Settings 基类。
2
-
3
- 除了 database_url / redis_url / secret_key 等基础字段外,还提供运行环境识别 API:
4
-
5
- - ``environment`` 字段: ``"dev"`` | ``"prod"``,默认 ``"prod"``(误配置时偏保守)
6
- - ``get_environment()`` / ``is_dev()`` / ``is_prod()``:业务代码读取当前环境
7
-
8
- 业务项目实例化 ``Settings()`` 时会自动把 environment 值写入模块级全局变量,
9
- 之后任何地方 import 即可读,避免把 Settings 实例到处传递。
10
- """
11
-
12
- from typing import Literal
13
-
14
- from pydantic import Field, model_validator
15
- from pydantic_settings import BaseSettings as PydanticBaseSettings
16
- from pydantic_settings import SettingsConfigDict
17
-
18
- Environment = Literal["dev", "prod"]
19
-
20
- _current_environment: Environment = "prod"
21
-
22
-
23
- def get_environment() -> Environment:
24
- """返回当前运行环境。
25
-
26
- 未实例化 Settings 前默认返回 ``"prod"`` (保守),业务 ``Settings()`` 加载
27
- 后会同步为配置值。
28
- """
29
- return _current_environment
30
-
31
-
32
- def is_dev() -> bool:
33
- """当前是否为开发环境。"""
34
- return _current_environment == "dev"
35
-
36
-
37
- def is_prod() -> bool:
38
- """当前是否为生产环境。"""
39
- return _current_environment == "prod"
40
-
41
-
42
- def set_current_environment(env: Environment) -> None:
43
- """内部使用: 由 BaseSettings 校验钩子或测试显式切换时调用。"""
44
- global _current_environment
45
- _current_environment = env
46
-
47
-
48
- class BaseSettings(PydanticBaseSettings):
49
- """FastAPI 项目共用的配置基类。
50
-
51
- 业务项目通过继承并追加项目专属字段使用,内置的 database_url / redis_url /
52
- secret_key 等字段覆盖最常用的基础设施需求,不需要每个项目重复声明。
53
- 配置来源默认为 ``.env`` 文件,嵌套字段使用双下划线 ``__`` 分隔。
54
- """
55
-
56
- database_url: str
57
- redis_url: str
58
- secret_key: str
59
- jwt_algorithm: str = "HS256"
60
- access_token_expire_minutes: int = 30
61
- environment: Environment = "prod"
62
- allowed_origins: list[str] = Field(default_factory=list)
63
-
64
- # Task module settings (task_ prefix)
65
- task_max_jobs: int = 5
66
- task_job_timeout: int = 3600
67
- task_failure_ttl: int = 86400
68
-
69
- # Logger module settings (log_ prefix)
70
- log_level: str = "INFO"
71
- log_dir: str | None = None
72
- log_filename: str = "app"
73
- log_max_bytes: int = 100 * 1024 * 1024
74
- log_retention: str = "30 days"
75
- log_console: bool = True
76
- log_serialize: bool = False
77
- log_intercept_stdlib: bool = True
78
-
79
- model_config = SettingsConfigDict(
80
- env_file=".env",
81
- env_file_encoding="utf-8",
82
- env_nested_delimiter="__",
83
- extra="ignore",
84
- )
85
-
86
- @model_validator(mode="after")
87
- def _sync_environment_to_module(self) -> "BaseSettings":
88
- """把 ``environment`` 写入模块全局,让 ``is_dev()`` / ``is_prod()`` 跨模块可读。"""
89
- set_current_environment(self.environment)
90
- return self
@@ -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