9 Commits

Author SHA1 Message Date
fawney19
cddc22d2b3 refactor: 重构邮箱验证模块并修复代码审查问题
- 重构: 将 verification 模块重命名为 email,目录结构更清晰
- 新增: 独立的邮件配置管理页面 (EmailSettings.vue)
- 新增: 邮件模板管理功能(支持自定义 HTML 模板和预览)
- 新增: 查询验证状态 API,支持页面刷新后恢复验证流程
- 新增: 注册邮箱后缀白名单/黑名单限制功能
- 修复: 统一密码最小长度为 6 位(前后端一致)
- 修复: SMTP 连接添加 30 秒超时配置,防止 worker 挂起
- 修复: 邮件模板变量添加 HTML 转义,防止 XSS
- 修复: 验证状态清除改为 db.commit 后执行,避免竞态条件
- 优化: RegisterDialog 重写验证码输入组件,提升用户体验
- 优化: Input 组件支持 disableAutofill 属性
2026-01-01 02:10:19 +08:00
fawney19
11ded575d5 Merge branch 'master' into feature/email-verification 2025-12-31 22:00:36 +08:00
fawney19
394cc536a9 feat: 添加 API 格式访问限制
扩展访问限制功能,支持 API Key 级别的 API 格式限制(OPENAI、CLAUDE、GEMINI)。

- AccessRestrictions 新增 allowed_api_formats 字段
- 新增 is_api_format_allowed() 方法检查格式权限
- models.py 添加 _filter_formats_by_restrictions() 函数过滤 API 格式
- 在所有模型列表和查询端点应用格式限制检查
- 添加 _build_empty_list_response() 统一空响应构建逻辑
2025-12-30 17:50:39 +08:00
RWDai
6bd8cdb9cf feat: 实现邮箱验证注册功能
添加完整的邮箱验证注册系统,包括验证码发送、验证和限流控制:
  - 新增邮箱验证服务模块(email_sender, email_template, email_verification)
  - 更新认证API支持邮箱验证注册流程
  - 添加注册对话框和验证码输入组件
  - 完善IP限流器支持邮箱验证场景
  - 修复前端类型定义问题,升级esbuild依赖

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

  Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 17:15:48 +08:00
fawney19
e20a09f15a feat: 添加模型列表访问限制功能
实现 API Key 和 User 级别的模型访问权限控制,支持按 Provider 和模型名称限制。

- 新增 AccessRestrictions 类处理访问限制合并逻辑(API Key 优先于 User)
- models_service 支持根据限制过滤模型列表
- models.py 在列表查询时构建并应用访问限制
- 优化缓存策略:仅无限制请求使用缓存,有限制的请求旁路缓存
- 修复 logger 配置:enqueue 改为 False 避免 macOS 信号量泄漏
2025-12-30 16:57:59 +08:00
fawney19
b89a4af0cf refactor: 统一 HTTP 客户端超时配置
将 HTTPClientPool 中硬编码的超时参数改为使用可配置的环境变量,提高系统的灵活性和可维护性。

- 添加 HTTP_READ_TIMEOUT 环境变量配置(默认 300 秒)
- 统一所有 HTTP 客户端创建逻辑使用配置化超时
- 改进变量命名清晰性(config -> default_config 或 client_config)
2025-12-30 15:06:55 +08:00
fawney19
a56854af43 feat: 为 GlobalModel 添加关联提供商查询 API
添加新的 API 端点 GET /api/admin/models/global/{global_model_id}/providers,用于获取 GlobalModel 的所有关联提供商(包括非活跃的)。

- 后端:实现 AdminGetGlobalModelProvidersAdapter 适配器
- 前端:使用新 API 替换原有的 ModelCatalog 获取方式
- 数据库:改进初始化时的错误提示和连接异常处理
2025-12-30 14:47:35 +08:00
fawney19
4a35d78c8d fix: 修正 README 中的图片文件扩展名 2025-12-30 09:44:45 +08:00
fawney19
26b281271e docs: 更新许可证说明、README 文档和模拟数据;调整模型版本至最新 2025-12-30 09:41:03 +08:00
47 changed files with 4520 additions and 546 deletions

15
LICENSE
View File

@@ -5,12 +5,17 @@ Aether 非商业开源许可证
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、 特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件: 复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
1. 仅限非商业用途 1. 仅限非盈利用途
本软件不得用于商业目的。商业目的包括但不限于: 本软件不得用于盈利目的。盈利目的包括但不限于:
- 出售本软件或任何衍生作品 - 出售本软件或任何衍生作品
- 使用本软件提供付费服务 - 使用本软件提供付费服务
- 将本软件用于商业产品或服务 - 将本软件用于以盈利为目的的商业产品或服务
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
以下用途被明确允许:
- 个人学习和研究
- 教育机构的教学和研究
- 非盈利组织的内部使用
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
2. 署名要求 2. 署名要求
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。 上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
您不得以不同的条款将本软件再许可给他人。 您不得以不同的条款将本软件再许可给他人。
5. 商业许可 5. 商业许可
如需商业使用,请联系版权持有人以获取单独的商业许可。 如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、 本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何 特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何

View File

@@ -143,7 +143,7 @@ cd frontend && npm install && npm run dev
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略 - **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略 - **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能用支持 1H缓存的模型 > **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
### Q: 如何配置负载均衡? ### Q: 如何配置负载均衡?
@@ -162,4 +162,16 @@ cd frontend && npm install && npm run dev
## 许可证 ## 许可证
本项目采用 [Aether 非商业开源许可证](LICENSE)。 本项目采用 [Aether 非商业开源许可证](LICENSE)。允许个人学习、教育研究、非盈利组织及企业内部非盈利性质的使用;禁止用于盈利目的。商业使用请联系获取商业许可。
## 联系作者
<p align="center">
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
</p>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date)

BIN
docs/author/qq_qrcode.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -262,6 +262,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -305,6 +306,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -316,9 +318,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -333,9 +335,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -350,9 +352,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -367,9 +369,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -384,9 +386,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -401,9 +403,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -418,9 +420,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -435,9 +437,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -452,9 +454,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -469,9 +471,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -486,9 +488,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -503,9 +505,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -520,9 +522,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -537,9 +539,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -554,9 +556,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -571,9 +573,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -588,9 +590,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -605,9 +607,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -622,9 +624,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -639,9 +641,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -656,9 +658,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -673,9 +675,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -690,9 +692,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -707,9 +709,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -724,9 +726,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -741,9 +743,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1598,6 +1600,7 @@
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.10.0" "undici-types": "~7.10.0"
} }
@@ -1676,6 +1679,7 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0", "@typescript-eslint/types": "8.49.0",
@@ -2004,6 +2008,7 @@
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==", "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.10", "@vitest/utils": "4.0.10",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@@ -2301,6 +2306,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2602,6 +2608,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.2", "baseline-browser-mapping": "^2.8.2",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@@ -2718,6 +2725,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -2940,6 +2948,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
@@ -2999,18 +3008,6 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -3134,9 +3131,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.9", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -3147,32 +3144,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.9", "@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.25.9", "@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.25.9", "@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.25.9", "@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.25.9", "@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.25.9", "@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.25.9", "@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.25.9", "@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.25.9", "@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.25.9", "@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.25.9", "@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.25.9" "@esbuild/win32-x64": "0.27.2"
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
@@ -3204,6 +3201,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3747,9 +3745,9 @@
} }
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "10.4.5", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -4084,18 +4082,6 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/jiti": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -4115,6 +4101,7 @@
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.23", "@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4", "@asamuzakjp/dom-selector": "^6.7.4",
@@ -4194,257 +4181,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"dev": true,
"license": "MPL-2.0",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.30.1",
"lightningcss-darwin-x64": "1.30.1",
"lightningcss-freebsd-x64": "1.30.1",
"lightningcss-linux-arm-gnueabihf": "1.30.1",
"lightningcss-linux-arm64-gnu": "1.30.1",
"lightningcss-linux-arm64-musl": "1.30.1",
"lightningcss-linux-x64-gnu": "1.30.1",
"lightningcss-linux-x64-musl": "1.30.1",
"lightningcss-win32-arm64-msvc": "1.30.1",
"lightningcss-win32-x64-msvc": "1.30.1"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -4930,6 +4666,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4997,6 +4734,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -6027,6 +5765,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -6115,13 +5854,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.5", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -6195,6 +5935,7 @@
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.10", "@vitest/expect": "4.0.10",
"@vitest/mocker": "4.0.10", "@vitest/mocker": "4.0.10",
@@ -6279,6 +6020,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.21", "@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21", "@vue/compiler-sfc": "3.5.21",
@@ -6311,7 +6053,6 @@
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"eslint-scope": "^8.2.0", "eslint-scope": "^8.2.0",
@@ -6336,7 +6077,6 @@
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },

View File

@@ -124,6 +124,37 @@ export interface ModelExport {
config?: any config?: any
} }
// 邮件模板接口
export interface EmailTemplateInfo {
type: string
name: string
variables: string[]
subject: string
html: string
is_custom: boolean
default_subject?: string
default_html?: string
}
export interface EmailTemplatesResponse {
templates: EmailTemplateInfo[]
}
export interface EmailTemplatePreviewResponse {
html: string
variables: Record<string, string>
}
export interface EmailTemplateResetResponse {
message: string
template: {
type: string
name: string
subject: string
html: string
}
}
// Provider 模型查询响应 // Provider 模型查询响应
export interface ProviderModelsQueryResponse { export interface ProviderModelsQueryResponse {
success: boolean success: boolean
@@ -386,5 +417,61 @@ export const adminApi = {
{ provider_id: providerId, api_key_id: apiKeyId } { provider_id: providerId, api_key_id: apiKeyId }
) )
return response.data return response.data
},
// 测试 SMTP 连接,支持传入未保存的配置
async testSmtpConnection(config: Record<string, any> = {}): Promise<{ success: boolean; message: string }> {
const response = await apiClient.post<{ success: boolean; message: string }>(
'/api/admin/system/smtp/test',
config
)
return response.data
},
// 邮件模板相关
// 获取所有邮件模板
async getEmailTemplates(): Promise<EmailTemplatesResponse> {
const response = await apiClient.get<EmailTemplatesResponse>('/api/admin/system/email/templates')
return response.data
},
// 获取指定类型的邮件模板
async getEmailTemplate(templateType: string): Promise<EmailTemplateInfo> {
const response = await apiClient.get<EmailTemplateInfo>(
`/api/admin/system/email/templates/${templateType}`
)
return response.data
},
// 更新邮件模板
async updateEmailTemplate(
templateType: string,
data: { subject?: string; html?: string }
): Promise<{ message: string }> {
const response = await apiClient.put<{ message: string }>(
`/api/admin/system/email/templates/${templateType}`,
data
)
return response.data
},
// 预览邮件模板
async previewEmailTemplate(
templateType: string,
data?: { html?: string } & Record<string, string>
): Promise<EmailTemplatePreviewResponse> {
const response = await apiClient.post<EmailTemplatePreviewResponse>(
`/api/admin/system/email/templates/${templateType}/preview`,
data || {}
)
return response.data
},
// 重置邮件模板为默认值
async resetEmailTemplate(templateType: string): Promise<EmailTemplateResetResponse> {
const response = await apiClient.post<EmailTemplateResetResponse>(
`/api/admin/system/email/templates/${templateType}/reset`
)
return response.data
} }
} }

View File

@@ -31,6 +31,56 @@ export interface UserStats {
[key: string]: unknown // 允许扩展其他统计数据 [key: string]: unknown // 允许扩展其他统计数据
} }
export interface SendVerificationCodeRequest {
email: string
}
export interface SendVerificationCodeResponse {
message: string
success: boolean
expire_minutes?: number
}
export interface VerifyEmailRequest {
email: string
code: string
}
export interface VerifyEmailResponse {
message: string
success: boolean
}
export interface VerificationStatusRequest {
email: string
}
export interface VerificationStatusResponse {
email: string
has_pending_code: boolean
is_verified: boolean
cooldown_remaining: number | null
code_expires_in: number | null
}
export interface RegisterRequest {
email: string
username: string
password: string
}
export interface RegisterResponse {
user_id: string
email: string
username: string
message: string
}
export interface RegistrationSettingsResponse {
enable_registration: boolean
require_email_verification: boolean
}
export interface User { export interface User {
id: string // UUID id: string // UUID
username: string username: string
@@ -87,5 +137,41 @@ export const authApi = {
localStorage.setItem('refresh_token', response.data.refresh_token) localStorage.setItem('refresh_token', response.data.refresh_token)
} }
return response.data return response.data
},
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
const response = await apiClient.post<SendVerificationCodeResponse>(
'/api/auth/send-verification-code',
{ email }
)
return response.data
},
async verifyEmail(email: string, code: string): Promise<VerifyEmailResponse> {
const response = await apiClient.post<VerifyEmailResponse>(
'/api/auth/verify-email',
{ email, code }
)
return response.data
},
async register(data: RegisterRequest): Promise<RegisterResponse> {
const response = await apiClient.post<RegisterResponse>('/api/auth/register', data)
return response.data
},
async getRegistrationSettings(): Promise<RegistrationSettingsResponse> {
const response = await apiClient.get<RegistrationSettingsResponse>(
'/api/auth/registration-settings'
)
return response.data
},
async getVerificationStatus(email: string): Promise<VerificationStatusResponse> {
const response = await apiClient.post<VerificationStatusResponse>(
'/api/auth/verification-status',
{ email }
)
return response.data
} }
} }

View File

@@ -4,7 +4,8 @@ import type {
GlobalModelUpdate, GlobalModelUpdate,
GlobalModelResponse, GlobalModelResponse,
GlobalModelWithStats, GlobalModelWithStats,
GlobalModelListResponse GlobalModelListResponse,
ModelCatalogProviderDetail,
} from './types' } from './types'
/** /**
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
) )
return response.data return response.data
} }
/**
* 获取 GlobalModel 的所有关联提供商(包括非活跃的)
*/
export async function getGlobalModelProviders(globalModelId: string): Promise<{
providers: ModelCatalogProviderDetail[]
total: number
}> {
const response = await client.get(
`/api/admin/models/global/${globalModelId}/providers`
)
return response.data
}

View File

@@ -20,4 +20,5 @@ export {
updateGlobalModel, updateGlobalModel,
deleteGlobalModel, deleteGlobalModel,
batchAssignToProviders, batchAssignToProviders,
getGlobalModelProviders,
} from './endpoints/global-models' } from './endpoints/global-models'

View File

@@ -0,0 +1,192 @@
<template>
<div class="verification-code-input">
<div class="code-inputs flex gap-2">
<input
v-for="(digit, index) in digits"
:key="index"
:ref="(el) => (inputRefs[index] = el as HTMLInputElement)"
v-model="digits[index]"
type="text"
inputmode="numeric"
maxlength="1"
class="code-digit"
:class="{ error: hasError }"
@input="handleInput(index, $event)"
@keydown="handleKeyDown(index, $event)"
@paste="handlePaste"
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
modelValue?: string
length?: number
hasError?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'complete', value: string): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
length: 6,
hasError: false
})
const emit = defineEmits<Emits>()
const digits = ref<string[]>(Array(props.length).fill(''))
const inputRefs = ref<HTMLInputElement[]>([])
// Watch modelValue changes from parent
watch(
() => props.modelValue,
(newValue) => {
if (newValue.length <= props.length) {
digits.value = newValue.split('').concat(Array(props.length - newValue.length).fill(''))
}
},
{ immediate: true }
)
const updateValue = () => {
const value = digits.value.join('')
emit('update:modelValue', value)
// Emit complete event when all digits are filled
if (value.length === props.length && /^\d+$/.test(value)) {
emit('complete', value)
}
}
const handleInput = (index: number, event: Event) => {
const input = event.target as HTMLInputElement
const value = input.value
// Only allow digits
if (!/^\d*$/.test(value)) {
input.value = digits.value[index]
return
}
digits.value[index] = value
// Auto-focus next input
if (value && index < props.length - 1) {
inputRefs.value[index + 1]?.focus()
}
updateValue()
}
const handleKeyDown = (index: number, event: KeyboardEvent) => {
// Handle backspace
if (event.key === 'Backspace') {
if (!digits.value[index] && index > 0) {
// If current input is empty, move to previous and clear it
inputRefs.value[index - 1]?.focus()
digits.value[index - 1] = ''
updateValue()
} else {
// Clear current input
digits.value[index] = ''
updateValue()
}
}
// Handle arrow keys
else if (event.key === 'ArrowLeft' && index > 0) {
inputRefs.value[index - 1]?.focus()
} else if (event.key === 'ArrowRight' && index < props.length - 1) {
inputRefs.value[index + 1]?.focus()
}
}
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault()
const pastedData = event.clipboardData?.getData('text') || ''
const cleanedData = pastedData.replace(/\D/g, '').slice(0, props.length)
if (cleanedData) {
digits.value = cleanedData.split('').concat(Array(props.length - cleanedData.length).fill(''))
updateValue()
// Focus the next empty input or the last input
const nextEmptyIndex = digits.value.findIndex((d) => !d)
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : props.length - 1
inputRefs.value[focusIndex]?.focus()
}
}
// Expose method to clear inputs
const clear = () => {
digits.value = Array(props.length).fill('')
inputRefs.value[0]?.focus()
updateValue()
}
// Expose method to focus first input
const focus = () => {
inputRefs.value[0]?.focus()
}
defineExpose({
clear,
focus
})
</script>
<style scoped>
.code-inputs {
display: flex;
justify-content: center;
align-items: center;
}
.code-digit {
width: 3rem;
height: 3.5rem;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
border: 2px solid hsl(var(--border));
border-radius: var(--radius);
background-color: hsl(var(--background));
color: hsl(var(--foreground));
transition: all 0.2s;
}
.code-digit:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
.code-digit:hover:not(:focus) {
border-color: hsl(var(--primary) / 0.5);
}
.code-digit.error {
border-color: hsl(var(--destructive));
}
.code-digit.error:focus {
box-shadow: 0 0 0 3px hsl(var(--destructive) / 0.1);
}
/* Prevent spinner buttons on number inputs */
.code-digit::-webkit-outer-spin-button,
.code-digit::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.code-digit[type='number'] {
-moz-appearance: textfield;
}
</style>

View File

