34 Commits

Author SHA1 Message Date
fawney19
465da6f818 feat: OpenAI 流式响应解析器支持提取 usage 信息
部分 OpenAI 兼容 API(如豆包)会在最后一个 chunk 中发送 usage 信息,
现在可以正确提取 prompt_tokens 和 completion_tokens。
2026-01-05 12:50:05 +08:00
fawney19
e5f12fddd9 feat: 流式预读增强与自适应并发算法优化
流式预读增强:
- 新增预读字节上限(64KB),防止无换行响应导致内存增长
- 预读结束后检测非 SSE 格式的错误响应(HTML 页面、纯 JSON 错误)
- 抽取 check_html_response 和 check_prefetched_response_error 到 utils.py

自适应并发算法优化(边界记忆 + 渐进探测):
- 缩容策略:从乘性减少改为边界 -1,一次 429 即可收敛到真实限制附近
- 扩容策略:普通扩容不超过已知边界,探测性扩容可谨慎突破(每次 +1)
- 仅在并发限制 429 时记录边界,避免 RPM/UNKNOWN 类型覆盖
2026-01-05 12:17:45 +08:00
fawney19
4fa9a1303a feat: 优化首字时间和 streaming 状态的记录时序
改进 streaming 状态更新机制:
- 统一在首次输出时记录 TTFB 并更新 streaming 状态
- 重构 CliMessageHandlerBase 中的状态更新逻辑,消除重复
- 确保 provider/key 信息在 streaming 状态更新时已可用

前端改进:
- 添加 first_byte_time_ms 字段支持
- 管理员接口支持返回 provider/api_key_name 字段
- 优化活跃请求轮询逻辑,更准确地判断是否需要刷新完整数据

数据库与 API:
- UsageService.get_active_requests_status 添加 include_admin_fields 参数
- 管理员接口调用时启用该参数以获取额外信息
2026-01-05 10:31:34 +08:00
fawney19
43f349d415 fix: 确保 CLI handler 的 streaming 状态更新时 provider 信息已设置
在 execute_with_fallback 返回后,显式设置 ctx 的 provider 信息,
与 chat_handler_base.py 的行为保持一致,避免 streaming 状态更新
时 provider 为空的问题。
2026-01-05 09:36:35 +08:00
fawney19
02069954de fix: streaming 状态更新时传递 first_byte_time_ms 2026-01-05 09:29:38 +08:00
fawney19
2e15875fed feat: 端点 API 支持 custom_path 字段
- ProviderEndpointCreate 添加 custom_path 参数
- ProviderEndpointUpdate 添加 custom_path 参数
- ProviderEndpointResponse 返回 custom_path 字段
- 创建端点时传递 custom_path 到数据库模型
2026-01-05 09:22:20 +08:00
fawney19
b34cfb676d fix: streaming 状态更新时传递 provider 相关 ID 信息
在 update_usage_status 方法中增加 provider_id、provider_endpoint_id
和 provider_api_key_id 参数,确保流式请求进入 streaming 状态时
能正确记录这些字段。
2026-01-05 09:12:03 +08:00
fawney19
3064497636 refactor: 改进上游错误消息的提取和传递
- 新增 extract_error_message 工具函数,统一错误消息提取逻辑
- 在 HTTPStatusError 异常上附加 upstream_response 属性,保留原始错误
- 优先使用上游响应内容作为错误消息,而非异常字符串表示
- 移除错误消息的长度限制(500/1000 字符)
- 修复边界条件检查,使用 startswith 匹配 "Unable to read" 前缀
- 简化 result.py 中的条件判断逻辑
2026-01-05 03:18:55 +08:00
fawney19
dec681fea0 fix: 统一时区处理,确保所有 datetime 带时区信息
- token_bucket.py: get_reset_time 和 Redis 后端使用 timezone.utc
- sliding_window.py: get_reset_time 和 retry_after 计算使用 timezone.utc
- provider_strategy.py: dateutil.parser 解析后确保有时区信息
2026-01-05 02:23:24 +08:00
fawney19
523e27ba9a fix: API Key 过期时间使用应用时区而非 UTC
- 后端:parse_expiry_date 使用 APP_TIMEZONE(默认 Asia/Shanghai)
- 前端:移除提示文案中的 "UTC"
2026-01-05 02:18:16 +08:00
fawney19
e7db76e581 refactor: API Key 过期时间改用日期选择器,rate_limit 支持无限制
- 前端:将过期时间设置从"天数输入"改为"日期选择器",更直观
- 后端:新增 expires_at 字段(ISO 日期格式),兼容旧版 expire_days
- rate_limit 字段现在支持 null 表示无限制,移除默认值 100
- 解析逻辑:过期时间设为当天 UTC 23:59:59.999999
2026-01-05 02:16:16 +08:00
fawney19
689339117a refactor: 提取 ModelMultiSelect 组件并支持失效模型检测
- 新增 ModelMultiSelect 组件,支持显示和移除已失效的模型
- 新增 useInvalidModels composable 检测 allowed_models 中的无效引用
- 重构 StandaloneKeyFormDialog 和 UserFormDialog 使用新组件
- 补充 GlobalModel 删除逻辑的设计说明注释
2026-01-05 01:20:58 +08:00
fawney19
b202765be4 perf: 优化流式响应 TTFB,将数据库状态更新移至 yield 后执行
- StreamUsageTracker: 先 yield 首个 chunk 再更新 streaming 状态
- EnhancedStreamUsageTracker: 同步添加 TTFB 记录和状态更新逻辑
- 确保客户端首字节响应不受数据库操作延迟影响
2026-01-05 00:13:23 +08:00
fawney19
3bbf3073df feat: 所有 Provider 失败时透传上游错误信息
- FallbackOrchestrator 在所有候选组合失败后保留最后的错误信息
- 从 httpx.HTTPStatusError 提取上游状态码和响应内容
- ProviderNotAvailableException 携带上游错误信息
- ErrorResponse 在返回错误时透传上游状态码和响应
2026-01-04 23:50:15 +08:00
fawney19
f46aaa2182 debug: 添加 streaming 状态更新时 provider 为空的调试日志
- base_handler: 更新 streaming 状态时检测并记录 provider 为空的情况
- cli_handler_base: 修复预读数据为空时未更新 streaming 状态的问题
- usage service: 检测状态变为 streaming 但 provider 仍为 pending 的异常
2026-01-04 23:16:01 +08:00
fawney19
a2f33a6c35 perf: 拆分热力图为独立接口并添加 Redis 缓存
- 新增独立热力图 API 端点 (/api/admin/usage/heatmap, /api/users/me/usage/heatmap)
- 添加 Redis 缓存层 (5分钟 TTL),减少数据库查询
- 用户角色变更时清除热力图缓存
- 前端并行加载统计数据和热力图,添加加载/错误状态显示
- 修复 cache_decorator 缺少 JSON 解析错误处理的问题
- 更新 docker-compose 启动命令提示
2026-01-04 22:42:58 +08:00
fawney19
b6bd6357ed perf: 优化 GlobalModel 列表查询的 N+1 问题 2026-01-04 20:05:23 +08:00
fawney19
c3a5878b1b feat: 优化用量查询分页和热力图性能
- 用量查询接口添加 limit/offset 分页参数支持
- 热力图统计从实时查询 Usage 表改为读取预计算的 StatsDaily/StatsUserDaily 表
- 修复 avg_response_time_ms 为 0 时被错误跳过的问题
2026-01-04 18:02:47 +08:00
fawney19
c02ac56da8 chore: 更新 docker-compose 命令为 docker compose
统一使用 Docker Compose V2 的现代写法
2026-01-03 01:39:45 +08:00
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
fawney19
96094cfde2 fix: 调整仪表盘普通用户月度统计显示,添加月度费用字段 2025-12-29 18:28:37 +08:00
fawney19
7e26af5476 fix: 修复仪表盘缓存命中率和配额显示逻辑
- 本月缓存命中率改为使用本月数据计算(monthly_input_tokens + cache_read_tokens),而非全时数据
- 修复配额显示:有配额时显示实际金额,无配额时显示为 /bin/zsh 并标记为高警告状态
2025-12-29 18:12:33 +08:00
fawney19
c8dfb784bc fix: 修复仪表盘月份统计逻辑,改为自然月而非过去30天 2025-12-29 17:55:42 +08:00
fawney19
fd3a5a5afe refactor: 将模型测试功能从 ModelsTab 移到 KeyAllowedModelsDialog 2025-12-29 17:44:02 +08:00
fawney19
599b3d4c95 feat: 添加 Provider API Key 查看和复制功能
- 后端添加 GET /api/admin/endpoints/keys/{key_id}/reveal 接口
- 前端密钥列表添加眼睛按钮(显示/隐藏完整密钥)和复制按钮
- 关闭抽屉时自动清除已显示的密钥(安全考虑)

Fixes #53
2025-12-29 17:14:26 +08:00
fawney19
41719a00e7 refactor: 改进分布式任务锁的清理策略
实现两种锁清理模式:
- 单实例模式(默认):启动时使用 Lua 脚本原子性清理旧锁,解决 worker 重启时���锁残留问题
- 多实例模式:使用 NX 选项竞争锁,依赖 TTL 处理异常退出

可通过 SINGLE_INSTANCE_MODE 环境变量控制模式选择。
2025-12-28 21:34:43 +08:00
98 changed files with 6224 additions and 1129 deletions

15
LICENSE
View File

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

View File

@@ -58,13 +58,13 @@ cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署
docker-compose up -d
docker compose up -d
# 4. 首次部署时, 初始化数据库
./migrate.sh
# 5. 更新
docker-compose pull && docker-compose up -d && ./migrate.sh
docker compose pull && docker compose up -d && ./migrate.sh
```
### Docker Compose本地构建镜像
@@ -86,7 +86,7 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
```bash
# 启动依赖
docker-compose -f docker-compose.build.yml up -d postgres redis
docker compose -f docker-compose.build.yml up -d postgres redis
# 后端
uv sync
@@ -143,7 +143,7 @@ cd frontend && npm install && npm run dev
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能用支持 1H缓存的模型
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
### 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)

View File

@@ -30,7 +30,7 @@ from src.models.database import Base
config = context.config
# 从环境变量获取数据库 URL
# 优先使用 DATABASE_URL否则从 DB_PASSWORD 自动构建(与 docker-compose 保持一致)
# 优先使用 DATABASE_URL否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
database_url = os.getenv("DATABASE_URL")
if not database_url:
db_password = os.getenv("DB_PASSWORD", "")

View File

@@ -1,7 +1,7 @@
# Aether 部署配置 - 本地构建
# 使用方法:
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
# 启动服务: docker-compose -f docker-compose.build.yml up -d --build
# 启动服务: docker compose -f docker-compose.build.yml up -d --build
services:
postgres:

View File

@@ -1,5 +1,5 @@
# Aether 部署配置 - 使用预构建镜像
# 使用方法: docker-compose up -d
# 使用方法: docker compose up -d
services:
postgres:

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

View File

