35 Commits

Author SHA1 Message Date
fawney19
0f78d5cbf3 fix: 增强 CLI 处理器的错误信息,包含上游响应详情 2026-01-05 19:44:38 +08:00
fawney19
431c6de8d2 feat: 用户用量页面支持分页、搜索和密钥信息展示
- 用户用量API增加search参数支持密钥名、模型名搜索
- 用户用量API返回api_key信息(id、name、display)
- 用户页面记录表格增加密钥列显示
- 前端统一管理员和用户页面的分页/搜索逻辑
- 后端LIKE查询增加特殊字符转义防止SQL注入
- 添加escape_like_pattern和safe_truncate_escaped工具函数
2026-01-05 19:35:14 +08:00
fawney19
142e15bbcc Merge pull request #69 from AoaoMH/feature/Record-optimization
feat: add usage statistics and records feature with new API routes, f…
2026-01-05 19:31:59 +08:00
AAEE86
31acc5c607 feat(models): sort models by release date within each provider
Models are now sorted by release date in descending order (newest first)
within each provider group. Models without release dates are placed at the
end. When release dates are identical or missing, models fall back to
alphabetical sorting by name.
2026-01-05 18:23:04 +08:00
fawney19
bfa0a26d41 feat: 用户导出支持独立余额Key,新增系统版本接口
- 用户导出/导入支持独立余额 Key (standalone_keys)
- API Key 导出增加 expires_at 字段
- 新增 /api/admin/system/version 接口获取版本信息
- 前端系统设置页面显示当前版本
- 移除导入对话框中多余的 bg-muted 背景样式
2026-01-05 18:18:45 +08:00
AoaoMH
93ab9b6a5e feat: add usage statistics and records feature with new API routes, frontend types, services, and UI components 2026-01-05 17:03:05 +08:00
fawney19
35e29d46bd refactor: 抽取统一计费模块,支持配置驱动的多厂商计费
- 新增 src/services/billing/ 模块,包含计费计算器、模板和使用量映射
- 将 ChatAdapterBase 和 CliAdapterBase 中的计费逻辑重构为调用 billing 模块
- 为每个 adapter 添加 BILLING_TEMPLATE 类属性,指定计费模板
- 支持 Claude/OpenAI/Gemini 三种计费模板,支持阶梯计费和缓存 TTL 定价
- 新增 tests/services/billing/ 单元测试
2026-01-05 16:48:59 +08:00
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
109 changed files with 8120 additions and 1392 deletions

15
LICENSE
View File

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

View File

@@ -58,13 +58,13 @@ cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署 # 3. 部署
docker-compose up -d docker compose up -d
# 4. 首次部署时, 初始化数据库 # 4. 首次部署时, 初始化数据库
./migrate.sh ./migrate.sh
# 5. 更新 # 5. 更新
docker-compose pull && docker-compose up -d && ./migrate.sh docker compose pull && docker compose up -d && ./migrate.sh
``` ```
### Docker Compose本地构建镜像 ### Docker Compose本地构建镜像
@@ -86,7 +86,7 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
```bash ```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 uv sync
@@ -143,7 +143,7 @@ cd frontend && npm install && npm run dev
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略 - **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略 - **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能用支持 1H缓存的模型 > **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
### Q: 如何配置负载均衡? ### Q: 如何配置负载均衡?
@@ -162,4 +162,16 @@ cd frontend && npm install && npm run dev
## 许可证 ## 许可证
本项目采用 [Aether 非商业开源许可证](LICENSE)。 本项目采用 [Aether 非商业开源许可证](LICENSE)。允许个人学习、教育研究、非盈利组织及企业内部非盈利性质的使用;禁止用于盈利目的。商业使用请联系获取商业许可。
## 联系作者
<p align="center">
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
</p>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date)

View File

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

View File

@@ -1,7 +1,7 @@
# Aether 部署配置 - 本地构建 # Aether 部署配置 - 本地构建
# 使用方法: # 使用方法:
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest . # 首次构建 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: services:
postgres: postgres:

View File

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

View File