@@ -71,8 +71,8 @@
</div> </div>
</slot> </slot>
<!-- 内容区域统一添加 padding --> <!-- 内容区域可选添加 padding -->
<div class="px-6 py-3"> <div :class="noPadding ? '' : 'px-6 py-3'">
<slot /> <slot />
</div> </div>
@@ -105,6 +105,7 @@ const props = defineProps<{
icon?: Component // Lucide icon component icon?: Component // Lucide icon component
iconClass?: string // Custom icon color class iconClass?: string // Custom icon color class
zIndex?: number // Custom z-index for nested dialogs (default: 60) zIndex?: number // Custom z-index for nested dialogs (default: 60)
noPadding?: boolean // Disable default content padding
}>() }>()
// Emits 定义 // Emits 定义

View File

@@ -3,6 +3,9 @@
:class="inputClass" :class="inputClass"
:value="modelValue" :value="modelValue"
:autocomplete="autocompleteAttr" :autocomplete="autocompleteAttr"
:data-lpignore="disableAutofill ? 'true' : undefined"
:data-1p-ignore="disableAutofill ? 'true' : undefined"
:data-form-type="disableAutofill ? 'other' : undefined"
v-bind="$attrs" v-bind="$attrs"
@input="handleInput" @input="handleInput"
> >
@@ -16,6 +19,7 @@ interface Props {
modelValue?: string | number modelValue?: string | number
class?: string class?: string
autocomplete?: string autocomplete?: string
disableAutofill?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -23,7 +27,12 @@ const emit = defineEmits<{
'update:modelValue': [value: string] 'update:modelValue': [value: string]
}>() }>()
const autocompleteAttr = computed(() => props.autocomplete ?? 'off') const autocompleteAttr = computed(() => {
if (props.disableAutofill) {
return 'one-time-code'
}
return props.autocomplete ?? 'off'
})
const inputClass = computed(() => const inputClass = computed(() =>
cn( cn(

View File

@@ -98,12 +98,27 @@
<!-- 提示信息 --> <!-- 提示信息 -->
<p <p
v-if="!isDemo" v-if="!isDemo && !allowRegistration"
class="text-xs text-slate-400 dark:text-muted-foreground/80" class="text-xs text-slate-400 dark:text-muted-foreground/80"
> >
如需开通账户请联系管理员配置访问权限 如需开通账户请联系管理员配置访问权限
</p> </p>
</form> </form>
<!-- 注册链接 -->
<div
v-if="allowRegistration"
class="mt-4 text-center text-sm"
>
还没有账户
<Button
variant="link"
class="h-auto p-0"
@click="handleSwitchToRegister"
>
立即注册
</Button>
</div>
</div> </div>
<template #footer> <template #footer>
@@ -124,10 +139,18 @@
</Button> </Button>
</template> </template>
</Dialog> </Dialog>
<!-- Register Dialog -->
<RegisterDialog
v-model:open="showRegisterDialog"
:require-email-verification="requireEmailVerification"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed } from 'vue' import { ref, watch, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Dialog } from '@/components/ui' import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
@@ -136,6 +159,8 @@ import Label from '@/components/ui/label.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo' import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -151,6 +176,9 @@ const { success: showSuccess, warning: showWarning, error: showError } = useToas
const isOpen = ref(props.modelValue) const isOpen = ref(props.modelValue)
const isDemo = computed(() => isDemoMode()) const isDemo = computed(() => isDemoMode())
const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
isOpen.value = val isOpen.value = val
@@ -201,4 +229,33 @@ async function handleLogin() {
showError(authStore.error || '登录失败,请检查邮箱和密码') showError(authStore.error || '登录失败,请检查邮箱和密码')
} }
} }
function handleSwitchToRegister() {
isOpen.value = false
showRegisterDialog.value = true
}
function handleRegisterSuccess() {
showRegisterDialog.value = false
showSuccess('注册成功!请登录')
isOpen.value = true
}
function handleSwitchToLogin() {
showRegisterDialog.value = false
isOpen.value = true
}
// Load registration settings on mount
onMounted(async () => {
try {
const settings = await authApi.getRegistrationSettings()
allowRegistration.value = !!settings.enable_registration
requireEmailVerification.value = !!settings.require_email_verification
} catch (error) {
// If获取失败保持默认关闭注册 & 关闭邮箱验证
allowRegistration.value = false
requireEmailVerification.value = false
}
})
</script> </script>

View File

@@ -0,0 +1,640 @@
<template>
<Dialog
v-model:open="isOpen"
size="lg"
>
<div class="space-y-6">
<!-- Logo 和标题 -->
<div class="flex flex-col items-center text-center">
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
<img
src="/aether_adaptive.svg"
alt="Logo"
class="h-16 w-16"
>
</div>
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
注册新账户
</h2>
<p class="mt-1 text-sm text-muted-foreground">
请填写您的邮箱和个人信息完成注册
</p>
</div>
<!-- 注册表单 -->
<form
class="space-y-4"
autocomplete="off"
data-form-type="other"
@submit.prevent="handleSubmit"
>
<!-- Email -->
<div class="space-y-2">
<Label for="reg-email">邮箱 <span class="text-muted-foreground">*</span></Label>
<Input
id="reg-email"
v-model="formData.email"
type="email"
placeholder="hello@example.com"
required
disable-autofill
:disabled="isLoading || emailVerified"
/>
</div>
<!-- Verification Code Section -->
<div
v-if="requireEmailVerification"
class="space-y-3"
>
<div class="flex items-center justify-between">
<Label>验证码 <span class="text-muted-foreground">*</span></Label>
<Button
type="button"
variant="link"
size="sm"
class="h-auto p-0 text-xs"
:disabled="isSendingCode || !canSendCode || emailVerified"
@click="handleSendCode"
>
{{ sendCodeButtonText }}
</Button>
</div>
<div class="flex justify-center gap-2">
<!-- 发送中显示 loading -->
<div
v-if="isSendingCode"
class="flex items-center justify-center gap-2 h-14 text-muted-foreground"
>
<svg
class="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span class="text-sm">正在发送验证码...</span>
</div>
<!-- 验证码输入框 -->
<template v-else>
<input
v-for="(_, index) in 6"
:key="index"
:ref="(el) => setCodeInputRef(index, el as HTMLInputElement)"
v-model="codeDigits[index]"
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="off"
data-form-type="other"
class="w-12 h-14 text-center text-xl font-semibold border-2 rounded-lg bg-background transition-all focus:outline-none focus:ring-2 focus:ring-primary/20"
:class="verificationError ? 'border-destructive' : 'border-border focus:border-primary'"
:disabled="emailVerified"
@input="handleCodeInput(index, $event)"
@keydown="handleCodeKeyDown(index, $event)"
@paste="handleCodePaste"
>
</template>
</div>
</div>
<!-- Username -->
<div class="space-y-2">
<Label for="reg-uname">用户名 <span class="text-muted-foreground">*</span></Label>
<Input
id="reg-uname"
v-model="formData.username"
type="text"
placeholder="请输入用户名"
required
disable-autofill
:disabled="isLoading"
/>
</div>
<!-- Password -->
<div class="space-y-2">
<Label :for="`pwd-${formNonce}`">密码 <span class="text-muted-foreground">*</span></Label>
<Input
:id="`pwd-${formNonce}`"
v-model="formData.password"
type="text"
autocomplete="one-time-code"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
:name="`pwd-${formNonce}`"
placeholder="至少 6 个字符"
required
class="-webkit-text-security-disc"
:disabled="isLoading"
/>
</div>
<!-- Confirm Password -->
<div class="space-y-2">
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-muted-foreground">*</span></Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="formData.confirmPassword"
type="text"
autocomplete="one-time-code"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
:name="`pwd-confirm-${formNonce}`"
placeholder="再次输入密码"
required
class="-webkit-text-security-disc"
:disabled="isLoading"
/>
</div>
</form>
<!-- 登录链接 -->
<div class="text-center text-sm">
已有账户
<Button
variant="link"
class="h-auto p-0"
@click="handleSwitchToLogin"
>
立即登录
</Button>
</div>
</div>
<template #footer>
<Button
type="button"
variant="outline"
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
:disabled="isLoading"
@click="handleCancel"
>
取消
</Button>
<Button
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
:disabled="isLoading || !canSubmit"
@click="handleSubmit"
>
{{ isLoading ? loadingText : '注册' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { authApi } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
interface Props {
open?: boolean
requireEmailVerification?: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
(e: 'success'): void
(e: 'switchToLogin'): void
}
const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false
})
const emit = defineEmits<Emits>()
const { success, error: showError } = useToast()
// Form nonce for password fields (prevent autofill)
const formNonce = ref(createFormNonce())
function createFormNonce(): string {
return Math.random().toString(36).slice(2, 10)
}
// Verification code inputs
const codeInputRefs = ref<(HTMLInputElement | null)[]>([])
const codeDigits = ref<string[]>(['', '', '', '', '', ''])
const setCodeInputRef = (index: number, el: HTMLInputElement | null) => {
codeInputRefs.value[index] = el
}
// Handle verification code input
const handleCodeInput = (index: number, event: Event) => {
const input = event.target as HTMLInputElement
const value = input.value
// Only allow digits
if (!/^\d*$/.test(value)) {
input.value = codeDigits.value[index]
return
}
codeDigits.value[index] = value
// Auto-focus next input
if (value && index < 5) {
codeInputRefs.value[index + 1]?.focus()
}
// Check if all digits are filled
const fullCode = codeDigits.value.join('')
if (fullCode.length === 6 && /^\d+$/.test(fullCode)) {
handleCodeComplete(fullCode)
}
}
const handleCodeKeyDown = (index: number, event: KeyboardEvent) => {
// Handle backspace
if (event.key === 'Backspace') {
if (!codeDigits.value[index] && index > 0) {
// If current input is empty, move to previous and clear it
codeInputRefs.value[index - 1]?.focus()
codeDigits.value[index - 1] = ''
} else {
// Clear current input
codeDigits.value[index] = ''
}
}
// Handle arrow keys
else if (event.key === 'ArrowLeft' && index > 0) {
codeInputRefs.value[index - 1]?.focus()
} else if (event.key === 'ArrowRight' && index < 5) {
codeInputRefs.value[index + 1]?.focus()
}
}
const handleCodePaste = (event: ClipboardEvent) => {
event.preventDefault()
const pastedData = event.clipboardData?.getData('text') || ''
const cleanedData = pastedData.replace(/\D/g, '').slice(0, 6)
if (cleanedData) {
// Fill digits
for (let i = 0; i < 6; i++) {
codeDigits.value[i] = cleanedData[i] || ''
}
// Focus the next empty input or the last input
const nextEmptyIndex = codeDigits.value.findIndex((d) => !d)
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : 5
codeInputRefs.value[focusIndex]?.focus()
// Check if all digits are filled
if (cleanedData.length === 6) {
handleCodeComplete(cleanedData)
}
}
}
const clearCodeInputs = () => {
codeDigits.value = ['', '', '', '', '', '']
codeInputRefs.value[0]?.focus()
}
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const formData = ref({
email: '',
username: '',
password: '',
confirmPassword: '',
verificationCode: ''
})
const isLoading = ref(false)
const loadingText = ref('注册中...')
const isSendingCode = ref(false)
const emailVerified = ref(false)
const verificationError = ref(false)
const codeSentAt = ref<number | null>(null)
const cooldownSeconds = ref(0)
const expireMinutes = ref(5)
const cooldownTimer = ref<number | null>(null)
// Send code cooldown timer
const canSendCode = computed(() => {
if (!formData.value.email) return false
if (cooldownSeconds.value > 0) return false
return true
})
const sendCodeButtonText = computed(() => {
if (isSendingCode.value) return '发送中...'
if (emailVerified.value) return '验证成功'
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
if (codeSentAt.value) return '重新发送验证码'
return '发送验证码'
})
const canSubmit = computed(() => {
const hasBasicInfo =
formData.value.email &&
formData.value.username &&
formData.value.password &&
formData.value.confirmPassword
if (!hasBasicInfo) return false
// If email verification is required, check if verified
if (props.requireEmailVerification && !emailVerified.value) {
return false
}
// Check password match
if (formData.value.password !== formData.value.confirmPassword) {
return false
}
// Check password length
if (formData.value.password.length < 6) {
return false
}
return true
})
// 查询并恢复验证状态
const checkAndRestoreVerificationStatus = async (email: string) => {
if (!email || !props.requireEmailVerification) return
try {
const status = await authApi.getVerificationStatus(email)
// 注意:不恢复 is_verified 状态
// 刷新页面后需要重新发送验证码并验证,防止验证码被他人使用
// 只恢复"有待验证验证码"的状态(冷却时间)
if (status.has_pending_code) {
codeSentAt.value = Date.now()
verificationError.value = false
// 恢复冷却时间
if (status.cooldown_remaining && status.cooldown_remaining > 0) {
startCooldown(status.cooldown_remaining)
}
}
} catch {
// 查询失败时静默处理,不影响用户体验
}
}
// 邮箱查询防抖定时器
let emailCheckTimer: number | null = null
// 监听邮箱变化,查询验证状态
watch(
() => formData.value.email,
(newEmail, oldEmail) => {
// 邮箱变化时重置验证状态
if (newEmail !== oldEmail) {
emailVerified.value = false
verificationError.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
cooldownTimer.value = null
}
codeDigits.value = ['', '', '', '', '', '']
}
// 清除之前的定时器
if (emailCheckTimer !== null) {
clearTimeout(emailCheckTimer)
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(newEmail)) return
// 防抖500ms 后查询验证状态
emailCheckTimer = window.setTimeout(() => {
checkAndRestoreVerificationStatus(newEmail)
}, 500)
}
)
// Reset form when dialog opens
watch(isOpen, (newValue) => {
if (newValue) {
resetForm()
}
})
// Start cooldown timer
const startCooldown = (seconds: number) => {
// Clear existing timer if any
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
}
cooldownSeconds.value = seconds
cooldownTimer.value = window.setInterval(() => {
cooldownSeconds.value--
if (cooldownSeconds.value <= 0) {
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
cooldownTimer.value = null
}
}
}, 1000)
}
// Cleanup timer on unmount
onUnmounted(() => {
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
}
if (emailCheckTimer !== null) {
clearTimeout(emailCheckTimer)
}
})
const resetForm = () => {
formData.value = {
email: '',
username: '',
password: '',
confirmPassword: '',
verificationCode: ''
}
emailVerified.value = false
verificationError.value = false
isSendingCode.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
// Reset password field nonce
formNonce.value = createFormNonce()
// Clear timer
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
cooldownTimer.value = null
}
// Clear verification code inputs
codeDigits.value = ['', '', '', '', '', '']
}
const handleSendCode = async () => {
if (!formData.value.email) {
showError('请输入邮箱')
return
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.value.email)) {
showError('请输入有效的邮箱地址', '邮箱格式错误')
return
}
isSendingCode.value = true
try {
const response = await authApi.sendVerificationCode(formData.value.email)
if (response.success) {
codeSentAt.value = Date.now()
if (response.expire_minutes) {
expireMinutes.value = response.expire_minutes
}
success(`请查收邮件,验证码有效期 ${expireMinutes.value} 分钟`, '验证码已发送')
// Start 60 second cooldown
startCooldown(60)
// Focus the first verification code input
nextTick(() => {
codeInputRefs.value[0]?.focus()
})
} else {
showError(response.message || '请稍后重试', '发送失败')
}
} catch (error: any) {
const errorMsg = error.response?.data?.detail
|| error.response?.data?.error?.message
|| error.message
|| '网络错误,请重试'
showError(errorMsg, '发送失败')
} finally {
isSendingCode.value = false
}
}
const handleCodeComplete = async (code: string) => {
if (!formData.value.email || code.length !== 6) return
// 如果已经验证成功,不再重复验证
if (emailVerified.value) return
isLoading.value = true
loadingText.value = '验证中...'
verificationError.value = false
try {
const response = await authApi.verifyEmail(formData.value.email, code)
if (response.success) {
emailVerified.value = true
success('邮箱验证通过,请继续完成注册', '验证成功')
} else {
verificationError.value = true
showError(response.message || '验证码错误', '验证失败')
// Clear the code input
clearCodeInputs()
}
} catch (error: any) {
verificationError.value = true
const errorMsg = error.response?.data?.detail
|| error.response?.data?.error?.message
|| error.message
|| '验证码错误,请重试'
showError(errorMsg, '验证失败')
// Clear the code input
clearCodeInputs()
} finally {
isLoading.value = false
}
}
const handleSubmit = async () => {
// Validate password match
if (formData.value.password !== formData.value.confirmPassword) {
showError('两次输入的密码不一致', '密码不匹配')
return
}
// Validate password length
if (formData.value.password.length < 6) {
showError('密码长度至少 6 位', '密码过短')
return
}
// Check email verification if required
if (props.requireEmailVerification && !emailVerified.value) {
showError('请先完成邮箱验证')
return
}
isLoading.value = true
loadingText.value = '注册中...'
try {
const response = await authApi.register({
email: formData.value.email,
username: formData.value.username,
password: formData.value.password
})
success(response.message || '欢迎加入!请登录以继续', '注册成功')
emit('success')
isOpen.value = false
} catch (error: any) {
const errorMsg = error.response?.data?.detail
|| error.response?.data?.error?.message
|| error.message
|| '注册失败,请重试'
showError(errorMsg, '注册失败')
} finally {
isLoading.value = false
}
}
const handleCancel = () => {
isOpen.value = false
}
const handleSwitchToLogin = () => {
emit('switchToLogin')
isOpen.value = false
}
</script>

View File

@@ -374,8 +374,6 @@ import {
} from '@/api/endpoints' } from '@/api/endpoints'
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache' import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
providerId: string providerId: string
@@ -388,6 +386,8 @@ const emit = defineEmits<{
'changed': [] 'changed': []
}>() }>()
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
const { error: showError, success } = useToast() const { error: showError, success } = useToast()
// 状态 // 状态

View File

