mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-06 09:42:28 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f78d5cbf3 | ||
|
|
431c6de8d2 | ||
|
|
142e15bbcc | ||
|
|
31acc5c607 | ||
|
|
bfa0a26d41 | ||
|
|
93ab9b6a5e | ||
|
|
35e29d46bd | ||
|
|
465da6f818 | ||
|
|
e5f12fddd9 | ||
|
|
4fa9a1303a | ||
|
|
43f349d415 | ||
|
|
02069954de | ||
|
|
2e15875fed | ||
|
|
b34cfb676d | ||
|
|
3064497636 | ||
|
|
dec681fea0 | ||
|
|
523e27ba9a | ||
|
|
e7db76e581 | ||
|
|
689339117a | ||
|
|
b202765be4 | ||
|
|
3bbf3073df | ||
|
|
f46aaa2182 | ||
|
|
a2f33a6c35 | ||
|
|
b6bd6357ed | ||
|
|
c3a5878b1b | ||
|
|
c02ac56da8 | ||
|
|
cddc22d2b3 | ||
|
|
11ded575d5 | ||
|
|
394cc536a9 | ||
|
|
6bd8cdb9cf | ||
|
|
e20a09f15a | ||
|
|
b89a4af0cf | ||
|
|
a56854af43 | ||
|
|
4a35d78c8d | ||
|
|
26b281271e |
15
LICENSE
15
LICENSE
@@ -5,12 +5,17 @@ Aether 非商业开源许可证
|
|||||||
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
||||||
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
||||||
|
|
||||||
1. 仅限非商业用途
|
1. 仅限非盈利用途
|
||||||
本软件不得用于商业目的。商业目的包括但不限于:
|
本软件不得用于盈利目的。盈利目的包括但不限于:
|
||||||
- 出售本软件或任何衍生作品
|
- 出售本软件或任何衍生作品
|
||||||
- 使用本软件提供付费服务
|
- 使用本软件提供付费服务
|
||||||
- 将本软件用于商业产品或服务
|
- 将本软件用于以盈利为目的的商业产品或服务
|
||||||
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
|
|
||||||
|
以下用途被明确允许:
|
||||||
|
- 个人学习和研究
|
||||||
|
- 教育机构的教学和研究
|
||||||
|
- 非盈利组织的内部使用
|
||||||
|
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
|
||||||
|
|
||||||
2. 署名要求
|
2. 署名要求
|
||||||
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
||||||
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
|
|||||||
您不得以不同的条款将本软件再许可给他人。
|
您不得以不同的条款将本软件再许可给他人。
|
||||||
|
|
||||||
5. 商业许可
|
5. 商业许可
|
||||||
如需商业使用,请联系版权持有人以获取单独的商业许可。
|
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
|
||||||
|
|
||||||
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
||||||
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -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
|
||||||
|
|
||||||
|
[](https://star-history.com/#fawney19/Aether&Date)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
BIN
docs/author/qq_qrcode.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
BIN
docs/author/wechat_payment.jpg
Normal file
BIN
docs/author/wechat_payment.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
522
frontend/package-lock.json
generated
522
frontend/package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ export {
|
|||||||
updateGlobalModel,
|
updateGlobalModel,
|
||||||
deleteGlobalModel,
|
deleteGlobalModel,
|
||||||
batchAssignToProviders,
|
batchAssignToProviders,
|
||||||
|
getGlobalModelProviders,
|
||||||
} from './endpoints/global-models'
|
} from './endpoints/global-models'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
192
frontend/src/components/VerificationCodeInput.vue
Normal file
192
frontend/src/components/VerificationCodeInput.vue
Normal 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>
|
||||||
117
frontend/src/components/common/ModelMultiSelect.vue
Normal file
117
frontend/src/components/common/ModelMultiSelect.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 定义
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
34
frontend/src/composables/useInvalidModels.ts
Normal file
34
frontend/src/composables/useInvalidModels.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
640
frontend/src/features/auth/components/RegisterDialog.vue
Normal file
640
frontend/src/features/auth/components/RegisterDialog.vue
Normal 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>
|
||||||
@@ -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()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 格式显示名称
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }>>([])
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
|
|||||||
@@ -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 },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
|
|
||||||
// 生成模拟的请求间隔数据
|
// 生成模拟的请求间隔数据
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
856
frontend/src/views/admin/EmailSettings.vue
Normal file
856
frontend/src/views/admin/EmailSettings.vue
Normal 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>
|
||||||
@@ -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, '加载关联提供商失败'), '错误')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示请求详情
|
// 显示请求详情
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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_code):stream, standard, error
|
# 旧的筛选值(基于 is_stream 和 status_code):stream, 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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# 模型列表查询 - 子类应覆盖此方法
|
# 模型列表查询 - 子类应覆盖此方法
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# 模型列表查询 - 子类应覆盖此方法
|
# 模型列表查询 - 子类应覆盖此方法
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
# 便捷访问函数
|
# 便捷访问函数
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# 探测性扩容最小请求数 - 在探测间隔内至少需要这么多请求
|
# 探测性扩容最小请求数 - 在探测间隔内至少需要这么多请求
|
||||||
|
|||||||
@@ -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
28
src/core/error_utils.py
Normal 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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),仅用于独立Key,None = 无限制"
|
None, description="初始余额(USD),仅用于独立Key,None = 无限制"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 能力配置
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 实现"""
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
51
src/services/billing/__init__.py
Normal file
51
src/services/billing/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
339
src/services/billing/calculator.py
Normal file
339
src/services/billing/calculator.py
Normal 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,
|
||||||
|
}
|
||||||
281
src/services/billing/models.py
Normal file
281
src/services/billing/models.py
Normal 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,
|
||||||
|
)
|
||||||
213
src/services/billing/templates.py
Normal file
213
src/services/billing/templates.py
Normal 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())
|
||||||
267
src/services/billing/usage_mapper.py
Normal file
267
src/services/billing/usage_mapper.py
Normal 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)
|
||||||
9
src/services/email/__init__.py
Normal file
9
src/services/email/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
邮箱验证服务模块
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .email_sender import EmailSenderService
|
||||||
|
from .email_template import EmailTemplate
|
||||||
|
from .email_verification import EmailVerificationService
|
||||||
|
|
||||||
|
__all__ = ["EmailVerificationService", "EmailSenderService", "EmailTemplate"]
|
||||||
473
src/services/email/email_sender.py
Normal file
473
src/services/email/email_sender.py
Normal 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
|
||||||
442
src/services/email/email_template.py
Normal file
442
src/services/email/email_template.py
Normal 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, "通知")
|
||||||
247
src/services/email/email_verification.py
Normal file
247
src/services/email/email_verification.py
Normal 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)}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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'}")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user