@@ -13,6 +13,7 @@ export interface UsersExportData {
version: string version: string
exported_at: string exported_at: string
users: UserExport[] users: UserExport[]
standalone_keys?: StandaloneKeyExport[]
} }
export interface UserExport { export interface UserExport {
@@ -42,15 +43,19 @@ export interface UserApiKeyExport {
allowed_endpoints?: string[] | null allowed_endpoints?: string[] | null
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
allowed_models?: string[] | null allowed_models?: string[] | null
rate_limit?: number rate_limit?: number | null // null = 无限制
concurrent_limit?: number | null concurrent_limit?: number | null
force_capabilities?: any force_capabilities?: any
is_active: boolean is_active: boolean
expires_at?: string | null
auto_delete_on_expiry?: boolean auto_delete_on_expiry?: boolean
total_requests?: number total_requests?: number
total_cost_usd?: number total_cost_usd?: number
} }
// 独立余额 Key 导出结构(与 UserApiKeyExport 相同,但不包含 is_standalone
export type StandaloneKeyExport = Omit<UserApiKeyExport, 'is_standalone'>
export interface GlobalModelExport { export interface GlobalModelExport {
name: string name: string
display_name: string display_name: string
@@ -124,6 +129,37 @@ export interface ModelExport {
config?: any config?: any
} }
// 邮件模板接口
export interface EmailTemplateInfo {
type: string
name: string
variables: string[]
subject: string
html: string
is_custom: boolean
default_subject?: string
default_html?: string
}
export interface EmailTemplatesResponse {
templates: EmailTemplateInfo[]
}
export interface EmailTemplatePreviewResponse {
html: string
variables: Record<string, string>
}
export interface EmailTemplateResetResponse {
message: string
template: {
type: string
name: string
subject: string
html: string
}
}
// Provider 模型查询响应 // Provider 模型查询响应
export interface ProviderModelsQueryResponse { export interface ProviderModelsQueryResponse {
success: boolean success: boolean
@@ -158,6 +194,7 @@ export interface UsersImportResponse {
stats: { stats: {
users: { created: number; updated: number; skipped: number } users: { created: number; updated: number; skipped: number }
api_keys: { created: number; skipped: number } api_keys: { created: number; skipped: number }
standalone_keys?: { created: number; skipped: number }
errors: string[] errors: string[]
} }
} }
@@ -189,7 +226,7 @@ export interface AdminApiKey {
total_requests?: number total_requests?: number
total_tokens?: number total_tokens?: number
total_cost_usd?: number total_cost_usd?: number
rate_limit?: number rate_limit?: number | null // null = 无限制
allowed_providers?: string[] | null // 允许的提供商列表 allowed_providers?: string[] | null // 允许的提供商列表
allowed_api_formats?: string[] | null // 允许的 API 格式列表 allowed_api_formats?: string[] | null // 允许的 API 格式列表
allowed_models?: string[] | null // 允许的模型列表 allowed_models?: string[] | null // 允许的模型列表
@@ -205,8 +242,8 @@ export interface CreateStandaloneApiKeyRequest {
allowed_providers?: string[] | null allowed_providers?: string[] | null
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
allowed_models?: string[] | null allowed_models?: string[] | null
rate_limit?: number rate_limit?: number | null // null = 无限制
expire_days?: number | null // null = 永不过期 expires_at?: string | null // ISO 日期字符串,如 "2025-12-31"null = 永不过期
initial_balance_usd: number // 初始余额,必须设置 initial_balance_usd: number // 初始余额,必须设置
auto_delete_on_expiry?: boolean // 过期后是否自动删除 auto_delete_on_expiry?: boolean // 过期后是否自动删除
} }
@@ -386,5 +423,69 @@ export const adminApi = {
{ provider_id: providerId, api_key_id: apiKeyId } { provider_id: providerId, api_key_id: apiKeyId }
) )
return response.data return response.data
},
// 测试 SMTP 连接,支持传入未保存的配置
async testSmtpConnection(config: Record<string, any> = {}): Promise<{ success: boolean; message: string }> {
const response = await apiClient.post<{ success: boolean; message: string }>(
'/api/admin/system/smtp/test',
config
)
return response.data
},
// 邮件模板相关
// 获取所有邮件模板
async getEmailTemplates(): Promise<EmailTemplatesResponse> {
const response = await apiClient.get<EmailTemplatesResponse>('/api/admin/system/email/templates')
return response.data
},
// 获取指定类型的邮件模板
async getEmailTemplate(templateType: string): Promise<EmailTemplateInfo> {
const response = await apiClient.get<EmailTemplateInfo>(
`/api/admin/system/email/templates/${templateType}`
)
return response.data
},
// 更新邮件模板
async updateEmailTemplate(
templateType: string,
data: { subject?: string; html?: string }
): Promise<{ message: string }> {
const response = await apiClient.put<{ message: string }>(
`/api/admin/system/email/templates/${templateType}`,
data
)
return response.data
},
// 预览邮件模板
async previewEmailTemplate(
templateType: string,
data?: { html?: string } & Record<string, string>
): Promise<EmailTemplatePreviewResponse> {
const response = await apiClient.post<EmailTemplatePreviewResponse>(
`/api/admin/system/email/templates/${templateType}/preview`,
data || {}
)
return response.data
},
// 重置邮件模板为默认值
async resetEmailTemplate(templateType: string): Promise<EmailTemplateResetResponse> {
const response = await apiClient.post<EmailTemplateResetResponse>(
`/api/admin/system/email/templates/${templateType}/reset`
)
return response.data
},
// 获取系统版本信息
async getSystemVersion(): Promise<{ version: string }> {
const response = await apiClient.get<{ version: string }>(
'/api/admin/system/version'
)
return response.data
} }
} }

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,11 @@ export interface UsageRecordDetail {
cache_creation_price_per_1m?: number cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格 price_per_request?: number // 按次计费价格
api_key?: {
id: string
name: string
display: string
}
} }
// 模型统计接口 // 模型统计接口
@@ -75,6 +80,16 @@ export interface ModelSummary {
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见) 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 { export interface UsageResponse {
total_requests: number total_requests: number
@@ -87,6 +102,13 @@ export interface UsageResponse {
quota_usd: number | null quota_usd: number | null
used_usd: number used_usd: number
summary_by_model: ModelSummary[] summary_by_model: ModelSummary[]
summary_by_provider?: ProviderSummary[]
pagination?: {
total: number
limit: number
offset: number
has_more: boolean
}
records: UsageRecordDetail[] records: UsageRecordDetail[]
activity_heatmap?: ActivityHeatmap | null activity_heatmap?: ActivityHeatmap | null
} }
@@ -175,6 +197,9 @@ export const meApi = {
async getUsage(params?: { async getUsage(params?: {
start_date?: string start_date?: string
end_date?: string end_date?: string
search?: string // 通用搜索:密钥名、模型名
limit?: number
offset?: number
}): Promise<UsageResponse> { }): Promise<UsageResponse> {
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params }) const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
return response.data return response.data
@@ -184,11 +209,12 @@ export const meApi = {
async getActiveRequests(ids?: string): Promise<{ async getActiveRequests(ids?: string): Promise<{
requests: Array<{ requests: Array<{
id: string id: string
status: string status: 'pending' | 'streaming' | 'completed' | 'failed'
input_tokens: number input_tokens: number
output_tokens: number output_tokens: number
cost: number cost: number
response_time_ms: number | null response_time_ms: number | null
first_byte_time_ms: number | null
}> }>
}> { }> {
const params = ids ? { ids } : {} const params = ids ? { ids } : {}
@@ -267,5 +293,14 @@ export const meApi = {
}> { }> {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params }) const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
return response.data return response.data
},
/**
* 获取活跃度热力图数据(用户)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
return response.data
} }
} }

View File

@@ -192,10 +192,17 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
} }
} }
// 按 provider 名称和模型名称排序 // 按 provider 名称排序provider 中的模型按 release_date 从近到远排序
items.sort((a, b) => { items.sort((a, b) => {
const providerCompare = a.providerName.localeCompare(b.providerName) const providerCompare = a.providerName.localeCompare(b.providerName)
if (providerCompare !== 0) return providerCompare if (providerCompare !== 0) return providerCompare
// 模型按 release_date 从近到远排序(没有日期的排到最后)
const aDate = a.releaseDate ? new Date(a.releaseDate).getTime() : 0
const bDate = b.releaseDate ? new Date(b.releaseDate).getTime() : 0
if (aDate !== bDate) return bDate - aDate // 降序:新的在前
// 日期相同或都没有日期时,按模型名称排序
return a.modelName.localeCompare(b.modelName) return a.modelName.localeCompare(b.modelName)
}) })

View File

@@ -164,6 +164,7 @@ export const usageApi = {
async getAllUsageRecords(params?: { async getAllUsageRecords(params?: {
start_date?: string start_date?: string
end_date?: string end_date?: string
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
user_id?: string // UUID user_id?: string // UUID
username?: string username?: string
model?: string model?: string
@@ -193,10 +194,22 @@ export const usageApi = {
output_tokens: number output_tokens: number
cost: number cost: number
response_time_ms: number | null 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 params = ids?.length ? { ids: ids.join(',') } : {}
const response = await apiClient.get('/api/admin/usage/active', { params }) const response = await apiClient.get('/api/admin/usage/active', { params })
return response.data 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 EmptyState } from './EmptyState.vue'
export { default as AlertDialog } from './AlertDialog.vue' export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue' export { default as LoadingState } from './LoadingState.vue'
// 表单组件
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,22 @@
<span class="flex-shrink-0"></span> <span class="flex-shrink-0"></span>
</div> </div>
</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 <ActivityHeatmap
v-if="hasData" v-else-if="hasData"
:data="data" :data="data"
:show-header="false" :show-header="false"
/> />
@@ -34,6 +48,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { Loader2, AlertCircle } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue' import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity' import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
@@ -41,6 +56,8 @@ import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
const props = defineProps<{ const props = defineProps<{
data: ActivityHeatmapData | null data: ActivityHeatmapData | null
title: string title: string
isLoading?: boolean
hasError?: boolean
}>() }>()
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85] const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]

View File

@@ -32,6 +32,17 @@
<!-- 分隔线 --> <!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 通用搜索 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="usage-records-search"
v-model="localSearch"
:placeholder="isAdmin ? '搜索用户/密钥/模型/提供商' : '搜索密钥/模型'"
class="w-32 sm:w-48 h-8 text-xs border-border/60 pl-8"
/>
</div>
<!-- 用户筛选仅管理员可见 --> <!-- 用户筛选仅管理员可见 -->
<Select <Select
v-if="isAdmin && availableUsers.length > 0" v-if="isAdmin && availableUsers.length > 0"
@@ -164,6 +175,12 @@
> >
用户 用户
</TableHead> </TableHead>
<TableHead
v-if="!isAdmin"
class="h-12 font-semibold w-[100px]"
>
密钥
</TableHead>
<TableHead class="h-12 font-semibold w-[140px]"> <TableHead class="h-12 font-semibold w-[140px]">
模型 模型
</TableHead> </TableHead>
@@ -196,7 +213,7 @@
<TableBody> <TableBody>
<TableRow v-if="records.length === 0"> <TableRow v-if="records.length === 0">
<TableCell <TableCell
:colspan="isAdmin ? 9 : 7" :colspan="isAdmin ? 9 : 8"
class="text-center py-12 text-muted-foreground" class="text-center py-12 text-muted-foreground"
> >
暂无请求记录 暂无请求记录
@@ -218,7 +235,34 @@
class="py-4 w-[100px] truncate" class="py-4 w-[100px] truncate"
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')" :title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
> >
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }} <div class="flex flex-col text-xs gap-0.5">
<span class="truncate">
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
</span>
<span
v-if="record.api_key?.name"
class="text-muted-foreground truncate"
:title="record.api_key.name"
>
{{ record.api_key.name }}
</span>
</div>
</TableCell>
<!-- 用户页面的密钥列 -->
<TableCell
v-if="!isAdmin"
class="py-4 w-[100px]"
:title="record.api_key?.name || '-'"
>
<div class="flex flex-col text-xs gap-0.5">
<span class="truncate">{{ record.api_key?.name || '-' }}</span>
<span
v-if="record.api_key?.display"
class="text-muted-foreground truncate"
>
{{ record.api_key.display }}
</span>
</div>
</TableCell> </TableCell>
<TableCell <TableCell
class="font-medium py-4 w-[140px]" class="font-medium py-4 w-[140px]"
@@ -438,6 +482,7 @@ import {
TableCard, TableCard,
Badge, Badge,
Button, Button,
Input,
Select, Select,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
@@ -451,7 +496,7 @@ import {
TableCell, TableCell,
Pagination, Pagination,
} from '@/components/ui' } from '@/components/ui'
import { RefreshCcw } from 'lucide-vue-next' import { RefreshCcw, Search } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables' import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick' import { useRowClick } from '@/composables/useRowClick'
@@ -471,6 +516,7 @@ const props = defineProps<{
// 时间段 // 时间段
selectedPeriod: string selectedPeriod: string
// 筛选 // 筛选
filterSearch: string
filterUser: string filterUser: string
filterModel: string filterModel: string
filterProvider: string filterProvider: string
@@ -489,6 +535,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:selectedPeriod': [value: string] 'update:selectedPeriod': [value: string]
'update:filterSearch': [value: string]
'update:filterUser': [value: string] 'update:filterUser': [value: string]
'update:filterModel': [value: string] 'update:filterModel': [value: string]
'update:filterProvider': [value: string] 'update:filterProvider': [value: string]
@@ -507,6 +554,23 @@ const filterModelSelectOpen = ref(false)
const filterProviderSelectOpen = ref(false) const filterProviderSelectOpen = ref(false)
const filterStatusSelectOpen = ref(false) const filterStatusSelectOpen = ref(false)
// 通用搜索(输入防抖)
const localSearch = ref(props.filterSearch)
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => props.filterSearch, (value) => {
if (value !== localSearch.value) {
localSearch.value = value
}
})
watch(localSearch, (value) => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
emit('update:filterSearch', value)
}, 300)
})
// 动态计时器相关 // 动态计时器相关
const now = ref(Date.now()) const now = ref(Date.now())
let timerInterval: ReturnType<typeof setInterval> | null = null let timerInterval: ReturnType<typeof setInterval> | null = null
@@ -574,6 +638,10 @@ function handleRowClick(event: MouseEvent, id: string) {
// 组件卸载时清理 // 组件卸载时清理
onUnmounted(() => { onUnmounted(() => {
stopTimer() stopTimer()
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}) })
// 格式化 API 格式显示名称 // 格式化 API 格式显示名称

View File

@@ -23,6 +23,7 @@ export interface PaginationParams {
} }
export interface FilterParams { export interface FilterParams {
search?: string
user_id?: string user_id?: string
model?: string model?: string
provider?: string provider?: string
@@ -64,9 +65,6 @@ export function useUsageData(options: UseUsageDataOptions) {
})) }))
}) })
// 活跃度热图数据
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
// 加载统计数据(不加载记录) // 加载统计数据(不加载记录)
async function loadStats(dateRange?: DateRangeParams) { async function loadStats(dateRange?: DateRangeParams) {
isLoadingStats.value = true isLoadingStats.value = true
@@ -93,7 +91,7 @@ export function useUsageData(options: UseUsageDataOptions) {
cache_stats: (statsData as any).cache_stats, cache_stats: (statsData as any).cache_stats,
period_start: '', period_start: '',
period_end: '', period_end: '',
activity_heatmap: statsData.activity_heatmap || null activity_heatmap: null
} }
modelStats.value = modelData.map(item => ({ modelStats.value = modelData.map(item => ({
@@ -143,7 +141,7 @@ export function useUsageData(options: UseUsageDataOptions) {
avg_response_time: userData.avg_response_time || 0, avg_response_time: userData.avg_response_time || 0,
period_start: '', period_start: '',
period_end: '', period_end: '',
activity_heatmap: userData.activity_heatmap || null activity_heatmap: null
} }
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({ modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
@@ -237,11 +235,6 @@ export function useUsageData(options: UseUsageDataOptions) {
pagination: PaginationParams, pagination: PaginationParams,
filters?: FilterParams filters?: FilterParams
): Promise<void> { ): Promise<void> {
if (!isAdminPage.value) {
// 用户页面不需要分页加载,记录已在 loadStats 中获取
return
}
isLoadingRecords.value = true isLoadingRecords.value = true
try { try {
@@ -255,24 +248,34 @@ export function useUsageData(options: UseUsageDataOptions) {
} }
// 添加筛选条件 // 添加筛选条件
if (filters?.user_id) { if (filters?.search?.trim()) {
params.user_id = filters.user_id params.search = filters.search.trim()
}
if (filters?.model) {
params.model = filters.model
}
if (filters?.provider) {
params.provider = filters.provider
}
if (filters?.status) {
params.status = filters.status
} }
const response = await usageApi.getAllUsageRecords(params) if (isAdminPage.value) {
// 管理员页面:使用管理员 API
currentRecords.value = (response.records || []) as UsageRecord[] if (filters?.user_id) {
totalRecords.value = response.total || 0 params.user_id = filters.user_id
}
if (filters?.model) {
params.model = filters.model
}
if (filters?.provider) {
params.provider = filters.provider
}
if (filters?.status) {
params.status = filters.status
}
const response = await usageApi.getAllUsageRecords(params)
currentRecords.value = (response.records || []) as UsageRecord[]
totalRecords.value = response.total || 0
} else {
// 用户页面:使用用户 API
const userData = await meApi.getUsage(params)
currentRecords.value = (userData.records || []) as UsageRecord[]
totalRecords.value = userData.pagination?.total || currentRecords.value.length
}
} catch (error) { } catch (error) {
log.error('加载记录失败:', error) log.error('加载记录失败:', error)
currentRecords.value = [] currentRecords.value = []
@@ -305,7 +308,6 @@ export function useUsageData(options: UseUsageDataOptions) {
// 计算属性 // 计算属性
enhancedModelStats, enhancedModelStats,
activityHeatmapData,
// 方法 // 方法
loadStats, loadStats,

View File

@@ -1,5 +1,3 @@
import type { ActivityHeatmap } from '@/types/activity'
// 统计数据状态 // 统计数据状态
export interface UsageStatsState { export interface UsageStatsState {
total_requests: number total_requests: number
@@ -17,7 +15,6 @@ export interface UsageStatsState {
} }
period_start: string period_start: string
period_end: string period_end: string
activity_heatmap: ActivityHeatmap | null
} }
// 模型统计 // 模型统计
@@ -64,6 +61,11 @@ export interface UsageRecord {
user_id?: string user_id?: string
username?: string username?: string
user_email?: string user_email?: string
api_key?: {
id: string | null
name: string | null
display: string | null
} | null
provider: string provider: string
api_key_name?: string api_key_name?: string
rate_multiplier?: number rate_multiplier?: number
@@ -115,7 +117,6 @@ export function createDefaultStats(): UsageStatsState {
error_rate: undefined, error_rate: undefined,
cache_stats: undefined, cache_stats: undefined,
period_start: '', period_start: '',
period_end: '', period_end: ''
activity_heatmap: null
} }
} }

View File

@@ -316,55 +316,10 @@
</div> </div>
<!-- 模型多选下拉框 --> <!-- 模型多选下拉框 -->
<div class="space-y-2"> <ModelMultiSelect
<Label class="text-sm font-medium">允许的模型</Label> v-model="form.allowed_models"
<div class="relative"> :models="globalModels"
<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>
</div> </div>
</div> </div>
</form> </form>
@@ -404,10 +359,12 @@ import {
} from '@/components/ui' } from '@/components/ui'
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next' import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
import { useFormDialog } from '@/composables/useFormDialog' import { useFormDialog } from '@/composables/useFormDialog'
import { ModelMultiSelect } from '@/components/common'
import { getProvidersSummary } from '@/api/endpoints/providers' import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models' import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
export interface UserFormData { export interface UserFormData {
id?: string id?: string
@@ -440,11 +397,10 @@ const roleSelectOpen = ref(false)
// 下拉框状态 // 下拉框状态
const providerDropdownOpen = ref(false) const providerDropdownOpen = ref(false)
const endpointDropdownOpen = ref(false) const endpointDropdownOpen = ref(false)
const modelDropdownOpen = ref(false)
// 选项数据 // 选项数据
const providers = ref<any[]>([]) const providers = ref<ProviderWithEndpointsSummary[]>([])
const globalModels = ref<any[]>([]) const globalModels = ref<GlobalModelResponse[]>([])
const apiFormats = ref<Array<{ value: string; label: string }>>([]) const apiFormats = ref<Array<{ value: string; label: string }>>([])
// 表单数据 // 表单数据

View File

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

View File

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

View File

@@ -367,6 +367,11 @@ function generateMockUsageRecords(count: number = 100) {
user_id: user.id, user_id: user.id,
username: user.username, username: user.username,
user_email: user.email, user_email: user.email,
api_key: {
id: `key-${user.id}-${Math.ceil(Math.random() * 2)}`,
name: `${user.username} Key ${Math.ceil(Math.random() * 3)}`,
display: `sk-ae...${String(1000 + Math.floor(Math.random() * 9000))}`
},
provider: model.provider, provider: model.provider,
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`, api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
rate_multiplier: 1.0, rate_multiplier: 1.0,
@@ -405,10 +410,10 @@ function getUsageRecords() {
// Mock 映射数据 // Mock 映射数据
const MOCK_ALIASES = [ const MOCK_ALIASES = [
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }, { id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-003', target_global_model_name: 'claude-sonnet-4-5-20250929', target_global_model_display_name: 'Claude Sonnet 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }, { id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-5-20251101', target_global_model_display_name: 'Claude Opus 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-003', source_model: 'gpt4o', target_global_model_id: 'gm-004', target_global_model_name: 'gpt-4o', target_global_model_display_name: 'GPT-4o', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }, { id: 'alias-003', source_model: 'gpt5', target_global_model_id: 'gm-006', target_global_model_name: 'gpt-5.1', target_global_model_display_name: 'GPT-5.1', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-004', source_model: 'gemini-flash', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-2.0-flash', target_global_model_display_name: 'Gemini 2.0 Flash', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' } { id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
] ]
// Mock Endpoint Keys // Mock Endpoint Keys
@@ -835,10 +840,26 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
'GET /api/admin/usage/records': async (config) => { 'GET /api/admin/usage/records': async (config) => {
await delay() await delay()
requireAdmin() requireAdmin()
const records = getUsageRecords() let records = getUsageRecords()
const params = config.params || {} const params = config.params || {}
const limit = parseInt(params.limit) || 20 const limit = parseInt(params.limit) || 20
const offset = parseInt(params.offset) || 0 const offset = parseInt(params.offset) || 0
// 通用搜索:用户名、密钥名、模型名、提供商名
// 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
if (typeof params.search === 'string' && params.search.trim()) {
const keywords = params.search.trim().toLowerCase().split(/\s+/)
records = records.filter(r => {
// 每个关键词都要匹配至少一个字段
return keywords.every((keyword: string) =>
(r.username || '').toLowerCase().includes(keyword) ||
(r.api_key?.name || '').toLowerCase().includes(keyword) ||
(r.model || '').toLowerCase().includes(keyword) ||
(r.provider || '').toLowerCase().includes(keyword)
)
})
}
return createMockResponse({ return createMockResponse({
records: records.slice(offset, offset + limit), records: records.slice(offset, offset + limit),
total: records.length, total: records.length,
@@ -2172,10 +2193,10 @@ function generateIntervalTimelineData(
// 模型列表(用于按模型区分颜色) // 模型列表(用于按模型区分颜色)
const models = [ const models = [
'claude-sonnet-4-20250514', 'claude-sonnet-4-5-20250929',
'claude-3-5-sonnet-20241022', 'claude-haiku-4-5-20251001',
'claude-3-5-haiku-20241022', 'claude-opus-4-5-20251101',
'claude-opus-4-20250514' 'gpt-5.1'
] ]
// 生成模拟的请求间隔数据 // 生成模拟的请求间隔数据

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -465,6 +465,29 @@
</div> </div>
</CardSection> </CardSection>
<!-- 系统版本信息 -->
<CardSection
title="系统信息"
description="当前系统版本和构建信息"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Label class="text-sm font-medium text-muted-foreground">版本:</Label>
<span
v-if="systemVersion"
class="text-sm font-mono"
>
{{ systemVersion }}
</span>
<span
v-else
class="text-sm text-muted-foreground"
>
加载中...
</span>
</div>
</div>
</CardSection>
</div> </div>
<!-- 导入配置对话框 --> <!-- 导入配置对话框 -->
@@ -476,7 +499,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div <div
v-if="importPreview" v-if="importPreview"
class="p-3 bg-muted rounded-lg text-sm" class="text-sm"
> >
<p class="font-medium mb-2"> <p class="font-medium mb-2">
配置预览 配置预览
@@ -558,7 +581,7 @@
class="space-y-4" class="space-y-4"
> >
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
全局模型 全局模型
</p> </p>
@@ -568,7 +591,7 @@
跳过: {{ importResult.stats.global_models.skipped }} 跳过: {{ importResult.stats.global_models.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
提供商 提供商
</p> </p>
@@ -578,7 +601,7 @@
跳过: {{ importResult.stats.providers.skipped }} 跳过: {{ importResult.stats.providers.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
端点 端点
</p> </p>
@@ -588,7 +611,7 @@
跳过: {{ importResult.stats.endpoints.skipped }} 跳过: {{ importResult.stats.endpoints.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
API Keys API Keys
</p> </p>
@@ -597,7 +620,7 @@
跳过: {{ importResult.stats.keys.skipped }} 跳过: {{ importResult.stats.keys.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg col-span-2"> <div class="col-span-2">
<p class="font-medium"> <p class="font-medium">
模型配置 模型配置
</p> </p>
@@ -643,7 +666,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div <div
v-if="importUsersPreview" v-if="importUsersPreview"
class="p-3 bg-muted rounded-lg text-sm" class="text-sm"
> >
<p class="font-medium mb-2"> <p class="font-medium mb-2">
数据预览 数据预览
@@ -653,6 +676,9 @@
<li> <li>
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }}
</li> </li>
<li v-if="importUsersPreview.standalone_keys?.length">
独立余额 Keys: {{ importUsersPreview.standalone_keys.length }}
</li>
</ul> </ul>
</div> </div>
@@ -721,7 +747,7 @@
class="space-y-4" class="space-y-4"
> >
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
用户 用户
</p> </p>
@@ -731,7 +757,7 @@
跳过: {{ importUsersResult.stats.users.skipped }} 跳过: {{ importUsersResult.stats.users.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
API Keys API Keys
</p> </p>
@@ -740,6 +766,18 @@
跳过: {{ importUsersResult.stats.api_keys.skipped }} 跳过: {{ importUsersResult.stats.api_keys.skipped }}
</p> </p>
</div> </div>
<div
v-if="importUsersResult.stats.standalone_keys"
class="col-span-2"
>
<p class="font-medium">
独立余额 Keys
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.standalone_keys.created }},
跳过: {{ importUsersResult.stats.standalone_keys.skipped }}
</p>
</div>
</div> </div>
<div <div
@@ -770,7 +808,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Download, Upload } from 'lucide-vue-next' import { Download, Upload } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue' import Input from '@/components/ui/input.vue'
@@ -840,6 +878,9 @@ const importUsersResult = ref<UsersImportResponse | null>(null)
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip') const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const usersMergeModeSelectOpen = ref(false) const usersMergeModeSelectOpen = ref(false)
// 系统版本信息
const systemVersion = ref<string>('')
const systemConfig = ref<SystemConfig>({ const systemConfig = ref<SystemConfig>({
// 基础配置 // 基础配置
default_user_quota_usd: 10.0, default_user_quota_usd: 10.0,
@@ -891,9 +932,21 @@ const sensitiveHeadersStr = computed({
}) })
onMounted(async () => { onMounted(async () => {
await loadSystemConfig() await Promise.all([
loadSystemConfig(),
loadSystemVersion()
])
}) })
async function loadSystemVersion() {
try {
const data = await adminApi.getSystemVersion()
systemVersion.value = data.version
} catch (err) {
log.error('加载系统版本失败:', err)
}
}
async function loadSystemConfig() { async function loadSystemConfig() {
try { try {
const configs = [ const configs = [
@@ -1179,12 +1232,6 @@ function handleUsersFileSelect(event: Event) {
const content = e.target?.result as string const content = e.target?.result as string
const data = JSON.parse(content) as UsersExportData const data = JSON.parse(content) as UsersExportData
// 验证版本
if (data.version !== '1.0') {
error(`不支持的配置版本: ${data.version}`)
return
}
importUsersPreview.value = data importUsersPreview.value = data
usersMergeMode.value = 'skip' usersMergeMode.value = 'skip'
importUsersDialogOpen.value = true importUsersDialogOpen.value = true

View File

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

View File

@@ -5,6 +5,8 @@
<ActivityHeatmapCard <ActivityHeatmapCard
:data="activityHeatmapData" :data="activityHeatmapData"
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'" :title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
:is-loading="isLoadingHeatmap"
:has-error="heatmapError"
/> />
<IntervalTimelineCard <IntervalTimelineCard
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'" :title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
@@ -54,6 +56,7 @@
:show-actual-cost="authStore.isAdmin" :show-actual-cost="authStore.isAdmin"
:loading="isLoadingRecords" :loading="isLoadingRecords"
:selected-period="selectedPeriod" :selected-period="selectedPeriod"
:filter-search="filterSearch"
:filter-user="filterUser" :filter-user="filterUser"
:filter-model="filterModel" :filter-model="filterModel"
:filter-provider="filterProvider" :filter-provider="filterProvider"
@@ -67,6 +70,7 @@
:page-size-options="pageSizeOptions" :page-size-options="pageSizeOptions"
:auto-refresh="globalAutoRefresh" :auto-refresh="globalAutoRefresh"
@update:selected-period="handlePeriodChange" @update:selected-period="handlePeriodChange"
@update:filter-search="handleFilterSearchChange"
@update:filter-user="handleFilterUserChange" @update:filter-user="handleFilterUserChange"
@update:filter-model="handleFilterModelChange" @update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange" @update:filter-provider="handleFilterProviderChange"
@@ -112,8 +116,11 @@ import {
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types' import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue' import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
import { useToast } from '@/composables/useToast'
const route = useRoute() const route = useRoute()
const { warning } = useToast()
const authStore = useAuthStore() const authStore = useAuthStore()
// 判断是否是管理员页面 // 判断是否是管理员页面
@@ -128,6 +135,7 @@ const pageSize = ref(20)
const pageSizeOptions = [10, 20, 50, 100] const pageSizeOptions = [10, 20, 50, 100]
// 筛选状态 // 筛选状态
const filterSearch = ref('')
const filterUser = ref('__all__') const filterUser = ref('__all__')
const filterModel = ref('__all__') const filterModel = ref('__all__')
const filterProvider = ref('__all__') const filterProvider = ref('__all__')
@@ -144,13 +152,35 @@ const {
currentRecords, currentRecords,
totalRecords, totalRecords,
enhancedModelStats, enhancedModelStats,
activityHeatmapData,
availableModels, availableModels,
availableProviders, availableProviders,
loadStats, loadStats,
loadRecords loadRecords
} = useUsageData({ isAdminPage }) } = 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(() => { const filteredRecords = computed(() => {
if (!isAdminPage.value) { if (!isAdminPage.value) {
@@ -232,27 +262,40 @@ async function pollActiveRequests() {
? await usageApi.getActiveRequests(activeRequestIds.value) ? await usageApi.getActiveRequests(activeRequestIds.value)
: await meApi.getActiveRequests(idsParam) : await meApi.getActiveRequests(idsParam)
// 检查是否有状态变化 let shouldRefresh = false
let hasChanges = false
for (const update of requests) { for (const update of requests) {
const record = currentRecords.value.find(r => r.id === update.id) const record = currentRecords.value.find(r => r.id === update.id)
if (record && record.status !== update.status) { if (!record) {
hasChanges = true // 后端返回了未知的活跃请求,触发刷新以获取完整数据
// 如果状态变为 completed 或 failed需要刷新获取完整数据 shouldRefresh = true
if (update.status === 'completed' || update.status === 'failed') { continue
break }
}
// 否则只更新状态和 token 信息 // 状态变化completed/failed 需要刷新获取完整数据
if (record.status !== update.status) {
record.status = update.status record.status = update.status
record.input_tokens = update.input_tokens }
record.output_tokens = update.output_tokens if (update.status === 'completed' || update.status === 'failed') {
record.cost = update.cost shouldRefresh = true
record.response_time_ms = update.response_time_ms ?? undefined }
// 进行中状态也需要持续更新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 (shouldRefresh) {
if (hasChanges && requests.some(r => r.status === 'completed' || r.status === 'failed')) {
await refreshData() await refreshData()
} }
} catch (error) { } catch (error) {
@@ -335,16 +378,34 @@ const selectedRequestId = ref<string | null>(null)
// 初始化加载 // 初始化加载
onMounted(async () => { onMounted(async () => {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value) 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) { if (isAdminPage.value) {
// 并行加载用户列表和记录 // 管理员页面:并行加载用户列表和记录
const [users] = await Promise.all([ const [users] = await Promise.all([
usersApi.getAllUsers(), usersApi.getAllUsers(),
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters()) loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
]) ])
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email })) availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
} else {
// 用户页面:加载记录
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
} }
}) })
@@ -355,34 +416,26 @@ async function handlePeriodChange(value: string) {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value) const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange) await loadStats(dateRange)
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
} }
// 处理分页变化 // 处理分页变化
async function handlePageChange(page: number) { async function handlePageChange(page: number) {
currentPage.value = page currentPage.value = page
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
if (isAdminPage.value) {
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
}
} }
// 处理每页大小变化 // 处理每页大小变化
async function handlePageSizeChange(size: number) { async function handlePageSizeChange(size: number) {
pageSize.value = size pageSize.value = size
currentPage.value = 1 // 重置到第一页 currentPage.value = 1 // 重置到第一页
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
}
} }
// 获取当前筛选参数 // 获取当前筛选参数
function getCurrentFilters() { function getCurrentFilters() {
return { return {
search: filterSearch.value.trim() || undefined,
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined, user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
model: filterModel.value !== '__all__' ? filterModel.value : undefined, model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined, provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
@@ -391,6 +444,13 @@ function getCurrentFilters() {
} }
// 处理筛选变化 // 处理筛选变化
async function handleFilterSearchChange(value: string) {
filterSearch.value = value
currentPage.value = 1
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterUserChange(value: string) { async function handleFilterUserChange(value: string) {
filterUser.value = value filterUser.value = value
currentPage.value = 1 // 重置到第一页 currentPage.value = 1 // 重置到第一页
@@ -431,10 +491,7 @@ async function handleFilterStatusChange(value: string) {
async function refreshData() { async function refreshData() {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value) const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange) await loadStats(dateRange)
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
if (isAdminPage.value) {
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
}
} }
// 显示请求详情 // 显示请求详情

View File

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

View File

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

View File

@@ -3,22 +3,64 @@
独立余额Key不关联用户配额有独立余额限制用于给非注册用户使用。 独立余额Key不关联用户配额有独立余额限制用于给非注册用户使用。
""" """
from datetime import datetime, timezone import os
from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.pipeline import ApiRequestPipeline 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.core.logger import logger
from src.database import get_db from src.database import get_db
from src.models.api import CreateApiKeyRequest 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 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)"]) router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -215,6 +257,9 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
# 独立Key需要关联到管理员用户从context获取 # 独立Key需要关联到管理员用户从context获取
admin_user_id = context.user.id admin_user_id = context.user.id
# 解析过期时间(优先使用 expires_at其次使用 expire_days
expires_at_dt = parse_expiry_date(self.key_data.expires_at)
# 创建独立Key # 创建独立Key
api_key, plain_key = ApiKeyService.create_api_key( api_key, plain_key = ApiKeyService.create_api_key(
db=db, db=db,
@@ -224,7 +269,8 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
allowed_api_formats=self.key_data.allowed_api_formats, allowed_api_formats=self.key_data.allowed_api_formats,
allowed_models=self.key_data.allowed_models, allowed_models=self.key_data.allowed_models,
rate_limit=self.key_data.rate_limit, # None 表示不限制 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, initial_balance_usd=self.key_data.initial_balance_usd,
is_standalone=True, # 标记为独立Key is_standalone=True, # 标记为独立Key
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry, auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
@@ -270,7 +316,8 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
update_data = {} update_data = {}
if self.key_data.name is not None: if self.key_data.name is not None:
update_data["name"] = self.key_data.name 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 update_data["rate_limit"] = self.key_data.rate_limit
if ( if (
hasattr(self.key_data, "auto_delete_on_expiry") hasattr(self.key_data, "auto_delete_on_expiry")
@@ -287,19 +334,21 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
update_data["allowed_models"] = self.key_data.allowed_models update_data["allowed_models"] = self.key_data.allowed_models
# 处理过期时间 # 处理过期时间
if self.key_data.expire_days is not None: # 优先使用 expires_at如果显式传递且有值
if self.key_data.expire_days > 0: if self.key_data.expires_at and self.key_data.expires_at.strip():
from datetime import timedelta 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( update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
days=self.key_data.expire_days days=self.key_data.expire_days
) )
else: else:
# expire_days = 0 或负数表示永不过期 # expire_days = None/0/负数 表示永不过期
update_data["expires_at"] = None 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 更新 # 使用 ApiKeyService 更新
updated_key = ApiKeyService.update_api_key(db, self.key_id, **update_data) updated_key = ApiKeyService.update_api_key(db, self.key_id, **update_data)

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
提供商策略管理 API 端点 提供商策略管理 API 端点
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
@@ -103,6 +103,9 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
if config.quota_last_reset_at: if config.quota_last_reset_at:
new_reset_at = parser.parse(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 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}") logger.info(f"Synced usage for provider {provider.name}: ${period_usage:.4f} since {new_reset_at}")
if config.quota_expires_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.commit()
db.refresh(provider) db.refresh(provider)
@@ -149,7 +156,7 @@ class AdminProviderStatsAdapter(AdminApiAdapter):
if not provider: if not provider:
raise HTTPException(status_code=404, detail="Provider not found") 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 = ( stats = (
db.query(ProviderUsageTracking) db.query(ProviderUsageTracking)
.filter( .filter(

View File

@@ -1,5 +1,7 @@
"""系统设置API端点。""" """系统设置API端点。"""
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@@ -13,9 +15,50 @@ from src.core.exceptions import InvalidRequestException, NotFoundException, tran
from src.database import get_db from src.database import get_db
from src.models.api import SystemSettingsRequest, SystemSettingsResponse from src.models.api import SystemSettingsRequest, SystemSettingsResponse
from src.models.database import ApiKey, Provider, Usage, User from src.models.database import ApiKey, Provider, Usage, User
from src.services.email.email_template import EmailTemplate
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"]) router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
def _get_version_from_git() -> str | None:
"""从 git describe 获取版本号"""
import subprocess
try:
result = subprocess.run(
["git", "describe", "--tags", "--always"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
version = result.stdout.strip()
if version.startswith("v"):
version = version[1:]
return version
except Exception:
pass
return None
@router.get("/version")
async def get_system_version():
"""获取系统版本信息"""
# 优先从 git 获取
version = _get_version_from_git()
if version:
return {"version": version}
# 回退到静态版本文件
try:
from src._version import __version__
return {"version": __version__}
except ImportError:
return {"version": "unknown"}
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -119,6 +162,59 @@ async def import_users(request: Request, db: Session = Depends(get_db)):
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/smtp/test")
async def test_smtp(request: Request, db: Session = Depends(get_db)):
"""测试 SMTP 连接(管理员)"""
adapter = AdminTestSmtpAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# -------- 邮件模板 API --------
@router.get("/email/templates")
async def get_email_templates(request: Request, db: Session = Depends(get_db)):
"""获取所有邮件模板(管理员)"""
adapter = AdminGetEmailTemplatesAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/email/templates/{template_type}")
async def get_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""获取指定类型的邮件模板(管理员)"""
adapter = AdminGetEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/email/templates/{template_type}")
async def update_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""更新邮件模板(管理员)"""
adapter = AdminUpdateEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/email/templates/{template_type}/preview")
async def preview_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""预览邮件模板(管理员)"""
adapter = AdminPreviewEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/email/templates/{template_type}/reset")
async def reset_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""重置邮件模板为默认值(管理员)"""
adapter = AdminResetEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# -------- 系统设置适配器 -------- # -------- 系统设置适配器 --------
@@ -196,10 +292,16 @@ class AdminGetAllConfigsAdapter(AdminApiAdapter):
class AdminGetSystemConfigAdapter(AdminApiAdapter): class AdminGetSystemConfigAdapter(AdminApiAdapter):
key: str key: str
# 敏感配置项,不返回实际值
SENSITIVE_KEYS = {"smtp_password"}
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
value = SystemConfigService.get_config(context.db, self.key) value = SystemConfigService.get_config(context.db, self.key)
if value is None: if value is None:
raise NotFoundException(f"配置项 '{self.key}' 不存在") raise NotFoundException(f"配置项 '{self.key}' 不存在")
# 对敏感配置,只返回是否已设置的标志,不返回实际值
if self.key in self.SENSITIVE_KEYS:
return {"key": self.key, "value": None, "is_set": bool(value)}
return {"key": self.key, "value": value} return {"key": self.key, "value": value}
@@ -207,18 +309,31 @@ class AdminGetSystemConfigAdapter(AdminApiAdapter):
class AdminSetSystemConfigAdapter(AdminApiAdapter): class AdminSetSystemConfigAdapter(AdminApiAdapter):
key: str key: str
# 需要加密存储的配置项
ENCRYPTED_KEYS = {"smtp_password"}
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
payload = context.ensure_json_body() payload = context.ensure_json_body()
value = payload.get("value")
# 对敏感配置进行加密
if self.key in self.ENCRYPTED_KEYS and value:
from src.core.crypto import crypto_service
value = crypto_service.encrypt(value)
config = SystemConfigService.set_config( config = SystemConfigService.set_config(
context.db, context.db,
self.key, self.key,
payload.get("value"), value,
payload.get("description"), payload.get("description"),
) )
# 返回时不暴露加密后的值
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
return { return {
"key": config.key, "key": config.key,
"value": config.value, "value": display_value,
"description": config.description, "description": config.description,
"updated_at": config.updated_at.isoformat(), "updated_at": config.updated_at.isoformat(),
} }
@@ -877,6 +992,31 @@ class AdminExportUsersAdapter(AdminApiAdapter):
db = context.db db = context.db
def _serialize_api_key(key: ApiKey, include_is_standalone: bool = False) -> dict:
"""序列化 API Key 为导出格式"""
data = {
"key_hash": key.key_hash,
"key_encrypted": key.key_encrypted,
"name": key.name,
"balance_used_usd": key.balance_used_usd,
"current_balance_usd": key.current_balance_usd,
"allowed_providers": key.allowed_providers,
"allowed_endpoints": key.allowed_endpoints,
"allowed_api_formats": key.allowed_api_formats,
"allowed_models": key.allowed_models,
"rate_limit": key.rate_limit,
"concurrent_limit": key.concurrent_limit,
"force_capabilities": key.force_capabilities,
"is_active": key.is_active,
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
"auto_delete_on_expiry": key.auto_delete_on_expiry,
"total_requests": key.total_requests,
"total_cost_usd": key.total_cost_usd,
}
if include_is_standalone:
data["is_standalone"] = key.is_standalone
return data
# 导出 Users排除管理员 # 导出 Users排除管理员
users = db.query(User).filter( users = db.query(User).filter(
User.is_deleted.is_(False), User.is_deleted.is_(False),
@@ -884,31 +1024,12 @@ class AdminExportUsersAdapter(AdminApiAdapter):
).all() ).all()
users_data = [] users_data = []
for user in users: for user in users:
# 导出用户的 API Keys保留加密数据 # 导出用户的 API Keys排除独立余额Key独立Key单独导出
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all() api_keys = db.query(ApiKey).filter(
api_keys_data = [] ApiKey.user_id == user.id,
for key in api_keys: ApiKey.is_standalone.is_(False)
api_keys_data.append( ).all()
{ api_keys_data = [_serialize_api_key(key, include_is_standalone=True) for key in api_keys]
"key_hash": key.key_hash,
"key_encrypted": key.key_encrypted,
"name": key.name,
"is_standalone": key.is_standalone,
"balance_used_usd": key.balance_used_usd,
"current_balance_usd": key.current_balance_usd,
"allowed_providers": key.allowed_providers,
"allowed_endpoints": key.allowed_endpoints,
"allowed_api_formats": key.allowed_api_formats,
"allowed_models": key.allowed_models,
"rate_limit": key.rate_limit,
"concurrent_limit": key.concurrent_limit,
"force_capabilities": key.force_capabilities,
"is_active": key.is_active,
"auto_delete_on_expiry": key.auto_delete_on_expiry,
"total_requests": key.total_requests,
"total_cost_usd": key.total_cost_usd,
}
)
users_data.append( users_data.append(
{ {
@@ -928,10 +1049,15 @@ class AdminExportUsersAdapter(AdminApiAdapter):
} }
) )
# 导出独立余额 Keys管理员创建的不属于普通用户
standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all()
standalone_keys_data = [_serialize_api_key(key) for key in standalone_keys]
return { return {
"version": "1.0", "version": "1.1",
"exported_at": datetime.now(timezone.utc).isoformat(), "exported_at": datetime.now(timezone.utc).isoformat(),
"users": users_data, "users": users_data,
"standalone_keys": standalone_keys_data,
} }
@@ -951,21 +1077,72 @@ class AdminImportUsersAdapter(AdminApiAdapter):
db = context.db db = context.db
payload = context.ensure_json_body() payload = context.ensure_json_body()
# 验证配置版本
version = payload.get("version")
if version != "1.0":
raise InvalidRequestException(f"不支持的配置版本: {version}")
# 获取导入选项 # 获取导入选项
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
users_data = payload.get("users", []) users_data = payload.get("users", [])
standalone_keys_data = payload.get("standalone_keys", [])
stats = { stats = {
"users": {"created": 0, "updated": 0, "skipped": 0}, "users": {"created": 0, "updated": 0, "skipped": 0},
"api_keys": {"created": 0, "skipped": 0}, "api_keys": {"created": 0, "skipped": 0},
"standalone_keys": {"created": 0, "skipped": 0},
"errors": [], "errors": [],
} }
def _create_api_key_from_data(
key_data: dict,
owner_id: str,
is_standalone: bool = False,
) -> tuple[ApiKey | None, str]:
"""从导入数据创建 ApiKey 对象
Returns:
(ApiKey, "created"): 成功创建
(None, "skipped"): key 已存在,跳过
(None, "invalid"): 数据无效,跳过
"""
key_hash = key_data.get("key_hash", "").strip()
if not key_hash:
return None, "invalid"
# 检查是否已存在
existing = db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
if existing:
return None, "skipped"
# 解析 expires_at
expires_at = None
if key_data.get("expires_at"):
try:
expires_at = datetime.fromisoformat(key_data["expires_at"])
except ValueError:
stats["errors"].append(
f"API Key '{key_data.get('name', key_hash[:8])}' 的 expires_at 格式无效"
)
return ApiKey(
id=str(uuid.uuid4()),
user_id=owner_id,
key_hash=key_hash,
key_encrypted=key_data.get("key_encrypted"),
name=key_data.get("name"),
is_standalone=is_standalone or key_data.get("is_standalone", False),
balance_used_usd=key_data.get("balance_used_usd", 0.0),
current_balance_usd=key_data.get("current_balance_usd"),
allowed_providers=key_data.get("allowed_providers"),
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"),
concurrent_limit=key_data.get("concurrent_limit", 5),
force_capabilities=key_data.get("force_capabilities"),
is_active=key_data.get("is_active", True),
expires_at=expires_at,
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
total_requests=key_data.get("total_requests", 0),
total_cost_usd=key_data.get("total_cost_usd", 0.0),
), "created"
try: try:
for user_data in users_data: for user_data in users_data:
# 跳过管理员角色的导入(不区分大小写) # 跳过管理员角色的导入(不区分大小写)
@@ -1036,40 +1213,31 @@ class AdminImportUsersAdapter(AdminApiAdapter):
# 导入 API Keys # 导入 API Keys
for key_data in user_data.get("api_keys", []): for key_data in user_data.get("api_keys", []):
# 检查是否已存在相同的 key_hash new_key, status = _create_api_key_from_data(key_data, user_id)
if key_data.get("key_hash"): if new_key:
existing_key = ( db.add(new_key)
db.query(ApiKey) stats["api_keys"]["created"] += 1
.filter(ApiKey.key_hash == key_data["key_hash"]) elif status == "skipped":
.first() stats["api_keys"]["skipped"] += 1
) # invalid 数据不计入统计
if existing_key:
stats["api_keys"]["skipped"] += 1
continue
new_key = ApiKey( # 导入独立余额 Keys需要找一个管理员用户作为 owner
id=str(uuid.uuid4()), if standalone_keys_data:
user_id=user_id, # 查找一个管理员用户作为独立Key的owner
key_hash=key_data.get("key_hash", ""), admin_user = db.query(User).filter(User.role == UserRole.ADMIN).first()
key_encrypted=key_data.get("key_encrypted"), if not admin_user:
name=key_data.get("name"), stats["errors"].append("无法导入独立余额Key: 系统中没有管理员用户")
is_standalone=key_data.get("is_standalone", False), else:
balance_used_usd=key_data.get("balance_used_usd", 0.0), for key_data in standalone_keys_data:
current_balance_usd=key_data.get("current_balance_usd"), new_key, status = _create_api_key_from_data(
allowed_providers=key_data.get("allowed_providers"), key_data, admin_user.id, is_standalone=True
allowed_endpoints=key_data.get("allowed_endpoints"), )
allowed_api_formats=key_data.get("allowed_api_formats"), if new_key:
allowed_models=key_data.get("allowed_models"), db.add(new_key)
rate_limit=key_data.get("rate_limit", 100), stats["standalone_keys"]["created"] += 1
concurrent_limit=key_data.get("concurrent_limit", 5), elif status == "skipped":
force_capabilities=key_data.get("force_capabilities"), stats["standalone_keys"]["skipped"] += 1
is_active=key_data.get("is_active", True), # invalid 数据不计入统计
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
total_requests=key_data.get("total_requests", 0),
total_cost_usd=key_data.get("total_cost_usd", 0.0),
)
db.add(new_key)
stats["api_keys"]["created"] += 1
db.commit() db.commit()
@@ -1084,3 +1252,265 @@ class AdminImportUsersAdapter(AdminApiAdapter):
except Exception as e: except Exception as e:
db.rollback() db.rollback()
raise InvalidRequestException(f"导入失败: {str(e)}") raise InvalidRequestException(f"导入失败: {str(e)}")
class AdminTestSmtpAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override]
"""测试 SMTP 连接"""
from src.core.crypto import crypto_service
from src.services.system.config import SystemConfigService
from src.services.email.email_sender import EmailSenderService
db = context.db
payload = context.ensure_json_body() or {}
# 获取密码:优先使用前端传入的明文密码,否则从数据库获取并解密
smtp_password = payload.get("smtp_password")
if not smtp_password:
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
if encrypted_password:
try:
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
except Exception:
# 解密失败,可能是旧的未加密密码
smtp_password = encrypted_password
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
config = {
"smtp_host": payload.get("smtp_host") or SystemConfigService.get_config(db, "smtp_host"),
"smtp_port": payload.get("smtp_port") or SystemConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": payload.get("smtp_user") or SystemConfigService.get_config(db, "smtp_user"),
"smtp_password": smtp_password,
"smtp_use_tls": payload.get("smtp_use_tls")
if payload.get("smtp_use_tls") is not None
else SystemConfigService.get_config(db, "smtp_use_tls", default=True),
"smtp_use_ssl": payload.get("smtp_use_ssl")
if payload.get("smtp_use_ssl") is not None
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
"smtp_from_email": payload.get("smtp_from_email")
or SystemConfigService.get_config(db, "smtp_from_email"),
"smtp_from_name": payload.get("smtp_from_name")
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
}
# 验证必要配置
missing_fields = [
field for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"] if not config.get(field)
]
if missing_fields:
return {
"success": False,
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}"
}
# 测试连接
try:
success, error_msg = await EmailSenderService.test_smtp_connection(
db=db, override_config=config
)
if success:
return {
"success": True,
"message": "SMTP 连接测试成功"
}
else:
return {
"success": False,
"message": error_msg
}
except Exception as e:
return {
"success": False,
"message": str(e)
}
# -------- 邮件模板适配器 --------
class AdminGetEmailTemplatesAdapter(AdminApiAdapter):
"""获取所有邮件模板"""
async def handle(self, context): # type: ignore[override]
db = context.db
templates = []
for template_type, type_info in EmailTemplate.TEMPLATE_TYPES.items():
# 获取自定义模板或默认模板
template = EmailTemplate.get_template(db, template_type)
default_template = EmailTemplate.get_default_template(template_type)
# 检查是否使用了自定义模板
is_custom = (
template["subject"] != default_template["subject"]
or template["html"] != default_template["html"]
)
templates.append(
{
"type": template_type,
"name": type_info["name"],
"variables": type_info["variables"],
"subject": template["subject"],
"html": template["html"],
"is_custom": is_custom,
}
)
return {"templates": templates}
@dataclass
class AdminGetEmailTemplateAdapter(AdminApiAdapter):
"""获取指定类型的邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
template = EmailTemplate.get_template(db, self.template_type)
default_template = EmailTemplate.get_default_template(self.template_type)
is_custom = (
template["subject"] != default_template["subject"]
or template["html"] != default_template["html"]
)
return {
"type": self.template_type,
"name": type_info["name"],
"variables": type_info["variables"],
"subject": template["subject"],
"html": template["html"],
"is_custom": is_custom,
"default_subject": default_template["subject"],
"default_html": default_template["html"],
}
@dataclass
class AdminUpdateEmailTemplateAdapter(AdminApiAdapter):
"""更新邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
payload = context.ensure_json_body()
subject = payload.get("subject")
html = payload.get("html")
# 至少需要提供一个字段
if subject is None and html is None:
raise InvalidRequestException("请提供 subject 或 html")
# 保存模板
subject_key = f"email_template_{self.template_type}_subject"
html_key = f"email_template_{self.template_type}_html"
if subject is not None:
if subject:
SystemConfigService.set_config(db, subject_key, subject)
else:
# 空字符串表示删除自定义值,恢复默认
SystemConfigService.delete_config(db, subject_key)
if html is not None:
if html:
SystemConfigService.set_config(db, html_key, html)
else:
SystemConfigService.delete_config(db, html_key)
return {"message": "模板保存成功"}
@dataclass
class AdminPreviewEmailTemplateAdapter(AdminApiAdapter):
"""预览邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
payload = context.ensure_json_body() or {}
# 获取模板 HTML优先使用请求体中的否则使用数据库中的
html = payload.get("html")
if not html:
template = EmailTemplate.get_template(db, self.template_type)
html = template["html"]
# 获取预览变量
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
# 构建预览变量,使用请求中的值或默认示例值
preview_variables = {}
default_values = {
"app_name": SystemConfigService.get_config(db, "email_app_name")
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
"code": "123456",
"expire_minutes": "30",
"email": "example@example.com",
"reset_link": "https://example.com/reset?token=abc123",
}
for var in type_info["variables"]:
preview_variables[var] = payload.get(var, default_values.get(var, f"{{{{{var}}}}}"))
# 渲染模板
rendered_html = EmailTemplate.render_template(html, preview_variables)
return {
"html": rendered_html,
"variables": preview_variables,
}
@dataclass
class AdminResetEmailTemplateAdapter(AdminApiAdapter):
"""重置邮件模板为默认值"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
# 删除自定义模板
subject_key = f"email_template_{self.template_type}_subject"
html_key = f"email_template_{self.template_type}_html"
SystemConfigService.delete_config(db, subject_key)
SystemConfigService.delete_config(db, html_key)
# 返回默认模板
default_template = EmailTemplate.get_default_template(self.template_type)
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
return {
"message": "模板已重置为默认值",
"template": {
"type": self.template_type,
"name": type_info["name"],
"subject": default_template["subject"],
"html": default_template["html"],
},
}

View File

@@ -73,11 +73,26 @@ async def get_usage_stats(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/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") @router.get("/records")
async def get_usage_records( async def get_usage_records(
request: Request, request: Request,
start_date: Optional[datetime] = None, start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None, end_date: Optional[datetime] = None,
search: Optional[str] = None, # 通用搜索:用户名、密钥名、模型名、提供商名
user_id: Optional[str] = None, user_id: Optional[str] = None,
username: Optional[str] = None, username: Optional[str] = None,
model: Optional[str] = None, model: Optional[str] = None,
@@ -90,6 +105,7 @@ async def get_usage_records(
adapter = AdminUsageRecordsAdapter( adapter = AdminUsageRecordsAdapter(
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
search=search,
user_id=user_id, user_id=user_id,
username=username, username=username,
model=model, model=model,
@@ -168,12 +184,6 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)) (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
).count() ).count()
activity_heatmap = UsageService.get_daily_activity(
db=db,
window_days=365,
include_actual_cost=True,
)
context.add_audit_metadata( context.add_audit_metadata(
action="usage_stats", action="usage_stats",
start_date=self.start_date.isoformat() if self.start_date else None, start_date=self.start_date.isoformat() if self.start_date else None,
@@ -204,10 +214,22 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
), ),
"cache_read_cost": float(cache_stats.cache_read_cost or 0) if cache_stats else 0, "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): class AdminUsageByModelAdapter(AdminApiAdapter):
def __init__(self, start_date: Optional[datetime], end_date: Optional[datetime], limit: int): def __init__(self, start_date: Optional[datetime], end_date: Optional[datetime], limit: int):
self.start_date = start_date self.start_date = start_date
@@ -480,6 +502,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
self, self,
start_date: Optional[datetime], start_date: Optional[datetime],
end_date: Optional[datetime], end_date: Optional[datetime],
search: Optional[str],
user_id: Optional[str], user_id: Optional[str],
username: Optional[str], username: Optional[str],
model: Optional[str], model: Optional[str],
@@ -490,6 +513,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
): ):
self.start_date = start_date self.start_date = start_date
self.end_date = end_date self.end_date = end_date
self.search = search
self.user_id = user_id self.user_id = user_id
self.username = username self.username = username
self.model = model self.model = model
@@ -499,25 +523,54 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
self.offset = offset self.offset = offset
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
from sqlalchemy import or_
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
db = context.db db = context.db
query = ( query = (
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey) db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
.outerjoin(User, Usage.user_id == User.id) .outerjoin(User, Usage.user_id == User.id)
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id) .outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id) .outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
) )
# 如果需要按 Provider 名称搜索/筛选,统一在这里 JOIN
if self.search or self.provider:
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True)
# 通用搜索:用户名、密钥名、模型名、提供商名
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
# 限制:最多 10 个关键词,转义后每个关键词最长 100 字符
if self.search:
keywords = [kw for kw in self.search.strip().split() if kw][:10]
for keyword in keywords:
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
search_pattern = f"%{escaped}%"
query = query.filter(
or_(
User.username.ilike(search_pattern, escape="\\"),
ApiKey.name.ilike(search_pattern, escape="\\"),
Usage.model.ilike(search_pattern, escape="\\"),
Provider.name.ilike(search_pattern, escape="\\"),
)
)
if self.user_id: if self.user_id:
query = query.filter(Usage.user_id == self.user_id) query = query.filter(Usage.user_id == self.user_id)
if self.username: if self.username:
# 支持用户名模糊搜索 # 支持用户名模糊搜索
query = query.filter(User.username.ilike(f"%{self.username}%")) escaped = escape_like_pattern(self.username)
query = query.filter(User.username.ilike(f"%{escaped}%", escape="\\"))
if self.model: if self.model:
# 支持模型名模糊搜索 # 支持模型名模糊搜索
query = query.filter(Usage.model.ilike(f"%{self.model}%")) escaped = escape_like_pattern(self.model)
query = query.filter(Usage.model.ilike(f"%{escaped}%", escape="\\"))
if self.provider: if self.provider:
# 支持提供商名称搜索(通过 Provider 表) # 支持提供商名称搜索
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True) escaped = escape_like_pattern(self.provider)
query = query.filter(Provider.name.ilike(f"%{self.provider}%")) query = query.filter(Provider.name.ilike(f"%{escaped}%", escape="\\"))
if self.status: if self.status:
# 状态筛选 # 状态筛选
# 旧的筛选值(基于 is_stream 和 status_codestream, standard, error # 旧的筛选值(基于 is_stream 和 status_codestream, standard, error
@@ -555,7 +608,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all() query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
) )
request_ids = [usage.request_id for usage, _, _, _ in records if usage.request_id] request_ids = [usage.request_id for usage, _, _, _, _ in records if usage.request_id]
fallback_map = {} fallback_map = {}
if request_ids: if request_ids:
# 只统计实际执行的候选success 或 failed不包括 skipped/pending/available # 只统计实际执行的候选success 或 failed不包括 skipped/pending/available
@@ -575,6 +628,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
action="usage_records", action="usage_records",
start_date=self.start_date.isoformat() if self.start_date else None, start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None, end_date=self.end_date.isoformat() if self.end_date else None,
search=self.search,
user_id=self.user_id, user_id=self.user_id,
username=self.username, username=self.username,
model=self.model, model=self.model,
@@ -586,7 +640,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
) )
# 构建 provider_id -> Provider 名称的映射,避免 N+1 查询 # 构建 provider_id -> Provider 名称的映射,避免 N+1 查询
provider_ids = [usage.provider_id for usage, _, _, _ in records if usage.provider_id] provider_ids = [usage.provider_id for usage, _, _, _, _ in records if usage.provider_id]
provider_map = {} provider_map = {}
if provider_ids: if provider_ids:
providers_data = ( providers_data = (
@@ -595,7 +649,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
provider_map = {str(p.id): p.name for p in providers_data} provider_map = {str(p.id): p.name for p in providers_data}
data = [] data = []
for usage, user, endpoint, api_key in records: for usage, user, endpoint, provider_api_key, user_api_key in records:
actual_cost = ( actual_cost = (
float(usage.actual_total_cost_usd) float(usage.actual_total_cost_usd)
if usage.actual_total_cost_usd is not None if usage.actual_total_cost_usd is not None
@@ -616,6 +670,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"user_id": user.id if user else None, "user_id": user.id if user else None,
"user_email": user.email if user else "已删除用户", "user_email": user.email if user else "已删除用户",
"username": user.username if user else "已删除用户", "username": user.username if user else "已删除用户",
"api_key": (
{
"id": user_api_key.id,
"name": user_api_key.name,
"display": user_api_key.get_display_key(),
}
if user_api_key
else None
),
"provider": provider_name, "provider": provider_name,
"model": usage.model, "model": usage.model,
"target_model": usage.target_model, # 映射后的目标模型名 "target_model": usage.target_model, # 映射后的目标模型名
@@ -641,7 +704,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"has_fallback": fallback_map.get(usage.request_id, False), "has_fallback": fallback_map.get(usage.request_id, False),
"api_format": usage.api_format "api_format": usage.api_format
or (endpoint.api_format if endpoint and endpoint.api_format else None), or (endpoint.api_format if endpoint and endpoint.api_format else None),
"api_key_name": api_key.name if api_key else None, "api_key_name": provider_api_key.name if provider_api_key else None,
"request_metadata": usage.request_metadata, # Provider 响应元数据 "request_metadata": usage.request_metadata, # Provider 响应元数据
} }
) )
@@ -670,7 +733,9 @@ class AdminActiveRequestsAdapter(AdminApiAdapter):
if not id_list: if not id_list:
return {"requests": []} 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} return {"requests": requests}

View File

@@ -248,6 +248,7 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
update_data = request.model_dump(exclude_unset=True) update_data = request.model_dump(exclude_unset=True)
old_role = existing_user.role
if "role" in update_data and update_data["role"]: if "role" in update_data and update_data["role"]:
if hasattr(update_data["role"], "value"): if hasattr(update_data["role"], "value"):
update_data["role"] = update_data["role"] update_data["role"] = update_data["role"]
@@ -258,6 +259,12 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
if not user: if not user:
raise NotFoundException("用户不存在", "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()) changed_fields = list(update_data.keys())
context.add_audit_metadata( context.add_audit_metadata(
action="update_user", action="update_user",
@@ -424,7 +431,7 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
name=key_data.name, name=key_data.name,
allowed_providers=key_data.allowed_providers, allowed_providers=key_data.allowed_providers,
allowed_models=key_data.allowed_models, 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, expire_days=key_data.expire_days,
initial_balance_usd=None, # 普通Key不设置余额限制 initial_balance_usd=None, # 普通Key不设置余额限制
is_standalone=False, # 不是独立Key is_standalone=False, # 不是独立Key

View File

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

View File

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

View File

@@ -47,7 +47,6 @@ if TYPE_CHECKING:
from src.api.handlers.base.stream_context import StreamContext from src.api.handlers.base.stream_context import StreamContext
class MessageTelemetry: class MessageTelemetry:
""" """
负责记录 Usage/Audit避免处理器里重复代码。 负责记录 Usage/Audit避免处理器里重复代码。
@@ -406,7 +405,7 @@ class BaseMessageHandler:
asyncio.create_task(_do_update()) asyncio.create_task(_do_update())
def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None: def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None:
"""更新 Usage 状态为 streaming同时更新 provider 和 target_model """更新 Usage 状态为 streaming同时更新 provider 相关信息
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输 使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
@@ -414,7 +413,7 @@ class BaseMessageHandler:
并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。 并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。
Args: Args:
ctx: 流式上下文,包含 provider_name 和 mapped_model ctx: 流式上下文,包含 provider 相关信息
""" """
import asyncio import asyncio
from src.database.database import get_db from src.database.database import get_db
@@ -422,6 +421,17 @@ class BaseMessageHandler:
target_request_id = self.request_id target_request_id = self.request_id
provider = ctx.provider_name provider = ctx.provider_name
target_model = ctx.mapped_model 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: async def _do_update() -> None:
try: try:
@@ -434,6 +444,10 @@ class BaseMessageHandler:
status="streaming", status="streaming",
provider=provider, provider=provider,
target_model=target_model, 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: finally:
db.close() db.close()

View File

@@ -40,6 +40,7 @@ from src.core.exceptions import (
UpstreamClientException, UpstreamClientException,
) )
from src.core.logger import logger from src.core.logger import logger
from src.services.billing import calculate_request_cost as _calculate_request_cost
from src.services.request.result import RequestResult from src.services.request.result import RequestResult
from src.services.usage.recorder import UsageRecorder from src.services.usage.recorder import UsageRecorder
@@ -63,6 +64,9 @@ class ChatAdapterBase(ApiAdapter):
name: str = "chat.base" name: str = "chat.base"
mode = ApiMode.STANDARD mode = ApiMode.STANDARD
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini"
BILLING_TEMPLATE: str = "claude"
# 子类可以配置的特殊方法用于check_endpoint # 子类可以配置的特殊方法用于check_endpoint
@classmethod @classmethod
def build_endpoint_url(cls, base_url: str) -> str: def build_endpoint_url(cls, base_url: str) -> str:
@@ -486,40 +490,6 @@ class ChatAdapterBase(ApiAdapter):
""" """
return input_tokens + cache_read_input_tokens return input_tokens + cache_read_input_tokens
def get_cache_read_price_for_ttl(
self,
tier: dict,
cache_ttl_minutes: Optional[int] = None,
) -> Optional[float]:
"""
根据缓存 TTL 获取缓存读取价格
默认实现:检查 cache_ttl_pricing 配置,按 TTL 选择价格
子类可覆盖此方法实现不同的 TTL 定价逻辑
Args:
tier: 当前阶梯配置
cache_ttl_minutes: 缓存时长(分钟)
Returns:
缓存读取价格(每 1M tokens
"""
ttl_pricing = tier.get("cache_ttl_pricing")
if ttl_pricing and cache_ttl_minutes is not None:
matched_price = None
for ttl_config in ttl_pricing:
ttl_limit = ttl_config.get("ttl_minutes", 0)
if cache_ttl_minutes <= ttl_limit:
matched_price = ttl_config.get("cache_read_price_per_1m")
break
if matched_price is not None:
return matched_price
# 超过所有配置的 TTL使用最后一个
if ttl_pricing:
return ttl_pricing[-1].get("cache_read_price_per_1m")
return tier.get("cache_read_price_per_1m")
def compute_cost( def compute_cost(
self, self,
input_tokens: int, input_tokens: int,
@@ -537,8 +507,9 @@ class ChatAdapterBase(ApiAdapter):
""" """
计算请求成本 计算请求成本
默认实现:支持固定价格和阶梯计费 使用 billing 模块的配置驱动计费
子类可覆盖此方法实现完全不同的计费逻辑 子类可通过设置 BILLING_TEMPLATE 类属性来指定计费模板,
或覆盖此方法实现完全自定义的计费逻辑。
Args: Args:
input_tokens: 输入 token 数 input_tokens: 输入 token 数
@@ -566,88 +537,26 @@ class ChatAdapterBase(ApiAdapter):
"tier_index": Optional[int], # 命中的阶梯索引 "tier_index": Optional[int], # 命中的阶梯索引
} }
""" """
tier_index = None # 计算总输入上下文(使用子类可覆盖的方法)
effective_input_price = input_price_per_1m total_input_context = self.compute_total_input_context(
effective_output_price = output_price_per_1m input_tokens, cache_read_input_tokens, cache_creation_input_tokens
effective_cache_creation_price = cache_creation_price_per_1m )
effective_cache_read_price = cache_read_price_per_1m
# 检查阶梯计费 return _calculate_request_cost(
if tiered_pricing and tiered_pricing.get("tiers"): input_tokens=input_tokens,
total_input_context = self.compute_total_input_context( output_tokens=output_tokens,
input_tokens, cache_read_input_tokens, cache_creation_input_tokens cache_creation_input_tokens=cache_creation_input_tokens,
) cache_read_input_tokens=cache_read_input_tokens,
tier = self._get_tier_for_tokens(tiered_pricing, total_input_context) input_price_per_1m=input_price_per_1m,
output_price_per_1m=output_price_per_1m,
if tier: cache_creation_price_per_1m=cache_creation_price_per_1m,
tier_index = tiered_pricing["tiers"].index(tier) cache_read_price_per_1m=cache_read_price_per_1m,
effective_input_price = tier.get("input_price_per_1m", input_price_per_1m) price_per_request=price_per_request,
effective_output_price = tier.get("output_price_per_1m", output_price_per_1m) tiered_pricing=tiered_pricing,
effective_cache_creation_price = tier.get( cache_ttl_minutes=cache_ttl_minutes,
"cache_creation_price_per_1m", cache_creation_price_per_1m total_input_context=total_input_context,
) billing_template=self.BILLING_TEMPLATE,
effective_cache_read_price = self.get_cache_read_price_for_ttl( )
tier, cache_ttl_minutes
)
if effective_cache_read_price is None:
effective_cache_read_price = cache_read_price_per_1m
# 计算各项成本
input_cost = (input_tokens / 1_000_000) * effective_input_price
output_cost = (output_tokens / 1_000_000) * effective_output_price
cache_creation_cost = 0.0
cache_read_cost = 0.0
if cache_creation_input_tokens > 0 and effective_cache_creation_price is not None:
cache_creation_cost = (
cache_creation_input_tokens / 1_000_000
) * effective_cache_creation_price
if cache_read_input_tokens > 0 and effective_cache_read_price is not None:
cache_read_cost = (
cache_read_input_tokens / 1_000_000
) * effective_cache_read_price
cache_cost = cache_creation_cost + cache_read_cost
request_cost = price_per_request if price_per_request else 0.0
total_cost = input_cost + output_cost + cache_cost + request_cost
return {
"input_cost": input_cost,
"output_cost": output_cost,
"cache_creation_cost": cache_creation_cost,
"cache_read_cost": cache_read_cost,
"cache_cost": cache_cost,
"request_cost": request_cost,
"total_cost": total_cost,
"tier_index": tier_index,
}
@staticmethod
def _get_tier_for_tokens(tiered_pricing: dict, total_input_tokens: int) -> Optional[dict]:
"""
根据总输入 token 数确定价格阶梯
Args:
tiered_pricing: 阶梯计费配置 {"tiers": [...]}
total_input_tokens: 总输入 token 数
Returns:
匹配的阶梯配置
"""
if not tiered_pricing or "tiers" not in tiered_pricing:
return None
tiers = tiered_pricing.get("tiers", [])
if not tiers:
return None
for tier in tiers:
up_to = tier.get("up_to")
if up_to is None or total_input_tokens <= up_to:
return tier
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
return tiers[-1] if tiers else None
# ========================================================================= # =========================================================================
# 模型列表查询 - 子类应覆盖此方法 # 模型列表查询 - 子类应覆盖此方法

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

View File

@@ -38,6 +38,7 @@ from src.core.exceptions import (
UpstreamClientException, UpstreamClientException,
) )
from src.core.logger import logger from src.core.logger import logger
from src.services.billing import calculate_request_cost as _calculate_request_cost
from src.services.request.result import RequestResult from src.services.request.result import RequestResult
from src.services.usage.recorder import UsageRecorder from src.services.usage.recorder import UsageRecorder
@@ -61,6 +62,9 @@ class CliAdapterBase(ApiAdapter):
name: str = "cli.base" name: str = "cli.base"
mode = ApiMode.PROXY mode = ApiMode.PROXY
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini"
BILLING_TEMPLATE: str = "claude"
def __init__(self, allowed_api_formats: Optional[list[str]] = None): def __init__(self, allowed_api_formats: Optional[list[str]] = None):
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID] self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
@@ -438,40 +442,6 @@ class CliAdapterBase(ApiAdapter):
""" """
return input_tokens + cache_read_input_tokens return input_tokens + cache_read_input_tokens
def get_cache_read_price_for_ttl(
self,
tier: dict,
cache_ttl_minutes: Optional[int] = None,
) -> Optional[float]:
"""
根据缓存 TTL 获取缓存读取价格
默认实现:检查 cache_ttl_pricing 配置,按 TTL 选择价格
子类可覆盖此方法实现不同的 TTL 定价逻辑
Args:
tier: 当前阶梯配置
cache_ttl_minutes: 缓存时长(分钟)
Returns:
缓存读取价格(每 1M tokens
"""
ttl_pricing = tier.get("cache_ttl_pricing")
if ttl_pricing and cache_ttl_minutes is not None:
matched_price = None
for ttl_config in ttl_pricing:
ttl_limit = ttl_config.get("ttl_minutes", 0)
if cache_ttl_minutes <= ttl_limit:
matched_price = ttl_config.get("cache_read_price_per_1m")
break
if matched_price is not None:
return matched_price
# 超过所有配置的 TTL使用最后一个
if ttl_pricing:
return ttl_pricing[-1].get("cache_read_price_per_1m")
return tier.get("cache_read_price_per_1m")
def compute_cost( def compute_cost(
self, self,
input_tokens: int, input_tokens: int,
@@ -489,8 +459,9 @@ class CliAdapterBase(ApiAdapter):
""" """
计算请求成本 计算请求成本
默认实现:支持固定价格和阶梯计费 使用 billing 模块的配置驱动计费
子类可覆盖此方法实现完全不同的计费逻辑 子类可通过设置 BILLING_TEMPLATE 类属性来指定计费模板,
或覆盖此方法实现完全自定义的计费逻辑。
Args: Args:
input_tokens: 输入 token 数 input_tokens: 输入 token 数
@@ -508,78 +479,26 @@ class CliAdapterBase(ApiAdapter):
Returns: Returns:
包含各项成本的字典 包含各项成本的字典
""" """
tier_index = None # 计算总输入上下文(使用子类可覆盖的方法)
effective_input_price = input_price_per_1m total_input_context = self.compute_total_input_context(
effective_output_price = output_price_per_1m input_tokens, cache_read_input_tokens, cache_creation_input_tokens
effective_cache_creation_price = cache_creation_price_per_1m )
effective_cache_read_price = cache_read_price_per_1m
# 检查阶梯计费 return _calculate_request_cost(
if tiered_pricing and tiered_pricing.get("tiers"): input_tokens=input_tokens,
total_input_context = self.compute_total_input_context( output_tokens=output_tokens,
input_tokens, cache_read_input_tokens, cache_creation_input_tokens cache_creation_input_tokens=cache_creation_input_tokens,
) cache_read_input_tokens=cache_read_input_tokens,
tier = self._get_tier_for_tokens(tiered_pricing, total_input_context) input_price_per_1m=input_price_per_1m,
output_price_per_1m=output_price_per_1m,
if tier: cache_creation_price_per_1m=cache_creation_price_per_1m,
tier_index = tiered_pricing["tiers"].index(tier) cache_read_price_per_1m=cache_read_price_per_1m,
effective_input_price = tier.get("input_price_per_1m", input_price_per_1m) price_per_request=price_per_request,
effective_output_price = tier.get("output_price_per_1m", output_price_per_1m) tiered_pricing=tiered_pricing,
effective_cache_creation_price = tier.get( cache_ttl_minutes=cache_ttl_minutes,
"cache_creation_price_per_1m", cache_creation_price_per_1m total_input_context=total_input_context,
) billing_template=self.BILLING_TEMPLATE,
effective_cache_read_price = self.get_cache_read_price_for_ttl( )
tier, cache_ttl_minutes
)
if effective_cache_read_price is None:
effective_cache_read_price = cache_read_price_per_1m
# 计算各项成本
input_cost = (input_tokens / 1_000_000) * effective_input_price
output_cost = (output_tokens / 1_000_000) * effective_output_price
cache_creation_cost = 0.0
cache_read_cost = 0.0
if cache_creation_input_tokens > 0 and effective_cache_creation_price is not None:
cache_creation_cost = (
cache_creation_input_tokens / 1_000_000
) * effective_cache_creation_price
if cache_read_input_tokens > 0 and effective_cache_read_price is not None:
cache_read_cost = (
cache_read_input_tokens / 1_000_000
) * effective_cache_read_price
cache_cost = cache_creation_cost + cache_read_cost
request_cost = price_per_request if price_per_request else 0.0
total_cost = input_cost + output_cost + cache_cost + request_cost
return {
"input_cost": input_cost,
"output_cost": output_cost,
"cache_creation_cost": cache_creation_cost,
"cache_read_cost": cache_read_cost,
"cache_cost": cache_cost,
"request_cost": request_cost,
"total_cost": total_cost,
"tier_index": tier_index,
}
@staticmethod
def _get_tier_for_tokens(tiered_pricing: dict, total_input_tokens: int) -> Optional[dict]:
"""根据总输入 token 数确定价格阶梯"""
if not tiered_pricing or "tiers" not in tiered_pricing:
return None
tiers = tiered_pricing.get("tiers", [])
if not tiers:
return None
for tier in tiers:
up_to = tier.get("up_to")
if up_to is None or total_input_tokens <= up_to:
return tier
return tiers[-1] if tiers else None
# ========================================================================= # =========================================================================
# 模型列表查询 - 子类应覆盖此方法 # 模型列表查询 - 子类应覆盖此方法

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.parsers import get_parser_for_format
from src.api.handlers.base.request_builder import PassthroughRequestBuilder from src.api.handlers.base.request_builder import PassthroughRequestBuilder
from src.api.handlers.base.stream_context import StreamContext 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 ( from src.api.handlers.base.response_parser import (
@@ -57,6 +62,7 @@ from src.models.database import (
ProviderEndpoint, ProviderEndpoint,
User, User,
) )
from src.config.constants import StreamDefaults
from src.config.settings import config from src.config.settings import config
from src.services.provider.transport import build_provider_url from src.services.provider.transport import build_provider_url
from src.utils.sse_parser import SSEEventParser from src.utils.sse_parser import SSEEventParser
@@ -328,9 +334,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
stream_generator, stream_generator,
provider_name, provider_name,
attempt_id, attempt_id,
_provider_id, provider_id,
_endpoint_id, endpoint_id,
_key_id, key_id,
) = await self.orchestrator.execute_with_fallback( ) = await self.orchestrator.execute_with_fallback(
api_format=ctx.api_format, api_format=ctx.api_format,
model_name=ctx.model, model_name=ctx.model,
@@ -340,7 +346,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
is_stream=True, is_stream=True,
capability_requirements=capability_requirements or None, capability_requirements=capability_requirements or None,
) )
# 更新上下文(确保 provider 信息已设置,用于 streaming 状态更新)
ctx.attempt_id = attempt_id 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() background_tasks = BackgroundTasks()
@@ -488,6 +504,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
error_text = await self._extract_error_text(e) error_text = await self._extract_error_text(e)
logger.error(f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}") logger.error(f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}")
await http_client.aclose() await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
raise raise
except EmbeddedErrorException: except EmbeddedErrorException:
@@ -523,8 +541,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
try: try:
sse_parser = SSEEventParser() sse_parser = SSEEventParser()
last_data_time = time.time() last_data_time = time.time()
streaming_status_updated = False
buffer = b"" buffer = b""
output_state = {"first_yield": True, "streaming_updated": False}
# 使用增量解码器处理跨 chunk 的 UTF-8 字符 # 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
@@ -532,11 +550,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
needs_conversion = self._needs_format_conversion(ctx) needs_conversion = self._needs_format_conversion(ctx)
async for chunk in stream_response.aiter_bytes(): 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 buffer += chunk
# 处理缓冲区中的完整行 # 处理缓冲区中的完整行
while b"\n" in buffer: while b"\n" in buffer:
@@ -561,6 +574,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
event.get("event"), event.get("event"),
event.get("data") or "", event.get("data") or "",
) )
self._mark_first_output(ctx, output_state)
yield b"\n" yield b"\n"
continue continue
@@ -578,6 +592,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据", "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") yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
return # 结束生成器 return # 结束生成器
@@ -585,8 +600,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
if needs_conversion: if needs_conversion:
converted_line = self._convert_sse_line(ctx, line, events) converted_line = self._convert_sse_line(ctx, line, events)
if converted_line: if converted_line:
self._mark_first_output(ctx, output_state)
yield (converted_line + "\n").encode("utf-8") yield (converted_line + "\n").encode("utf-8")
else: else:
self._mark_first_output(ctx, output_state)
yield (line + "\n").encode("utf-8") yield (line + "\n").encode("utf-8")
for event in events: 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") 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: if ctx.data_count > 0:
error_event = { error_event = {
"type": "error", "type": "error",
@@ -691,7 +708,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
ProviderTimeoutException: 如果首字节超时TTFB timeout ProviderTimeoutException: 如果首字节超时TTFB timeout
""" """
prefetched_chunks: list = [] 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"" buffer = b""
line_count = 0 line_count = 0
should_stop = False should_stop = False
@@ -718,14 +737,16 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_name=str(provider.name), provider_name=str(provider.name),
) )
prefetched_chunks.append(first_chunk) prefetched_chunks.append(first_chunk)
total_prefetched_bytes += len(first_chunk)
buffer += first_chunk buffer += first_chunk
# 继续读取剩余的预读数据 # 继续读取剩余的预读数据
async for chunk in aiter: async for chunk in aiter:
prefetched_chunks.append(chunk) prefetched_chunks.append(chunk)
total_prefetched_bytes += len(chunk)
buffer += chunk buffer += chunk
# 尝试按行解析缓冲区 # 尝试按行解析缓冲区SSE 格式)
while b"\n" in buffer: while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1) line_bytes, buffer = buffer.split(b"\n", 1)
try: try:
@@ -742,15 +763,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
normalized_line = line.rstrip("\r") normalized_line = line.rstrip("\r")
# 检测 HTML 响应base_url 配置错误的常见症状) # 检测 HTML 响应base_url 配置错误的常见症状)
lower_line = normalized_line.lower() if check_html_response(normalized_line):
if lower_line.startswith("<!doctype") or lower_line.startswith("<html"):
logger.error( logger.error(
f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: " f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., " f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
f"base_url={endpoint.base_url}" f"base_url={endpoint.base_url}"
) )
raise ProviderNotAvailableException( 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(":"): if not normalized_line or normalized_line.startswith(":"):
@@ -799,9 +820,30 @@ class CliMessageHandlerBase(BaseMessageHandler):
should_stop = True should_stop = True
break 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: if should_stop or line_count >= max_prefetch_lines:
break 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): except (EmbeddedErrorException, ProviderTimeoutException, ProviderNotAvailableException):
# 重新抛出可重试的 Provider 异常,触发故障转移 # 重新抛出可重试的 Provider 异常,触发故障转移
raise raise
@@ -833,17 +875,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
sse_parser = SSEEventParser() sse_parser = SSEEventParser()
last_data_time = time.time() last_data_time = time.time()
buffer = b"" buffer = b""
first_yield = True # 标记是否是第一次 yield output_state = {"first_yield": True, "streaming_updated": False}
# 使用增量解码器处理跨 chunk 的 UTF-8 字符 # 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# 检查是否需要格式转换 # 检查是否需要格式转换
needs_conversion = self._needs_format_conversion(ctx) needs_conversion = self._needs_format_conversion(ctx)
# 在第一次输出数据前更新状态为 streaming
if prefetched_chunks:
self._update_usage_to_streaming_with_ctx(ctx)
# 先处理预读的字节块 # 先处理预读的字节块
for chunk in prefetched_chunks: for chunk in prefetched_chunks:
buffer += chunk buffer += chunk
@@ -870,10 +908,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
event.get("event"), event.get("event"),
event.get("data") or "", event.get("data") or "",
) )
# 记录首字时间 (第一次 yield) self._mark_first_output(ctx, output_state)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
yield b"\n" yield b"\n"
continue continue
@@ -883,16 +918,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
if needs_conversion: if needs_conversion:
converted_line = self._convert_sse_line(ctx, line, events) converted_line = self._convert_sse_line(ctx, line, events)
if converted_line: if converted_line:
# 记录首字时间 (第一次 yield) self._mark_first_output(ctx, output_state)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
yield (converted_line + "\n").encode("utf-8") yield (converted_line + "\n").encode("utf-8")
else: else:
# 记录首字时间 (第一次 yield) self._mark_first_output(ctx, output_state)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
yield (line + "\n").encode("utf-8") yield (line + "\n").encode("utf-8")
for event in events: for event in events:
@@ -931,10 +960,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
event.get("event"), event.get("event"),
event.get("data") or "", event.get("data") or "",
) )
# 记录首字时间 (第一次 yield) - 如果预读数据为空 self._mark_first_output(ctx, output_state)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
yield b"\n" yield b"\n"
continue continue
@@ -952,6 +978,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
"message": f"提供商 '{ctx.provider_name}' 流超时且未返回有效数据", "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") yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
return return
@@ -959,16 +986,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
if needs_conversion: if needs_conversion:
converted_line = self._convert_sse_line(ctx, line, events) converted_line = self._convert_sse_line(ctx, line, events)
if converted_line: if converted_line:
# 记录首字时间 (第一次 yield) - 如果预读数据为空 self._mark_first_output(ctx, output_state)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
yield (converted_line + "\n").encode("utf-8") yield (converted_line + "\n").encode("utf-8")
else: else:
# 记录首字时间 (第一次 yield) - 如果预读数据为空 self._mark_first_output(ctx, output_state)
if first_yield:
ctx.record_first_byte_time(self.start_time)
first_yield = False
yield (line + "\n").encode("utf-8") yield (line + "\n").encode("utf-8")
for event in events: for event in events:
@@ -1352,7 +1373,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
model=ctx.model, model=ctx.model,
response_time_ms=response_time_ms, response_time_ms=response_time_ms,
status_code=status_code, status_code=status_code,
error_message=str(error), error_message=extract_error_message(error),
request_headers=original_headers, request_headers=original_headers,
request_body=actual_request_body, request_body=actual_request_body,
is_stream=True, is_stream=True,
@@ -1476,8 +1497,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
retry_after=int(resp.headers.get("retry-after", 0)) or None, retry_after=int(resp.headers.get("retry-after", 0)) or None,
) )
elif resp.status_code >= 500: elif resp.status_code >= 500:
error_text = resp.text
raise ProviderNotAvailableException( raise ProviderNotAvailableException(
f"提供商服务不可用: {provider.name}, 状态: {resp.status_code}" f"提供商服务不可用: {provider.name}, 状态: {resp.status_code}",
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=error_text,
) )
elif 300 <= resp.status_code < 400: elif 300 <= resp.status_code < 400:
redirect_url = resp.headers.get("location", "unknown") redirect_url = resp.headers.get("location", "unknown")
@@ -1487,7 +1512,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
elif resp.status_code != 200: elif resp.status_code != 200:
error_text = resp.text error_text = resp.text
raise ProviderNotAvailableException( raise ProviderNotAvailableException(
f"提供商返回错误: {provider.name}, 状态: {resp.status_code}, 错误: {error_text[:200]}" f"提供商返回错误: {provider.name}, 状态: {resp.status_code}",
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=error_text,
) )
# 安全解析 JSON 响应,处理可能的编码错误 # 安全解析 JSON 响应,处理可能的编码错误
@@ -1620,7 +1648,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
model=model, model=model,
response_time_ms=response_time_ms, response_time_ms=response_time_ms,
status_code=status_code, status_code=status_code,
error_message=str(e), error_message=extract_error_message(e),
request_headers=original_headers, request_headers=original_headers,
request_body=actual_request_body, request_body=actual_request_body,
is_stream=False, is_stream=False,
@@ -1640,14 +1668,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
for encoding in ["utf-8", "gbk", "latin1"]: for encoding in ["utf-8", "gbk", "latin1"]:
try: try:
return error_bytes.decode(encoding)[:500] return error_bytes.decode(encoding)
except (UnicodeDecodeError, LookupError): except (UnicodeDecodeError, LookupError):
continue continue
return error_bytes.decode("utf-8", errors="replace")[:500] return error_bytes.decode("utf-8", errors="replace")
else: else:
return ( return (
e.response.text[:500] e.response.text
if hasattr(e.response, "_content") if hasattr(e.response, "_content")
else "Unable to read response" else "Unable to read response"
) )
@@ -1665,6 +1693,25 @@ class CliMessageHandlerBase(BaseMessageHandler):
return False return False
return ctx.provider_api_format.upper() != ctx.client_api_format.upper() 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( def _convert_sse_line(
self, self,
ctx: StreamContext, ctx: StreamContext,

View File

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

View File

@@ -2,8 +2,10 @@
Handler 基础工具函数 Handler 基础工具函数
""" """
import json
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from src.core.exceptions import EmbeddedErrorException, ProviderNotAvailableException
from src.core.logger import logger 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: if extra_headers:
headers.update(extra_headers) headers.update(extra_headers)
return 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

@@ -63,6 +63,7 @@ class ClaudeChatAdapter(ChatAdapterBase):
""" """
FORMAT_ID = "CLAUDE" FORMAT_ID = "CLAUDE"
BILLING_TEMPLATE = "claude" # 使用 Claude 计费模板
name = "claude.chat" name = "claude.chat"
@property @property

View File

@@ -24,6 +24,7 @@ class ClaudeCliAdapter(CliAdapterBase):
""" """
FORMAT_ID = "CLAUDE_CLI" FORMAT_ID = "CLAUDE_CLI"
BILLING_TEMPLATE = "claude" # 使用 Claude 计费模板
name = "claude.cli" name = "claude.cli"
@property @property

View File

@@ -27,6 +27,7 @@ class GeminiChatAdapter(ChatAdapterBase):
""" """
FORMAT_ID = "GEMINI" FORMAT_ID = "GEMINI"
BILLING_TEMPLATE = "gemini" # 使用 Gemini 计费模板
name = "gemini.chat" name = "gemini.chat"
@property @property

View File

@@ -24,6 +24,7 @@ class GeminiCliAdapter(CliAdapterBase):
""" """
FORMAT_ID = "GEMINI_CLI" FORMAT_ID = "GEMINI_CLI"
BILLING_TEMPLATE = "gemini" # 使用 Gemini 计费模板
name = "gemini.cli" name = "gemini.cli"
@property @property

View File

@@ -26,6 +26,7 @@ class OpenAIChatAdapter(ChatAdapterBase):
""" """
FORMAT_ID = "OPENAI" FORMAT_ID = "OPENAI"
BILLING_TEMPLATE = "openai" # 使用 OpenAI 计费模板
name = "openai.chat" name = "openai.chat"
@property @property

View File

@@ -24,6 +24,7 @@ class OpenAICliAdapter(CliAdapterBase):
""" """
FORMAT_ID = "OPENAI_CLI" FORMAT_ID = "OPENAI_CLI"
BILLING_TEMPLATE = "openai" # 使用 OpenAI 计费模板
name = "openai.cli" name = "openai.cli"
@property @property

View File

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

View File

@@ -104,9 +104,14 @@ async def get_my_usage(
request: Request, request: Request,
start_date: Optional[datetime] = None, start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None, end_date: Optional[datetime] = None,
search: Optional[str] = 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), 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, search=search, limit=limit, offset=offset
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -133,6 +138,20 @@ async def get_my_interval_timeline(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/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") @router.get("/providers")
async def list_available_providers(request: Request, db: Session = Depends(get_db)): async def list_available_providers(request: Request, db: Session = Depends(get_db)):
adapter = ListAvailableProvidersAdapter() adapter = ListAvailableProvidersAdapter()
@@ -471,8 +490,15 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
class GetUsageAdapter(AuthenticatedApiAdapter): class GetUsageAdapter(AuthenticatedApiAdapter):
start_date: Optional[datetime] start_date: Optional[datetime]
end_date: Optional[datetime] end_date: Optional[datetime]
search: Optional[str] = None
limit: int = 100
offset: int = 0
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
from sqlalchemy import or_
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
db = context.db db = context.db
user = context.user user = context.user
summary_list = UsageService.get_usage_summary( summary_list = UsageService.get_usage_summary(
@@ -553,7 +579,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
stats["total_cost_usd"] += item["total_cost_usd"] stats["total_cost_usd"] += item["total_cost_usd"]
# 假设 summary 中的都是成功的请求 # 假设 summary 中的都是成功的请求
stats["success_count"] += item["requests"] 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["total_response_time_ms"] += item["avg_response_time_ms"] * item["requests"]
stats["response_time_count"] += item["requests"] stats["response_time_count"] += item["requests"]
@@ -577,12 +603,33 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
}) })
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True) summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
query = db.query(Usage).filter(Usage.user_id == user.id) query = (
db.query(Usage, ApiKey)
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
.filter(Usage.user_id == user.id)
)
if self.start_date: if self.start_date:
query = query.filter(Usage.created_at >= self.start_date) query = query.filter(Usage.created_at >= self.start_date)
if self.end_date: if self.end_date:
query = query.filter(Usage.created_at <= self.end_date) query = query.filter(Usage.created_at <= self.end_date)
usage_records = query.order_by(Usage.created_at.desc()).limit(100).all()
# 通用搜索:密钥名、模型名
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
if self.search and self.search.strip():
keywords = [kw for kw in self.search.strip().split() if kw][:10]
for keyword in keywords:
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
search_pattern = f"%{escaped}%"
query = query.filter(
or_(
ApiKey.name.ilike(search_pattern, escape="\\"),
Usage.model.ilike(search_pattern, escape="\\"),
)
)
# 计算总数用于分页
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( avg_resp_query = db.query(func.avg(Usage.response_time_ms)).filter(
Usage.user_id == user.id, Usage.user_id == user.id,
@@ -608,6 +655,13 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"used_usd": user.used_usd, "used_usd": user.used_usd,
"summary_by_model": summary_by_model, "summary_by_model": summary_by_model,
"summary_by_provider": summary_by_provider, "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": [ "records": [
{ {
"id": r.id, "id": r.id,
@@ -631,23 +685,25 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"output_price_per_1m": r.output_price_per_1m, "output_price_per_1m": r.output_price_per_1m,
"cache_creation_price_per_1m": r.cache_creation_price_per_1m, "cache_creation_price_per_1m": r.cache_creation_price_per_1m,
"cache_read_price_per_1m": r.cache_read_price_per_1m, "cache_read_price_per_1m": r.cache_read_price_per_1m,
"api_key": (
{
"id": str(api_key.id),
"name": api_key.name,
"display": api_key.get_display_key(),
}
if api_key
else None
),
} }
for r in usage_records for r, api_key in usage_records
], ],
} }
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": if user.role == "admin":
response_data["total_actual_cost"] = total_actual_cost response_data["total_actual_cost"] = total_actual_cost
# 为每条记录添加真实成本和倍率信息 # 为每条记录添加真实成本和倍率信息
for i, r in enumerate(usage_records): for i, (r, _) in enumerate(usage_records):
# 确保字段有值,避免前端显示 - # 确保字段有值,避免前端显示 -
actual_cost = ( actual_cost = (
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0 r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
@@ -709,6 +765,20 @@ class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
return result 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): class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload

View File

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

View File

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

View File

@@ -21,6 +21,9 @@ class CacheTTL:
# L1 本地缓存(用于减少 Redis 访问) # L1 本地缓存(用于减少 Redis 访问)
L1_LOCAL = 3 # 3秒 L1_LOCAL = 3 # 3秒
# 活跃度热力图缓存 - 历史数据变化不频繁
ACTIVITY_HEATMAP = 300 # 5分钟
# 并发锁 TTL - 防止死锁 # 并发锁 TTL - 防止死锁
CONCURRENCY_LOCK = 600 # 10分钟 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: class ConcurrencyDefaults:
"""并发控制默认值""" """并发控制默认值
算法说明:边界记忆 + 渐进探测
- 触发 429 时记录边界last_concurrent_peak新限制 = 边界 - 1
- 扩容时不超过边界,除非是探测性扩容(长时间无 429
- 这样可以快速收敛到真实限制附近,避免过度保守
"""
# 自适应并发初始限制(宽松起步,遇到 429 再降低) # 自适应并发初始限制(宽松起步,遇到 429 再降低)
INITIAL_LIMIT = 50 INITIAL_LIMIT = 50
@@ -69,10 +89,6 @@ class ConcurrencyDefaults:
# 扩容步长 - 每次扩容增加的并发数 # 扩容步长 - 每次扩容增加的并发数
INCREASE_STEP = 2 INCREASE_STEP = 2
# 缩容乘数 - 遇到 429 时基于当前并发数的缩容比例
# 0.85 表示降到触发 429 时并发数的 85%
DECREASE_MULTIPLIER = 0.85
# 最大并发限制上限 # 最大并发限制上限
MAX_CONCURRENT_LIMIT = 200 MAX_CONCURRENT_LIMIT = 200
@@ -84,6 +100,7 @@ class ConcurrencyDefaults:
# === 探测性扩容参数 === # === 探测性扩容参数 ===
# 探测性扩容间隔(分钟)- 长时间无 429 且有流量时尝试扩容 # 探测性扩容间隔(分钟)- 长时间无 429 且有流量时尝试扩容
# 探测性扩容可以突破已知边界,尝试更高的并发
PROBE_INCREASE_INTERVAL_MINUTES = 30 PROBE_INCREASE_INTERVAL_MINUTES = 30
# 探测性扩容最小请求数 - 在探测间隔内至少需要这么多请求 # 探测性扩容最小请求数 - 在探测间隔内至少需要这么多请求

View File

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

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 关联 - 所有错误都记录到日志,通过错误 ID 关联
""" """
if isinstance(e, ProxyException): 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( return ErrorResponse.create(
error_type=e.error_type, error_type=e.error_type,
message=e.message, message=message,
status_code=e.status_code, status_code=status_code,
details=e.details, details=details if details else None,
) )
elif isinstance(e, HTTPException): elif isinstance(e, HTTPException):
return ErrorResponse.create( return ErrorResponse.create(

View File

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

View File

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

View File

@@ -123,6 +123,98 @@ class LogoutResponse(BaseModel):
success: bool success: bool
class SendVerificationCodeRequest(BaseModel):
"""发送验证码请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效")
return v.lower()
class SendVerificationCodeResponse(BaseModel):
"""发送验证码响应"""
message: str
success: bool
expire_minutes: Optional[int] = None
class VerifyEmailRequest(BaseModel):
"""验证邮箱请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
code: str = Field(..., min_length=6, max_length=6, description="6位验证码")
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效")
return v.lower()
@field_validator("code")
@classmethod
def validate_code(cls, v):
"""验证验证码格式"""
v = v.strip()
if not v.isdigit():
raise ValueError("验证码必须是6位数字")
if len(v) != 6:
raise ValueError("验证码必须是6位数字")
return v
class VerifyEmailResponse(BaseModel):
"""验证邮箱响应"""
message: str
success: bool
class VerificationStatusRequest(BaseModel):
"""验证状态查询请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
v = v.strip().lower()
if not v:
raise ValueError("邮箱不能为空")
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效")
return v
class VerificationStatusResponse(BaseModel):
"""验证状态响应"""
email: str
has_pending_code: bool = Field(description="是否有待验证的验证码")
is_verified: bool = Field(description="邮箱是否已验证")
cooldown_remaining: Optional[int] = Field(None, description="发送冷却剩余秒数")
code_expires_in: Optional[int] = Field(None, description="验证码剩余有效秒数")
class RegistrationSettingsResponse(BaseModel):
"""注册设置响应(公开接口返回)"""
enable_registration: bool
require_email_verification: bool
# ========== 用户管理 ========== # ========== 用户管理 ==========
class CreateUserRequest(BaseModel): class CreateUserRequest(BaseModel):
"""创建用户请求""" """创建用户请求"""
@@ -217,8 +309,9 @@ class CreateApiKeyRequest(BaseModel):
allowed_endpoints: Optional[List[str]] = None # 允许使用的端点 ID 列表 allowed_endpoints: Optional[List[str]] = None # 允许使用的端点 ID 列表
allowed_api_formats: Optional[List[str]] = None # 允许使用的 API 格式列表 allowed_api_formats: Optional[List[str]] = None # 允许使用的 API 格式列表
allowed_models: Optional[List[str]] = None # 允许使用的模型名称列表 allowed_models: Optional[List[str]] = None # 允许使用的模型名称列表
rate_limit: Optional[int] = 100 rate_limit: Optional[int] = None # None = 无限制
expire_days: 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( initial_balance_usd: Optional[float] = Field(
None, description="初始余额USD仅用于独立KeyNone = 无限制" None, description="初始余额USD仅用于独立KeyNone = 无限制"
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
"""
计费模块
提供配置驱动的计费计算,支持不同厂商的差异化计费模式:
- Claude: input + output + cache_creation + cache_read
- OpenAI: input + output + cache_read (无缓存创建费用)
- 豆包: input + output + cache_read + cache_storage (缓存按时计费)
- 按次计费: per_request
使用方式:
from src.services.billing import BillingCalculator, UsageMapper, StandardizedUsage
# 1. 将原始 usage 映射为标准格式
usage = UsageMapper.map(raw_usage, api_format="OPENAI")
# 2. 使用计费计算器计算费用
calculator = BillingCalculator(template="openai")
result = calculator.calculate(usage, prices)
# 3. 获取费用明细
print(result.total_cost)
print(result.costs) # {"input": 0.01, "output": 0.02, ...}
"""
from src.services.billing.calculator import BillingCalculator, calculate_request_cost
from src.services.billing.models import (
BillingDimension,
BillingUnit,
CostBreakdown,
StandardizedUsage,
)
from src.services.billing.templates import BILLING_TEMPLATE_REGISTRY, BillingTemplates
from src.services.billing.usage_mapper import UsageMapper, map_usage, map_usage_from_response
__all__ = [
# 数据模型
"BillingDimension",
"BillingUnit",
"CostBreakdown",
"StandardizedUsage",
# 模板
"BillingTemplates",
"BILLING_TEMPLATE_REGISTRY",
# 计算器
"BillingCalculator",
"calculate_request_cost",
# 映射器
"UsageMapper",
"map_usage",
"map_usage_from_response",
]

View File

@@ -0,0 +1,339 @@
"""
计费计算器
配置驱动的计费计算,支持:
- 固定价格计费
- 阶梯计费
- 多种计费模板
- 自定义计费维度
"""
from typing import Any, Dict, List, Optional, Tuple
from src.services.billing.models import (
BillingDimension,
BillingUnit,
CostBreakdown,
StandardizedUsage,
)
from src.services.billing.templates import (
BILLING_TEMPLATE_REGISTRY,
BillingTemplates,
get_template,
)
class BillingCalculator:
"""
配置驱动的计费计算器
支持多种计费模式:
- 使用预定义模板claude, openai, doubao 等)
- 自定义计费维度
- 阶梯计费
示例:
# 使用模板
calculator = BillingCalculator(template="openai")
# 自定义维度
calculator = BillingCalculator(dimensions=[
BillingDimension(name="input", usage_field="input_tokens", price_field="input_price_per_1m"),
BillingDimension(name="output", usage_field="output_tokens", price_field="output_price_per_1m"),
])
# 计算费用
usage = StandardizedUsage(input_tokens=1000, output_tokens=500)
prices = {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
result = calculator.calculate(usage, prices)
"""
def __init__(
self,
dimensions: Optional[List[BillingDimension]] = None,
template: Optional[str] = None,
):
"""
初始化计费计算器
Args:
dimensions: 自定义计费维度列表(优先级高于模板)
template: 使用预定义模板名称 ("claude", "openai", "doubao", "per_request" 等)
"""
if dimensions:
self.dimensions = dimensions
elif template:
self.dimensions = get_template(template)
else:
# 默认使用 Claude 模板(向后兼容)
self.dimensions = BillingTemplates.CLAUDE_STANDARD
self.template_name = template
def calculate(
self,
usage: StandardizedUsage,
prices: Dict[str, float],
tiered_pricing: Optional[Dict[str, Any]] = None,
cache_ttl_minutes: Optional[int] = None,
total_input_context: Optional[int] = None,
) -> CostBreakdown:
"""
计算费用
Args:
usage: 标准化的 usage 数据
prices: 价格配置 {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0, ...}
tiered_pricing: 阶梯计费配置(可选)
cache_ttl_minutes: 缓存 TTL 分钟数(用于 TTL 差异化定价)
total_input_context: 总输入上下文(用于阶梯判定,可选)
如果提供,将使用该值进行阶梯判定;否则使用默认计算逻辑
Returns:
费用明细 (CostBreakdown)
"""
result = CostBreakdown()
# 处理阶梯计费
effective_prices = prices.copy()
if tiered_pricing and tiered_pricing.get("tiers"):
tier, tier_index = self._get_tier(usage, tiered_pricing, total_input_context)
if tier:
result.tier_index = tier_index
# 阶梯价格覆盖默认价格
for key, value in tier.items():
if key not in ("up_to", "cache_ttl_pricing") and value is not None:
effective_prices[key] = value
# 处理 TTL 差异化定价
if cache_ttl_minutes is not None:
ttl_price = self._get_cache_read_price_for_ttl(tier, cache_ttl_minutes)
if ttl_price is not None:
effective_prices["cache_read_price_per_1m"] = ttl_price
# 记录使用的价格
result.effective_prices = effective_prices.copy()
# 计算各维度费用
total = 0.0
for dim in self.dimensions:
usage_value = usage.get(dim.usage_field, 0)
price = effective_prices.get(dim.price_field, dim.default_price)
if usage_value and price:
cost = dim.calculate(usage_value, price)
result.costs[dim.name] = cost
total += cost
result.total_cost = total
return result
def _get_tier(
self,
usage: StandardizedUsage,
tiered_pricing: Dict[str, Any],
total_input_context: Optional[int] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[int]]:
"""
确定价格阶梯
Args:
usage: usage 数据
tiered_pricing: 阶梯配置 {"tiers": [...]}
total_input_context: 预计算的总输入上下文(可选)
Returns:
(匹配的阶梯配置, 阶梯索引)
"""
tiers = tiered_pricing.get("tiers", [])
if not tiers:
return None, None
# 使用传入的 total_input_context或者默认计算
if total_input_context is None:
total_input_context = self._compute_total_input_context(usage)
for i, tier in enumerate(tiers):
up_to = tier.get("up_to")
# up_to 为 None 表示无上限(最后一个阶梯)
if up_to is None or total_input_context <= up_to:
return tier, i
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
return tiers[-1], len(tiers) - 1
def _compute_total_input_context(self, usage: StandardizedUsage) -> int:
"""
计算总输入上下文(用于阶梯计费判定)
默认: input_tokens + cache_read_tokens
Args:
usage: usage 数据
Returns:
总输入 token 数
"""
return usage.input_tokens + usage.cache_read_tokens
def _get_cache_read_price_for_ttl(
self,
tier: Dict[str, Any],
cache_ttl_minutes: int,
) -> Optional[float]:
"""
根据缓存 TTL 获取缓存读取价格
某些厂商(如 Claude对不同 TTL 的缓存有不同定价。
Args:
tier: 当前阶梯配置
cache_ttl_minutes: 缓存时长(分钟)
Returns:
缓存读取价格,如果没有 TTL 差异化配置返回 None
"""
ttl_pricing = tier.get("cache_ttl_pricing")
if not ttl_pricing:
return None
# 找到匹配或最接近的 TTL 价格
for ttl_config in ttl_pricing:
ttl_limit = ttl_config.get("ttl_minutes", 0)
if cache_ttl_minutes <= ttl_limit:
price = ttl_config.get("cache_read_price_per_1m")
return float(price) if price is not None else None
# 超过所有配置的 TTL使用最后一个
if ttl_pricing:
price = ttl_pricing[-1].get("cache_read_price_per_1m")
return float(price) if price is not None else None
return None
@classmethod
def from_config(cls, config: Dict[str, Any]) -> "BillingCalculator":
"""
从配置创建计费计算器
Config 格式:
{
"template": "claude", # 或 "openai", "doubao", "per_request"
# 或者自定义维度:
"dimensions": [
{"name": "input", "usage_field": "input_tokens", "price_field": "input_price_per_1m"},
...
]
}
Args:
config: 配置字典
Returns:
BillingCalculator 实例
"""
if "dimensions" in config:
dimensions = [BillingDimension.from_dict(d) for d in config["dimensions"]]
return cls(dimensions=dimensions)
return cls(template=config.get("template", "claude"))
def get_dimension_names(self) -> List[str]:
"""获取所有计费维度名称"""
return [dim.name for dim in self.dimensions]
def get_required_price_fields(self) -> List[str]:
"""获取所需的价格字段名称"""
return [dim.price_field for dim in self.dimensions]
def get_required_usage_fields(self) -> List[str]:
"""获取所需的 usage 字段名称"""
return [dim.usage_field for dim in self.dimensions]
def calculate_request_cost(
input_tokens: int,
output_tokens: int,
cache_creation_input_tokens: int,
cache_read_input_tokens: int,
input_price_per_1m: float,
output_price_per_1m: float,
cache_creation_price_per_1m: Optional[float],
cache_read_price_per_1m: Optional[float],
price_per_request: Optional[float],
tiered_pricing: Optional[Dict[str, Any]] = None,
cache_ttl_minutes: Optional[int] = None,
total_input_context: Optional[int] = None,
billing_template: str = "claude",
) -> Dict[str, Any]:
"""
计算请求成本的便捷函数
封装了 BillingCalculator 的调用逻辑,返回兼容旧格式的字典。
Args:
input_tokens: 输入 token 数
output_tokens: 输出 token 数
cache_creation_input_tokens: 缓存创建 token 数
cache_read_input_tokens: 缓存读取 token 数
input_price_per_1m: 输入价格(每 1M tokens
output_price_per_1m: 输出价格(每 1M tokens
cache_creation_price_per_1m: 缓存创建价格(每 1M tokens
cache_read_price_per_1m: 缓存读取价格(每 1M tokens
price_per_request: 按次计费价格
tiered_pricing: 阶梯计费配置
cache_ttl_minutes: 缓存时长(分钟)
total_input_context: 总输入上下文(用于阶梯判定)
billing_template: 计费模板名称
Returns:
包含各项成本的字典:
{
"input_cost": float,
"output_cost": float,
"cache_creation_cost": float,
"cache_read_cost": float,
"cache_cost": float,
"request_cost": float,
"total_cost": float,
"tier_index": Optional[int],
}
"""
# 构建标准化 usage
usage = StandardizedUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_tokens=cache_creation_input_tokens,
cache_read_tokens=cache_read_input_tokens,
request_count=1,
)
# 构建价格配置
prices: Dict[str, float] = {
"input_price_per_1m": input_price_per_1m,
"output_price_per_1m": output_price_per_1m,
}
if cache_creation_price_per_1m is not None:
prices["cache_creation_price_per_1m"] = cache_creation_price_per_1m
if cache_read_price_per_1m is not None:
prices["cache_read_price_per_1m"] = cache_read_price_per_1m
if price_per_request is not None:
prices["price_per_request"] = price_per_request
# 使用 BillingCalculator 计算
calculator = BillingCalculator(template=billing_template)
result = calculator.calculate(
usage, prices, tiered_pricing, cache_ttl_minutes, total_input_context
)
# 返回兼容旧格式的字典
return {
"input_cost": result.input_cost,
"output_cost": result.output_cost,
"cache_creation_cost": result.cache_creation_cost,
"cache_read_cost": result.cache_read_cost,
"cache_cost": result.cache_cost,
"request_cost": result.request_cost,
"total_cost": result.total_cost,
"tier_index": result.tier_index,
}

View File

@@ -0,0 +1,281 @@
"""
计费模块数据模型
定义计费相关的核心数据结构:
- BillingUnit: 计费单位枚举
- BillingDimension: 计费维度定义
- StandardizedUsage: 标准化的 usage 数据
- CostBreakdown: 计费明细结果
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, Optional
class BillingUnit(str, Enum):
"""计费单位"""
PER_1M_TOKENS = "per_1m_tokens" # 每百万 token
PER_1M_TOKENS_HOUR = "per_1m_tokens_hour" # 每百万 token 每小时(豆包缓存存储)
PER_REQUEST = "per_request" # 每次请求
FIXED = "fixed" # 固定费用
@dataclass
class BillingDimension:
"""
计费维度定义
每个维度描述一种计费方式,例如:
- 输入 token 计费
- 输出 token 计费
- 缓存读取计费
- 按次计费
"""
name: str # 维度名称,如 "input", "output", "cache_read"
usage_field: str # 从 usage 中取值的字段名
price_field: str # 价格配置中的字段名
unit: BillingUnit = BillingUnit.PER_1M_TOKENS # 计费单位
default_price: float = 0.0 # 默认价格(当价格配置中没有时使用)
def calculate(self, usage_value: float, price: float) -> float:
"""
计算该维度的费用
Args:
usage_value: 使用量数值
price: 单价
Returns:
计算后的费用
"""
if usage_value <= 0 or price <= 0:
return 0.0
if self.unit == BillingUnit.PER_1M_TOKENS:
return (usage_value / 1_000_000) * price
elif self.unit == BillingUnit.PER_1M_TOKENS_HOUR:
# 缓存存储按 token 数 * 小时数计费
return (usage_value / 1_000_000) * price
elif self.unit == BillingUnit.PER_REQUEST:
return usage_value * price
elif self.unit == BillingUnit.FIXED:
return price
return 0.0
def to_dict(self) -> Dict[str, Any]:
"""转换为字典(用于序列化)"""
return {
"name": self.name,
"usage_field": self.usage_field,
"price_field": self.price_field,
"unit": self.unit.value,
"default_price": self.default_price,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "BillingDimension":
"""从字典创建实例"""
return cls(
name=data["name"],
usage_field=data["usage_field"],
price_field=data["price_field"],
unit=BillingUnit(data.get("unit", "per_1m_tokens")),
default_price=data.get("default_price", 0.0),
)
@dataclass
class StandardizedUsage:
"""
标准化的 Usage 数据
将不同 API 格式的 usage 统一为标准格式,便于计费计算。
"""
# 基础 token 计数
input_tokens: int = 0
output_tokens: int = 0
# 缓存相关
cache_creation_tokens: int = 0 # Claude: 缓存创建
cache_read_tokens: int = 0 # Claude/OpenAI/豆包: 缓存读取/命中
# 特殊 token 类型
reasoning_tokens: int = 0 # o1/豆包: 推理 token通常包含在 output 中,单独记录用于分析)
# 时间相关(用于按时计费)
cache_storage_token_hours: float = 0.0 # 豆包: 缓存存储 token*小时
# 请求计数(用于按次计费)
request_count: int = 1
# 扩展字段(未来可能需要的额外维度)
extra: Dict[str, Any] = field(default_factory=dict)
def get(self, field_name: str, default: Any = 0) -> Any:
"""
通用字段获取
支持获取标准字段和扩展字段。
Args:
field_name: 字段名
default: 默认值
Returns:
字段值
"""
if hasattr(self, field_name):
value = getattr(self, field_name)
# 对于 extra 字段,不直接返回
if field_name != "extra":
return value
return self.extra.get(field_name, default)
def set(self, field_name: str, value: Any) -> None:
"""
通用字段设置
Args:
field_name: 字段名
value: 字段值
"""
if hasattr(self, field_name) and field_name != "extra":
setattr(self, field_name, value)
else:
self.extra[field_name] = value
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
result: Dict[str, Any] = {
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cache_creation_tokens": self.cache_creation_tokens,
"cache_read_tokens": self.cache_read_tokens,
"reasoning_tokens": self.reasoning_tokens,
"cache_storage_token_hours": self.cache_storage_token_hours,
"request_count": self.request_count,
}
if self.extra:
result["extra"] = self.extra
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "StandardizedUsage":
"""从字典创建实例"""
extra = data.pop("extra", {}) if "extra" in data else {}
# 只取已知字段
known_fields = {
"input_tokens",
"output_tokens",
"cache_creation_tokens",
"cache_read_tokens",
"reasoning_tokens",
"cache_storage_token_hours",
"request_count",
}
filtered = {k: v for k, v in data.items() if k in known_fields}
return cls(**filtered, extra=extra)
@dataclass
class CostBreakdown:
"""
计费明细结果
包含各维度的费用和总费用。
"""
# 各维度费用 {"input": 0.01, "output": 0.02, "cache_read": 0.001, ...}
costs: Dict[str, float] = field(default_factory=dict)
# 总费用
total_cost: float = 0.0
# 命中的阶梯索引(如果使用阶梯计费)
tier_index: Optional[int] = None
# 货币单位
currency: str = "USD"
# 使用的价格(用于记录和审计)
effective_prices: Dict[str, float] = field(default_factory=dict)
# =========================================================================
# 兼容旧接口的属性(便于渐进式迁移)
# =========================================================================
@property
def input_cost(self) -> float:
"""输入费用"""
return self.costs.get("input", 0.0)
@property
def output_cost(self) -> float:
"""输出费用"""
return self.costs.get("output", 0.0)
@property
def cache_creation_cost(self) -> float:
"""缓存创建费用"""
return self.costs.get("cache_creation", 0.0)
@property
def cache_read_cost(self) -> float:
"""缓存读取费用"""
return self.costs.get("cache_read", 0.0)
@property
def cache_cost(self) -> float:
"""总缓存费用(创建 + 读取)"""
return self.cache_creation_cost + self.cache_read_cost
@property
def request_cost(self) -> float:
"""按次计费费用"""
return self.costs.get("request", 0.0)
@property
def cache_storage_cost(self) -> float:
"""缓存存储费用(豆包等)"""
return self.costs.get("cache_storage", 0.0)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"costs": self.costs,
"total_cost": self.total_cost,
"tier_index": self.tier_index,
"currency": self.currency,
"effective_prices": self.effective_prices,
# 兼容字段
"input_cost": self.input_cost,
"output_cost": self.output_cost,
"cache_creation_cost": self.cache_creation_cost,
"cache_read_cost": self.cache_read_cost,
"cache_cost": self.cache_cost,
"request_cost": self.request_cost,
}
def to_legacy_tuple(self) -> tuple:
"""
转换为旧接口的元组格式
Returns:
(input_cost, output_cost, cache_creation_cost, cache_read_cost,
cache_cost, request_cost, total_cost, tier_index)
"""
return (
self.input_cost,
self.output_cost,
self.cache_creation_cost,
self.cache_read_cost,
self.cache_cost,
self.request_cost,
self.total_cost,
self.tier_index,
)

View File

@@ -0,0 +1,213 @@
"""
预定义计费模板
提供常见厂商的计费配置模板,避免重复配置:
- CLAUDE_STANDARD: Claude/Anthropic 标准计费
- OPENAI_STANDARD: OpenAI 标准计费
- DOUBAO_STANDARD: 豆包计费(含缓存存储)
- GEMINI_STANDARD: Gemini 标准计费
- PER_REQUEST: 按次计费
"""
from typing import Dict, List, Optional
from src.services.billing.models import BillingDimension, BillingUnit
class BillingTemplates:
"""预定义的计费模板"""
# =========================================================================
# Claude/Anthropic 标准计费
# - 输入 token
# - 输出 token
# - 缓存创建(创建时收费,约 1.25x 输入价格)
# - 缓存读取(约 0.1x 输入价格)
# =========================================================================
CLAUDE_STANDARD: List[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="cache_creation",
usage_field="cache_creation_tokens",
price_field="cache_creation_price_per_1m",
),
BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
]
# =========================================================================
# OpenAI 标准计费
# - 输入 token
# - 输出 token
# - 缓存读取(部分模型支持,无缓存创建费用)
# =========================================================================
OPENAI_STANDARD: List[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
]
# =========================================================================
# 豆包计费
# - 推理输入 (input_tokens)
# - 推理输出 (output_tokens)
# - 缓存命中 (cache_read_tokens) - 类似 Claude 的缓存读取
# - 缓存存储 (cache_storage_token_hours) - 按 token 数 * 存储时长计费
#
# 注意:豆包的缓存创建是免费的,但存储需要按时付费
# =========================================================================
DOUBAO_STANDARD: List[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
BillingDimension(
name="cache_storage",
usage_field="cache_storage_token_hours",
price_field="cache_storage_price_per_1m_hour",
unit=BillingUnit.PER_1M_TOKENS_HOUR,
),
]
# =========================================================================
# Gemini 标准计费
# - 输入 token
# - 输出 token
# - 缓存读取
# =========================================================================
GEMINI_STANDARD: List[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="cache_read",
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
]
# =========================================================================
# 按次计费
# - 适用于某些图片生成模型、特殊 API 等
# - 仅按请求次数计费,不按 token 计费
# =========================================================================
PER_REQUEST: List[BillingDimension] = [
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
# 混合计费(按次 + 按 token
# - 某些模型既有固定费用又有 token 费用
# =========================================================================
HYBRID_STANDARD: List[BillingDimension] = [
BillingDimension(
name="input",
usage_field="input_tokens",
price_field="input_price_per_1m",
),
BillingDimension(
name="output",
usage_field="output_tokens",
price_field="output_price_per_1m",
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
# 模板注册表
# =========================================================================
BILLING_TEMPLATE_REGISTRY: Dict[str, List[BillingDimension]] = {
# 按厂商名称
"claude": BillingTemplates.CLAUDE_STANDARD,
"anthropic": BillingTemplates.CLAUDE_STANDARD,
"openai": BillingTemplates.OPENAI_STANDARD,
"doubao": BillingTemplates.DOUBAO_STANDARD,
"bytedance": BillingTemplates.DOUBAO_STANDARD,
"gemini": BillingTemplates.GEMINI_STANDARD,
"google": BillingTemplates.GEMINI_STANDARD,
# 按计费模式
"per_request": BillingTemplates.PER_REQUEST,
"hybrid": BillingTemplates.HYBRID_STANDARD,
# 默认
"default": BillingTemplates.CLAUDE_STANDARD,
}
def get_template(name: Optional[str]) -> List[BillingDimension]:
"""
获取计费模板
Args:
name: 模板名称(不区分大小写)
Returns:
计费维度列表
"""
if not name:
return BILLING_TEMPLATE_REGISTRY["default"]
template = BILLING_TEMPLATE_REGISTRY.get(name.lower())
if template is None:
available = ", ".join(sorted(BILLING_TEMPLATE_REGISTRY.keys()))
raise ValueError(f"Unknown billing template: {name!r}. Available: {available}")
return template
def list_templates() -> List[str]:
"""列出所有可用的模板名称"""
return list(BILLING_TEMPLATE_REGISTRY.keys())

View File

@@ -0,0 +1,267 @@
"""
Usage 字段映射器
将不同 API 格式的原始 usage 数据映射为标准化格式。
支持的格式:
- OPENAI / OPENAI_CLI: OpenAI Chat Completions API
- CLAUDE / CLAUDE_CLI: Anthropic Messages API
- GEMINI / GEMINI_CLI: Google Gemini API
"""
from typing import Any, Dict, Optional
from src.services.billing.models import StandardizedUsage
class UsageMapper:
"""
Usage 字段映射器
将不同 API 格式的 usage 统一映射为 StandardizedUsage。
示例:
# OpenAI 格式
raw_usage = {
"prompt_tokens": 100,
"completion_tokens": 50,
"prompt_tokens_details": {"cached_tokens": 20},
"completion_tokens_details": {"reasoning_tokens": 10}
}
usage = UsageMapper.map(raw_usage, "OPENAI")
# Claude 格式
raw_usage = {
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 30,
"cache_read_input_tokens": 20
}
usage = UsageMapper.map(raw_usage, "CLAUDE")
"""
# =========================================================================
# 字段映射配置
# 格式: "source_path" -> "target_field"
# source_path 支持点号分隔的嵌套路径
# =========================================================================
# OpenAI 格式字段映射
OPENAI_MAPPING: Dict[str, str] = {
"prompt_tokens": "input_tokens",
"completion_tokens": "output_tokens",
"prompt_tokens_details.cached_tokens": "cache_read_tokens",
"completion_tokens_details.reasoning_tokens": "reasoning_tokens",
}
# Claude 格式字段映射
CLAUDE_MAPPING: Dict[str, str] = {
"input_tokens": "input_tokens",
"output_tokens": "output_tokens",
"cache_creation_input_tokens": "cache_creation_tokens",
"cache_read_input_tokens": "cache_read_tokens",
}
# Gemini 格式字段映射
GEMINI_MAPPING: Dict[str, str] = {
"promptTokenCount": "input_tokens",
"candidatesTokenCount": "output_tokens",
"cachedContentTokenCount": "cache_read_tokens",
# Gemini 的 usageMetadata 格式
"usageMetadata.promptTokenCount": "input_tokens",
"usageMetadata.candidatesTokenCount": "output_tokens",
"usageMetadata.cachedContentTokenCount": "cache_read_tokens",
}
# 格式名称到映射的对应关系
FORMAT_MAPPINGS: Dict[str, Dict[str, str]] = {
"OPENAI": OPENAI_MAPPING,
"OPENAI_CLI": OPENAI_MAPPING,
"CLAUDE": CLAUDE_MAPPING,
"CLAUDE_CLI": CLAUDE_MAPPING,
"GEMINI": GEMINI_MAPPING,
"GEMINI_CLI": GEMINI_MAPPING,
}
@classmethod
def map(
cls,
raw_usage: Dict[str, Any],
api_format: str,
extra_mapping: Optional[Dict[str, str]] = None,
) -> StandardizedUsage:
"""
将原始 usage 映射为标准化格式
Args:
raw_usage: 原始 usage 字典
api_format: API 格式 ("OPENAI", "CLAUDE", "GEMINI" 等)
extra_mapping: 额外的字段映射(用于自定义扩展)
Returns:
标准化的 usage 对象
"""
if not raw_usage:
return StandardizedUsage()
# 获取对应格式的字段映射
mapping = cls._get_mapping(api_format)
# 合并额外映射
if extra_mapping:
mapping = {**mapping, **extra_mapping}
result = StandardizedUsage()
# 执行映射
for source_path, target_field in mapping.items():
value = cls._get_nested_value(raw_usage, source_path)
if value is not None:
result.set(target_field, value)
return result
@classmethod
def map_from_response(
cls,
response: Dict[str, Any],
api_format: str,
) -> StandardizedUsage:
"""
从完整响应中提取并映射 usage
不同 API 格式的 usage 位置可能不同:
- OpenAI: response["usage"]
- Claude: response["usage"] 或 message_delta 中
- Gemini: response["usageMetadata"]
Args:
response: 完整的 API 响应
api_format: API 格式
Returns:
标准化的 usage 对象
"""
format_upper = api_format.upper() if api_format else ""
# 提取 usage 部分
usage_data: Dict[str, Any] = {}
if format_upper.startswith("GEMINI"):
# Gemini: usageMetadata
usage_data = response.get("usageMetadata", {})
if not usage_data:
# 尝试从 candidates 中获取
candidates = response.get("candidates", [])
if candidates:
usage_data = candidates[0].get("usageMetadata", {})
else:
# OpenAI/Claude: usage
usage_data = response.get("usage", {})
return cls.map(usage_data, api_format)
@classmethod
def _get_mapping(cls, api_format: str) -> Dict[str, str]:
"""获取对应格式的字段映射"""
if not api_format:
return cls.CLAUDE_MAPPING
format_upper = api_format.upper()
# 精确匹配
if format_upper in cls.FORMAT_MAPPINGS:
return cls.FORMAT_MAPPINGS[format_upper]
# 前缀匹配
for key, mapping in cls.FORMAT_MAPPINGS.items():
if format_upper.startswith(key.split("_")[0]):
return mapping
# 默认使用 Claude 映射
return cls.CLAUDE_MAPPING
@classmethod
def _get_nested_value(cls, data: Dict[str, Any], path: str) -> Any:
"""
获取嵌套字段值
支持点号分隔的路径,如 "prompt_tokens_details.cached_tokens"
Args:
data: 数据字典
path: 字段路径
Returns:
字段值,不存在则返回 None
"""
if not data or not path:
return None
keys = path.split(".")
value: Any = data
for key in keys:
if isinstance(value, dict):
value = value.get(key)
if value is None:
return None
else:
return None
return value
@classmethod
def register_format(cls, format_name: str, mapping: Dict[str, str]) -> None:
"""
注册新的格式映射
Args:
format_name: 格式名称(会自动转为大写)
mapping: 字段映射
"""
cls.FORMAT_MAPPINGS[format_name.upper()] = mapping
@classmethod
def get_supported_formats(cls) -> list:
"""获取所有支持的格式"""
return list(cls.FORMAT_MAPPINGS.keys())
# =========================================================================
# 便捷函数
# =========================================================================
def map_usage(
raw_usage: Dict[str, Any],
api_format: str,
) -> StandardizedUsage:
"""
便捷函数:将原始 usage 映射为标准化格式
Args:
raw_usage: 原始 usage 字典
api_format: API 格式
Returns:
StandardizedUsage 对象
"""
return UsageMapper.map(raw_usage, api_format)
def map_usage_from_response(
response: Dict[str, Any],
api_format: str,
) -> StandardizedUsage:
"""
便捷函数:从响应中提取并映射 usage
Args:
response: API 响应
api_format: API 格式
Returns:
StandardizedUsage 对象
"""
return UsageMapper.map_from_response(response, api_format)

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 删除 GlobalModel
默认行为: 级联删除所有关联的 Provider 模型实现 默认行为: 级联删除所有关联的 Provider 模型实现
注意: 不清理 API Key 和 User 的 allowed_models 引用,
保留无效引用可让用户在前端看到"已失效"的模型,便于手动清理或等待重建同名模型
""" """
global_model = GlobalModelService.get_global_model(db, global_model_id) 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", ""))) result["reason"] = str(data.get("reason", data.get("code", "")))
except (json.JSONDecodeError, TypeError, KeyError): except (json.JSONDecodeError, TypeError, KeyError):
result["message"] = error_text[:500] if len(error_text) > 500 else error_text result["message"] = error_text
return result return result
@@ -323,8 +323,8 @@ class ErrorClassifier:
if parts: if parts:
return ": ".join(parts) if len(parts) > 1 else parts[0] 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( def classify(
self, self,
@@ -484,11 +484,15 @@ class ErrorClassifier:
return ProviderNotAvailableException( return ProviderNotAvailableException(
message=detailed_message, message=detailed_message,
provider_name=provider_name, provider_name=provider_name,
upstream_status=status,
upstream_response=error_response_text,
) )
return ProviderNotAvailableException( return ProviderNotAvailableException(
message=detailed_message, message=detailed_message,
provider_name=provider_name, provider_name=provider_name,
upstream_status=status,
upstream_response=error_response_text,
) )
async def handle_http_error( async def handle_http_error(
@@ -532,12 +536,14 @@ class ErrorClassifier:
provider_name = str(provider.name) provider_name = str(provider.name)
# 尝试读取错误响应内容 # 尝试读取错误响应内容
error_response_text = None # 优先使用 handler 附加的 upstream_response 属性(流式请求中 response.text 可能为空)
try: error_response_text = getattr(http_error, "upstream_response", None)
if http_error.response and hasattr(http_error.response, "text"): if not error_response_text:
error_response_text = http_error.response.text[:1000] # 限制长度 try:
except Exception: if http_error.response and hasattr(http_error.response, "text"):
pass error_response_text = http_error.response.text
except Exception:
pass
logger.warning(f" [{request_id}] HTTP错误 (attempt={attempt}/{max_attempts}): " logger.warning(f" [{request_id}] HTTP错误 (attempt={attempt}/{max_attempts}): "
f"{http_error.response.status_code if http_error.response else 'unknown'}") 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 sqlalchemy.orm import Session
from src.core.enums import APIFormat from src.core.enums import APIFormat
from src.core.error_utils import extract_error_message
from src.core.exceptions import ( from src.core.exceptions import (
ConcurrencyLimitError, ConcurrencyLimitError,
ProviderNotAvailableException, ProviderNotAvailableException,
@@ -401,7 +402,7 @@ class FallbackOrchestrator:
db=self.db, db=self.db,
candidate_id=candidate_record_id, candidate_id=candidate_record_id,
error_type="HTTPStatusError", error_type="HTTPStatusError",
error_message=f"HTTP {status_code}: {str(cause)}", error_message=extract_error_message(cause, status_code),
status_code=status_code, status_code=status_code,
latency_ms=elapsed_ms, latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent, concurrent_requests=captured_key_concurrent,
@@ -425,31 +426,22 @@ class FallbackOrchestrator:
attempt=attempt, attempt=attempt,
max_attempts=max_attempts, 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( RequestCandidateService.mark_candidate_failed(
db=self.db, db=self.db,
candidate_id=candidate_record_id, candidate_id=candidate_record_id,
error_type=type(cause).__name__, error_type=type(cause).__name__,
error_message=error_msg, error_message=extract_error_message(cause),
latency_ms=elapsed_ms, latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent, concurrent_requests=captured_key_concurrent,
) )
return "continue" if has_retry_left else "break" 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( RequestCandidateService.mark_candidate_failed(
db=self.db, db=self.db,
candidate_id=candidate_record_id, candidate_id=candidate_record_id,
error_type=type(cause).__name__, error_type=type(cause).__name__,
error_message=error_msg, error_message=extract_error_message(cause),
latency_ms=elapsed_ms, latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent, concurrent_requests=captured_key_concurrent,
) )
@@ -543,7 +535,9 @@ class FallbackOrchestrator:
raise last_error 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( async def _try_candidate_with_retries(
self, self,
@@ -565,6 +559,7 @@ class FallbackOrchestrator:
provider = candidate.provider provider = candidate.provider
endpoint = candidate.endpoint endpoint = candidate.endpoint
max_retries_for_candidate = int(endpoint.max_retries) if candidate.is_cached else 1 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): for retry_index in range(max_retries_for_candidate):
attempt_counter += 1 attempt_counter += 1
@@ -599,6 +594,7 @@ class FallbackOrchestrator:
return {"success": True, "response": response} return {"success": True, "response": response}
except ExecutionError as exec_err: except ExecutionError as exec_err:
last_error = exec_err.cause
action = await self._handle_candidate_error( action = await self._handle_candidate_error(
exec_err=exec_err, exec_err=exec_err,
candidate=candidate, candidate=candidate,
@@ -630,6 +626,7 @@ class FallbackOrchestrator:
"success": False, "success": False,
"attempt_counter": attempt_counter, "attempt_counter": attempt_counter,
"max_attempts": max_attempts, "max_attempts": max_attempts,
"error": last_error,
} }
def _attach_metadata_to_error( def _attach_metadata_to_error(
@@ -678,6 +675,7 @@ class FallbackOrchestrator:
last_candidate: Optional[ProviderCandidate], last_candidate: Optional[ProviderCandidate],
model_name: str, model_name: str,
api_format_enum: APIFormat, api_format_enum: APIFormat,
last_error: Optional[Exception] = None,
) -> NoReturn: ) -> NoReturn:
"""所有组合都失败时抛出异常""" """所有组合都失败时抛出异常"""
logger.error(f" [{request_id}] 所有 {max_attempts} 个组合均失败") logger.error(f" [{request_id}] 所有 {max_attempts} 个组合均失败")
@@ -693,9 +691,38 @@ class FallbackOrchestrator:
"api_format": api_format_enum.value, "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( raise ProviderNotAvailableException(
f"所有Provider均不可用已尝试{max_attempts}个组合", f"所有Provider均不可用已尝试{max_attempts}个组合",
request_metadata=request_metadata, request_metadata=request_metadata,
upstream_status=upstream_status,
upstream_response=upstream_response,
) )
async def execute_with_fallback( async def execute_with_fallback(

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More