@@ -177,8 +177,8 @@
<Label for="proxy_user">用户名可选</Label> <Label for="proxy_user">用户名可选</Label>
<Input <Input
:id="`proxy_user_${formId}`" :id="`proxy_user_${formId}`"
:name="`proxy_user_${formId}`"
v-model="form.proxy_username" v-model="form.proxy_username"
:name="`proxy_user_${formId}`"
placeholder="代理认证用户名" placeholder="代理认证用户名"
autocomplete="off" autocomplete="off"
data-form-type="other" data-form-type="other"
@@ -191,8 +191,8 @@
<Label :for="`proxy_pass_${formId}`">密码可选</Label> <Label :for="`proxy_pass_${formId}`">密码可选</Label>
<Input <Input
:id="`proxy_pass_${formId}`" :id="`proxy_pass_${formId}`"
:name="`proxy_pass_${formId}`"
v-model="form.proxy_password" v-model="form.proxy_password"
:name="`proxy_pass_${formId}`"
type="text" type="text"
:placeholder="passwordPlaceholder" :placeholder="passwordPlaceholder"
autocomplete="off" autocomplete="off"

View File

@@ -126,8 +126,14 @@
:disabled="testingModelName === model.global_model_name" :disabled="testingModelName === model.global_model_name"
@click.stop="testModelConnection(model)" @click.stop="testModelConnection(model)"
> >
<Loader2 v-if="testingModelName === model.global_model_name" class="w-3.5 h-3.5 animate-spin" /> <Loader2
<Play v-else class="w-3.5 h-3.5" /> v-if="testingModelName === model.global_model_name"
class="w-3.5 h-3.5 animate-spin"
/>
<Play
v-else
class="w-3.5 h-3.5"
/>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -131,8 +131,14 @@
:disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`" :disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
@click="testMapping(group, mapping)" @click="testMapping(group, mapping)"
> >
<Loader2 v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`" class="w-3 h-3 animate-spin" /> <Loader2
<Play v-else class="w-3 h-3" /> v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
class="w-3 h-3 animate-spin"
/>
<Play
v-else
class="w-3 h-3"
/>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -320,6 +320,7 @@ import {
Megaphone, Megaphone,
Menu, Menu,
X, X,
Mail,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const router = useRouter() const router = useRouter()
@@ -421,6 +422,7 @@ const navigation = computed(() => {
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge }, { name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield }, { name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle }, { name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
{ name: '邮件配置', href: '/admin/email', icon: Mail },
{ name: '系统设置', href: '/admin/system', icon: Cog }, { name: '系统设置', href: '/admin/system', icon: Cog },
] ]
} }

View File