@@ -42,7 +42,7 @@ export interface UserApiKeyExport {
allowed_endpoints?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number
rate_limit?: number | null // null = 无限制
concurrent_limit?: number | null
force_capabilities?: any
is_active: boolean
@@ -124,6 +124,37 @@ export interface ModelExport {
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 模型查询响应
export interface ProviderModelsQueryResponse {
success: boolean
@@ -189,7 +220,7 @@ export interface AdminApiKey {
total_requests?: number
total_tokens?: number
total_cost_usd?: number
rate_limit?: number
rate_limit?: number | null // null = 无限制
allowed_providers?: string[] | null // 允许的提供商列表
allowed_api_formats?: string[] | null // 允许的 API 格式列表
allowed_models?: string[] | null // 允许的模型列表
@@ -205,8 +236,8 @@ export interface CreateStandaloneApiKeyRequest {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number
expire_days?: number | null // null = 永不过期
rate_limit?: number | null // null = 无限制
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31"null = 永不过期
initial_balance_usd: number // 初始余额,必须设置
auto_delete_on_expiry?: boolean // 过期后是否自动删除
}
@@ -386,5 +417,61 @@ export const adminApi = {
{ provider_id: providerId, api_key_id: apiKeyId }
)
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 // 允许扩展其他统计数据
}
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 {
id: string // UUID
username: string
@@ -87,5 +137,41 @@ export const authApi = {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
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

@@ -87,6 +87,8 @@ export interface DashboardStatsResponse {
cache_stats?: CacheStats
users?: UserStats
token_breakdown?: TokenBreakdown
// 普通用户专用字段
monthly_cost?: number
}
export interface RecentRequestsResponse {

View File

@@ -4,7 +4,8 @@ import type {
GlobalModelUpdate,
GlobalModelResponse,
GlobalModelWithStats,
GlobalModelListResponse
GlobalModelListResponse,
ModelCatalogProviderDetail,
} from './types'
/**
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
)
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

@@ -110,6 +110,14 @@ export async function updateEndpointKey(
return response.data
}
/**
* 获取完整的 API Key用于查看和复制
*/
export async function revealEndpointKey(keyId: string): Promise<{ api_key: string }> {
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`)
return response.data
}
/**
* 删除 Endpoint Key
*/

View File

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

View File

@@ -75,6 +75,16 @@ export interface ModelSummary {
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
}
// 提供商统计接口
export interface ProviderSummary {
provider: string
requests: number
total_tokens: number
total_cost_usd: number
success_rate: number | null
avg_response_time_ms: number | null
}
// 使用统计响应接口
export interface UsageResponse {
total_requests: number
@@ -87,6 +97,13 @@ export interface UsageResponse {
quota_usd: number | null
used_usd: number
summary_by_model: ModelSummary[]
summary_by_provider?: ProviderSummary[]
pagination?: {
total: number
limit: number
offset: number
has_more: boolean
}
records: UsageRecordDetail[]
activity_heatmap?: ActivityHeatmap | null
}
@@ -175,6 +192,8 @@ export const meApi = {
async getUsage(params?: {
start_date?: string
end_date?: string
limit?: number
offset?: number
}): Promise<UsageResponse> {
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
return response.data
@@ -184,11 +203,12 @@ export const meApi = {
async getActiveRequests(ids?: string): Promise<{
requests: Array<{
id: string
status: string
status: 'pending' | 'streaming' | 'completed' | 'failed'
input_tokens: number
output_tokens: number
cost: number
response_time_ms: number | null
first_byte_time_ms: number | null
}>
}> {
const params = ids ? { ids } : {}
@@ -267,5 +287,14 @@ export const meApi = {
}> {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
return response.data
},
/**
* 获取活跃度热力图数据(用户)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
return response.data
}
}

View File

@@ -193,10 +193,22 @@ export const usageApi = {
output_tokens: number
cost: number
response_time_ms: number | null
first_byte_time_ms: number | null
provider?: string | null
api_key_name?: string | null
}>
}> {
const params = ids?.length ? { ids: ids.join(',') } : {}
const response = await apiClient.get('/api/admin/usage/active', { params })
return response.data
},
/**
* 获取活跃度热力图数据(管理员)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
return response.data
}
}

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

@@ -0,0 +1,117 @@
<template>
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="relative">
<button
type="button"
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
@click="isOpen = !isOpen"
>
<span :class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'">
{{ modelValue.length ? `已选择 ${modelValue.length}` : '全部可用' }}
<span
v-if="invalidModels.length"
class="text-destructive"
>({{ invalidModels.length }} 个已失效)</span>
</span>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="isOpen ? 'rotate-180' : ''"
/>
</button>
<div
v-if="isOpen"
class="fixed inset-0 z-[80]"
@click.stop="isOpen = false"
/>
<div
v-if="isOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<!-- 失效模型置顶显示只能取消选择 -->
<div
v-for="modelName in invalidModels"
:key="modelName"
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer bg-destructive/5"
@click="removeModel(modelName)"
>
<input
type="checkbox"
:checked="true"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop
@change="removeModel(modelName)"
>
<span class="text-sm text-destructive">{{ modelName }}</span>
<span class="text-xs text-destructive/70">(已失效)</span>
</div>
<!-- 有效模型 -->
<div
v-for="model in models"
:key="model.name"
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
@click="toggleModel(model.name)"
>
<input
type="checkbox"
:checked="modelValue.includes(model.name)"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop
@change="toggleModel(model.name)"
>
<span class="text-sm">{{ model.name }}</span>
</div>
<div
v-if="models.length === 0 && invalidModels.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用模型
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Label } from '@/components/ui'
import { ChevronDown } from 'lucide-vue-next'
import { useInvalidModels } from '@/composables/useInvalidModels'
export interface ModelWithName {
name: string
}
const props = defineProps<{
modelValue: string[]
models: ModelWithName[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const isOpen = ref(false)
// 检测失效模型
const { invalidModels } = useInvalidModels(
computed(() => props.modelValue),
computed(() => props.models)
)
function toggleModel(name: string) {
const newValue = [...props.modelValue]
const index = newValue.indexOf(name)
if (index === -1) {
newValue.push(name)
} else {
newValue.splice(index, 1)
}
emit('update:modelValue', newValue)
}
function removeModel(name: string) {
const newValue = props.modelValue.filter(m => m !== name)
emit('update:modelValue', newValue)
}
</script>

View File

@@ -7,3 +7,6 @@
export { default as EmptyState } from './EmptyState.vue'
export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue'
// 表单组件
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import { computed, type Ref, type ComputedRef } from 'vue'
/**
* 检测失效模型的 composable
*
* 用于检测 allowed_models 中已不存在于 globalModels 的模型名称,
* 这些模型可能已被删除但引用未清理。
*
* @example
* ```typescript
* const { invalidModels } = useInvalidModels(
* computed(() => form.value.allowed_models),
* globalModels
* )
* ```
*/
export interface ModelWithName {
name: string
}
export function useInvalidModels<T extends ModelWithName>(
allowedModels: Ref<string[]> | ComputedRef<string[]>,
globalModels: Ref<T[]>
): { invalidModels: ComputedRef<string[]> } {
const validModelNames = computed(() =>
new Set(globalModels.value.map(m => m.name))
)
const invalidModels = computed(() =>
allowedModels.value.filter(name => !validModelNames.value.has(name))
)
return { invalidModels }
}

View File

@@ -79,45 +79,45 @@
<div class="space-y-2">
<Label
for="form-expire-days"
for="form-expires-at"
class="text-sm font-medium"
>有效期设置</Label>
<div class="flex items-center gap-2">
<Input
id="form-expire-days"
:model-value="form.expire_days ?? ''"
type="number"
min="1"
max="3650"
placeholder="天数"
:class="form.never_expire ? 'flex-1 h-9 opacity-50' : 'flex-1 h-9'"
:disabled="form.never_expire"
@update:model-value="(v) => form.expire_days = parseNumberInput(v, { min: 1, max: 3650 })"
/>
<label class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap">
<input
v-model="form.never_expire"
type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
@change="onNeverExpireChange"
<div class="relative flex-1">
<Input
id="form-expires-at"
:model-value="form.expires_at || ''"
type="date"
:min="minExpiryDate"
class="h-9 pr-8"
:placeholder="form.expires_at ? '' : '永不过期'"
@update:model-value="(v) => form.expires_at = v || undefined"
/>
<button
v-if="form.expires_at"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
title="清空永不过期"
@click="clearExpiryDate"
>
永不过期
</label>
<X class="h-4 w-4" />
</button>
</div>
<label
class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap"
:class="form.never_expire ? 'opacity-50' : ''"
:class="!form.expires_at ? 'opacity-50 cursor-not-allowed' : ''"
>
<input
v-model="form.auto_delete_on_expiry"
type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
:disabled="form.never_expire"
:disabled="!form.expires_at"
>
到期删除
</label>
</div>
<p class="text-xs text-muted-foreground">
不勾选"到期删除"则仅禁用
{{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 23:59 失效)' : '留空表示永不过期' }}
</p>
</div>
@@ -244,55 +244,10 @@
</div>
<!-- 模型多选下拉框 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="relative">
<button
type="button"
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
@click="modelDropdownOpen = !modelDropdownOpen"
>
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
</span>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="modelDropdownOpen ? 'rotate-180' : ''"
/>
</button>
<div
v-if="modelDropdownOpen"
class="fixed inset-0 z-[80]"
@click.stop="modelDropdownOpen = false"
/>
<div
v-if="modelDropdownOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<div
v-for="model in globalModels"
:key="model.name"
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
@click="toggleSelection('allowed_models', model.name)"
>
<input
type="checkbox"
:checked="form.allowed_models.includes(model.name)"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop
@change="toggleSelection('allowed_models', model.name)"
>
<span class="text-sm">{{ model.name }}</span>
</div>
<div
v-if="globalModels.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用模型
</div>
</div>
</div>
</div>
<ModelMultiSelect
v-model="form.allowed_models"
:models="globalModels"
/>
</div>
</div>
</form>
@@ -325,8 +280,9 @@ import {
Input,
Label,
} from '@/components/ui'
import { Plus, SquarePen, Key, Shield, ChevronDown } from 'lucide-vue-next'
import { Plus, SquarePen, Key, Shield, ChevronDown, X } from 'lucide-vue-next'
import { useFormDialog } from '@/composables/useFormDialog'
import { ModelMultiSelect } from '@/components/common'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin'
@@ -338,8 +294,7 @@ export interface StandaloneKeyFormData {
id?: string
name: string
initial_balance_usd?: number
expire_days?: number
never_expire: boolean
expires_at?: string // ISO 日期字符串,如 "2025-12-31"undefined = 永不过期
rate_limit?: number
auto_delete_on_expiry: boolean
allowed_providers: string[]
@@ -363,7 +318,6 @@ const saving = ref(false)
// 下拉框状态
const providerDropdownOpen = ref(false)
const apiFormatDropdownOpen = ref(false)
const modelDropdownOpen = ref(false)
// 选项数据
const providers = ref<ProviderWithEndpointsSummary[]>([])
@@ -374,8 +328,7 @@ const allApiFormats = ref<string[]>([])
const form = ref<StandaloneKeyFormData>({
name: '',
initial_balance_usd: 10,
expire_days: undefined,
never_expire: true,
expires_at: undefined,
rate_limit: undefined,
auto_delete_on_expiry: false,
allowed_providers: [],
@@ -383,12 +336,18 @@ const form = ref<StandaloneKeyFormData>({
allowed_models: []
})
// 计算最小可选日期(明天)
const minExpiryDate = computed(() => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
return tomorrow.toISOString().split('T')[0]
})
function resetForm() {
form.value = {
name: '',
initial_balance_usd: 10,
expire_days: undefined,
never_expire: true,
expires_at: undefined,
rate_limit: undefined,
auto_delete_on_expiry: false,
allowed_providers: [],
@@ -397,7 +356,6 @@ function resetForm() {
}
providerDropdownOpen.value = false
apiFormatDropdownOpen.value = false
modelDropdownOpen.value = false
}
function loadKeyData() {
@@ -406,8 +364,7 @@ function loadKeyData() {
id: props.apiKey.id,
name: props.apiKey.name || '',
initial_balance_usd: props.apiKey.initial_balance_usd,
expire_days: props.apiKey.expire_days,
never_expire: props.apiKey.never_expire,
expires_at: props.apiKey.expires_at,
rate_limit: props.apiKey.rate_limit,
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
allowed_providers: props.apiKey.allowed_providers || [],
@@ -452,12 +409,10 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
}
}
// 永不过期切换
function onNeverExpireChange() {
if (form.value.never_expire) {
form.value.expire_days = undefined
form.value.auto_delete_on_expiry = false
}
// 清空过期日期(同时清空到期删除选项)
function clearExpiryDate() {
form.value.expires_at = undefined
form.value.auto_delete_on_expiry = false
}
// 提交表单

View File

@@ -98,12 +98,27 @@
<!-- 提示信息 -->
<p
v-if="!isDemo"
v-if="!isDemo && !allowRegistration"
class="text-xs text-slate-400 dark:text-muted-foreground/80"
>
如需开通账户请联系管理员配置访问权限
</p>
</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>
<template #footer>
@@ -124,10 +139,18 @@
</Button>
</template>
</Dialog>
<!-- Register Dialog -->
<RegisterDialog
v-model:open="showRegisterDialog"
:require-email-verification="requireEmailVerification"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
@@ -136,6 +159,8 @@ import Label from '@/components/ui/label.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth'
const props = defineProps<{
modelValue: boolean
@@ -151,6 +176,9 @@ const { success: showSuccess, warning: showWarning, error: showError } = useToas
const isOpen = ref(props.modelValue)
const isDemo = computed(() => isDemoMode())
const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
watch(() => props.modelValue, (val) => {
isOpen.value = val
@@ -201,4 +229,33 @@ async function handleLogin() {
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>

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

View File

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

View File

@@ -116,6 +116,25 @@
{{ model.global_model_name }}
</div>
</div>
<!-- 测试按钮 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="测试模型连接"
:disabled="testingModelName === model.global_model_name"
@click.stop="testModelConnection(model)"
>
<Loader2
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>
</div>
</div>
</div>
@@ -148,16 +167,17 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
import { Box, Loader2, Settings2, Play } from 'lucide-vue-next'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import {
updateEndpointKey,
getProviderAvailableSourceModels,
testModel,
type EndpointAPIKey,
type ProviderAvailableSourceModel
} from '@/api/endpoints'
@@ -181,6 +201,7 @@ const loadingModels = ref(false)
const availableModels = ref<ProviderAvailableSourceModel[]>([])
const selectedModels = ref<string[]>([])
const initialModels = ref<string[]>([])
const testingModelName = ref<string | null>(null)
// 监听对话框打开
watch(() => props.open, (open) => {
@@ -268,6 +289,32 @@ function clearModels() {
selectedModels.value = []
}
// 测试模型连接
async function testModelConnection(model: ProviderAvailableSourceModel) {
if (!props.providerId || !props.apiKey || testingModelName.value) return
testingModelName.value = model.global_model_name
try {
const result = await testModel({
provider_id: props.providerId,
model_name: model.provider_model_name,
api_key_id: props.apiKey.id,
message: "hello"
})
if (result.success) {
success(`模型 "${model.display_name}" 测试成功`)
} else {
showError(`模型测试失败: ${parseTestModelError(result)}`)
}
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
showError(`模型测试失败: ${errorMsg}`)
} finally {
testingModelName.value = null
}
}
function areArraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
const sortedA = [...a].sort()

View File

@@ -337,8 +337,40 @@
{{ key.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
<div class="text-[10px] font-mono text-muted-foreground truncate">
{{ key.api_key_masked }}
<div class="flex items-center gap-1">
<span class="text-[10px] font-mono text-muted-foreground truncate max-w-[180px]">
{{ revealedKeys.has(key.id) ? revealedKeys.get(key.id) : key.api_key_masked }}
</span>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0"
:title="revealedKeys.has(key.id) ? '隐藏密钥' : '显示密钥'"
:disabled="revealingKeyId === key.id"
@click.stop="toggleKeyReveal(key)"
>
<Loader2
v-if="revealingKeyId === key.id"
class="w-3 h-3 animate-spin"
/>
<EyeOff
v-else-if="revealedKeys.has(key.id)"
class="w-3 h-3"
/>
<Eye
v-else
class="w-3 h-3"
/>
</Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)"
>
<Copy class="w-3 h-3" />
</Button>
</div>
</div>
<div class="flex items-center gap-1.5 ml-auto shrink-0">
@@ -654,7 +686,9 @@ import {
Power,
Layers,
GripVertical,
Copy
Copy,
Eye,
EyeOff
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import Button from '@/components/ui/button.vue'
@@ -681,6 +715,7 @@ import {
updateEndpoint,
updateEndpointKey,
batchUpdateKeyPriority,
revealEndpointKey,
type ProviderEndpoint,
type EndpointAPIKey,
type Model
@@ -731,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null)
const togglingKeyId = ref<string | null>(null)
// 密钥显示状态key_id -> 完整密钥
const revealedKeys = ref<Map<string, string>>(new Map())
const revealingKeyId = ref<string | null>(null)
// 模型相关状态
const modelFormDialogOpen = ref(false)
const editingModel = ref<Model | null>(null)
@@ -800,6 +839,9 @@ watch(() => props.open, (newOpen) => {
currentEndpoint.value = null
editingKey.value = null
keyToDelete.value = null
// 清除已显示的密钥(安全考虑)
revealedKeys.value.clear()
}
})
@@ -888,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
keyAllowedModelsDialogOpen.value = true
}
// 切换密钥显示/隐藏
async function toggleKeyReveal(key: EndpointAPIKey) {
if (revealedKeys.value.has(key.id)) {
// 已显示,隐藏它
revealedKeys.value.delete(key.id)
return
}
// 未显示,调用 API 获取完整密钥
revealingKeyId.value = key.id
try {
const result = await revealEndpointKey(key.id)
revealedKeys.value.set(key.id, result.api_key)
} catch (err: any) {
showError(err.response?.data?.detail || '获取密钥失败', '错误')
} finally {
revealingKeyId.value = null
}
}
// 复制完整密钥
async function copyFullKey(key: EndpointAPIKey) {
// 如果已经显示了,直接复制
if (revealedKeys.value.has(key.id)) {
copyToClipboard(revealedKeys.value.get(key.id)!)
return
}
// 否则先获取再复制
try {
const result = await revealEndpointKey(key.id)
copyToClipboard(result.api_key)
} catch (err: any) {
showError(err.response?.data?.detail || '获取密钥失败', '错误')
}
}
function handleDeleteKey(key: EndpointAPIKey) {
keyToDelete.value = key
deleteKeyConfirmOpen.value = true

View File

@@ -131,8 +131,14 @@
:disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
@click="testMapping(group, mapping)"
>
<Loader2 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" />
<Loader2
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>
</div>
</div>

View File

@@ -156,17 +156,6 @@
</td>
<td class="align-top px-4 py-3">
<div class="flex justify-center gap-1.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="测试模型"
:disabled="testingModelId === model.id"
@click="testModelConnection(model)"
>
<Loader2 v-if="testingModelId === model.id" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -220,14 +209,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image, Loader2, Play } from 'lucide-vue-next'
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { getProviderModels, testModel, type Model } from '@/api/endpoints'
import { getProviderModels, type Model } from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { parseTestModelError } from '@/utils/errorParser'
const props = defineProps<{
provider: any
@@ -246,7 +234,6 @@ const { copyToClipboard } = useClipboard()
const loading = ref(false)
const models = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const testingModelId = ref<string | null>(null)
// 按名称排序的模型列表
const sortedModels = computed(() => {
@@ -390,39 +377,6 @@ async function toggleModelActive(model: Model) {
}
}
// 测试模型连接性
async function testModelConnection(model: Model) {
if (testingModelId.value) return
testingModelId.value = model.id
try {
const result = await testModel({
provider_id: props.provider.id,
model_name: model.provider_model_name,
message: "hello"
})
if (result.success) {
showSuccess(`模型 "${model.provider_model_name}" 测试成功`)
// 如果有响应内容,可以显示更多信息
if (result.data?.response?.choices?.[0]?.message?.content) {
const content = result.data.response.choices[0].message.content
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
} else if (result.data?.content_preview) {
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
}
} else {
showError(`模型测试失败: ${parseTestModelError(result)}`)
}
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
showError(`模型测试失败: ${errorMsg}`)
} finally {
testingModelId.value = null
}
}
onMounted(() => {
loadModels()
})

View File

@@ -18,8 +18,22 @@
<span class="flex-shrink-0"></span>
</div>
</div>
<div
v-if="isLoading"
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
>
<Loader2 class="h-5 w-5 animate-spin mr-2" />
加载中...
</div>
<div
v-else-if="hasError"
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive"
>
<AlertCircle class="h-4 w-4 mr-1.5" />
加载失败
</div>
<ActivityHeatmap
v-if="hasData"
v-else-if="hasData"
:data="data"
:show-header="false"
/>
@@ -34,6 +48,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Loader2, AlertCircle } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
@@ -41,6 +56,8 @@ import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
const props = defineProps<{
data: ActivityHeatmapData | null
title: string
isLoading?: boolean
hasError?: boolean
}>()
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]

View File

@@ -64,9 +64,6 @@ export function useUsageData(options: UseUsageDataOptions) {
}))
})
// 活跃度热图数据
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
// 加载统计数据(不加载记录)
async function loadStats(dateRange?: DateRangeParams) {
isLoadingStats.value = true
@@ -93,7 +90,7 @@ export function useUsageData(options: UseUsageDataOptions) {
cache_stats: (statsData as any).cache_stats,
period_start: '',
period_end: '',
activity_heatmap: statsData.activity_heatmap || null
activity_heatmap: null
}
modelStats.value = modelData.map(item => ({
@@ -143,7 +140,7 @@ export function useUsageData(options: UseUsageDataOptions) {
avg_response_time: userData.avg_response_time || 0,
period_start: '',
period_end: '',
activity_heatmap: userData.activity_heatmap || null
activity_heatmap: null
}
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
@@ -305,7 +302,6 @@ export function useUsageData(options: UseUsageDataOptions) {
// 计算属性
enhancedModelStats,
activityHeatmapData,
// 方法
loadStats,

View File

@@ -1,5 +1,3 @@
import type { ActivityHeatmap } from '@/types/activity'
// 统计数据状态
export interface UsageStatsState {
total_requests: number
@@ -17,7 +15,6 @@ export interface UsageStatsState {
}
period_start: string
period_end: string
activity_heatmap: ActivityHeatmap | null
}
// 模型统计
@@ -115,7 +112,6 @@ export function createDefaultStats(): UsageStatsState {
error_rate: undefined,
cache_stats: undefined,
period_start: '',
period_end: '',
activity_heatmap: null
period_end: ''
}
}

View File

@@ -316,55 +316,10 @@
</div>
<!-- 模型多选下拉框 -->
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="relative">
<button
type="button"
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
@click="modelDropdownOpen = !modelDropdownOpen"
>
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length}` : '全部可用' }}
</span>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="modelDropdownOpen ? 'rotate-180' : ''"
/>
</button>
<div
v-if="modelDropdownOpen"
class="fixed inset-0 z-[80]"
@click.stop="modelDropdownOpen = false"
/>
<div
v-if="modelDropdownOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<div
v-for="model in globalModels"
:key="model.name"
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
@click="toggleSelection('allowed_models', model.name)"
>
<input
type="checkbox"
:checked="form.allowed_models.includes(model.name)"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop
@change="toggleSelection('allowed_models', model.name)"
>
<span class="text-sm">{{ model.name }}</span>
</div>
<div
v-if="globalModels.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用模型
</div>
</div>
</div>
</div>
<ModelMultiSelect
v-model="form.allowed_models"
:models="globalModels"
/>
</div>
</div>
</form>
@@ -404,10 +359,12 @@ import {
} from '@/components/ui'
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
import { useFormDialog } from '@/composables/useFormDialog'
import { ModelMultiSelect } from '@/components/common'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
export interface UserFormData {
id?: string
@@ -440,11 +397,10 @@ const roleSelectOpen = ref(false)
// 下拉框状态
const providerDropdownOpen = ref(false)
const endpointDropdownOpen = ref(false)
const modelDropdownOpen = ref(false)
// 选项数据
const providers = ref<any[]>([])
const globalModels = ref<any[]>([])
const providers = ref<ProviderWithEndpointsSummary[]>([])
const globalModels = ref<GlobalModelResponse[]>([])
const apiFormats = ref<Array<{ value: string; label: string }>>([])
// 表单数据

View File

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

View File

@@ -5,7 +5,7 @@
import type { User, LoginResponse } from '@/api/auth'
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 { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
output: 700000,
cache_creation: 50000,
cache_read: 200000
}
},
// 普通用户专用字段
monthly_cost: 45.67
}
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' }
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
]
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
unique_models: 8 + Math.floor(Math.random() * 5),
unique_providers: 4 + Math.floor(Math.random() * 3),
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: 'gpt-4o', 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: 'gemini-2.0-flash', 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-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-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-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).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-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 {
daily_stats: dailyStats,
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: 'gpt-4o', 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: '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: '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-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-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-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-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-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: {
start_date: dailyStats[0].date,
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
// ========== API Key 数据 ==========
export const MOCK_USER_API_KEYS: ApiKey[] = [
export const MOCK_USER_API_KEYS = [
{
id: 'key-uuid-001',
key_display: 'sk-ae...x7f9',
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true,
is_standalone: false,
total_requests: 1234,
total_cost_usd: 45.67
total_cost_usd: 45.67,
force_capabilities: null
},
{
id: 'key-uuid-002',
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true,
is_standalone: false,
total_requests: 5678,
total_cost_usd: 123.45
total_cost_usd: 123.45,
force_capabilities: { cache_1h: true }
},
{
id: 'key-uuid-003',
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: false,
is_standalone: false,
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,
used_usd: 45.32,
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: '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: '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: '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: '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-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-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-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: [
{
id: 'usage-001',
provider: 'anthropic',
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-5-20250929',
input_tokens: 1500,
output_tokens: 800,
total_tokens: 2300,
@@ -837,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
{
id: 'usage-002',
provider: 'openai',
model: 'gpt-4o',
model: 'gpt-5.1',
input_tokens: 2000,
output_tokens: 500,
total_tokens: 2500,

View File

@@ -405,10 +405,10 @@ function getUsageRecords() {
// Mock 映射数据
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-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-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-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-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-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: '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-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
@@ -2172,10 +2172,10 @@ function generateIntervalTimelineData(
// 模型列表(用于按模型区分颜色)
const models = [
'claude-sonnet-4-20250514',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-opus-4-20250514'
'claude-sonnet-4-5-20250929',
'claude-haiku-4-5-20251001',
'claude-opus-4-5-20251101',
'gpt-5.1'
]
// 生成模拟的请求间隔数据

View File

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

View File

@@ -1191,4 +1191,11 @@ body[theme-mode='dark'] .literary-annotation {
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
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

@@ -850,28 +850,20 @@ async function deleteApiKey(apiKey: AdminApiKey) {
}
function editApiKey(apiKey: AdminApiKey) {
// 计算过期天数
let expireDays: number | undefined = undefined
let neverExpire = true
// 解析过期日期为 YYYY-MM-DD 格式
// 保留原始日期,不做时间过滤(避免编辑当天过期的 Key 时意外清空)
let expiresAt: string | undefined = undefined
if (apiKey.expires_at) {
const expiresDate = new Date(apiKey.expires_at)
const now = new Date()
const diffMs = expiresDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffDays > 0) {
expireDays = diffDays
neverExpire = false
}
expiresAt = expiresDate.toISOString().split('T')[0]
}
editingKeyData.value = {
id: apiKey.id,
name: apiKey.name || '',
expire_days: expireDays,
never_expire: neverExpire,
rate_limit: apiKey.rate_limit || 100,
expires_at: expiresAt,
rate_limit: apiKey.rate_limit ?? undefined,
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
allowed_providers: apiKey.allowed_providers || [],
allowed_api_formats: apiKey.allowed_api_formats || [],
@@ -1033,14 +1025,25 @@ function closeKeyFormDialog() {
// 统一处理表单提交
async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
// 验证过期日期(如果设置了,必须晚于今天)
if (data.expires_at) {
const selectedDate = new Date(data.expires_at)
const today = new Date()
today.setHours(0, 0, 0, 0)
if (selectedDate <= today) {
error('过期日期必须晚于今天')
return
}
}
keyFormDialogRef.value?.setSaving(true)
try {
if (data.id) {
// 更新
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
name: data.name || undefined,
rate_limit: data.rate_limit,
expire_days: data.never_expire ? null : (data.expire_days || null),
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
expires_at: data.expires_at || null, // undefined/空 = 永不过期
auto_delete_on_expiry: data.auto_delete_on_expiry,
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
allowed_providers: data.allowed_providers,
@@ -1058,8 +1061,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
const createData: CreateStandaloneApiKeyRequest = {
name: data.name || undefined,
initial_balance_usd: data.initial_balance_usd,
rate_limit: data.rate_limit,
expire_days: data.never_expire ? null : (data.expire_days || null),
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
expires_at: data.expires_at || null, // undefined/空 = 永不过期
auto_delete_on_expiry: data.auto_delete_on_expiry,
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
allowed_providers: data.allowed_providers,

View File

@@ -1057,7 +1057,10 @@ onBeforeUnmount(() => {
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span>
</div>
<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">
<SelectValue placeholder="时间段" />
</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,
deleteGlobalModel,
batchAssignToProviders,
getGlobalModelProviders,
type GlobalModelResponse,
} from '@/api/global-models'
import { log } from '@/utils/logger'
@@ -1080,42 +1081,32 @@ async function selectModel(model: GlobalModelResponse) {
async function loadModelProviders(_globalModelId: string) {
loadingModelProviders.value = true
try {
// 使用 ModelCatalog API 获取详细的关联提供商信息
const { getModelCatalog } = await import('@/api/endpoints')
const catalogResponse = await getModelCatalog()
// 使用新的 API 获取所有关联提供商(包括非活跃的)
const response = await getGlobalModelProviders(_globalModelId)
// 查找当前 GlobalModel 对应的 catalog item
const catalogItem = catalogResponse.models.find(
m => m.global_model_name === selectedModel.value?.name
)
if (catalogItem) {
// 转换为展示格式,包含完整的模型实现信息
selectedModelProviders.value = catalogItem.providers.map(p => ({
id: p.provider_id,
model_id: p.model_id,
display_name: p.provider_display_name || p.provider_name,
identifier: p.provider_name,
provider_type: 'API',
target_model: p.target_model,
is_active: p.is_active,
// 价格信息
input_price_per_1m: p.input_price_per_1m,
output_price_per_1m: p.output_price_per_1m,
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
cache_read_price_per_1m: p.cache_read_price_per_1m,
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
price_per_request: p.price_per_request,
effective_tiered_pricing: p.effective_tiered_pricing,
tier_count: p.tier_count,
// 能力信息
supports_vision: p.supports_vision,
supports_function_calling: p.supports_function_calling,
supports_streaming: p.supports_streaming
}))
} else {
selectedModelProviders.value = []
}
// 转换为展示格式
selectedModelProviders.value = response.providers.map(p => ({
id: p.provider_id,
model_id: p.model_id,
display_name: p.provider_display_name || p.provider_name,
identifier: p.provider_name,
provider_type: 'API',
target_model: p.target_model,
is_active: p.is_active,
// 价格信息
input_price_per_1m: p.input_price_per_1m,
output_price_per_1m: p.output_price_per_1m,
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
cache_read_price_per_1m: p.cache_read_price_per_1m,
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
price_per_request: p.price_per_request,
effective_tiered_pricing: p.effective_tiered_pricing,
tier_count: p.tier_count,
// 能力信息
supports_vision: p.supports_vision,
supports_function_calling: p.supports_function_calling,
supports_streaming: p.supports_streaming
}))
} catch (err: any) {
log.error('加载关联提供商失败:', err)
showError(parseApiError(err, '加载关联提供商失败'), '错误')

View File

@@ -464,7 +464,6 @@
</div>
</div>
</CardSection>
</div>
<!-- 导入配置对话框 -->
@@ -770,7 +769,7 @@
</template>
<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 Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'

View File

@@ -145,10 +145,10 @@
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
实际成
月费用
</p>
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatCurrency(costStats.total_actual_cost) }}
{{ formatCurrency(costStats.total_cost) }}
</p>
<Badge
v-if="costStats.cost_savings > 0"
@@ -162,14 +162,14 @@
</div>
</div>
<!-- 普通用户缓存统计 -->
<!-- 普通用户月度统计 -->
<div
v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0"
v-else-if="!isAdmin && (hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))"
class="mt-6"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-medium text-foreground">
本月缓存使用
本月统计
</h3>
<Badge
variant="outline"
@@ -178,8 +178,16 @@
Monthly
</Badge>
</div>
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
<div
class="grid gap-2 sm:gap-3"
:class="[
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
]"
>
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-book-cloth/30"
>
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -190,7 +198,10 @@
</p>
</div>
</Card>
<Card class="relative p-3 sm:p-4 border-kraft/30">
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-kraft/30"
>
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -201,7 +212,10 @@
</p>
</div>
</Card>
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-book-cloth/25"
>
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -213,19 +227,16 @@
</div>
</Card>
<Card
v-if="tokenBreakdown"
v-if="userMonthlyCost !== null"
class="relative p-3 sm:p-4 border-manilla/40"
>
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
总Token
本月费用
</p>
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
</p>
<p class="mt-0.5 sm:mt-1 text-[9px] sm:text-[10px] text-muted-foreground">
输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}
{{ formatCurrency(userMonthlyCost) }}
</p>
</div>
</Card>
@@ -831,6 +842,12 @@ const cacheStats = ref<{
total_cache_tokens: number
} | null>(null)
const userMonthlyCost = ref<number | null>(null)
const hasCacheData = computed(() =>
cacheStats.value && cacheStats.value.total_cache_tokens > 0
)
const tokenBreakdown = ref<{
input: number
output: number
@@ -1086,6 +1103,7 @@ async function loadDashboardData() {
} else {
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
}
} finally {
loading.value = false

View File

@@ -5,6 +5,8 @@
<ActivityHeatmapCard
:data="activityHeatmapData"
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
:is-loading="isLoadingHeatmap"
:has-error="heatmapError"
/>
<IntervalTimelineCard
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
@@ -112,8 +114,11 @@ import {
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const { warning } = useToast()
const authStore = useAuthStore()
// 判断是否是管理员页面
@@ -144,13 +149,35 @@ const {
currentRecords,
totalRecords,
enhancedModelStats,
activityHeatmapData,
availableModels,
availableProviders,
loadStats,
loadRecords
} = useUsageData({ isAdminPage })
// 热力图状态
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
const isLoadingHeatmap = ref(false)
const heatmapError = ref(false)
// 加载热力图数据
async function loadHeatmapData() {
isLoadingHeatmap.value = true
heatmapError.value = false
try {
if (isAdminPage.value) {
activityHeatmapData.value = await usageApi.getActivityHeatmap()
} else {
activityHeatmapData.value = await meApi.getActivityHeatmap()
}
} catch (error) {
log.error('加载热力图数据失败:', error)
heatmapError.value = true
} finally {
isLoadingHeatmap.value = false
}
}
// 用户页面需要前端筛选
const filteredRecords = computed(() => {
if (!isAdminPage.value) {
@@ -232,27 +259,40 @@ async function pollActiveRequests() {
? await usageApi.getActiveRequests(activeRequestIds.value)
: await meApi.getActiveRequests(idsParam)
// 检查是否有状态变化
let hasChanges = false
let shouldRefresh = false
for (const update of requests) {
const record = currentRecords.value.find(r => r.id === update.id)
if (record && record.status !== update.status) {
hasChanges = true
// 如果状态变为 completed 或 failed需要刷新获取完整数据
if (update.status === 'completed' || update.status === 'failed') {
break
}
// 否则只更新状态和 token 信息
if (!record) {
// 后端返回了未知的活跃请求,触发刷新以获取完整数据
shouldRefresh = true
continue
}
// 状态变化completed/failed 需要刷新获取完整数据
if (record.status !== update.status) {
record.status = update.status
record.input_tokens = update.input_tokens
record.output_tokens = update.output_tokens
record.cost = update.cost
record.response_time_ms = update.response_time_ms ?? undefined
}
if (update.status === 'completed' || update.status === 'failed') {
shouldRefresh = true
}
// 进行中状态也需要持续更新provider/key/TTFB 可能在 streaming 后才落库)
record.input_tokens = update.input_tokens
record.output_tokens = update.output_tokens
record.cost = update.cost
record.response_time_ms = update.response_time_ms ?? undefined
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
// 管理员接口返回额外字段
if ('provider' in update && typeof update.provider === 'string') {
record.provider = update.provider
}
if ('api_key_name' in update) {
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
}
}
// 如果有请求完成或失败,刷新整个列表获取完整数据
if (hasChanges && requests.some(r => r.status === 'completed' || r.status === 'failed')) {
if (shouldRefresh) {
await refreshData()
}
} catch (error) {
@@ -335,7 +375,22 @@ const selectedRequestId = ref<string | null>(null)
// 初始化加载
onMounted(async () => {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange)
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
const [statsResult, heatmapResult] = await Promise.allSettled([
loadStats(dateRange),
loadHeatmapData()
])
// 检查加载结果并通知用户
if (statsResult.status === 'rejected') {
log.error('加载统计数据失败:', statsResult.reason)
warning('统计数据加载失败,请刷新重试')
}
if (heatmapResult.status === 'rejected') {
log.error('加载热力图数据失败:', heatmapResult.reason)
// 热力图加载失败不提示,因为 UI 已显示占位符
}
// 管理员页面加载用户列表和第一页记录
if (isAdminPage.value) {

View File

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

View File

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

View File

@@ -3,22 +3,64 @@
独立余额Key不关联用户配额有独立余额限制用于给非注册用户使用。
"""
from datetime import datetime, timezone
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import NotFoundException
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.database import get_db
from src.models.api import CreateApiKeyRequest
from src.models.database import ApiKey, User
from src.models.database import ApiKey
from src.services.user.apikey import ApiKeyService
# 应用时区配置,默认为 Asia/Shanghai
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
def parse_expiry_date(date_str: Optional[str]) -> Optional[datetime]:
"""解析过期日期字符串为 datetime 对象。
Args:
date_str: 日期字符串,支持 "YYYY-MM-DD" 或 ISO 格式
Returns:
datetime 对象(当天 23:59:59.999999,应用时区),或 None 如果输入为空
Raises:
BadRequestException: 日期格式无效
"""
if not date_str or not date_str.strip():
return None
date_str = date_str.strip()
# 尝试 YYYY-MM-DD 格式
try:
parsed_date = datetime.strptime(date_str, "%Y-%m-%d")
# 设置为当天结束时间 (23:59:59.999999,应用时区)
return parsed_date.replace(
hour=23, minute=59, second=59, microsecond=999999, tzinfo=APP_TIMEZONE
)
except ValueError:
pass
# 尝试完整 ISO 格式
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except ValueError:
pass
raise InvalidRequestException(f"无效的日期格式: {date_str},请使用 YYYY-MM-DD 格式")
router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
pipeline = ApiRequestPipeline()
@@ -215,6 +257,9 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
# 独立Key需要关联到管理员用户从context获取
admin_user_id = context.user.id
# 解析过期时间(优先使用 expires_at其次使用 expire_days
expires_at_dt = parse_expiry_date(self.key_data.expires_at)
# 创建独立Key
api_key, plain_key = ApiKeyService.create_api_key(
db=db,
@@ -224,7 +269,8 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
allowed_api_formats=self.key_data.allowed_api_formats,
allowed_models=self.key_data.allowed_models,
rate_limit=self.key_data.rate_limit, # None 表示不限制
expire_days=self.key_data.expire_days,
expire_days=self.key_data.expire_days, # 兼容旧版
expires_at=expires_at_dt, # 优先使用
initial_balance_usd=self.key_data.initial_balance_usd,
is_standalone=True, # 标记为独立Key
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
@@ -270,7 +316,8 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
update_data = {}
if self.key_data.name is not None:
update_data["name"] = self.key_data.name
if self.key_data.rate_limit is not None:
# rate_limit: 显式传递时更新(包括 null 表示无限制)
if "rate_limit" in self.key_data.model_fields_set:
update_data["rate_limit"] = self.key_data.rate_limit
if (
hasattr(self.key_data, "auto_delete_on_expiry")
@@ -287,19 +334,21 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
update_data["allowed_models"] = self.key_data.allowed_models
# 处理过期时间
if self.key_data.expire_days is not None:
if self.key_data.expire_days > 0:
from datetime import timedelta
# 优先使用 expires_at如果显式传递且有值
if self.key_data.expires_at and self.key_data.expires_at.strip():
update_data["expires_at"] = parse_expiry_date(self.key_data.expires_at)
elif "expires_at" in self.key_data.model_fields_set:
# expires_at 明确传递为 null 或空字符串,设为永不过期
update_data["expires_at"] = None
# 兼容旧版 expire_days
elif "expire_days" in self.key_data.model_fields_set:
if self.key_data.expire_days is not None and self.key_data.expire_days > 0:
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
days=self.key_data.expire_days
)
else:
# expire_days = 0 或负数表示永不过期
# expire_days = None/0/负数 表示永不过期
update_data["expires_at"] = None
elif hasattr(self.key_data, "expire_days") and self.key_data.expire_days is None:
# 明确传递 None设为永不过期
update_data["expires_at"] = None
# 使用 ApiKeyService 更新
updated_key = ApiKeyService.update_api_key(db, self.key_id, **update_data)

View File

@@ -80,6 +80,17 @@ async def get_keys_grouped_by_format(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/keys/{key_id}/reveal")
async def reveal_endpoint_key(
key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""获取完整的 API Key用于查看和复制"""
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/keys/{key_id}")
async def delete_endpoint_key(
key_id: str,
@@ -293,6 +304,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
return EndpointAPIKeyResponse(**response_dict)
@dataclass
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
"""获取完整的 API Key用于查看和复制"""
key_id: str
async def handle(self, context): # type: ignore[override]
db = context.db
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
if not key:
raise NotFoundException(f"Key {self.key_id} 不存在")
try:
decrypted_key = crypto_service.decrypt(key.api_key)
except Exception as e:
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
raise InvalidRequestException(
"无法解密 API Key可能是加密密钥已更改。请重新添加该密钥。"
)
logger.info(f"[REVEAL] 查看完整 Key: ID={self.key_id}, Name={key.name}")
return {"api_key": decrypted_key}
@dataclass
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
key_id: str

View File

@@ -206,6 +206,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
provider_id=self.provider_id,
api_format=self.endpoint_data.api_format,
base_url=self.endpoint_data.base_url,
custom_path=self.endpoint_data.custom_path,
headers=self.endpoint_data.headers,
timeout=self.endpoint_data.timeout,
max_retries=self.endpoint_data.max_retries,

View File

@@ -5,7 +5,7 @@ GlobalModel Admin API
"""
from dataclasses import dataclass
from typing import List, Optional
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
@@ -19,9 +19,11 @@ from src.models.pydantic_models import (
BatchAssignToProvidersResponse,
GlobalModelCreate,
GlobalModelListResponse,
GlobalModelProvidersResponse,
GlobalModelResponse,
GlobalModelUpdate,
GlobalModelWithStats,
ModelCatalogProviderDetail,
)
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)
@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 ==========
@@ -133,20 +146,25 @@ class AdminListGlobalModelsAdapter(AdminApiAdapter):
search=self.search,
)
# 为每个 GlobalModel 添加统计数据
# 一次性查询所有 GlobalModel 的 provider_count优化 N+1 问题)
model_ids = [gm.id for gm in models]
provider_counts = {}
if model_ids:
count_results = (
context.db.query(
Model.global_model_id, func.count(func.distinct(Model.provider_id))
)
.filter(Model.global_model_id.in_(model_ids))
.group_by(Model.global_model_id)
.all()
)
provider_counts = {gm_id: count for gm_id, count in count_results}
# 构建响应
model_responses = []
for gm in models:
# 统计关联的 Model 数量(去重 Provider
provider_count = (
context.db.query(func.count(func.distinct(Model.provider_id)))
.filter(Model.global_model_id == gm.id)
.scalar()
or 0
)
response = GlobalModelResponse.model_validate(gm)
response.provider_count = provider_count
# usage_count 直接从 GlobalModel 表读取,已在 model_validate 中自动映射
response.provider_count = provider_counts.get(gm.id, 0)
model_responses.append(response)
return GlobalModelListResponse(
@@ -275,3 +293,61 @@ class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
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

@@ -2,7 +2,7 @@
提供商策略管理 API 端点
"""
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -103,6 +103,9 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
if config.quota_last_reset_at:
new_reset_at = parser.parse(config.quota_last_reset_at)
# 确保有时区信息,如果没有则假设为 UTC
if new_reset_at.tzinfo is None:
new_reset_at = new_reset_at.replace(tzinfo=timezone.utc)
provider.quota_last_reset_at = new_reset_at
# 自动同步该周期内的历史使用量
@@ -118,7 +121,11 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
logger.info(f"Synced usage for provider {provider.name}: ${period_usage:.4f} since {new_reset_at}")
if config.quota_expires_at:
provider.quota_expires_at = parser.parse(config.quota_expires_at)
expires_at = parser.parse(config.quota_expires_at)
# 确保有时区信息,如果没有则假设为 UTC
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
provider.quota_expires_at = expires_at
db.commit()
db.refresh(provider)
@@ -149,7 +156,7 @@ class AdminProviderStatsAdapter(AdminApiAdapter):
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
since = datetime.now() - timedelta(hours=self.hours)
since = datetime.now(timezone.utc) - timedelta(hours=self.hours)
stats = (
db.query(ProviderUsageTracking)
.filter(

View File

@@ -13,6 +13,7 @@ from src.core.exceptions import InvalidRequestException, NotFoundException, tran
from src.database import get_db
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
from src.models.database import ApiKey, Provider, Usage, User
from src.services.email.email_template import EmailTemplate
from src.services.system.config import SystemConfigService
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)
@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):
key: str
# 敏感配置项,不返回实际值
SENSITIVE_KEYS = {"smtp_password"}
async def handle(self, context): # type: ignore[override]
value = SystemConfigService.get_config(context.db, self.key)
if value is None:
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}
@@ -207,18 +267,31 @@ class AdminGetSystemConfigAdapter(AdminApiAdapter):
class AdminSetSystemConfigAdapter(AdminApiAdapter):
key: str
# 需要加密存储的配置项
ENCRYPTED_KEYS = {"smtp_password"}
async def handle(self, context): # type: ignore[override]
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(
context.db,
self.key,
payload.get("value"),
value,
payload.get("description"),
)
# 返回时不暴露加密后的值
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
return {
"key": config.key,
"value": config.value,
"value": display_value,
"description": config.description,
"updated_at": config.updated_at.isoformat(),
}
@@ -1060,7 +1133,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
allowed_endpoints=key_data.get("allowed_endpoints"),
allowed_api_formats=key_data.get("allowed_api_formats"),
allowed_models=key_data.get("allowed_models"),
rate_limit=key_data.get("rate_limit", 100),
rate_limit=key_data.get("rate_limit"), # None = 无限制
concurrent_limit=key_data.get("concurrent_limit", 5),
force_capabilities=key_data.get("force_capabilities"),
is_active=key_data.get("is_active", True),
@@ -1084,3 +1157,265 @@ class AdminImportUsersAdapter(AdminApiAdapter):
except Exception as e:
db.rollback()
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

@@ -73,6 +73,20 @@ async def get_usage_stats(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/heatmap")
async def get_activity_heatmap(
request: Request,
db: Session = Depends(get_db),
):
"""
Get activity heatmap data for the past 365 days.
This endpoint is cached for 5 minutes to reduce database load.
"""
adapter = AdminActivityHeatmapAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/records")
async def get_usage_records(
request: Request,
@@ -168,12 +182,6 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
).count()
activity_heatmap = UsageService.get_daily_activity(
db=db,
window_days=365,
include_actual_cost=True,
)
context.add_audit_metadata(
action="usage_stats",
start_date=self.start_date.isoformat() if self.start_date else None,
@@ -204,10 +212,22 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
),
"cache_read_cost": float(cache_stats.cache_read_cost or 0) if cache_stats else 0,
},
"activity_heatmap": activity_heatmap,
}
class AdminActivityHeatmapAdapter(AdminApiAdapter):
"""Activity heatmap adapter with Redis caching."""
async def handle(self, context): # type: ignore[override]
result = await UsageService.get_cached_heatmap(
db=context.db,
user_id=None,
include_actual_cost=True,
)
context.add_audit_metadata(action="activity_heatmap")
return result
class AdminUsageByModelAdapter(AdminApiAdapter):
def __init__(self, start_date: Optional[datetime], end_date: Optional[datetime], limit: int):
self.start_date = start_date
@@ -670,7 +690,9 @@ class AdminActiveRequestsAdapter(AdminApiAdapter):
if not id_list:
return {"requests": []}
requests = UsageService.get_active_requests_status(db=db, ids=id_list)
requests = UsageService.get_active_requests_status(
db=db, ids=id_list, include_admin_fields=True
)
return {"requests": requests}

View File

@@ -248,6 +248,7 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
raise InvalidRequestException("请求数据验证失败")
update_data = request.model_dump(exclude_unset=True)
old_role = existing_user.role
if "role" in update_data and update_data["role"]:
if hasattr(update_data["role"], "value"):
update_data["role"] = update_data["role"]
@@ -258,6 +259,12 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
if not user:
raise NotFoundException("用户不存在", "user")
# 角色变更时清除热力图缓存(影响 include_actual_cost 权限)
if "role" in update_data and update_data["role"] != old_role:
from src.services.usage.service import UsageService
await UsageService.clear_user_heatmap_cache(self.user_id)
changed_fields = list(update_data.keys())
context.add_audit_metadata(
action="update_user",
@@ -424,7 +431,7 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
name=key_data.name,
allowed_providers=key_data.allowed_providers,
allowed_models=key_data.allowed_models,
rate_limit=key_data.rate_limit or 100,
rate_limit=key_data.rate_limit, # None = 无限制
expire_days=key_data.expire_days,
initial_balance_usd=None, # 普通Key不设置余额限制
is_standalone=False, # 不是独立Key

View File

@@ -2,7 +2,7 @@
认证相关API端点
"""
from typing import Optional
from typing import Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -23,21 +23,82 @@ from src.models.api import (
RefreshTokenResponse,
RegisterRequest,
RegisterResponse,
RegistrationSettingsResponse,
SendVerificationCodeRequest,
SendVerificationCodeResponse,
VerificationStatusRequest,
VerificationStatusResponse,
VerifyEmailRequest,
VerifyEmailResponse,
)
from src.models.database import AuditEventType, User, UserRole
from src.services.auth.service import AuthService
from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
from src.services.system.config import SystemConfigService
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
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"])
security = HTTPBearer()
pipeline = ApiRequestPipeline()
# 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)
async def login(request: Request, db: Session = Depends(get_db)):
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)
@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="刷新令牌失败")
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):
async def handle(self, context): # type: ignore[override]
from src.models.database import SystemConfig
@@ -241,6 +337,37 @@ class AuthRegisterAdapter(AuthPublicAdapter):
db.commit()
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:
user = UserService.create_user(
db=db,
@@ -258,7 +385,16 @@ class AuthRegisterAdapter(AuthPublicAdapter):
user_agent=user_agent,
metadata={"email": user.email, "username": user.username, "role": user.role.value},
)
db.commit()
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
if require_verification:
try:
await EmailVerificationService.clear_verification(register_request.email)
except Exception as e:
logger.warning(f"清理验证状态失败: {e}")
return RegisterResponse(
user_id=user.id,
email=user.email,
@@ -308,8 +444,8 @@ class AuthChangePasswordAdapter(AuthenticatedApiAdapter):
user = context.user
if not user.verify_password(old_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
if len(new_password) < 8:
raise InvalidRequestException("密码长度至少8")
if len(new_password) < 6:
raise InvalidRequestException("密码长度至少6")
user.set_password(new_password)
context.db.commit()
logger.info(f"用户修改密码: {user.email}")
@@ -351,3 +487,177 @@ class AuthLogoutAdapter(AuthenticatedApiAdapter):
else:
logger.warning(f"用户登出失败Redis不可用: {user.email}")
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.core.cache_service import CacheService
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 前缀
_CACHE_KEY_PREFIX = "models:list"
@@ -82,6 +90,7 @@ class ModelInfo:
created_at: Optional[str] # ISO 格式
created_timestamp: int # Unix 时间戳
provider_name: str
provider_id: str = "" # Provider ID用于权限过滤
# 能力配置
streaming: bool = True
vision: bool = False
@@ -99,6 +108,92 @@ class ModelInfo:
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]:
"""
返回有可用端点的 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
provider_name: str = model.provider.name if model.provider else "unknown"
provider_id: str = model.provider_id or ""
# 从 GlobalModel.config 提取配置信息
config: dict = {}
@@ -233,6 +329,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
created_at=created_at,
created_timestamp=created_timestamp,
provider_name=provider_name,
provider_id=provider_id,
# 能力配置
streaming=config.get("streaming", True),
vision=config.get("vision", False),
@@ -255,6 +352,7 @@ async def list_available_models(
db: Session,
available_provider_ids: set[str],
api_formats: Optional[list[str]] = None,
restrictions: Optional[AccessRestrictions] = None,
) -> list[ModelInfo]:
"""
获取可用模型列表(已去重,带缓存)
@@ -263,6 +361,7 @@ async def list_available_models(
db: 数据库会话
available_provider_ids: 有可用端点的 Provider ID 集合
api_formats: API 格式列表,用于检查 Key 的 allowed_models
restrictions: API Key/User 的访问限制
Returns:
去重后的 ModelInfo 列表,按创建时间倒序
@@ -270,8 +369,16 @@ async def list_available_models(
if not available_provider_ids:
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)
if cached is not None:
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:
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:
continue
seen_model_ids.add(info.id)
result.append(info)
# 写入缓存
if api_formats:
# 只有无限制的情况才写入缓存
if api_formats and use_cache:
await _set_cached_models(api_formats, result)
return result
@@ -324,6 +436,7 @@ def find_model_by_id(
model_id: str,
available_provider_ids: set[str],
api_formats: Optional[list[str]] = None,
restrictions: Optional[AccessRestrictions] = None,
) -> Optional[ModelInfo]:
"""
按 ID 查找模型
@@ -338,6 +451,7 @@ def find_model_by_id(
model_id: 模型 ID
available_provider_ids: 有可用端点的 Provider ID 集合
api_formats: API 格式列表,用于检查 Key 的 allowed_models
restrictions: API Key/User 的访问限制
Returns:
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:
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 查找
models_by_global = (
db.query(Model)
@@ -368,8 +487,19 @@ def find_model_by_id(
.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(
(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,
)
@@ -393,7 +523,7 @@ def find_model_by_id(
)
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,
)

View File

@@ -118,7 +118,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
# 本月第一天(自然月)
month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc)
# ==================== 使用预聚合数据 ====================
# 从 stats_summary + 今日实时数据获取全局统计
@@ -208,7 +210,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
func.sum(StatsDaily.fallback_count).label("fallback_count"),
)
.filter(StatsDaily.date >= last_month, StatsDaily.date < today)
.filter(StatsDaily.date >= month_start, StatsDaily.date < today)
.first()
)
@@ -227,24 +229,24 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
else:
# 回退到实时查询(没有预聚合数据时)
total_requests = (
db.query(func.count(Usage.id)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0
)
total_cost = (
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
)
total_actual_cost = (
db.query(func.sum(Usage.actual_total_cost_usd))
.filter(Usage.created_at >= last_month).scalar() or 0
.filter(Usage.created_at >= month_start).scalar() or 0
)
error_requests = (
db.query(func.count(Usage.id))
.filter(
Usage.created_at >= last_month,
Usage.created_at >= month_start,
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
).scalar() or 0
)
total_tokens = (
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= month_start).scalar() or 0
)
cache_stats = (
db.query(
@@ -253,7 +255,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
)
.filter(Usage.created_at >= last_month)
.filter(Usage.created_at >= month_start)
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
@@ -267,7 +269,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
)
.filter(
RequestCandidate.created_at >= last_month,
RequestCandidate.created_at >= month_start,
RequestCandidate.status.in_(["success", "failed"]),
)
.group_by(RequestCandidate.request_id)
@@ -447,7 +449,9 @@ class UserDashboardStatsAdapter(DashboardAdapter):
# 转换为 UTC 用于数据库查询
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
# 本月第一天(自然月)
month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc)
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
active_keys = (
@@ -483,12 +487,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
# 本月请求统计
user_requests = (
db.query(func.count(Usage.id))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.scalar()
)
user_cost = (
db.query(func.sum(Usage.total_cost_usd))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.scalar()
or 0
)
@@ -532,18 +536,19 @@ class UserDashboardStatsAdapter(DashboardAdapter):
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.input_tokens).label("total_input_tokens"),
)
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
monthly_input_tokens = int(cache_stats.total_input_tokens or 0) if cache_stats else 0
# 计算缓存命中率cache_read / (input_tokens + cache_read)
# 计算本月缓存命中率cache_read / (input_tokens + cache_read)
# input_tokens 是实际发送给模型的输入不含缓存读取cache_read 是从缓存读取的
# 总输入 = input_tokens + cache_read缓存命中率 = cache_read / 总输入
total_input_with_cache = all_time_input_tokens + all_time_cache_read
total_input_with_cache = monthly_input_tokens + cache_read_tokens
cache_hit_rate = (
round((all_time_cache_read / total_input_with_cache) * 100, 1)
round((cache_read_tokens / total_input_with_cache) * 100, 1)
if total_input_with_cache > 0
else 0
)
@@ -569,15 +574,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
quota_value = "无限制"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
elif user.quota_usd and user.quota_usd > 0:
elif user.quota_usd > 0:
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
quota_value = "无限制"
quota_value = f"${user.quota_usd:.0f}"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = percent > 80
else:
quota_value = "0%"
quota_value = "$0"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
quota_high = True
return {
"stats": [
@@ -605,9 +610,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"icon": "TrendingUp",
},
{
"name": "本月费用",
"value": f"${user_cost:.2f}",
"icon": "DollarSign",
"name": "总Token",
"value": format_tokens(
all_time_input_tokens
+ all_time_output_tokens
+ all_time_cache_creation
+ all_time_cache_read
),
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
"icon": "Hash",
},
],
"today": {
@@ -631,6 +642,8 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"cache_hit_rate": cache_hit_rate,
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
},
# 本月费用(用于下方缓存区域显示)
"monthly_cost": float(user_cost),
}

View File

@@ -47,7 +47,6 @@ if TYPE_CHECKING:
from src.api.handlers.base.stream_context import StreamContext
class MessageTelemetry:
"""
负责记录 Usage/Audit避免处理器里重复代码。
@@ -406,7 +405,7 @@ class BaseMessageHandler:
asyncio.create_task(_do_update())
def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None:
"""更新 Usage 状态为 streaming同时更新 provider 和 target_model
"""更新 Usage 状态为 streaming同时更新 provider 相关信息
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
@@ -414,7 +413,7 @@ class BaseMessageHandler:
并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。
Args:
ctx: 流式上下文,包含 provider_name 和 mapped_model
ctx: 流式上下文,包含 provider 相关信息
"""
import asyncio
from src.database.database import get_db
@@ -422,6 +421,17 @@ class BaseMessageHandler:
target_request_id = self.request_id
provider = ctx.provider_name
target_model = ctx.mapped_model
provider_id = ctx.provider_id
endpoint_id = ctx.endpoint_id
key_id = ctx.key_id
first_byte_time_ms = ctx.first_byte_time_ms
# 如果 provider 为空,记录警告(不应该发生,但用于调试)
if not provider:
logger.warning(
f"[{target_request_id}] 更新 streaming 状态时 provider 为空: "
f"ctx.provider_name={ctx.provider_name}, ctx.provider_id={ctx.provider_id}"
)
async def _do_update() -> None:
try:
@@ -434,6 +444,10 @@ class BaseMessageHandler:
status="streaming",
provider=provider,
target_model=target_model,
provider_id=provider_id,
provider_endpoint_id=endpoint_id,
provider_api_key_id=key_id,
first_byte_time_ms=first_byte_time_ms,
)
finally:
db.close()

View File

@@ -36,6 +36,7 @@ from src.api.handlers.base.stream_processor import StreamProcessor
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
from src.api.handlers.base.utils import build_sse_headers
from src.config.settings import config
from src.core.error_utils import extract_error_message
from src.core.exceptions import (
EmbeddedErrorException,
ProviderAuthException,
@@ -500,6 +501,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
error_text = await self._extract_error_text(e)
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
raise
except EmbeddedErrorException:
@@ -549,7 +552,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
model=ctx.model,
response_time_ms=response_time_ms,
status_code=status_code,
error_message=str(error),
error_message=extract_error_message(error),
request_headers=original_headers,
request_body=actual_request_body,
is_stream=True,
@@ -785,7 +788,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
model=model,
response_time_ms=response_time_ms,
status_code=status_code,
error_message=str(e),
error_message=extract_error_message(e),
request_headers=original_headers,
request_body=actual_request_body,
is_stream=False,
@@ -802,10 +805,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
try:
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
error_bytes = await e.response.aread()
return error_bytes.decode("utf-8", errors="replace")[:500]
return error_bytes.decode("utf-8", errors="replace")
else:
return (
e.response.text[:500] if hasattr(e.response, "_content") else "Unable to read"
e.response.text if hasattr(e.response, "_content") else "Unable to read"
)
except Exception as decode_error:
return f"Unable to read error: {decode_error}"

View File

@@ -34,7 +34,12 @@ from src.api.handlers.base.base_handler import (
from src.api.handlers.base.parsers import get_parser_for_format
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.utils import build_sse_headers
from src.api.handlers.base.utils import (
build_sse_headers,
check_html_response,
check_prefetched_response_error,
)
from src.core.error_utils import extract_error_message
# 直接从具体模块导入,避免循环依赖
from src.api.handlers.base.response_parser import (
@@ -57,6 +62,7 @@ from src.models.database import (
ProviderEndpoint,
User,
)
from src.config.constants import StreamDefaults
from src.config.settings import config
from src.services.provider.transport import build_provider_url
from src.utils.sse_parser import SSEEventParser
@@ -328,9 +334,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
stream_generator,
provider_name,
attempt_id,
_provider_id,
_endpoint_id,
_key_id,
provider_id,
endpoint_id,
key_id,
) = await self.orchestrator.execute_with_fallback(
api_format=ctx.api_format,
model_name=ctx.model,
@@ -340,7 +346,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
is_stream=True,
capability_requirements=capability_requirements or None,
)
# 更新上下文(确保 provider 信息已设置,用于 streaming 状态更新)
ctx.attempt_id = attempt_id
if not ctx.provider_name:
ctx.provider_name = provider_name
if not ctx.provider_id:
ctx.provider_id = provider_id
if not ctx.endpoint_id:
ctx.endpoint_id = endpoint_id
if not ctx.key_id:
ctx.key_id = key_id
# 创建后台任务记录统计
background_tasks = BackgroundTasks()
@@ -488,6 +504,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
error_text = await self._extract_error_text(e)
logger.error(f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}")
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
raise
except EmbeddedErrorException:
@@ -523,8 +541,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
try:
sse_parser = SSEEventParser()
last_data_time = time.time()
streaming_status_updated = False
buffer = b""
output_state = {"first_yield": True, "streaming_updated": False}
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
@@ -532,11 +550,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
needs_conversion = self._needs_format_conversion(ctx)
async for chunk in stream_response.aiter_bytes():
# 在第一次输出数据前更新状态为 streaming
if not streaming_status_updated:
self._update_usage_to_streaming_with_ctx(ctx)
streaming_status_updated = True
buffer += chunk
# 处理缓冲区中的完整行
while b"\n" in buffer:
@@ -561,6 +574,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
event.get("event"),
event.get("data") or "",
)
self._mark_first_output(ctx, output_state)
yield b"\n"
continue
@@ -578,6 +592,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据",
},
}
self._mark_first_output(ctx, output_state)
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
return # 结束生成器
@@ -585,8 +600,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
if needs_conversion:
converted_line = self._convert_sse_line(ctx, line, events)
if converted_line:
self._mark_first_output(ctx, output_state)
yield (converted_line + "\n").encode("utf-8")
else:
self._mark_first_output(ctx, output_state)
yield (line + "\n").encode("utf-8")
for event in events:
@@ -637,7 +654,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
},
}
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
except httpx.RemoteProtocolError as e:
except httpx.RemoteProtocolError:
if ctx.data_count > 0:
error_event = {
"type": "error",
@@ -691,7 +708,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
ProviderTimeoutException: 如果首字节超时TTFB timeout
"""
prefetched_chunks: list = []
max_prefetch_lines = 5 # 最多预读5行来检测错误
max_prefetch_lines = config.stream_prefetch_lines # 最多预读行来检测错误
max_prefetch_bytes = StreamDefaults.MAX_PREFETCH_BYTES # 避免无换行响应导致 buffer 增长
total_prefetched_bytes = 0
buffer = b""
line_count = 0
should_stop = False
@@ -718,14 +737,16 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_name=str(provider.name),
)
prefetched_chunks.append(first_chunk)
total_prefetched_bytes += len(first_chunk)
buffer += first_chunk
# 继续读取剩余的预读数据
async for chunk in aiter:
prefetched_chunks.append(chunk)
total_prefetched_bytes += len(chunk)
buffer += chunk
# 尝试按行解析缓冲区
# 尝试按行解析缓冲区SSE 格式)
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
try:
@@ -742,15 +763,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
normalized_line = line.rstrip("\r")
# 检测 HTML 响应base_url 配置错误的常见症状)
lower_line = normalized_line.lower()
if lower_line.startswith("<!doctype") or lower_line.startswith("<html"):
if check_html_response(normalized_line):
logger.error(
f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
f"base_url={endpoint.base_url}"
)
raise ProviderNotAvailableException(
f"提供商 '{provider.name}' 返回了 HTML 页面而非 API 响应,请检查 endpoint 的 base_url 配置是否正确"
f"提供商 '{provider.name}' 返回了 HTML 页面而非 API 响应,"
f"请检查 endpoint 的 base_url 配置是否正确"
)
if not normalized_line or normalized_line.startswith(":"):
@@ -799,9 +820,30 @@ class CliMessageHandlerBase(BaseMessageHandler):
should_stop = True
break
# 达到预读字节上限,停止继续预读(避免无换行响应导致内存增长)
if not should_stop and total_prefetched_bytes >= max_prefetch_bytes:
logger.debug(
f" [{self.request_id}] 预读达到字节上限,停止继续预读: "
f"Provider={provider.name}, bytes={total_prefetched_bytes}, "
f"max_bytes={max_prefetch_bytes}"
)
break
if should_stop or line_count >= max_prefetch_lines:
break
# 预读结束后,检查是否为非 SSE 格式的 HTML/JSON 响应
# 处理某些代理返回的纯 JSON 错误(可能无换行/多行 JSON以及 HTML 页面base_url 配置错误)
if not should_stop and prefetched_chunks:
check_prefetched_response_error(
prefetched_chunks=prefetched_chunks,
parser=provider_parser,
request_id=self.request_id,
provider_name=str(provider.name),
endpoint_id=endpoint.id,
base_url=endpoint.base_url,
)
except (EmbeddedErrorException, ProviderTimeoutException, ProviderNotAvailableException):
# 重新抛出可重试的 Provider 异常,触发故障转移
raise
@@ -833,17 +875,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
sse_parser = SSEEventParser()
last_data_time = time.time()
buffer = b""
first_yield = True # 标记是否是第一次 yield
output_state = {"first_yield": True, "streaming_updated": False}
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# 检查是否需要格式转换
needs_conversion = self._needs_format_conversion(ctx)
# 在第一次输出数据前更新状态为 streaming
if prefetched_chunks:
self._update_usage_to_streaming_with_ctx(ctx)
# 先处理预读的字节块
for chunk in prefetched_chunks:
buffer += chunk
@@ -870,10 +908,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
event.get("event"),
event.get("data") or "",
)
# 记录首字时间 (第一次 yield)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
self._mark_first_output(ctx, output_state)
yield b"\n"
continue
@@ -883,16 +918,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
if needs_conversion:
converted_line = self._convert_sse_line(ctx, line, events)
if converted_line:
# 记录首字时间 (第一次 yield)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
self._mark_first_output(ctx, output_state)
yield (converted_line + "\n").encode("utf-8")
else:
# 记录首字时间 (第一次 yield)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
self._mark_first_output(ctx, output_state)
yield (line + "\n").encode("utf-8")
for event in events:
@@ -931,10 +960,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
event.get("event"),
event.get("data") or "",
)
# 记录首字时间 (第一次 yield) - 如果预读数据为空
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
self._mark_first_output(ctx, output_state)
yield b"\n"
continue
@@ -952,6 +978,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据",
},
}
self._mark_first_output(ctx, output_state)
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
return
@@ -959,16 +986,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
if needs_conversion:
converted_line = self._convert_sse_line(ctx, line, events)
if converted_line:
# 记录首字时间 (第一次 yield) - 如果预读数据为空
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
self._mark_first_output(ctx, output_state)
yield (converted_line + "\n").encode("utf-8")
else:
# 记录首字时间 (第一次 yield) - 如果预读数据为空
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
self._mark_first_output(ctx, output_state)
yield (line + "\n").encode("utf-8")
for event in events:
@@ -1352,7 +1373,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
model=ctx.model,
response_time_ms=response_time_ms,
status_code=status_code,
error_message=str(error),
error_message=extract_error_message(error),
request_headers=original_headers,
request_body=actual_request_body,
is_stream=True,
@@ -1620,7 +1641,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
model=model,
response_time_ms=response_time_ms,
status_code=status_code,
error_message=str(e),
error_message=extract_error_message(e),
request_headers=original_headers,
request_body=actual_request_body,
is_stream=False,
@@ -1640,14 +1661,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
for encoding in ["utf-8", "gbk", "latin1"]:
try:
return error_bytes.decode(encoding)[:500]
return error_bytes.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return error_bytes.decode("utf-8", errors="replace")[:500]
return error_bytes.decode("utf-8", errors="replace")
else:
return (
e.response.text[:500]
e.response.text
if hasattr(e.response, "_content")
else "Unable to read response"
)
@@ -1665,6 +1686,25 @@ class CliMessageHandlerBase(BaseMessageHandler):
return False
return ctx.provider_api_format.upper() != ctx.client_api_format.upper()
def _mark_first_output(self, ctx: StreamContext, state: Dict[str, bool]) -> None:
"""
标记首次输出:记录 TTFB 并更新 streaming 状态
在第一次 yield 数据前调用,确保:
1. 首字时间 (TTFB) 已记录到 ctx
2. Usage 状态已更新为 streaming包含 provider/key/TTFB 信息)
Args:
ctx: 流上下文
state: 包含 first_yield 和 streaming_updated 的状态字典
"""
if state["first_yield"]:
ctx.record_first_byte_time(self.start_time)
state["first_yield"] = False
if not state["streaming_updated"]:
self._update_usage_to_streaming_with_ctx(ctx)
state["streaming_updated"] = True
def _convert_sse_line(
self,
ctx: StreamContext,

View File

@@ -98,6 +98,17 @@ class OpenAIResponseParser(ResponseParser):
chunk.is_done = True
stats.has_completion = True
# 提取 usage 信息(某些 OpenAI 兼容 API 如豆包会在最后一个 chunk 中发送 usage
# 这个 chunk 通常 choices 为空数组,但包含完整的 usage 信息
usage = parsed.get("usage")
if usage and isinstance(usage, dict):
chunk.input_tokens = usage.get("prompt_tokens", 0)
chunk.output_tokens = usage.get("completion_tokens", 0)
# 更新 stats
stats.input_tokens = chunk.input_tokens
stats.output_tokens = chunk.output_tokens
stats.chunk_count += 1
stats.data_count += 1

View File

@@ -25,8 +25,17 @@ from src.api.handlers.base.content_extractors import (
from src.api.handlers.base.parsers import get_parser_for_format
from src.api.handlers.base.response_parser import ResponseParser
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.utils import (
check_html_response,
check_prefetched_response_error,
)
from src.config.constants import StreamDefaults
from src.config.settings import config
from src.core.exceptions import EmbeddedErrorException, ProviderTimeoutException
from src.core.exceptions import (
EmbeddedErrorException,
ProviderNotAvailableException,
ProviderTimeoutException,
)
from src.core.logger import logger
from src.models.database import Provider, ProviderEndpoint
from src.utils.sse_parser import SSEEventParser
@@ -165,6 +174,7 @@ class StreamProcessor:
endpoint: ProviderEndpoint,
ctx: StreamContext,
max_prefetch_lines: int = 5,
max_prefetch_bytes: int = StreamDefaults.MAX_PREFETCH_BYTES,
) -> list:
"""
预读流的前几行,检测嵌套错误
@@ -180,12 +190,14 @@ class StreamProcessor:
endpoint: Endpoint 对象
ctx: 流式上下文
max_prefetch_lines: 最多预读行数
max_prefetch_bytes: 最多预读字节数(避免无换行响应导致 buffer 增长)
Returns:
预读的字节块列表
Raises:
EmbeddedErrorException: 如果检测到嵌套错误
ProviderNotAvailableException: 如果检测到 HTML 响应(配置错误)
ProviderTimeoutException: 如果首字节超时TTFB timeout
"""
prefetched_chunks: list = []
@@ -193,6 +205,7 @@ class StreamProcessor:
buffer = b""
line_count = 0
should_stop = False
total_prefetched_bytes = 0
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
@@ -206,11 +219,13 @@ class StreamProcessor:
provider_name=str(provider.name),
)
prefetched_chunks.append(first_chunk)
total_prefetched_bytes += len(first_chunk)
buffer += first_chunk
# 继续读取剩余的预读数据
async for chunk in aiter:
prefetched_chunks.append(chunk)
total_prefetched_bytes += len(chunk)
buffer += chunk
# 尝试按行解析缓冲区
@@ -228,10 +243,21 @@ class StreamProcessor:
line_count += 1
# 检测 HTML 响应base_url 配置错误的常见症状)
if check_html_response(line):
logger.error(
f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
f"base_url={endpoint.base_url}"
)
raise ProviderNotAvailableException(
f"提供商 '{provider.name}' 返回了 HTML 页面而非 API 响应,"
f"请检查 endpoint 的 base_url 配置是否正确"
)
# 跳过空行和注释行
if not line or line.startswith(":"):
if line_count >= max_prefetch_lines:
should_stop = True
break
continue
@@ -248,7 +274,6 @@ class StreamProcessor:
data = json.loads(data_str)
except json.JSONDecodeError:
if line_count >= max_prefetch_lines:
should_stop = True
break
continue
@@ -276,14 +301,34 @@ class StreamProcessor:
should_stop = True
break
# 达到预读字节上限,停止继续预读(避免无换行响应导致内存增长)
if not should_stop and total_prefetched_bytes >= max_prefetch_bytes:
logger.debug(
f" [{self.request_id}] 预读达到字节上限,停止继续预读: "
f"Provider={provider.name}, bytes={total_prefetched_bytes}, "
f"max_bytes={max_prefetch_bytes}"
)
break
if should_stop or line_count >= max_prefetch_lines:
break
except (EmbeddedErrorException, ProviderTimeoutException):
# 预读结束后,检查是否为非 SSE 格式的 HTML/JSON 响应
if not should_stop and prefetched_chunks:
check_prefetched_response_error(
prefetched_chunks=prefetched_chunks,
parser=parser,
request_id=self.request_id,
provider_name=str(provider.name),
endpoint_id=endpoint.id,
base_url=endpoint.base_url,
)
except (EmbeddedErrorException, ProviderNotAvailableException, ProviderTimeoutException):
# 重新抛出可重试的 Provider 异常,触发故障转移
raise
except (OSError, IOError) as e:
# 网络 I/O <EFBFBD><EFBFBD><EFBFBD>常:记录警告,可能需要重试
# 网络 I/O 常:记录警告,可能需要重试
logger.warning(
f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}"
)
@@ -332,15 +377,15 @@ class StreamProcessor:
# 处理预读数据
if prefetched_chunks:
if not streaming_started and self.on_streaming_start:
self.on_streaming_start()
streaming_started = True
for chunk in prefetched_chunks:
# 记录首字时间 (TTFB) - 在 yield 之前记录
if start_time is not None:
ctx.record_first_byte_time(start_time)
start_time = None # 只记录一次
# 首次输出前触发 streaming 回调(确保 TTFB 已写入 ctx
if not streaming_started and self.on_streaming_start:
self.on_streaming_start()
streaming_started = True
# 把原始数据转发给客户端
yield chunk
@@ -363,14 +408,14 @@ class StreamProcessor:
# 处理剩余的流数据
async for chunk in byte_iterator:
if not streaming_started and self.on_streaming_start:
self.on_streaming_start()
streaming_started = True
# 记录首字时间 (TTFB) - 在 yield 之前记录(如果预读数据为空)
if start_time is not None:
ctx.record_first_byte_time(start_time)
start_time = None # 只记录一次
# 首次输出前触发 streaming 回调(确保 TTFB 已写入 ctx
if not streaming_started and self.on_streaming_start:
self.on_streaming_start()
streaming_started = True
# 原始数据透传
yield chunk

View File

@@ -2,8 +2,10 @@
Handler 基础工具函数
"""
import json
from typing import Any, Dict, Optional
from src.core.exceptions import EmbeddedErrorException, ProviderNotAvailableException
from src.core.logger import logger
@@ -107,3 +109,95 @@ def build_sse_headers(extra_headers: Optional[Dict[str, str]] = None) -> Dict[st
if extra_headers:
headers.update(extra_headers)
return headers
def check_html_response(line: str) -> bool:
"""
检查行是否为 HTML 响应base_url 配置错误的常见症状)
Args:
line: 要检查的行内容
Returns:
True 如果检测到 HTML 响应
"""
lower_line = line.lstrip().lower()
return lower_line.startswith("<!doctype") or lower_line.startswith("<html")
def check_prefetched_response_error(
prefetched_chunks: list,
parser: Any,
request_id: str,
provider_name: str,
endpoint_id: Optional[str],
base_url: Optional[str],
) -> None:
"""
检查预读的响应是否为非 SSE 格式的错误响应HTML 或纯 JSON 错误)
某些代理可能返回:
1. HTML 页面base_url 配置错误)
2. 纯 JSON 错误(无换行或多行 JSON
Args:
prefetched_chunks: 预读的字节块列表
parser: 响应解析器(需要有 is_error_response 和 parse_response 方法)
request_id: 请求 ID用于日志
provider_name: Provider 名称
endpoint_id: Endpoint ID
base_url: Endpoint 的 base_url
Raises:
ProviderNotAvailableException: 如果检测到 HTML 响应
EmbeddedErrorException: 如果检测到 JSON 错误响应
"""
if not prefetched_chunks:
return
try:
prefetched_bytes = b"".join(prefetched_chunks)
stripped = prefetched_bytes.lstrip()
# 去除 BOM
if stripped.startswith(b"\xef\xbb\xbf"):
stripped = stripped[3:]
# HTML 响应(通常是 base_url 配置错误导致返回网页)
lower_prefix = stripped[:32].lower()
if lower_prefix.startswith(b"<!doctype") or lower_prefix.startswith(b"<html"):
endpoint_short = endpoint_id[:8] + "..." if endpoint_id else "N/A"
logger.error(
f" [{request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
f"Provider={provider_name}, Endpoint={endpoint_short}, "
f"base_url={base_url}"
)
raise ProviderNotAvailableException(
f"提供商 '{provider_name}' 返回了 HTML 页面而非 API 响应,"
f"请检查 endpoint 的 base_url 配置是否正确"
)
# 纯 JSON可能无换行/多行 JSON
if stripped.startswith(b"{") or stripped.startswith(b"["):
payload_str = stripped.decode("utf-8", errors="replace").strip()
data = json.loads(payload_str)
if isinstance(data, dict) and parser.is_error_response(data):
parsed = parser.parse_response(data, 200)
logger.warning(
f" [{request_id}] 检测到 JSON 错误响应: "
f"Provider={provider_name}, "
f"error_type={parsed.error_type}, "
f"message={parsed.error_message}"
)
raise EmbeddedErrorException(
provider_name=provider_name,
error_code=(
int(parsed.error_type)
if parsed.error_type and parsed.error_type.isdigit()
else None
),
error_message=parsed.error_message,
error_status=parsed.error_type,
)
except json.JSONDecodeError:
pass

View File

@@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from src.api.base.models_service import (
AccessRestrictions,
ModelInfo,
find_model_by_id,
get_available_provider_ids,
@@ -103,6 +104,35 @@ def _get_formats_for_api(api_format: str) -> list[str]:
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]]:
"""
认证 API Key
@@ -375,22 +405,24 @@ async def list_models(
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:
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, 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)
if not available_provider_ids:
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": []}
return _build_empty_list_response(api_format)
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)} 个模型")
if api_format == "claude":
@@ -419,14 +451,21 @@ async def retrieve_model(
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:
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, _ = _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)
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:
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)
# 认证
user, _ = _authenticate(db, api_key)
user, key_record = _authenticate(db, api_key)
if not user:
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:
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)} 个模型")
response = _build_gemini_list_response(models, page_size, page_token)
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)
# 认证
user, _ = _authenticate(db, api_key)
user, key_record = _authenticate(db, api_key)
if not user:
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:
return _build_404_response(model_id, "gemini")

View File

@@ -104,9 +104,11 @@ async def get_my_usage(
request: Request,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = Query(100, ge=1, le=200, description="每页记录数默认100最大200"),
offset: int = Query(0, ge=0, le=2000, description="偏移量用于分页最大2000"),
db: Session = Depends(get_db),
):
adapter = GetUsageAdapter(start_date=start_date, end_date=end_date)
adapter = GetUsageAdapter(start_date=start_date, end_date=end_date, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -133,6 +135,20 @@ async def get_my_interval_timeline(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/usage/heatmap")
async def get_my_activity_heatmap(
request: Request,
db: Session = Depends(get_db),
):
"""
Get user's activity heatmap data for the past 365 days.
This endpoint is cached for 5 minutes to reduce database load.
"""
adapter = GetMyActivityHeatmapAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/providers")
async def list_available_providers(request: Request, db: Session = Depends(get_db)):
adapter = ListAvailableProvidersAdapter()
@@ -471,6 +487,8 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
class GetUsageAdapter(AuthenticatedApiAdapter):
start_date: Optional[datetime]
end_date: Optional[datetime]
limit: int = 100
offset: int = 0
async def handle(self, context): # type: ignore[override]
db = context.db
@@ -553,7 +571,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
stats["total_cost_usd"] += item["total_cost_usd"]
# 假设 summary 中的都是成功的请求
stats["success_count"] += item["requests"]
if item.get("avg_response_time_ms"):
if item.get("avg_response_time_ms") is not None:
stats["total_response_time_ms"] += item["avg_response_time_ms"] * item["requests"]
stats["response_time_count"] += item["requests"]
@@ -582,7 +600,10 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
usage_records = query.order_by(Usage.created_at.desc()).limit(100).all()
# 计算总数用于分页
total_records = query.count()
usage_records = query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
avg_resp_query = db.query(func.avg(Usage.response_time_ms)).filter(
Usage.user_id == user.id,
@@ -608,6 +629,13 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"used_usd": user.used_usd,
"summary_by_model": summary_by_model,
"summary_by_provider": summary_by_provider,
# 分页信息
"pagination": {
"total": total_records,
"limit": self.limit,
"offset": self.offset,
"has_more": self.offset + self.limit < total_records,
},
"records": [
{
"id": r.id,
@@ -636,13 +664,6 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
],
}
response_data["activity_heatmap"] = UsageService.get_daily_activity(
db=db,
user_id=user.id,
window_days=365,
include_actual_cost=user.role == "admin",
)
# 管理员可以看到真实成本
if user.role == "admin":
response_data["total_actual_cost"] = total_actual_cost
@@ -709,6 +730,20 @@ class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
return result
class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
"""Activity heatmap adapter with Redis caching for user."""
async def handle(self, context): # type: ignore[override]
user = context.user
result = await UsageService.get_cached_heatmap(
db=context.db,
user_id=user.id,
include_actual_cost=user.role == "admin",
)
context.add_audit_metadata(action="activity_heatmap")
return result
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
async def handle(self, context): # type: ignore[override]
from sqlalchemy.orm import selectinload

View File

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

View File

@@ -213,7 +213,7 @@ class RedisClientManager:
f"Redis连接失败: {error_msg}\n"
"缓存亲和性功能需要Redis支持请确保Redis服务正常运行。\n"
"检查事项:\n"
"1. Redis服务是否已启动docker-compose up -d redis\n"
"1. Redis服务是否已启动docker compose up -d redis\n"
"2. 环境变量 REDIS_URL 或 REDIS_PASSWORD 是否配置正确\n"
"3. Redis端口默认6379是否可访问"
) from e

View File

@@ -21,6 +21,9 @@ class CacheTTL:
# L1 本地缓存(用于减少 Redis 访问)
L1_LOCAL = 3 # 3秒
# 活跃度热力图缓存 - 历史数据变化不频繁
ACTIVITY_HEATMAP = 300 # 5分钟
# 并发锁 TTL - 防止死锁
CONCURRENCY_LOCK = 600 # 10分钟
@@ -38,8 +41,25 @@ class CacheSize:
# ==============================================================================
class StreamDefaults:
"""流式处理默认值"""
# 预读字节上限(避免无换行响应导致内存增长)
# 64KB 基于:
# 1. SSE 单条消息通常远小于此值
# 2. 足够检测 HTML 和 JSON 错误响应
# 3. 不会占用过多内存
MAX_PREFETCH_BYTES = 64 * 1024 # 64KB
class ConcurrencyDefaults:
"""并发控制默认值"""
"""并发控制默认值
算法说明:边界记忆 + 渐进探测
- 触发 429 时记录边界last_concurrent_peak新限制 = 边界 - 1
- 扩容时不超过边界,除非是探测性扩容(长时间无 429
- 这样可以快速收敛到真实限制附近,避免过度保守
"""
# 自适应并发初始限制(宽松起步,遇到 429 再降低)
INITIAL_LIMIT = 50
@@ -69,10 +89,6 @@ class ConcurrencyDefaults:
# 扩容步长 - 每次扩容增加的并发数
INCREASE_STEP = 2
# 缩容乘数 - 遇到 429 时基于当前并发数的缩容比例
# 0.85 表示降到触发 429 时并发数的 85%
DECREASE_MULTIPLIER = 0.85
# 最大并发限制上限
MAX_CONCURRENT_LIMIT = 200
@@ -84,6 +100,7 @@ class ConcurrencyDefaults:
# === 探测性扩容参数 ===
# 探测性扩容间隔(分钟)- 长时间无 429 且有流量时尝试扩容
# 探测性扩容可以突破已知边界,尝试更高的并发
PROBE_INCREASE_INTERVAL_MINUTES = 30
# 探测性扩容最小请求数 - 在探测间隔内至少需要这么多请求

View File

@@ -148,6 +148,7 @@ class Config:
# HTTP 请求超时配置(秒)
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_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"
)
# 邮箱验证配置
# 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()

28
src/core/error_utils.py Normal file
View File

@@ -0,0 +1,28 @@
"""
错误消息处理工具函数
"""
from typing import Optional
def extract_error_message(error: Exception, status_code: Optional[int] = None) -> str:
"""
从异常中提取错误消息,优先使用上游响应内容
Args:
error: 异常对象
status_code: 可选的 HTTP 状态码,用于构建更详细的错误消息
Returns:
错误消息字符串
"""
# 优先使用 upstream_response 属性(包含上游 Provider 的原始错误)
upstream_response = getattr(error, "upstream_response", None)
if upstream_response and isinstance(upstream_response, str) and upstream_response.strip():
return str(upstream_response)
# 回退到异常的字符串表示str 可能为空,如 httpx 超时异常)
error_str = str(error) or repr(error)
if status_code is not None:
return f"HTTP {status_code}: {error_str}"
return error_str

View File

@@ -547,11 +547,19 @@ class ErrorResponse:
- 所有错误都记录到日志,通过错误 ID 关联
"""
if isinstance(e, ProxyException):
details = e.details.copy() if e.details else {}
status_code = e.status_code
message = e.message
# 如果是 ProviderNotAvailableException 且有上游错误,直接透传上游信息
if isinstance(e, ProviderNotAvailableException) and e.upstream_response:
if e.upstream_status:
status_code = e.upstream_status
message = e.upstream_response
return ErrorResponse.create(
error_type=e.error_type,
message=e.message,
status_code=e.status_code,
details=e.details,
message=message,
status_code=status_code,
details=details if details else None,
)
elif isinstance(e, HTTPException):
return ErrorResponse.create(

View File

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

View File

@@ -360,6 +360,9 @@ def init_db():
注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh
"""
import sys
from sqlalchemy.exc import OperationalError
logger.info("初始化数据库...")
# 确保引擎已创建
@@ -382,6 +385,38 @@ def init_db():
db.commit()
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 -f docker-compose.build.yml 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:
logger.error(f"数据库初始化失败: {e}")
db.rollback()

View File

@@ -123,6 +123,98 @@ class LogoutResponse(BaseModel):
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):
"""创建用户请求"""
@@ -217,8 +309,9 @@ class CreateApiKeyRequest(BaseModel):
allowed_endpoints: Optional[List[str]] = None # 允许使用的端点 ID 列表
allowed_api_formats: Optional[List[str]] = None # 允许使用的 API 格式列表
allowed_models: Optional[List[str]] = None # 允许使用的模型名称列表
rate_limit: Optional[int] = 100
expire_days: Optional[int] = None # None = 永不过期,数字 = 多少天后过期
rate_limit: Optional[int] = None # None = 无限制
expire_days: Optional[int] = None # None = 永不过期,数字 = 多少天后过期(兼容旧版)
expires_at: Optional[str] = None # ISO 日期字符串,如 "2025-12-31",优先于 expire_days
initial_balance_usd: Optional[float] = Field(
None, description="初始余额USD仅用于独立KeyNone = 无限制"
)

View File

@@ -150,7 +150,7 @@ class ApiKey(Base):
allowed_endpoints = Column(JSON, nullable=True) # 允许使用的端点 ID 列表
allowed_api_formats = Column(JSON, nullable=True) # 允许使用的 API 格式列表
allowed_models = Column(JSON, nullable=True) # 允许使用的模型名称列表
rate_limit = Column(Integer, default=100) # 每分钟请求限制
rate_limit = Column(Integer, default=None, nullable=True) # 每分钟请求限制None = 无限制
concurrent_limit = Column(Integer, default=5, nullable=True) # 并发请求限制
# Key 能力配置

View File

@@ -19,6 +19,7 @@ class ProviderEndpointCreate(BaseModel):
provider_id: str = Field(..., description="Provider ID")
api_format: str = Field(..., description="API 格式 (CLAUDE, OPENAI, CLAUDE_CLI, OPENAI_CLI)")
base_url: str = Field(..., min_length=1, max_length=500, description="API 基础 URL")
custom_path: Optional[str] = Field(default=None, max_length=200, description="自定义请求路径")
# 请求配置
headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
@@ -62,6 +63,7 @@ class ProviderEndpointUpdate(BaseModel):
base_url: Optional[str] = Field(
default=None, min_length=1, max_length=500, description="API 基础 URL"
)
custom_path: Optional[str] = Field(default=None, max_length=200, description="自定义请求路径")
headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
timeout: Optional[int] = Field(default=None, ge=10, le=600, description="超时时间(秒)")
max_retries: Optional[int] = Field(default=None, ge=0, le=10, description="最大重试次数")
@@ -94,6 +96,7 @@ class ProviderEndpointResponse(BaseModel):
# API 配置
api_format: str
base_url: str
custom_path: Optional[str] = None
# 请求配置
headers: Optional[Dict[str, str]] = None

View File

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

View File

@@ -21,7 +21,7 @@ WARNING: 多进程环境注意事项
import asyncio
import time
from collections import deque
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Deque, Dict
from src.core.logger import logger
@@ -95,12 +95,12 @@ class SlidingWindow:
"""获取最早的重置时间"""
self._cleanup()
if not self.requests:
return datetime.now()
return datetime.now(timezone.utc)
# 最早的请求将在window_size秒后过期
oldest_request = self.requests[0]
reset_time = oldest_request + self.window_size
return datetime.fromtimestamp(reset_time)
return datetime.fromtimestamp(reset_time, tz=timezone.utc)
class SlidingWindowStrategy(RateLimitStrategy):
@@ -250,7 +250,7 @@ class SlidingWindowStrategy(RateLimitStrategy):
retry_after = None
if not allowed:
# 计算需要等待的时间(最早请求过期的时间)
retry_after = int((reset_at - datetime.now()).total_seconds()) + 1
retry_after = int((reset_at - datetime.now(timezone.utc)).total_seconds()) + 1
return RateLimitResult(
allowed=allowed,

View File

@@ -3,7 +3,7 @@
import asyncio
import os
import time
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple
from ...clients.redis_client import get_redis_client_sync
@@ -63,11 +63,11 @@ class TokenBucket:
def get_reset_time(self) -> datetime:
"""获取下次完全恢复的时间"""
if self.tokens >= self.capacity:
return datetime.now()
return datetime.now(timezone.utc)
tokens_needed = self.capacity - self.tokens
seconds_to_full = tokens_needed / self.refill_rate
return datetime.now() + timedelta(seconds=seconds_to_full)
return datetime.now(timezone.utc) + timedelta(seconds=seconds_to_full)
class TokenBucketStrategy(RateLimitStrategy):
@@ -370,7 +370,7 @@ class RedisTokenBucketBackend:
if tokens is None or last_refill is None:
remaining = capacity
reset_at = datetime.now() + timedelta(seconds=capacity / refill_rate)
reset_at = datetime.now(timezone.utc) + timedelta(seconds=capacity / refill_rate)
else:
tokens_value = float(tokens)
last_refill_value = float(last_refill)
@@ -378,7 +378,7 @@ class RedisTokenBucketBackend:
tokens_value = min(capacity, tokens_value + delta * refill_rate)
remaining = int(tokens_value)
reset_after = 0 if tokens_value >= capacity else (capacity - tokens_value) / refill_rate
reset_at = datetime.now() + timedelta(seconds=reset_after)
reset_at = datetime.now(timezone.utc) + timedelta(seconds=reset_after)
allowed = remaining >= amount
retry_after = None

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

@@ -148,6 +148,8 @@ class GlobalModelService:
删除 GlobalModel
默认行为: 级联删除所有关联的 Provider 模型实现
注意: 不清理 API Key 和 User 的 allowed_models 引用,
保留无效引用可让用户在前端看到"已失效"的模型,便于手动清理或等待重建同名模型
"""
global_model = GlobalModelService.get_global_model(db, global_model_id)

View File

@@ -237,7 +237,7 @@ class ErrorClassifier:
result["reason"] = str(data.get("reason", data.get("code", "")))
except (json.JSONDecodeError, TypeError, KeyError):
result["message"] = error_text[:500] if len(error_text) > 500 else error_text
result["message"] = error_text
return result
@@ -323,8 +323,8 @@ class ErrorClassifier:
if parts:
return ": ".join(parts) if len(parts) > 1 else parts[0]
# 无法解析,返回原始文本(截断)
return parsed["raw"][:500] if len(parsed["raw"]) > 500 else parsed["raw"]
# 无法解析,返回原始文本
return parsed["raw"]
def classify(
self,
@@ -484,11 +484,15 @@ class ErrorClassifier:
return ProviderNotAvailableException(
message=detailed_message,
provider_name=provider_name,
upstream_status=status,
upstream_response=error_response_text,
)
return ProviderNotAvailableException(
message=detailed_message,
provider_name=provider_name,
upstream_status=status,
upstream_response=error_response_text,
)
async def handle_http_error(
@@ -532,12 +536,14 @@ class ErrorClassifier:
provider_name = str(provider.name)
# 尝试读取错误响应内容
error_response_text = None
try:
if http_error.response and hasattr(http_error.response, "text"):
error_response_text = http_error.response.text[:1000] # 限制长度
except Exception:
pass
# 优先使用 handler 附加的 upstream_response 属性(流式请求中 response.text 可能为空)
error_response_text = getattr(http_error, "upstream_response", None)
if not error_response_text:
try:
if http_error.response and hasattr(http_error.response, "text"):
error_response_text = http_error.response.text
except Exception:
pass
logger.warning(f" [{request_id}] HTTP错误 (attempt={attempt}/{max_attempts}): "
f"{http_error.response.status_code if http_error.response else 'unknown'}")

View File

@@ -30,6 +30,7 @@ from redis import Redis
from sqlalchemy.orm import Session
from src.core.enums import APIFormat
from src.core.error_utils import extract_error_message
from src.core.exceptions import (
ConcurrencyLimitError,
ProviderNotAvailableException,
@@ -401,7 +402,7 @@ class FallbackOrchestrator:
db=self.db,
candidate_id=candidate_record_id,
error_type="HTTPStatusError",
error_message=f"HTTP {status_code}: {str(cause)}",
error_message=extract_error_message(cause, status_code),
status_code=status_code,
latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent,
@@ -425,31 +426,22 @@ class FallbackOrchestrator:
attempt=attempt,
max_attempts=max_attempts,
)
# str(cause) 可能为空(如 httpx 超时异常),使用 repr() 作为备用
error_msg = str(cause) or repr(cause)
# 如果是 ProviderNotAvailableException附加上游响应
if hasattr(cause, "upstream_response") and cause.upstream_response:
error_msg = f"{error_msg} | 上游响应: {cause.upstream_response[:500]}"
RequestCandidateService.mark_candidate_failed(
db=self.db,
candidate_id=candidate_record_id,
error_type=type(cause).__name__,
error_message=error_msg,
error_message=extract_error_message(cause),
latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent,
)
return "continue" if has_retry_left else "break"
# 未知错误:记录失败并抛出
error_msg = str(cause) or repr(cause)
# 如果是 ProviderNotAvailableException附加上游响应
if hasattr(cause, "upstream_response") and cause.upstream_response:
error_msg = f"{error_msg} | 上游响应: {cause.upstream_response[:500]}"
RequestCandidateService.mark_candidate_failed(
db=self.db,
candidate_id=candidate_record_id,
error_type=type(cause).__name__,
error_message=error_msg,
error_message=extract_error_message(cause),
latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent,
)
@@ -543,7 +535,9 @@ class FallbackOrchestrator:
raise last_error
# 所有组合都已尝试完毕,全部失败
self._raise_all_failed_exception(request_id, max_attempts, last_candidate, model_name, api_format_enum)
self._raise_all_failed_exception(
request_id, max_attempts, last_candidate, model_name, api_format_enum, last_error
)
async def _try_candidate_with_retries(
self,
@@ -565,6 +559,7 @@ class FallbackOrchestrator:
provider = candidate.provider
endpoint = candidate.endpoint
max_retries_for_candidate = int(endpoint.max_retries) if candidate.is_cached else 1
last_error: Optional[Exception] = None
for retry_index in range(max_retries_for_candidate):
attempt_counter += 1
@@ -599,6 +594,7 @@ class FallbackOrchestrator:
return {"success": True, "response": response}
except ExecutionError as exec_err:
last_error = exec_err.cause
action = await self._handle_candidate_error(
exec_err=exec_err,
candidate=candidate,
@@ -630,6 +626,7 @@ class FallbackOrchestrator:
"success": False,
"attempt_counter": attempt_counter,
"max_attempts": max_attempts,
"error": last_error,
}
def _attach_metadata_to_error(
@@ -678,6 +675,7 @@ class FallbackOrchestrator:
last_candidate: Optional[ProviderCandidate],
model_name: str,
api_format_enum: APIFormat,
last_error: Optional[Exception] = None,
) -> NoReturn:
"""所有组合都失败时抛出异常"""
logger.error(f" [{request_id}] 所有 {max_attempts} 个组合均失败")
@@ -693,9 +691,38 @@ class FallbackOrchestrator:
"api_format": api_format_enum.value,
}
# 提取上游错误响应
upstream_status: Optional[int] = None
upstream_response: Optional[str] = None
if last_error:
# 从 httpx.HTTPStatusError 提取
if isinstance(last_error, httpx.HTTPStatusError):
upstream_status = last_error.response.status_code
# 优先使用我们附加的 upstream_response 属性(流已读取时 response.text 可能为空)
upstream_response = getattr(last_error, "upstream_response", None)
if not upstream_response:
try:
upstream_response = last_error.response.text
except Exception:
pass
# 从其他异常属性提取(如 ProviderNotAvailableException
else:
upstream_status = getattr(last_error, "upstream_status", None)
upstream_response = getattr(last_error, "upstream_response", None)
# 如果响应为空或无效,使用异常的字符串表示
if (
not upstream_response
or not upstream_response.strip()
or upstream_response.startswith("Unable to read")
):
upstream_response = str(last_error)
raise ProviderNotAvailableException(
f"所有Provider均不可用已尝试{max_attempts}个组合",
request_metadata=request_metadata,
upstream_status=upstream_status,
upstream_response=upstream_response,
)
async def execute_with_fallback(

View File

@@ -1,14 +1,16 @@
"""
自适应并发调整器 - 基于滑动窗口利用率的并发限制调整
自适应并发调整器 - 基于边界记忆的并发限制调整
核心改进(相对于旧版基于"持续高利用率"的方案):
- 使用滑动窗口采样,容忍并发波动
- 基于窗口内高利用率采样比例决策,而非要求连续高利用率
- 增加探测性扩容机制,长时间稳定时主动尝试扩容
核心算法:边界记忆 + 渐进探测
- 触发 429 时记录边界last_concurrent_peak这就是真实上限
- 缩容策略:新限制 = 边界 - 1而非乘性减少
- 扩容策略:不超过已知边界,除非是探测性扩容
- 探测性扩容:长时间无 429 时尝试突破边界
AIMD 参数说明
- 扩容:加性增加 (+INCREASE_STEP)
- 缩容:乘性减少 (*DECREASE_MULTIPLIER默认 0.85)
设计原则
1. 快速收敛:一次 429 就能找到接近真实的限制
2. 避免过度保守:不会因为多次 429 而无限下降
3. 安全探测:允许在稳定后尝试更高并发
"""
from datetime import datetime, timezone
@@ -35,21 +37,21 @@ class AdaptiveConcurrencyManager:
"""
自适应并发管理器
核心算法:基于滑动窗口利用率的 AIMD
- 滑动窗口记录最近 N 次请求的利用率
- 当窗口内高利用率采样比例 >= 60% 时触发扩容
- 遇到 429 错误时乘性减少 (*0.85)
- 长时间无 429 且有流量时触发探测性扩容
核心算法:边界记忆 + 渐进探测
- 触发 429 时记录边界last_concurrent_peak = 触发时的并发数)
- 缩容:新限制 = 边界 - 1快速收敛到真实限制附近
- 扩容:不超过边界(即 last_concurrent_peak允许回到边界值尝试
- 探测性扩容长时间30分钟无 429 时,可以尝试 +1 突破边界
扩容条件(满足任一即可):
1. 滑动窗口扩容:窗口内 >= 60% 的采样利用率 >= 70%,且不在冷却期
2. 探测性扩容:距上次 429 超过 30 分钟,且期间有足够请求量
1. 利用率扩容:窗口内利用率比例 >= 60%,且当前限制 < 边界
2. 探测性扩容:距上次 429 超过 30 分钟,可以尝试突破边界
关键特性:
1. 滑动窗口容忍并发波动,不会因单次低利用率重置
2. 区分并发限制和 RPM 限制
3. 探测性扩容避免长期卡在低限制
4. 记录调整历史
1. 快速收敛:一次 429 就能学到接近真实的限制值
2. 边界保护:普通扩容不会超过已知边界
3. 安全探测:长时间稳定后允许尝试更高并发
4. 区分并发限制和 RPM 限制
"""
# 默认配置 - 使用统一常量
@@ -59,7 +61,6 @@ class AdaptiveConcurrencyManager:
# AIMD 参数
INCREASE_STEP = ConcurrencyDefaults.INCREASE_STEP
DECREASE_MULTIPLIER = ConcurrencyDefaults.DECREASE_MULTIPLIER
# 滑动窗口参数
UTILIZATION_WINDOW_SIZE = ConcurrencyDefaults.UTILIZATION_WINDOW_SIZE
@@ -115,7 +116,13 @@ class AdaptiveConcurrencyManager:
# 更新429统计
key.last_429_at = datetime.now(timezone.utc) # type: ignore[assignment]
key.last_429_type = rate_limit_info.limit_type # type: ignore[assignment]
key.last_concurrent_peak = current_concurrent # type: ignore[assignment]
# 仅在并发限制且拿到并发数时记录边界RPM/UNKNOWN 不应覆盖并发边界记忆)
if (
rate_limit_info.limit_type == RateLimitType.CONCURRENT
and current_concurrent is not None
and current_concurrent > 0
):
key.last_concurrent_peak = current_concurrent # type: ignore[assignment]
# 遇到 429 错误,清空利用率采样窗口(重新开始收集)
key.utilization_samples = [] # type: ignore[assignment]
@@ -207,6 +214,9 @@ class AdaptiveConcurrencyManager:
current_limit = int(key.learned_max_concurrent or self.DEFAULT_INITIAL_LIMIT)
# 获取已知边界(上次触发 429 时的并发数)
known_boundary = key.last_concurrent_peak
# 计算当前利用率
utilization = float(current_concurrent / current_limit) if current_limit > 0 else 0.0
@@ -217,22 +227,29 @@ class AdaptiveConcurrencyManager:
samples = self._update_utilization_window(key, now_ts, utilization)
# 检查是否满足扩容条件
increase_reason = self._check_increase_conditions(key, samples, now)
increase_reason = self._check_increase_conditions(key, samples, now, known_boundary)
if increase_reason and current_limit < self.MAX_CONCURRENT_LIMIT:
old_limit = current_limit
new_limit = self._increase_limit(current_limit)
is_probe = increase_reason == "probe_increase"
new_limit = self._increase_limit(current_limit, known_boundary, is_probe)
# 如果没有实际增长(已达边界),跳过
if new_limit <= old_limit:
return None
# 计算窗口统计用于日志
avg_util = sum(s["util"] for s in samples) / len(samples) if samples else 0
high_util_count = sum(1 for s in samples if s["util"] >= self.UTILIZATION_THRESHOLD)
high_util_ratio = high_util_count / len(samples) if samples else 0
boundary_info = f"边界: {known_boundary}" if known_boundary else "无边界"
logger.info(
f"[INCREASE] {increase_reason}: Key {key.id[:8]}... | "
f"窗口采样: {len(samples)} | "
f"平均利用率: {avg_util:.1%} | "
f"高利用率比例: {high_util_ratio:.1%} | "
f"{boundary_info} | "
f"调整: {old_limit} -> {new_limit}"
)
@@ -246,13 +263,14 @@ class AdaptiveConcurrencyManager:
high_util_ratio=round(high_util_ratio, 2),
sample_count=len(samples),
current_concurrent=current_concurrent,
known_boundary=known_boundary,
)
# 更新限制
key.learned_max_concurrent = new_limit # type: ignore[assignment]
# 如果是探测性扩容,更新探测时间
if increase_reason == "probe_increase":
if is_probe:
key.last_probe_increase_at = now # type: ignore[assignment]
# 扩容后清空采样窗口,重新开始收集
@@ -303,7 +321,11 @@ class AdaptiveConcurrencyManager:
return samples
def _check_increase_conditions(
self, key: ProviderAPIKey, samples: List[Dict[str, Any]], now: datetime
self,
key: ProviderAPIKey,
samples: List[Dict[str, Any]],
now: datetime,
known_boundary: Optional[int] = None,
) -> Optional[str]:
"""
检查是否满足扩容条件
@@ -312,6 +334,7 @@ class AdaptiveConcurrencyManager:
key: API Key对象
samples: 利用率采样列表
now: 当前时间
known_boundary: 已知边界(触发 429 时的并发数)
Returns:
扩容原因(如果满足条件),否则返回 None
@@ -320,15 +343,25 @@ class AdaptiveConcurrencyManager:
if self._is_in_cooldown(key):
return None
# 条件1滑动窗口扩容
current_limit = int(key.learned_max_concurrent or self.DEFAULT_INITIAL_LIMIT)
# 条件1滑动窗口扩容不超过边界
if len(samples) >= self.MIN_SAMPLES_FOR_DECISION:
high_util_count = sum(1 for s in samples if s["util"] >= self.UTILIZATION_THRESHOLD)
high_util_ratio = high_util_count / len(samples)
if high_util_ratio >= self.HIGH_UTILIZATION_RATIO:
return "high_utilization"
# 检查是否还有扩容空间(边界保护)
if known_boundary:
# 允许扩容到边界值(而非 boundary - 1因为缩容时已经 -1 了
if current_limit < known_boundary:
return "high_utilization"
# 已达边界,不触发普通扩容
else:
# 无边界信息,允许扩容
return "high_utilization"
# 条件2探测性扩容长时间无 429 且有流量)
# 条件2探测性扩容长时间无 429 且有流量,可以突破边界
if self._should_probe_increase(key, samples, now):
return "probe_increase"
@@ -406,32 +439,65 @@ class AdaptiveConcurrencyManager:
current_concurrent: Optional[int] = None,
) -> int:
"""
减少并发限制
减少并发限制(基于边界记忆策略)
策略:
- 如果知道当前并发数设置为当前并发的70%
- 否则,使用乘性减少
- 如果知道触发 429 时的并发数,新限制 = 并发数 - 1
- 这样可以快速收敛到真实限制附近,而不会过度保守
- 例如:真实限制 8触发时并发 8 -> 新限制 7而非 8*0.85=6
"""
if current_concurrent:
# 基于当前并发数减少
new_limit = max(
int(current_concurrent * self.DECREASE_MULTIPLIER), self.MIN_CONCURRENT_LIMIT
)
if current_concurrent is not None and current_concurrent > 0:
# 边界记忆策略:新限制 = 触发边界 - 1
candidate = current_concurrent - 1
else:
# 乘性减少
new_limit = max(
int(current_limit * self.DECREASE_MULTIPLIER), self.MIN_CONCURRENT_LIMIT
)
# 没有并发信息时,保守减少 1
candidate = current_limit - 1
# 保证不会“缩容变扩容”(例如 current_concurrent > current_limit 的异常场景)
candidate = min(candidate, current_limit - 1)
new_limit = max(candidate, self.MIN_CONCURRENT_LIMIT)
return new_limit
def _increase_limit(self, current_limit: int) -> int:
def _increase_limit(
self,
current_limit: int,
known_boundary: Optional[int] = None,
is_probe: bool = False,
) -> int:
"""
增加并发限制
增加并发限制(考虑边界保护)
策略:加性增加 (+1)
策略:
- 普通扩容:每次 +INCREASE_STEP但不超过 known_boundary
(因为缩容时已经 -1 了,这里允许回到边界值尝试)
- 探测性扩容:每次只 +1可以突破边界但要谨慎
Args:
current_limit: 当前限制
known_boundary: 已知边界last_concurrent_peak即触发 429 时的并发数
is_probe: 是否是探测性扩容(可以突破边界)
"""
new_limit = min(current_limit + self.INCREASE_STEP, self.MAX_CONCURRENT_LIMIT)
if is_probe:
# 探测模式:每次只 +1谨慎突破边界
new_limit = current_limit + 1
else:
# 普通模式:每次 +INCREASE_STEP
new_limit = current_limit + self.INCREASE_STEP
# 边界保护:普通扩容不超过 known_boundary允许回到边界值尝试
if known_boundary:
if new_limit > known_boundary:
new_limit = known_boundary
# 全局上限保护
new_limit = min(new_limit, self.MAX_CONCURRENT_LIMIT)
# 确保有增长(否则返回原值表示不扩容)
if new_limit <= current_limit:
return current_limit
return new_limit
def _record_adjustment(
@@ -503,11 +569,16 @@ class AdaptiveConcurrencyManager:
if key.last_probe_increase_at:
last_probe_at_str = cast(datetime, key.last_probe_increase_at).isoformat()
# 边界信息
known_boundary = key.last_concurrent_peak
return {
"adaptive_mode": is_adaptive,
"max_concurrent": key.max_concurrent, # NULL=自适应,数字=固定限制
"effective_limit": effective_limit, # 当前有效限制
"learned_limit": key.learned_max_concurrent, # 学习到的限制
# 边界记忆相关
"known_boundary": known_boundary, # 触发 429 时的并发数(已知上限)
"concurrent_429_count": int(key.concurrent_429_count or 0),
"rpm_429_count": int(key.rpm_429_count or 0),
"last_429_at": last_429_at_str,

View File

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

View File

@@ -289,11 +289,11 @@ class RequestResult:
status_code = 500
error_type = "internal_error"
# 构建错误消息,包含上游响应信息
error_message = str(exception)
if isinstance(exception, ProviderNotAvailableException):
if exception.upstream_response:
error_message = f"{error_message} | 上游响应: {exception.upstream_response[:500]}"
# 构建错误消息:优先使用上游响应作为主要错误信息
if isinstance(exception, ProviderNotAvailableException) and exception.upstream_response:
error_message = exception.upstream_response
else:
error_message = str(exception)
return cls(
status=RequestStatus.FAILED,

View File

@@ -168,19 +168,28 @@ class SystemConfigService:
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()
return [
{
result = []
for config in configs:
item = {
"key": config.key,
"value": config.value,
"description": config.description,
"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
def delete_config(cls, db: Session, key: str) -> bool:

View File

@@ -86,6 +86,118 @@ class UsageRecordParams:
class UsageService:
"""用量统计服务"""
# ==================== 缓存键常量 ====================
# 热力图缓存键前缀(依赖 TTL 自动过期,用户角色变更时主动清除)
HEATMAP_CACHE_KEY_PREFIX = "activity_heatmap"
# ==================== 热力图缓存 ====================
@classmethod
def _get_heatmap_cache_key(cls, user_id: Optional[str], include_actual_cost: bool) -> str:
"""生成热力图缓存键"""
cost_suffix = "with_cost" if include_actual_cost else "no_cost"
if user_id:
return f"{cls.HEATMAP_CACHE_KEY_PREFIX}:user:{user_id}:{cost_suffix}"
else:
return f"{cls.HEATMAP_CACHE_KEY_PREFIX}:admin:all:{cost_suffix}"
@classmethod
async def clear_user_heatmap_cache(cls, user_id: str) -> None:
"""
清除用户的热力图缓存(用户角色变更时调用)
Args:
user_id: 用户ID
"""
from src.clients.redis_client import get_redis_client
redis_client = await get_redis_client(require_redis=False)
if not redis_client:
return
# 清除该用户的所有热力图缓存with_cost 和 no_cost
keys_to_delete = [
cls._get_heatmap_cache_key(user_id, include_actual_cost=True),
cls._get_heatmap_cache_key(user_id, include_actual_cost=False),
]
for key in keys_to_delete:
try:
await redis_client.delete(key)
logger.debug(f"已清除热力图缓存: {key}")
except Exception as e:
logger.warning(f"清除热力图缓存失败: {key}, error={e}")
@classmethod
async def get_cached_heatmap(
cls,
db: Session,
user_id: Optional[str] = None,
include_actual_cost: bool = False,
) -> Dict[str, Any]:
"""
获取带缓存的热力图数据
缓存策略:
- TTL: 5分钟CacheTTL.ACTIVITY_HEATMAP
- 仅依赖 TTL 自动过期,新使用记录最多延迟 5 分钟出现
- 用户角色变更时通过 clear_user_heatmap_cache() 主动清除
Args:
db: 数据库会话
user_id: 用户IDNone 表示获取全局热力图(管理员)
include_actual_cost: 是否包含实际成本
Returns:
热力图数据字典
"""
from src.clients.redis_client import get_redis_client
from src.config.constants import CacheTTL
import json
cache_key = cls._get_heatmap_cache_key(user_id, include_actual_cost)
cache_ttl = CacheTTL.ACTIVITY_HEATMAP
redis_client = await get_redis_client(require_redis=False)
# 尝试从缓存获取
if redis_client:
try:
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached) # type: ignore[no-any-return]
except json.JSONDecodeError as e:
logger.warning(f"热力图缓存解析失败,删除损坏缓存: {cache_key}, error={e}")
try:
await redis_client.delete(cache_key)
except Exception:
pass
except Exception as e:
logger.error(f"读取热力图缓存出错: {cache_key}, error={e}")
# 从数据库查询
result = cls.get_daily_activity(
db=db,
user_id=user_id,
window_days=365,
include_actual_cost=include_actual_cost,
)
# 保存到缓存(失败不影响返回结果)
if redis_client:
try:
await redis_client.setex(
cache_key,
cache_ttl,
json.dumps(result, ensure_ascii=False, default=str),
)
except Exception as e:
logger.warning(f"保存热力图缓存失败: {cache_key}, error={e}")
return result
# ==================== 内部数据类 ====================
@staticmethod
@@ -1027,7 +1139,12 @@ class UsageService:
window_days: int = 365,
include_actual_cost: bool = False,
) -> Dict[str, Any]:
"""按天统计请求活跃度,用于渲染热力图。"""
"""按天统计请求活跃度,用于渲染热力图。
优化策略:
- 历史数据从预计算的 StatsDaily/StatsUserDaily 表读取
- 只有"今天"的数据才实时查询 Usage 表
"""
def ensure_timezone(value: datetime) -> datetime:
if value.tzinfo is None:
@@ -1041,54 +1158,109 @@ class UsageService:
ensure_timezone(start_date) if start_date else end_dt - timedelta(days=window_days - 1)
)
# 对齐到自然日的开始/结束,避免遗漏边界数据
start_dt = start_dt.replace(hour=0, minute=0, second=0, microsecond=0)
end_dt = end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
from src.utils.database_helpers import date_trunc_portable
bind = db.get_bind()
dialect = bind.dialect.name if bind is not None else "sqlite"
day_bucket = date_trunc_portable(dialect, "day", Usage.created_at).label("day")
columns = [
day_bucket,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
]
if include_actual_cost:
columns.append(func.sum(Usage.actual_total_cost_usd).label("actual_total_cost_usd"))
query = db.query(*columns).filter(Usage.created_at >= start_dt, Usage.created_at <= end_dt)
if user_id:
query = query.filter(Usage.user_id == user_id)
query = query.group_by(day_bucket).order_by(day_bucket)
rows = query.all()
def normalize_period(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value[:10]
if isinstance(value, datetime):
return value.date().isoformat()
return str(value)
# 对齐到自然日的开始/结束
start_dt = datetime.combine(start_dt.date(), datetime.min.time(), tzinfo=timezone.utc)
end_dt = datetime.combine(end_dt.date(), datetime.max.time(), tzinfo=timezone.utc)
today = now.date()
today_start_dt = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
aggregated: Dict[str, Dict[str, Any]] = {}
for row in rows:
key = normalize_period(row.day)
aggregated[key] = {
"requests": int(row.requests or 0),
"total_tokens": int(row.total_tokens or 0),
"total_cost_usd": float(row.total_cost_usd or 0.0),
}
if include_actual_cost:
aggregated[key]["actual_total_cost_usd"] = float(row.actual_total_cost_usd or 0.0)
# 1. 从预计算表读取历史数据(不包括今天)
if user_id:
from src.models.database import StatsUserDaily
hist_query = db.query(StatsUserDaily).filter(
StatsUserDaily.user_id == user_id,
StatsUserDaily.date >= start_dt,
StatsUserDaily.date < today_start_dt,
)
for row in hist_query.all():
key = (
row.date.date().isoformat()
if isinstance(row.date, datetime)
else str(row.date)[:10]
)
aggregated[key] = {
"requests": row.total_requests or 0,
"total_tokens": (
(row.input_tokens or 0)
+ (row.output_tokens or 0)
+ (row.cache_creation_tokens or 0)
+ (row.cache_read_tokens or 0)
),
"total_cost_usd": float(row.total_cost or 0.0),
}
# StatsUserDaily 没有 actual_total_cost 字段,用户视图不需要倍率成本
else:
from src.models.database import StatsDaily
hist_query = db.query(StatsDaily).filter(
StatsDaily.date >= start_dt,
StatsDaily.date < today_start_dt,
)
for row in hist_query.all():
key = (
row.date.date().isoformat()
if isinstance(row.date, datetime)
else str(row.date)[:10]
)
aggregated[key] = {
"requests": row.total_requests or 0,
"total_tokens": (
(row.input_tokens or 0)
+ (row.output_tokens or 0)
+ (row.cache_creation_tokens or 0)
+ (row.cache_read_tokens or 0)
),
"total_cost_usd": float(row.total_cost or 0.0),
}
if include_actual_cost:
aggregated[key]["actual_total_cost_usd"] = float(
row.actual_total_cost or 0.0 # type: ignore[attr-defined]
)
# 2. 实时查询今天的数据(如果在查询范围内)
if today >= start_dt.date() and today <= end_dt.date():
today_start = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
today_end = datetime.combine(today, datetime.max.time(), tzinfo=timezone.utc)
if include_actual_cost:
today_query = db.query(
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
func.sum(Usage.actual_total_cost_usd).label("actual_total_cost_usd"),
).filter(
Usage.created_at >= today_start,
Usage.created_at <= today_end,
)
else:
today_query = db.query(
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
).filter(
Usage.created_at >= today_start,
Usage.created_at <= today_end,
)
if user_id:
today_query = today_query.filter(Usage.user_id == user_id)
today_row = today_query.first()
if today_row and today_row.requests:
aggregated[today.isoformat()] = {
"requests": int(today_row.requests or 0),
"total_tokens": int(today_row.total_tokens or 0),
"total_cost_usd": float(today_row.total_cost_usd or 0.0),
}
if include_actual_cost:
aggregated[today.isoformat()]["actual_total_cost_usd"] = float(
today_row.actual_total_cost_usd or 0.0
)
# 3. 构建返回结果
days: List[Dict[str, Any]] = []
cursor = start_dt.date()
end_date_only = end_dt.date()
@@ -1304,6 +1476,9 @@ class UsageService:
provider: Optional[str] = None,
target_model: Optional[str] = None,
first_byte_time_ms: Optional[int] = None,
provider_id: Optional[str] = None,
provider_endpoint_id: Optional[str] = None,
provider_api_key_id: Optional[str] = None,
) -> Optional[Usage]:
"""
快速更新使用记录状态
@@ -1316,6 +1491,9 @@ class UsageService:
provider: 提供商名称可选streaming 状态时更新)
target_model: 映射后的目标模型名(可选)
first_byte_time_ms: 首字时间/TTFB可选streaming 状态时更新)
provider_id: Provider ID可选streaming 状态时更新)
provider_endpoint_id: Endpoint ID可选streaming 状态时更新)
provider_api_key_id: Provider API Key ID可选streaming 状态时更新)
Returns:
更新后的 Usage 记录,如果未找到则返回 None
@@ -1331,10 +1509,22 @@ class UsageService:
usage.error_message = error_message
if provider:
usage.provider = provider
elif status == "streaming" and usage.provider == "pending":
# 状态变为 streaming 但 provider 仍为 pending记录警告
logger.warning(
f"状态更新为 streaming 但 provider 为空: request_id={request_id}, "
f"当前 provider={usage.provider}"
)
if target_model:
usage.target_model = target_model
if first_byte_time_ms is not None:
usage.first_byte_time_ms = first_byte_time_ms
if provider_id is not None:
usage.provider_id = provider_id
if provider_endpoint_id is not None:
usage.provider_endpoint_id = provider_endpoint_id
if provider_api_key_id is not None:
usage.provider_api_key_id = provider_api_key_id
db.commit()
@@ -1446,6 +1636,8 @@ class UsageService:
ids: Optional[List[str]] = None,
user_id: Optional[str] = None,
default_timeout_seconds: int = 300,
*,
include_admin_fields: bool = False,
) -> List[Dict[str, Any]]:
"""
获取活跃请求状态(用于前端轮询),并自动清理超时的 pending/streaming 请求
@@ -1482,6 +1674,15 @@ class UsageService:
ProviderEndpoint.timeout.label("endpoint_timeout"),
).outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
# 管理员轮询:可附带 provider 与上游 key 名称(注意:不要在普通用户接口暴露上游 key 信息)
if include_admin_fields:
from src.models.database import ProviderAPIKey
query = query.add_columns(
Usage.provider,
ProviderAPIKey.name.label("api_key_name"),
).outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
if ids:
query = query.filter(Usage.id.in_(ids))
if user_id:
@@ -1518,8 +1719,9 @@ class UsageService:
)
db.commit()
return [
{
result: List[Dict[str, Any]] = []
for r in records:
item: Dict[str, Any] = {
"id": r.id,
"status": "failed" if r.id in timeout_ids else r.status,
"input_tokens": r.input_tokens,
@@ -1528,8 +1730,12 @@ class UsageService:
"response_time_ms": r.response_time_ms,
"first_byte_time_ms": r.first_byte_time_ms, # 首字时间 (TTFB)
}
for r in records
]
if include_admin_fields:
item["provider"] = r.provider
item["api_key_name"] = r.api_key_name
result.append(item)
return result
# ========== 缓存亲和性分析方法 ==========

View File

@@ -459,34 +459,38 @@ class StreamUsageTracker:
logger.debug(f"ID:{self.request_id} | 开始跟踪流式响应 | 估算输入tokens:{self.input_tokens}")
chunk_count = 0
first_chunk_received = False
first_byte_time_ms = None # 预先记录 TTFB避免 yield 后计算不准确
try:
async for chunk in stream:
chunk_count += 1
# 保存原始字节流(用于错误诊断)
self.raw_chunks.append(chunk)
# 第一个 chunk 收到时,更新状态为 streaming 并记录 TTFB
if not first_chunk_received:
first_chunk_received = True
if self.request_id:
try:
# 计算 TTFB使用请求原始开始时间或 track_stream 开始时间)
base_time = self.request_start_time or self.start_time
first_byte_time_ms = int((time.time() - base_time) * 1000) if base_time else None
UsageService.update_usage_status(
db=self.db,
request_id=self.request_id,
status="streaming",
provider=self.provider,
first_byte_time_ms=first_byte_time_ms,
)
except Exception as e:
logger.warning(f"更新使用记录状态为 streaming 失败: {e}")
# 第一个 chunk 收到时,记录 TTFB 时间点(但先不更新数据库,避免阻塞)
if chunk_count == 1:
# 计算 TTFB使用请求原始开始时间或 track_stream 开始时间)
base_time = self.request_start_time or self.start_time
first_byte_time_ms = int((time.time() - base_time) * 1000) if base_time else None
# 返回原始块给客户端
# 返回原始块给客户端,确保 TTFB 不受数据库操作影响
yield chunk
# yield 后再更新数据库状态(仅第一个 chunk 时执行)
if chunk_count == 1 and self.request_id:
try:
UsageService.update_usage_status(
db=self.db,
request_id=self.request_id,
status="streaming",
provider=self.provider,
first_byte_time_ms=first_byte_time_ms,
provider_id=self.provider_id,
provider_endpoint_id=self.provider_endpoint_id,
provider_api_key_id=self.provider_api_key_id,
)
except Exception as e:
logger.warning(f"更新使用记录状态为 streaming 失败: {e}")
# 解析块以提取内容和使用信息chunk是原始字节
content, usage = self.parse_stream_chunk(chunk)
@@ -916,15 +920,38 @@ class EnhancedStreamUsageTracker(StreamUsageTracker):
logger.debug(f"ID:{self.request_id} | 开始跟踪流式响应(Enhanced) | 估算输入tokens:{self.input_tokens}")
chunk_count = 0
first_byte_time_ms = None # 预先记录 TTFB避免 yield 后计算不准确
try:
async for chunk in stream:
chunk_count += 1
# 保存原始字节流(用于错误诊断)
self.raw_chunks.append(chunk)
# 返回原始块给客户端
# 第一个 chunk 收到时,记录 TTFB 时间点(但先不更新数据库,避免阻塞)
if chunk_count == 1:
# 计算 TTFB使用请求原始开始时间或 track_stream 开始时间)
base_time = self.request_start_time or self.start_time
first_byte_time_ms = int((time.time() - base_time) * 1000) if base_time else None
# 先返回原始块给客户端,确保 TTFB 不受数据库操作影响
yield chunk
# yield 后再更新数据库状态(仅第一个 chunk 时执行)
if chunk_count == 1 and self.request_id:
try:
UsageService.update_usage_status(
db=self.db,
request_id=self.request_id,
status="streaming",
provider=self.provider,
first_byte_time_ms=first_byte_time_ms,
provider_id=self.provider_id,
provider_endpoint_id=self.provider_endpoint_id,
provider_api_key_id=self.provider_api_key_id,
)
except Exception as e:
logger.warning(f"更新使用记录状态为 streaming 失败: {e}")
# 解析块以提取内容和使用信息chunk是原始字节
content, usage = self.parse_stream_chunk(chunk)

View File

@@ -25,9 +25,10 @@ class ApiKeyService:
allowed_providers: Optional[List[str]] = None,
allowed_api_formats: Optional[List[str]] = None,
allowed_models: Optional[List[str]] = None,
rate_limit: int = 100,
rate_limit: Optional[int] = None,
concurrent_limit: int = 5,
expire_days: Optional[int] = None,
expires_at: Optional[datetime] = None, # 直接传入过期时间,优先于 expire_days
initial_balance_usd: Optional[float] = None,
is_standalone: bool = False,
auto_delete_on_expiry: bool = False,
@@ -44,6 +45,7 @@ class ApiKeyService:
rate_limit: 速率限制
concurrent_limit: 并发限制
expire_days: 过期天数None = 永不过期
expires_at: 直接指定过期时间,优先于 expire_days
initial_balance_usd: 初始余额USD仅用于独立KeyNone = 无限制
is_standalone: 是否为独立余额Key仅管理员可创建
auto_delete_on_expiry: 过期后是否自动删除True=物理删除False=仅禁用)
@@ -54,10 +56,10 @@ class ApiKeyService:
key_hash = ApiKey.hash_key(key)
key_encrypted = crypto_service.encrypt(key) # 加密存储密钥
# 计算过期时间
expires_at = None
if expire_days:
expires_at = datetime.now(timezone.utc) + timedelta(days=expire_days)
# 计算过期时间:优先使用 expires_at其次使用 expire_days
final_expires_at = expires_at
if final_expires_at is None and expire_days:
final_expires_at = datetime.now(timezone.utc) + timedelta(days=expire_days)
# 空数组转为 None表示不限制
api_key = ApiKey(
@@ -70,7 +72,7 @@ class ApiKeyService:
allowed_models=allowed_models or None,
rate_limit=rate_limit,
concurrent_limit=concurrent_limit,
expires_at=expires_at,
expires_at=final_expires_at,
balance_used_usd=0.0,
current_balance_usd=initial_balance_usd, # 直接使用初始余额None = 无限制
is_standalone=is_standalone,
@@ -145,6 +147,9 @@ class ApiKeyService:
# 允许显式设置为空数组/None 的字段(空数组会转为 None表示"全部"
nullable_list_fields = {"allowed_providers", "allowed_api_formats", "allowed_models"}
# 允许显式设置为 None 的字段(如 expires_at=None 表示永不过期rate_limit=None 表示无限制)
nullable_fields = {"expires_at", "rate_limit"}
for field, value in kwargs.items():
if field not in updatable_fields:
continue
@@ -153,6 +158,9 @@ class ApiKeyService:
if value is not None:
# 空数组转为 None表示允许全部
setattr(api_key, field, value if value else None)
elif field in nullable_fields:
# 这些字段允许显式设置为 None
setattr(api_key, field, value)
elif value is not None:
setattr(api_key, field, value)

View File

@@ -49,8 +49,16 @@ def cache_result(key_prefix: str, ttl: int = 60, user_specific: bool = True) ->
# 尝试从缓存获取
cached = await redis_client.get(cache_key)
if cached:
logger.debug(f"缓存命中: {cache_key}")
return json.loads(cached)
try:
result = json.loads(cached)
logger.debug(f"缓存命中: {cache_key}")
return result
except json.JSONDecodeError as e:
logger.warning(f"缓存解析失败,删除损坏缓存: {cache_key}, 错误: {e}")
try:
await redis_client.delete(cache_key)
except Exception:
pass
# 执行原函数
result = await func(*args, **kwargs)

View File

@@ -1,8 +1,16 @@
"""分布式任务协调器,确保仅有一个 worker 执行特定任务"""
"""分布式任务协调器,确保仅有一个 worker 执行特定任务
锁清理策略:
- 单实例模式(默认):启动时使用原子操作清理旧锁并获取新锁
- 多实例模式:使用 NX 选项竞争锁,依赖 TTL 处理异常退出
使用方式:
- 默认行为:启动时清理旧锁(适用于单机部署)
- 多实例部署:设置 SINGLE_INSTANCE_MODE=false 禁用启动清理
"""
from __future__ import annotations
import asyncio
import os
import pathlib
import uuid
@@ -19,6 +27,10 @@ except ImportError: # pragma: no cover - Windows 环境
class StartupTaskCoordinator:
"""利用 Redis 或文件锁,保证任务只在单个进程/实例中运行"""
# 类级别标记:在当前进程中是否已尝试过启动清理
# 注意:这在 fork 模式下每个 worker 都是独立的
_startup_cleanup_attempted = False
def __init__(self, redis_client=None, lock_dir: Optional[str] = None):
self.redis = redis_client
self._tokens: Dict[str, str] = {}
@@ -26,6 +38,8 @@ class StartupTaskCoordinator:
self._lock_dir = pathlib.Path(lock_dir or os.getenv("TASK_LOCK_DIR", "./.locks"))
if not self._lock_dir.exists():
self._lock_dir.mkdir(parents=True, exist_ok=True)
# 单实例模式:启动时清理旧锁(适用于单机部署,避免残留锁问题)
self._single_instance_mode = os.getenv("SINGLE_INSTANCE_MODE", "true").lower() == "true"
def _redis_key(self, name: str) -> str:
return f"task_lock:{name}"
@@ -36,12 +50,51 @@ class StartupTaskCoordinator:
if self.redis:
token = str(uuid.uuid4())
try:
acquired = await self.redis.set(self._redis_key(name), token, nx=True, ex=ttl)
if acquired:
self._tokens[name] = token
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
return True
return False
if self._single_instance_mode:
# 单实例模式:使用 Lua 脚本原子性地"清理旧锁 + 竞争获取"
# 只有当锁不存在或成功获取时才返回 1
# 这样第一个执行的 worker 会清理旧锁并获取,后续 worker 会正常竞争
script = """
local key = KEYS[1]
local token = ARGV[1]
local ttl = tonumber(ARGV[2])
local startup_key = KEYS[1] .. ':startup'
-- 检查是否已有 worker 执行过启动清理
local cleaned = redis.call('GET', startup_key)
if not cleaned then
-- 第一个 worker删除旧锁标记已清理
redis.call('DEL', key)
redis.call('SET', startup_key, '1', 'EX', 60)
end
-- 尝试获取锁NX 模式)
local result = redis.call('SET', key, token, 'NX', 'EX', ttl)
if result then
return 1
end
return 0
"""
result = await self.redis.eval(
script, 2,
self._redis_key(name), self._redis_key(name),
token, ttl
)
if result == 1:
self._tokens[name] = token
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
return True
return False
else:
# 多实例模式:直接使用 NX 选项竞争锁
acquired = await self.redis.set(
self._redis_key(name), token, nx=True, ex=ttl
)
if acquired:
self._tokens[name] = token
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
return True
return False
except Exception as exc: # pragma: no cover - Redis 异常回退
logger.warning(f"Redis 锁获取失败,回退到文件锁: {exc}")