@@ -5,7 +5,7 @@
import type { User, LoginResponse } from '@/api/auth' import type { User, LoginResponse } from '@/api/auth'
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard' import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
import type { User as AdminUser, ApiKey } from '@/api/users' import type { User as AdminUser } from '@/api/users'
import type { AdminApiKeysResponse } from '@/api/admin' import type { AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me' import type { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types' import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
output: 700000, output: 700000,
cache_creation: 50000, cache_creation: 50000,
cache_read: 200000 cache_read: 200000
} },
// 普通用户专用字段
monthly_cost: 45.67
} }
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [ export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' }, { id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' }, { id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' }, { id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' }, { id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' }, { id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' }, { id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' }, { id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' } { id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
] ]
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [ export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
unique_models: 8 + Math.floor(Math.random() * 5), unique_models: 8 + Math.floor(Math.random() * 5),
unique_providers: 4 + Math.floor(Math.random() * 3), unique_providers: 4 + Math.floor(Math.random() * 3),
model_breakdown: [ model_breakdown: [
{ model: 'claude-sonnet-4-20250514', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) }, { model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
{ model: 'gpt-4o', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) }, { model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
{ model: 'claude-opus-4-20250514', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) }, { model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
{ model: 'gemini-2.0-flash', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) }, { model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
{ model: 'claude-haiku-3-5-20241022', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) } { model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
] ]
}) })
} }
@@ -243,11 +245,11 @@ function generateDailyStats(): DailyStatsResponse {
return { return {
daily_stats: dailyStats, daily_stats: dailyStats,
model_summary: [ model_summary: [
{ model: 'claude-sonnet-4-20250514', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 }, { model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
{ model: 'gpt-4o', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 }, { model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
{ model: 'claude-opus-4-20250514', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 }, { model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
{ model: 'gemini-2.0-flash', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 }, { model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
{ model: 'claude-haiku-3-5-20241022', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 } { model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
], ],
period: { period: {
start_date: dailyStats[0].date, start_date: dailyStats[0].date,
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
// ========== API Key 数据 ========== // ========== API Key 数据 ==========
export const MOCK_USER_API_KEYS: ApiKey[] = [ export const MOCK_USER_API_KEYS = [
{ {
id: 'key-uuid-001', id: 'key-uuid-001',
key_display: 'sk-ae...x7f9', key_display: 'sk-ae...x7f9',
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true, is_active: true,
is_standalone: false, is_standalone: false,
total_requests: 1234, total_requests: 1234,
total_cost_usd: 45.67 total_cost_usd: 45.67,
force_capabilities: null
}, },
{ {
id: 'key-uuid-002', id: 'key-uuid-002',
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true, is_active: true,
is_standalone: false, is_standalone: false,
total_requests: 5678, total_requests: 5678,
total_cost_usd: 123.45 total_cost_usd: 123.45,
force_capabilities: { cache_1h: true }
}, },
{ {
id: 'key-uuid-003', id: 'key-uuid-003',
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: false, is_active: false,
is_standalone: false, is_standalone: false,
total_requests: 100, total_requests: 100,
total_cost_usd: 2.34 total_cost_usd: 2.34,
force_capabilities: null
} }
] ]
@@ -813,16 +818,16 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
quota_usd: 100, quota_usd: 100,
used_usd: 45.32, used_usd: 45.32,
summary_by_model: [ summary_by_model: [
{ model: 'claude-sonnet-4-20250514', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 }, { model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
{ model: 'gpt-4o', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 }, { model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
{ model: 'claude-haiku-3-5-20241022', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 }, { model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
{ model: 'gemini-2.0-flash', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 } { model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
], ],
records: [ records: [
{ {
id: 'usage-001', id: 'usage-001',
provider: 'anthropic', provider: 'anthropic',
model: 'claude-sonnet-4-20250514', model: 'claude-sonnet-4-5-20250929',
input_tokens: 1500, input_tokens: 1500,
output_tokens: 800, output_tokens: 800,
total_tokens: 2300, total_tokens: 2300,
@@ -837,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
{ {
id: 'usage-002', id: 'usage-002',
provider: 'openai', provider: 'openai',
model: 'gpt-4o', model: 'gpt-5.1',
input_tokens: 2000, input_tokens: 2000,
output_tokens: 500, output_tokens: 500,
total_tokens: 2500, total_tokens: 2500,

View File

@@ -405,10 +405,10 @@ function getUsageRecords() {
// Mock 映射数据 // Mock 映射数据
const MOCK_ALIASES = [ const MOCK_ALIASES = [
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }, { id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-003', target_global_model_name: 'claude-sonnet-4-5-20250929', target_global_model_display_name: 'Claude Sonnet 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }, { id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-5-20251101', target_global_model_display_name: 'Claude Opus 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-003', source_model: 'gpt4o', target_global_model_id: 'gm-004', target_global_model_name: 'gpt-4o', target_global_model_display_name: 'GPT-4o', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }, { id: 'alias-003', source_model: 'gpt5', target_global_model_id: 'gm-006', target_global_model_name: 'gpt-5.1', target_global_model_display_name: 'GPT-5.1', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-004', source_model: 'gemini-flash', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-2.0-flash', target_global_model_display_name: 'Gemini 2.0 Flash', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' } { id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
] ]
// Mock Endpoint Keys // Mock Endpoint Keys
@@ -2172,10 +2172,10 @@ function generateIntervalTimelineData(
// 模型列表(用于按模型区分颜色) // 模型列表(用于按模型区分颜色)
const models = [ const models = [
'claude-sonnet-4-20250514', 'claude-sonnet-4-5-20250929',
'claude-3-5-sonnet-20241022', 'claude-haiku-4-5-20251001',
'claude-3-5-haiku-20241022', 'claude-opus-4-5-20251101',
'claude-opus-4-20250514' 'gpt-5.1'
] ]
// 生成模拟的请求间隔数据 // 生成模拟的请求间隔数据

View File

@@ -106,6 +106,11 @@ const routes: RouteRecordRaw[] = [
name: 'SystemSettings', name: 'SystemSettings',
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue')) component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
}, },
{
path: 'email',
name: 'EmailSettings',
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
},
{ {
path: 'audit-logs', path: 'audit-logs',
name: 'AuditLogs', name: 'AuditLogs',

View File

@@ -1191,4 +1191,11 @@ body[theme-mode='dark'] .literary-annotation {
.scrollbar-thin::-webkit-scrollbar-thumb:hover { .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5); background-color: hsl(var(--muted-foreground) / 0.5);
} }
/* Password masking without type="password" to prevent browser autofill */
.-webkit-text-security-disc {
-webkit-text-security: disc;
-moz-text-security: disc;
text-security: disc;
}
} }

View File

@@ -1057,7 +1057,10 @@ onBeforeUnmount(() => {
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span> <span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Select v-model="analysisHours" v-model:open="analysisHoursSelectOpen"> <Select
v-model="analysisHours"
v-model:open="analysisHoursSelectOpen"
>
<SelectTrigger class="w-24 sm:w-28 h-8"> <SelectTrigger class="w-24 sm:w-28 h-8">
<SelectValue placeholder="时间段" /> <SelectValue placeholder="时间段" />
</SelectTrigger> </SelectTrigger>

View File

@@ -0,0 +1,856 @@
<template>
<PageContainer>
<PageHeader
title="邮件配置"
description="配置邮件发送服务和注册邮箱限制"
/>
<div class="mt-6 space-y-6">
<!-- SMTP 邮件配置 -->
<CardSection
title="SMTP 邮件配置"
description="配置 SMTP 服务用于发送验证码邮件"
>
<template #actions>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
:disabled="testSmtpLoading"
@click="handleTestSmtp"
>
{{ testSmtpLoading ? '测试中...' : '测试连接' }}
</Button>
<Button
size="sm"
:disabled="smtpSaveLoading"
@click="saveSmtpConfig"
>
{{ smtpSaveLoading ? '保存中...' : '保存' }}
</Button>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="smtp-host"
class="block text-sm font-medium"
>
SMTP 服务器地址
</Label>
<Input
id="smtp-host"
v-model="emailConfig.smtp_host"
type="text"
placeholder="smtp.gmail.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
邮件服务器地址
</p>
</div>
<div>
<Label
for="smtp-port"
class="block text-sm font-medium"
>
SMTP 端口
</Label>
<Input
id="smtp-port"
v-model.number="emailConfig.smtp_port"
type="number"
placeholder="587"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
常用端口: 587 (TLS), 465 (SSL), 25 (无加密)
</p>
</div>
<div>
<Label
for="smtp-user"
class="block text-sm font-medium"
>
SMTP 用户名
</Label>
<Input
id="smtp-user"
v-model="emailConfig.smtp_user"
type="text"
placeholder="your-email@example.com"
class="mt-1"
autocomplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
/>
<p class="mt-1 text-xs text-muted-foreground">
通常是您的邮箱地址
</p>
</div>
<div>
<Label
for="smtp-password"
class="block text-sm font-medium"
>
SMTP 密码
</Label>
<div class="relative mt-1">
<Input
id="smtp-password"
v-model="emailConfig.smtp_password"
type="text"
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
class="-webkit-text-security-disc"
:class="smtpPasswordIsSet ? 'pr-8' : ''"
autocomplete="one-time-code"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
/>
<button
v-if="smtpPasswordIsSet"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
title="清除已保存的密码"
@click="handleClearSmtpPassword"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">
邮箱密码或应用专用密码
</p>
</div>
<div>
<Label
for="smtp-from-email"
class="block text-sm font-medium"
>
发件人邮箱
</Label>
<Input
id="smtp-from-email"
v-model="emailConfig.smtp_from_email"
type="email"
placeholder="noreply@example.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
显示为发件人的邮箱地址
</p>
</div>
<div>
<Label
for="smtp-from-name"
class="block text-sm font-medium"
>
发件人名称
</Label>
<Input
id="smtp-from-name"
v-model="emailConfig.smtp_from_name"
type="text"
placeholder="Aether"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
显示为发件人的名称
</p>
</div>
<div>
<Label
for="smtp-encryption"
class="block text-sm font-medium mb-2"
>
加密方式
</Label>
<Select
v-model="smtpEncryption"
v-model:open="smtpEncryptionSelectOpen"
>
<SelectTrigger
id="smtp-encryption"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssl">
SSL (隐式加密)
</SelectItem>
<SelectItem value="tls">
TLS / STARTTLS
</SelectItem>
<SelectItem value="none">
无加密
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
Gmail 等服务推荐使用 SSL
</p>
</div>
</div>
</CardSection>
<!-- 邮件模板配置 -->
<CardSection
title="邮件模板"
description="配置不同类型邮件的 HTML 模板"
>
<template #actions>
<Button
size="sm"
:disabled="templateSaveLoading"
@click="handleSaveTemplate"
>
{{ templateSaveLoading ? '保存中...' : '保存' }}
</Button>
</template>
<!-- 模板类型选择 -->
<div class="flex items-center gap-2 mb-4">
<button
v-for="tpl in templateTypes"
:key="tpl.type"
class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors"
:class="activeTemplateType === tpl.type
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'"
@click="handleTemplateTypeChange(tpl.type)"
>
{{ tpl.name }}
<span
v-if="tpl.is_custom"
class="ml-1 text-xs opacity-70"
>(已自定义)</span>
</button>
</div>
<!-- 当前模板编辑区 -->
<div
v-if="currentTemplate"
class="space-y-4"
>
<!-- 可用变量提示 -->
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
可用变量:
<code
v-for="(v, i) in currentTemplate.variables"
:key="v"
class="mx-1 px-1.5 py-0.5 bg-background rounded text-foreground"
>{{ formatVariable(v) }}<span v-if="i < currentTemplate.variables.length - 1">,</span></code>
</div>
<!-- 邮件主题 -->
<div>
<Label
for="template-subject"
class="block text-sm font-medium"
>
邮件主题
</Label>
<Input
id="template-subject"
v-model="templateSubject"
type="text"
:placeholder="currentTemplate.default_subject || '验证码'"
class="mt-1"
/>
</div>
<!-- HTML 模板编辑 -->
<div>
<Label
for="template-html"
class="block text-sm font-medium"
>
HTML 模板
</Label>
<textarea
id="template-html"
v-model="templateHtml"
rows="16"
class="mt-1 w-full font-mono text-sm bg-muted/30 border border-border rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
:placeholder="currentTemplate.default_html || '<!DOCTYPE html>...'"
spellcheck="false"
/>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2">
<Button
variant="outline"
:disabled="previewLoading"
@click="handlePreviewTemplate"
>
{{ previewLoading ? '加载中...' : '预览' }}
</Button>
<Button
variant="outline"
:disabled="!currentTemplate.is_custom"
@click="handleResetTemplate"
>
重置为默认
</Button>
</div>
</div>
<!-- 加载中状态 -->
<div
v-else-if="templateLoading"
class="py-8 text-center text-muted-foreground"
>
正在加载模板...
</div>
</CardSection>
<!-- 预览对话框 -->
<Dialog
v-model:open="previewDialogOpen"
no-padding
max-width="xl"
>
<!-- 自定义窗口布局 -->
<div class="flex flex-col max-h-[80vh]">
<!-- 窗口标题栏 -->
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/50 border-b border-border/50 flex-shrink-0">
<div class="flex items-center gap-3">
<button
type="button"
class="flex gap-1.5 group"
title="关闭"
@click="previewDialogOpen = false"
>
<div class="w-2.5 h-2.5 rounded-full bg-red-400/80 group-hover:bg-red-500" />
<div class="w-2.5 h-2.5 rounded-full bg-yellow-400/80" />
<div class="w-2.5 h-2.5 rounded-full bg-green-400/80" />
</button>
<span class="text-sm font-medium text-foreground/80">邮件预览</span>
</div>
<div class="text-xs text-muted-foreground font-mono">
{{ currentTemplate?.name || '模板' }}
</div>
</div>
<!-- 邮件头部信息 -->
<div class="px-4 py-3 bg-muted/30 border-b border-border/30 space-y-1.5 flex-shrink-0">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground w-14">主题:</span>
<span class="font-medium text-foreground">{{ templateSubject || '(无主题)' }}</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground w-14">收件人:</span>
<span class="text-foreground/80">example@example.com</span>
</div>
</div>
<!-- 邮件内容区域 - 直接显示邮件模板 -->
<div class="flex-1 overflow-auto">
<iframe
v-if="previewHtml"
ref="previewIframe"
:srcdoc="previewHtml"
class="w-full border-0"
style="min-height: 400px;"
sandbox="allow-same-origin"
@load="adjustIframeHeight"
/>
</div>
</div>
</Dialog>
<!-- 注册邮箱限制 -->
<CardSection
title="注册邮箱限制"
description="控制允许注册的邮箱后缀,支持白名单或黑名单模式"
>
<template #actions>
<Button
size="sm"
:disabled="emailSuffixSaveLoading"
@click="saveEmailSuffixConfig"
>
{{ emailSuffixSaveLoading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="space-y-4">
<div>
<Label
for="email-suffix-mode"
class="block text-sm font-medium mb-2"
>
限制模式
</Label>
<Select
v-model="emailConfig.email_suffix_mode"
v-model:open="emailSuffixModeSelectOpen"
>
<SelectTrigger
id="email-suffix-mode"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
不限制 - 允许所有邮箱
</SelectItem>
<SelectItem value="whitelist">
白名单 - 仅允许列出的后缀
</SelectItem>
<SelectItem value="blacklist">
黑名单 - 拒绝列出的后缀
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="emailConfig.email_suffix_mode === 'none'">
不限制邮箱后缀所有邮箱均可注册
</template>
<template v-else-if="emailConfig.email_suffix_mode === 'whitelist'">
仅允许下方列出后缀的邮箱注册
</template>
<template v-else>
拒绝下方列出后缀的邮箱注册
</template>
</p>
</div>
<div v-if="emailConfig.email_suffix_mode !== 'none'">
<Label
for="email-suffix-list"
class="block text-sm font-medium"
>
邮箱后缀列表
</Label>
<Input
id="email-suffix-list"
v-model="emailSuffixListStr"
placeholder="gmail.com, outlook.com, qq.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
逗号分隔例如: gmail.com, outlook.com, qq.com
</p>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { useToast } from '@/composables/useToast'
import { adminApi, type EmailTemplateInfo } from '@/api/admin'
import { log } from '@/utils/logger'
const { success, error } = useToast()
interface EmailConfig {
// SMTP 邮件配置
smtp_host: string | null
smtp_port: number
smtp_user: string | null
smtp_password: string | null
smtp_use_tls: boolean
smtp_use_ssl: boolean
smtp_from_email: string | null
smtp_from_name: string
// 注册邮箱限制
email_suffix_mode: 'none' | 'whitelist' | 'blacklist'
email_suffix_list: string[]
}
const smtpSaveLoading = ref(false)
const emailSuffixSaveLoading = ref(false)
const smtpEncryptionSelectOpen = ref(false)
const emailSuffixModeSelectOpen = ref(false)
const testSmtpLoading = ref(false)
const smtpPasswordIsSet = ref(false)
// 邮件模板相关状态
const templateLoading = ref(false)
const templateSaveLoading = ref(false)
const previewLoading = ref(false)
const previewDialogOpen = ref(false)
const previewHtml = ref('')
const templateTypes = ref<EmailTemplateInfo[]>([])
const activeTemplateType = ref('verification')
const templateSubject = ref('')
const templateHtml = ref('')
const previewIframe = ref<HTMLIFrameElement | null>(null)
// 当前选中的模板
const currentTemplate = computed(() => {
return templateTypes.value.find(t => t.type === activeTemplateType.value)
})
// 格式化变量显示(避免 Vue 模板中的双花括号语法冲突)
function formatVariable(name: string): string {
return `{{${name}}}`
}
// 调整 iframe 高度以适应内容
function adjustIframeHeight() {
if (previewIframe.value) {
try {
const doc = previewIframe.value.contentDocument || previewIframe.value.contentWindow?.document
if (doc && doc.body) {
// 获取内容实际高度,添加一点余量
const height = doc.body.scrollHeight + 20
// 限制最大高度为视口的 70%
const maxHeight = window.innerHeight * 0.7
previewIframe.value.style.height = `${Math.min(height, maxHeight)}px`
}
} catch {
// 跨域限制时使用默认高度
previewIframe.value.style.height = '500px'
}
}
}
const emailConfig = ref<EmailConfig>({
// SMTP 邮件配置
smtp_host: null,
smtp_port: 587,
smtp_user: null,
smtp_password: null,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_from_email: null,
smtp_from_name: 'Aether',
// 注册邮箱限制
email_suffix_mode: 'none',
email_suffix_list: [],
})
// 计算属性:邮箱后缀列表数组和字符串之间的转换
const emailSuffixListStr = computed({
get: () => emailConfig.value.email_suffix_list.join(', '),
set: (val: string) => {
emailConfig.value.email_suffix_list = val
.split(',')
.map(s => s.trim().toLowerCase())
.filter(s => s.length > 0)
}
})
// 计算属性SMTP 加密方式ssl/tls/none
const smtpEncryption = computed({
get: () => {
if (emailConfig.value.smtp_use_ssl) return 'ssl'
if (emailConfig.value.smtp_use_tls) return 'tls'
return 'none'
},
set: (val: string) => {
emailConfig.value.smtp_use_ssl = val === 'ssl'
emailConfig.value.smtp_use_tls = val === 'tls'
}
})
onMounted(async () => {
await Promise.all([
loadEmailConfig(),
loadEmailTemplates()
])
})
async function loadEmailTemplates() {
templateLoading.value = true
try {
const response = await adminApi.getEmailTemplates()
templateTypes.value = response.templates
// 设置第一个模板为当前模板
if (response.templates.length > 0) {
const firstTemplate = response.templates[0]
activeTemplateType.value = firstTemplate.type
templateSubject.value = firstTemplate.subject
templateHtml.value = firstTemplate.html
}
} catch (err) {
error('加载邮件模板失败')
log.error('加载邮件模板失败:', err)
} finally {
templateLoading.value = false
}
}
function handleTemplateTypeChange(type: string) {
activeTemplateType.value = type
const template = templateTypes.value.find(t => t.type === type)
if (template) {
templateSubject.value = template.subject
templateHtml.value = template.html
}
}
async function handleSaveTemplate() {
templateSaveLoading.value = true
try {
await adminApi.updateEmailTemplate(activeTemplateType.value, {
subject: templateSubject.value,
html: templateHtml.value
})
// 更新本地状态
const idx = templateTypes.value.findIndex(t => t.type === activeTemplateType.value)
if (idx !== -1) {
templateTypes.value[idx].subject = templateSubject.value
templateTypes.value[idx].html = templateHtml.value
templateTypes.value[idx].is_custom = true
}
success('模板保存成功')
} catch (err) {
error('保存模板失败')
log.error('保存模板失败:', err)
} finally {
templateSaveLoading.value = false
}
}
async function handlePreviewTemplate() {
previewLoading.value = true
try {
const response = await adminApi.previewEmailTemplate(activeTemplateType.value, {
html: templateHtml.value
})
previewHtml.value = response.html
previewDialogOpen.value = true
} catch (err) {
error('预览模板失败')
log.error('预览模板失败:', err)
} finally {
previewLoading.value = false
}
}
async function handleResetTemplate() {
try {
const response = await adminApi.resetEmailTemplate(activeTemplateType.value)
// 更新本地状态
const idx = templateTypes.value.findIndex(t => t.type === activeTemplateType.value)
if (idx !== -1) {
templateTypes.value[idx].subject = response.template.subject
templateTypes.value[idx].html = response.template.html
templateTypes.value[idx].is_custom = false
}
templateSubject.value = response.template.subject
templateHtml.value = response.template.html
success('模板已重置为默认值')
} catch (err) {
error('重置模板失败')
log.error('重置模板失败:', err)
}
}
async function loadEmailConfig() {
try {
const configs = [
// SMTP 邮件配置
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_password',
'smtp_use_tls',
'smtp_use_ssl',
'smtp_from_email',
'smtp_from_name',
// 注册邮箱限制
'email_suffix_mode',
'email_suffix_list',
]
for (const key of configs) {
try {
const response = await adminApi.getSystemConfig(key)
// 特殊处理敏感字段:只记录是否已设置,不填充值
if (key === 'smtp_password') {
smtpPasswordIsSet.value = response.is_set === true
// 不设置 smtp_password 的值,保持为 null
} else if (response.value !== null && response.value !== undefined) {
(emailConfig.value as any)[key] = response.value
}
} catch {
// 配置不存在时使用默认值,无需处理
}
}
} catch (err) {
error('加载邮件配置失败')
log.error('加载邮件配置失败:', err)
}
}
// 保存 SMTP 配置
async function saveSmtpConfig() {
smtpSaveLoading.value = true
try {
const configItems = [
{
key: 'smtp_host',
value: emailConfig.value.smtp_host,
description: 'SMTP 服务器地址'
},
{
key: 'smtp_port',
value: emailConfig.value.smtp_port,
description: 'SMTP 端口'
},
{
key: 'smtp_user',
value: emailConfig.value.smtp_user,
description: 'SMTP 用户名'
},
// 只有输入了新密码才提交(空值表示保持原密码)
...(emailConfig.value.smtp_password
? [{
key: 'smtp_password',
value: emailConfig.value.smtp_password,
description: 'SMTP 密码'
}]
: []),
{
key: 'smtp_use_tls',
value: emailConfig.value.smtp_use_tls,
description: '是否使用 TLS 加密'
},
{
key: 'smtp_use_ssl',
value: emailConfig.value.smtp_use_ssl,
description: '是否使用 SSL 加密'
},
{
key: 'smtp_from_email',
value: emailConfig.value.smtp_from_email,
description: '发件人邮箱'
},
{
key: 'smtp_from_name',
value: emailConfig.value.smtp_from_name,
description: '发件人名称'
},
]
const promises = configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
await Promise.all(promises)
success('SMTP 配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存 SMTP 配置失败:', err)
} finally {
smtpSaveLoading.value = false
}
}
// 保存邮箱后缀限制配置
async function saveEmailSuffixConfig() {
emailSuffixSaveLoading.value = true
try {
const configItems = [
{
key: 'email_suffix_mode',
value: emailConfig.value.email_suffix_mode,
description: '邮箱后缀限制模式none/whitelist/blacklist'
},
{
key: 'email_suffix_list',
value: emailConfig.value.email_suffix_list,
description: '邮箱后缀列表'
},
]
const promises = configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
await Promise.all(promises)
success('邮箱限制配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存邮箱限制配置失败:', err)
} finally {
emailSuffixSaveLoading.value = false
}
}
// 清除 SMTP 密码
async function handleClearSmtpPassword() {
try {
await adminApi.deleteSystemConfig('smtp_password')
smtpPasswordIsSet.value = false
emailConfig.value.smtp_password = null
success('SMTP 密码已清除')
} catch (err) {
error('清除密码失败')
log.error('清除 SMTP 密码失败:', err)
}
}
// 测试 SMTP 连接
async function handleTestSmtp() {
testSmtpLoading.value = true
try {
// 如果没有输入新密码,不发送(后端会使用数据库中的密码)
const result = await adminApi.testSmtpConnection({
smtp_host: emailConfig.value.smtp_host,
smtp_port: emailConfig.value.smtp_port,
smtp_user: emailConfig.value.smtp_user,
smtp_password: emailConfig.value.smtp_password || undefined,
smtp_use_tls: emailConfig.value.smtp_use_tls,
smtp_use_ssl: emailConfig.value.smtp_use_ssl,
smtp_from_email: emailConfig.value.smtp_from_email,
smtp_from_name: emailConfig.value.smtp_from_name
})
if (result.success) {
success('SMTP 连接测试成功')
} else {
error(result.message || '未知错误', 'SMTP 连接测试失败')
}
} catch (err: any) {
log.error('SMTP 连接测试失败:', err)
const errMsg = err.response?.data?.detail || err.message || '未知错误'
error(errMsg, 'SMTP 连接测试失败')
} finally {
testSmtpLoading.value = false
}
}
</script>

View File

@@ -737,6 +737,7 @@ import {
updateGlobalModel, updateGlobalModel,
deleteGlobalModel, deleteGlobalModel,
batchAssignToProviders, batchAssignToProviders,
getGlobalModelProviders,
type GlobalModelResponse, type GlobalModelResponse,
} from '@/api/global-models' } from '@/api/global-models'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
@@ -1080,18 +1081,11 @@ async function selectModel(model: GlobalModelResponse) {
async function loadModelProviders(_globalModelId: string) { async function loadModelProviders(_globalModelId: string) {
loadingModelProviders.value = true loadingModelProviders.value = true
try { try {
// 使用 ModelCatalog API 获取详细的关联提供商信息 // 使用新的 API 获取所有关联提供商(包括非活跃的)
const { getModelCatalog } = await import('@/api/endpoints') const response = await getGlobalModelProviders(_globalModelId)
const catalogResponse = await getModelCatalog()
// 查找当前 GlobalModel 对应的 catalog item // 转换为展示格式
const catalogItem = catalogResponse.models.find( selectedModelProviders.value = response.providers.map(p => ({
m => m.global_model_name === selectedModel.value?.name
)
if (catalogItem) {
// 转换为展示格式,包含完整的模型实现信息
selectedModelProviders.value = catalogItem.providers.map(p => ({
id: p.provider_id, id: p.provider_id,
model_id: p.model_id, model_id: p.model_id,
display_name: p.provider_display_name || p.provider_name, display_name: p.provider_display_name || p.provider_name,
@@ -1113,9 +1107,6 @@ async function loadModelProviders(_globalModelId: string) {
supports_function_calling: p.supports_function_calling, supports_function_calling: p.supports_function_calling,
supports_streaming: p.supports_streaming supports_streaming: p.supports_streaming
})) }))
} else {
selectedModelProviders.value = []
}
} catch (err: any) { } catch (err: any) {
log.error('加载关联提供商失败:', err) log.error('加载关联提供商失败:', err)
showError(parseApiError(err, '加载关联提供商失败'), '错误') showError(parseApiError(err, '加载关联提供商失败'), '错误')

View File

@@ -464,7 +464,6 @@
</div> </div>
</div> </div>
</CardSection> </CardSection>
</div> </div>
<!-- 导入配置对话框 --> <!-- 导入配置对话框 -->
@@ -770,7 +769,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Download, Upload } from 'lucide-vue-next' import { Download, Upload } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue' import Input from '@/components/ui/input.vue'

View File

@@ -179,8 +179,8 @@
</Badge> </Badge>
</div> </div>
<div <div
class="grid gap-2 sm:gap-3"
:class="[ :class="[
'grid gap-2 sm:gap-3',
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs' hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
]" ]"
> >

View File

@@ -477,8 +477,8 @@ async function changePassword() {
return return
} }
if (passwordForm.value.new_password.length < 8) { if (passwordForm.value.new_password.length < 6) {
showError('密码长度至少8位') showError('密码长度至少6位')
return return
} }

View File

@@ -13,7 +13,7 @@ authors = [
classifiers = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: Other/Proprietary License",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",

View File

@@ -5,7 +5,7 @@ GlobalModel Admin API
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional from typing import Optional
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -19,9 +19,11 @@ from src.models.pydantic_models import (
BatchAssignToProvidersResponse, BatchAssignToProvidersResponse,
GlobalModelCreate, GlobalModelCreate,
GlobalModelListResponse, GlobalModelListResponse,
GlobalModelProvidersResponse,
GlobalModelResponse, GlobalModelResponse,
GlobalModelUpdate, GlobalModelUpdate,
GlobalModelWithStats, GlobalModelWithStats,
ModelCatalogProviderDetail,
) )
from src.services.model.global_model import GlobalModelService from src.services.model.global_model import GlobalModelService
@@ -108,6 +110,17 @@ async def batch_assign_to_providers(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{global_model_id}/providers", response_model=GlobalModelProvidersResponse)
async def get_global_model_providers(
request: Request,
global_model_id: str,
db: Session = Depends(get_db),
) -> GlobalModelProvidersResponse:
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ========== Adapters ========== # ========== Adapters ==========
@@ -275,3 +288,61 @@ class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}") logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
return BatchAssignToProvidersResponse(**result) return BatchAssignToProvidersResponse(**result)
@dataclass
class AdminGetGlobalModelProvidersAdapter(AdminApiAdapter):
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
global_model_id: str
async def handle(self, context): # type: ignore[override]
from sqlalchemy.orm import joinedload
from src.models.database import Model
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
# 获取所有关联的 Model包括非活跃的
models = (
context.db.query(Model)
.options(joinedload(Model.provider), joinedload(Model.global_model))
.filter(Model.global_model_id == global_model.id)
.all()
)
provider_entries = []
for model in models:
provider = model.provider
if not provider:
continue
effective_tiered = model.get_effective_tiered_pricing()
tier_count = len(effective_tiered.get("tiers", [])) if effective_tiered else 1
provider_entries.append(
ModelCatalogProviderDetail(
provider_id=provider.id,
provider_name=provider.name,
provider_display_name=provider.display_name,
model_id=model.id,
target_model=model.provider_model_name,
input_price_per_1m=model.get_effective_input_price(),
output_price_per_1m=model.get_effective_output_price(),
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
cache_read_price_per_1m=model.get_effective_cache_read_price(),
cache_1h_creation_price_per_1m=model.get_effective_1h_cache_creation_price(),
price_per_request=model.get_effective_price_per_request(),
effective_tiered_pricing=effective_tiered,
tier_count=tier_count,
supports_vision=model.get_effective_supports_vision(),
supports_function_calling=model.get_effective_supports_function_calling(),
supports_streaming=model.get_effective_supports_streaming(),
is_active=bool(model.is_active),
)
)
return GlobalModelProvidersResponse(
providers=provider_entries,
total=len(provider_entries),
)

View File

@@ -13,6 +13,7 @@ from src.core.exceptions import InvalidRequestException, NotFoundException, tran
from src.database import get_db from src.database import get_db
from src.models.api import SystemSettingsRequest, SystemSettingsResponse from src.models.api import SystemSettingsRequest, SystemSettingsResponse
from src.models.database import ApiKey, Provider, Usage, User from src.models.database import ApiKey, Provider, Usage, User
from src.services.email.email_template import EmailTemplate
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"]) router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
@@ -119,6 +120,59 @@ async def import_users(request: Request, db: Session = Depends(get_db)):
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/smtp/test")
async def test_smtp(request: Request, db: Session = Depends(get_db)):
"""测试 SMTP 连接(管理员)"""
adapter = AdminTestSmtpAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# -------- 邮件模板 API --------
@router.get("/email/templates")
async def get_email_templates(request: Request, db: Session = Depends(get_db)):
"""获取所有邮件模板(管理员)"""
adapter = AdminGetEmailTemplatesAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/email/templates/{template_type}")
async def get_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""获取指定类型的邮件模板(管理员)"""
adapter = AdminGetEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/email/templates/{template_type}")
async def update_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""更新邮件模板(管理员)"""
adapter = AdminUpdateEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/email/templates/{template_type}/preview")
async def preview_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""预览邮件模板(管理员)"""
adapter = AdminPreviewEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/email/templates/{template_type}/reset")
async def reset_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""重置邮件模板为默认值(管理员)"""
adapter = AdminResetEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# -------- 系统设置适配器 -------- # -------- 系统设置适配器 --------
@@ -196,10 +250,16 @@ class AdminGetAllConfigsAdapter(AdminApiAdapter):
class AdminGetSystemConfigAdapter(AdminApiAdapter): class AdminGetSystemConfigAdapter(AdminApiAdapter):
key: str key: str
# 敏感配置项,不返回实际值
SENSITIVE_KEYS = {"smtp_password"}
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
value = SystemConfigService.get_config(context.db, self.key) value = SystemConfigService.get_config(context.db, self.key)
if value is None: if value is None:
raise NotFoundException(f"配置项 '{self.key}' 不存在") raise NotFoundException(f"配置项 '{self.key}' 不存在")
# 对敏感配置,只返回是否已设置的标志,不返回实际值
if self.key in self.SENSITIVE_KEYS:
return {"key": self.key, "value": None, "is_set": bool(value)}
return {"key": self.key, "value": value} return {"key": self.key, "value": value}
@@ -207,18 +267,31 @@ class AdminGetSystemConfigAdapter(AdminApiAdapter):
class AdminSetSystemConfigAdapter(AdminApiAdapter): class AdminSetSystemConfigAdapter(AdminApiAdapter):
key: str key: str
# 需要加密存储的配置项
ENCRYPTED_KEYS = {"smtp_password"}
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
payload = context.ensure_json_body() payload = context.ensure_json_body()
value = payload.get("value")
# 对敏感配置进行加密
if self.key in self.ENCRYPTED_KEYS and value:
from src.core.crypto import crypto_service
value = crypto_service.encrypt(value)
config = SystemConfigService.set_config( config = SystemConfigService.set_config(
context.db, context.db,
self.key, self.key,
payload.get("value"), value,
payload.get("description"), payload.get("description"),
) )
# 返回时不暴露加密后的值
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
return { return {
"key": config.key, "key": config.key,
"value": config.value, "value": display_value,
"description": config.description, "description": config.description,
"updated_at": config.updated_at.isoformat(), "updated_at": config.updated_at.isoformat(),
} }
@@ -1084,3 +1157,265 @@ class AdminImportUsersAdapter(AdminApiAdapter):
except Exception as e: except Exception as e:
db.rollback() db.rollback()
raise InvalidRequestException(f"导入失败: {str(e)}") raise InvalidRequestException(f"导入失败: {str(e)}")
class AdminTestSmtpAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override]
"""测试 SMTP 连接"""
from src.core.crypto import crypto_service
from src.services.system.config import SystemConfigService
from src.services.email.email_sender import EmailSenderService
db = context.db
payload = context.ensure_json_body() or {}
# 获取密码:优先使用前端传入的明文密码,否则从数据库获取并解密
smtp_password = payload.get("smtp_password")
if not smtp_password:
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
if encrypted_password:
try:
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
except Exception:
# 解密失败,可能是旧的未加密密码
smtp_password = encrypted_password
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
config = {
"smtp_host": payload.get("smtp_host") or SystemConfigService.get_config(db, "smtp_host"),
"smtp_port": payload.get("smtp_port") or SystemConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": payload.get("smtp_user") or SystemConfigService.get_config(db, "smtp_user"),
"smtp_password": smtp_password,
"smtp_use_tls": payload.get("smtp_use_tls")
if payload.get("smtp_use_tls") is not None
else SystemConfigService.get_config(db, "smtp_use_tls", default=True),
"smtp_use_ssl": payload.get("smtp_use_ssl")
if payload.get("smtp_use_ssl") is not None
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
"smtp_from_email": payload.get("smtp_from_email")
or SystemConfigService.get_config(db, "smtp_from_email"),
"smtp_from_name": payload.get("smtp_from_name")
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
}
# 验证必要配置
missing_fields = [
field for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"] if not config.get(field)
]
if missing_fields:
return {
"success": False,
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}"
}
# 测试连接
try:
success, error_msg = await EmailSenderService.test_smtp_connection(
db=db, override_config=config
)
if success:
return {
"success": True,
"message": "SMTP 连接测试成功"
}
else:
return {
"success": False,
"message": error_msg
}
except Exception as e:
return {
"success": False,
"message": str(e)
}
# -------- 邮件模板适配器 --------
class AdminGetEmailTemplatesAdapter(AdminApiAdapter):
"""获取所有邮件模板"""
async def handle(self, context): # type: ignore[override]
db = context.db
templates = []
for template_type, type_info in EmailTemplate.TEMPLATE_TYPES.items():
# 获取自定义模板或默认模板
template = EmailTemplate.get_template(db, template_type)
default_template = EmailTemplate.get_default_template(template_type)
# 检查是否使用了自定义模板
is_custom = (
template["subject"] != default_template["subject"]
or template["html"] != default_template["html"]
)
templates.append(
{
"type": template_type,
"name": type_info["name"],
"variables": type_info["variables"],
"subject": template["subject"],
"html": template["html"],
"is_custom": is_custom,
}
)
return {"templates": templates}
@dataclass
class AdminGetEmailTemplateAdapter(AdminApiAdapter):
"""获取指定类型的邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
template = EmailTemplate.get_template(db, self.template_type)
default_template = EmailTemplate.get_default_template(self.template_type)
is_custom = (
template["subject"] != default_template["subject"]
or template["html"] != default_template["html"]
)
return {
"type": self.template_type,
"name": type_info["name"],
"variables": type_info["variables"],
"subject": template["subject"],
"html": template["html"],
"is_custom": is_custom,
"default_subject": default_template["subject"],
"default_html": default_template["html"],
}
@dataclass
class AdminUpdateEmailTemplateAdapter(AdminApiAdapter):
"""更新邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
payload = context.ensure_json_body()
subject = payload.get("subject")
html = payload.get("html")
# 至少需要提供一个字段
if subject is None and html is None:
raise InvalidRequestException("请提供 subject 或 html")
# 保存模板
subject_key = f"email_template_{self.template_type}_subject"
html_key = f"email_template_{self.template_type}_html"
if subject is not None:
if subject:
SystemConfigService.set_config(db, subject_key, subject)
else:
# 空字符串表示删除自定义值,恢复默认
SystemConfigService.delete_config(db, subject_key)
if html is not None:
if html:
SystemConfigService.set_config(db, html_key, html)
else:
SystemConfigService.delete_config(db, html_key)
return {"message": "模板保存成功"}
@dataclass
class AdminPreviewEmailTemplateAdapter(AdminApiAdapter):
"""预览邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
payload = context.ensure_json_body() or {}
# 获取模板 HTML优先使用请求体中的否则使用数据库中的
html = payload.get("html")
if not html:
template = EmailTemplate.get_template(db, self.template_type)
html = template["html"]
# 获取预览变量
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
# 构建预览变量,使用请求中的值或默认示例值
preview_variables = {}
default_values = {
"app_name": SystemConfigService.get_config(db, "email_app_name")
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
"code": "123456",
"expire_minutes": "30",
"email": "example@example.com",
"reset_link": "https://example.com/reset?token=abc123",
}
for var in type_info["variables"]:
preview_variables[var] = payload.get(var, default_values.get(var, f"{{{{{var}}}}}"))
# 渲染模板
rendered_html = EmailTemplate.render_template(html, preview_variables)
return {
"html": rendered_html,
"variables": preview_variables,
}
@dataclass
class AdminResetEmailTemplateAdapter(AdminApiAdapter):
"""重置邮件模板为默认值"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
# 删除自定义模板
subject_key = f"email_template_{self.template_type}_subject"
html_key = f"email_template_{self.template_type}_html"
SystemConfigService.delete_config(db, subject_key)
SystemConfigService.delete_config(db, html_key)
# 返回默认模板
default_template = EmailTemplate.get_default_template(self.template_type)
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
return {
"message": "模板已重置为默认值",
"template": {
"type": self.template_type,
"name": type_info["name"],
"subject": default_template["subject"],
"html": default_template["html"],
},
}

View File

@@ -2,7 +2,7 @@
认证相关API端点 认证相关API端点
""" """
from typing import Optional from typing import Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -23,21 +23,82 @@ from src.models.api import (
RefreshTokenResponse, RefreshTokenResponse,
RegisterRequest, RegisterRequest,
RegisterResponse, RegisterResponse,
RegistrationSettingsResponse,
SendVerificationCodeRequest,
SendVerificationCodeResponse,
VerificationStatusRequest,
VerificationStatusResponse,
VerifyEmailRequest,
VerifyEmailResponse,
) )
from src.models.database import AuditEventType, User, UserRole from src.models.database import AuditEventType, User, UserRole
from src.services.auth.service import AuthService from src.services.auth.service import AuthService
from src.services.rate_limit.ip_limiter import IPRateLimiter from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService from src.services.system.audit import AuditService
from src.services.system.config import SystemConfigService
from src.services.user.service import UserService from src.services.user.service import UserService
from src.services.email import EmailSenderService, EmailVerificationService
from src.utils.request_utils import get_client_ip, get_user_agent from src.utils.request_utils import get_client_ip, get_user_agent
def validate_email_suffix(db: Session, email: str) -> Tuple[bool, Optional[str]]:
"""
验证邮箱后缀是否允许注册
Args:
db: 数据库会话
email: 邮箱地址
Returns:
(是否允许, 错误信息)
"""
# 获取邮箱后缀限制配置
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
if mode == "none":
return True, None
# 获取邮箱后缀列表
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
if not suffix_list:
# 没有配置后缀列表时,不限制
return True, None
# 确保 suffix_list 是列表类型
if isinstance(suffix_list, str):
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
# 获取邮箱后缀
if "@" not in email:
return False, "邮箱格式无效"
email_suffix = email.split("@")[1].lower()
if mode == "whitelist":
# 白名单模式:只允许列出的后缀
if email_suffix not in suffix_list:
return False, f"该邮箱后缀不在允许列表中,仅支持: {', '.join(suffix_list)}"
elif mode == "blacklist":
# 黑名单模式:拒绝列出的后缀
if email_suffix in suffix_list:
return False, f"该邮箱后缀 ({email_suffix}) 不允许注册"
return True, None
router = APIRouter(prefix="/api/auth", tags=["Authentication"]) router = APIRouter(prefix="/api/auth", tags=["Authentication"])
security = HTTPBearer() security = HTTPBearer()
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
# API端点 # API端点
@router.get("/registration-settings", response_model=RegistrationSettingsResponse)
async def registration_settings(request: Request, db: Session = Depends(get_db)):
"""公开获取注册相关配置"""
adapter = AuthRegistrationSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login(request: Request, db: Session = Depends(get_db)): async def login(request: Request, db: Session = Depends(get_db)):
adapter = AuthLoginAdapter() adapter = AuthLoginAdapter()
@@ -75,6 +136,27 @@ async def logout(request: Request, db: Session = Depends(get_db)):
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
async def send_verification_code(request: Request, db: Session = Depends(get_db)):
"""发送邮箱验证码"""
adapter = AuthSendVerificationCodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/verify-email", response_model=VerifyEmailResponse)
async def verify_email(request: Request, db: Session = Depends(get_db)):
"""验证邮箱验证码"""
adapter = AuthVerifyEmailAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/verification-status", response_model=VerificationStatusResponse)
async def verification_status(request: Request, db: Session = Depends(get_db)):
"""查询邮箱验证状态"""
adapter = AuthVerificationStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ============== 适配器实现 ============== # ============== 适配器实现 ==============
@@ -209,6 +291,20 @@ class AuthRefreshAdapter(AuthPublicAdapter):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败")
class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""公开返回注册相关配置"""
db = context.db
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
return RegistrationSettingsResponse(
enable_registration=bool(enable_registration),
require_email_verification=bool(require_verification),
).model_dump()
class AuthRegisterAdapter(AuthPublicAdapter): class AuthRegisterAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
from src.models.database import SystemConfig from src.models.database import SystemConfig
@@ -241,6 +337,37 @@ class AuthRegisterAdapter(AuthPublicAdapter):
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
# 检查邮箱后缀是否允许
suffix_allowed, suffix_error = validate_email_suffix(db, register_request.email)
if not suffix_allowed:
logger.warning(f"注册失败:邮箱后缀不允许: {register_request.email}")
AuditService.log_event(
db=db,
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
description=f"Registration attempt rejected - email suffix not allowed: {register_request.email}",
ip_address=client_ip,
user_agent=user_agent,
metadata={"email": register_request.email, "reason": "email_suffix_not_allowed"},
)
db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=suffix_error,
)
# 检查是否需要邮箱验证
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
if require_verification:
# 检查邮箱是否已验证
is_verified = await EmailVerificationService.is_email_verified(register_request.email)
if not is_verified:
logger.warning(f"注册失败:邮箱未验证: {register_request.email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
)
try: try:
user = UserService.create_user( user = UserService.create_user(
db=db, db=db,
@@ -258,7 +385,16 @@ class AuthRegisterAdapter(AuthPublicAdapter):
user_agent=user_agent, user_agent=user_agent,
metadata={"email": user.email, "username": user.username, "role": user.role.value}, metadata={"email": user.email, "username": user.username, "role": user.role.value},
) )
db.commit() db.commit()
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
if require_verification:
try:
await EmailVerificationService.clear_verification(register_request.email)
except Exception as e:
logger.warning(f"清理验证状态失败: {e}")
return RegisterResponse( return RegisterResponse(
user_id=user.id, user_id=user.id,
email=user.email, email=user.email,
@@ -308,8 +444,8 @@ class AuthChangePasswordAdapter(AuthenticatedApiAdapter):
user = context.user user = context.user
if not user.verify_password(old_password): if not user.verify_password(old_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
if len(new_password) < 8: if len(new_password) < 6:
raise InvalidRequestException("密码长度至少8") raise InvalidRequestException("密码长度至少6")
user.set_password(new_password) user.set_password(new_password)
context.db.commit() context.db.commit()
logger.info(f"用户修改密码: {user.email}") logger.info(f"用户修改密码: {user.email}")
@@ -351,3 +487,177 @@ class AuthLogoutAdapter(AuthenticatedApiAdapter):
else: else:
logger.warning(f"用户登出失败Redis不可用: {user.email}") logger.warning(f"用户登出失败Redis不可用: {user.email}")
return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump() return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump()
class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""发送邮箱验证码"""
db = context.db
payload = context.ensure_json_body()
try:
send_request = SendVerificationCodeRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
email = send_request.email
# IP 速率限制检查验证码发送3次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_send"
)
if not allowed:
logger.warning(f"验证码发送请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 检查邮箱是否已注册
existing_user = db.query(User).filter(User.email == email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已被注册,请直接登录或使用其他邮箱",
)
# 检查邮箱后缀是否允许
suffix_allowed, suffix_error = validate_email_suffix(db, email)
if not suffix_allowed:
logger.warning(f"邮箱后缀不允许: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=suffix_error,
)
# 生成并发送验证码(使用服务中的默认配置)
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
email
)
if not success:
logger.error(f"发送验证码失败: {email}, 错误: {code_or_error}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_detail or code_or_error,
)
# 发送邮件
expire_minutes = EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
email_success, email_error = await EmailSenderService.send_verification_code(
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
)
if not email_success:
logger.error(f"发送验证码邮件失败: {email}, 错误: {email_error}")
# 不向用户暴露 SMTP 详细错误信息,防止信息泄露
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="发送验证码失败,请稍后重试",
)
logger.info(f"验证码已发送: {email}")
return SendVerificationCodeResponse(
message="验证码已发送,请查收邮件",
success=True,
expire_minutes=expire_minutes,
).model_dump()
class AuthVerifyEmailAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""验证邮箱验证码"""
db = context.db
payload = context.ensure_json_body()
try:
verify_request = VerifyEmailRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
email = verify_request.email
code = verify_request.code
# IP 速率限制检查验证码验证10次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_verify"
)
if not allowed:
logger.warning(f"验证码验证请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 验证验证码
success, message = await EmailVerificationService.verify_code(email, code)
if not success:
logger.warning(f"验证码验证失败: {email}, 原因: {message}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
logger.info(f"邮箱验证成功: {email}")
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()
class AuthVerificationStatusAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""查询邮箱验证状态"""
payload = context.ensure_json_body()
try:
status_request = VerificationStatusRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
email = status_request.email
# IP 速率限制检查验证状态查询20次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_status", limit=20
)
if not allowed:
logger.warning(f"验证状态查询请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 获取验证状态
status_data = await EmailVerificationService.get_verification_status(email)
# 计算冷却剩余时间
cooldown_remaining = None
if status_data.get("has_pending_code") and status_data.get("created_at"):
from datetime import datetime, timezone
created_at = datetime.fromisoformat(status_data["created_at"])
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
cooldown = EmailVerificationService.SEND_COOLDOWN_SECONDS - int(elapsed)
if cooldown > 0:
cooldown_remaining = cooldown
return VerificationStatusResponse(
email=email,
has_pending_code=status_data.get("has_pending_code", False),
is_verified=status_data.get("is_verified", False),
cooldown_remaining=cooldown_remaining,
code_expires_in=status_data.get("code_expires_in"),
).model_dump()

View File

@@ -18,7 +18,15 @@ from sqlalchemy.orm import Session, joinedload
from src.config.constants import CacheTTL from src.config.constants import CacheTTL
from src.core.cache_service import CacheService from src.core.cache_service import CacheService
from src.core.logger import logger from src.core.logger import logger
from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import (
ApiKey,
GlobalModel,
Model,
Provider,
ProviderAPIKey,
ProviderEndpoint,
User,
)
# 缓存 key 前缀 # 缓存 key 前缀
_CACHE_KEY_PREFIX = "models:list" _CACHE_KEY_PREFIX = "models:list"
@@ -82,6 +90,7 @@ class ModelInfo:
created_at: Optional[str] # ISO 格式 created_at: Optional[str] # ISO 格式
created_timestamp: int # Unix 时间戳 created_timestamp: int # Unix 时间戳
provider_name: str provider_name: str
provider_id: str = "" # Provider ID用于权限过滤
# 能力配置 # 能力配置
streaming: bool = True streaming: bool = True
vision: bool = False vision: bool = False
@@ -99,6 +108,92 @@ class ModelInfo:
output_modalities: Optional[list[str]] = None output_modalities: Optional[list[str]] = None
@dataclass
class AccessRestrictions:
"""API Key 或 User 的访问限制"""
allowed_providers: Optional[list[str]] = None # 允许的 Provider ID 列表
allowed_models: Optional[list[str]] = None # 允许的模型名称列表
allowed_api_formats: Optional[list[str]] = None # 允许的 API 格式列表
@classmethod
def from_api_key_and_user(
cls, api_key: Optional[ApiKey], user: Optional[User]
) -> "AccessRestrictions":
"""
从 API Key 和 User 合并访问限制
限制逻辑:
- API Key 的限制优先于 User 的限制
- 如果 API Key 有限制,使用 API Key 的限制
- 如果 API Key 无限制但 User 有限制,使用 User 的限制
- 两者都无限制则返回空限制
"""
allowed_providers: Optional[list[str]] = None
allowed_models: Optional[list[str]] = None
allowed_api_formats: Optional[list[str]] = None
# 优先使用 API Key 的限制
if api_key:
if api_key.allowed_providers is not None:
allowed_providers = api_key.allowed_providers
if api_key.allowed_models is not None:
allowed_models = api_key.allowed_models
if api_key.allowed_api_formats is not None:
allowed_api_formats = api_key.allowed_api_formats
# 如果 API Key 没有限制,检查 User 的限制
# 注意: User 没有 allowed_api_formats 字段
if user:
if allowed_providers is None and user.allowed_providers is not None:
allowed_providers = user.allowed_providers
if allowed_models is None and user.allowed_models is not None:
allowed_models = user.allowed_models
return cls(
allowed_providers=allowed_providers,
allowed_models=allowed_models,
allowed_api_formats=allowed_api_formats,
)
def is_api_format_allowed(self, api_format: str) -> bool:
"""
检查 API 格式是否被允许
Args:
api_format: API 格式 (如 "OPENAI", "CLAUDE", "GEMINI")
Returns:
True 如果格式被允许False 否则
"""
if self.allowed_api_formats is None:
return True
return api_format in self.allowed_api_formats
def is_model_allowed(self, model_id: str, provider_id: str) -> bool:
"""
检查模型是否被允许访问
Args:
model_id: 模型 ID
provider_id: Provider ID
Returns:
True 如果模型被允许False 否则
"""
# 检查 Provider 限制
if self.allowed_providers is not None:
if provider_id not in self.allowed_providers:
return False
# 检查模型限制
if self.allowed_models is not None:
if model_id not in self.allowed_models:
return False
return True
def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]: def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]:
""" """
返回有可用端点的 Provider IDs 返回有可用端点的 Provider IDs
@@ -218,6 +313,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
) )
created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0 created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0
provider_name: str = model.provider.name if model.provider else "unknown" provider_name: str = model.provider.name if model.provider else "unknown"
provider_id: str = model.provider_id or ""
# 从 GlobalModel.config 提取配置信息 # 从 GlobalModel.config 提取配置信息
config: dict = {} config: dict = {}
@@ -233,6 +329,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
created_at=created_at, created_at=created_at,
created_timestamp=created_timestamp, created_timestamp=created_timestamp,
provider_name=provider_name, provider_name=provider_name,
provider_id=provider_id,
# 能力配置 # 能力配置
streaming=config.get("streaming", True), streaming=config.get("streaming", True),
vision=config.get("vision", False), vision=config.get("vision", False),
@@ -255,6 +352,7 @@ async def list_available_models(
db: Session, db: Session,
available_provider_ids: set[str], available_provider_ids: set[str],
api_formats: Optional[list[str]] = None, api_formats: Optional[list[str]] = None,
restrictions: Optional[AccessRestrictions] = None,
) -> list[ModelInfo]: ) -> list[ModelInfo]:
""" """
获取可用模型列表(已去重,带缓存) 获取可用模型列表(已去重,带缓存)
@@ -263,6 +361,7 @@ async def list_available_models(
db: 数据库会话 db: 数据库会话
available_provider_ids: 有可用端点的 Provider ID 集合 available_provider_ids: 有可用端点的 Provider ID 集合
api_formats: API 格式列表,用于检查 Key 的 allowed_models api_formats: API 格式列表,用于检查 Key 的 allowed_models
restrictions: API Key/User 的访问限制
Returns: Returns:
去重后的 ModelInfo 列表,按创建时间倒序 去重后的 ModelInfo 列表,按创建时间倒序
@@ -270,8 +369,16 @@ async def list_available_models(
if not available_provider_ids: if not available_provider_ids:
return [] return []
# 缓存策略:只有完全无访问限制时才使用缓存
# - restrictions is None: 未传入限制对象
# - restrictions 的两个字段都为 None: 传入了限制对象但无实际限制
# 以上两种情况返回的结果相同,可以共享全局缓存
use_cache = restrictions is None or (
restrictions.allowed_providers is None and restrictions.allowed_models is None
)
# 尝试从缓存获取 # 尝试从缓存获取
if api_formats: if api_formats and use_cache:
cached = await _get_cached_models(api_formats) cached = await _get_cached_models(api_formats)
if cached is not None: if cached is not None:
return cached return cached
@@ -306,14 +413,19 @@ async def list_available_models(
if available_model_ids is not None and info.id not in available_model_ids: if available_model_ids is not None and info.id not in available_model_ids:
continue continue
# 检查 API Key/User 访问限制
if restrictions is not None:
if not restrictions.is_model_allowed(info.id, info.provider_id):
continue
if info.id in seen_model_ids: if info.id in seen_model_ids:
continue continue
seen_model_ids.add(info.id) seen_model_ids.add(info.id)
result.append(info) result.append(info)
# 写入缓存 # 只有无限制的情况才写入缓存
if api_formats: if api_formats and use_cache:
await _set_cached_models(api_formats, result) await _set_cached_models(api_formats, result)
return result return result
@@ -324,6 +436,7 @@ def find_model_by_id(
model_id: str, model_id: str,
available_provider_ids: set[str], available_provider_ids: set[str],
api_formats: Optional[list[str]] = None, api_formats: Optional[list[str]] = None,
restrictions: Optional[AccessRestrictions] = None,
) -> Optional[ModelInfo]: ) -> Optional[ModelInfo]:
""" """
按 ID 查找模型 按 ID 查找模型
@@ -338,6 +451,7 @@ def find_model_by_id(
model_id: 模型 ID model_id: 模型 ID
available_provider_ids: 有可用端点的 Provider ID 集合 available_provider_ids: 有可用端点的 Provider ID 集合
api_formats: API 格式列表,用于检查 Key 的 allowed_models api_formats: API 格式列表,用于检查 Key 的 allowed_models
restrictions: API Key/User 的访问限制
Returns: Returns:
ModelInfo 或 None ModelInfo 或 None
@@ -353,6 +467,11 @@ def find_model_by_id(
if available_model_ids is not None and model_id not in available_model_ids: if available_model_ids is not None and model_id not in available_model_ids:
return None return None
# 快速检查:如果 restrictions 明确限制了模型列表且目标模型不在其中,直接返回 None
if restrictions is not None and restrictions.allowed_models is not None:
if model_id not in restrictions.allowed_models:
return None
# 先按 GlobalModel.name 查找 # 先按 GlobalModel.name 查找
models_by_global = ( models_by_global = (
db.query(Model) db.query(Model)
@@ -368,8 +487,19 @@ def find_model_by_id(
.all() .all()
) )
def is_model_accessible(m: Model) -> bool:
"""检查模型是否可访问"""
if m.provider_id not in available_provider_ids:
return False
# 检查 API Key/User 访问限制
if restrictions is not None:
provider_id = m.provider_id or ""
if not restrictions.is_model_allowed(model_id, provider_id):
return False
return True
model = next( model = next(
(m for m in models_by_global if m.provider_id in available_provider_ids), (m for m in models_by_global if is_model_accessible(m)),
None, None,
) )
@@ -393,7 +523,7 @@ def find_model_by_id(
) )
model = next( model = next(
(m for m in models_by_provider_name if m.provider_id in available_provider_ids), (m for m in models_by_provider_name if is_model_accessible(m)),
None, None,
) )

View File

@@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.models_service import ( from src.api.base.models_service import (
AccessRestrictions,
ModelInfo, ModelInfo,
find_model_by_id, find_model_by_id,
get_available_provider_ids, get_available_provider_ids,
@@ -103,6 +104,35 @@ def _get_formats_for_api(api_format: str) -> list[str]:
return _OPENAI_FORMATS return _OPENAI_FORMATS
def _build_empty_list_response(api_format: str) -> dict:
"""根据 API 格式构建空列表响应"""
if api_format == "claude":
return {"data": [], "has_more": False, "first_id": None, "last_id": None}
elif api_format == "gemini":
return {"models": []}
else:
return {"object": "list", "data": []}
def _filter_formats_by_restrictions(
formats: list[str], restrictions: AccessRestrictions, api_format: str
) -> Tuple[list[str], Optional[dict]]:
"""
根据访问限制过滤 API 格式
Returns:
(过滤后的格式列表, 空响应或None)
如果过滤后为空,返回对应格式的空响应
"""
if restrictions.allowed_api_formats is None:
return formats, None
filtered = [f for f in formats if f in restrictions.allowed_api_formats]
if not filtered:
logger.info(f"[Models] API Key 不允许访问格式 {api_format}")
return [], _build_empty_list_response(api_format)
return filtered, None
def _authenticate(db: Session, api_key: Optional[str]) -> Tuple[Optional[User], Optional[ApiKey]]: def _authenticate(db: Session, api_key: Optional[str]) -> Tuple[Optional[User], Optional[ApiKey]]:
""" """
认证 API Key 认证 API Key
@@ -375,22 +405,24 @@ async def list_models(
logger.info(f"[Models] GET /v1/models | format={api_format}") logger.info(f"[Models] GET /v1/models | format={api_format}")
# 认证 # 认证
user, _ = _authenticate(db, api_key) user, key_record = _authenticate(db, api_key)
if not user: if not user:
return _build_auth_error_response(api_format) return _build_auth_error_response(api_format)
# 构建访问限制
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 检查 API 格式限制
formats = _get_formats_for_api(api_format) formats = _get_formats_for_api(api_format)
formats, empty_response = _filter_formats_by_restrictions(formats, restrictions, api_format)
if empty_response is not None:
return empty_response
available_provider_ids = get_available_provider_ids(db, formats) available_provider_ids = get_available_provider_ids(db, formats)
if not available_provider_ids: if not available_provider_ids:
if api_format == "claude": return _build_empty_list_response(api_format)
return {"data": [], "has_more": False, "first_id": None, "last_id": None}
elif api_format == "gemini":
return {"models": []}
else:
return {"object": "list", "data": []}
models = await list_available_models(db, available_provider_ids, formats) models = await list_available_models(db, available_provider_ids, formats, restrictions)
logger.debug(f"[Models] 返回 {len(models)} 个模型") logger.debug(f"[Models] 返回 {len(models)} 个模型")
if api_format == "claude": if api_format == "claude":
@@ -419,14 +451,21 @@ async def retrieve_model(
logger.info(f"[Models] GET /v1/models/{model_id} | format={api_format}") logger.info(f"[Models] GET /v1/models/{model_id} | format={api_format}")
# 认证 # 认证
user, _ = _authenticate(db, api_key) user, key_record = _authenticate(db, api_key)
if not user: if not user:
return _build_auth_error_response(api_format) return _build_auth_error_response(api_format)
# 构建访问限制
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 检查 API 格式限制
formats = _get_formats_for_api(api_format) formats = _get_formats_for_api(api_format)
formats, _ = _filter_formats_by_restrictions(formats, restrictions, api_format)
if not formats:
return _build_404_response(model_id, api_format)
available_provider_ids = get_available_provider_ids(db, formats) available_provider_ids = get_available_provider_ids(db, formats)
model_info = find_model_by_id(db, model_id, available_provider_ids, formats) model_info = find_model_by_id(db, model_id, available_provider_ids, formats, restrictions)
if not model_info: if not model_info:
return _build_404_response(model_id, api_format) return _build_404_response(model_id, api_format)
@@ -455,15 +494,25 @@ async def list_models_gemini(
api_key = _extract_api_key_from_request(request, gemini_def) api_key = _extract_api_key_from_request(request, gemini_def)
# 认证 # 认证
user, _ = _authenticate(db, api_key) user, key_record = _authenticate(db, api_key)
if not user: if not user:
return _build_auth_error_response("gemini") return _build_auth_error_response("gemini")
available_provider_ids = get_available_provider_ids(db, _GEMINI_FORMATS) # 构建访问限制
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 检查 API 格式限制
formats, empty_response = _filter_formats_by_restrictions(
_GEMINI_FORMATS, restrictions, "gemini"
)
if empty_response is not None:
return empty_response
available_provider_ids = get_available_provider_ids(db, formats)
if not available_provider_ids: if not available_provider_ids:
return {"models": []} return {"models": []}
models = await list_available_models(db, available_provider_ids, _GEMINI_FORMATS) models = await list_available_models(db, available_provider_ids, formats, restrictions)
logger.debug(f"[Models] 返回 {len(models)} 个模型") logger.debug(f"[Models] 返回 {len(models)} 个模型")
response = _build_gemini_list_response(models, page_size, page_token) response = _build_gemini_list_response(models, page_size, page_token)
logger.debug(f"[Models] Gemini 响应: {response}") logger.debug(f"[Models] Gemini 响应: {response}")
@@ -486,12 +535,22 @@ async def get_model_gemini(
api_key = _extract_api_key_from_request(request, gemini_def) api_key = _extract_api_key_from_request(request, gemini_def)
# 认证 # 认证
user, _ = _authenticate(db, api_key) user, key_record = _authenticate(db, api_key)
if not user: if not user:
return _build_auth_error_response("gemini") return _build_auth_error_response("gemini")
available_provider_ids = get_available_provider_ids(db, _GEMINI_FORMATS) # 构建访问限制
model_info = find_model_by_id(db, model_id, available_provider_ids, _GEMINI_FORMATS) restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 检查 API 格式限制
formats, _ = _filter_formats_by_restrictions(_GEMINI_FORMATS, restrictions, "gemini")
if not formats:
return _build_404_response(model_id, "gemini")
available_provider_ids = get_available_provider_ids(db, formats)
model_info = find_model_by_id(
db, model_id, available_provider_ids, formats, restrictions
)
if not model_info: if not model_info:
return _build_404_response(model_id, "gemini") return _build_404_response(model_id, "gemini")

View File

@@ -9,6 +9,7 @@ from urllib.parse import quote, urlparse
import httpx import httpx
from src.config import config
from src.core.logger import logger from src.core.logger import logger
@@ -83,10 +84,10 @@ class HTTPClientPool:
http2=False, # 暂时禁用HTTP/2以提高兼容性 http2=False, # 暂时禁用HTTP/2以提高兼容性
verify=True, # 启用SSL验证 verify=True, # 启用SSL验证
timeout=httpx.Timeout( timeout=httpx.Timeout(
connect=10.0, # 连接超时 connect=config.http_connect_timeout,
read=300.0, # 读取超时(5分钟,适合流式响应) read=config.http_read_timeout,
write=60.0, # 写入超时(60秒,支持大请求体) write=config.http_write_timeout,
pool=5.0, # 连接池超时 pool=config.http_pool_timeout,
), ),
limits=httpx.Limits( limits=httpx.Limits(
max_connections=100, # 最大连接数 max_connections=100, # 最大连接数
@@ -111,15 +112,20 @@ class HTTPClientPool:
""" """
if name not in cls._clients: if name not in cls._clients:
# 合并默认配置和自定义配置 # 合并默认配置和自定义配置
config = { default_config = {
"http2": False, "http2": False,
"verify": True, "verify": True,
"timeout": httpx.Timeout(10.0, read=300.0), "timeout": httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
),
"follow_redirects": True, "follow_redirects": True,
} }
config.update(kwargs) default_config.update(kwargs)
cls._clients[name] = httpx.AsyncClient(**config) cls._clients[name] = httpx.AsyncClient(**default_config)
logger.debug(f"创建命名HTTP客户端: {name}") logger.debug(f"创建命名HTTP客户端: {name}")
return cls._clients[name] return cls._clients[name]
@@ -151,14 +157,19 @@ class HTTPClientPool:
async with HTTPClientPool.get_temp_client() as client: async with HTTPClientPool.get_temp_client() as client:
response = await client.get('https://example.com') response = await client.get('https://example.com')
""" """
config = { default_config = {
"http2": False, "http2": False,
"verify": True, "verify": True,
"timeout": httpx.Timeout(10.0), "timeout": httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
),
} }
config.update(kwargs) default_config.update(kwargs)
client = httpx.AsyncClient(**config) client = httpx.AsyncClient(**default_config)
try: try:
yield client yield client
finally: finally:
@@ -182,25 +193,30 @@ class HTTPClientPool:
Returns: Returns:
配置好的 httpx.AsyncClient 实例 配置好的 httpx.AsyncClient 实例
""" """
config: Dict[str, Any] = { client_config: Dict[str, Any] = {
"http2": False, "http2": False,
"verify": True, "verify": True,
"follow_redirects": True, "follow_redirects": True,
} }
if timeout: if timeout:
config["timeout"] = timeout client_config["timeout"] = timeout
else: else:
config["timeout"] = httpx.Timeout(10.0, read=300.0) client_config["timeout"] = httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
)
# 添加代理配置 # 添加代理配置
proxy_url = build_proxy_url(proxy_config) if proxy_config else None proxy_url = build_proxy_url(proxy_config) if proxy_config else None
if proxy_url: if proxy_url:
config["proxy"] = proxy_url client_config["proxy"] = proxy_url
logger.debug(f"创建带代理的HTTP客户端: {proxy_config.get('url', 'unknown')}") logger.debug(f"创建带代理的HTTP客户端: {proxy_config.get('url', 'unknown')}")
config.update(kwargs) client_config.update(kwargs)
return httpx.AsyncClient(**config) return httpx.AsyncClient(**client_config)
# 便捷访问函数 # 便捷访问函数

View File

@@ -148,6 +148,7 @@ class Config:
# HTTP 请求超时配置(秒) # HTTP 请求超时配置(秒)
self.http_connect_timeout = float(os.getenv("HTTP_CONNECT_TIMEOUT", "10.0")) self.http_connect_timeout = float(os.getenv("HTTP_CONNECT_TIMEOUT", "10.0"))
self.http_read_timeout = float(os.getenv("HTTP_READ_TIMEOUT", "300.0"))
self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0")) self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0"))
self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.0")) self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.0"))
@@ -172,6 +173,16 @@ class Config:
"GEMINI_CLI_USER_AGENT", "gemini-cli/0.1.0" "GEMINI_CLI_USER_AGENT", "gemini-cli/0.1.0"
) )
# 邮箱验证配置
# VERIFICATION_CODE_EXPIRE_MINUTES: 验证码有效期(分钟)
# VERIFICATION_SEND_COOLDOWN: 发送冷却时间(秒)
self.verification_code_expire_minutes = int(
os.getenv("VERIFICATION_CODE_EXPIRE_MINUTES", "5")
)
self.verification_send_cooldown = int(
os.getenv("VERIFICATION_SEND_COOLDOWN", "60")
)
# 验证连接池配置 # 验证连接池配置
self._validate_pool_config() self._validate_pool_config()

View File

@@ -96,13 +96,15 @@ if not DISABLE_FILE_LOG:
log_dir.mkdir(exist_ok=True) log_dir.mkdir(exist_ok=True)
# 文件日志通用配置 # 文件日志通用配置
# 注意: enqueue=False 使用同步模式,避免 multiprocessing 信号量泄漏
# 在 macOS 上,进程异常退出时 POSIX 信号量不会自动释放,导致资源耗尽
file_log_config = { file_log_config = {
"format": FILE_FORMAT, "format": FILE_FORMAT,
"filter": _log_filter, "filter": _log_filter,
"rotation": "100 MB", "rotation": "100 MB",
"retention": "30 days", "retention": "30 days",
"compression": "gz", "compression": "gz",
"enqueue": True, "enqueue": False,
"encoding": "utf-8", "encoding": "utf-8",
"catch": True, "catch": True,
} }

View File

@@ -360,6 +360,9 @@ def init_db():
注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh 注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh
""" """
import sys
from sqlalchemy.exc import OperationalError
logger.info("初始化数据库...") logger.info("初始化数据库...")
# 确保引擎已创建 # 确保引擎已创建
@@ -382,6 +385,38 @@ def init_db():
db.commit() db.commit()
logger.info("数据库初始化完成") logger.info("数据库初始化完成")
except OperationalError as e:
db.rollback()
# 提取数据库连接信息用于提示
db_url = config.database_url
# 隐藏密码,只显示 host:port/database
if "@" in db_url:
db_info = db_url.split("@")[-1]
else:
db_info = db_url
import os
# 直接打印到 stderr确保消息显示
print("", file=sys.stderr)
print("=" * 60, file=sys.stderr)
print("数据库连接失败", file=sys.stderr)
print("=" * 60, file=sys.stderr)
print("", file=sys.stderr)
print(f"无法连接到数据库: {db_info}", file=sys.stderr)
print("", file=sys.stderr)
print("请检查以下事项:", file=sys.stderr)
print(" 1. PostgreSQL 服务是否正在运行", file=sys.stderr)
print(" 2. 数据库连接配置是否正确 (DATABASE_URL)", file=sys.stderr)
print(" 3. 数据库用户名和密码是否正确", file=sys.stderr)
print("", file=sys.stderr)
print("如果使用 Docker请先运行:", file=sys.stderr)
print(" docker-compose up -d postgres redis", file=sys.stderr)
print("", file=sys.stderr)
print("=" * 60, file=sys.stderr)
# 使用 os._exit 直接退出,避免 uvicorn 捕获并打印堆栈
os._exit(1)
except Exception as e: except Exception as e:
logger.error(f"数据库初始化失败: {e}") logger.error(f"数据库初始化失败: {e}")
db.rollback() db.rollback()

View File

@@ -123,6 +123,98 @@ class LogoutResponse(BaseModel):
success: bool success: bool
class SendVerificationCodeRequest(BaseModel):
"""发送验证码请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效")
return v.lower()
class SendVerificationCodeResponse(BaseModel):
"""发送验证码响应"""
message: str
success: bool
expire_minutes: Optional[int] = None
class VerifyEmailRequest(BaseModel):
"""验证邮箱请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
code: str = Field(..., min_length=6, max_length=6, description="6位验证码")
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效")
return v.lower()
@field_validator("code")
@classmethod
def validate_code(cls, v):
"""验证验证码格式"""
v = v.strip()
if not v.isdigit():
raise ValueError("验证码必须是6位数字")
if len(v) != 6:
raise ValueError("验证码必须是6位数字")
return v
class VerifyEmailResponse(BaseModel):
"""验证邮箱响应"""
message: str
success: bool
class VerificationStatusRequest(BaseModel):
"""验证状态查询请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
v = v.strip().lower()
if not v:
raise ValueError("邮箱不能为空")
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效")
return v
class VerificationStatusResponse(BaseModel):
"""验证状态响应"""
email: str
has_pending_code: bool = Field(description="是否有待验证的验证码")
is_verified: bool = Field(description="邮箱是否已验证")
cooldown_remaining: Optional[int] = Field(None, description="发送冷却剩余秒数")
code_expires_in: Optional[int] = Field(None, description="验证码剩余有效秒数")
class RegistrationSettingsResponse(BaseModel):
"""注册设置响应(公开接口返回)"""
enable_registration: bool
require_email_verification: bool
# ========== 用户管理 ========== # ========== 用户管理 ==========
class CreateUserRequest(BaseModel): class CreateUserRequest(BaseModel):
"""创建用户请求""" """创建用户请求"""

View File

@@ -274,6 +274,13 @@ class GlobalModelListResponse(BaseModel):
total: int total: int
class GlobalModelProvidersResponse(BaseModel):
"""GlobalModel 关联提供商列表响应"""
providers: List[ModelCatalogProviderDetail]
total: int
class BatchAssignToProvidersRequest(BaseModel): class BatchAssignToProvidersRequest(BaseModel):
"""批量为 Provider 添加 GlobalModel 实现""" """批量为 Provider 添加 GlobalModel 实现"""

View File

@@ -0,0 +1,9 @@
"""
邮箱验证服务模块
"""
from .email_sender import EmailSenderService
from .email_template import EmailTemplate
from .email_verification import EmailVerificationService
__all__ = ["EmailVerificationService", "EmailSenderService", "EmailTemplate"]

View File

@@ -0,0 +1,473 @@
"""
邮件发送服务
提供 SMTP 邮件发送功能
"""
import asyncio
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional, Tuple
try:
import aiosmtplib
AIOSMTPLIB_AVAILABLE = True
except ImportError:
AIOSMTPLIB_AVAILABLE = False
aiosmtplib = None
def _create_ssl_context() -> ssl.SSLContext:
"""创建 SSL 上下文,使用 certifi 证书或系统默认证书"""
try:
import certifi
context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
context = ssl.create_default_context()
return context
from sqlalchemy.orm import Session
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.services.system.config import SystemConfigService
from .email_template import EmailTemplate
class EmailSenderService:
"""邮件发送服务"""
# SMTP 超时配置(秒)
SMTP_TIMEOUT = 30
@staticmethod
def _get_smtp_config(db: Session) -> dict:
"""
从数据库获取 SMTP 配置
Args:
db: 数据库会话
Returns:
SMTP 配置字典
"""
# 获取加密的密码并解密
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
smtp_password = None
if encrypted_password:
try:
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
except Exception:
# 解密失败,可能是旧的未加密密码,直接使用
smtp_password = encrypted_password
config = {
"smtp_host": SystemConfigService.get_config(db, "smtp_host"),
"smtp_port": SystemConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": SystemConfigService.get_config(db, "smtp_user"),
"smtp_password": smtp_password,
"smtp_use_tls": SystemConfigService.get_config(db, "smtp_use_tls", default=True),
"smtp_use_ssl": SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
"smtp_from_email": SystemConfigService.get_config(db, "smtp_from_email"),
"smtp_from_name": SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
}
return config
@staticmethod
def _validate_smtp_config(config: dict) -> Tuple[bool, Optional[str]]:
"""
验证 SMTP 配置
Args:
config: SMTP 配置字典
Returns:
(是否有效, 错误信息)
"""
required_fields = ["smtp_host", "smtp_from_email"]
for field in required_fields:
if not config.get(field):
return False, f"缺少必要的 SMTP 配置: {field}"
return True, None
@staticmethod
async def send_verification_code(
db: Session, to_email: str, code: str, expire_minutes: int = 30
) -> Tuple[bool, Optional[str]]:
"""
发送验证码邮件
Args:
db: 数据库会话
to_email: 收件人邮箱
code: 验证码
expire_minutes: 过期时间(分钟)
Returns:
(是否发送成功, 错误信息)
"""
# 获取 SMTP 配置
config = EmailSenderService._get_smtp_config(db)
# 验证配置
valid, error = EmailSenderService._validate_smtp_config(config)
if not valid:
logger.error(f"SMTP 配置无效: {error}")
return False, error
# 生成邮件内容
# 优先使用 email_app_name否则回退到 smtp_from_name
app_name = SystemConfigService.get_config(db, "email_app_name", default=None)
if not app_name:
app_name = SystemConfigService.get_config(db, "smtp_from_name", default="Aether")
html_body = EmailTemplate.get_verification_code_html(
code=code, expire_minutes=expire_minutes, db=db, app_name=app_name, email=to_email
)
text_body = EmailTemplate.get_verification_code_text(
code=code, expire_minutes=expire_minutes, db=db, app_name=app_name, email=to_email
)
subject = EmailTemplate.get_subject("verification", db=db)
# 发送邮件
return await EmailSenderService._send_email(
config=config, to_email=to_email, subject=subject, html_body=html_body, text_body=text_body
)
@staticmethod
async def _send_email(
config: dict,
to_email: str,
subject: str,
html_body: Optional[str] = None,
text_body: Optional[str] = None,
) -> Tuple[bool, Optional[str]]:
"""
发送邮件(内部方法)
Args:
config: SMTP 配置
to_email: 收件人邮箱
subject: 邮件主题
html_body: HTML 邮件内容
text_body: 纯文本邮件内容
Returns:
(是否发送成功, 错误信息)
"""
if AIOSMTPLIB_AVAILABLE:
return await EmailSenderService._send_email_async(
config, to_email, subject, html_body, text_body
)
else:
return await EmailSenderService._send_email_sync_wrapper(
config, to_email, subject, html_body, text_body
)
@staticmethod
async def _send_email_async(
config: dict,
to_email: str,
subject: str,
html_body: Optional[str] = None,
text_body: Optional[str] = None,
) -> Tuple[bool, Optional[str]]:
"""
异步发送邮件(使用 aiosmtplib
Args:
config: SMTP 配置
to_email: 收件人邮箱
subject: 邮件主题
html_body: HTML 邮件内容
text_body: 纯文本邮件内容
Returns:
(是否发送成功, 错误信息)
"""
try:
# 构建邮件
message = MIMEMultipart("alternative")
message["Subject"] = subject
message["From"] = f"{config['smtp_from_name']} <{config['smtp_from_email']}>"
message["To"] = to_email
# 添加纯文本部分
if text_body:
message.attach(MIMEText(text_body, "plain", "utf-8"))
# 添加 HTML 部分
if html_body:
message.attach(MIMEText(html_body, "html", "utf-8"))
# 发送邮件
ssl_context = _create_ssl_context()
if config["smtp_use_ssl"]:
await aiosmtplib.send(
message,
hostname=config["smtp_host"],
port=config["smtp_port"],
use_tls=True,
tls_context=ssl_context,
username=config["smtp_user"],
password=config["smtp_password"],
timeout=EmailSenderService.SMTP_TIMEOUT,
)
else:
await aiosmtplib.send(
message,
hostname=config["smtp_host"],
port=config["smtp_port"],
start_tls=config["smtp_use_tls"],
tls_context=ssl_context if config["smtp_use_tls"] else None,
username=config["smtp_user"],
password=config["smtp_password"],
timeout=EmailSenderService.SMTP_TIMEOUT,
)
logger.info(f"验证码邮件发送成功: {to_email}")
return True, None
except Exception as e:
error_msg = f"发送邮件失败: {str(e)}"
logger.error(error_msg)
return False, error_msg
@staticmethod
async def _send_email_sync_wrapper(
config: dict,
to_email: str,
subject: str,
html_body: Optional[str] = None,
text_body: Optional[str] = None,
) -> Tuple[bool, Optional[str]]:
"""
同步邮件发送的异步包装器
Args:
config: SMTP 配置
to_email: 收件人邮箱
subject: 邮件主题
html_body: HTML 邮件内容
text_body: 纯文本邮件内容
Returns:
(是否发送成功, 错误信息)
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, EmailSenderService._send_email_sync, config, to_email, subject, html_body, text_body
)
@staticmethod
def _send_email_sync(
config: dict,
to_email: str,
subject: str,
html_body: Optional[str] = None,
text_body: Optional[str] = None,
) -> Tuple[bool, Optional[str]]:
"""
同步发送邮件(使用标准库 smtplib
Args:
config: SMTP 配置
to_email: 收件人邮箱
subject: 邮件主题
html_body: HTML 邮件内容
text_body: 纯文本邮件内容
Returns:
(是否发送成功, 错误信息)
"""
try:
# 构建邮件
message = MIMEMultipart("alternative")
message["Subject"] = subject
message["From"] = f"{config['smtp_from_name']} <{config['smtp_from_email']}>"
message["To"] = to_email
# 添加纯文本部分
if text_body:
message.attach(MIMEText(text_body, "plain", "utf-8"))
# 添加 HTML 部分
if html_body:
message.attach(MIMEText(html_body, "html", "utf-8"))
# 连接 SMTP 服务器
server = None
ssl_context = _create_ssl_context()
try:
if config["smtp_use_ssl"]:
server = smtplib.SMTP_SSL(
config["smtp_host"],
config["smtp_port"],
context=ssl_context,
timeout=EmailSenderService.SMTP_TIMEOUT,
)
else:
server = smtplib.SMTP(
config["smtp_host"],
config["smtp_port"],
timeout=EmailSenderService.SMTP_TIMEOUT,
)
if config["smtp_use_tls"]:
server.starttls(context=ssl_context)
# 登录
if config["smtp_user"] and config["smtp_password"]:
server.login(config["smtp_user"], config["smtp_password"])
# 发送邮件
server.send_message(message)
logger.info(f"验证码邮件发送成功(同步方式): {to_email}")
return True, None
finally:
# 确保服务器连接被关闭
if server is not None:
try:
server.quit()
except Exception as quit_error:
logger.warning(f"关闭 SMTP 连接时出错: {quit_error}")
except Exception as e:
error_msg = f"发送邮件失败: {str(e)}"
logger.error(error_msg)
return False, error_msg
@staticmethod
async def test_smtp_connection(
db: Session, override_config: Optional[dict] = None
) -> Tuple[bool, Optional[str]]:
"""
测试 SMTP 连接
Args:
db: 数据库会话
override_config: 可选的覆盖配置(通常来自未保存的前端表单)
Returns:
(是否连接成功, 错误信息)
"""
config = EmailSenderService._get_smtp_config(db)
# 用外部传入的配置覆盖(仅覆盖提供的字段)
if override_config:
config.update({k: v for k, v in override_config.items() if v is not None})
# 验证配置
valid, error = EmailSenderService._validate_smtp_config(config)
if not valid:
return False, error
try:
ssl_context = _create_ssl_context()
if AIOSMTPLIB_AVAILABLE:
# 使用异步方式测试
# 注意: use_tls=True 表示隐式 SSL (端口 465)
# start_tls=True 表示 STARTTLS (端口 587)
use_ssl = config["smtp_use_ssl"]
use_starttls = config["smtp_use_tls"] and not use_ssl
smtp = aiosmtplib.SMTP(
hostname=config["smtp_host"],
port=config["smtp_port"],
use_tls=use_ssl,
start_tls=use_starttls,
tls_context=ssl_context if (use_ssl or use_starttls) else None,
timeout=EmailSenderService.SMTP_TIMEOUT,
)
await smtp.connect()
if config["smtp_user"] and config["smtp_password"]:
await smtp.login(config["smtp_user"], config["smtp_password"])
await smtp.quit()
else:
# 使用同步方式测试
if config["smtp_use_ssl"]:
server = smtplib.SMTP_SSL(
config["smtp_host"],
config["smtp_port"],
context=ssl_context,
timeout=EmailSenderService.SMTP_TIMEOUT,
)
else:
server = smtplib.SMTP(
config["smtp_host"],
config["smtp_port"],
timeout=EmailSenderService.SMTP_TIMEOUT,
)
if config["smtp_use_tls"]:
server.starttls(context=ssl_context)
if config["smtp_user"] and config["smtp_password"]:
server.login(config["smtp_user"], config["smtp_password"])
server.quit()
logger.info("SMTP 连接测试成功")
return True, None
except Exception as e:
error_msg = _translate_smtp_error(str(e))
logger.error(f"SMTP 连接测试失败: {error_msg}")
return False, error_msg
def _translate_smtp_error(error: str) -> str:
"""将 SMTP 错误信息转换为用户友好的中文提示"""
error_lower = error.lower()
# 认证相关错误
if "username and password not accepted" in error_lower:
return "用户名或密码错误,请检查 SMTP 凭据"
if "authentication failed" in error_lower:
return "认证失败,请检查用户名和密码"
if "invalid credentials" in error_lower or "badcredentials" in error_lower:
return "凭据无效,请检查用户名和密码"
if "smtp auth extension is not supported" in error_lower:
return "服务器不支持认证,请尝试使用 TLS 或 SSL 加密"
# 连接相关错误
if "connection refused" in error_lower:
return "连接被拒绝,请检查服务器地址和端口"
if "connection timed out" in error_lower or "timed out" in error_lower:
return "连接超时,请检查网络或服务器地址"
if "name or service not known" in error_lower or "getaddrinfo failed" in error_lower:
return "无法解析服务器地址,请检查 SMTP 服务器地址"
if "network is unreachable" in error_lower:
return "网络不可达,请检查网络连接"
# SSL/TLS 相关错误
if "certificate verify failed" in error_lower:
return "SSL 证书验证失败,请检查服务器证书或尝试其他加密方式"
if "ssl" in error_lower and "wrong version" in error_lower:
return "SSL 版本不匹配,请尝试其他加密方式"
if "starttls" in error_lower:
return "STARTTLS 握手失败,请检查加密设置"
# 其他常见错误
if "sender address rejected" in error_lower:
return "发件人地址被拒绝,请检查发件人邮箱设置"
if "relay access denied" in error_lower:
return "中继访问被拒绝,请检查 SMTP 服务器配置"
# 返回原始错误(简化格式)
# 去掉错误码前缀,如 "(535, '5.7.8 ..."
if error.startswith("(") and "'" in error:
# 提取引号内的内容
start = error.find("'") + 1
end = error.rfind("'")
if start > 0 and end > start:
return error[start:end].replace("\\n", " ").strip()
return error

View File

@@ -0,0 +1,442 @@
"""
邮件模板
提供验证码邮件的 HTML 和纯文本模板,支持从数据库加载自定义模板
"""
import html
import re
from html.parser import HTMLParser
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from src.services.system.config import SystemConfigService
class HTMLToTextParser(HTMLParser):
"""HTML 转纯文本解析器"""
def __init__(self):
super().__init__()
self.text_parts = []
self.skip_data = False
def handle_starttag(self, tag, attrs): # noqa: ARG002
if tag in ("script", "style", "head"):
self.skip_data = True
elif tag == "br":
self.text_parts.append("\n")
elif tag in ("p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
self.text_parts.append("\n")
def handle_endtag(self, tag):
if tag in ("script", "style", "head"):
self.skip_data = False
elif tag in ("p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "td"):
self.text_parts.append("\n")
def handle_data(self, data):
if not self.skip_data:
text = data.strip()
if text:
self.text_parts.append(text)
class EmailTemplate:
"""邮件模板类"""
# 模板类型定义
TEMPLATE_VERIFICATION = "verification"
TEMPLATE_PASSWORD_RESET = "password_reset"
# 支持的模板类型及其变量
TEMPLATE_TYPES = {
TEMPLATE_VERIFICATION: {
"name": "注册验证码",
"variables": ["app_name", "code", "expire_minutes", "email"],
"default_subject": "验证码",
},
TEMPLATE_PASSWORD_RESET: {
"name": "找回密码",
"variables": ["app_name", "reset_link", "expire_minutes", "email"],
"default_subject": "密码重置",
},
}
# Literary Tech 主题色 - 与网页保持一致
PRIMARY_COLOR = "#c96442" # book-cloth
PRIMARY_LIGHT = "#e4b2a0" # kraft
BG_WARM = "#faf9f5" # ivory-light
BG_MEDIUM = "#e9e6dc" # ivory-medium / cloud-medium
TEXT_DARK = "#3d3929" # slate-dark
TEXT_MUTED = "#6c695c" # slate-medium
BORDER_COLOR = "rgba(61, 57, 41, 0.12)"
@staticmethod
def get_default_verification_html() -> str:
"""获取默认的验证码邮件 HTML 模板 - Literary Tech 风格"""
return """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>验证码</title>
</head>
<body style="margin: 0; padding: 0; background-color: #faf9f5; font-family: Georgia, 'Times New Roman', 'Songti SC', 'STSong', serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #faf9f5; padding: 40px 20px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px;">
<!-- Header -->
<tr>
<td style="padding: 0 0 32px; text-align: center;">
<div style="font-size: 13px; font-family: 'SF Mono', Monaco, 'Courier New', monospace; color: #6c695c; letter-spacing: 0.15em; text-transform: uppercase;">
{{app_name}}
</div>
</td>
</tr>
<!-- Main Card -->
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border: 1px solid rgba(61, 57, 41, 0.1); border-radius: 6px;">
<!-- Content -->
<tr>
<td style="padding: 48px 40px;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 500; color: #3d3929; text-align: center; letter-spacing: -0.02em;">
验证码
</h1>
<p style="margin: 0 0 32px; font-size: 15px; color: #6c695c; line-height: 1.7; text-align: center;">
您正在注册账户,请使用以下验证码完成验证。
</p>
<!-- Code Box -->
<div style="background-color: #faf9f5; border: 1px solid rgba(61, 57, 41, 0.08); border-radius: 4px; padding: 32px 20px; text-align: center; margin-bottom: 32px;">
<div style="font-size: 40px; font-weight: 500; color: #c96442; letter-spacing: 12px; font-family: 'SF Mono', Monaco, 'Courier New', monospace;">
{{code}}
</div>
</div>
<p style="margin: 0; font-size: 14px; color: #6c695c; line-height: 1.6; text-align: center;">
验证码将在 <span style="color: #3d3929; font-weight: 500;">{{expire_minutes}} 分钟</span>后失效
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 32px 0 0; text-align: center;">
<p style="margin: 0 0 8px; font-size: 12px; color: #6c695c;">
如果这不是您的操作,请忽略此邮件。
</p>
<p style="margin: 0; font-size: 11px; color: rgba(108, 105, 92, 0.6);">
此邮件由系统自动发送,请勿回复
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
@staticmethod
def get_default_password_reset_html() -> str:
"""获取默认的密码重置邮件 HTML 模板 - Literary Tech 风格"""
return """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>密码重置</title>
</head>
<body style="margin: 0; padding: 0; background-color: #faf9f5; font-family: Georgia, 'Times New Roman', 'Songti SC', 'STSong', serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #faf9f5; padding: 40px 20px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px;">
<!-- Header -->
<tr>
<td style="padding: 0 0 32px; text-align: center;">
<div style="font-size: 13px; font-family: 'SF Mono', Monaco, 'Courier New', monospace; color: #6c695c; letter-spacing: 0.15em; text-transform: uppercase;">
{{app_name}}
</div>
</td>
</tr>
<!-- Main Card -->
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border: 1px solid rgba(61, 57, 41, 0.1); border-radius: 6px;">
<!-- Content -->
<tr>
<td style="padding: 48px 40px;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 500; color: #3d3929; text-align: center; letter-spacing: -0.02em;">
重置密码
</h1>
<p style="margin: 0 0 32px; font-size: 15px; color: #6c695c; line-height: 1.7; text-align: center;">
您正在重置账户密码,请点击下方按钮完成操作。
</p>
<!-- Button -->
<div style="text-align: center; margin-bottom: 32px;">
<a href="{{reset_link}}" style="display: inline-block; padding: 14px 36px; background-color: #c96442; color: #ffffff; text-decoration: none; border-radius: 4px; font-size: 15px; font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
重置密码
</a>
</div>
<p style="margin: 0; font-size: 14px; color: #6c695c; line-height: 1.6; text-align: center;">
链接将在 <span style="color: #3d3929; font-weight: 500;">{{expire_minutes}} 分钟</span>后失效
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 32px 0 0; text-align: center;">
<p style="margin: 0 0 8px; font-size: 12px; color: #6c695c;">
如果您没有请求重置密码,请忽略此邮件。
</p>
<p style="margin: 0; font-size: 11px; color: rgba(108, 105, 92, 0.6);">
此邮件由系统自动发送,请勿回复
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
@staticmethod
def get_default_template(template_type: str) -> Dict[str, str]:
"""
获取默认模板
Args:
template_type: 模板类型
Returns:
包含 subject 和 html 的字典
"""
if template_type == EmailTemplate.TEMPLATE_VERIFICATION:
return {
"subject": "验证码",
"html": EmailTemplate.get_default_verification_html(),
}
elif template_type == EmailTemplate.TEMPLATE_PASSWORD_RESET:
return {
"subject": "密码重置",
"html": EmailTemplate.get_default_password_reset_html(),
}
else:
return {"subject": "通知", "html": ""}
@staticmethod
def get_template(db: Session, template_type: str) -> Dict[str, str]:
"""
从数据库获取模板,如果不存在则返回默认模板
Args:
db: 数据库会话
template_type: 模板类型
Returns:
包含 subject 和 html 的字典
"""
default = EmailTemplate.get_default_template(template_type)
# 从数据库获取自定义模板
subject_key = f"email_template_{template_type}_subject"
html_key = f"email_template_{template_type}_html"
custom_subject = SystemConfigService.get_config(db, subject_key, default=None)
custom_html = SystemConfigService.get_config(db, html_key, default=None)
return {
"subject": custom_subject if custom_subject else default["subject"],
"html": custom_html if custom_html else default["html"],
}
@staticmethod
def render_template(template_html: str, variables: Dict[str, Any]) -> str:
"""
渲染模板,替换 {{variable}} 格式的变量
Args:
template_html: HTML 模板
variables: 变量字典
Returns:
渲染后的 HTML
"""
result = template_html
for key, value in variables.items():
# HTML 转义变量值,防止 XSS
escaped_value = html.escape(str(value))
# 替换 {{key}} 格式的变量
pattern = r"\{\{\s*" + re.escape(key) + r"\s*\}\}"
result = re.sub(pattern, escaped_value, result)
return result
@staticmethod
def html_to_text(html: str) -> str:
"""
从 HTML 提取纯文本
Args:
html: HTML 内容
Returns:
纯文本内容
"""
parser = HTMLToTextParser()
parser.feed(html)
text = " ".join(parser.text_parts)
# 清理多余空白
text = re.sub(r"\n\s*\n", "\n\n", text)
text = re.sub(r" +", " ", text)
return text.strip()
@staticmethod
def get_verification_code_html(
code: str, expire_minutes: int = 5, db: Optional[Session] = None, **kwargs
) -> str:
"""
获取验证码邮件 HTML
Args:
code: 验证码
expire_minutes: 过期时间(分钟)
db: 数据库会话(用于获取自定义模板)
**kwargs: 其他模板变量
Returns:
渲染后的 HTML
"""
app_name = kwargs.get("app_name", "Aether")
email = kwargs.get("email", "")
# 获取模板
if db:
template = EmailTemplate.get_template(db, EmailTemplate.TEMPLATE_VERIFICATION)
else:
template = EmailTemplate.get_default_template(EmailTemplate.TEMPLATE_VERIFICATION)
# 渲染变量
variables = {
"app_name": app_name,
"code": code,
"expire_minutes": expire_minutes,
"email": email,
}
return EmailTemplate.render_template(template["html"], variables)
@staticmethod
def get_verification_code_text(
code: str, expire_minutes: int = 5, db: Optional[Session] = None, **kwargs
) -> str:
"""
获取验证码邮件纯文本(从 HTML 自动生成)
Args:
code: 验证码
expire_minutes: 过期时间(分钟)
db: 数据库会话
**kwargs: 其他模板变量
Returns:
纯文本邮件内容
"""
html = EmailTemplate.get_verification_code_html(code, expire_minutes, db, **kwargs)
return EmailTemplate.html_to_text(html)
@staticmethod
def get_password_reset_html(
reset_link: str, expire_minutes: int = 30, db: Optional[Session] = None, **kwargs
) -> str:
"""
获取密码重置邮件 HTML
Args:
reset_link: 重置链接
expire_minutes: 过期时间(分钟)
db: 数据库会话
**kwargs: 其他模板变量
Returns:
渲染后的 HTML
"""
app_name = kwargs.get("app_name", "Aether")
email = kwargs.get("email", "")
# 获取模板
if db:
template = EmailTemplate.get_template(db, EmailTemplate.TEMPLATE_PASSWORD_RESET)
else:
template = EmailTemplate.get_default_template(EmailTemplate.TEMPLATE_PASSWORD_RESET)
# 渲染变量
variables = {
"app_name": app_name,
"reset_link": reset_link,
"expire_minutes": expire_minutes,
"email": email,
}
return EmailTemplate.render_template(template["html"], variables)
@staticmethod
def get_password_reset_text(
reset_link: str, expire_minutes: int = 30, db: Optional[Session] = None, **kwargs
) -> str:
"""
获取密码重置邮件纯文本(从 HTML 自动生成)
Args:
reset_link: 重置链接
expire_minutes: 过期时间(分钟)
db: 数据库会话
**kwargs: 其他模板变量
Returns:
纯文本邮件内容
"""
html = EmailTemplate.get_password_reset_html(reset_link, expire_minutes, db, **kwargs)
return EmailTemplate.html_to_text(html)
@staticmethod
def get_subject(
template_type: str = "verification", db: Optional[Session] = None
) -> str:
"""
获取邮件主题
Args:
template_type: 模板类型
db: 数据库会话
Returns:
邮件主题
"""
if db:
template = EmailTemplate.get_template(db, template_type)
return template["subject"]
default_subjects = {
"verification": "验证码",
"welcome": "欢迎加入",
"password_reset": "密码重置",
}
return default_subjects.get(template_type, "通知")

View File

@@ -0,0 +1,247 @@
"""
邮箱验证服务
提供验证码生成、发送、验证等功能
"""
import json
import secrets
from datetime import datetime, timezone
from typing import Optional, Tuple
from src.clients.redis_client import get_redis_client
from src.config.settings import Config
from src.core.logger import logger
# 从环境变量加载配置
_config = Config()
class EmailVerificationService:
"""邮箱验证码服务"""
# Redis key 前缀
VERIFICATION_PREFIX = "email:verification:"
VERIFIED_PREFIX = "email:verified:"
# 从环境变量读取配置
DEFAULT_CODE_EXPIRE_MINUTES = _config.verification_code_expire_minutes
SEND_COOLDOWN_SECONDS = _config.verification_send_cooldown
@staticmethod
def _generate_code() -> str:
"""
生成 6 位数字验证码
Returns:
6 位数字字符串
"""
# 使用 secrets 模块生成安全的随机数
code = secrets.randbelow(1000000)
return f"{code:06d}"
@staticmethod
async def send_verification_code(
email: str,
expire_minutes: Optional[int] = None,
) -> Tuple[bool, str, Optional[str]]:
"""
发送验证码(生成并存储到 Redis
Args:
email: 目标邮箱地址
expire_minutes: 验证码过期时间分钟None 则使用默认值
Returns:
(是否成功, 验证码/错误信息, 错误详情)
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
logger.error("Redis 不可用,无法发送验证码")
return False, "系统错误", "Redis 服务不可用"
try:
# 检查冷却时间
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
existing_data = await redis_client.get(verification_key)
if existing_data:
data = json.loads(existing_data)
created_at = datetime.fromisoformat(data["created_at"])
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
if elapsed < EmailVerificationService.SEND_COOLDOWN_SECONDS:
remaining = int(EmailVerificationService.SEND_COOLDOWN_SECONDS - elapsed)
logger.warning(f"邮箱 {email} 请求验证码过于频繁,需等待 {remaining}")
return False, "请求过于频繁", f"请在 {remaining} 秒后重试"
# 生成验证码
code = EmailVerificationService._generate_code()
expire_time = expire_minutes or EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
# 存储验证码数据
verification_data = {
"code": code,
"created_at": datetime.now(timezone.utc).isoformat(),
}
# 存储到 Redis设置过期时间
await redis_client.setex(
verification_key, expire_time * 60, json.dumps(verification_data)
)
logger.info(f"验证码已生成并存储: {email}, 有效期: {expire_time} 分钟")
return True, code, None
except Exception as e:
logger.error(f"发送验证码失败: {e}")
return False, "系统错误", str(e)
@staticmethod
async def verify_code(email: str, code: str) -> Tuple[bool, str]:
"""
验证验证码
Args:
email: 邮箱地址
code: 用户输入的验证码
Returns:
(是否验证成功, 错误信息)
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
logger.error("Redis 不可用,无法验证验证码")
return False, "系统错误"
try:
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
data_str = await redis_client.get(verification_key)
if not data_str:
logger.warning(f"验证码不存在或已过期: {email}")
return False, "验证码不存在或已过期"
data = json.loads(data_str)
# 验证码比对 - 使用常量时间比较防止时序攻击
if not secrets.compare_digest(code, data["code"]):
logger.warning(f"验证码错误: {email}")
return False, "验证码错误"
# 验证成功:删除验证码,标记邮箱已验证
await redis_client.delete(verification_key)
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
# 已验证标记保留 1 小时,足够完成注册流程
await redis_client.setex(verified_key, 3600, "verified")
logger.info(f"验证码验证成功: {email}")
return True, "验证成功"
except Exception as e:
logger.error(f"验证验证码失败: {e}")
return False, "系统错误"
@staticmethod
async def is_email_verified(email: str) -> bool:
"""
检查邮箱是否已验证
Args:
email: 邮箱地址
Returns:
是否已验证
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
logger.warning("Redis 不可用,跳过邮箱验证检查")
return False
try:
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
verified = await redis_client.exists(verified_key)
return bool(verified)
except Exception as e:
logger.error(f"检查邮箱验证状态失败: {e}")
return False
@staticmethod
async def clear_verification(email: str) -> bool:
"""
清除邮箱验证状态(注册成功后调用)
Args:
email: 邮箱地址
Returns:
是否清除成功
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
logger.warning("Redis 不可用,无法清除验证状态")
return False
try:
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
# 删除已验证标记和验证码(如果还存在)
await redis_client.delete(verified_key)
await redis_client.delete(verification_key)
logger.info(f"邮箱验证状态已清除: {email}")
return True
except Exception as e:
logger.error(f"清除邮箱验证状态失败: {e}")
return False
@staticmethod
async def get_verification_status(email: str) -> dict:
"""
获取邮箱验证状态(用于调试和管理)
Args:
email: 邮箱地址
Returns:
验证状态信息
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
return {"error": "Redis 不可用"}
try:
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
# 获取各个状态
verification_data = await redis_client.get(verification_key)
is_verified = await redis_client.exists(verified_key)
verification_ttl = await redis_client.ttl(verification_key)
verified_ttl = await redis_client.ttl(verified_key)
status = {
"email": email,
"has_pending_code": bool(verification_data),
"is_verified": bool(is_verified),
"code_expires_in": verification_ttl if verification_ttl > 0 else None,
"verified_expires_in": verified_ttl if verified_ttl > 0 else None,
}
if verification_data:
data = json.loads(verification_data)
status["created_at"] = data.get("created_at")
return status
except Exception as e:
logger.error(f"获取邮箱验证状态失败: {e}")
return {"error": str(e)}

View File

@@ -28,6 +28,8 @@ class IPRateLimiter:
"register": 3, # 注册接口 "register": 3, # 注册接口
"api": 60, # API 接口 "api": 60, # API 接口
"public": 60, # 公共接口 "public": 60, # 公共接口
"verification_send": 3, # 发送验证码接口
"verification_verify": 10, # 验证验证码接口
} }
@staticmethod @staticmethod

View File

@@ -168,19 +168,28 @@ class SystemConfigService:
db, "default_provider", provider_name, "系统默认提供商,当用户未设置个人提供商时使用" db, "default_provider", provider_name, "系统默认提供商,当用户未设置个人提供商时使用"
) )
@staticmethod # 敏感配置项,不返回实际值
def get_all_configs(db: Session) -> list: SENSITIVE_KEYS = {"smtp_password"}
@classmethod
def get_all_configs(cls, db: Session) -> list:
"""获取所有系统配置""" """获取所有系统配置"""
configs = db.query(SystemConfig).all() configs = db.query(SystemConfig).all()
return [ result = []
{ for config in configs:
item = {
"key": config.key, "key": config.key,
"value": config.value,
"description": config.description, "description": config.description,
"updated_at": config.updated_at.isoformat(), "updated_at": config.updated_at.isoformat(),
} }
for config in configs # 对敏感配置,只返回是否已设置的标志,不返回实际值
] if config.key in cls.SENSITIVE_KEYS:
item["value"] = None
item["is_set"] = bool(config.value)
else:
item["value"] = config.value
result.append(item)
return result
@classmethod @classmethod
def delete_config(cls, db: Session, key: str) -> bool: def delete_config(cls, db: Session, key: str) -> bool: