mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-09 03:02:26 +08:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59447fc12b | ||
|
|
c8033fb6ab | ||
|
|
e33d5b952c | ||
|
|
4345ac2ba2 | ||
|
|
a12b43ce5c | ||
|
|
6885cf1f6d | ||
|
|
00f6fafcfc | ||
|
|
42dc64246c | ||
|
|
fbe303a3cd | ||
|
|
373845450b | ||
|
|
084bbc0bef | ||
|
|
0061fc04b7 | ||
|
|
f6a6410626 | ||
|
|
835be3d329 | ||
|
|
2395093394 | ||
|
|
28209e1c2a | ||
|
|
00562dd1d4 | ||
|
|
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 | ||
|
|
3e4309eba3 | ||
|
|
414f45aa71 | ||
|
|
ebdc76346f | ||
|
|
64bfa955f4 | ||
|
|
612992fa1f | ||
|
|
c02ac56da8 | ||
|
|
9bfb295238 | ||
|
|
cddc22d2b3 | ||
|
|
11ded575d5 | ||
|
|
394cc536a9 | ||
|
|
6bd8cdb9cf | ||
|
|
e20a09f15a | ||
|
|
b89a4af0cf | ||
|
|
a56854af43 | ||
|
|
4a35d78c8d | ||
|
|
26b281271e | ||
|
|
96094cfde2 | ||
|
|
7e26af5476 | ||
|
|
c8dfb784bc | ||
|
|
fd3a5a5afe | ||
|
|
599b3d4c95 | ||
|
|
41719a00e7 |
@@ -39,7 +39,18 @@ COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
|
||||
# Nginx 配置模板
|
||||
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
|
||||
RUN printf '%s\n' \
|
||||
'map $http_x_real_ip $real_ip {' \
|
||||
' default $http_x_real_ip;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_x_forwarded_for $forwarded_for {' \
|
||||
' default $http_x_forwarded_for;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
@@ -47,6 +58,15 @@ RUN printf '%s\n' \
|
||||
' index index.html;' \
|
||||
' client_max_body_size 100M;' \
|
||||
'' \
|
||||
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
|
||||
' gzip on;' \
|
||||
' gzip_min_length 256;' \
|
||||
' gzip_comp_level 5;' \
|
||||
' gzip_vary on;' \
|
||||
' gzip_proxied any;' \
|
||||
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
|
||||
' gzip_disable "msie6";' \
|
||||
'' \
|
||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||
' expires 1y;' \
|
||||
' add_header Cache-Control "public, no-transform";' \
|
||||
@@ -62,6 +82,15 @@ RUN printf '%s\n' \
|
||||
' try_files $uri $uri/ /index.html;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location / {' \
|
||||
' try_files $uri $uri/ @backend;' \
|
||||
' }' \
|
||||
@@ -70,8 +99,8 @@ RUN printf '%s\n' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
@@ -98,14 +127,14 @@ RUN printf '%s\n' \
|
||||
'pidfile=/var/run/supervisord.pid' \
|
||||
'' \
|
||||
'[program:nginx]' \
|
||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/8084/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/var/log/nginx/access.log' \
|
||||
'stderr_logfile=/var/log/nginx/error.log' \
|
||||
'' \
|
||||
'[program:app]' \
|
||||
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
||||
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:8084 --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
||||
'directory=/app' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
@@ -124,7 +153,8 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PORT=8084
|
||||
PORT=8084 \
|
||||
GUNICORN_WORKERS=4
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
@@ -40,7 +40,18 @@ COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
|
||||
# Nginx 配置模板
|
||||
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
|
||||
RUN printf '%s\n' \
|
||||
'map $http_x_real_ip $real_ip {' \
|
||||
' default $http_x_real_ip;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_x_forwarded_for $forwarded_for {' \
|
||||
' default $http_x_forwarded_for;' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
@@ -48,6 +59,15 @@ RUN printf '%s\n' \
|
||||
' index index.html;' \
|
||||
' client_max_body_size 100M;' \
|
||||
'' \
|
||||
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
|
||||
' gzip on;' \
|
||||
' gzip_min_length 256;' \
|
||||
' gzip_comp_level 5;' \
|
||||
' gzip_vary on;' \
|
||||
' gzip_proxied any;' \
|
||||
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
|
||||
' gzip_disable "msie6";' \
|
||||
'' \
|
||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||
' expires 1y;' \
|
||||
' add_header Cache-Control "public, no-transform";' \
|
||||
@@ -71,8 +91,8 @@ RUN printf '%s\n' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
|
||||
15
LICENSE
15
LICENSE
@@ -5,12 +5,17 @@ Aether 非商业开源许可证
|
||||
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
||||
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
||||
|
||||
1. 仅限非商业用途
|
||||
本软件不得用于商业目的。商业目的包括但不限于:
|
||||
1. 仅限非盈利用途
|
||||
本软件不得用于盈利目的。盈利目的包括但不限于:
|
||||
- 出售本软件或任何衍生作品
|
||||
- 使用本软件提供付费服务
|
||||
- 将本软件用于商业产品或服务
|
||||
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
|
||||
- 将本软件用于以盈利为目的的商业产品或服务
|
||||
|
||||
以下用途被明确允许:
|
||||
- 个人学习和研究
|
||||
- 教育机构的教学和研究
|
||||
- 非盈利组织的内部使用
|
||||
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
|
||||
|
||||
2. 署名要求
|
||||
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
||||
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
|
||||
您不得以不同的条款将本软件再许可给他人。
|
||||
|
||||
5. 商业许可
|
||||
如需商业使用,请联系版权持有人以获取单独的商业许可。
|
||||
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
|
||||
|
||||
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
||||
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
||||
|
||||
26
README.md
26
README.md
@@ -51,20 +51,20 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd aether
|
||||
cd Aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
|
||||
# 3. 部署
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
|
||||
# 4. 首次部署时, 初始化数据库
|
||||
./migrate.sh
|
||||
|
||||
# 5. 更新
|
||||
docker-compose pull && docker-compose up -d && ./migrate.sh
|
||||
docker compose pull && docker compose up -d && ./migrate.sh
|
||||
```
|
||||
|
||||
### Docker Compose(本地构建镜像)
|
||||
@@ -72,7 +72,7 @@ docker-compose pull && docker-compose up -d && ./migrate.sh
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd aether
|
||||
cd Aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
@@ -86,7 +86,7 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
|
||||
```bash
|
||||
# 启动依赖
|
||||
docker-compose -f docker-compose.build.yml up -d postgres redis
|
||||
docker compose -f docker-compose.build.yml up -d postgres redis
|
||||
|
||||
# 后端
|
||||
uv sync
|
||||
@@ -143,7 +143,7 @@ cd frontend && npm install && npm run dev
|
||||
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
|
||||
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
|
||||
|
||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能调用支持 1H缓存的模型
|
||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
|
||||
|
||||
### Q: 如何配置负载均衡?
|
||||
|
||||
@@ -162,4 +162,16 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [Aether 非商业开源许可证](LICENSE)。
|
||||
本项目采用 [Aether 非商业开源许可证](LICENSE)。允许个人学习、教育研究、非盈利组织及企业内部非盈利性质的使用;禁止用于盈利目的。商业使用请联系获取商业许可。
|
||||
|
||||
## 联系作者
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
|
||||
</p>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#fawney19/Aether&Date)
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from src.models.database import Base
|
||||
config = context.config
|
||||
|
||||
# 从环境变量获取数据库 URL
|
||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker-compose 保持一致)
|
||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
db_password = os.getenv("DB_PASSWORD", "")
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""add ldap authentication support
|
||||
|
||||
Revision ID: c3d4e5f6g7h8
|
||||
Revises: b2c3d4e5f6g7
|
||||
Create Date: 2026-01-01 14:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c3d4e5f6g7h8'
|
||||
down_revision = 'b2c3d4e5f6g7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _type_exists(conn, type_name: str) -> bool:
|
||||
"""检查 PostgreSQL 类型是否存在"""
|
||||
result = conn.execute(
|
||||
text("SELECT 1 FROM pg_type WHERE typname = :name"),
|
||||
{"name": type_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = conn.execute(
|
||||
text("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table AND column_name = :column
|
||||
"""),
|
||||
{"table": table_name, "column": column_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _index_exists(conn, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
result = conn.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": index_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
result = conn.execute(
|
||||
text("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = :name AND table_schema = 'public'
|
||||
"""),
|
||||
{"name": table_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 LDAP 认证支持
|
||||
|
||||
1. 创建 authsource 枚举类型
|
||||
2. 在 users 表添加 auth_source 字段和 LDAP 标识字段
|
||||
3. 创建 ldap_configs 表
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. 创建 authsource 枚举类型(幂等)
|
||||
if not _type_exists(conn, 'authsource'):
|
||||
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
|
||||
|
||||
# 2. 在 users 表添加字段(幂等)
|
||||
if not _column_exists(conn, 'users', 'auth_source'):
|
||||
op.add_column('users', sa.Column(
|
||||
'auth_source',
|
||||
sa.Enum('local', 'ldap', name='authsource', create_type=False),
|
||||
nullable=False,
|
||||
server_default='local'
|
||||
))
|
||||
|
||||
if not _column_exists(conn, 'users', 'ldap_dn'):
|
||||
op.add_column('users', sa.Column('ldap_dn', sa.String(length=512), nullable=True))
|
||||
|
||||
if not _column_exists(conn, 'users', 'ldap_username'):
|
||||
op.add_column('users', sa.Column('ldap_username', sa.String(length=255), nullable=True))
|
||||
|
||||
# 创建索引(幂等)
|
||||
if not _index_exists(conn, 'ix_users_ldap_dn'):
|
||||
op.create_index('ix_users_ldap_dn', 'users', ['ldap_dn'])
|
||||
|
||||
if not _index_exists(conn, 'ix_users_ldap_username'):
|
||||
op.create_index('ix_users_ldap_username', 'users', ['ldap_username'])
|
||||
|
||||
# 3. 创建 ldap_configs 表(幂等)
|
||||
if not _table_exists(conn, 'ldap_configs'):
|
||||
op.create_table(
|
||||
'ldap_configs',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('server_url', sa.String(length=255), nullable=False),
|
||||
sa.Column('bind_dn', sa.String(length=255), nullable=False),
|
||||
sa.Column('bind_password_encrypted', sa.Text(), nullable=True),
|
||||
sa.Column('base_dn', sa.String(length=255), nullable=False),
|
||||
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
|
||||
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
|
||||
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
|
||||
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
|
||||
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('connect_timeout', sa.Integer(), nullable=False, server_default='10'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚 LDAP 认证支持
|
||||
|
||||
警告:回滚前请确保:
|
||||
1. 已备份数据库
|
||||
2. 没有 LDAP 用户需要保留
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 检查是否存在 LDAP 用户,防止数据丢失
|
||||
if _column_exists(conn, 'users', 'auth_source'):
|
||||
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE auth_source = 'ldap'"))
|
||||
ldap_user_count = result.scalar()
|
||||
if ldap_user_count and ldap_user_count > 0:
|
||||
raise RuntimeError(
|
||||
f"无法回滚:存在 {ldap_user_count} 个 LDAP 用户。"
|
||||
f"请先删除或转换这些用户,或使用 --force 参数强制回滚(将丢失数据)。"
|
||||
)
|
||||
|
||||
# 1. 删除 ldap_configs 表(幂等)
|
||||
if _table_exists(conn, 'ldap_configs'):
|
||||
op.drop_table('ldap_configs')
|
||||
|
||||
# 2. 删除 users 表的 LDAP 相关字段(幂等)
|
||||
if _index_exists(conn, 'ix_users_ldap_username'):
|
||||
op.drop_index('ix_users_ldap_username', table_name='users')
|
||||
|
||||
if _index_exists(conn, 'ix_users_ldap_dn'):
|
||||
op.drop_index('ix_users_ldap_dn', table_name='users')
|
||||
|
||||
if _column_exists(conn, 'users', 'ldap_username'):
|
||||
op.drop_column('users', 'ldap_username')
|
||||
|
||||
if _column_exists(conn, 'users', 'ldap_dn'):
|
||||
op.drop_column('users', 'ldap_dn')
|
||||
|
||||
if _column_exists(conn, 'users', 'auth_source'):
|
||||
op.drop_column('users', 'auth_source')
|
||||
|
||||
# 3. 删除 authsource 枚举类型(幂等)
|
||||
# 注意:不使用 CASCADE,因为此时所有依赖应该已被删除
|
||||
if _type_exists(conn, 'authsource'):
|
||||
conn.execute(text("DROP TYPE authsource"))
|
||||
@@ -0,0 +1,131 @@
|
||||
"""add_management_tokens_table
|
||||
|
||||
Revision ID: ad55f1d008b7
|
||||
Revises: c3d4e5f6g7h8
|
||||
Create Date: 2026-01-06 15:24:10.660394+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ad55f1d008b7'
|
||||
down_revision = 'c3d4e5f6g7h8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx["name"] == index_name for idx in indexes)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
"""检查约束是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
try:
|
||||
constraints = inspector.get_unique_constraints(table_name)
|
||||
if any(c["name"] == constraint_name for c in constraints):
|
||||
return True
|
||||
# 也检查 check 约束
|
||||
check_constraints = inspector.get_check_constraints(table_name)
|
||||
if any(c["name"] == constraint_name for c in check_constraints):
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:创建 management_tokens 表"""
|
||||
# 幂等性检查
|
||||
if table_exists("management_tokens"):
|
||||
# 表已存在,检查是否需要添加约束
|
||||
if not constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||
op.create_unique_constraint(
|
||||
"uq_management_tokens_user_name",
|
||||
"management_tokens",
|
||||
["user_id", "name"],
|
||||
)
|
||||
# 添加 IP 白名单非空检查约束
|
||||
if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||
op.create_check_constraint(
|
||||
"check_allowed_ips_not_empty",
|
||||
"management_tokens",
|
||||
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||
)
|
||||
return
|
||||
|
||||
op.create_table('management_tokens',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||
sa.Column('token_prefix', sa.String(length=12), nullable=True),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('allowed_ips', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_used_ip', sa.String(length=45), nullable=True),
|
||||
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False)
|
||||
op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True)
|
||||
# 添加用户名称唯一约束
|
||||
op.create_unique_constraint(
|
||||
"uq_management_tokens_user_name",
|
||||
"management_tokens",
|
||||
["user_id", "name"],
|
||||
)
|
||||
# 添加 IP 白名单非空检查约束
|
||||
# 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
|
||||
op.create_check_constraint(
|
||||
"check_allowed_ips_not_empty",
|
||||
"management_tokens",
|
||||
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:删除 management_tokens 表"""
|
||||
# 幂等性检查
|
||||
if not table_exists("management_tokens"):
|
||||
return
|
||||
|
||||
# 删除约束
|
||||
if constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||
op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check")
|
||||
if constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||
op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique")
|
||||
|
||||
# 删除索引
|
||||
if index_exists("management_tokens", "ix_management_tokens_token_hash"):
|
||||
op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens')
|
||||
if index_exists("management_tokens", "idx_management_tokens_user_id"):
|
||||
op.drop_index('idx_management_tokens_user_id', table_name='management_tokens')
|
||||
if index_exists("management_tokens", "idx_management_tokens_is_active"):
|
||||
op.drop_index('idx_management_tokens_is_active', table_name='management_tokens')
|
||||
|
||||
# 删除表
|
||||
op.drop_table('management_tokens')
|
||||
@@ -0,0 +1,73 @@
|
||||
"""cleanup ambiguous database fields
|
||||
|
||||
Revision ID: 02a45b66b7c4
|
||||
Revises: ad55f1d008b7
|
||||
Create Date: 2026-01-07 11:20:12.684426+00:00
|
||||
|
||||
变更内容:
|
||||
1. users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
|
||||
2. api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
|
||||
3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复,且未使用)
|
||||
4. usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '02a45b66b7c4'
|
||||
down_revision = 'ad55f1d008b7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
1. users.allowed_endpoints -> allowed_api_formats(重命名)
|
||||
2. api_keys.allowed_endpoints 删除
|
||||
3. providers.rate_limit 删除(与 rpm_limit 重复)
|
||||
4. usage.provider -> provider_name(重命名)
|
||||
"""
|
||||
# 1. users 表:重命名 allowed_endpoints 为 allowed_api_formats
|
||||
if _column_exists('users', 'allowed_endpoints'):
|
||||
op.alter_column('users', 'allowed_endpoints', new_column_name='allowed_api_formats')
|
||||
|
||||
# 2. api_keys 表:删除 allowed_endpoints 字段
|
||||
if _column_exists('api_keys', 'allowed_endpoints'):
|
||||
op.drop_column('api_keys', 'allowed_endpoints')
|
||||
|
||||
# 3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复)
|
||||
if _column_exists('providers', 'rate_limit'):
|
||||
op.drop_column('providers', 'rate_limit')
|
||||
|
||||
# 4. usage 表:重命名 provider 为 provider_name
|
||||
if _column_exists('usage', 'provider'):
|
||||
op.alter_column('usage', 'provider', new_column_name='provider_name')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚:恢复原字段"""
|
||||
# 4. usage 表:将 provider_name 改回 provider
|
||||
if _column_exists('usage', 'provider_name'):
|
||||
op.alter_column('usage', 'provider_name', new_column_name='provider')
|
||||
|
||||
# 3. providers 表:恢复 rate_limit 字段
|
||||
if not _column_exists('providers', 'rate_limit'):
|
||||
op.add_column('providers', sa.Column('rate_limit', sa.Integer(), nullable=True))
|
||||
|
||||
# 2. api_keys 表:恢复 allowed_endpoints 字段
|
||||
if not _column_exists('api_keys', 'allowed_endpoints'):
|
||||
op.add_column('api_keys', sa.Column('allowed_endpoints', sa.JSON(), nullable=True))
|
||||
|
||||
# 1. users 表:将 allowed_api_formats 改回 allowed_endpoints
|
||||
if _column_exists('users', 'allowed_api_formats'):
|
||||
op.alter_column('users', 'allowed_api_formats', new_column_name='allowed_endpoints')
|
||||
@@ -1,7 +1,7 @@
|
||||
# Aether 部署配置 - 本地构建
|
||||
# 使用方法:
|
||||
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
|
||||
# 启动服务: docker-compose -f docker-compose.build.yml up -d --build
|
||||
# 启动服务: docker compose -f docker-compose.build.yml up -d --build
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
@@ -44,20 +44,15 @@ services:
|
||||
dockerfile: Dockerfile.app.local
|
||||
image: aether-app:latest
|
||||
container_name: aether-app
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 需要组合的变量
|
||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
||||
PORT: 8084
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
JWT_ALGORITHM: HS256
|
||||
JWT_EXPIRATION_DELTA: 86400
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
|
||||
# Supervisor 需要的变量
|
||||
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
||||
# 容器级别设置
|
||||
TZ: Asia/Shanghai
|
||||
PYTHONIOENCODING: utf-8
|
||||
LANG: C.UTF-8
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Aether 部署配置 - 使用预构建镜像
|
||||
# 使用方法: docker-compose up -d
|
||||
# 使用方法: docker compose up -d
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
@@ -35,20 +35,15 @@ services:
|
||||
app:
|
||||
image: ghcr.io/fawney19/aether:latest
|
||||
container_name: aether-app
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 需要组合的变量
|
||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
||||
PORT: 8084
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
JWT_ALGORITHM: HS256
|
||||
JWT_EXPIRATION_DELTA: 86400
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
|
||||
# Supervisor 需要的变量
|
||||
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
|
||||
# 容器级别设置
|
||||
TZ: Asia/Shanghai
|
||||
PYTHONIOENCODING: utf-8
|
||||
LANG: C.UTF-8
|
||||
|
||||
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",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -305,6 +306,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -316,9 +318,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -333,9 +335,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -350,9 +352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -367,9 +369,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -384,9 +386,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -401,9 +403,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -418,9 +420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -435,9 +437,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -452,9 +454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -469,9 +471,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -486,9 +488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -503,9 +505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
||||
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
|
||||
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -520,9 +522,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
||||
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
|
||||
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -537,9 +539,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -554,9 +556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
||||
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
|
||||
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -571,9 +573,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
||||
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
|
||||
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -588,9 +590,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -605,9 +607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -622,9 +624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -639,9 +641,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -656,9 +658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -673,9 +675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -690,9 +692,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -707,9 +709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -724,9 +726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -741,9 +743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1598,6 +1600,7 @@
|
||||
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
@@ -1676,6 +1679,7 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@@ -2004,6 +2008,7 @@
|
||||
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.10",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -2301,6 +2306,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2602,6 +2608,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.2",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -2718,6 +2725,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -2940,6 +2948,7 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@@ -2999,18 +3008,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -3134,9 +3131,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -3147,32 +3144,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.9",
|
||||
"@esbuild/android-arm": "0.25.9",
|
||||
"@esbuild/android-arm64": "0.25.9",
|
||||
"@esbuild/android-x64": "0.25.9",
|
||||
"@esbuild/darwin-arm64": "0.25.9",
|
||||
"@esbuild/darwin-x64": "0.25.9",
|
||||
"@esbuild/freebsd-arm64": "0.25.9",
|
||||
"@esbuild/freebsd-x64": "0.25.9",
|
||||
"@esbuild/linux-arm": "0.25.9",
|
||||
"@esbuild/linux-arm64": "0.25.9",
|
||||
"@esbuild/linux-ia32": "0.25.9",
|
||||
"@esbuild/linux-loong64": "0.25.9",
|
||||
"@esbuild/linux-mips64el": "0.25.9",
|
||||
"@esbuild/linux-ppc64": "0.25.9",
|
||||
"@esbuild/linux-riscv64": "0.25.9",
|
||||
"@esbuild/linux-s390x": "0.25.9",
|
||||
"@esbuild/linux-x64": "0.25.9",
|
||||
"@esbuild/netbsd-arm64": "0.25.9",
|
||||
"@esbuild/netbsd-x64": "0.25.9",
|
||||
"@esbuild/openbsd-arm64": "0.25.9",
|
||||
"@esbuild/openbsd-x64": "0.25.9",
|
||||
"@esbuild/openharmony-arm64": "0.25.9",
|
||||
"@esbuild/sunos-x64": "0.25.9",
|
||||
"@esbuild/win32-arm64": "0.25.9",
|
||||
"@esbuild/win32-ia32": "0.25.9",
|
||||
"@esbuild/win32-x64": "0.25.9"
|
||||
"@esbuild/aix-ppc64": "0.27.2",
|
||||
"@esbuild/android-arm": "0.27.2",
|
||||
"@esbuild/android-arm64": "0.27.2",
|
||||
"@esbuild/android-x64": "0.27.2",
|
||||
"@esbuild/darwin-arm64": "0.27.2",
|
||||
"@esbuild/darwin-x64": "0.27.2",
|
||||
"@esbuild/freebsd-arm64": "0.27.2",
|
||||
"@esbuild/freebsd-x64": "0.27.2",
|
||||
"@esbuild/linux-arm": "0.27.2",
|
||||
"@esbuild/linux-arm64": "0.27.2",
|
||||
"@esbuild/linux-ia32": "0.27.2",
|
||||
"@esbuild/linux-loong64": "0.27.2",
|
||||
"@esbuild/linux-mips64el": "0.27.2",
|
||||
"@esbuild/linux-ppc64": "0.27.2",
|
||||
"@esbuild/linux-riscv64": "0.27.2",
|
||||
"@esbuild/linux-s390x": "0.27.2",
|
||||
"@esbuild/linux-x64": "0.27.2",
|
||||
"@esbuild/netbsd-arm64": "0.27.2",
|
||||
"@esbuild/netbsd-x64": "0.27.2",
|
||||
"@esbuild/openbsd-arm64": "0.27.2",
|
||||
"@esbuild/openbsd-x64": "0.27.2",
|
||||
"@esbuild/openharmony-arm64": "0.27.2",
|
||||
"@esbuild/sunos-x64": "0.27.2",
|
||||
"@esbuild/win32-arm64": "0.27.2",
|
||||
"@esbuild/win32-ia32": "0.27.2",
|
||||
"@esbuild/win32-x64": "0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
@@ -3204,6 +3201,7 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3747,9 +3745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -4084,18 +4082,6 @@
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -4115,6 +4101,7 @@
|
||||
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.23",
|
||||
"@asamuzakjp/dom-selector": "^6.7.4",
|
||||
@@ -4194,257 +4181,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "1.30.1",
|
||||
"lightningcss-darwin-x64": "1.30.1",
|
||||
"lightningcss-freebsd-x64": "1.30.1",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.1",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.1",
|
||||
"lightningcss-linux-arm64-musl": "1.30.1",
|
||||
"lightningcss-linux-x64-gnu": "1.30.1",
|
||||
"lightningcss-linux-x64-musl": "1.30.1",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.1",
|
||||
"lightningcss-win32-x64-msvc": "1.30.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
|
||||
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
|
||||
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
|
||||
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
|
||||
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
|
||||
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
|
||||
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
|
||||
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
|
||||
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
|
||||
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
|
||||
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -4930,6 +4666,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4997,6 +4734,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -6027,6 +5765,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6115,13 +5854,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
|
||||
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -6195,6 +5935,7 @@
|
||||
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.10",
|
||||
"@vitest/mocker": "4.0.10",
|
||||
@@ -6279,6 +6020,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
||||
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.21",
|
||||
"@vue/compiler-sfc": "3.5.21",
|
||||
@@ -6311,7 +6053,6 @@
|
||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0",
|
||||
@@ -6336,7 +6077,6 @@
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface UsersExportData {
|
||||
version: string
|
||||
exported_at: string
|
||||
users: UserExport[]
|
||||
standalone_keys?: StandaloneKeyExport[]
|
||||
}
|
||||
|
||||
export interface UserExport {
|
||||
@@ -21,7 +22,7 @@ export interface UserExport {
|
||||
password_hash: string
|
||||
role: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
model_capability_settings?: any
|
||||
quota_usd?: number | null
|
||||
@@ -39,18 +40,21 @@ export interface UserApiKeyExport {
|
||||
balance_used_usd?: number
|
||||
current_balance_usd?: number | null
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number
|
||||
rate_limit?: number | null // null = 无限制
|
||||
concurrent_limit?: number | null
|
||||
force_capabilities?: any
|
||||
is_active: boolean
|
||||
expires_at?: string | null
|
||||
auto_delete_on_expiry?: boolean
|
||||
total_requests?: number
|
||||
total_cost_usd?: number
|
||||
}
|
||||
|
||||
// 独立余额 Key 导出结构(与 UserApiKeyExport 相同,但不包含 is_standalone)
|
||||
export type StandaloneKeyExport = Omit<UserApiKeyExport, 'is_standalone'>
|
||||
|
||||
export interface GlobalModelExport {
|
||||
name: string
|
||||
display_name: string
|
||||
@@ -124,6 +128,75 @@ export interface ModelExport {
|
||||
config?: any
|
||||
}
|
||||
|
||||
// 邮件模板接口
|
||||
export interface EmailTemplateInfo {
|
||||
type: string
|
||||
name: string
|
||||
variables: string[]
|
||||
subject: string
|
||||
html: string
|
||||
is_custom: boolean
|
||||
default_subject?: string
|
||||
default_html?: string
|
||||
}
|
||||
|
||||
export interface EmailTemplatesResponse {
|
||||
templates: EmailTemplateInfo[]
|
||||
}
|
||||
|
||||
export interface EmailTemplatePreviewResponse {
|
||||
html: string
|
||||
variables: Record<string, string>
|
||||
}
|
||||
|
||||
export interface EmailTemplateResetResponse {
|
||||
message: string
|
||||
template: {
|
||||
type: string
|
||||
name: string
|
||||
subject: string
|
||||
html: string
|
||||
}
|
||||
}
|
||||
|
||||
// LDAP 配置响应
|
||||
export interface LdapConfigResponse {
|
||||
server_url: string | null
|
||||
bind_dn: string | null
|
||||
base_dn: string | null
|
||||
has_bind_password: boolean
|
||||
user_search_filter: string
|
||||
username_attr: string
|
||||
email_attr: string
|
||||
display_name_attr: string
|
||||
is_enabled: boolean
|
||||
is_exclusive: boolean
|
||||
use_starttls: boolean
|
||||
connect_timeout: number
|
||||
}
|
||||
|
||||
// LDAP 配置更新请求
|
||||
export interface LdapConfigUpdateRequest {
|
||||
server_url: string
|
||||
bind_dn: string
|
||||
bind_password?: string
|
||||
base_dn: string
|
||||
user_search_filter?: string
|
||||
username_attr?: string
|
||||
email_attr?: string
|
||||
display_name_attr?: string
|
||||
is_enabled?: boolean
|
||||
is_exclusive?: boolean
|
||||
use_starttls?: boolean
|
||||
connect_timeout?: number
|
||||
}
|
||||
|
||||
// LDAP 连接测试响应
|
||||
export interface LdapTestResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// Provider 模型查询响应
|
||||
export interface ProviderModelsQueryResponse {
|
||||
success: boolean
|
||||
@@ -158,6 +231,7 @@ export interface UsersImportResponse {
|
||||
stats: {
|
||||
users: { created: number; updated: number; skipped: number }
|
||||
api_keys: { created: number; skipped: number }
|
||||
standalone_keys?: { created: number; skipped: number }
|
||||
errors: string[]
|
||||
}
|
||||
}
|
||||
@@ -189,7 +263,7 @@ export interface AdminApiKey {
|
||||
total_requests?: number
|
||||
total_tokens?: number
|
||||
total_cost_usd?: number
|
||||
rate_limit?: number
|
||||
rate_limit?: number | null // null = 无限制
|
||||
allowed_providers?: string[] | null // 允许的提供商列表
|
||||
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许的模型列表
|
||||
@@ -205,8 +279,8 @@ export interface CreateStandaloneApiKeyRequest {
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number
|
||||
expire_days?: number | null // null = 永不过期
|
||||
rate_limit?: number | null // null = 无限制
|
||||
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
|
||||
initial_balance_usd: number // 初始余额,必须设置
|
||||
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
||||
}
|
||||
@@ -386,5 +460,91 @@ export const adminApi = {
|
||||
{ provider_id: providerId, api_key_id: apiKeyId }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 测试 SMTP 连接,支持传入未保存的配置
|
||||
async testSmtpConnection(config: Record<string, any> = {}): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||
'/api/admin/system/smtp/test',
|
||||
config
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 邮件模板相关
|
||||
// 获取所有邮件模板
|
||||
async getEmailTemplates(): Promise<EmailTemplatesResponse> {
|
||||
const response = await apiClient.get<EmailTemplatesResponse>('/api/admin/system/email/templates')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取指定类型的邮件模板
|
||||
async getEmailTemplate(templateType: string): Promise<EmailTemplateInfo> {
|
||||
const response = await apiClient.get<EmailTemplateInfo>(
|
||||
`/api/admin/system/email/templates/${templateType}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新邮件模板
|
||||
async updateEmailTemplate(
|
||||
templateType: string,
|
||||
data: { subject?: string; html?: string }
|
||||
): Promise<{ message: string }> {
|
||||
const response = await apiClient.put<{ message: string }>(
|
||||
`/api/admin/system/email/templates/${templateType}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 预览邮件模板
|
||||
async previewEmailTemplate(
|
||||
templateType: string,
|
||||
data?: { html?: string } & Record<string, string>
|
||||
): Promise<EmailTemplatePreviewResponse> {
|
||||
const response = await apiClient.post<EmailTemplatePreviewResponse>(
|
||||
`/api/admin/system/email/templates/${templateType}/preview`,
|
||||
data || {}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 重置邮件模板为默认值
|
||||
async resetEmailTemplate(templateType: string): Promise<EmailTemplateResetResponse> {
|
||||
const response = await apiClient.post<EmailTemplateResetResponse>(
|
||||
`/api/admin/system/email/templates/${templateType}/reset`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取系统版本信息
|
||||
async getSystemVersion(): Promise<{ version: string }> {
|
||||
const response = await apiClient.get<{ version: string }>(
|
||||
'/api/admin/system/version'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// LDAP 配置相关
|
||||
// 获取 LDAP 配置
|
||||
async getLdapConfig(): Promise<LdapConfigResponse> {
|
||||
const response = await apiClient.get<LdapConfigResponse>('/api/admin/ldap/config')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新 LDAP 配置
|
||||
async updateLdapConfig(config: LdapConfigUpdateRequest): Promise<{ message: string }> {
|
||||
const response = await apiClient.put<{ message: string }>(
|
||||
'/api/admin/ldap/config',
|
||||
config
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 测试 LDAP 连接
|
||||
async testLdapConnection(config: LdapConfigUpdateRequest): Promise<LdapTestResponse> {
|
||||
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { log } from '@/utils/logger'
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
auth_type?: 'local' | 'ldap'
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
@@ -31,6 +32,62 @@ export interface UserStats {
|
||||
[key: string]: unknown // 允许扩展其他统计数据
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeResponse {
|
||||
message: string
|
||||
success: boolean
|
||||
expire_minutes?: number
|
||||
}
|
||||
|
||||
export interface VerifyEmailRequest {
|
||||
email: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface VerifyEmailResponse {
|
||||
message: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface VerificationStatusRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface VerificationStatusResponse {
|
||||
email: string
|
||||
has_pending_code: boolean
|
||||
is_verified: boolean
|
||||
cooldown_remaining: number | null
|
||||
code_expires_in: number | null
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface RegistrationSettingsResponse {
|
||||
enable_registration: boolean
|
||||
require_email_verification: boolean
|
||||
}
|
||||
|
||||
export interface AuthSettingsResponse {
|
||||
local_enabled: boolean
|
||||
ldap_enabled: boolean
|
||||
ldap_exclusive: boolean
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string // UUID
|
||||
username: string
|
||||
@@ -41,7 +98,7 @@ export interface User {
|
||||
used_usd?: number
|
||||
total_usd?: number
|
||||
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_endpoints?: string[] | null // 允许使用的端点 ID 列表
|
||||
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许使用的模型名称列表
|
||||
created_at: string
|
||||
last_login_at?: string
|
||||
@@ -87,5 +144,46 @@ export const authApi = {
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
|
||||
const response = await apiClient.post<SendVerificationCodeResponse>(
|
||||
'/api/auth/send-verification-code',
|
||||
{ email }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async verifyEmail(email: string, code: string): Promise<VerifyEmailResponse> {
|
||||
const response = await apiClient.post<VerifyEmailResponse>(
|
||||
'/api/auth/verify-email',
|
||||
{ email, code }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
const response = await apiClient.post<RegisterResponse>('/api/auth/register', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getRegistrationSettings(): Promise<RegistrationSettingsResponse> {
|
||||
const response = await apiClient.get<RegistrationSettingsResponse>(
|
||||
'/api/auth/registration-settings'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getVerificationStatus(email: string): Promise<VerificationStatusResponse> {
|
||||
const response = await apiClient.post<VerificationStatusResponse>(
|
||||
'/api/auth/verification-status',
|
||||
{ email }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getAuthSettings(): Promise<AuthSettingsResponse> {
|
||||
const response = await apiClient.get<AuthSettingsResponse>('/api/auth/settings')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface DashboardStatsResponse {
|
||||
cache_stats?: CacheStats
|
||||
users?: UserStats
|
||||
token_breakdown?: TokenBreakdown
|
||||
// 普通用户专用字段
|
||||
monthly_cost?: number
|
||||
}
|
||||
|
||||
export interface RecentRequestsResponse {
|
||||
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
GlobalModelUpdate,
|
||||
GlobalModelResponse,
|
||||
GlobalModelWithStats,
|
||||
GlobalModelListResponse
|
||||
GlobalModelListResponse,
|
||||
ModelCatalogProviderDetail,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GlobalModel 的所有关联提供商(包括非活跃的)
|
||||
*/
|
||||
export async function getGlobalModelProviders(globalModelId: string): Promise<{
|
||||
providers: ModelCatalogProviderDetail[]
|
||||
total: number
|
||||
}> {
|
||||
const response = await client.get(
|
||||
`/api/admin/models/global/${globalModelId}/providers`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -110,6 +110,14 @@ export async function updateEndpointKey(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的 API Key(用于查看和复制)
|
||||
*/
|
||||
export async function revealEndpointKey(keyId: string): Promise<{ api_key: string }> {
|
||||
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Endpoint Key
|
||||
*/
|
||||
|
||||
@@ -20,4 +20,5 @@ export {
|
||||
updateGlobalModel,
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
} from './endpoints/global-models'
|
||||
|
||||
203
frontend/src/api/management-tokens.ts
Normal file
203
frontend/src/api/management-tokens.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Management Token API
|
||||
*/
|
||||
|
||||
import apiClient from './client'
|
||||
|
||||
// ============== 类型定义 ==============
|
||||
|
||||
export interface ManagementToken {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
description?: string
|
||||
token_display: string
|
||||
allowed_ips?: string[] | null
|
||||
expires_at?: string | null
|
||||
last_used_at?: string | null
|
||||
last_used_ip?: string | null
|
||||
usage_count: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user?: {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateManagementTokenRequest {
|
||||
name: string
|
||||
description?: string
|
||||
allowed_ips?: string[]
|
||||
expires_at?: string | null
|
||||
}
|
||||
|
||||
export interface CreateManagementTokenResponse {
|
||||
message: string
|
||||
token: string
|
||||
data: ManagementToken
|
||||
}
|
||||
|
||||
export interface UpdateManagementTokenRequest {
|
||||
name?: string
|
||||
description?: string | null
|
||||
allowed_ips?: string[] | null
|
||||
expires_at?: string | null
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface ManagementTokenListResponse {
|
||||
items: ManagementToken[]
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
quota?: {
|
||||
used: number
|
||||
max: number
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 用户自助管理 API ==============
|
||||
|
||||
export const managementTokenApi = {
|
||||
/**
|
||||
* 列出当前用户的 Management Tokens
|
||||
*/
|
||||
async listTokens(params?: {
|
||||
is_active?: boolean
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<ManagementTokenListResponse> {
|
||||
const response = await apiClient.get<ManagementTokenListResponse>(
|
||||
'/api/me/management-tokens',
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Management Token
|
||||
*/
|
||||
async createToken(
|
||||
data: CreateManagementTokenRequest
|
||||
): Promise<CreateManagementTokenResponse> {
|
||||
const response = await apiClient.post<CreateManagementTokenResponse>(
|
||||
'/api/me/management-tokens',
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Token 详情
|
||||
*/
|
||||
async getToken(tokenId: string): Promise<ManagementToken> {
|
||||
const response = await apiClient.get<ManagementToken>(
|
||||
`/api/me/management-tokens/${tokenId}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新 Token
|
||||
*/
|
||||
async updateToken(
|
||||
tokenId: string,
|
||||
data: UpdateManagementTokenRequest
|
||||
): Promise<{ message: string; data: ManagementToken }> {
|
||||
const response = await apiClient.put<{ message: string; data: ManagementToken }>(
|
||||
`/api/me/management-tokens/${tokenId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除 Token
|
||||
*/
|
||||
async deleteToken(tokenId: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete<{ message: string }>(
|
||||
`/api/me/management-tokens/${tokenId}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换 Token 状态
|
||||
*/
|
||||
async toggleToken(
|
||||
tokenId: string
|
||||
): Promise<{ message: string; data: ManagementToken }> {
|
||||
const response = await apiClient.patch<{ message: string; data: ManagementToken }>(
|
||||
`/api/me/management-tokens/${tokenId}/status`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 重新生成 Token
|
||||
*/
|
||||
async regenerateToken(
|
||||
tokenId: string
|
||||
): Promise<{ token: string; data: ManagementToken }> {
|
||||
const response = await apiClient.post<{ token: string; data: ManagementToken }>(
|
||||
`/api/me/management-tokens/${tokenId}/regenerate`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 管理员 API ==============
|
||||
|
||||
export const adminManagementTokenApi = {
|
||||
/**
|
||||
* 列出所有 Management Tokens(管理员)
|
||||
*/
|
||||
async listAllTokens(params?: {
|
||||
user_id?: string
|
||||
is_active?: boolean
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<ManagementTokenListResponse> {
|
||||
const response = await apiClient.get<ManagementTokenListResponse>(
|
||||
'/api/admin/management-tokens',
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Token 详情(管理员)
|
||||
*/
|
||||
async getToken(tokenId: string): Promise<ManagementToken> {
|
||||
const response = await apiClient.get<ManagementToken>(
|
||||
`/api/admin/management-tokens/${tokenId}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除任意 Token(管理员)
|
||||
*/
|
||||
async deleteToken(tokenId: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete<{ message: string }>(
|
||||
`/api/admin/management-tokens/${tokenId}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换任意 Token 状态(管理员)
|
||||
*/
|
||||
async toggleToken(
|
||||
tokenId: string
|
||||
): Promise<{ message: string; data: ManagementToken }> {
|
||||
const response = await apiClient.patch<{ message: string; data: ManagementToken }>(
|
||||
`/api/admin/management-tokens/${tokenId}/status`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,11 @@ export interface UsageRecordDetail {
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: 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 // 倍率消耗(仅管理员可见)
|
||||
}
|
||||
|
||||
// 提供商统计接口
|
||||
export interface ProviderSummary {
|
||||
provider: string
|
||||
requests: number
|
||||
total_tokens: number
|
||||
total_cost_usd: number
|
||||
success_rate: number | null
|
||||
avg_response_time_ms: number | null
|
||||
}
|
||||
|
||||
// 使用统计响应接口
|
||||
export interface UsageResponse {
|
||||
total_requests: number
|
||||
@@ -87,6 +102,13 @@ export interface UsageResponse {
|
||||
quota_usd: number | null
|
||||
used_usd: number
|
||||
summary_by_model: ModelSummary[]
|
||||
summary_by_provider?: ProviderSummary[]
|
||||
pagination?: {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
has_more: boolean
|
||||
}
|
||||
records: UsageRecordDetail[]
|
||||
activity_heatmap?: ActivityHeatmap | null
|
||||
}
|
||||
@@ -175,6 +197,9 @@ export const meApi = {
|
||||
async getUsage(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
search?: string // 通用搜索:密钥名、模型名
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<UsageResponse> {
|
||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
||||
return response.data
|
||||
@@ -184,11 +209,12 @@ export const meApi = {
|
||||
async getActiveRequests(ids?: string): Promise<{
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed'
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cost: number
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids ? { ids } : {}
|
||||
@@ -267,5 +293,14 @@ export const meApi = {
|
||||
}> {
|
||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃度热力图数据(用户)
|
||||
* 后端已缓存5分钟
|
||||
*/
|
||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +192,17 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
|
||||
}
|
||||
}
|
||||
|
||||
// 按 provider 名称和模型名称排序
|
||||
// 按 provider 名称排序,provider 中的模型按 release_date 从近到远排序
|
||||
items.sort((a, b) => {
|
||||
const providerCompare = a.providerName.localeCompare(b.providerName)
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ export const usageApi = {
|
||||
async getAllUsageRecords(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
user_id?: string // UUID
|
||||
username?: string
|
||||
model?: string
|
||||
@@ -193,10 +194,22 @@ export const usageApi = {
|
||||
output_tokens: number
|
||||
cost: number
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
provider?: string | null
|
||||
api_key_name?: string | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃度热力图数据(管理员)
|
||||
* 后端已缓存5分钟
|
||||
*/
|
||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface User {
|
||||
used_usd: number
|
||||
total_usd: number
|
||||
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_endpoints: string[] | null // 允许使用的端点 ID 列表
|
||||
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
|
||||
allowed_models: string[] | null // 允许使用的模型名称列表
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
@@ -23,7 +23,7 @@ export interface CreateUserRequest {
|
||||
role?: 'admin' | 'user'
|
||||
quota_usd?: number | null
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface UpdateUserRequest {
|
||||
quota_usd?: number | null
|
||||
password?: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
}
|
||||
|
||||
|
||||
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 AlertDialog } from './AlertDialog.vue'
|
||||
export { default as LoadingState } from './LoadingState.vue'
|
||||
|
||||
// 表单组件
|
||||
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
|
||||
|
||||
13
frontend/src/components/icons/GithubIcon.vue
Normal file
13
frontend/src/components/icons/GithubIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
||||
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto"
|
||||
:style="{ zIndex: backdropZIndex }"
|
||||
@click="handleClose"
|
||||
@click="handleBackdropClick"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
@@ -71,8 +71,8 @@
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- 内容区域:统一添加 padding -->
|
||||
<div class="px-6 py-3">
|
||||
<!-- 内容区域:可选添加 padding -->
|
||||
<div :class="noPadding ? '' : 'px-6 py-3'">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -105,6 +105,8 @@ const props = defineProps<{
|
||||
icon?: Component // Lucide icon component
|
||||
iconClass?: string // Custom icon color class
|
||||
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
||||
noPadding?: boolean // Disable default content padding
|
||||
persistent?: boolean // Prevent closing on backdrop click
|
||||
}>()
|
||||
|
||||
// Emits 定义
|
||||
@@ -137,6 +139,13 @@ function handleClose() {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理背景点击
|
||||
function handleBackdropClick() {
|
||||
if (!props.persistent) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const sizeValue = props.maxWidth || props.size || 'md'
|
||||
const sizes = {
|
||||
@@ -161,7 +170,7 @@ const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (isOpen.value) {
|
||||
if (isOpen.value && !props.persistent) {
|
||||
handleClose()
|
||||
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
:class="inputClass"
|
||||
:value="modelValue"
|
||||
:autocomplete="autocompleteAttr"
|
||||
:data-lpignore="disableAutofill ? 'true' : undefined"
|
||||
:data-1p-ignore="disableAutofill ? 'true' : undefined"
|
||||
:data-form-type="disableAutofill ? 'other' : undefined"
|
||||
v-bind="$attrs"
|
||||
@input="handleInput"
|
||||
>
|
||||
@@ -16,6 +19,7 @@ interface Props {
|
||||
modelValue?: string | number
|
||||
class?: string
|
||||
autocomplete?: string
|
||||
disableAutofill?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -23,7 +27,12 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const autocompleteAttr = computed(() => props.autocomplete ?? 'off')
|
||||
const autocompleteAttr = computed(() => {
|
||||
if (props.disableAutofill) {
|
||||
return 'one-time-code'
|
||||
}
|
||||
return props.autocomplete ?? 'off'
|
||||
})
|
||||
|
||||
const inputClass = computed(() =>
|
||||
cn(
|
||||
|
||||
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">
|
||||
<Label
|
||||
for="form-expire-days"
|
||||
for="form-expires-at"
|
||||
class="text-sm font-medium"
|
||||
>有效期设置</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
id="form-expire-days"
|
||||
:model-value="form.expire_days ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
placeholder="天数"
|
||||
:class="form.never_expire ? 'flex-1 h-9 opacity-50' : 'flex-1 h-9'"
|
||||
:disabled="form.never_expire"
|
||||
@update:model-value="(v) => form.expire_days = parseNumberInput(v, { min: 1, max: 3650 })"
|
||||
/>
|
||||
<label class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap">
|
||||
<input
|
||||
v-model="form.never_expire"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
||||
@change="onNeverExpireChange"
|
||||
<div class="relative flex-1">
|
||||
<Input
|
||||
id="form-expires-at"
|
||||
:model-value="form.expires_at || ''"
|
||||
type="date"
|
||||
:min="minExpiryDate"
|
||||
class="h-9 pr-8"
|
||||
:placeholder="form.expires_at ? '' : '永不过期'"
|
||||
@update:model-value="(v) => form.expires_at = v || undefined"
|
||||
/>
|
||||
<button
|
||||
v-if="form.expires_at"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
title="清空(永不过期)"
|
||||
@click="clearExpiryDate"
|
||||
>
|
||||
永不过期
|
||||
</label>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap"
|
||||
:class="form.never_expire ? 'opacity-50' : ''"
|
||||
:class="!form.expires_at ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
>
|
||||
<input
|
||||
v-model="form.auto_delete_on_expiry"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
||||
:disabled="form.never_expire"
|
||||
:disabled="!form.expires_at"
|
||||
>
|
||||
到期删除
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
不勾选"到期删除"则仅禁用
|
||||
{{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 23:59 失效)' : '留空表示永不过期' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -244,55 +244,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 模型多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||
:class="modelDropdownOpen ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="fixed inset-0 z-[80]"
|
||||
@click.stop="modelDropdownOpen = false"
|
||||
/>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="model in globalModels"
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_models.includes(model.name)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<span class="text-sm">{{ model.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="globalModels.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
暂无可用模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModelMultiSelect
|
||||
v-model="form.allowed_models"
|
||||
:models="globalModels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -325,8 +280,9 @@ import {
|
||||
Input,
|
||||
Label,
|
||||
} from '@/components/ui'
|
||||
import { Plus, SquarePen, Key, Shield, ChevronDown } from 'lucide-vue-next'
|
||||
import { Plus, SquarePen, Key, Shield, ChevronDown, X } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { ModelMultiSelect } from '@/components/common'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
@@ -338,8 +294,7 @@ export interface StandaloneKeyFormData {
|
||||
id?: string
|
||||
name: string
|
||||
initial_balance_usd?: number
|
||||
expire_days?: number
|
||||
never_expire: boolean
|
||||
expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期
|
||||
rate_limit?: number
|
||||
auto_delete_on_expiry: boolean
|
||||
allowed_providers: string[]
|
||||
@@ -363,7 +318,6 @@ const saving = ref(false)
|
||||
// 下拉框状态
|
||||
const providerDropdownOpen = ref(false)
|
||||
const apiFormatDropdownOpen = ref(false)
|
||||
const modelDropdownOpen = ref(false)
|
||||
|
||||
// 选项数据
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
@@ -374,8 +328,7 @@ const allApiFormats = ref<string[]>([])
|
||||
const form = ref<StandaloneKeyFormData>({
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
@@ -383,12 +336,18 @@ const form = ref<StandaloneKeyFormData>({
|
||||
allowed_models: []
|
||||
})
|
||||
|
||||
// 计算最小可选日期(明天)
|
||||
const minExpiryDate = computed(() => {
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return tomorrow.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
expires_at: undefined,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
@@ -397,7 +356,6 @@ function resetForm() {
|
||||
}
|
||||
providerDropdownOpen.value = false
|
||||
apiFormatDropdownOpen.value = false
|
||||
modelDropdownOpen.value = false
|
||||
}
|
||||
|
||||
function loadKeyData() {
|
||||
@@ -406,8 +364,7 @@ function loadKeyData() {
|
||||
id: props.apiKey.id,
|
||||
name: props.apiKey.name || '',
|
||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||
expire_days: props.apiKey.expire_days,
|
||||
never_expire: props.apiKey.never_expire,
|
||||
expires_at: props.apiKey.expires_at,
|
||||
rate_limit: props.apiKey.rate_limit,
|
||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||
allowed_providers: props.apiKey.allowed_providers || [],
|
||||
@@ -452,12 +409,10 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
|
||||
}
|
||||
}
|
||||
|
||||
// 永不过期切换
|
||||
function onNeverExpireChange() {
|
||||
if (form.value.never_expire) {
|
||||
form.value.expire_days = undefined
|
||||
form.value.auto_delete_on_expiry = false
|
||||
}
|
||||
// 清空过期日期(同时清空到期删除选项)
|
||||
function clearExpiryDate() {
|
||||
form.value.expires_at = undefined
|
||||
form.value.auto_delete_on_expiry = false
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
|
||||
@@ -66,19 +66,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 认证方式切换 -->
|
||||
<div
|
||||
v-if="showAuthTypeTabs"
|
||||
class="auth-type-tabs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="auth-tab"
|
||||
:class="[authType === 'local' && 'active']"
|
||||
@click="authType = 'local'"
|
||||
>
|
||||
本地登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="auth-tab"
|
||||
:class="[authType === 'ldap' && 'active']"
|
||||
@click="authType = 'ldap'"
|
||||
>
|
||||
LDAP 登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="login-email">邮箱</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="login-email">{{ emailLabel }}</Label>
|
||||
<button
|
||||
v-if="ldapExclusive && authType === 'ldap'"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
@click="authType = 'local'"
|
||||
>
|
||||
管理员本地登录
|
||||
</button>
|
||||
<button
|
||||
v-if="ldapExclusive && authType === 'local'"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
@click="authType = 'ldap'"
|
||||
>
|
||||
返回 LDAP 登录
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="login-email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
type="text"
|
||||
required
|
||||
placeholder="hello@example.com"
|
||||
placeholder="username 或 email"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,12 +140,27 @@
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<p
|
||||
v-if="!isDemo"
|
||||
v-if="!isDemo && !allowRegistration"
|
||||
class="text-xs text-slate-400 dark:text-muted-foreground/80"
|
||||
>
|
||||
如需开通账户,请联系管理员配置访问权限
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- 注册链接 -->
|
||||
<div
|
||||
v-if="allowRegistration"
|
||||
class="mt-4 text-center text-sm"
|
||||
>
|
||||
还没有账户?
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-auto p-0"
|
||||
@click="handleSwitchToRegister"
|
||||
>
|
||||
立即注册
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -124,10 +181,18 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Register Dialog -->
|
||||
<RegisterDialog
|
||||
v-model:open="showRegisterDialog"
|
||||
:require-email-verification="requireEmailVerification"
|
||||
@success="handleRegisterSuccess"
|
||||
@switch-to-login="handleSwitchToLogin"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -136,6 +201,8 @@ import Label from '@/components/ui/label.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
|
||||
import RegisterDialog from './RegisterDialog.vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -151,6 +218,33 @@ const { success: showSuccess, warning: showWarning, error: showError } = useToas
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
const isDemo = computed(() => isDemoMode())
|
||||
const showRegisterDialog = ref(false)
|
||||
const requireEmailVerification = ref(false)
|
||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||
|
||||
// LDAP authentication settings
|
||||
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
|
||||
function getStoredAuthType(): 'local' | 'ldap' {
|
||||
const stored = localStorage.getItem(PREFERRED_AUTH_TYPE_KEY)
|
||||
return (stored === 'ldap' || stored === 'local') ? stored : 'local'
|
||||
}
|
||||
const authType = ref<'local' | 'ldap'>(getStoredAuthType())
|
||||
const localEnabled = ref(true)
|
||||
const ldapEnabled = ref(false)
|
||||
const ldapExclusive = ref(false)
|
||||
|
||||
// 保存用户的认证类型偏好
|
||||
watch(authType, (newType) => {
|
||||
localStorage.setItem(PREFERRED_AUTH_TYPE_KEY, newType)
|
||||
})
|
||||
|
||||
const showAuthTypeTabs = computed(() => {
|
||||
return localEnabled.value && ldapEnabled.value && !ldapExclusive.value
|
||||
})
|
||||
|
||||
const emailLabel = computed(() => {
|
||||
return '用户名/邮箱'
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isOpen.value = val
|
||||
@@ -184,7 +278,7 @@ async function handleLogin() {
|
||||
return
|
||||
}
|
||||
|
||||
const success = await authStore.login(form.value.email, form.value.password)
|
||||
const success = await authStore.login(form.value.email, form.value.password, authType.value)
|
||||
if (success) {
|
||||
showSuccess('登录成功,正在跳转...')
|
||||
|
||||
@@ -201,4 +295,101 @@ async function handleLogin() {
|
||||
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwitchToRegister() {
|
||||
isOpen.value = false
|
||||
showRegisterDialog.value = true
|
||||
}
|
||||
|
||||
function handleRegisterSuccess() {
|
||||
showRegisterDialog.value = false
|
||||
showSuccess('注册成功!请登录')
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function handleSwitchToLogin() {
|
||||
showRegisterDialog.value = false
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
// Load authentication and registration settings on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load registration settings
|
||||
const regSettings = await authApi.getRegistrationSettings()
|
||||
allowRegistration.value = !!regSettings.enable_registration
|
||||
requireEmailVerification.value = !!regSettings.require_email_verification
|
||||
|
||||
// Load authentication settings
|
||||
const authSettings = await authApi.getAuthSettings()
|
||||
localEnabled.value = authSettings.local_enabled
|
||||
ldapEnabled.value = authSettings.ldap_enabled
|
||||
ldapExclusive.value = authSettings.ldap_exclusive
|
||||
// 若仅允许 LDAP 登录,则禁用本地注册入口
|
||||
if (ldapExclusive.value) {
|
||||
allowRegistration.value = false
|
||||
}
|
||||
|
||||
// Set default auth type based on settings
|
||||
if (authSettings.ldap_exclusive) {
|
||||
authType.value = 'ldap'
|
||||
} else if (!authSettings.local_enabled && authSettings.ldap_enabled) {
|
||||
authType.value = 'ldap'
|
||||
} else {
|
||||
authType.value = 'local'
|
||||
}
|
||||
} catch (error) {
|
||||
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证 & 使用本地认证
|
||||
allowRegistration.value = false
|
||||
requireEmailVerification.value = false
|
||||
localEnabled.value = true
|
||||
ldapEnabled.value = false
|
||||
ldapExclusive.value = false
|
||||
authType.value = 'local'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-type-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.auth-tab:hover:not(.active) {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.auth-tab.active {
|
||||
color: var(--book-cloth);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-tab.active::after {
|
||||
background: var(--book-cloth);
|
||||
}
|
||||
</style>
|
||||
|
||||
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'
|
||||
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
@@ -388,6 +386,8 @@ const emit = defineEmits<{
|
||||
'changed': []
|
||||
}>()
|
||||
|
||||
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const { error: showError, success } = useToast()
|
||||
|
||||
// 状态
|
||||
|
||||
@@ -177,8 +177,8 @@
|
||||
<Label for="proxy_user">用户名(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_user_${formId}`"
|
||||
:name="`proxy_user_${formId}`"
|
||||
v-model="form.proxy_username"
|
||||
:name="`proxy_user_${formId}`"
|
||||
placeholder="代理认证用户名"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
@@ -191,8 +191,8 @@
|
||||
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_pass_${formId}`"
|
||||
:name="`proxy_pass_${formId}`"
|
||||
v-model="form.proxy_password"
|
||||
:name="`proxy_pass_${formId}`"
|
||||
type="text"
|
||||
:placeholder="passwordPlaceholder"
|
||||
autocomplete="off"
|
||||
|
||||
@@ -116,6 +116,25 @@
|
||||
{{ model.global_model_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
title="测试模型连接"
|
||||
:disabled="testingModelName === model.global_model_name"
|
||||
@click.stop="testModelConnection(model)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testingModelName === model.global_model_name"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,16 +167,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
|
||||
import { Box, Loader2, Settings2, Play } from 'lucide-vue-next'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
||||
import {
|
||||
updateEndpointKey,
|
||||
getProviderAvailableSourceModels,
|
||||
testModel,
|
||||
type EndpointAPIKey,
|
||||
type ProviderAvailableSourceModel
|
||||
} from '@/api/endpoints'
|
||||
@@ -181,6 +201,7 @@ const loadingModels = ref(false)
|
||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
||||
const selectedModels = ref<string[]>([])
|
||||
const initialModels = ref<string[]>([])
|
||||
const testingModelName = ref<string | null>(null)
|
||||
|
||||
// 监听对话框打开
|
||||
watch(() => props.open, (open) => {
|
||||
@@ -268,6 +289,32 @@ function clearModels() {
|
||||
selectedModels.value = []
|
||||
}
|
||||
|
||||
// 测试模型连接
|
||||
async function testModelConnection(model: ProviderAvailableSourceModel) {
|
||||
if (!props.providerId || !props.apiKey || testingModelName.value) return
|
||||
|
||||
testingModelName.value = model.global_model_name
|
||||
try {
|
||||
const result = await testModel({
|
||||
provider_id: props.providerId,
|
||||
model_name: model.provider_model_name,
|
||||
api_key_id: props.apiKey.id,
|
||||
message: "hello"
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
success(`模型 "${model.display_name}" 测试成功`)
|
||||
} else {
|
||||
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||
showError(`模型测试失败: ${errorMsg}`)
|
||||
} finally {
|
||||
testingModelName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort()
|
||||
|
||||
@@ -337,8 +337,40 @@
|
||||
{{ key.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{{ key.api_key_masked }}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] font-mono text-muted-foreground truncate max-w-[180px]">
|
||||
{{ revealedKeys.has(key.id) ? revealedKeys.get(key.id) : key.api_key_masked }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 shrink-0"
|
||||
:title="revealedKeys.has(key.id) ? '隐藏密钥' : '显示密钥'"
|
||||
:disabled="revealingKeyId === key.id"
|
||||
@click.stop="toggleKeyReveal(key)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="revealingKeyId === key.id"
|
||||
class="w-3 h-3 animate-spin"
|
||||
/>
|
||||
<EyeOff
|
||||
v-else-if="revealedKeys.has(key.id)"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
<Eye
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 shrink-0"
|
||||
title="复制密钥"
|
||||
@click.stop="copyFullKey(key)"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
@@ -654,7 +686,9 @@ import {
|
||||
Power,
|
||||
Layers,
|
||||
GripVertical,
|
||||
Copy
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -681,6 +715,7 @@ import {
|
||||
updateEndpoint,
|
||||
updateEndpointKey,
|
||||
batchUpdateKeyPriority,
|
||||
revealEndpointKey,
|
||||
type ProviderEndpoint,
|
||||
type EndpointAPIKey,
|
||||
type Model
|
||||
@@ -731,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
|
||||
const togglingEndpointId = ref<string | null>(null)
|
||||
const togglingKeyId = ref<string | null>(null)
|
||||
|
||||
// 密钥显示状态:key_id -> 完整密钥
|
||||
const revealedKeys = ref<Map<string, string>>(new Map())
|
||||
const revealingKeyId = ref<string | null>(null)
|
||||
|
||||
// 模型相关状态
|
||||
const modelFormDialogOpen = ref(false)
|
||||
const editingModel = ref<Model | null>(null)
|
||||
@@ -800,6 +839,9 @@ watch(() => props.open, (newOpen) => {
|
||||
currentEndpoint.value = null
|
||||
editingKey.value = null
|
||||
keyToDelete.value = null
|
||||
|
||||
// 清除已显示的密钥(安全考虑)
|
||||
revealedKeys.value.clear()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -888,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
|
||||
keyAllowedModelsDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 切换密钥显示/隐藏
|
||||
async function toggleKeyReveal(key: EndpointAPIKey) {
|
||||
if (revealedKeys.value.has(key.id)) {
|
||||
// 已显示,隐藏它
|
||||
revealedKeys.value.delete(key.id)
|
||||
return
|
||||
}
|
||||
|
||||
// 未显示,调用 API 获取完整密钥
|
||||
revealingKeyId.value = key.id
|
||||
try {
|
||||
const result = await revealEndpointKey(key.id)
|
||||
revealedKeys.value.set(key.id, result.api_key)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||
} finally {
|
||||
revealingKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 复制完整密钥
|
||||
async function copyFullKey(key: EndpointAPIKey) {
|
||||
// 如果已经显示了,直接复制
|
||||
if (revealedKeys.value.has(key.id)) {
|
||||
copyToClipboard(revealedKeys.value.get(key.id)!)
|
||||
return
|
||||
}
|
||||
|
||||
// 否则先获取再复制
|
||||
try {
|
||||
const result = await revealEndpointKey(key.id)
|
||||
copyToClipboard(result.api_key)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteKey(key: EndpointAPIKey) {
|
||||
keyToDelete.value = key
|
||||
deleteKeyConfirmOpen.value = true
|
||||
|
||||
@@ -131,8 +131,14 @@
|
||||
:disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
|
||||
@click="testMapping(group, mapping)"
|
||||
>
|
||||
<Loader2 v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`" class="w-3 h-3 animate-spin" />
|
||||
<Play v-else class="w-3 h-3" />
|
||||
<Loader2
|
||||
v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
|
||||
class="w-3 h-3 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,17 +156,6 @@
|
||||
</td>
|
||||
<td class="align-top px-4 py-3">
|
||||
<div class="flex justify-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="测试模型"
|
||||
:disabled="testingModelId === model.id"
|
||||
@click="testModelConnection(model)"
|
||||
>
|
||||
<Loader2 v-if="testingModelId === model.id" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Play v-else class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -220,14 +209,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image, Loader2, Play } from 'lucide-vue-next'
|
||||
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProviderModels, testModel, type Model } from '@/api/endpoints'
|
||||
import { getProviderModels, type Model } from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseTestModelError } from '@/utils/errorParser'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: any
|
||||
@@ -246,7 +234,6 @@ const { copyToClipboard } = useClipboard()
|
||||
const loading = ref(false)
|
||||
const models = ref<Model[]>([])
|
||||
const togglingModelId = ref<string | null>(null)
|
||||
const testingModelId = ref<string | null>(null)
|
||||
|
||||
// 按名称排序的模型列表
|
||||
const sortedModels = computed(() => {
|
||||
@@ -390,39 +377,6 @@ async function toggleModelActive(model: Model) {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试模型连接性
|
||||
async function testModelConnection(model: Model) {
|
||||
if (testingModelId.value) return
|
||||
|
||||
testingModelId.value = model.id
|
||||
try {
|
||||
const result = await testModel({
|
||||
provider_id: props.provider.id,
|
||||
model_name: model.provider_model_name,
|
||||
message: "hello"
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(`模型 "${model.provider_model_name}" 测试成功`)
|
||||
|
||||
// 如果有响应内容,可以显示更多信息
|
||||
if (result.data?.response?.choices?.[0]?.message?.content) {
|
||||
const content = result.data.response.choices[0].message.content
|
||||
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
|
||||
} else if (result.data?.content_preview) {
|
||||
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
|
||||
}
|
||||
} else {
|
||||
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||
showError(`模型测试失败: ${errorMsg}`)
|
||||
} finally {
|
||||
testingModelId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadModels()
|
||||
})
|
||||
|
||||
@@ -18,8 +18,22 @@
|
||||
<span class="flex-shrink-0">多</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
加载中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasError"
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4 mr-1.5" />
|
||||
加载失败
|
||||
</div>
|
||||
<ActivityHeatmap
|
||||
v-if="hasData"
|
||||
v-else-if="hasData"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
/>
|
||||
@@ -34,6 +48,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
|
||||
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
@@ -41,6 +56,8 @@ import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
const props = defineProps<{
|
||||
data: ActivityHeatmapData | null
|
||||
title: string
|
||||
isLoading?: boolean
|
||||
hasError?: boolean
|
||||
}>()
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
<!-- 分隔线 -->
|
||||
<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
|
||||
v-if="isAdmin && availableUsers.length > 0"
|
||||
@@ -164,6 +175,12 @@
|
||||
>
|
||||
用户
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-if="!isAdmin"
|
||||
class="h-12 font-semibold w-[100px]"
|
||||
>
|
||||
密钥
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[140px]">
|
||||
模型
|
||||
</TableHead>
|
||||
@@ -196,7 +213,7 @@
|
||||
<TableBody>
|
||||
<TableRow v-if="records.length === 0">
|
||||
<TableCell
|
||||
:colspan="isAdmin ? 9 : 7"
|
||||
:colspan="isAdmin ? 9 : 8"
|
||||
class="text-center py-12 text-muted-foreground"
|
||||
>
|
||||
暂无请求记录
|
||||
@@ -218,7 +235,34 @@
|
||||
class="py-4 w-[100px] truncate"
|
||||
: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
|
||||
class="font-medium py-4 w-[140px]"
|
||||
@@ -438,6 +482,7 @@ import {
|
||||
TableCard,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -451,7 +496,7 @@ import {
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from '@/components/ui'
|
||||
import { RefreshCcw } from 'lucide-vue-next'
|
||||
import { RefreshCcw, Search } from 'lucide-vue-next'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
@@ -471,6 +516,7 @@ const props = defineProps<{
|
||||
// 时间段
|
||||
selectedPeriod: string
|
||||
// 筛选
|
||||
filterSearch: string
|
||||
filterUser: string
|
||||
filterModel: string
|
||||
filterProvider: string
|
||||
@@ -489,6 +535,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedPeriod': [value: string]
|
||||
'update:filterSearch': [value: string]
|
||||
'update:filterUser': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'update:filterProvider': [value: string]
|
||||
@@ -507,6 +554,23 @@ const filterModelSelectOpen = ref(false)
|
||||
const filterProviderSelectOpen = 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())
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||
@@ -574,6 +638,10 @@ function handleRowClick(event: MouseEvent, id: string) {
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
stopTimer()
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化 API 格式显示名称
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface PaginationParams {
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
search?: string
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
@@ -64,9 +65,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}))
|
||||
})
|
||||
|
||||
// 活跃度热图数据
|
||||
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
|
||||
|
||||
// 加载统计数据(不加载记录)
|
||||
async function loadStats(dateRange?: DateRangeParams) {
|
||||
isLoadingStats.value = true
|
||||
@@ -93,7 +91,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
cache_stats: (statsData as any).cache_stats,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: statsData.activity_heatmap || null
|
||||
activity_heatmap: null
|
||||
}
|
||||
|
||||
modelStats.value = modelData.map(item => ({
|
||||
@@ -143,7 +141,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
avg_response_time: userData.avg_response_time || 0,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: userData.activity_heatmap || null
|
||||
activity_heatmap: null
|
||||
}
|
||||
|
||||
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
|
||||
@@ -237,11 +235,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams
|
||||
): Promise<void> {
|
||||
if (!isAdminPage.value) {
|
||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingRecords.value = true
|
||||
|
||||
try {
|
||||
@@ -255,24 +248,34 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters?.user_id) {
|
||||
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
|
||||
if (filters?.search?.trim()) {
|
||||
params.search = filters.search.trim()
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
|
||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||
totalRecords.value = response.total || 0
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面:使用管理员 API
|
||||
if (filters?.user_id) {
|
||||
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) {
|
||||
log.error('加载记录失败:', error)
|
||||
currentRecords.value = []
|
||||
@@ -305,7 +308,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
// 计算属性
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
|
||||
// 方法
|
||||
loadStats,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
// 统计数据状态
|
||||
export interface UsageStatsState {
|
||||
total_requests: number
|
||||
@@ -17,7 +15,6 @@ export interface UsageStatsState {
|
||||
}
|
||||
period_start: string
|
||||
period_end: string
|
||||
activity_heatmap: ActivityHeatmap | null
|
||||
}
|
||||
|
||||
// 模型统计
|
||||
@@ -64,6 +61,11 @@ export interface UsageRecord {
|
||||
user_id?: string
|
||||
username?: string
|
||||
user_email?: string
|
||||
api_key?: {
|
||||
id: string | null
|
||||
name: string | null
|
||||
display: string | null
|
||||
} | null
|
||||
provider: string
|
||||
api_key_name?: string
|
||||
rate_multiplier?: number
|
||||
@@ -115,7 +117,6 @@ export function createDefaultStats(): UsageStatsState {
|
||||
error_rate: undefined,
|
||||
cache_stats: undefined,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: null
|
||||
period_end: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,8 +273,8 @@
|
||||
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="endpointDropdownOpen = !endpointDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_endpoints.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_endpoints.length ? `已选择 ${form.allowed_endpoints.length} 个` : '全部可用' }}
|
||||
<span :class="form.allowed_api_formats.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_api_formats.length ? `已选择 ${form.allowed_api_formats.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||
@@ -294,14 +294,14 @@
|
||||
v-for="format in apiFormats"
|
||||
:key="format.value"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_endpoints', format.value)"
|
||||
@click="toggleSelection('allowed_api_formats', format.value)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_endpoints.includes(format.value)"
|
||||
:checked="form.allowed_api_formats.includes(format.value)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_endpoints', format.value)"
|
||||
@change="toggleSelection('allowed_api_formats', format.value)"
|
||||
>
|
||||
<span class="text-sm">{{ format.label }}</span>
|
||||
</div>
|
||||
@@ -316,55 +316,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 模型多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||
:class="modelDropdownOpen ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="fixed inset-0 z-[80]"
|
||||
@click.stop="modelDropdownOpen = false"
|
||||
/>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="model in globalModels"
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_models.includes(model.name)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<span class="text-sm">{{ model.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="globalModels.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
暂无可用模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModelMultiSelect
|
||||
v-model="form.allowed_models"
|
||||
:models="globalModels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -404,10 +359,12 @@ import {
|
||||
} from '@/components/ui'
|
||||
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { ModelMultiSelect } from '@/components/common'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
|
||||
export interface UserFormData {
|
||||
id?: string
|
||||
@@ -417,7 +374,7 @@ export interface UserFormData {
|
||||
role: 'admin' | 'user'
|
||||
is_active?: boolean
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
}
|
||||
|
||||
@@ -440,11 +397,10 @@ const roleSelectOpen = ref(false)
|
||||
// 下拉框状态
|
||||
const providerDropdownOpen = ref(false)
|
||||
const endpointDropdownOpen = ref(false)
|
||||
const modelDropdownOpen = ref(false)
|
||||
|
||||
// 选项数据
|
||||
const providers = ref<any[]>([])
|
||||
const globalModels = ref<any[]>([])
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||
|
||||
// 表单数据
|
||||
@@ -458,7 +414,7 @@ const form = ref({
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
allowed_providers: [] as string[],
|
||||
allowed_endpoints: [] as string[],
|
||||
allowed_api_formats: [] as string[],
|
||||
allowed_models: [] as string[]
|
||||
})
|
||||
|
||||
@@ -479,7 +435,7 @@ function resetForm() {
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
allowed_providers: [],
|
||||
allowed_endpoints: [],
|
||||
allowed_api_formats: [],
|
||||
allowed_models: []
|
||||
}
|
||||
}
|
||||
@@ -498,7 +454,7 @@ function loadUserData() {
|
||||
unlimited: props.user.quota_usd == null,
|
||||
is_active: props.user.is_active ?? true,
|
||||
allowed_providers: props.user.allowed_providers || [],
|
||||
allowed_endpoints: props.user.allowed_endpoints || [],
|
||||
allowed_api_formats: props.user.allowed_api_formats || [],
|
||||
allowed_models: props.user.allowed_models || []
|
||||
}
|
||||
}
|
||||
@@ -539,7 +495,7 @@ async function loadAccessControlOptions() {
|
||||
}
|
||||
|
||||
// 切换选择
|
||||
function toggleSelection(field: 'allowed_providers' | 'allowed_endpoints' | 'allowed_models', value: string) {
|
||||
function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'allowed_models', value: string) {
|
||||
const arr = form.value[field]
|
||||
const index = arr.indexOf(value)
|
||||
if (index === -1) {
|
||||
@@ -564,7 +520,7 @@ async function handleSubmit() {
|
||||
quota_usd: form.value.unlimited ? null : form.value.quota,
|
||||
role: form.value.role,
|
||||
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,
|
||||
allowed_endpoints: form.value.allowed_endpoints.length > 0 ? form.value.allowed_endpoints : null,
|
||||
allowed_api_formats: form.value.allowed_api_formats.length > 0 ? form.value.allowed_api_formats : null,
|
||||
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
|
||||
}
|
||||
|
||||
|
||||
@@ -280,6 +280,16 @@
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<!-- GitHub Link -->
|
||||
<a
|
||||
href="https://github.com/fawney19/Aether"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
title="GitHub 仓库"
|
||||
>
|
||||
<GithubIcon class="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -302,6 +312,7 @@ import {
|
||||
Home,
|
||||
Users,
|
||||
Key,
|
||||
KeyRound,
|
||||
BarChart3,
|
||||
Cog,
|
||||
Settings,
|
||||
@@ -320,7 +331,9 @@ import {
|
||||
Megaphone,
|
||||
Menu,
|
||||
X,
|
||||
Mail,
|
||||
} from 'lucide-vue-next'
|
||||
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -386,6 +399,7 @@ const navigation = computed(() => {
|
||||
items: [
|
||||
{ name: '模型目录', href: '/dashboard/models', icon: Box },
|
||||
{ name: 'API 密钥', href: '/dashboard/api-keys', icon: Key },
|
||||
{ name: '访问令牌', href: '/dashboard/management-tokens', icon: KeyRound },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -411,6 +425,7 @@ const navigation = computed(() => {
|
||||
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
||||
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
||||
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
||||
{ name: '访问令牌', href: '/admin/management-tokens', icon: KeyRound },
|
||||
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
||||
]
|
||||
},
|
||||
@@ -421,6 +436,8 @@ const navigation = computed(() => {
|
||||
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
||||
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
||||
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||
{ name: 'LDAP 配置', href: '/admin/ldap', icon: Shield },
|
||||
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { User, LoginResponse } from '@/api/auth'
|
||||
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
|
||||
import type { User as AdminUser, ApiKey } from '@/api/users'
|
||||
import type { User as AdminUser } from '@/api/users'
|
||||
import type { AdminApiKeysResponse } from '@/api/admin'
|
||||
import type { Profile, UsageResponse } from '@/api/me'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
@@ -22,7 +22,7 @@ export const MOCK_ADMIN_USER: User = {
|
||||
used_usd: 156.78,
|
||||
total_usd: 1234.56,
|
||||
allowed_providers: null,
|
||||
allowed_endpoints: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
last_login_at: new Date().toISOString()
|
||||
@@ -38,7 +38,7 @@ export const MOCK_NORMAL_USER: User = {
|
||||
used_usd: 45.32,
|
||||
total_usd: 245.32,
|
||||
allowed_providers: null,
|
||||
allowed_endpoints: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
created_at: '2024-06-01T00:00:00Z',
|
||||
last_login_at: new Date().toISOString()
|
||||
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
|
||||
output: 700000,
|
||||
cache_creation: 50000,
|
||||
cache_read: 200000
|
||||
}
|
||||
},
|
||||
// 普通用户专用字段
|
||||
monthly_cost: 45.67
|
||||
}
|
||||
|
||||
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
|
||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' },
|
||||
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' },
|
||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' },
|
||||
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' },
|
||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' },
|
||||
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' },
|
||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' },
|
||||
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' }
|
||||
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
|
||||
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
|
||||
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
|
||||
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
|
||||
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
|
||||
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
|
||||
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
|
||||
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
|
||||
]
|
||||
|
||||
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
|
||||
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
|
||||
unique_models: 8 + Math.floor(Math.random() * 5),
|
||||
unique_providers: 4 + Math.floor(Math.random() * 3),
|
||||
model_breakdown: [
|
||||
{ model: 'claude-sonnet-4-20250514', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
|
||||
{ model: 'gpt-4o', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
|
||||
{ model: 'claude-opus-4-20250514', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
|
||||
{ model: 'gemini-2.0-flash', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
|
||||
{ model: 'claude-haiku-3-5-20241022', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
|
||||
{ model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
|
||||
{ model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
|
||||
{ model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -243,11 +245,11 @@ function generateDailyStats(): DailyStatsResponse {
|
||||
return {
|
||||
daily_stats: dailyStats,
|
||||
model_summary: [
|
||||
{ model: 'claude-sonnet-4-20250514', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
|
||||
{ model: 'gpt-4o', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
|
||||
{ model: 'claude-opus-4-20250514', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
|
||||
{ model: 'gemini-2.0-flash', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
|
||||
{ model: 'claude-haiku-3-5-20241022', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
|
||||
{ model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
|
||||
{ model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
|
||||
{ model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
|
||||
],
|
||||
period: {
|
||||
start_date: dailyStats[0].date,
|
||||
@@ -272,7 +274,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
used_usd: 156.78,
|
||||
total_usd: 1234.56,
|
||||
allowed_providers: null,
|
||||
allowed_endpoints: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
created_at: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
@@ -286,7 +288,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
used_usd: 45.32,
|
||||
total_usd: 245.32,
|
||||
allowed_providers: null,
|
||||
allowed_endpoints: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
created_at: '2024-06-01T00:00:00Z'
|
||||
},
|
||||
@@ -300,7 +302,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
used_usd: 23.45,
|
||||
total_usd: 123.45,
|
||||
allowed_providers: null,
|
||||
allowed_endpoints: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
created_at: '2024-03-15T00:00:00Z'
|
||||
},
|
||||
@@ -314,7 +316,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
used_usd: 89.12,
|
||||
total_usd: 589.12,
|
||||
allowed_providers: null,
|
||||
allowed_endpoints: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
created_at: '2024-02-20T00:00:00Z'
|
||||
},
|
||||
@@ -328,7 +330,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
used_usd: 30.00,
|
||||
total_usd: 30.00,
|
||||
allowed_providers: null,
|
||||
allowed_endpoints: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
created_at: '2024-04-10T00:00:00Z'
|
||||
}
|
||||
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
|
||||
|
||||
// ========== API Key 数据 ==========
|
||||
|
||||
export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
export const MOCK_USER_API_KEYS = [
|
||||
{
|
||||
id: 'key-uuid-001',
|
||||
key_display: 'sk-ae...x7f9',
|
||||
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: true,
|
||||
is_standalone: false,
|
||||
total_requests: 1234,
|
||||
total_cost_usd: 45.67
|
||||
total_cost_usd: 45.67,
|
||||
force_capabilities: null
|
||||
},
|
||||
{
|
||||
id: 'key-uuid-002',
|
||||
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: true,
|
||||
is_standalone: false,
|
||||
total_requests: 5678,
|
||||
total_cost_usd: 123.45
|
||||
total_cost_usd: 123.45,
|
||||
force_capabilities: { cache_1h: true }
|
||||
},
|
||||
{
|
||||
id: 'key-uuid-003',
|
||||
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
|
||||
is_active: false,
|
||||
is_standalone: false,
|
||||
total_requests: 100,
|
||||
total_cost_usd: 2.34
|
||||
total_cost_usd: 2.34,
|
||||
force_capabilities: null
|
||||
}
|
||||
]
|
||||
|
||||
@@ -813,16 +818,16 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
||||
quota_usd: 100,
|
||||
used_usd: 45.32,
|
||||
summary_by_model: [
|
||||
{ model: 'claude-sonnet-4-20250514', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
||||
{ model: 'gpt-4o', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
||||
{ model: 'claude-haiku-3-5-20241022', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
|
||||
{ model: 'gemini-2.0-flash', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
|
||||
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
|
||||
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
|
||||
{ model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
|
||||
{ model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 'usage-001',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
input_tokens: 1500,
|
||||
output_tokens: 800,
|
||||
total_tokens: 2300,
|
||||
@@ -837,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
|
||||
{
|
||||
id: 'usage-002',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-5.1',
|
||||
input_tokens: 2000,
|
||||
output_tokens: 500,
|
||||
total_tokens: 2500,
|
||||
|
||||
@@ -367,6 +367,11 @@ function generateMockUsageRecords(count: number = 100) {
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
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,
|
||||
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
|
||||
rate_multiplier: 1.0,
|
||||
@@ -405,10 +410,10 @@ function getUsageRecords() {
|
||||
|
||||
// Mock 映射数据
|
||||
const MOCK_ALIASES = [
|
||||
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-003', source_model: 'gpt4o', target_global_model_id: 'gm-004', target_global_model_name: 'gpt-4o', target_global_model_display_name: 'GPT-4o', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-004', source_model: 'gemini-flash', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-2.0-flash', target_global_model_display_name: 'Gemini 2.0 Flash', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
|
||||
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-003', target_global_model_name: 'claude-sonnet-4-5-20250929', target_global_model_display_name: 'Claude Sonnet 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-5-20251101', target_global_model_display_name: 'Claude Opus 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-003', source_model: 'gpt5', target_global_model_id: 'gm-006', target_global_model_name: 'gpt-5.1', target_global_model_display_name: 'GPT-5.1', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
|
||||
]
|
||||
|
||||
// Mock Endpoint Keys
|
||||
@@ -685,7 +690,7 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
used_usd: 0,
|
||||
total_usd: 0,
|
||||
allowed_providers: null,
|
||||
allowed_endpoints: null,
|
||||
allowed_api_formats: null,
|
||||
allowed_models: null,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
@@ -835,10 +840,26 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
'GET /api/admin/usage/records': async (config) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const records = getUsageRecords()
|
||||
let records = getUsageRecords()
|
||||
const params = config.params || {}
|
||||
const limit = parseInt(params.limit) || 20
|
||||
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({
|
||||
records: records.slice(offset, offset + limit),
|
||||
total: records.length,
|
||||
@@ -2172,10 +2193,10 @@ function generateIntervalTimelineData(
|
||||
|
||||
// 模型列表(用于按模型区分颜色)
|
||||
const models = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-opus-4-20250514'
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-haiku-4-5-20251001',
|
||||
'claude-opus-4-5-20251101',
|
||||
'gpt-5.1'
|
||||
]
|
||||
|
||||
// 生成模拟的请求间隔数据
|
||||
|
||||
@@ -34,6 +34,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'MyApiKeys',
|
||||
component: () => importWithRetry(() => import('@/views/user/MyApiKeys.vue'))
|
||||
},
|
||||
{
|
||||
path: 'management-tokens',
|
||||
name: 'ManagementTokens',
|
||||
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue'))
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'Announcements',
|
||||
@@ -81,6 +86,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ApiKeys',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ApiKeys.vue'))
|
||||
},
|
||||
{
|
||||
path: 'management-tokens',
|
||||
name: 'AdminManagementTokens',
|
||||
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue'))
|
||||
},
|
||||
{
|
||||
path: 'providers',
|
||||
name: 'ProviderManagement',
|
||||
@@ -106,6 +116,16 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'SystemSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'ldap',
|
||||
name: 'LdapSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'AuditLogs',
|
||||
|
||||
@@ -31,12 +31,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
async function login(email: string, password: string, authType: 'local' | 'ldap' = 'local') {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await authApi.login({ email, password })
|
||||
const response = await authApi.login({ email, password, auth_type: authType })
|
||||
token.value = response.access_token
|
||||
|
||||
// 获取用户信息
|
||||
|
||||
@@ -1191,4 +1191,11 @@ body[theme-mode='dark'] .literary-annotation {
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Password masking without type="password" to prevent browser autofill */
|
||||
.-webkit-text-security-disc {
|
||||
-webkit-text-security: disc;
|
||||
-moz-text-security: disc;
|
||||
text-security: disc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,28 +850,20 @@ async function deleteApiKey(apiKey: AdminApiKey) {
|
||||
}
|
||||
|
||||
function editApiKey(apiKey: AdminApiKey) {
|
||||
// 计算过期天数
|
||||
let expireDays: number | undefined = undefined
|
||||
let neverExpire = true
|
||||
// 解析过期日期为 YYYY-MM-DD 格式
|
||||
// 保留原始日期,不做时间过滤(避免编辑当天过期的 Key 时意外清空)
|
||||
let expiresAt: string | undefined = undefined
|
||||
|
||||
if (apiKey.expires_at) {
|
||||
const expiresDate = new Date(apiKey.expires_at)
|
||||
const now = new Date()
|
||||
const diffMs = expiresDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays > 0) {
|
||||
expireDays = diffDays
|
||||
neverExpire = false
|
||||
}
|
||||
expiresAt = expiresDate.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
editingKeyData.value = {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name || '',
|
||||
expire_days: expireDays,
|
||||
never_expire: neverExpire,
|
||||
rate_limit: apiKey.rate_limit || 100,
|
||||
expires_at: expiresAt,
|
||||
rate_limit: apiKey.rate_limit ?? undefined,
|
||||
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
|
||||
allowed_providers: apiKey.allowed_providers || [],
|
||||
allowed_api_formats: apiKey.allowed_api_formats || [],
|
||||
@@ -1033,14 +1025,25 @@ function closeKeyFormDialog() {
|
||||
|
||||
// 统一处理表单提交
|
||||
async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
// 验证过期日期(如果设置了,必须晚于今天)
|
||||
if (data.expires_at) {
|
||||
const selectedDate = new Date(data.expires_at)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (selectedDate <= today) {
|
||||
error('过期日期必须晚于今天')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
keyFormDialogRef.value?.setSaving(true)
|
||||
try {
|
||||
if (data.id) {
|
||||
// 更新
|
||||
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
|
||||
name: data.name || undefined,
|
||||
rate_limit: data.rate_limit,
|
||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
||||
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
|
||||
expires_at: data.expires_at || null, // undefined/空 = 永不过期
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
|
||||
allowed_providers: data.allowed_providers,
|
||||
@@ -1058,8 +1061,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
const createData: CreateStandaloneApiKeyRequest = {
|
||||
name: data.name || undefined,
|
||||
initial_balance_usd: data.initial_balance_usd,
|
||||
rate_limit: data.rate_limit,
|
||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
||||
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
|
||||
expires_at: data.expires_at || null, // undefined/空 = 永不过期
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
|
||||
allowed_providers: data.allowed_providers,
|
||||
|
||||
@@ -1057,7 +1057,10 @@ onBeforeUnmount(() => {
|
||||
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔,推荐合适的缓存 TTL</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Select v-model="analysisHours" v-model:open="analysisHoursSelectOpen">
|
||||
<Select
|
||||
v-model="analysisHours"
|
||||
v-model:open="analysisHoursSelectOpen"
|
||||
>
|
||||
<SelectTrigger class="w-24 sm:w-28 h-8">
|
||||
<SelectValue placeholder="时间段" />
|
||||
</SelectTrigger>
|
||||
|
||||
880
frontend/src/views/admin/EmailSettings.vue
Normal file
880
frontend/src/views/admin/EmailSettings.vue
Normal file
@@ -0,0 +1,880 @@
|
||||
<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 || emailConfig.smtp_password) ? 'pr-10' : ''"
|
||||
autocomplete="one-time-code"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<button
|
||||
v-if="smtpPasswordIsSet || emailConfig.smtp_password"
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="清除密码"
|
||||
@click="handleClearSmtpPassword"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
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 clearSmtpPassword = 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 {
|
||||
// 配置不存在时使用默认值,无需处理
|
||||
}
|
||||
}
|
||||
clearSmtpPassword.value = false
|
||||
} catch (err) {
|
||||
error('加载邮件配置失败')
|
||||
log.error('加载邮件配置失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 SMTP 配置
|
||||
async function saveSmtpConfig() {
|
||||
smtpSaveLoading.value = true
|
||||
try {
|
||||
const passwordAction: 'unchanged' | 'updated' | 'cleared' = emailConfig.value.smtp_password
|
||||
? 'updated'
|
||||
: clearSmtpPassword.value
|
||||
? 'cleared'
|
||||
: 'unchanged'
|
||||
|
||||
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 用户名'
|
||||
},
|
||||
// 只有输入了新密码才提交(空值表示保持原密码)
|
||||
...(passwordAction === 'updated'
|
||||
? [{
|
||||
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)
|
||||
)
|
||||
|
||||
// 如果标记了清除密码,删除密码配置
|
||||
if (passwordAction === 'cleared') {
|
||||
promises.push(adminApi.deleteSystemConfig('smtp_password'))
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
success('SMTP 配置已保存')
|
||||
|
||||
// 更新状态
|
||||
if (passwordAction === 'cleared') {
|
||||
clearSmtpPassword.value = false
|
||||
smtpPasswordIsSet.value = false
|
||||
} else if (passwordAction === 'updated') {
|
||||
clearSmtpPassword.value = false
|
||||
smtpPasswordIsSet.value = true
|
||||
}
|
||||
emailConfig.value.smtp_password = null
|
||||
} 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 密码
|
||||
function handleClearSmtpPassword() {
|
||||
// 如果有输入内容,先清空输入框
|
||||
if (emailConfig.value.smtp_password) {
|
||||
emailConfig.value.smtp_password = null
|
||||
return
|
||||
}
|
||||
// 标记要清除服务端密码(保存时生效)
|
||||
if (smtpPasswordIsSet.value) {
|
||||
clearSmtpPassword.value = true
|
||||
smtpPasswordIsSet.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 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>
|
||||
426
frontend/src/views/admin/LdapSettings.vue
Normal file
426
frontend/src/views/admin/LdapSettings.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="LDAP 配置"
|
||||
description="配置 LDAP 认证服务"
|
||||
/>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<CardSection
|
||||
title="LDAP 服务器配置"
|
||||
description="配置 LDAP 服务器连接参数"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="testLoading"
|
||||
@click="handleTestConnection"
|
||||
>
|
||||
{{ testLoading ? '测试中...' : '测试连接' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saveLoading"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saveLoading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="server-url"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
服务器地址
|
||||
</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
v-model="ldapConfig.server_url"
|
||||
type="text"
|
||||
placeholder="ldap://ldap.example.com:389"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
格式: ldap://host:389 或 ldaps://host:636
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="bind-dn"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
绑定 DN
|
||||
</Label>
|
||||
<Input
|
||||
id="bind-dn"
|
||||
v-model="ldapConfig.bind_dn"
|
||||
type="text"
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
用于连接 LDAP 服务器的管理员 DN
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="bind-password"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
绑定密码
|
||||
</Label>
|
||||
<div class="relative mt-1">
|
||||
<Input
|
||||
id="bind-password"
|
||||
v-model="ldapConfig.bind_password"
|
||||
type="password"
|
||||
:placeholder="hasPassword ? '已设置(留空保持不变)' : '请输入密码'"
|
||||
:class="(hasPassword || ldapConfig.bind_password) ? 'pr-10' : ''"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
v-if="hasPassword || ldapConfig.bind_password"
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="清除密码"
|
||||
@click="handleClearPassword"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><line
|
||||
x1="18"
|
||||
y1="6"
|
||||
x2="6"
|
||||
y2="18"
|
||||
/><line
|
||||
x1="6"
|
||||
y1="6"
|
||||
x2="18"
|
||||
y2="18"
|
||||
/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
绑定账号的密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="base-dn"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
基础 DN
|
||||
</Label>
|
||||
<Input
|
||||
id="base-dn"
|
||||
v-model="ldapConfig.base_dn"
|
||||
type="text"
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
用户搜索的基础 DN
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="user-search-filter"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
用户搜索过滤器
|
||||
</Label>
|
||||
<Input
|
||||
id="user-search-filter"
|
||||
v-model="ldapConfig.user_search_filter"
|
||||
type="text"
|
||||
placeholder="(uid={username})"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{username} 会被替换为登录用户名
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="username-attr"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
用户名属性
|
||||
</Label>
|
||||
<Input
|
||||
id="username-attr"
|
||||
v-model="ldapConfig.username_attr"
|
||||
type="text"
|
||||
placeholder="uid"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
常用: uid (OpenLDAP), sAMAccountName (AD)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="email-attr"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
邮箱属性
|
||||
</Label>
|
||||
<Input
|
||||
id="email-attr"
|
||||
v-model="ldapConfig.email_attr"
|
||||
type="text"
|
||||
placeholder="mail"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="display-name-attr"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
显示名称属性
|
||||
</Label>
|
||||
<Input
|
||||
id="display-name-attr"
|
||||
v-model="ldapConfig.display_name_attr"
|
||||
type="text"
|
||||
placeholder="cn"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="connect-timeout"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
连接超时 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="connect-timeout"
|
||||
v-model.number="ldapConfig.connect_timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
placeholder="10"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
单次 LDAP 操作超时时间 (1-60秒),跨国网络建议 15-30 秒
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">使用 STARTTLS</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
在非 SSL 连接上启用 TLS 加密
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="ldapConfig.use_starttls" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">启用 LDAP 认证</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
允许用户使用 LDAP 账号登录
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="ldapConfig.is_enabled" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">仅允许 LDAP 登录</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
禁用本地账号登录,仅允许 LDAP 认证
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="ldapConfig.is_exclusive" />
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
|
||||
import { Button, Input, Label, Switch } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi, type LdapConfigUpdateRequest } from '@/api/admin'
|
||||
|
||||
const { success, error } = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const saveLoading = ref(false)
|
||||
const testLoading = ref(false)
|
||||
const hasPassword = ref(false)
|
||||
const clearPassword = ref(false) // 标记是否要清除密码
|
||||
|
||||
const ldapConfig = ref({
|
||||
server_url: '',
|
||||
bind_dn: '',
|
||||
bind_password: '',
|
||||
base_dn: '',
|
||||
user_search_filter: '(uid={username})',
|
||||
username_attr: 'uid',
|
||||
email_attr: 'mail',
|
||||
display_name_attr: 'cn',
|
||||
is_enabled: false,
|
||||
is_exclusive: false,
|
||||
use_starttls: false,
|
||||
connect_timeout: 10,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfig()
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminApi.getLdapConfig()
|
||||
ldapConfig.value = {
|
||||
server_url: response.server_url || '',
|
||||
bind_dn: response.bind_dn || '',
|
||||
bind_password: '',
|
||||
base_dn: response.base_dn || '',
|
||||
user_search_filter: response.user_search_filter || '(uid={username})',
|
||||
username_attr: response.username_attr || 'uid',
|
||||
email_attr: response.email_attr || 'mail',
|
||||
display_name_attr: response.display_name_attr || 'cn',
|
||||
is_enabled: response.is_enabled || false,
|
||||
is_exclusive: response.is_exclusive || false,
|
||||
use_starttls: response.use_starttls || false,
|
||||
connect_timeout: response.connect_timeout || 10,
|
||||
}
|
||||
hasPassword.value = !!response.has_bind_password
|
||||
clearPassword.value = false
|
||||
} catch (err) {
|
||||
error('加载 LDAP 配置失败')
|
||||
console.error('加载 LDAP 配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saveLoading.value = true
|
||||
try {
|
||||
const payload: LdapConfigUpdateRequest = {
|
||||
server_url: ldapConfig.value.server_url,
|
||||
bind_dn: ldapConfig.value.bind_dn,
|
||||
base_dn: ldapConfig.value.base_dn,
|
||||
user_search_filter: ldapConfig.value.user_search_filter,
|
||||
username_attr: ldapConfig.value.username_attr,
|
||||
email_attr: ldapConfig.value.email_attr,
|
||||
display_name_attr: ldapConfig.value.display_name_attr,
|
||||
is_enabled: ldapConfig.value.is_enabled,
|
||||
is_exclusive: ldapConfig.value.is_exclusive,
|
||||
use_starttls: ldapConfig.value.use_starttls,
|
||||
connect_timeout: ldapConfig.value.connect_timeout,
|
||||
}
|
||||
|
||||
// 优先使用输入的新密码;否则如果标记清除则发送空字符串
|
||||
let passwordAction: 'unchanged' | 'updated' | 'cleared' = 'unchanged'
|
||||
if (ldapConfig.value.bind_password) {
|
||||
payload.bind_password = ldapConfig.value.bind_password
|
||||
passwordAction = 'updated'
|
||||
} else if (clearPassword.value) {
|
||||
payload.bind_password = ''
|
||||
passwordAction = 'cleared'
|
||||
}
|
||||
|
||||
await adminApi.updateLdapConfig(payload)
|
||||
success('LDAP 配置保存成功')
|
||||
|
||||
if (passwordAction === 'cleared') {
|
||||
hasPassword.value = false
|
||||
clearPassword.value = false
|
||||
} else if (passwordAction === 'updated') {
|
||||
hasPassword.value = true
|
||||
clearPassword.value = false
|
||||
}
|
||||
ldapConfig.value.bind_password = ''
|
||||
} catch (err) {
|
||||
error('保存 LDAP 配置失败')
|
||||
console.error('保存 LDAP 配置失败:', err)
|
||||
} finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (clearPassword.value && !ldapConfig.value.bind_password) {
|
||||
error('已标记清除绑定密码,请先保存或输入新的绑定密码再测试')
|
||||
return
|
||||
}
|
||||
|
||||
testLoading.value = true
|
||||
try {
|
||||
const payload: LdapConfigUpdateRequest = {
|
||||
server_url: ldapConfig.value.server_url,
|
||||
bind_dn: ldapConfig.value.bind_dn,
|
||||
base_dn: ldapConfig.value.base_dn,
|
||||
user_search_filter: ldapConfig.value.user_search_filter,
|
||||
username_attr: ldapConfig.value.username_attr,
|
||||
email_attr: ldapConfig.value.email_attr,
|
||||
display_name_attr: ldapConfig.value.display_name_attr,
|
||||
is_enabled: ldapConfig.value.is_enabled,
|
||||
is_exclusive: ldapConfig.value.is_exclusive,
|
||||
use_starttls: ldapConfig.value.use_starttls,
|
||||
connect_timeout: ldapConfig.value.connect_timeout,
|
||||
...(ldapConfig.value.bind_password && { bind_password: ldapConfig.value.bind_password }),
|
||||
}
|
||||
const response = await adminApi.testLdapConnection(payload)
|
||||
if (response.success) {
|
||||
success('LDAP 连接测试成功')
|
||||
} else {
|
||||
error(`LDAP 连接测试失败: ${response.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
error('LDAP 连接测试失败')
|
||||
console.error('LDAP 连接测试失败:', err)
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearPassword() {
|
||||
// 如果有输入内容,先清空输入框
|
||||
if (ldapConfig.value.bind_password) {
|
||||
ldapConfig.value.bind_password = ''
|
||||
return
|
||||
}
|
||||
// 标记要清除服务端密码(保存时生效)
|
||||
if (hasPassword.value) {
|
||||
clearPassword.value = true
|
||||
hasPassword.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -737,6 +737,7 @@ import {
|
||||
updateGlobalModel,
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
type GlobalModelResponse,
|
||||
} from '@/api/global-models'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -1080,42 +1081,32 @@ async function selectModel(model: GlobalModelResponse) {
|
||||
async function loadModelProviders(_globalModelId: string) {
|
||||
loadingModelProviders.value = true
|
||||
try {
|
||||
// 使用 ModelCatalog API 获取详细的关联提供商信息
|
||||
const { getModelCatalog } = await import('@/api/endpoints')
|
||||
const catalogResponse = await getModelCatalog()
|
||||
// 使用新的 API 获取所有关联提供商(包括非活跃的)
|
||||
const response = await getGlobalModelProviders(_globalModelId)
|
||||
|
||||
// 查找当前 GlobalModel 对应的 catalog item
|
||||
const catalogItem = catalogResponse.models.find(
|
||||
m => m.global_model_name === selectedModel.value?.name
|
||||
)
|
||||
|
||||
if (catalogItem) {
|
||||
// 转换为展示格式,包含完整的模型实现信息
|
||||
selectedModelProviders.value = catalogItem.providers.map(p => ({
|
||||
id: p.provider_id,
|
||||
model_id: p.model_id,
|
||||
display_name: p.provider_display_name || p.provider_name,
|
||||
identifier: p.provider_name,
|
||||
provider_type: 'API',
|
||||
target_model: p.target_model,
|
||||
is_active: p.is_active,
|
||||
// 价格信息
|
||||
input_price_per_1m: p.input_price_per_1m,
|
||||
output_price_per_1m: p.output_price_per_1m,
|
||||
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m: p.cache_read_price_per_1m,
|
||||
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
|
||||
price_per_request: p.price_per_request,
|
||||
effective_tiered_pricing: p.effective_tiered_pricing,
|
||||
tier_count: p.tier_count,
|
||||
// 能力信息
|
||||
supports_vision: p.supports_vision,
|
||||
supports_function_calling: p.supports_function_calling,
|
||||
supports_streaming: p.supports_streaming
|
||||
}))
|
||||
} else {
|
||||
selectedModelProviders.value = []
|
||||
}
|
||||
// 转换为展示格式
|
||||
selectedModelProviders.value = response.providers.map(p => ({
|
||||
id: p.provider_id,
|
||||
model_id: p.model_id,
|
||||
display_name: p.provider_display_name || p.provider_name,
|
||||
identifier: p.provider_name,
|
||||
provider_type: 'API',
|
||||
target_model: p.target_model,
|
||||
is_active: p.is_active,
|
||||
// 价格信息
|
||||
input_price_per_1m: p.input_price_per_1m,
|
||||
output_price_per_1m: p.output_price_per_1m,
|
||||
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m: p.cache_read_price_per_1m,
|
||||
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
|
||||
price_per_request: p.price_per_request,
|
||||
effective_tiered_pricing: p.effective_tiered_pricing,
|
||||
tier_count: p.tier_count,
|
||||
// 能力信息
|
||||
supports_vision: p.supports_vision,
|
||||
supports_function_calling: p.supports_function_calling,
|
||||
supports_streaming: p.supports_streaming
|
||||
}))
|
||||
} catch (err: any) {
|
||||
log.error('加载关联提供商失败:', err)
|
||||
showError(parseApiError(err, '加载关联提供商失败'), '错误')
|
||||
|
||||
@@ -465,6 +465,29 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 导入配置对话框 -->
|
||||
@@ -476,7 +499,7 @@
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="importPreview"
|
||||
class="p-3 bg-muted rounded-lg text-sm"
|
||||
class="text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
配置预览
|
||||
@@ -558,7 +581,7 @@
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
全局模型
|
||||
</p>
|
||||
@@ -568,7 +591,7 @@
|
||||
跳过: {{ importResult.stats.global_models.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
提供商
|
||||
</p>
|
||||
@@ -578,7 +601,7 @@
|
||||
跳过: {{ importResult.stats.providers.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
端点
|
||||
</p>
|
||||
@@ -588,7 +611,7 @@
|
||||
跳过: {{ importResult.stats.endpoints.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
@@ -597,7 +620,7 @@
|
||||
跳过: {{ importResult.stats.keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg col-span-2">
|
||||
<div class="col-span-2">
|
||||
<p class="font-medium">
|
||||
模型配置
|
||||
</p>
|
||||
@@ -643,7 +666,7 @@
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="importUsersPreview"
|
||||
class="p-3 bg-muted rounded-lg text-sm"
|
||||
class="text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
数据预览
|
||||
@@ -653,6 +676,9 @@
|
||||
<li>
|
||||
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
|
||||
</li>
|
||||
<li v-if="importUsersPreview.standalone_keys?.length">
|
||||
独立余额 Keys: {{ importUsersPreview.standalone_keys.length }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -721,7 +747,7 @@
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
用户
|
||||
</p>
|
||||
@@ -731,7 +757,7 @@
|
||||
跳过: {{ importUsersResult.stats.users.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
@@ -740,6 +766,18 @@
|
||||
跳过: {{ importUsersResult.stats.api_keys.skipped }}
|
||||
</p>
|
||||
</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
|
||||
@@ -770,7 +808,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Download, Upload } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
@@ -840,6 +878,9 @@ const importUsersResult = ref<UsersImportResponse | null>(null)
|
||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const usersMergeModeSelectOpen = ref(false)
|
||||
|
||||
// 系统版本信息
|
||||
const systemVersion = ref<string>('')
|
||||
|
||||
const systemConfig = ref<SystemConfig>({
|
||||
// 基础配置
|
||||
default_user_quota_usd: 10.0,
|
||||
@@ -891,9 +932,21 @@ const sensitiveHeadersStr = computed({
|
||||
})
|
||||
|
||||
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() {
|
||||
try {
|
||||
const configs = [
|
||||
@@ -1179,12 +1232,6 @@ function handleUsersFileSelect(event: Event) {
|
||||
const content = e.target?.result as string
|
||||
const data = JSON.parse(content) as UsersExportData
|
||||
|
||||
// 验证版本
|
||||
if (data.version !== '1.0') {
|
||||
error(`不支持的配置版本: ${data.version}`)
|
||||
return
|
||||
}
|
||||
|
||||
importUsersPreview.value = data
|
||||
usersMergeMode.value = 'skip'
|
||||
importUsersDialogOpen.value = true
|
||||
|
||||
@@ -907,7 +907,7 @@ function editUser(user: any) {
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
allowed_providers: user.allowed_providers || [],
|
||||
allowed_endpoints: user.allowed_endpoints || [],
|
||||
allowed_api_formats: user.allowed_api_formats || [],
|
||||
allowed_models: user.allowed_models || []
|
||||
}
|
||||
showUserFormDialog.value = true
|
||||
@@ -929,7 +929,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
||||
quota_usd: data.quota_usd,
|
||||
role: data.role,
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_endpoints: data.allowed_endpoints,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
}
|
||||
if (data.password) {
|
||||
@@ -946,7 +946,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
|
||||
quota_usd: data.quota_usd,
|
||||
role: data.role,
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_endpoints: data.allowed_endpoints,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
})
|
||||
// 如果创建时指定为禁用,则更新状态
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="fixed top-0 left-0 right-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
|
||||
<div class="mx-auto max-w-7xl px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo & Brand -->
|
||||
<header class="sticky top-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
|
||||
<div class="h-16 flex items-center">
|
||||
<!-- Centered content container (max-w-7xl) -->
|
||||
<div class="mx-auto max-w-7xl w-full px-6 flex items-center justify-between">
|
||||
<!-- Left: Logo & Brand -->
|
||||
<div
|
||||
class="flex items-center gap-3 group/logo cursor-pointer"
|
||||
@click="scrollToSection(0)"
|
||||
@@ -40,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center Navigation -->
|
||||
<!-- Center: Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-2">
|
||||
<button
|
||||
v-for="(section, index) in sections"
|
||||
@@ -59,42 +60,54 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Right Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<SunMoon
|
||||
v-if="themeMode === 'system'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<Sun
|
||||
v-else-if="themeMode === 'light'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<Moon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<!-- Right: Login/Dashboard Button -->
|
||||
<RouterLink
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="dashboardPath"
|
||||
class="min-w-[72px] text-center rounded-xl bg-[#191919] dark:bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52] whitespace-nowrap"
|
||||
>
|
||||
控制台
|
||||
</RouterLink>
|
||||
<button
|
||||
v-else
|
||||
class="min-w-[72px] text-center rounded-xl bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f] whitespace-nowrap"
|
||||
@click="showLoginDialog = true"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="dashboardPath"
|
||||
class="rounded-xl bg-[#191919] dark:bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52]"
|
||||
>
|
||||
控制台
|
||||
</RouterLink>
|
||||
<button
|
||||
<!-- Fixed right icons (px-8 to match dashboard) -->
|
||||
<div class="absolute right-8 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<SunMoon
|
||||
v-if="themeMode === 'system'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<Sun
|
||||
v-else-if="themeMode === 'light'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<Moon
|
||||
v-else
|
||||
class="rounded-xl bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f]"
|
||||
@click="showLoginDialog = true"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<!-- GitHub Link -->
|
||||
<a
|
||||
href="https://github.com/fawney19/Aether"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
title="GitHub 仓库"
|
||||
>
|
||||
<GithubIcon class="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -336,31 +349,6 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="relative z-10 border-t border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-md py-8">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<p class="text-sm text-[#91918d] dark:text-muted-foreground">
|
||||
© 2025 Aether. 团队内部使用
|
||||
</p>
|
||||
<div class="flex items-center gap-6 text-sm text-[#91918d] dark:text-muted-foreground">
|
||||
<a
|
||||
href="#"
|
||||
class="transition hover:text-[#191919] dark:hover:text-white"
|
||||
>使用条款</a>
|
||||
<a
|
||||
href="#"
|
||||
class="transition hover:text-[#191919] dark:hover:text-white"
|
||||
>隐私政策</a>
|
||||
<a
|
||||
href="#"
|
||||
class="transition hover:text-[#191919] dark:hover:text-white"
|
||||
>技术支持</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<LoginDialog v-model="showLoginDialog" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -378,6 +366,7 @@ import {
|
||||
SunMoon,
|
||||
Terminal
|
||||
} from 'lucide-vue-next'
|
||||
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
|
||||
@@ -145,10 +145,10 @@
|
||||
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
实际成本
|
||||
本月费用
|
||||
</p>
|
||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||
{{ formatCurrency(costStats.total_actual_cost) }}
|
||||
{{ formatCurrency(costStats.total_cost) }}
|
||||
</p>
|
||||
<Badge
|
||||
v-if="costStats.cost_savings > 0"
|
||||
@@ -162,14 +162,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 普通用户:缓存统计 -->
|
||||
<!-- 普通用户:月度统计 -->
|
||||
<div
|
||||
v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0"
|
||||
v-else-if="!isAdmin && (hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))"
|
||||
class="mt-6"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
本月缓存使用
|
||||
本月统计
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -178,8 +178,16 @@
|
||||
Monthly
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
||||
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
|
||||
<div
|
||||
class="grid gap-2 sm:gap-3"
|
||||
:class="[
|
||||
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
|
||||
]"
|
||||
>
|
||||
<Card
|
||||
v-if="cacheStats"
|
||||
class="relative p-3 sm:p-4 border-book-cloth/30"
|
||||
>
|
||||
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
@@ -190,7 +198,10 @@
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative p-3 sm:p-4 border-kraft/30">
|
||||
<Card
|
||||
v-if="cacheStats"
|
||||
class="relative p-3 sm:p-4 border-kraft/30"
|
||||
>
|
||||
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
@@ -201,7 +212,10 @@
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
|
||||
<Card
|
||||
v-if="cacheStats"
|
||||
class="relative p-3 sm:p-4 border-book-cloth/25"
|
||||
>
|
||||
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
@@ -213,19 +227,16 @@
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="tokenBreakdown"
|
||||
v-if="userMonthlyCost !== null"
|
||||
class="relative p-3 sm:p-4 border-manilla/40"
|
||||
>
|
||||
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
<div class="pr-6">
|
||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||
总Token
|
||||
本月费用
|
||||
</p>
|
||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
|
||||
</p>
|
||||
<p class="mt-0.5 sm:mt-1 text-[9px] sm:text-[10px] text-muted-foreground">
|
||||
输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}
|
||||
{{ formatCurrency(userMonthlyCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -831,6 +842,12 @@ const cacheStats = ref<{
|
||||
total_cache_tokens: number
|
||||
} | null>(null)
|
||||
|
||||
const userMonthlyCost = ref<number | null>(null)
|
||||
|
||||
const hasCacheData = computed(() =>
|
||||
cacheStats.value && cacheStats.value.total_cache_tokens > 0
|
||||
)
|
||||
|
||||
const tokenBreakdown = ref<{
|
||||
input: number
|
||||
output: number
|
||||
@@ -1086,6 +1103,7 @@ async function loadDashboardData() {
|
||||
} else {
|
||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<ActivityHeatmapCard
|
||||
:data="activityHeatmapData"
|
||||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
||||
:is-loading="isLoadingHeatmap"
|
||||
:has-error="heatmapError"
|
||||
/>
|
||||
<IntervalTimelineCard
|
||||
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
|
||||
@@ -54,6 +56,7 @@
|
||||
:show-actual-cost="authStore.isAdmin"
|
||||
:loading="isLoadingRecords"
|
||||
:selected-period="selectedPeriod"
|
||||
:filter-search="filterSearch"
|
||||
:filter-user="filterUser"
|
||||
:filter-model="filterModel"
|
||||
:filter-provider="filterProvider"
|
||||
@@ -67,6 +70,7 @@
|
||||
:page-size-options="pageSizeOptions"
|
||||
:auto-refresh="globalAutoRefresh"
|
||||
@update:selected-period="handlePeriodChange"
|
||||
@update:filter-search="handleFilterSearchChange"
|
||||
@update:filter-user="handleFilterUserChange"
|
||||
@update:filter-model="handleFilterModelChange"
|
||||
@update:filter-provider="handleFilterProviderChange"
|
||||
@@ -112,8 +116,11 @@ import {
|
||||
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
|
||||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const { warning } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 判断是否是管理员页面
|
||||
@@ -128,6 +135,7 @@ const pageSize = ref(20)
|
||||
const pageSizeOptions = [10, 20, 50, 100]
|
||||
|
||||
// 筛选状态
|
||||
const filterSearch = ref('')
|
||||
const filterUser = ref('__all__')
|
||||
const filterModel = ref('__all__')
|
||||
const filterProvider = ref('__all__')
|
||||
@@ -144,13 +152,35 @@ const {
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
availableModels,
|
||||
availableProviders,
|
||||
loadStats,
|
||||
loadRecords
|
||||
} = useUsageData({ isAdminPage })
|
||||
|
||||
// 热力图状态
|
||||
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
|
||||
const isLoadingHeatmap = ref(false)
|
||||
const heatmapError = ref(false)
|
||||
|
||||
// 加载热力图数据
|
||||
async function loadHeatmapData() {
|
||||
isLoadingHeatmap.value = true
|
||||
heatmapError.value = false
|
||||
try {
|
||||
if (isAdminPage.value) {
|
||||
activityHeatmapData.value = await usageApi.getActivityHeatmap()
|
||||
} else {
|
||||
activityHeatmapData.value = await meApi.getActivityHeatmap()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载热力图数据失败:', error)
|
||||
heatmapError.value = true
|
||||
} finally {
|
||||
isLoadingHeatmap.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 用户页面需要前端筛选
|
||||
const filteredRecords = computed(() => {
|
||||
if (!isAdminPage.value) {
|
||||
@@ -232,27 +262,40 @@ async function pollActiveRequests() {
|
||||
? await usageApi.getActiveRequests(activeRequestIds.value)
|
||||
: await meApi.getActiveRequests(idsParam)
|
||||
|
||||
// 检查是否有状态变化
|
||||
let hasChanges = false
|
||||
let shouldRefresh = false
|
||||
|
||||
for (const update of requests) {
|
||||
const record = currentRecords.value.find(r => r.id === update.id)
|
||||
if (record && record.status !== update.status) {
|
||||
hasChanges = true
|
||||
// 如果状态变为 completed 或 failed,需要刷新获取完整数据
|
||||
if (update.status === 'completed' || update.status === 'failed') {
|
||||
break
|
||||
}
|
||||
// 否则只更新状态和 token 信息
|
||||
if (!record) {
|
||||
// 后端返回了未知的活跃请求,触发刷新以获取完整数据
|
||||
shouldRefresh = true
|
||||
continue
|
||||
}
|
||||
|
||||
// 状态变化:completed/failed 需要刷新获取完整数据
|
||||
if (record.status !== update.status) {
|
||||
record.status = update.status
|
||||
record.input_tokens = update.input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cost = update.cost
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
}
|
||||
if (update.status === 'completed' || update.status === 'failed') {
|
||||
shouldRefresh = true
|
||||
}
|
||||
|
||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||
record.input_tokens = update.input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cost = update.cost
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
||||
// 管理员接口返回额外字段
|
||||
if ('provider' in update && typeof update.provider === 'string') {
|
||||
record.provider = update.provider
|
||||
}
|
||||
if ('api_key_name' in update) {
|
||||
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有请求完成或失败,刷新整个列表获取完整数据
|
||||
if (hasChanges && requests.some(r => r.status === 'completed' || r.status === 'failed')) {
|
||||
if (shouldRefresh) {
|
||||
await refreshData()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -335,16 +378,34 @@ const selectedRequestId = ref<string | null>(null)
|
||||
// 初始化加载
|
||||
onMounted(async () => {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
// 管理员页面加载用户列表和第一页记录
|
||||
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
|
||||
const [statsResult, heatmapResult] = await Promise.allSettled([
|
||||
loadStats(dateRange),
|
||||
loadHeatmapData()
|
||||
])
|
||||
|
||||
// 检查加载结果并通知用户
|
||||
if (statsResult.status === 'rejected') {
|
||||
log.error('加载统计数据失败:', statsResult.reason)
|
||||
warning('统计数据加载失败,请刷新重试')
|
||||
}
|
||||
if (heatmapResult.status === 'rejected') {
|
||||
log.error('加载热力图数据失败:', heatmapResult.reason)
|
||||
// 热力图加载失败不提示,因为 UI 已显示占位符
|
||||
}
|
||||
|
||||
// 加载记录和用户列表
|
||||
if (isAdminPage.value) {
|
||||
// 并行加载用户列表和记录
|
||||
// 管理员页面:并行加载用户列表和记录
|
||||
const [users] = await Promise.all([
|
||||
usersApi.getAllUsers(),
|
||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
])
|
||||
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)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
async function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
|
||||
// 处理每页大小变化
|
||||
async function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||
}
|
||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||
}
|
||||
|
||||
// 获取当前筛选参数
|
||||
function getCurrentFilters() {
|
||||
return {
|
||||
search: filterSearch.value.trim() || undefined,
|
||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||
model: filterModel.value !== '__all__' ? filterModel.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) {
|
||||
filterUser.value = value
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
@@ -431,10 +491,7 @@ async function handleFilterStatusChange(value: string) {
|
||||
async function refreshData() {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
|
||||
// 显示请求详情
|
||||
|
||||
859
frontend/src/views/user/ManagementTokens.vue
Normal file
859
frontend/src/views/user/ManagementTokens.vue
Normal file
@@ -0,0 +1,859 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 访问令牌表格 -->
|
||||
<Card
|
||||
variant="default"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm sm:text-base font-semibold">
|
||||
访问令牌
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
<template v-if="quota">
|
||||
已创建 {{ quota.used }}/{{ quota.max }} 个令牌
|
||||
<span
|
||||
v-if="quota.used >= quota.max"
|
||||
class="text-destructive font-medium"
|
||||
>(已达上限)</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
用于程序化访问管理 API 的令牌
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 新增按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="创建新令牌"
|
||||
:disabled="quota ? quota.used >= quota.max : false"
|
||||
@click="showCreateDialog = true"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="loadTokens"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<LoadingState message="加载中..." />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="tokens.length === 0"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无访问令牌"
|
||||
description="创建你的第一个访问令牌开始使用管理 API"
|
||||
:icon="KeyRound"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="lg"
|
||||
class="shadow-lg shadow-primary/20"
|
||||
@click="showCreateDialog = true"
|
||||
>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
创建访问令牌
|
||||
</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div
|
||||
v-else
|
||||
class="hidden md:block overflow-x-auto"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="min-w-[180px] h-12 font-semibold">
|
||||
名称
|
||||
</TableHead>
|
||||
<TableHead class="min-w-[160px] h-12 font-semibold">
|
||||
令牌
|
||||
</TableHead>
|
||||
<TableHead class="min-w-[80px] h-12 font-semibold text-center">
|
||||
使用次数
|
||||
</TableHead>
|
||||
<TableHead class="min-w-[70px] h-12 font-semibold text-center">
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead class="min-w-[100px] h-12 font-semibold">
|
||||
时间
|
||||
</TableHead>
|
||||
<TableHead class="min-w-[100px] h-12 font-semibold text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="token in paginatedTokens"
|
||||
:key="token.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<!-- 名称 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm font-semibold truncate"
|
||||
:title="token.name"
|
||||
>
|
||||
{{ token.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="token.description"
|
||||
class="text-xs text-muted-foreground mt-0.5 truncate"
|
||||
:title="token.description"
|
||||
>
|
||||
{{ token.description }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- Token 显示 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<code class="text-xs font-mono text-muted-foreground bg-muted/30 px-2 py-1 rounded">
|
||||
{{ token.token_display }}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
title="重新生成令牌"
|
||||
@click="confirmRegenerate(token)"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 使用次数 -->
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm font-medium">
|
||||
{{ formatNumber(token.usage_count || 0) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<!-- 状态 -->
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge
|
||||
:variant="getStatusVariant(token)"
|
||||
class="font-medium px-3 py-1"
|
||||
>
|
||||
{{ getStatusText(token) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<!-- 时间 -->
|
||||
<TableCell class="py-4 text-sm text-muted-foreground">
|
||||
<div class="text-xs">
|
||||
创建于 {{ formatDate(token.created_at) }}
|
||||
</div>
|
||||
<div class="text-xs mt-1">
|
||||
{{ token.last_used_at ? `最后使用 ${formatRelativeTime(token.last_used_at)}` : '从未使用' }}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="编辑"
|
||||
@click="openEditDialog(token)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="token.is_active ? '禁用' : '启用'"
|
||||
@click="toggleToken(token)"
|
||||
>
|
||||
<Power class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="删除"
|
||||
@click="confirmDelete(token)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div
|
||||
v-if="!loading && tokens.length > 0"
|
||||
class="md:hidden space-y-3 p-4"
|
||||
>
|
||||
<Card
|
||||
v-for="token in paginatedTokens"
|
||||
:key="token.id"
|
||||
variant="default"
|
||||
class="group hover:shadow-md hover:border-primary/30 transition-all duration-200"
|
||||
>
|
||||
<div class="p-4">
|
||||
<!-- 第一行:名称、状态、操作 -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold text-foreground truncate">
|
||||
{{ token.name }}
|
||||
</h3>
|
||||
<Badge
|
||||
:variant="getStatusVariant(token)"
|
||||
class="text-xs px-1.5 py-0"
|
||||
>
|
||||
{{ getStatusText(token) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑"
|
||||
@click="openEditDialog(token)"
|
||||
>
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="token.is_active ? '禁用' : '启用'"
|
||||
@click="toggleToken(token)"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
@click="confirmDelete(token)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token 显示 -->
|
||||
<div class="flex items-center gap-2 text-xs mb-2">
|
||||
<code class="font-mono text-muted-foreground">{{ token.token_display }}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
title="重新生成"
|
||||
@click="confirmRegenerate(token)"
|
||||
>
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{{ formatNumber(token.usage_count || 0) }} 次使用</span>
|
||||
<span>·</span>
|
||||
<span>{{ token.last_used_at ? formatRelativeTime(token.last_used_at) : '从未使用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="totalTokens > 0"
|
||||
:current="currentPage"
|
||||
:total="totalTokens"
|
||||
:page-size="pageSize"
|
||||
@update:current="currentPage = $event"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 创建/编辑 Token 对话框 -->
|
||||
<Dialog
|
||||
v-model="showCreateDialog"
|
||||
size="lg"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<KeyRound class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
{{ editingToken ? '编辑访问令牌' : '创建访问令牌' }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ editingToken ? '修改令牌配置' : '创建一个新的令牌用于访问管理 API' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 名称 -->
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="token-name"
|
||||
class="text-sm font-semibold"
|
||||
>名称 *</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
v-model="formData.name"
|
||||
placeholder="例如:CI/CD 自动化"
|
||||
class="h-11 border-border/60"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="token-description"
|
||||
class="text-sm font-semibold"
|
||||
>描述</Label>
|
||||
<Input
|
||||
id="token-description"
|
||||
v-model="formData.description"
|
||||
placeholder="用途说明(可选)"
|
||||
class="h-11 border-border/60"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- IP 白名单 -->
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="token-ips"
|
||||
class="text-sm font-semibold"
|
||||
>IP 白名单</Label>
|
||||
<Input
|
||||
id="token-ips"
|
||||
v-model="formData.allowedIpsText"
|
||||
placeholder="例如:192.168.1.0/24, 10.0.0.1(逗号分隔,留空不限制)"
|
||||
class="h-11 border-border/60"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
限制只能从指定 IP 地址使用此令牌,支持 CIDR 格式
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 过期时间 -->
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="token-expires"
|
||||
class="text-sm font-semibold"
|
||||
>过期时间</Label>
|
||||
<Input
|
||||
id="token-expires"
|
||||
v-model="formData.expiresAt"
|
||||
type="datetime-local"
|
||||
class="h-11 border-border/60"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
留空表示永不过期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-11 px-6"
|
||||
@click="closeDialog"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="h-11 px-6 shadow-lg shadow-primary/20"
|
||||
:disabled="saving || !isFormValid"
|
||||
@click="saveToken"
|
||||
>
|
||||
<Loader2
|
||||
v-if="saving"
|
||||
class="animate-spin h-4 w-4 mr-2"
|
||||
/>
|
||||
{{ saving ? '保存中...' : (editingToken ? '保存' : '创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 新 Token 创建成功对话框 -->
|
||||
<Dialog
|
||||
v-model="showTokenDialog"
|
||||
size="lg"
|
||||
persistent
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
|
||||
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
{{ isRegenerating ? '令牌已重新生成' : '创建成功' }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
请妥善保管,此令牌只会显示一次
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">访问令牌</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
:value="newTokenValue"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm bg-muted/50 h-11"
|
||||
@click="($event.target as HTMLInputElement)?.select()"
|
||||
/>
|
||||
<Button
|
||||
class="h-11"
|
||||
@click="copyToken(newTokenValue)"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
||||
<div class="flex gap-2">
|
||||
<AlertTriangle class="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
此令牌只会显示一次,关闭后将无法再次查看,请妥善保管。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
class="h-10 px-5"
|
||||
@click="showTokenDialog = false"
|
||||
>
|
||||
我已安全保存
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog
|
||||
v-model="showDeleteDialog"
|
||||
type="danger"
|
||||
title="确认删除"
|
||||
:description="`确定要删除令牌「${tokenToDelete?.name}」吗?此操作不可恢复。`"
|
||||
confirm-text="删除"
|
||||
:loading="deleting"
|
||||
@confirm="deleteToken"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 重新生成确认对话框 -->
|
||||
<AlertDialog
|
||||
v-model="showRegenerateDialog"
|
||||
type="warning"
|
||||
title="确认重新生成"
|
||||
:description="`重新生成后,原令牌将立即失效。确定要重新生成「${tokenToRegenerate?.name}」吗?`"
|
||||
confirm-text="重新生成"
|
||||
:loading="regenerating"
|
||||
@confirm="regenerateToken"
|
||||
@cancel="showRegenerateDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||
import {
|
||||
managementTokenApi,
|
||||
type ManagementToken
|
||||
} from '@/api/management-tokens'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { Dialog, Pagination } from '@/components/ui'
|
||||
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import {
|
||||
Plus,
|
||||
KeyRound,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Power,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
AlertTriangle
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 数据
|
||||
const tokens = ref<ManagementToken[]>([])
|
||||
const totalTokens = ref(0)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const regenerating = ref(false)
|
||||
|
||||
// 配额信息
|
||||
const quota = ref<{ used: number; max: number } | null>(null)
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const paginatedTokens = computed(() => tokens.value)
|
||||
|
||||
// 监听分页变化
|
||||
watch([currentPage, pageSize], () => {
|
||||
loadTokens()
|
||||
})
|
||||
|
||||
function handlePageSizeChange(newSize: number) {
|
||||
pageSize.value = newSize
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 对话框状态
|
||||
const showCreateDialog = ref(false)
|
||||
const showTokenDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showRegenerateDialog = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const editingToken = ref<ManagementToken | null>(null)
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
allowedIpsText: '',
|
||||
expiresAt: ''
|
||||
})
|
||||
|
||||
const newTokenValue = ref('')
|
||||
const isRegenerating = ref(false)
|
||||
const tokenToDelete = ref<ManagementToken | null>(null)
|
||||
const tokenToRegenerate = ref<ManagementToken | null>(null)
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
return formData.name.trim().length > 0
|
||||
})
|
||||
|
||||
function getStatusVariant(token: ManagementToken): 'success' | 'secondary' | 'destructive' {
|
||||
if (token.expires_at && isExpired(token.expires_at)) {
|
||||
return 'destructive'
|
||||
}
|
||||
return token.is_active ? 'success' : 'secondary'
|
||||
}
|
||||
|
||||
function getStatusText(token: ManagementToken): string {
|
||||
if (token.expires_at && isExpired(token.expires_at)) {
|
||||
return '已过期'
|
||||
}
|
||||
return token.is_active ? '活跃' : '禁用'
|
||||
}
|
||||
|
||||
function isExpired(dateString: string): boolean {
|
||||
return new Date(dateString) < new Date()
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
loadTokens()
|
||||
})
|
||||
|
||||
async function loadTokens() {
|
||||
loading.value = true
|
||||
try {
|
||||
const skip = (currentPage.value - 1) * pageSize.value
|
||||
const response = await managementTokenApi.listTokens({ skip, limit: pageSize.value })
|
||||
|
||||
tokens.value = response.items
|
||||
totalTokens.value = response.total
|
||||
|
||||
if (response.quota) {
|
||||
quota.value = response.quota
|
||||
}
|
||||
|
||||
// 如果当前页超出范围,重置到第一页
|
||||
if (tokens.value.length === 0 && currentPage.value > 1) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.error('加载 Management Tokens 失败:', err)
|
||||
if (!err.response) {
|
||||
showError('无法连接到服务器')
|
||||
} else {
|
||||
showError(`加载失败:${err.response?.data?.detail || err.message}`)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
function openEditDialog(token: ManagementToken) {
|
||||
editingToken.value = token
|
||||
formData.name = token.name
|
||||
formData.description = token.description || ''
|
||||
formData.allowedIpsText = (token.allowed_ips && token.allowed_ips.length > 0)
|
||||
? token.allowed_ips.join(', ')
|
||||
: ''
|
||||
formData.expiresAt = token.expires_at
|
||||
? toLocalDatetimeString(new Date(token.expires_at))
|
||||
: ''
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
function closeDialog() {
|
||||
showCreateDialog.value = false
|
||||
editingToken.value = null
|
||||
formData.name = ''
|
||||
formData.description = ''
|
||||
formData.allowedIpsText = ''
|
||||
formData.expiresAt = ''
|
||||
}
|
||||
|
||||
// 保存 Token
|
||||
async function saveToken() {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const allowedIps = formData.allowedIpsText
|
||||
.split(',')
|
||||
.map(ip => ip.trim())
|
||||
.filter(ip => ip)
|
||||
|
||||
// 将本地时间转换为 UTC ISO 字符串
|
||||
const expiresAtUtc = formData.expiresAt
|
||||
? new Date(formData.expiresAt).toISOString()
|
||||
: null
|
||||
|
||||
if (editingToken.value) {
|
||||
// 更新
|
||||
await managementTokenApi.updateToken(editingToken.value.id, {
|
||||
name: formData.name,
|
||||
description: formData.description.trim() || null,
|
||||
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
|
||||
expires_at: expiresAtUtc
|
||||
})
|
||||
success('令牌更新成功')
|
||||
} else {
|
||||
// 创建
|
||||
const result = await managementTokenApi.createToken({
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
allowed_ips: allowedIps.length > 0 ? allowedIps : undefined,
|
||||
expires_at: expiresAtUtc
|
||||
})
|
||||
newTokenValue.value = result.token
|
||||
isRegenerating.value = false
|
||||
showTokenDialog.value = true
|
||||
success('令牌创建成功')
|
||||
}
|
||||
|
||||
closeDialog()
|
||||
await loadTokens()
|
||||
} catch (err: any) {
|
||||
log.error('保存 Token 失败:', err)
|
||||
const message = err.response?.data?.error?.message
|
||||
|| err.response?.data?.detail
|
||||
|| '保存失败'
|
||||
showError(message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
async function toggleToken(token: ManagementToken) {
|
||||
try {
|
||||
const result = await managementTokenApi.toggleToken(token.id)
|
||||
|
||||
const index = tokens.value.findIndex(t => t.id === token.id)
|
||||
if (index !== -1) {
|
||||
tokens.value[index] = result.data
|
||||
}
|
||||
success(result.data.is_active ? '令牌已启用' : '令牌已禁用')
|
||||
} catch (err: any) {
|
||||
log.error('切换状态失败:', err)
|
||||
showError('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
function confirmDelete(token: ManagementToken) {
|
||||
tokenToDelete.value = token
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteToken() {
|
||||
if (!tokenToDelete.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
await managementTokenApi.deleteToken(tokenToDelete.value.id)
|
||||
|
||||
showDeleteDialog.value = false
|
||||
success('令牌已删除')
|
||||
await loadTokens()
|
||||
} catch (err: any) {
|
||||
log.error('删除 Token 失败:', err)
|
||||
showError('删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
tokenToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
function confirmRegenerate(token: ManagementToken) {
|
||||
tokenToRegenerate.value = token
|
||||
showRegenerateDialog.value = true
|
||||
}
|
||||
|
||||
async function regenerateToken() {
|
||||
if (!tokenToRegenerate.value) return
|
||||
|
||||
regenerating.value = true
|
||||
try {
|
||||
const result = await managementTokenApi.regenerateToken(tokenToRegenerate.value.id)
|
||||
newTokenValue.value = result.token
|
||||
isRegenerating.value = true
|
||||
showRegenerateDialog.value = false
|
||||
showTokenDialog.value = true
|
||||
await loadTokens()
|
||||
success('令牌已重新生成')
|
||||
} catch (err: any) {
|
||||
log.error('重新生成失败:', err)
|
||||
showError('重新生成失败')
|
||||
} finally {
|
||||
regenerating.value = false
|
||||
tokenToRegenerate.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 复制 Token
|
||||
async function copyToken(text: string) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制到剪贴板')
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
success('已复制到剪贴板')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('复制失败:', err)
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function toLocalDatetimeString(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMins < 1) return '刚刚'
|
||||
if (diffMins < 60) return `${diffMins}分钟前`
|
||||
if (diffHours < 24) return `${diffHours}小时前`
|
||||
if (diffDays < 7) return `${diffDays}天前`
|
||||
|
||||
return formatDate(dateString)
|
||||
}
|
||||
</script>
|
||||
@@ -477,8 +477,8 @@ async function changePassword() {
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.value.new_password.length < 8) {
|
||||
showError('密码长度至少8位')
|
||||
if (passwordForm.value.new_password.length < 6) {
|
||||
showError('密码长度至少6位')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ authors = [
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: Other/Proprietary License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
@@ -47,6 +47,7 @@ dependencies = [
|
||||
"redis>=5.0.0",
|
||||
"prometheus-client>=0.20.0",
|
||||
"apscheduler>=3.10.0",
|
||||
"ldap3>=2.9.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
||||
commit_id: COMMIT_ID
|
||||
__commit_id__: COMMIT_ID
|
||||
|
||||
__version__ = version = '0.1.1.dev0+g393d4d13f.d20251213'
|
||||
__version_tuple__ = version_tuple = (0, 1, 1, 'dev0', 'g393d4d13f.d20251213')
|
||||
__version__ = version = '0.2.5'
|
||||
__version_tuple__ = version_tuple = (0, 2, 5)
|
||||
|
||||
__commit_id__ = commit_id = None
|
||||
|
||||
@@ -5,6 +5,8 @@ from fastapi import APIRouter
|
||||
from .adaptive import router as adaptive_router
|
||||
from .api_keys import router as api_keys_router
|
||||
from .endpoints import router as endpoints_router
|
||||
from .ldap import router as ldap_router
|
||||
from .management_tokens import router as management_tokens_router
|
||||
from .models import router as models_router
|
||||
from .monitoring import router as monitoring_router
|
||||
from .provider_query import router as provider_query_router
|
||||
@@ -28,5 +30,7 @@ router.include_router(adaptive_router)
|
||||
router.include_router(models_router)
|
||||
router.include_router(security_router)
|
||||
router.include_router(provider_query_router)
|
||||
router.include_router(ldap_router)
|
||||
router.include_router(management_tokens_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -3,22 +3,64 @@
|
||||
独立余额Key:不关联用户配额,有独立余额限制,用于给非注册用户使用。
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.api import CreateApiKeyRequest
|
||||
from src.models.database import ApiKey, User
|
||||
from src.models.database import ApiKey
|
||||
from src.services.user.apikey import ApiKeyService
|
||||
|
||||
|
||||
# 应用时区配置,默认为 Asia/Shanghai
|
||||
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
||||
|
||||
|
||||
def parse_expiry_date(date_str: Optional[str]) -> Optional[datetime]:
|
||||
"""解析过期日期字符串为 datetime 对象。
|
||||
|
||||
Args:
|
||||
date_str: 日期字符串,支持 "YYYY-MM-DD" 或 ISO 格式
|
||||
|
||||
Returns:
|
||||
datetime 对象(当天 23:59:59.999999,应用时区),或 None 如果输入为空
|
||||
|
||||
Raises:
|
||||
BadRequestException: 日期格式无效
|
||||
"""
|
||||
if not date_str or not date_str.strip():
|
||||
return None
|
||||
|
||||
date_str = date_str.strip()
|
||||
|
||||
# 尝试 YYYY-MM-DD 格式
|
||||
try:
|
||||
parsed_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
# 设置为当天结束时间 (23:59:59.999999,应用时区)
|
||||
return parsed_date.replace(
|
||||
hour=23, minute=59, second=59, microsecond=999999, tzinfo=APP_TIMEZONE
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 尝试完整 ISO 格式
|
||||
try:
|
||||
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise InvalidRequestException(f"无效的日期格式: {date_str},请使用 YYYY-MM-DD 格式")
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
@@ -31,7 +73,26 @@ async def list_standalone_api_keys(
|
||||
is_active: Optional[bool] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出所有独立余额API Keys"""
|
||||
"""
|
||||
列出所有独立余额 API Keys
|
||||
|
||||
获取系统中所有独立余额 API Key 的列表。独立余额 Key 不关联用户配额,
|
||||
有独立的余额限制,主要用于给非注册用户使用。
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过的记录数(分页偏移量),默认 0
|
||||
- `limit`: 返回的记录数(分页限制),默认 100,最大 500
|
||||
- `is_active`: 可选,根据启用状态筛选(true/false)
|
||||
|
||||
**返回字段**:
|
||||
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, current_balance_usd,
|
||||
balance_used_usd, total_requests, total_cost_usd, rate_limit, allowed_providers,
|
||||
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
|
||||
auto_delete_on_expiry 等字段
|
||||
- `total`: 符合条件的总记录数
|
||||
- `limit`: 当前分页限制
|
||||
- `skip`: 当前分页偏移量
|
||||
"""
|
||||
adapter = AdminListStandaloneKeysAdapter(skip=skip, limit=limit, is_active=is_active)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -42,7 +103,35 @@ async def create_standalone_api_key(
|
||||
key_data: CreateApiKeyRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""创建独立余额API Key(必须设置余额限制)"""
|
||||
"""
|
||||
创建独立余额 API Key
|
||||
|
||||
创建一个新的独立余额 API Key。独立余额 Key 必须设置初始余额限制。
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: API Key 的名称
|
||||
- `initial_balance_usd`: 必需,初始余额(美元),必须大于 0
|
||||
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||
- `allowed_models`: 可选,允许使用的模型列表
|
||||
- `rate_limit`: 可选,速率限制配置(请求数/秒)
|
||||
- `expire_days`: 可选,过期天数(兼容旧版)
|
||||
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days)
|
||||
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `key`: 完整的 API Key(仅在创建时返回一次)
|
||||
- `name`: API Key 名称
|
||||
- `key_display`: 脱敏显示的 Key
|
||||
- `is_standalone`: 是否为独立余额 Key(始终为 true)
|
||||
- `current_balance_usd`: 当前余额
|
||||
- `balance_used_usd`: 已使用余额
|
||||
- `rate_limit`: 速率限制配置
|
||||
- `expires_at`: 过期时间
|
||||
- `created_at`: 创建时间
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminCreateStandaloneKeyAdapter(key_data=key_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -51,20 +140,72 @@ async def create_standalone_api_key(
|
||||
async def update_api_key(
|
||||
key_id: str, request: Request, key_data: CreateApiKeyRequest, db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新独立余额Key(可修改名称、过期时间、余额限制等)"""
|
||||
"""
|
||||
更新独立余额 API Key
|
||||
|
||||
更新指定 ID 的独立余额 API Key 的配置信息。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: 可选,API Key 的名称
|
||||
- `rate_limit`: 可选,速率限制配置(null 表示无限制)
|
||||
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||
- `allowed_models`: 可选,允许使用的模型列表
|
||||
- `expire_days`: 可选,过期天数(兼容旧版)
|
||||
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days,null 或空字符串表示永不过期)
|
||||
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `name`: API Key 名称
|
||||
- `key_display`: 脱敏显示的 Key
|
||||
- `is_active`: 是否启用
|
||||
- `current_balance_usd`: 当前余额
|
||||
- `balance_used_usd`: 已使用余额
|
||||
- `rate_limit`: 速率限制配置
|
||||
- `expires_at`: 过期时间
|
||||
- `updated_at`: 更新时间
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminUpdateApiKeyAdapter(key_id=key_id, key_data=key_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{key_id}")
|
||||
async def toggle_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Toggle API key active status (PATCH with is_active in body)"""
|
||||
"""
|
||||
切换 API Key 启用状态
|
||||
|
||||
切换指定 API Key 的启用/禁用状态。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `is_active`: 新的启用状态
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminToggleApiKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/{key_id}")
|
||||
async def delete_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
删除 API Key
|
||||
|
||||
删除指定的 API Key。此操作不可逆。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminDeleteApiKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -75,7 +216,24 @@ async def add_balance_to_key(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Adjust balance for standalone API key (positive to add, negative to deduct)"""
|
||||
"""
|
||||
调整独立余额 API Key 的余额
|
||||
|
||||
为指定的独立余额 API Key 增加或扣除余额。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**请求体字段**:
|
||||
- `amount_usd`: 调整金额(美元),正数为充值,负数为扣除
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `name`: API Key 名称
|
||||
- `current_balance_usd`: 调整后的当前余额
|
||||
- `balance_used_usd`: 已使用余额
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
# 从请求体获取调整金额
|
||||
body = await request.json()
|
||||
amount_usd = body.get("amount_usd")
|
||||
@@ -120,7 +278,24 @@ async def get_api_key_detail(
|
||||
include_key: bool = Query(False, description="Include full decrypted key in response"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get API key detail, optionally include full key"""
|
||||
"""
|
||||
获取 API Key 详情
|
||||
|
||||
获取指定 API Key 的详细信息。可选择是否返回完整的解密密钥。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**查询参数**:
|
||||
- `include_key`: 是否包含完整的解密密钥,默认 false
|
||||
|
||||
**返回字段**:
|
||||
- 当 include_key=false 时,返回基本信息:id, user_id, name, key_display, is_active,
|
||||
is_standalone, current_balance_usd, balance_used_usd, total_requests, total_cost_usd,
|
||||
rate_limit, allowed_providers, allowed_api_formats, allowed_models, last_used_at,
|
||||
expires_at, created_at, updated_at
|
||||
- 当 include_key=true 时,返回完整密钥:key
|
||||
"""
|
||||
if include_key:
|
||||
adapter = AdminGetFullKeyAdapter(key_id=key_id)
|
||||
else:
|
||||
@@ -215,6 +390,9 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
# 独立Key需要关联到管理员用户(从context获取)
|
||||
admin_user_id = context.user.id
|
||||
|
||||
# 解析过期时间(优先使用 expires_at,其次使用 expire_days)
|
||||
expires_at_dt = parse_expiry_date(self.key_data.expires_at)
|
||||
|
||||
# 创建独立Key
|
||||
api_key, plain_key = ApiKeyService.create_api_key(
|
||||
db=db,
|
||||
@@ -224,7 +402,8 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
allowed_api_formats=self.key_data.allowed_api_formats,
|
||||
allowed_models=self.key_data.allowed_models,
|
||||
rate_limit=self.key_data.rate_limit, # None 表示不限制
|
||||
expire_days=self.key_data.expire_days,
|
||||
expire_days=self.key_data.expire_days, # 兼容旧版
|
||||
expires_at=expires_at_dt, # 优先使用
|
||||
initial_balance_usd=self.key_data.initial_balance_usd,
|
||||
is_standalone=True, # 标记为独立Key
|
||||
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
|
||||
@@ -270,7 +449,8 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||
update_data = {}
|
||||
if self.key_data.name is not None:
|
||||
update_data["name"] = self.key_data.name
|
||||
if self.key_data.rate_limit is not None:
|
||||
# rate_limit: 显式传递时更新(包括 null 表示无限制)
|
||||
if "rate_limit" in self.key_data.model_fields_set:
|
||||
update_data["rate_limit"] = self.key_data.rate_limit
|
||||
if (
|
||||
hasattr(self.key_data, "auto_delete_on_expiry")
|
||||
@@ -287,19 +467,21 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||
update_data["allowed_models"] = self.key_data.allowed_models
|
||||
|
||||
# 处理过期时间
|
||||
if self.key_data.expire_days is not None:
|
||||
if self.key_data.expire_days > 0:
|
||||
from datetime import timedelta
|
||||
|
||||
# 优先使用 expires_at(如果显式传递且有值)
|
||||
if self.key_data.expires_at and self.key_data.expires_at.strip():
|
||||
update_data["expires_at"] = parse_expiry_date(self.key_data.expires_at)
|
||||
elif "expires_at" in self.key_data.model_fields_set:
|
||||
# expires_at 明确传递为 null 或空字符串,设为永不过期
|
||||
update_data["expires_at"] = None
|
||||
# 兼容旧版 expire_days
|
||||
elif "expire_days" in self.key_data.model_fields_set:
|
||||
if self.key_data.expire_days is not None and self.key_data.expire_days > 0:
|
||||
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
|
||||
days=self.key_data.expire_days
|
||||
)
|
||||
else:
|
||||
# expire_days = 0 或负数表示永不过期
|
||||
# expire_days = None/0/负数 表示永不过期
|
||||
update_data["expires_at"] = None
|
||||
elif hasattr(self.key_data, "expire_days") and self.key_data.expire_days is None:
|
||||
# 明确传递 None,设为永不过期
|
||||
update_data["expires_at"] = None
|
||||
|
||||
# 使用 ApiKeyService 更新
|
||||
updated_key = ApiKeyService.update_api_key(db, self.key_id, **update_data)
|
||||
|
||||
@@ -7,7 +7,7 @@ from .health import router as health_router
|
||||
from .keys import router as keys_router
|
||||
from .routes import router as routes_router
|
||||
|
||||
router = APIRouter(prefix="/api/admin/endpoints", tags=["Endpoint Management"])
|
||||
router = APIRouter(prefix="/api/admin/endpoints", tags=["Admin - Endpoints"])
|
||||
|
||||
# Endpoint CRUD
|
||||
router.include_router(routes_router)
|
||||
|
||||
@@ -29,7 +29,19 @@ async def get_endpoint_concurrency(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ConcurrencyStatusResponse:
|
||||
"""获取 Endpoint 当前并发状态"""
|
||||
"""
|
||||
获取 Endpoint 当前并发状态
|
||||
|
||||
查询指定 Endpoint 的实时并发使用情况,包括当前并发数和最大并发限制。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**返回字段**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
- `endpoint_current_concurrency`: 当前并发数
|
||||
- `endpoint_max_concurrent`: 最大并发限制
|
||||
"""
|
||||
adapter = AdminEndpointConcurrencyAdapter(endpoint_id=endpoint_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -40,7 +52,19 @@ async def get_key_concurrency(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ConcurrencyStatusResponse:
|
||||
"""获取 Key 当前并发状态"""
|
||||
"""
|
||||
获取 Key 当前并发状态
|
||||
|
||||
查询指定 API Key 的实时并发使用情况,包括当前并发数和最大并发限制。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `key_id`: API Key ID
|
||||
- `key_current_concurrency`: 当前并发数
|
||||
- `key_max_concurrent`: 最大并发限制
|
||||
"""
|
||||
adapter = AdminKeyConcurrencyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -51,7 +75,19 @@ async def reset_concurrency(
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Reset concurrency counters (admin function, use with caution)"""
|
||||
"""
|
||||
重置并发计数器
|
||||
|
||||
重置指定 Endpoint 或 Key 的并发计数器,用于解决计数不准确的问题。
|
||||
管理员功能,请谨慎使用。
|
||||
|
||||
**请求体字段**:
|
||||
- `endpoint_id`: Endpoint ID(可选)
|
||||
- `key_id`: API Key ID(可选)
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
"""
|
||||
adapter = AdminResetConcurrencyAdapter(endpoint_id=request.endpoint_id, key_id=request.key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@@ -36,7 +36,20 @@ async def get_health_summary(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> HealthSummaryResponse:
|
||||
"""获取健康状态摘要"""
|
||||
"""
|
||||
获取健康状态摘要
|
||||
|
||||
获取系统整体健康状态摘要,包括所有 Provider、Endpoint 和 Key 的健康状态统计。
|
||||
|
||||
**返回字段**:
|
||||
- `total_providers`: Provider 总数
|
||||
- `active_providers`: 活跃 Provider 数量
|
||||
- `total_endpoints`: Endpoint 总数
|
||||
- `active_endpoints`: 活跃 Endpoint 数量
|
||||
- `total_keys`: Key 总数
|
||||
- `active_keys`: 活跃 Key 数量
|
||||
- `circuit_breaker_open_keys`: 熔断的 Key 数量
|
||||
"""
|
||||
adapter = AdminHealthSummaryAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -50,9 +63,21 @@ async def get_endpoint_health_status(
|
||||
"""
|
||||
获取端点健康状态(简化视图,与用户端点统一)
|
||||
|
||||
获取按 API 格式聚合的端点健康状态时间线,基于 Usage 表统计,
|
||||
返回 50 个时间段的聚合状态,适用于快速查看整体健康趋势。
|
||||
|
||||
与 /health/api-formats 的区别:
|
||||
- /health/status: 返回聚合的时间线状态(50个时间段),基于 Usage 表
|
||||
- /health/api-formats: 返回详细的事件列表,基于 RequestCandidate 表
|
||||
|
||||
**查询参数**:
|
||||
- `lookback_hours`: 回溯的小时数(1-72),默认 6
|
||||
|
||||
**返回字段**:
|
||||
- `api_format`: API 格式名称
|
||||
- `timeline`: 时间线数据(50个时间段)
|
||||
- `time_range_start`: 时间范围起始
|
||||
- `time_range_end`: 时间范围结束
|
||||
"""
|
||||
adapter = AdminEndpointHealthStatusAdapter(lookback_hours=lookback_hours)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -65,7 +90,33 @@ async def get_api_format_health_monitor(
|
||||
per_format_limit: int = Query(60, ge=10, le=200, description="每个 API 格式的事件数量"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ApiFormatHealthMonitorResponse:
|
||||
"""获取按 API 格式聚合的健康监控时间线(详细事件列表)"""
|
||||
"""
|
||||
获取按 API 格式聚合的健康监控时间线(详细事件列表)
|
||||
|
||||
获取每个 API 格式的详细健康监控数据,包括请求事件列表、成功率统计、
|
||||
时间线数据等,基于 RequestCandidate 表查询,适用于详细分析。
|
||||
|
||||
**查询参数**:
|
||||
- `lookback_hours`: 回溯的小时数(1-72),默认 6
|
||||
- `per_format_limit`: 每个 API 格式返回的事件数量(10-200),默认 60
|
||||
|
||||
**返回字段**:
|
||||
- `generated_at`: 数据生成时间
|
||||
- `formats`: API 格式健康监控数据列表
|
||||
- `api_format`: API 格式名称
|
||||
- `total_attempts`: 总请求数
|
||||
- `success_count`: 成功请求数
|
||||
- `failed_count`: 失败请求数
|
||||
- `skipped_count`: 跳过请求数
|
||||
- `success_rate`: 成功率
|
||||
- `provider_count`: Provider 数量
|
||||
- `key_count`: Key 数量
|
||||
- `last_event_at`: 最后事件时间
|
||||
- `events`: 事件列表
|
||||
- `timeline`: 时间线数据
|
||||
- `time_range_start`: 时间范围起始
|
||||
- `time_range_end`: 时间范围结束
|
||||
"""
|
||||
adapter = AdminApiFormatHealthMonitorAdapter(
|
||||
lookback_hours=lookback_hours,
|
||||
per_format_limit=per_format_limit,
|
||||
@@ -79,7 +130,26 @@ async def get_key_health(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> HealthStatusResponse:
|
||||
"""获取 Key 健康状态"""
|
||||
"""
|
||||
获取 Key 健康状态
|
||||
|
||||
获取指定 API Key 的健康状态详情,包括健康分数、连续失败次数、
|
||||
熔断器状态等信息。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `key_id`: API Key ID
|
||||
- `key_health_score`: 健康分数(0.0-1.0)
|
||||
- `key_consecutive_failures`: 连续失败次数
|
||||
- `key_last_failure_at`: 最后失败时间
|
||||
- `key_is_active`: 是否活跃
|
||||
- `key_statistics`: 统计信息
|
||||
- `circuit_breaker_open`: 熔断器是否打开
|
||||
- `circuit_breaker_open_at`: 熔断器打开时间
|
||||
- `next_probe_at`: 下次探测时间
|
||||
"""
|
||||
adapter = AdminKeyHealthAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -91,13 +161,20 @@ async def recover_key_health(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Recover key health status
|
||||
恢复 Key 健康状态
|
||||
|
||||
Resets health_score to 1.0, closes circuit breaker,
|
||||
cancels auto-disable, and resets all failure counts.
|
||||
手动恢复指定 Key 的健康状态,将健康分数重置为 1.0,关闭熔断器,
|
||||
取消自动禁用,并重置所有失败计数。
|
||||
|
||||
Parameters:
|
||||
- key_id: Key ID (path parameter)
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
- `details`: 详细信息
|
||||
- `health_score`: 健康分数
|
||||
- `circuit_breaker_open`: 熔断器状态
|
||||
- `is_active`: 是否活跃
|
||||
"""
|
||||
adapter = AdminRecoverKeyHealthAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -109,12 +186,21 @@ async def recover_all_keys_health(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Batch recover all circuit-broken keys
|
||||
批量恢复所有熔断 Key 的健康状态
|
||||
|
||||
Finds all keys with circuit_breaker_open=True and:
|
||||
1. Resets health_score to 1.0
|
||||
2. Closes circuit breaker
|
||||
3. Resets failure counts
|
||||
查找所有处于熔断状态的 Key(circuit_breaker_open=True),
|
||||
并批量执行以下操作:
|
||||
1. 将健康分数重置为 1.0
|
||||
2. 关闭熔断器
|
||||
3. 重置失败计数
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
- `recovered_count`: 恢复的 Key 数量
|
||||
- `recovered_keys`: 恢复的 Key 列表
|
||||
- `key_id`: Key ID
|
||||
- `key_name`: Key 名称
|
||||
- `endpoint_id`: Endpoint ID
|
||||
"""
|
||||
adapter = AdminRecoverAllKeysHealthAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -37,7 +37,33 @@ async def list_endpoint_keys(
|
||||
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> List[EndpointAPIKeyResponse]:
|
||||
"""获取 Endpoint 的所有 Keys"""
|
||||
"""
|
||||
获取 Endpoint 的所有 Keys
|
||||
|
||||
获取指定 Endpoint 下的所有 API Key 列表,包括 Key 的配置、统计信息等。
|
||||
结果按优先级和创建时间排序。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过的记录数,用于分页(默认 0)
|
||||
- `limit`: 返回的最大记录数(1-1000,默认 100)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: Key ID
|
||||
- `name`: Key 名称
|
||||
- `api_key_masked`: 脱敏后的 API Key
|
||||
- `internal_priority`: 内部优先级
|
||||
- `global_priority`: 全局优先级
|
||||
- `rate_multiplier`: 速率倍数
|
||||
- `max_concurrent`: 最大并发数(null 表示自适应模式)
|
||||
- `is_adaptive`: 是否为自适应并发模式
|
||||
- `effective_limit`: 有效并发限制
|
||||
- `success_rate`: 成功率
|
||||
- `avg_response_time_ms`: 平均响应时间(毫秒)
|
||||
- 其他配置和统计字段
|
||||
"""
|
||||
adapter = AdminListEndpointKeysAdapter(
|
||||
endpoint_id=endpoint_id,
|
||||
skip=skip,
|
||||
@@ -53,7 +79,32 @@ async def add_endpoint_key(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> EndpointAPIKeyResponse:
|
||||
"""为 Endpoint 添加 Key"""
|
||||
"""
|
||||
为 Endpoint 添加 Key
|
||||
|
||||
为指定 Endpoint 添加新的 API Key,支持配置并发限制、速率倍数、
|
||||
优先级、配额限制、能力限制等。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**请求体字段**:
|
||||
- `endpoint_id`: Endpoint ID(必须与路径参数一致)
|
||||
- `api_key`: API Key 原文(将被加密存储)
|
||||
- `name`: Key 名称
|
||||
- `note`: 备注(可选)
|
||||
- `rate_multiplier`: 速率倍数(默认 1.0)
|
||||
- `internal_priority`: 内部优先级(默认 100)
|
||||
- `max_concurrent`: 最大并发数(null 表示自适应模式)
|
||||
- `rate_limit`: 每分钟请求限制(可选)
|
||||
- `daily_limit`: 每日请求限制(可选)
|
||||
- `monthly_limit`: 每月请求限制(可选)
|
||||
- `allowed_models`: 允许的模型列表(可选)
|
||||
- `capabilities`: 能力配置(可选)
|
||||
|
||||
**返回字段**:
|
||||
- 包含完整的 Key 信息,其中 `api_key_plain` 为原文(仅在创建时返回)
|
||||
"""
|
||||
adapter = AdminCreateEndpointKeyAdapter(endpoint_id=endpoint_id, key_data=key_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -65,7 +116,32 @@ async def update_endpoint_key(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> EndpointAPIKeyResponse:
|
||||
"""更新 Endpoint Key"""
|
||||
"""
|
||||
更新 Endpoint Key
|
||||
|
||||
更新指定 Key 的配置,支持修改并发限制、速率倍数、优先级、
|
||||
配额限制、能力限制等。支持部分更新。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: Key ID
|
||||
|
||||
**请求体字段**(均为可选):
|
||||
- `api_key`: 新的 API Key 原文
|
||||
- `name`: Key 名称
|
||||
- `note`: 备注
|
||||
- `rate_multiplier`: 速率倍数
|
||||
- `internal_priority`: 内部优先级
|
||||
- `max_concurrent`: 最大并发数(设置为 null 可切换到自适应模式)
|
||||
- `rate_limit`: 每分钟请求限制
|
||||
- `daily_limit`: 每日请求限制
|
||||
- `monthly_limit`: 每月请求限制
|
||||
- `allowed_models`: 允许的模型列表
|
||||
- `capabilities`: 能力配置
|
||||
- `is_active`: 是否活跃
|
||||
|
||||
**返回字段**:
|
||||
- 包含更新后的完整 Key 信息
|
||||
"""
|
||||
adapter = AdminUpdateEndpointKeyAdapter(key_id=key_id, key_data=key_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -75,18 +151,74 @@ async def get_keys_grouped_by_format(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""获取按 API 格式分组的所有 Keys(用于全局优先级管理)"""
|
||||
"""
|
||||
获取按 API 格式分组的所有 Keys
|
||||
|
||||
获取所有活跃的 Key,按 API 格式分组返回,用于全局优先级管理。
|
||||
每个 Key 包含基本信息、健康度指标、能力标签等。
|
||||
|
||||
**返回字段**:
|
||||
- 返回一个字典,键为 API 格式,值为该格式下的 Key 列表
|
||||
- 每个 Key 包含:
|
||||
- `id`: Key ID
|
||||
- `name`: Key 名称
|
||||
- `api_key_masked`: 脱敏后的 API Key
|
||||
- `internal_priority`: 内部优先级
|
||||
- `global_priority`: 全局优先级
|
||||
- `rate_multiplier`: 速率倍数
|
||||
- `is_active`: 是否活跃
|
||||
- `circuit_breaker_open`: 熔断器状态
|
||||
- `provider_name`: Provider 名称
|
||||
- `endpoint_base_url`: Endpoint 基础 URL
|
||||
- `api_format`: API 格式
|
||||
- `capabilities`: 能力简称列表
|
||||
- `success_rate`: 成功率
|
||||
- `avg_response_time_ms`: 平均响应时间
|
||||
- `request_count`: 请求总数
|
||||
"""
|
||||
adapter = AdminGetKeysGroupedByFormatAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/keys/{key_id}/reveal")
|
||||
async def reveal_endpoint_key(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
获取完整的 API Key
|
||||
|
||||
解密并返回指定 Key 的完整原文,用于查看和复制。
|
||||
此操作会被记录到审计日志。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `api_key`: 完整的 API Key 原文
|
||||
"""
|
||||
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_endpoint_key(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""删除 Endpoint Key"""
|
||||
"""
|
||||
删除 Endpoint Key
|
||||
|
||||
删除指定的 API Key。此操作不可逆,请谨慎使用。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
"""
|
||||
adapter = AdminDeleteEndpointKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -98,7 +230,24 @@ async def batch_update_key_priority(
|
||||
priority_data: BatchUpdateKeyPriorityRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""批量更新 Endpoint 下 Keys 的优先级(用于拖动排序)"""
|
||||
"""
|
||||
批量更新 Endpoint 下 Keys 的优先级
|
||||
|
||||
批量更新指定 Endpoint 下多个 Key 的内部优先级,用于拖动排序。
|
||||
所有 Key 必须属于指定的 Endpoint。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**请求体字段**:
|
||||
- `priorities`: 优先级列表
|
||||
- `key_id`: Key ID
|
||||
- `internal_priority`: 新的内部优先级
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
- `updated_count`: 实际更新的 Key 数量
|
||||
"""
|
||||
adapter = AdminBatchUpdateKeyPriorityAdapter(endpoint_id=endpoint_id, priority_data=priority_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -293,6 +442,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
return EndpointAPIKeyResponse(**response_dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
|
||||
"""获取完整的 API Key(用于查看和复制)"""
|
||||
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
db = context.db
|
||||
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||
if not key:
|
||||
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||
|
||||
try:
|
||||
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||
except Exception as e:
|
||||
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
|
||||
raise InvalidRequestException(
|
||||
"无法解密 API Key,可能是加密密钥已更改。请重新添加该密钥。"
|
||||
)
|
||||
|
||||
logger.info(f"[REVEAL] 查看完整 Key: ID={self.key_id}, Name={key.name}")
|
||||
return {"api_key": decrypted_key}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
@@ -45,7 +45,36 @@ async def list_provider_endpoints(
|
||||
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> List[ProviderEndpointResponse]:
|
||||
"""获取指定 Provider 的所有 Endpoints"""
|
||||
"""
|
||||
获取指定 Provider 的所有 Endpoints
|
||||
|
||||
获取指定 Provider 下的所有 Endpoint 列表,包括配置、统计信息等。
|
||||
结果按创建时间倒序排列。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过的记录数,用于分页(默认 0)
|
||||
- `limit`: 返回的最大记录数(1-1000,默认 100)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: Endpoint ID
|
||||
- `provider_id`: Provider ID
|
||||
- `provider_name`: Provider 名称
|
||||
- `api_format`: API 格式
|
||||
- `base_url`: 基础 URL
|
||||
- `custom_path`: 自定义路径
|
||||
- `timeout`: 超时时间(秒)
|
||||
- `max_retries`: 最大重试次数
|
||||
- `max_concurrent`: 最大并发数
|
||||
- `rate_limit`: 速率限制
|
||||
- `is_active`: 是否活跃
|
||||
- `total_keys`: Key 总数
|
||||
- `active_keys`: 活跃 Key 数量
|
||||
- `proxy`: 代理配置(密码已脱敏)
|
||||
- 其他配置字段
|
||||
"""
|
||||
adapter = AdminListProviderEndpointsAdapter(
|
||||
provider_id=provider_id,
|
||||
skip=skip,
|
||||
@@ -61,7 +90,31 @@ async def create_provider_endpoint(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderEndpointResponse:
|
||||
"""为 Provider 创建新的 Endpoint"""
|
||||
"""
|
||||
为 Provider 创建新的 Endpoint
|
||||
|
||||
为指定 Provider 创建新的 Endpoint,每个 Provider 的每种 API 格式
|
||||
只能创建一个 Endpoint。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
|
||||
**请求体字段**:
|
||||
- `provider_id`: Provider ID(必须与路径参数一致)
|
||||
- `api_format`: API 格式(如 claude、openai、gemini 等)
|
||||
- `base_url`: 基础 URL
|
||||
- `custom_path`: 自定义路径(可选)
|
||||
- `headers`: 自定义请求头(可选)
|
||||
- `timeout`: 超时时间(秒,默认 300)
|
||||
- `max_retries`: 最大重试次数(默认 2)
|
||||
- `max_concurrent`: 最大并发数(可选)
|
||||
- `rate_limit`: 速率限制(可选)
|
||||
- `config`: 额外配置(可选)
|
||||
- `proxy`: 代理配置(可选)
|
||||
|
||||
**返回字段**:
|
||||
- 包含完整的 Endpoint 信息
|
||||
"""
|
||||
adapter = AdminCreateProviderEndpointAdapter(
|
||||
provider_id=provider_id,
|
||||
endpoint_data=endpoint_data,
|
||||
@@ -75,7 +128,31 @@ async def get_endpoint(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderEndpointResponse:
|
||||
"""获取 Endpoint 详情"""
|
||||
"""
|
||||
获取 Endpoint 详情
|
||||
|
||||
获取指定 Endpoint 的详细信息,包括配置、统计信息等。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: Endpoint ID
|
||||
- `provider_id`: Provider ID
|
||||
- `provider_name`: Provider 名称
|
||||
- `api_format`: API 格式
|
||||
- `base_url`: 基础 URL
|
||||
- `custom_path`: 自定义路径
|
||||
- `timeout`: 超时时间(秒)
|
||||
- `max_retries`: 最大重试次数
|
||||
- `max_concurrent`: 最大并发数
|
||||
- `rate_limit`: 速率限制
|
||||
- `is_active`: 是否活跃
|
||||
- `total_keys`: Key 总数
|
||||
- `active_keys`: 活跃 Key 数量
|
||||
- `proxy`: 代理配置(密码已脱敏)
|
||||
- 其他配置字段
|
||||
"""
|
||||
adapter = AdminGetProviderEndpointAdapter(endpoint_id=endpoint_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -87,7 +164,29 @@ async def update_endpoint(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderEndpointResponse:
|
||||
"""更新 Endpoint"""
|
||||
"""
|
||||
更新 Endpoint
|
||||
|
||||
更新指定 Endpoint 的配置。支持部分更新。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**请求体字段**(均为可选):
|
||||
- `base_url`: 基础 URL
|
||||
- `custom_path`: 自定义路径
|
||||
- `headers`: 自定义请求头
|
||||
- `timeout`: 超时时间(秒)
|
||||
- `max_retries`: 最大重试次数
|
||||
- `max_concurrent`: 最大并发数
|
||||
- `rate_limit`: 速率限制
|
||||
- `is_active`: 是否活跃
|
||||
- `config`: 额外配置
|
||||
- `proxy`: 代理配置(设置为 null 可清除代理)
|
||||
|
||||
**返回字段**:
|
||||
- 包含更新后的完整 Endpoint 信息
|
||||
"""
|
||||
adapter = AdminUpdateProviderEndpointAdapter(
|
||||
endpoint_id=endpoint_id,
|
||||
endpoint_data=endpoint_data,
|
||||
@@ -101,7 +200,19 @@ async def delete_endpoint(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""删除 Endpoint(级联删除所有关联的 Keys)"""
|
||||
"""
|
||||
删除 Endpoint
|
||||
|
||||
删除指定的 Endpoint,同时级联删除所有关联的 API Keys。
|
||||
此操作不可逆,请谨慎使用。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
- `deleted_keys_count`: 同时删除的 Key 数量
|
||||
"""
|
||||
adapter = AdminDeleteProviderEndpointAdapter(endpoint_id=endpoint_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -206,6 +317,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||
provider_id=self.provider_id,
|
||||
api_format=self.endpoint_data.api_format,
|
||||
base_url=self.endpoint_data.base_url,
|
||||
custom_path=self.endpoint_data.custom_path,
|
||||
headers=self.endpoint_data.headers,
|
||||
timeout=self.endpoint_data.timeout,
|
||||
max_retries=self.endpoint_data.max_retries,
|
||||
|
||||
501
src/api/admin/ldap.py
Normal file
501
src/api/admin/ldap.py
Normal file
@@ -0,0 +1,501 @@
|
||||
"""LDAP配置管理API端点。"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.enums import AuthSource
|
||||
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.database import AuditEventType, LDAPConfig, User, UserRole
|
||||
from src.services.system.audit import AuditService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/ldap", tags=["Admin - LDAP"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
# bcrypt 哈希格式正则:$2a$, $2b$, $2y$ + 2位cost + $ + 53字符(22位salt + 31位hash)
|
||||
BCRYPT_HASH_PATTERN = re.compile(r"^\$2[aby]\$\d{2}\$.{53}$")
|
||||
|
||||
|
||||
# ========== Request/Response Models ==========
|
||||
|
||||
|
||||
class LDAPConfigResponse(BaseModel):
|
||||
"""LDAP配置响应(不返回密码)"""
|
||||
|
||||
server_url: Optional[str] = None
|
||||
bind_dn: Optional[str] = None
|
||||
base_dn: Optional[str] = None
|
||||
has_bind_password: bool = False
|
||||
user_search_filter: str
|
||||
username_attr: str
|
||||
email_attr: str
|
||||
display_name_attr: str
|
||||
is_enabled: bool
|
||||
is_exclusive: bool
|
||||
use_starttls: bool
|
||||
connect_timeout: int
|
||||
|
||||
|
||||
class LDAPConfigUpdate(BaseModel):
|
||||
"""LDAP配置更新请求"""
|
||||
|
||||
server_url: str = Field(..., min_length=1, max_length=255)
|
||||
bind_dn: str = Field(..., min_length=1, max_length=255)
|
||||
# 允许空字符串表示"清除密码";非空时自动 strip 并校验不能为空
|
||||
bind_password: Optional[str] = Field(None, max_length=1024)
|
||||
base_dn: str = Field(..., min_length=1, max_length=255)
|
||||
user_search_filter: str = Field(default="(uid={username})", max_length=500)
|
||||
username_attr: str = Field(default="uid", max_length=50)
|
||||
email_attr: str = Field(default="mail", max_length=50)
|
||||
display_name_attr: str = Field(default="cn", max_length=50)
|
||||
is_enabled: bool = False
|
||||
is_exclusive: bool = False
|
||||
use_starttls: bool = False
|
||||
connect_timeout: int = Field(default=10, ge=1, le=60) # 单次操作超时,跨国网络建议 15-30 秒
|
||||
|
||||
@field_validator("bind_password")
|
||||
@classmethod
|
||||
def validate_bind_password(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v == "":
|
||||
return v
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("绑定密码不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("user_search_filter")
|
||||
@classmethod
|
||||
def validate_search_filter(cls, v: str) -> str:
|
||||
if "{username}" not in v:
|
||||
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||
# 验证括号匹配和嵌套正确性
|
||||
depth = 0
|
||||
for char in v:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
raise ValueError("搜索过滤器括号不匹配")
|
||||
if depth != 0:
|
||||
raise ValueError("搜索过滤器括号不匹配")
|
||||
# 限制过滤器复杂度,防止构造复杂查询
|
||||
# 检查嵌套层数而非括号总数
|
||||
depth = 0
|
||||
max_depth = 0
|
||||
for char in v:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
max_depth = max(max_depth, depth)
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if max_depth > 5:
|
||||
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||
if len(v) > 200:
|
||||
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||
return v
|
||||
|
||||
|
||||
class LDAPTestResponse(BaseModel):
|
||||
"""LDAP连接测试响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class LDAPConfigTest(BaseModel):
|
||||
"""LDAP配置测试请求(全部可选,用于临时覆盖)"""
|
||||
|
||||
server_url: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
bind_dn: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
bind_password: Optional[str] = Field(None, min_length=1)
|
||||
base_dn: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
user_search_filter: Optional[str] = Field(None, max_length=500)
|
||||
username_attr: Optional[str] = Field(None, max_length=50)
|
||||
email_attr: Optional[str] = Field(None, max_length=50)
|
||||
display_name_attr: Optional[str] = Field(None, max_length=50)
|
||||
is_enabled: Optional[bool] = None
|
||||
is_exclusive: Optional[bool] = None
|
||||
use_starttls: Optional[bool] = None
|
||||
connect_timeout: Optional[int] = Field(None, ge=1, le=60)
|
||||
|
||||
@field_validator("user_search_filter")
|
||||
@classmethod
|
||||
def validate_search_filter(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
if "{username}" not in v:
|
||||
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||
# 验证括号匹配和嵌套正确性
|
||||
depth = 0
|
||||
for char in v:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
raise ValueError("搜索过滤器括号不匹配")
|
||||
if depth != 0:
|
||||
raise ValueError("搜索过滤器括号不匹配")
|
||||
# 限制过滤器复杂度(检查嵌套层数而非括号总数)
|
||||
depth = 0
|
||||
max_depth = 0
|
||||
for char in v:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
max_depth = max(max_depth, depth)
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if max_depth > 5:
|
||||
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||
if len(v) > 200:
|
||||
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||
return v
|
||||
|
||||
|
||||
# ========== API Endpoints ==========
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
获取 LDAP 配置
|
||||
|
||||
获取系统当前的 LDAP 认证配置信息,用于管理界面显示和编辑。
|
||||
密码字段不会返回原文,仅返回是否已设置的标志。
|
||||
|
||||
**返回字段**:
|
||||
- `server_url`: LDAP 服务器地址(如:ldap://ldap.example.com:389)
|
||||
- `bind_dn`: 绑定 DN(如:cn=admin,dc=example,dc=com)
|
||||
- `base_dn`: 搜索基准 DN(如:ou=users,dc=example,dc=com)
|
||||
- `has_bind_password`: 是否已设置绑定密码(布尔值)
|
||||
- `user_search_filter`: 用户搜索过滤器(默认:(uid={username}))
|
||||
- `username_attr`: 用户名属性(默认:uid)
|
||||
- `email_attr`: 邮箱属性(默认:mail)
|
||||
- `display_name_attr`: 显示名称属性(默认:cn)
|
||||
- `is_enabled`: 是否启用 LDAP 认证
|
||||
- `is_exclusive`: 是否仅允许 LDAP 登录(独占模式)
|
||||
- `use_starttls`: 是否使用 STARTTLS 加密连接
|
||||
- `connect_timeout`: 连接超时时间(秒,1-60)
|
||||
"""
|
||||
adapter = AdminGetLDAPConfigAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def update_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
更新 LDAP 配置
|
||||
|
||||
更新系统的 LDAP 认证配置。支持完整配置更新,包括连接参数、
|
||||
搜索过滤器、属性映射等。提供多重安全校验,防止误锁定管理员。
|
||||
|
||||
**请求体字段**:
|
||||
- `server_url`: LDAP 服务器地址(必填,1-255字符)
|
||||
- `bind_dn`: 绑定 DN(必填,1-255字符)
|
||||
- `bind_password`: 绑定密码(可选,设为空字符串可清除密码)
|
||||
- `base_dn`: 搜索基准 DN(必填,1-255字符)
|
||||
- `user_search_filter`: 用户搜索过滤器(必须包含 {username} 占位符,默认:(uid={username}))
|
||||
- `username_attr`: 用户名属性(默认:uid)
|
||||
- `email_attr`: 邮箱属性(默认:mail)
|
||||
- `display_name_attr`: 显示名称属性(默认:cn)
|
||||
- `is_enabled`: 是否启用 LDAP 认证
|
||||
- `is_exclusive`: 是否仅允许 LDAP 登录(需先启用 LDAP)
|
||||
- `use_starttls`: 是否使用 STARTTLS 加密连接
|
||||
- `connect_timeout`: 连接超时时间(秒,1-60,默认 10)
|
||||
|
||||
**安全校验**:
|
||||
- 启用 LDAP 时必须设置有效的绑定密码
|
||||
- 启用独占模式前会检查是否有至少 1 个有效的本地管理员账户
|
||||
- 独占模式要求先启用 LDAP 认证
|
||||
- 搜索过滤器必须包含 {username} 占位符且括号匹配
|
||||
- 搜索过滤器嵌套层数不超过 5 层,长度不超过 200 字符
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
"""
|
||||
adapter = AdminUpdateLDAPConfigAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_ldap_connection(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
测试 LDAP 连接
|
||||
|
||||
在保存配置前测试 LDAP 服务器连接是否正常。支持使用已保存的配置,
|
||||
也支持通过请求体覆盖任意配置项进行临时测试,而不影响已保存的配置。
|
||||
|
||||
**请求体字段**(均为可选,用于临时覆盖):
|
||||
- `server_url`: LDAP 服务器地址(覆盖已保存的配置)
|
||||
- `bind_dn`: 绑定 DN(覆盖已保存的配置)
|
||||
- `bind_password`: 绑定密码(覆盖已保存的密码)
|
||||
- `base_dn`: 搜索基准 DN(覆盖已保存的配置)
|
||||
- `user_search_filter`: 用户搜索过滤器(覆盖已保存的配置)
|
||||
- `username_attr`: 用户名属性(覆盖已保存的配置)
|
||||
- `email_attr`: 邮箱属性(覆盖已保存的配置)
|
||||
- `display_name_attr`: 显示名称属性(覆盖已保存的配置)
|
||||
- `use_starttls`: 是否使用 STARTTLS(覆盖已保存的配置)
|
||||
- `connect_timeout`: 连接超时时间(覆盖已保存的配置)
|
||||
|
||||
**测试逻辑**:
|
||||
- 未提供的字段使用已保存的配置值
|
||||
- `bind_password` 优先使用请求体中的值,否则使用已保存的加密密码
|
||||
- 测试时会尝试连接 LDAP 服务器并验证绑定 DN
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 测试是否成功(布尔值)
|
||||
- `message`: 测试结果消息(成功或失败原因)
|
||||
"""
|
||||
adapter = AdminTestLDAPConnectionAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
class AdminGetLDAPConfigAdapter(AdminApiAdapter):
|
||||
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
|
||||
db = context.db
|
||||
config = db.query(LDAPConfig).first()
|
||||
|
||||
if not config:
|
||||
return LDAPConfigResponse(
|
||||
server_url=None,
|
||||
bind_dn=None,
|
||||
base_dn=None,
|
||||
has_bind_password=False,
|
||||
user_search_filter="(uid={username})",
|
||||
username_attr="uid",
|
||||
email_attr="mail",
|
||||
display_name_attr="cn",
|
||||
is_enabled=False,
|
||||
is_exclusive=False,
|
||||
use_starttls=False,
|
||||
connect_timeout=10,
|
||||
).model_dump()
|
||||
|
||||
return LDAPConfigResponse(
|
||||
server_url=config.server_url,
|
||||
bind_dn=config.bind_dn,
|
||||
base_dn=config.base_dn,
|
||||
has_bind_password=bool(config.bind_password_encrypted),
|
||||
user_search_filter=config.user_search_filter,
|
||||
username_attr=config.username_attr,
|
||||
email_attr=config.email_attr,
|
||||
display_name_attr=config.display_name_attr,
|
||||
is_enabled=config.is_enabled,
|
||||
is_exclusive=config.is_exclusive,
|
||||
use_starttls=config.use_starttls,
|
||||
connect_timeout=config.connect_timeout,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class AdminUpdateLDAPConfigAdapter(AdminApiAdapter):
|
||||
async def handle(self, context) -> Dict[str, str]: # type: ignore[override]
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
config_update = LDAPConfigUpdate.model_validate(payload)
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
if errors:
|
||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
# 使用行级锁防止并发修改导致的竞态条件
|
||||
config = db.query(LDAPConfig).with_for_update().first()
|
||||
is_new_config = config is None
|
||||
|
||||
if is_new_config:
|
||||
# 首次创建配置时必须提供密码
|
||||
if not config_update.bind_password:
|
||||
raise InvalidRequestException("首次配置 LDAP 时必须设置绑定密码")
|
||||
config = LDAPConfig()
|
||||
db.add(config)
|
||||
|
||||
# 需要启用 LDAP 且未提交新密码时,验证已保存密码可解密(避免开启后不可用)
|
||||
if config_update.is_enabled and config_update.bind_password is None:
|
||||
try:
|
||||
if not config.get_bind_password():
|
||||
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||
except InvalidRequestException:
|
||||
raise
|
||||
except Exception:
|
||||
raise InvalidRequestException("绑定密码解密失败,请重新设置绑定密码")
|
||||
|
||||
# 计算更新后的密码状态(用于校验是否可启用/独占)
|
||||
if config_update.bind_password is None:
|
||||
will_have_password = bool(config.bind_password_encrypted)
|
||||
elif config_update.bind_password == "":
|
||||
will_have_password = False
|
||||
else:
|
||||
will_have_password = True
|
||||
|
||||
# 独占模式必须启用 LDAP 且必须有绑定密码(防止误锁定)
|
||||
if config_update.is_exclusive and not config_update.is_enabled:
|
||||
raise InvalidRequestException("仅允许 LDAP 登录 需要先启用 LDAP 认证")
|
||||
if config_update.is_enabled and not will_have_password:
|
||||
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||
if config_update.is_exclusive and not will_have_password:
|
||||
raise InvalidRequestException("仅允许 LDAP 登录 需要先设置绑定密码")
|
||||
|
||||
config.server_url = config_update.server_url
|
||||
config.bind_dn = config_update.bind_dn
|
||||
config.base_dn = config_update.base_dn
|
||||
config.user_search_filter = config_update.user_search_filter
|
||||
config.username_attr = config_update.username_attr
|
||||
config.email_attr = config_update.email_attr
|
||||
config.display_name_attr = config_update.display_name_attr
|
||||
config.is_enabled = config_update.is_enabled
|
||||
config.is_exclusive = config_update.is_exclusive
|
||||
config.use_starttls = config_update.use_starttls
|
||||
config.connect_timeout = config_update.connect_timeout
|
||||
|
||||
# 启用独占模式前检查是否有足够的本地管理员(防止锁定)
|
||||
# 使用 with_for_update() 阻塞锁防止竞态条件(移除 nowait 确保并发安全)
|
||||
if config_update.is_enabled and config_update.is_exclusive:
|
||||
local_admins = (
|
||||
db.query(User)
|
||||
.filter(
|
||||
User.role == UserRole.ADMIN,
|
||||
User.auth_source == AuthSource.LOCAL,
|
||||
User.is_active.is_(True),
|
||||
User.is_deleted.is_(False),
|
||||
)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
# 验证至少有一个管理员有有效的密码哈希(可以登录)
|
||||
# 使用严格的 bcrypt 格式校验:$2a$/$2b$/$2y$ + 2位cost + $ + 53字符
|
||||
valid_admin_count = sum(
|
||||
1
|
||||
for admin in local_admins
|
||||
if admin.password_hash
|
||||
and isinstance(admin.password_hash, str)
|
||||
and BCRYPT_HASH_PATTERN.match(admin.password_hash)
|
||||
)
|
||||
if valid_admin_count < 1:
|
||||
raise InvalidRequestException(
|
||||
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道"
|
||||
)
|
||||
|
||||
if config_update.bind_password is not None:
|
||||
if config_update.bind_password == "":
|
||||
# 显式清除密码(设置为 NULL)
|
||||
config.bind_password_encrypted = None
|
||||
password_changed = "cleared"
|
||||
else:
|
||||
config.bind_password_encrypted = crypto_service.encrypt(config_update.bind_password)
|
||||
password_changed = "updated"
|
||||
else:
|
||||
password_changed = None
|
||||
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type=AuditEventType.CONFIG_CHANGED,
|
||||
description=f"LDAP 配置已更新 (enabled={config_update.is_enabled}, exclusive={config_update.is_exclusive})",
|
||||
user_id=str(context.user.id) if context.user else None,
|
||||
metadata={
|
||||
"server_url": config_update.server_url,
|
||||
"is_enabled": config_update.is_enabled,
|
||||
"is_exclusive": config_update.is_exclusive,
|
||||
"password_changed": password_changed,
|
||||
"is_new_config": is_new_config,
|
||||
},
|
||||
)
|
||||
|
||||
return {"message": "LDAP配置更新成功"}
|
||||
|
||||
|
||||
class AdminTestLDAPConnectionAdapter(AdminApiAdapter):
|
||||
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
|
||||
from src.services.auth.ldap import LDAPService
|
||||
|
||||
db = context.db
|
||||
if context.json_body is not None:
|
||||
payload = context.json_body
|
||||
elif not context.raw_body:
|
||||
payload = {}
|
||||
else:
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
saved_config = db.query(LDAPConfig).first()
|
||||
|
||||
try:
|
||||
overrides = LDAPConfigTest.model_validate(payload)
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
if errors:
|
||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
config_data: Dict[str, Any] = {}
|
||||
|
||||
if saved_config:
|
||||
config_data = {
|
||||
"server_url": saved_config.server_url,
|
||||
"bind_dn": saved_config.bind_dn,
|
||||
"base_dn": saved_config.base_dn,
|
||||
"user_search_filter": saved_config.user_search_filter,
|
||||
"username_attr": saved_config.username_attr,
|
||||
"email_attr": saved_config.email_attr,
|
||||
"display_name_attr": saved_config.display_name_attr,
|
||||
"use_starttls": saved_config.use_starttls,
|
||||
"connect_timeout": saved_config.connect_timeout,
|
||||
}
|
||||
|
||||
# 应用前端传入的覆盖值
|
||||
for field in [
|
||||
"server_url",
|
||||
"bind_dn",
|
||||
"base_dn",
|
||||
"user_search_filter",
|
||||
"username_attr",
|
||||
"email_attr",
|
||||
"display_name_attr",
|
||||
"use_starttls",
|
||||
"is_enabled",
|
||||
"is_exclusive",
|
||||
"connect_timeout",
|
||||
]:
|
||||
value = getattr(overrides, field)
|
||||
if value is not None:
|
||||
config_data[field] = value
|
||||
|
||||
# bind_password 优先使用 overrides;否则使用已保存的密码(允许保存密码无法解密时依然用 overrides 测试)
|
||||
if overrides.bind_password is not None:
|
||||
config_data["bind_password"] = overrides.bind_password
|
||||
elif saved_config and saved_config.bind_password_encrypted:
|
||||
try:
|
||||
config_data["bind_password"] = crypto_service.decrypt(
|
||||
saved_config.bind_password_encrypted
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"绑定密码解密失败: {type(e).__name__}: {e}")
|
||||
return LDAPTestResponse(
|
||||
success=False, message="绑定密码解密失败,请检查配置或重新设置密码"
|
||||
).model_dump()
|
||||
|
||||
# 必填字段检查
|
||||
required_fields = ["server_url", "bind_dn", "base_dn", "bind_password"]
|
||||
missing = [f for f in required_fields if not config_data.get(f)]
|
||||
if missing:
|
||||
return LDAPTestResponse(
|
||||
success=False, message=f"缺少必要字段: {', '.join(missing)}"
|
||||
).model_dump()
|
||||
|
||||
success, message = LDAPService.test_connection_with_config(config_data)
|
||||
return LDAPTestResponse(success=success, message=message).model_dump()
|
||||
10
src/api/admin/management_tokens/__init__.py
Normal file
10
src/api/admin/management_tokens/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Management Token 管理员路由模块"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .routes import router as management_tokens_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(management_tokens_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
300
src/api/admin/management_tokens/routes.py
Normal file
300
src/api/admin/management_tokens/routes.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""管理员 Management Token 管理端点"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.database import get_db
|
||||
from src.models.database import AuditEventType, ManagementToken, User
|
||||
from src.services.management_token import ManagementTokenService, token_to_dict
|
||||
|
||||
router = APIRouter(prefix="/api/admin/management-tokens", tags=["Admin - Management Tokens"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
# ============== 安全基类 ==============
|
||||
|
||||
|
||||
class AdminManagementTokenApiAdapter(AdminApiAdapter):
|
||||
"""管理员 Management Token 管理 API 的基类
|
||||
|
||||
安全限制:禁止使用 Management Token 调用这些接口。
|
||||
"""
|
||||
|
||||
def authorize(self, context: ApiRequestContext) -> None:
|
||||
# 先调用父类的认证和权限检查
|
||||
super().authorize(context)
|
||||
|
||||
# 禁止使用 Management Token 调用 management-tokens 相关接口
|
||||
if context.management_token is not None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="不允许使用 Management Token 管理其他 Token,请使用 Web 界面或 JWT 认证",
|
||||
)
|
||||
|
||||
|
||||
# ============== 路由 ==============
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_all_management_tokens(
|
||||
request: Request,
|
||||
user_id: Optional[str] = Query(None, description="筛选用户 ID"),
|
||||
is_active: Optional[bool] = Query(None, description="筛选激活状态"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出所有 Management Tokens(管理员)
|
||||
|
||||
管理员查看所有用户的 Management Tokens,支持筛选和分页。
|
||||
|
||||
**查询参数**
|
||||
- user_id (Optional[str]): 筛选指定用户 ID 的 tokens
|
||||
- is_active (Optional[bool]): 筛选激活状态(true/false)
|
||||
- skip (int): 分页偏移量,默认 0
|
||||
- limit (int): 每页数量,范围 1-100,默认 50
|
||||
|
||||
**返回字段**
|
||||
- items (List[dict]): Token 列表
|
||||
- id (str): Token ID
|
||||
- user_id (str): 所属用户 ID
|
||||
- user (dict): 用户信息(包含 id, username, email 等)
|
||||
- name (str): Token 名称
|
||||
- description (Optional[str]): 描述
|
||||
- token_hash (str): Token 哈希值(不返回明文)
|
||||
- is_active (bool): 是否激活
|
||||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||
- expires_at (Optional[str]): 过期时间(ISO 8601 格式)
|
||||
- last_used_at (Optional[str]): 最后使用时间
|
||||
- created_at (str): 创建时间
|
||||
- updated_at (str): 更新时间
|
||||
- total (int): 总数量
|
||||
- skip (int): 当前偏移量
|
||||
- limit (int): 当前每页数量
|
||||
"""
|
||||
adapter = AdminListManagementTokensAdapter(
|
||||
user_id=user_id, is_active=is_active, skip=skip, limit=limit
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{token_id}")
|
||||
async def get_management_token(
|
||||
token_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取 Management Token 详情(管理员)
|
||||
|
||||
管理员查看任意 Management Token 的详细信息。
|
||||
|
||||
**路径参数**
|
||||
- token_id (str): Token ID
|
||||
|
||||
**返回字段**
|
||||
- id (str): Token ID
|
||||
- user_id (str): 所属用户 ID
|
||||
- user (dict): 用户信息(包含 id, username, email 等)
|
||||
- name (str): Token 名称
|
||||
- description (Optional[str]): 描述
|
||||
- token_hash (str): Token 哈希值(不返回明文)
|
||||
- is_active (bool): 是否激活
|
||||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||
- expires_at (Optional[str]): 过期时间(ISO 8601 格式)
|
||||
- last_used_at (Optional[str]): 最后使用时间
|
||||
- created_at (str): 创建时间
|
||||
- updated_at (str): 更新时间
|
||||
"""
|
||||
adapter = AdminGetManagementTokenAdapter(token_id=token_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/{token_id}")
|
||||
async def delete_management_token(
|
||||
token_id: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除任意 Management Token(管理员)
|
||||
|
||||
管理员可以删除任意用户的 Management Token。
|
||||
|
||||
**路径参数**
|
||||
- token_id (str): 要删除的 Token ID
|
||||
|
||||
**返回字段**
|
||||
- message (str): 操作结果消息
|
||||
"""
|
||||
adapter = AdminDeleteManagementTokenAdapter(token_id=token_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{token_id}/status")
|
||||
async def toggle_management_token(
|
||||
token_id: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""切换任意 Management Token 状态(管理员)
|
||||
|
||||
管理员可以启用/禁用任意用户的 Management Token。
|
||||
|
||||
**路径参数**
|
||||
- token_id (str): Token ID
|
||||
|
||||
**返回字段**
|
||||
- message (str): 操作结果消息("Token 已启用" 或 "Token 已禁用")
|
||||
- data (dict): 更新后的 Token 信息
|
||||
- id (str): Token ID
|
||||
- user_id (str): 所属用户 ID
|
||||
- user (dict): 用户信息
|
||||
- name (str): Token 名称
|
||||
- description (Optional[str]): 描述
|
||||
- token_hash (str): Token 哈希值
|
||||
- is_active (bool): 是否激活(已切换后的状态)
|
||||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||
- expires_at (Optional[str]): 过期时间
|
||||
- last_used_at (Optional[str]): 最后使用时间
|
||||
- created_at (str): 创建时间
|
||||
- updated_at (str): 更新时间
|
||||
"""
|
||||
adapter = AdminToggleManagementTokenAdapter(token_id=token_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ============== 适配器 ==============
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminListManagementTokensAdapter(AdminManagementTokenApiAdapter):
|
||||
"""列出所有 Management Tokens"""
|
||||
|
||||
name: str = "admin_list_management_tokens"
|
||||
user_id: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
skip: int = 0
|
||||
limit: int = 50
|
||||
|
||||
async def handle(self, context: ApiRequestContext):
|
||||
# 构建查询
|
||||
query = context.db.query(ManagementToken)
|
||||
|
||||
if self.user_id:
|
||||
query = query.filter(ManagementToken.user_id == self.user_id)
|
||||
if self.is_active is not None:
|
||||
query = query.filter(ManagementToken.is_active == self.is_active)
|
||||
|
||||
total = query.count()
|
||||
tokens = (
|
||||
query.order_by(ManagementToken.created_at.desc())
|
||||
.offset(self.skip)
|
||||
.limit(self.limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 预加载用户信息
|
||||
user_ids = list(set(t.user_id for t in tokens))
|
||||
users = {u.id: u for u in context.db.query(User).filter(User.id.in_(user_ids)).all()}
|
||||
for token in tokens:
|
||||
token.user = users.get(token.user_id)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [token_to_dict(t, include_user=True) for t in tokens],
|
||||
"total": total,
|
||||
"skip": self.skip,
|
||||
"limit": self.limit,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||
"""获取 Management Token 详情"""
|
||||
|
||||
name: str = "admin_get_management_token"
|
||||
token_id: str = ""
|
||||
|
||||
async def handle(self, context: ApiRequestContext):
|
||||
token = ManagementTokenService.get_token_by_id(
|
||||
db=context.db, token_id=self.token_id
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise NotFoundException("Management Token 不存在")
|
||||
|
||||
# 加载用户信息
|
||||
token.user = context.db.query(User).filter(User.id == token.user_id).first()
|
||||
|
||||
return JSONResponse(content=token_to_dict(token, include_user=True))
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminDeleteManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||
"""删除 Management Token"""
|
||||
|
||||
name: str = "admin_delete_management_token"
|
||||
token_id: str = ""
|
||||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_DELETED
|
||||
|
||||
async def handle(self, context: ApiRequestContext):
|
||||
# 先获取 token 信息用于审计
|
||||
token = ManagementTokenService.get_token_by_id(
|
||||
db=context.db, token_id=self.token_id
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise NotFoundException("Management Token 不存在")
|
||||
|
||||
context.add_audit_metadata(
|
||||
token_id=token.id,
|
||||
token_name=token.name,
|
||||
owner_user_id=token.user_id,
|
||||
)
|
||||
|
||||
success = ManagementTokenService.delete_token(
|
||||
db=context.db, token_id=self.token_id
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise NotFoundException("Management Token 不存在")
|
||||
|
||||
return JSONResponse(content={"message": "删除成功"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminToggleManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||
"""切换 Management Token 状态"""
|
||||
|
||||
name: str = "admin_toggle_management_token"
|
||||
token_id: str = ""
|
||||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
|
||||
|
||||
async def handle(self, context: ApiRequestContext):
|
||||
token = ManagementTokenService.toggle_status(
|
||||
db=context.db, token_id=self.token_id
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise NotFoundException("Management Token 不存在")
|
||||
|
||||
# 加载用户信息
|
||||
token.user = context.db.query(User).filter(User.id == token.user_id).first()
|
||||
|
||||
context.add_audit_metadata(
|
||||
token_id=token.id,
|
||||
token_name=token.name,
|
||||
owner_user_id=token.user_id,
|
||||
is_active=token.is_active,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"message": f"Token 已{'启用' if token.is_active else '禁用'}",
|
||||
"data": token_to_dict(token, include_user=True),
|
||||
}
|
||||
)
|
||||
@@ -8,7 +8,7 @@ from .catalog import router as catalog_router
|
||||
from .external import router as external_router
|
||||
from .global_models import router as global_models_router
|
||||
|
||||
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Model Management"])
|
||||
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Models"])
|
||||
|
||||
# 挂载子路由
|
||||
router.include_router(catalog_router)
|
||||
|
||||
@@ -31,6 +31,22 @@ async def get_model_catalog(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ModelCatalogResponse:
|
||||
"""
|
||||
获取统一模型目录
|
||||
|
||||
基于 GlobalModel 聚合所有活跃模型及其关联提供商的信息,返回完整的模型目录视图。
|
||||
|
||||
**返回字段**:
|
||||
- `models`: 模型列表,每个模型包含:
|
||||
- `global_model_name`: GlobalModel 名称
|
||||
- `display_name`: 显示名称
|
||||
- `description`: 模型描述
|
||||
- `providers`: 提供商列表,包含提供商名称、价格、能力等详细信息
|
||||
- `price_range`: 价格区间(基于 GlobalModel 第一阶梯价格)
|
||||
- `total_providers`: 关联提供商数量
|
||||
- `capabilities`: 模型能力标志(视觉、函数调用、流式输出)
|
||||
- `total`: 模型总数
|
||||
"""
|
||||
adapter = AdminGetModelCatalogAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@@ -82,9 +82,21 @@ def _mark_official_providers(data: dict[str, Any]) -> dict[str, Any]:
|
||||
@router.get("/external")
|
||||
async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
|
||||
"""
|
||||
获取 models.dev 的模型数据(代理请求,解决跨域问题)
|
||||
数据缓存 15 分钟(使用 Redis,多 worker 共享)
|
||||
每个提供商会标记 official 字段,前端可据此过滤
|
||||
获取外部模型数据
|
||||
|
||||
从 models.dev 获取第三方模型数据,用于导入新模型或参考定价信息。
|
||||
该接口作为代理请求解决跨域问题,并提供缓存优化。
|
||||
|
||||
**功能特性**:
|
||||
- 代理 models.dev API,解决前端跨域问题
|
||||
- 使用 Redis 缓存 15 分钟,多 worker 共享缓存
|
||||
- 自动标记官方提供商(official 字段),前端可据此过滤第三方转售商
|
||||
|
||||
**返回字段**:
|
||||
- 键为提供商 ID(如 "anthropic"、"openai")
|
||||
- 值为提供商详细信息,包含:
|
||||
- `official`: 是否为官方提供商(true/false)
|
||||
- 其他 models.dev 提供的原始字段(模型列表、定价等)
|
||||
"""
|
||||
# 检查缓存
|
||||
cached = await _get_cached_data()
|
||||
@@ -130,7 +142,16 @@ async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
|
||||
|
||||
@router.delete("/external/cache")
|
||||
async def clear_external_models_cache(_: User = Depends(require_admin)) -> dict:
|
||||
"""清除 models.dev 缓存"""
|
||||
"""
|
||||
清除外部模型数据缓存
|
||||
|
||||
手动清除 models.dev 的 Redis 缓存,强制下次请求重新获取最新数据。
|
||||
通常用于需要立即更新外部模型数据的场景。
|
||||
|
||||
**返回字段**:
|
||||
- `cleared`: 是否成功清除缓存(true/false)
|
||||
- `message`: 提示信息(仅在 Redis 未启用时返回)
|
||||
"""
|
||||
redis = await get_redis_client()
|
||||
if redis is None:
|
||||
return {"cleared": False, "message": "Redis 未启用"}
|
||||
|
||||
@@ -5,7 +5,7 @@ GlobalModel Admin API
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -19,9 +19,11 @@ from src.models.pydantic_models import (
|
||||
BatchAssignToProvidersResponse,
|
||||
GlobalModelCreate,
|
||||
GlobalModelListResponse,
|
||||
GlobalModelProvidersResponse,
|
||||
GlobalModelResponse,
|
||||
GlobalModelUpdate,
|
||||
GlobalModelWithStats,
|
||||
ModelCatalogProviderDetail,
|
||||
)
|
||||
from src.services.model.global_model import GlobalModelService
|
||||
|
||||
@@ -38,7 +40,27 @@ async def list_global_models(
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelListResponse:
|
||||
"""获取 GlobalModel 列表"""
|
||||
"""
|
||||
获取 GlobalModel 列表
|
||||
|
||||
查询系统中的全局模型列表,支持分页、过滤和搜索功能。
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过记录数,用于分页(默认 0)
|
||||
- `limit`: 返回记录数,用于分页(默认 100,最大 1000)
|
||||
- `is_active`: 过滤活跃状态(true/false/null,null 表示不过滤)
|
||||
- `search`: 搜索关键词,支持按名称或显示名称模糊搜索
|
||||
|
||||
**返回字段**:
|
||||
- `models`: GlobalModel 列表,每个包含:
|
||||
- `id`: GlobalModel ID
|
||||
- `name`: 模型名称(唯一)
|
||||
- `display_name`: 显示名称
|
||||
- `is_active`: 是否活跃
|
||||
- `provider_count`: 关联提供商数量
|
||||
- 定价和能力配置等其他字段
|
||||
- `total`: 返回的模型总数
|
||||
"""
|
||||
adapter = AdminListGlobalModelsAdapter(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
@@ -54,7 +76,21 @@ async def get_global_model(
|
||||
global_model_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelWithStats:
|
||||
"""获取单个 GlobalModel 详情(含统计信息)"""
|
||||
"""
|
||||
获取单个 GlobalModel 详情
|
||||
|
||||
查询指定 GlobalModel 的详细信息,包含关联的提供商和价格统计数据。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**返回字段**:
|
||||
- 基础字段:`id`, `name`, `display_name`, `is_active` 等
|
||||
- 统计字段:
|
||||
- `total_models`: 关联的 Model 实现数量
|
||||
- `total_providers`: 关联的提供商数量
|
||||
- `price_range`: 价格区间统计(最低/最高输入输出价格)
|
||||
"""
|
||||
adapter = AdminGetGlobalModelAdapter(global_model_id=global_model_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -65,7 +101,24 @@ async def create_global_model(
|
||||
payload: GlobalModelCreate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelResponse:
|
||||
"""创建 GlobalModel"""
|
||||
"""
|
||||
创建 GlobalModel
|
||||
|
||||
创建一个新的全局模型定义,作为多个提供商实现的统一抽象。
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: 模型名称(唯一标识,如 "claude-3-5-sonnet-20241022")
|
||||
- `display_name`: 显示名称(如 "Claude 3.5 Sonnet")
|
||||
- `is_active`: 是否活跃(默认 true)
|
||||
- `default_price_per_request`: 默认按次计费价格(可选)
|
||||
- `default_tiered_pricing`: 默认阶梯定价配置(包含多个价格阶梯)
|
||||
- `supported_capabilities`: 支持的能力标志(vision、function_calling、streaming)
|
||||
- `config`: 额外配置(JSON 格式,如 description、context_window 等)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 创建的 GlobalModel ID
|
||||
- 其他请求体中的所有字段
|
||||
"""
|
||||
adapter = AdminCreateGlobalModelAdapter(payload=payload)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -77,7 +130,26 @@ async def update_global_model(
|
||||
payload: GlobalModelUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelResponse:
|
||||
"""更新 GlobalModel"""
|
||||
"""
|
||||
更新 GlobalModel
|
||||
|
||||
更新指定 GlobalModel 的配置信息,支持部分字段更新。
|
||||
更新后会自动失效相关缓存。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**请求体字段**(均为可选):
|
||||
- `display_name`: 显示名称
|
||||
- `is_active`: 是否活跃
|
||||
- `default_price_per_request`: 默认按次计费价格
|
||||
- `default_tiered_pricing`: 默认阶梯定价配置
|
||||
- `supported_capabilities`: 支持的能力标志
|
||||
- `config`: 额外配置
|
||||
|
||||
**返回字段**:
|
||||
- 更新后的完整 GlobalModel 信息
|
||||
"""
|
||||
adapter = AdminUpdateGlobalModelAdapter(global_model_id=global_model_id, payload=payload)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -88,7 +160,18 @@ async def delete_global_model(
|
||||
global_model_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除 GlobalModel(级联删除所有关联的 Provider 模型实现)"""
|
||||
"""
|
||||
删除 GlobalModel
|
||||
|
||||
删除指定的 GlobalModel,会级联删除所有关联的 Provider 模型实现。
|
||||
删除后会自动失效相关缓存。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**返回**:
|
||||
- 成功删除返回 204 状态码,无响应体
|
||||
"""
|
||||
adapter = AdminDeleteGlobalModelAdapter(global_model_id=global_model_id)
|
||||
await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
return None
|
||||
@@ -103,11 +186,64 @@ async def batch_assign_to_providers(
|
||||
payload: BatchAssignToProvidersRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> BatchAssignToProvidersResponse:
|
||||
"""批量为多个 Provider 添加 GlobalModel 实现"""
|
||||
"""
|
||||
批量为提供商添加模型实现
|
||||
|
||||
为指定的 GlobalModel 批量创建多个 Provider 的模型实现(Model 记录)。
|
||||
用于快速将一个统一模型分配给多个提供商。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**请求体字段**:
|
||||
- `provider_ids`: 提供商 ID 列表
|
||||
- `create_models`: Model 创建配置列表,每个包含:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `provider_model_name`: 提供商侧的模型名称(如 "claude-3-5-sonnet-20241022")
|
||||
- 其他可选字段(价格覆盖、能力覆盖等)
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 成功创建的 Model 列表
|
||||
- `errors`: 失败的提供商及错误信息列表
|
||||
- `total_requested`: 请求处理的总数
|
||||
- `total_success`: 成功创建的数量
|
||||
- `total_errors`: 失败的数量
|
||||
"""
|
||||
adapter = AdminBatchAssignToProvidersAdapter(global_model_id=global_model_id, payload=payload)
|
||||
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 的关联提供商
|
||||
|
||||
查询指定 GlobalModel 的所有关联提供商及其模型实现详情,包括非活跃的提供商。
|
||||
用于查看某个统一模型在各个提供商上的具体配置。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**返回字段**:
|
||||
- `providers`: 提供商列表,每个包含:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `provider_name`: 提供商名称
|
||||
- `provider_display_name`: 提供商显示名称
|
||||
- `model_id`: Model 实现 ID
|
||||
- `target_model`: 提供商侧的模型名称
|
||||
- 价格信息(input_price_per_1m、output_price_per_1m 等)
|
||||
- 能力标志(supports_vision、supports_function_calling、supports_streaming)
|
||||
- `is_active`: 是否活跃
|
||||
- `total`: 关联提供商总数
|
||||
"""
|
||||
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
@@ -133,20 +269,25 @@ class AdminListGlobalModelsAdapter(AdminApiAdapter):
|
||||
search=self.search,
|
||||
)
|
||||
|
||||
# 为每个 GlobalModel 添加统计数据
|
||||
# 一次性查询所有 GlobalModel 的 provider_count(优化 N+1 问题)
|
||||
model_ids = [gm.id for gm in models]
|
||||
provider_counts = {}
|
||||
if model_ids:
|
||||
count_results = (
|
||||
context.db.query(
|
||||
Model.global_model_id, func.count(func.distinct(Model.provider_id))
|
||||
)
|
||||
.filter(Model.global_model_id.in_(model_ids))
|
||||
.group_by(Model.global_model_id)
|
||||
.all()
|
||||
)
|
||||
provider_counts = {gm_id: count for gm_id, count in count_results}
|
||||
|
||||
# 构建响应
|
||||
model_responses = []
|
||||
for gm in models:
|
||||
# 统计关联的 Model 数量(去重 Provider)
|
||||
provider_count = (
|
||||
context.db.query(func.count(func.distinct(Model.provider_id)))
|
||||
.filter(Model.global_model_id == gm.id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
response = GlobalModelResponse.model_validate(gm)
|
||||
response.provider_count = provider_count
|
||||
# usage_count 直接从 GlobalModel 表读取,已在 model_validate 中自动映射
|
||||
response.provider_count = provider_counts.get(gm.id, 0)
|
||||
model_responses.append(response)
|
||||
|
||||
return GlobalModelListResponse(
|
||||
@@ -275,3 +416,61 @@ class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
|
||||
logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
|
||||
|
||||
return BatchAssignToProvidersResponse(**result)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetGlobalModelProvidersAdapter(AdminApiAdapter):
|
||||
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
|
||||
|
||||
global_model_id: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from src.models.database import Model
|
||||
|
||||
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
|
||||
|
||||
# 获取所有关联的 Model(包括非活跃的)
|
||||
models = (
|
||||
context.db.query(Model)
|
||||
.options(joinedload(Model.provider), joinedload(Model.global_model))
|
||||
.filter(Model.global_model_id == global_model.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
provider_entries = []
|
||||
for model in models:
|
||||
provider = model.provider
|
||||
if not provider:
|
||||
continue
|
||||
|
||||
effective_tiered = model.get_effective_tiered_pricing()
|
||||
tier_count = len(effective_tiered.get("tiers", [])) if effective_tiered else 1
|
||||
|
||||
provider_entries.append(
|
||||
ModelCatalogProviderDetail(
|
||||
provider_id=provider.id,
|
||||
provider_name=provider.name,
|
||||
provider_display_name=provider.display_name,
|
||||
model_id=model.id,
|
||||
target_model=model.provider_model_name,
|
||||
input_price_per_1m=model.get_effective_input_price(),
|
||||
output_price_per_1m=model.get_effective_output_price(),
|
||||
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
|
||||
cache_read_price_per_1m=model.get_effective_cache_read_price(),
|
||||
cache_1h_creation_price_per_1m=model.get_effective_1h_cache_creation_price(),
|
||||
price_per_request=model.get_effective_price_per_request(),
|
||||
effective_tiered_pricing=effective_tiered,
|
||||
tier_count=tier_count,
|
||||
supports_vision=model.get_effective_supports_vision(),
|
||||
supports_function_calling=model.get_effective_supports_function_calling(),
|
||||
supports_streaming=model.get_effective_supports_streaming(),
|
||||
is_active=bool(model.is_active),
|
||||
)
|
||||
)
|
||||
|
||||
return GlobalModelProvidersResponse(
|
||||
providers=provider_entries,
|
||||
total=len(provider_entries),
|
||||
)
|
||||
|
||||
@@ -39,6 +39,34 @@ async def get_audit_logs(
|
||||
offset: int = Query(0, description="偏移量"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取审计日志
|
||||
|
||||
获取系统审计日志列表,支持按用户、事件类型、时间范围筛选。需要管理员权限。
|
||||
|
||||
**查询参数**:
|
||||
- `user_id`: 可选,用户 ID 筛选(UUID 格式)
|
||||
- `event_type`: 可选,事件类型筛选
|
||||
- `days`: 查询最近多少天的日志,默认 7 天
|
||||
- `limit`: 返回数量限制,默认 100
|
||||
- `offset`: 分页偏移量,默认 0
|
||||
|
||||
**返回字段**:
|
||||
- `items`: 审计日志列表,每条日志包含:
|
||||
- `id`: 日志 ID
|
||||
- `event_type`: 事件类型
|
||||
- `user_id`: 用户 ID
|
||||
- `user_email`: 用户邮箱
|
||||
- `user_username`: 用户名
|
||||
- `description`: 事件描述
|
||||
- `ip_address`: IP 地址
|
||||
- `status_code`: HTTP 状态码
|
||||
- `error_message`: 错误信息
|
||||
- `metadata`: 事件元数据
|
||||
- `created_at`: 创建时间
|
||||
- `meta`: 分页元数据(total, limit, offset, count)
|
||||
- `filters`: 筛选条件
|
||||
"""
|
||||
adapter = AdminGetAuditLogsAdapter(
|
||||
user_id=user_id,
|
||||
event_type=event_type,
|
||||
@@ -51,6 +79,19 @@ async def get_audit_logs(
|
||||
|
||||
@router.get("/system-status")
|
||||
async def get_system_status(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取系统状态
|
||||
|
||||
获取系统当前的运行状态和关键指标。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `timestamp`: 当前时间戳
|
||||
- `users`: 用户统计(total: 总用户数, active: 活跃用户数)
|
||||
- `providers`: 提供商统计(total: 总提供商数, active: 活跃提供商数)
|
||||
- `api_keys`: API Key 统计(total: 总数, active: 活跃数)
|
||||
- `today_stats`: 今日统计(requests: 请求数, tokens: token 数, cost_usd: 成本)
|
||||
- `recent_errors`: 最近 1 小时内的错误数
|
||||
"""
|
||||
adapter = AdminSystemStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -61,6 +102,26 @@ async def get_suspicious_activities(
|
||||
hours: int = Query(24, description="时间范围(小时)"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取可疑活动记录
|
||||
|
||||
获取系统检测到的可疑活动记录。需要管理员权限。
|
||||
|
||||
**查询参数**:
|
||||
- `hours`: 时间范围(小时),默认 24 小时
|
||||
|
||||
**返回字段**:
|
||||
- `activities`: 可疑活动列表,每条记录包含:
|
||||
- `id`: 记录 ID
|
||||
- `event_type`: 事件类型
|
||||
- `user_id`: 用户 ID
|
||||
- `description`: 事件描述
|
||||
- `ip_address`: IP 地址
|
||||
- `metadata`: 事件元数据
|
||||
- `created_at`: 创建时间
|
||||
- `count`: 活动总数
|
||||
- `time_range_hours`: 查询的时间范围(小时)
|
||||
"""
|
||||
adapter = AdminSuspiciousActivitiesAdapter(hours=hours)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -72,19 +133,56 @@ async def analyze_user_behavior(
|
||||
days: int = Query(30, description="分析天数"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
分析用户行为
|
||||
|
||||
分析指定用户的行为模式和使用情况。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID
|
||||
|
||||
**查询参数**:
|
||||
- `days`: 分析最近多少天的数据,默认 30 天
|
||||
|
||||
**返回字段**:
|
||||
- 用户行为分析结果,包括活动频率、使用模式、异常行为等
|
||||
"""
|
||||
adapter = AdminUserBehaviorAdapter(user_id=user_id, days=days)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/resilience-status")
|
||||
async def get_resilience_status(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取韧性系统状态
|
||||
|
||||
获取系统韧性管理的当前状态,包括错误统计、熔断器状态等。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `timestamp`: 当前时间戳
|
||||
- `health_score`: 健康评分(0-100)
|
||||
- `status`: 系统状态(healthy: 健康,degraded: 降级,critical: 严重)
|
||||
- `error_statistics`: 错误统计信息
|
||||
- `recent_errors`: 最近的错误列表(最多 10 条)
|
||||
- `recommendations`: 系统建议
|
||||
"""
|
||||
adapter = AdminResilienceStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/resilience/error-stats")
|
||||
async def reset_error_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""Reset resilience error statistics"""
|
||||
"""
|
||||
重置错误统计
|
||||
|
||||
重置韧性系统的错误统计数据。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
- `previous_stats`: 重置前的统计数据
|
||||
- `reset_by`: 执行重置的管理员邮箱
|
||||
- `reset_at`: 重置时间
|
||||
"""
|
||||
adapter = AdminResetErrorStatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -95,6 +193,18 @@ async def get_circuit_history(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取熔断器历史记录
|
||||
|
||||
获取熔断器的状态变更历史记录。需要管理员权限。
|
||||
|
||||
**查询参数**:
|
||||
- `limit`: 返回数量限制,默认 50,最大 200
|
||||
|
||||
**返回字段**:
|
||||
- `items`: 熔断器历史记录列表
|
||||
- `count`: 记录总数
|
||||
"""
|
||||
adapter = AdminCircuitHistoryAdapter(limit=limit)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -107,6 +217,9 @@ class AdminGetAuditLogsAdapter(AdminApiAdapter):
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
# 查看审计日志本身不应该产生审计记录,避免刷新页面时产生大量无意义的日志
|
||||
audit_log_enabled: bool = False
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
db = context.db
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.days)
|
||||
|
||||
@@ -117,12 +117,21 @@ async def get_cache_stats(
|
||||
"""
|
||||
获取缓存亲和性统计信息
|
||||
|
||||
返回:
|
||||
- 缓存命中率
|
||||
- 缓存用户数
|
||||
- Provider切换次数
|
||||
- Key切换次数
|
||||
- 缓存预留配置
|
||||
获取缓存调度器的运行统计数据,包括命中率、切换次数、调度器配置等。
|
||||
用于监控缓存亲和性功能的运行状态和性能指标。
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `data`: 统计数据对象
|
||||
- `scheduler`: 调度器名称(cache_aware 或 random)
|
||||
- `total_affinities`: 总缓存亲和性数量
|
||||
- `cache_hit_rate`: 缓存命中率(0.0-1.0)
|
||||
- `provider_switches`: Provider 切换次数
|
||||
- `key_switches`: Key 切换次数
|
||||
- `cache_hits`: 缓存命中次数
|
||||
- `cache_misses`: 缓存未命中次数
|
||||
- `scheduler_metrics`: 调度器详细指标
|
||||
- `affinity_stats`: 亲和性统计数据
|
||||
"""
|
||||
adapter = AdminCacheStatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -137,16 +146,33 @@ async def get_user_affinity(
|
||||
"""
|
||||
查询指定用户的所有缓存亲和性
|
||||
|
||||
参数:
|
||||
- user_identifier: 用户标识符,支持以下格式:
|
||||
* 用户名 (username),如: yuanhonghu
|
||||
* 邮箱 (email),如: user@example.com
|
||||
* 用户UUID (user_id),如: 550e8400-e29b-41d4-a716-446655440000
|
||||
* API Key ID,如: 660e8400-e29b-41d4-a716-446655440000
|
||||
根据用户标识符查询该用户在各个端点上的缓存亲和性记录。
|
||||
支持多种标识符格式的自动识别和解析。
|
||||
|
||||
返回:
|
||||
- 用户信息
|
||||
- 所有端点的缓存亲和性列表(每个端点一条记录)
|
||||
**路径参数**:
|
||||
- `user_identifier`: 用户标识符,支持以下格式:
|
||||
- 用户名(username),如:yuanhonghu
|
||||
- 邮箱(email),如:user@example.com
|
||||
- 用户 UUID(user_id),如:550e8400-e29b-41d4-a716-446655440000
|
||||
- API Key ID,如:660e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok 或 not_found)
|
||||
- `message`: 提示消息(当无缓存时)
|
||||
- `user_info`: 用户信息
|
||||
- `user_id`: 用户 ID
|
||||
- `username`: 用户名
|
||||
- `email`: 邮箱
|
||||
- `affinities`: 缓存亲和性列表
|
||||
- `provider_id`: Provider ID
|
||||
- `endpoint_id`: Endpoint ID
|
||||
- `key_id`: Key ID
|
||||
- `api_format`: API 格式
|
||||
- `model_name`: 模型名称(global_model_id)
|
||||
- `created_at`: 创建时间
|
||||
- `expire_at`: 过期时间
|
||||
- `request_count`: 请求计数
|
||||
- `total_endpoints`: 缓存的端点数量
|
||||
"""
|
||||
adapter = AdminGetUserAffinityAdapter(user_identifier=user_identifier)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -161,10 +187,50 @@ async def list_affinities(
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取所有缓存亲和性列表,可选按关键词过滤
|
||||
获取所有缓存亲和性列表
|
||||
|
||||
参数:
|
||||
- keyword: 可选,支持用户名/邮箱/User ID/API Key ID 或模糊匹配
|
||||
查询系统中所有的缓存亲和性记录,支持按关键词过滤和分页。
|
||||
返回详细的用户、Provider、Endpoint、Key 信息。
|
||||
|
||||
**查询参数**:
|
||||
- `keyword`: 可选,支持以下过滤方式(可选)
|
||||
- 用户名/邮箱/User ID/API Key ID(精确匹配)
|
||||
- 任意字段的模糊匹配(affinity_key、user_id、username、email、provider_id、key_id)
|
||||
- `limit`: 返回数量限制(1-1000,默认 100)
|
||||
- `offset`: 偏移量(用于分页,默认 0)
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `data`: 分页数据对象
|
||||
- `items`: 缓存亲和性列表
|
||||
- `affinity_key`: API Key ID(用于缓存键)
|
||||
- `user_api_key_name`: 用户 API Key 名称
|
||||
- `user_api_key_prefix`: 脱敏后的用户 API Key
|
||||
- `is_standalone`: 是否为独立 API Key
|
||||
- `user_id`: 用户 ID
|
||||
- `username`: 用户名
|
||||
- `email`: 邮箱
|
||||
- `provider_id`: Provider ID
|
||||
- `provider_name`: Provider 显示名称
|
||||
- `endpoint_id`: Endpoint ID
|
||||
- `endpoint_api_format`: Endpoint API 格式
|
||||
- `endpoint_url`: Endpoint 基础 URL
|
||||
- `key_id`: Key ID
|
||||
- `key_name`: Key 名称
|
||||
- `key_prefix`: 脱敏后的 Provider Key
|
||||
- `rate_multiplier`: 速率倍数
|
||||
- `global_model_id`: GlobalModel ID
|
||||
- `model_name`: 模型名称
|
||||
- `model_display_name`: 模型显示名称
|
||||
- `api_format`: API 格式
|
||||
- `created_at`: 创建时间
|
||||
- `expire_at`: 过期时间
|
||||
- `request_count`: 请求计数
|
||||
- `meta`: 分页元数据
|
||||
- `count`: 总数量
|
||||
- `limit`: 每页数量
|
||||
- `offset`: 当前偏移量
|
||||
- `matched_user_id`: 匹配到的用户 ID(当关键词为用户标识时)
|
||||
"""
|
||||
adapter = AdminListAffinitiesAdapter(keyword=keyword, limit=limit, offset=offset)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -177,10 +243,27 @@ async def clear_user_cache(
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Clear cache affinity for a specific user
|
||||
清除指定用户的缓存亲和性
|
||||
|
||||
Parameters:
|
||||
- user_identifier: User identifier (username, email, user_id, or API Key ID)
|
||||
清除指定用户或 API Key 的所有缓存亲和性记录。
|
||||
支持按用户维度或单个 API Key 维度清除。
|
||||
|
||||
**路径参数**:
|
||||
- `user_identifier`: 用户标识符,支持以下格式:
|
||||
- 用户名(username)
|
||||
- 邮箱(email)
|
||||
- 用户 UUID(user_id)
|
||||
- API Key ID(清除该 API Key 的缓存)
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `message`: 操作结果消息
|
||||
- `user_info`: 用户信息
|
||||
- `user_id`: 用户 ID
|
||||
- `username`: 用户名
|
||||
- `email`: 邮箱
|
||||
- `api_key_id`: API Key ID(当清除单个 API Key 时)
|
||||
- `api_key_name`: API Key 名称(当清除单个 API Key 时)
|
||||
"""
|
||||
adapter = AdminClearUserCacheAdapter(user_identifier=user_identifier)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -196,13 +279,23 @@ async def clear_single_affinity(
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Clear a single cache affinity entry
|
||||
清除单条缓存亲和性记录
|
||||
|
||||
Parameters:
|
||||
- affinity_key: API Key ID
|
||||
- endpoint_id: Endpoint ID
|
||||
- model_id: Model ID (GlobalModel ID)
|
||||
- api_format: API format (claude/openai)
|
||||
根据精确的缓存键(affinity_key + endpoint_id + model_id + api_format)
|
||||
清除单条缓存亲和性记录。用于精确控制缓存清除。
|
||||
|
||||
**路径参数**:
|
||||
- `affinity_key`: API Key ID(用于缓存的键)
|
||||
- `endpoint_id`: Endpoint ID
|
||||
- `model_id`: GlobalModel ID
|
||||
- `api_format`: API 格式(如:claude、openai、gemini)
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `message`: 操作结果消息
|
||||
- `affinity_key`: API Key ID
|
||||
- `endpoint_id`: Endpoint ID
|
||||
- `model_id`: GlobalModel ID
|
||||
"""
|
||||
adapter = AdminClearSingleAffinityAdapter(
|
||||
affinity_key=affinity_key, endpoint_id=endpoint_id, model_id=model_id, api_format=api_format
|
||||
@@ -216,9 +309,17 @@ async def clear_all_cache(
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Clear all cache affinities
|
||||
清除所有缓存亲和性
|
||||
|
||||
Warning: This affects all users, use with caution
|
||||
清除系统中所有用户的缓存亲和性记录。此操作会影响所有用户,
|
||||
下次请求时将重新建立缓存亲和性。请谨慎使用。
|
||||
|
||||
**警告**: 此操作影响所有用户,使用前请确认
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `message`: 操作结果消息
|
||||
- `count`: 清除的缓存数量
|
||||
"""
|
||||
adapter = AdminClearAllCacheAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -231,10 +332,19 @@ async def clear_provider_cache(
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Clear cache affinities for a specific provider
|
||||
清除指定 Provider 的缓存亲和性
|
||||
|
||||
Parameters:
|
||||
- provider_id: Provider ID
|
||||
清除与指定 Provider 相关的所有缓存亲和性记录。
|
||||
当 Provider 配置变更或下线时使用。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `message`: 操作结果消息
|
||||
- `provider_id`: Provider ID
|
||||
- `count`: 清除的缓存数量
|
||||
"""
|
||||
adapter = AdminClearProviderCacheAdapter(provider_id=provider_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -248,9 +358,25 @@ async def get_cache_config(
|
||||
"""
|
||||
获取缓存相关配置
|
||||
|
||||
返回:
|
||||
- 缓存TTL
|
||||
- 缓存预留比例
|
||||
获取缓存亲和性功能的配置参数,包括缓存 TTL、预留比例、
|
||||
动态预留机制配置等。
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `data`: 配置数据
|
||||
- `cache_ttl_seconds`: 缓存亲和性有效期(秒)
|
||||
- `cache_reservation_ratio`: 静态预留比例(已被动态预留替代)
|
||||
- `dynamic_reservation`: 动态预留机制配置
|
||||
- `enabled`: 是否启用
|
||||
- `config`: 配置参数
|
||||
- `probe_phase_requests`: 探测阶段请求数阈值
|
||||
- `probe_reservation`: 探测阶段预留比例
|
||||
- `stable_min_reservation`: 稳定阶段最小预留比例
|
||||
- `stable_max_reservation`: 稳定阶段最大预留比例
|
||||
- `low_load_threshold`: 低负载阈值
|
||||
- `high_load_threshold`: 高负载阈值
|
||||
- `description`: 各参数说明
|
||||
- `description`: 配置说明
|
||||
"""
|
||||
adapter = AdminCacheConfigAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -262,7 +388,31 @@ async def get_cache_metrics(
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
以 Prometheus 文本格式暴露缓存调度指标,方便接入 Grafana。
|
||||
获取缓存调度指标(Prometheus 格式)
|
||||
|
||||
以 Prometheus 文本格式输出缓存调度器的监控指标,
|
||||
方便接入 Prometheus/Grafana 等监控系统。
|
||||
|
||||
**返回格式**: Prometheus 文本格式(Content-Type: text/plain)
|
||||
|
||||
**指标列表**:
|
||||
- `cache_scheduler_total_batches`: 总批次数
|
||||
- `cache_scheduler_last_batch_size`: 最后一批候选数
|
||||
- `cache_scheduler_total_candidates`: 总候选数
|
||||
- `cache_scheduler_last_candidate_count`: 最后一批候选计数
|
||||
- `cache_scheduler_cache_hits`: 缓存命中次数
|
||||
- `cache_scheduler_cache_misses`: 缓存未命中次数
|
||||
- `cache_scheduler_cache_hit_rate`: 缓存命中率
|
||||
- `cache_scheduler_concurrency_denied`: 并发拒绝次数
|
||||
- `cache_scheduler_avg_candidates_per_batch`: 平均每批候选数
|
||||
- `cache_affinity_total`: 总缓存亲和性数量
|
||||
- `cache_affinity_hits`: 亲和性命中次数
|
||||
- `cache_affinity_misses`: 亲和性未命中次数
|
||||
- `cache_affinity_hit_rate`: 亲和性命中率
|
||||
- `cache_affinity_invalidations`: 亲和性失效次数
|
||||
- `cache_affinity_provider_switches`: Provider 切换次数
|
||||
- `cache_affinity_key_switches`: Key 切换次数
|
||||
- `cache_scheduler_info`: 调度器信息(label: scheduler)
|
||||
"""
|
||||
adapter = AdminCacheMetricsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -998,10 +1148,39 @@ async def get_model_mapping_cache_stats(
|
||||
"""
|
||||
获取模型映射缓存统计信息
|
||||
|
||||
返回:
|
||||
- 缓存键数量
|
||||
- 缓存 TTL 配置
|
||||
- 各类型缓存数量
|
||||
获取模型解析缓存的详细统计信息,包括各类型缓存键数量、
|
||||
映射关系列表、Provider 级别的模型映射缓存等。
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `data`: 统计数据
|
||||
- `available`: Redis 是否可用
|
||||
- `message`: 提示消息(当 Redis 未启用时)
|
||||
- `ttl_seconds`: 缓存 TTL(秒)
|
||||
- `total_keys`: 总缓存键数量
|
||||
- `breakdown`: 各类型缓存键数量分解
|
||||
- `model_by_id`: Model ID 缓存数量
|
||||
- `model_by_provider_global`: Provider-GlobalModel 缓存数量
|
||||
- `global_model_by_id`: GlobalModel ID 缓存数量
|
||||
- `global_model_by_name`: GlobalModel 名称缓存数量
|
||||
- `global_model_resolve`: GlobalModel 解析缓存数量
|
||||
- `mappings`: 模型映射列表(最多 100 条)
|
||||
- `mapping_name`: 映射名称(别名)
|
||||
- `global_model_name`: GlobalModel 名称
|
||||
- `global_model_display_name`: GlobalModel 显示名称
|
||||
- `providers`: 使用该映射的 Provider 列表
|
||||
- `ttl`: 缓存剩余 TTL(秒)
|
||||
- `provider_model_mappings`: Provider 级别的模型映射(最多 100 条)
|
||||
- `provider_id`: Provider ID
|
||||
- `provider_name`: Provider 名称
|
||||
- `global_model_id`: GlobalModel ID
|
||||
- `global_model_name`: GlobalModel 名称
|
||||
- `global_model_display_name`: GlobalModel 显示名称
|
||||
- `provider_model_name`: Provider 侧的模型名称
|
||||
- `aliases`: 别名列表
|
||||
- `ttl`: 缓存剩余 TTL(秒)
|
||||
- `hit_count`: 缓存命中次数
|
||||
- `unmapped`: 未映射或无效的缓存条目
|
||||
"""
|
||||
adapter = AdminModelMappingCacheStatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -1015,7 +1194,15 @@ async def clear_all_model_mapping_cache(
|
||||
"""
|
||||
清除所有模型映射缓存
|
||||
|
||||
警告: 这会影响所有模型解析,请谨慎使用
|
||||
清除系统中所有的模型映射缓存,包括 Model、GlobalModel、
|
||||
模型解析等所有相关缓存。下次请求时将重新从数据库查询。
|
||||
|
||||
**警告**: 此操作会影响所有模型解析,请谨慎使用
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `message`: 操作结果消息
|
||||
- `deleted_count`: 删除的缓存键数量
|
||||
"""
|
||||
adapter = AdminClearAllModelMappingCacheAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -1030,8 +1217,17 @@ async def clear_model_mapping_cache_by_name(
|
||||
"""
|
||||
清除指定模型名称的映射缓存
|
||||
|
||||
参数:
|
||||
- model_name: 模型名称(可以是 GlobalModel.name 或映射名称)
|
||||
根据模型名称清除相关的映射缓存,包括 resolve 缓存和 name 缓存。
|
||||
用于更新单个模型的配置后刷新缓存。
|
||||
|
||||
**路径参数**:
|
||||
- `model_name`: 模型名称(可以是 GlobalModel.name 或映射名称)
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `message`: 操作结果消息
|
||||
- `model_name`: 模型名称
|
||||
- `deleted_keys`: 删除的缓存键列表
|
||||
"""
|
||||
adapter = AdminClearModelMappingCacheByNameAdapter(model_name=model_name)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -1047,9 +1243,19 @@ async def clear_provider_model_mapping_cache(
|
||||
"""
|
||||
清除指定 Provider 和 GlobalModel 的模型映射缓存
|
||||
|
||||
参数:
|
||||
- provider_id: Provider ID
|
||||
- global_model_id: GlobalModel ID
|
||||
清除特定 Provider 和 GlobalModel 组合的映射缓存及其命中次数统计。
|
||||
用于 Provider 模型配置更新后刷新缓存。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**返回字段**:
|
||||
- `status`: 状态(ok)
|
||||
- `message`: 操作结果消息
|
||||
- `provider_id`: Provider ID
|
||||
- `global_model_id`: GlobalModel ID
|
||||
- `deleted_keys`: 删除的缓存键列表
|
||||
"""
|
||||
adapter = AdminClearProviderModelMappingCacheAdapter(
|
||||
provider_id=provider_id, global_model_id=global_model_id
|
||||
|
||||
@@ -71,7 +71,47 @@ async def get_request_trace(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取特定请求的完整追踪信息"""
|
||||
"""
|
||||
获取请求的完整追踪信息
|
||||
|
||||
获取指定请求的完整链路追踪信息,包括所有候选(candidates)的执行情况。
|
||||
|
||||
**路径参数**:
|
||||
- `request_id`: 请求 ID
|
||||
|
||||
**返回字段**:
|
||||
- `request_id`: 请求 ID
|
||||
- `total_candidates`: 候选总数
|
||||
- `final_status`: 最终状态(success: 成功,failed: 失败,streaming: 流式传输中,pending: 等待中)
|
||||
- `total_latency_ms`: 总延迟(毫秒)
|
||||
- `candidates`: 候选列表,每个候选包含:
|
||||
- `id`: 候选 ID
|
||||
- `request_id`: 请求 ID
|
||||
- `candidate_index`: 候选索引
|
||||
- `retry_index`: 重试序号
|
||||
- `provider_id`: 提供商 ID
|
||||
- `provider_name`: 提供商名称
|
||||
- `provider_website`: 提供商官网
|
||||
- `endpoint_id`: 端点 ID
|
||||
- `endpoint_name`: 端点名称(API 格式)
|
||||
- `key_id`: 密钥 ID
|
||||
- `key_name`: 密钥名称
|
||||
- `key_preview`: 密钥脱敏预览
|
||||
- `key_capabilities`: 密钥支持的能力
|
||||
- `required_capabilities`: 请求需要的能力标签
|
||||
- `status`: 状态(pending, success, failed, skipped)
|
||||
- `skip_reason`: 跳过原因
|
||||
- `is_cached`: 是否缓存命中
|
||||
- `status_code`: HTTP 状态码
|
||||
- `error_type`: 错误类型
|
||||
- `error_message`: 错误信息
|
||||
- `latency_ms`: 延迟(毫秒)
|
||||
- `concurrent_requests`: 并发请求数
|
||||
- `extra_data`: 额外数据
|
||||
- `created_at`: 创建时间
|
||||
- `started_at`: 开始时间
|
||||
- `finished_at`: 完成时间
|
||||
"""
|
||||
|
||||
adapter = AdminGetRequestTraceAdapter(request_id=request_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -85,9 +125,23 @@ async def get_provider_failure_rate(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取某个 Provider 的失败率统计
|
||||
获取提供商的失败率统计
|
||||
|
||||
需要管理员权限
|
||||
获取指定提供商最近的失败率统计信息。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**查询参数**:
|
||||
- `limit`: 统计最近的尝试数量,默认 100,最大 1000
|
||||
|
||||
**返回字段**:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `total_attempts`: 总尝试次数
|
||||
- `success_count`: 成功次数
|
||||
- `failed_count`: 失败次数
|
||||
- `failure_rate`: 失败率(百分比)
|
||||
- `avg_latency_ms`: 平均延迟(毫秒)
|
||||
"""
|
||||
adapter = AdminProviderFailureRateAdapter(provider_id=provider_id, limit=limit)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
提供商策略管理 API 端点
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -39,6 +39,31 @@ async def update_provider_billing(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新提供商计费配置
|
||||
|
||||
更新指定提供商的计费策略、配额设置和优先级配置。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**请求体字段**:
|
||||
- `billing_type`: 计费类型(pay_as_you_go、subscription、prepaid、monthly_quota)
|
||||
- `monthly_quota_usd`: 月度配额(美元),可选
|
||||
- `quota_reset_day`: 配额重置周期(天数,1-365),默认 30
|
||||
- `quota_last_reset_at`: 当前周期开始时间,可选(设置后会自动同步该周期内的历史使用量)
|
||||
- `quota_expires_at`: 配额过期时间,可选
|
||||
- `rpm_limit`: 每分钟请求数限制,可选
|
||||
- `provider_priority`: 提供商优先级(0-200),默认 100
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
- `provider`: 更新后的提供商信息
|
||||
- `id`: 提供商 ID
|
||||
- `name`: 提供商名称
|
||||
- `billing_type`: 计费类型
|
||||
- `provider_priority`: 提供商优先级
|
||||
"""
|
||||
adapter = AdminProviderBillingAdapter(provider_id=provider_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -50,6 +75,39 @@ async def get_provider_stats(
|
||||
hours: int = 24,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取提供商统计数据
|
||||
|
||||
获取指定提供商的计费信息、RPM 使用情况和使用统计数据。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**查询参数**:
|
||||
- `hours`: 统计时间范围(小时),默认 24
|
||||
|
||||
**返回字段**:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `provider_name`: 提供商名称
|
||||
- `period_hours`: 统计时间范围
|
||||
- `billing_info`: 计费信息
|
||||
- `billing_type`: 计费类型
|
||||
- `monthly_quota_usd`: 月度配额
|
||||
- `monthly_used_usd`: 月度已使用
|
||||
- `quota_remaining_usd`: 剩余配额
|
||||
- `quota_expires_at`: 配额过期时间
|
||||
- `rpm_info`: RPM 信息
|
||||
- `rpm_limit`: RPM 限制
|
||||
- `rpm_used`: 已使用 RPM
|
||||
- `rpm_reset_at`: RPM 重置时间
|
||||
- `usage_stats`: 使用统计
|
||||
- `total_requests`: 总请求数
|
||||
- `successful_requests`: 成功请求数
|
||||
- `failed_requests`: 失败请求数
|
||||
- `success_rate`: 成功率
|
||||
- `avg_response_time_ms`: 平均响应时间(毫秒)
|
||||
- `total_cost_usd`: 总成本(美元)
|
||||
"""
|
||||
adapter = AdminProviderStatsAdapter(provider_id=provider_id, hours=hours)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -67,6 +125,20 @@ async def reset_provider_quota(
|
||||
|
||||
@router.get("/strategies")
|
||||
async def list_available_strategies(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取可用负载均衡策略列表
|
||||
|
||||
列出系统中所有已注册的负载均衡策略插件。
|
||||
|
||||
**返回字段**:
|
||||
- `strategies`: 策略列表
|
||||
- `name`: 策略名称
|
||||
- `priority`: 策略优先级
|
||||
- `version`: 策略版本
|
||||
- `description`: 策略描述
|
||||
- `author`: 策略作者
|
||||
- `total`: 策略总数
|
||||
"""
|
||||
adapter = AdminListStrategiesAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -103,6 +175,9 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
|
||||
|
||||
if config.quota_last_reset_at:
|
||||
new_reset_at = parser.parse(config.quota_last_reset_at)
|
||||
# 确保有时区信息,如果没有则假设为 UTC
|
||||
if new_reset_at.tzinfo is None:
|
||||
new_reset_at = new_reset_at.replace(tzinfo=timezone.utc)
|
||||
provider.quota_last_reset_at = new_reset_at
|
||||
|
||||
# 自动同步该周期内的历史使用量
|
||||
@@ -118,7 +193,11 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
|
||||
logger.info(f"Synced usage for provider {provider.name}: ${period_usage:.4f} since {new_reset_at}")
|
||||
|
||||
if config.quota_expires_at:
|
||||
provider.quota_expires_at = parser.parse(config.quota_expires_at)
|
||||
expires_at = parser.parse(config.quota_expires_at)
|
||||
# 确保有时区信息,如果没有则假设为 UTC
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
provider.quota_expires_at = expires_at
|
||||
|
||||
db.commit()
|
||||
db.refresh(provider)
|
||||
@@ -149,7 +228,7 @@ class AdminProviderStatsAdapter(AdminApiAdapter):
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
since = datetime.now() - timedelta(hours=self.hours)
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=self.hours)
|
||||
stats = (
|
||||
db.query(ProviderUsageTracking)
|
||||
.filter(
|
||||
|
||||
@@ -49,7 +49,36 @@ async def list_provider_models(
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
) -> List[ModelResponse]:
|
||||
"""获取提供商的所有模型(管理员)"""
|
||||
"""
|
||||
获取提供商的所有模型
|
||||
|
||||
获取指定提供商的模型列表,支持分页和状态过滤。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**查询参数**:
|
||||
- `is_active`: 可选的活跃状态过滤,true 仅返回活跃模型,false 返回禁用模型,不传则返回全部
|
||||
- `skip`: 跳过的记录数,默认为 0
|
||||
- `limit`: 返回的最大记录数,默认为 100
|
||||
|
||||
**返回字段**(数组,每项包含):
|
||||
- `id`: 模型 ID
|
||||
- `provider_id`: 提供商 ID
|
||||
- `global_model_id`: 全局模型 ID
|
||||
- `provider_model_name`: 提供商模型名称
|
||||
- `is_active`: 是否启用
|
||||
- `input_price_per_1m`: 输入价格(每百万 token)
|
||||
- `output_price_per_1m`: 输出价格(每百万 token)
|
||||
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token)
|
||||
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token)
|
||||
- `price_per_request`: 每次请求价格
|
||||
- `supports_vision`: 是否支持视觉
|
||||
- `supports_function_calling`: 是否支持函数调用
|
||||
- `supports_streaming`: 是否支持流式输出
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
"""
|
||||
adapter = AdminListProviderModelsAdapter(
|
||||
provider_id=provider_id,
|
||||
is_active=is_active,
|
||||
@@ -66,7 +95,29 @@ async def create_provider_model(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ModelResponse:
|
||||
"""创建模型(管理员)"""
|
||||
"""
|
||||
创建模型
|
||||
|
||||
为指定提供商创建一个新的模型配置。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**请求体字段**:
|
||||
- `provider_model_name`: 提供商模型名称(必填)
|
||||
- `global_model_id`: 全局模型 ID(可选,关联到全局模型)
|
||||
- `is_active`: 是否启用(默认 true)
|
||||
- `input_price_per_1m`: 输入价格(每百万 token)(可选)
|
||||
- `output_price_per_1m`: 输出价格(每百万 token)(可选)
|
||||
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token)(可选)
|
||||
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token)(可选)
|
||||
- `price_per_request`: 每次请求价格(可选)
|
||||
- `supports_vision`: 是否支持视觉(可选)
|
||||
- `supports_function_calling`: 是否支持函数调用(可选)
|
||||
- `supports_streaming`: 是否支持流式输出(可选)
|
||||
|
||||
**返回字段**: 返回创建的模型详细信息(与 GET 单个模型接口返回格式相同)
|
||||
"""
|
||||
adapter = AdminCreateProviderModelAdapter(provider_id=provider_id, model_data=model_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -78,7 +129,32 @@ async def get_provider_model(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ModelResponse:
|
||||
"""获取模型详情(管理员)"""
|
||||
"""
|
||||
获取模型详情
|
||||
|
||||
获取指定模型的详细配置信息。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `model_id`: 模型 ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 模型 ID
|
||||
- `provider_id`: 提供商 ID
|
||||
- `global_model_id`: 全局模型 ID
|
||||
- `provider_model_name`: 提供商模型名称
|
||||
- `is_active`: 是否启用
|
||||
- `input_price_per_1m`: 输入价格(每百万 token)
|
||||
- `output_price_per_1m`: 输出价格(每百万 token)
|
||||
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token)
|
||||
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token)
|
||||
- `price_per_request`: 每次请求价格
|
||||
- `supports_vision`: 是否支持视觉
|
||||
- `supports_function_calling`: 是否支持函数调用
|
||||
- `supports_streaming`: 是否支持流式输出
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
"""
|
||||
adapter = AdminGetProviderModelAdapter(provider_id=provider_id, model_id=model_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -91,7 +167,30 @@ async def update_provider_model(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ModelResponse:
|
||||
"""更新模型(管理员)"""
|
||||
"""
|
||||
更新模型配置
|
||||
|
||||
更新指定模型的配置信息。只需传入需要更新的字段,未传入的字段保持不变。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `model_id`: 模型 ID
|
||||
|
||||
**请求体字段**(所有字段可选):
|
||||
- `provider_model_name`: 提供商模型名称
|
||||
- `global_model_id`: 全局模型 ID
|
||||
- `is_active`: 是否启用
|
||||
- `input_price_per_1m`: 输入价格(每百万 token)
|
||||
- `output_price_per_1m`: 输出价格(每百万 token)
|
||||
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token)
|
||||
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token)
|
||||
- `price_per_request`: 每次请求价格
|
||||
- `supports_vision`: 是否支持视觉
|
||||
- `supports_function_calling`: 是否支持函数调用
|
||||
- `supports_streaming`: 是否支持流式输出
|
||||
|
||||
**返回字段**: 返回更新后的模型详细信息(与 GET 单个模型接口返回格式相同)
|
||||
"""
|
||||
adapter = AdminUpdateProviderModelAdapter(
|
||||
provider_id=provider_id,
|
||||
model_id=model_id,
|
||||
@@ -107,7 +206,18 @@ async def delete_provider_model(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除模型(管理员)"""
|
||||
"""
|
||||
删除模型
|
||||
|
||||
删除指定的模型配置。注意:此操作不可逆。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `model_id`: 模型 ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 删除成功提示信息
|
||||
"""
|
||||
adapter = AdminDeleteProviderModelAdapter(provider_id=provider_id, model_id=model_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -119,7 +229,29 @@ async def batch_create_provider_models(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> List[ModelResponse]:
|
||||
"""批量创建模型(管理员)"""
|
||||
"""
|
||||
批量创建模型
|
||||
|
||||
为指定提供商批量创建多个模型配置。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**请求体**: 模型数据数组,每项包含:
|
||||
- `provider_model_name`: 提供商模型名称(必填)
|
||||
- `global_model_id`: 全局模型 ID(可选)
|
||||
- `is_active`: 是否启用(默认 true)
|
||||
- `input_price_per_1m`: 输入价格(每百万 token)(可选)
|
||||
- `output_price_per_1m`: 输出价格(每百万 token)(可选)
|
||||
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token)(可选)
|
||||
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token)(可选)
|
||||
- `price_per_request`: 每次请求价格(可选)
|
||||
- `supports_vision`: 是否支持视觉(可选)
|
||||
- `supports_function_calling`: 是否支持函数调用(可选)
|
||||
- `supports_streaming`: 是否支持流式输出(可选)
|
||||
|
||||
**返回字段**: 返回创建的模型列表(与 GET 模型列表接口返回格式相同)
|
||||
"""
|
||||
adapter = AdminBatchCreateModelsAdapter(provider_id=provider_id, models_data=models_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -134,10 +266,23 @@ async def get_provider_available_source_models(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取该 Provider 支持的所有统一模型名(source_model)
|
||||
获取提供商支持的可用源模型
|
||||
|
||||
包括:
|
||||
1. 直连模型(Model.provider_model_name 直接作为统一模型名)
|
||||
获取该提供商支持的所有统一模型名(source_model),包含价格和能力信息。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**返回字段**:
|
||||
- `models`: 可用源模型数组,每项包含:
|
||||
- `global_model_name`: 全局模型名称
|
||||
- `display_name`: 显示名称
|
||||
- `provider_model_name`: 提供商模型名称
|
||||
- `model_id`: 模型 ID
|
||||
- `price`: 价格信息(包含 input_price_per_1m, output_price_per_1m, cache_creation_price_per_1m, cache_read_price_per_1m, price_per_request)
|
||||
- `capabilities`: 能力信息(包含 supports_vision, supports_function_calling, supports_streaming)
|
||||
- `is_active`: 是否启用
|
||||
- `total`: 总数
|
||||
"""
|
||||
adapter = AdminGetProviderAvailableSourceModelsAdapter(provider_id=provider_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -153,7 +298,27 @@ async def batch_assign_global_models_to_provider(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> BatchAssignModelsToProviderResponse:
|
||||
"""批量为 Provider 关联 GlobalModels(自动继承价格和能力配置)"""
|
||||
"""
|
||||
批量关联全局模型
|
||||
|
||||
批量为提供商关联全局模型,自动继承全局模型的价格和能力配置。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**请求体字段**:
|
||||
- `global_model_ids`: 全局模型 ID 数组(必填)
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 成功关联的模型数组,每项包含:
|
||||
- `global_model_id`: 全局模型 ID
|
||||
- `global_model_name`: 全局模型名称
|
||||
- `model_id`: 新创建的模型 ID
|
||||
- `errors`: 失败的模型数组,每项包含:
|
||||
- `global_model_id`: 全局模型 ID
|
||||
- `global_model_name`: 全局模型名称(如果可用)
|
||||
- `error`: 错误信息
|
||||
"""
|
||||
adapter = AdminBatchAssignModelsToProviderAdapter(
|
||||
provider_id=provider_id, payload=payload
|
||||
)
|
||||
@@ -173,10 +338,30 @@ async def import_models_from_upstream(
|
||||
"""
|
||||
从上游提供商导入模型
|
||||
|
||||
流程:
|
||||
从上游提供商导入模型列表。如果全局模型不存在,将自动创建。
|
||||
|
||||
**流程说明**:
|
||||
1. 根据 model_ids 检查全局模型是否存在(按 name 匹配)
|
||||
2. 如不存在,自动创建新的 GlobalModel(使用默认配置)
|
||||
2. 如不存在,自动创建新的 GlobalModel(使用默认免费配置)
|
||||
3. 创建 Model 关联到当前 Provider
|
||||
4. 如模型已关联,则记录到成功列表中
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**请求体字段**:
|
||||
- `model_ids`: 模型 ID 数组(必填,每个 ID 长度 1-100 字符)
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 成功导入的模型数组,每项包含:
|
||||
- `model_id`: 模型 ID
|
||||
- `global_model_id`: 全局模型 ID
|
||||
- `global_model_name`: 全局模型名称
|
||||
- `provider_model_id`: 提供商模型 ID
|
||||
- `created_global_model`: 是否新创建了全局模型
|
||||
- `errors`: 失败的模型数组,每项包含:
|
||||
- `model_id`: 模型 ID
|
||||
- `error`: 错误信息
|
||||
"""
|
||||
adapter = AdminImportFromUpstreamAdapter(provider_id=provider_id, payload=payload)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -27,24 +27,114 @@ async def list_providers(
|
||||
is_active: Optional[bool] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取提供商列表
|
||||
|
||||
获取所有提供商的基本信息列表,支持分页和状态过滤。
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过的记录数,用于分页,默认为 0
|
||||
- `limit`: 返回的最大记录数,范围 1-500,默认为 100
|
||||
- `is_active`: 可选的活跃状态过滤,true 仅返回活跃提供商,false 返回禁用提供商,不传则返回全部
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 提供商 ID
|
||||
- `name`: 提供商名称(唯一标识)
|
||||
- `display_name`: 显示名称
|
||||
- `api_format`: API 格式(如 claude、openai、gemini 等)
|
||||
- `base_url`: API 基础 URL
|
||||
- `api_key`: API 密钥(脱敏显示)
|
||||
- `priority`: 优先级
|
||||
- `is_active`: 是否活跃
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
"""
|
||||
adapter = AdminListProvidersAdapter(skip=skip, limit=limit, is_active=is_active)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_provider(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
创建新提供商
|
||||
|
||||
创建一个新的 AI 模型提供商配置。
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: 提供商名称(必填,唯一,用于系统标识)
|
||||
- `display_name`: 显示名称(必填)
|
||||
- `description`: 描述信息(可选)
|
||||
- `website`: 官网地址(可选)
|
||||
- `billing_type`: 计费类型(可选,pay_as_you_go/subscription/prepaid,默认 pay_as_you_go)
|
||||
- `monthly_quota_usd`: 月度配额(美元)(可选)
|
||||
- `quota_reset_day`: 配额重置日期(1-31)(可选)
|
||||
- `quota_last_reset_at`: 上次配额重置时间(可选)
|
||||
- `quota_expires_at`: 配额过期时间(可选)
|
||||
- `rpm_limit`: 每分钟请求数限制(可选)
|
||||
- `provider_priority`: 提供商优先级(数字越小优先级越高,默认 100)
|
||||
- `is_active`: 是否启用(默认 true)
|
||||
- `concurrent_limit`: 并发限制(可选)
|
||||
- `config`: 额外配置信息(JSON,可选)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 新创建的提供商 ID
|
||||
- `name`: 提供商名称
|
||||
- `display_name`: 显示名称
|
||||
- `message`: 成功提示信息
|
||||
"""
|
||||
adapter = AdminCreateProviderAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/{provider_id}")
|
||||
async def update_provider(provider_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
更新提供商配置
|
||||
|
||||
更新指定提供商的配置信息。只需传入需要更新的字段,未传入的字段保持不变。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**请求体字段**(所有字段可选):
|
||||
- `name`: 提供商名称
|
||||
- `display_name`: 显示名称
|
||||
- `description`: 描述信息
|
||||
- `website`: 官网地址
|
||||
- `billing_type`: 计费类型(pay_as_you_go/subscription/prepaid)
|
||||
- `monthly_quota_usd`: 月度配额(美元)
|
||||
- `quota_reset_day`: 配额重置日期(1-31)
|
||||
- `quota_last_reset_at`: 上次配额重置时间
|
||||
- `quota_expires_at`: 配额过期时间
|
||||
- `rpm_limit`: 每分钟请求数限制
|
||||
- `provider_priority`: 提供商优先级
|
||||
- `is_active`: 是否启用
|
||||
- `concurrent_limit`: 并发限制
|
||||
- `config`: 额外配置信息(JSON)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 提供商 ID
|
||||
- `name`: 提供商名称
|
||||
- `is_active`: 是否启用
|
||||
- `message`: 成功提示信息
|
||||
"""
|
||||
adapter = AdminUpdateProviderAdapter(provider_id=provider_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/{provider_id}")
|
||||
async def delete_provider(provider_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
删除提供商
|
||||
|
||||
删除指定的提供商。注意:此操作会级联删除关联的端点、密钥和模型配置。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 删除成功提示信息
|
||||
"""
|
||||
adapter = AdminDeleteProviderAdapter(provider_id=provider_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -136,7 +226,6 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
|
||||
rpm_limit=validated_data.rpm_limit,
|
||||
provider_priority=validated_data.provider_priority,
|
||||
is_active=validated_data.is_active,
|
||||
rate_limit=validated_data.rate_limit,
|
||||
concurrent_limit=validated_data.concurrent_limit,
|
||||
config=validated_data.config,
|
||||
)
|
||||
|
||||
@@ -40,7 +40,41 @@ async def get_providers_summary(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> List[ProviderWithEndpointsSummary]:
|
||||
"""获取所有 Providers 的摘要信息(包含 Endpoints 和 Keys 统计)"""
|
||||
"""
|
||||
获取所有提供商摘要信息
|
||||
|
||||
获取所有提供商的详细摘要信息,包含端点、密钥、模型统计和健康状态。
|
||||
|
||||
**返回字段**(数组,每项包含):
|
||||
- `id`: 提供商 ID
|
||||
- `name`: 提供商名称
|
||||
- `display_name`: 显示名称
|
||||
- `description`: 描述信息
|
||||
- `website`: 官网地址
|
||||
- `provider_priority`: 优先级
|
||||
- `is_active`: 是否启用
|
||||
- `billing_type`: 计费类型
|
||||
- `monthly_quota_usd`: 月度配额(美元)
|
||||
- `monthly_used_usd`: 本月已使用金额(美元)
|
||||
- `quota_reset_day`: 配额重置日期
|
||||
- `quota_last_reset_at`: 上次配额重置时间
|
||||
- `quota_expires_at`: 配额过期时间
|
||||
- `rpm_limit`: RPM 限制
|
||||
- `rpm_used`: 已使用 RPM
|
||||
- `rpm_reset_at`: RPM 重置时间
|
||||
- `total_endpoints`: 端点总数
|
||||
- `active_endpoints`: 活跃端点数
|
||||
- `total_keys`: 密钥总数
|
||||
- `active_keys`: 活跃密钥数
|
||||
- `total_models`: 模型总数
|
||||
- `active_models`: 活跃模型数
|
||||
- `avg_health_score`: 平均健康分数(0-1)
|
||||
- `unhealthy_endpoints`: 不健康端点数(健康分数 < 0.5)
|
||||
- `api_formats`: 支持的 API 格式列表
|
||||
- `endpoint_health_details`: 端点健康详情(包含 api_format, health_score, is_active, active_keys)
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
"""
|
||||
adapter = AdminProviderSummaryAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -51,7 +85,44 @@ async def get_provider_summary(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderWithEndpointsSummary:
|
||||
"""获取单个 Provider 的摘要信息(包含 Endpoints 和 Keys 统计)"""
|
||||
"""
|
||||
获取单个提供商摘要信息
|
||||
|
||||
获取指定提供商的详细摘要信息,包含端点、密钥、模型统计和健康状态。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 提供商 ID
|
||||
- `name`: 提供商名称
|
||||
- `display_name`: 显示名称
|
||||
- `description`: 描述信息
|
||||
- `website`: 官网地址
|
||||
- `provider_priority`: 优先级
|
||||
- `is_active`: 是否启用
|
||||
- `billing_type`: 计费类型
|
||||
- `monthly_quota_usd`: 月度配额(美元)
|
||||
- `monthly_used_usd`: 本月已使用金额(美元)
|
||||
- `quota_reset_day`: 配额重置日期
|
||||
- `quota_last_reset_at`: 上次配额重置时间
|
||||
- `quota_expires_at`: 配额过期时间
|
||||
- `rpm_limit`: RPM 限制
|
||||
- `rpm_used`: 已使用 RPM
|
||||
- `rpm_reset_at`: RPM 重置时间
|
||||
- `total_endpoints`: 端点总数
|
||||
- `active_endpoints`: 活跃端点数
|
||||
- `total_keys`: 密钥总数
|
||||
- `active_keys`: 活跃密钥数
|
||||
- `total_models`: 模型总数
|
||||
- `active_models`: 活跃模型数
|
||||
- `avg_health_score`: 平均健康分数(0-1)
|
||||
- `unhealthy_endpoints`: 不健康端点数(健康分数 < 0.5)
|
||||
- `api_formats`: 支持的 API 格式列表
|
||||
- `endpoint_health_details`: 端点健康详情(包含 api_format, health_score, is_active, active_keys)
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
"""
|
||||
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException(f"Provider {provider_id} not found")
|
||||
@@ -67,7 +138,34 @@ async def get_provider_health_monitor(
|
||||
per_endpoint_limit: int = Query(48, ge=10, le=200, description="每个端点的事件数量"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderEndpointHealthMonitorResponse:
|
||||
"""获取 Provider 下所有端点的健康监控时间线"""
|
||||
"""
|
||||
获取提供商健康监控数据
|
||||
|
||||
获取指定提供商下所有端点的健康监控时间线,包含请求成功率、延迟、错误信息等。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**查询参数**:
|
||||
- `lookback_hours`: 回溯的小时数,范围 1-72,默认为 6
|
||||
- `per_endpoint_limit`: 每个端点返回的事件数量,范围 10-200,默认为 48
|
||||
|
||||
**返回字段**:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `provider_name`: 提供商名称
|
||||
- `generated_at`: 生成时间
|
||||
- `endpoints`: 端点健康监控数据数组,每项包含:
|
||||
- `endpoint_id`: 端点 ID
|
||||
- `api_format`: API 格式
|
||||
- `is_active`: 是否活跃
|
||||
- `total_attempts`: 总请求次数
|
||||
- `success_count`: 成功次数
|
||||
- `failed_count`: 失败次数
|
||||
- `skipped_count`: 跳过次数
|
||||
- `success_rate`: 成功率(0-1)
|
||||
- `last_event_at`: 最后事件时间
|
||||
- `events`: 事件详情数组(包含 timestamp, status, status_code, latency_ms, error_type, error_message)
|
||||
"""
|
||||
|
||||
adapter = AdminProviderHealthMonitorAdapter(
|
||||
provider_id=provider_id,
|
||||
@@ -84,7 +182,29 @@ async def update_provider_settings(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderWithEndpointsSummary:
|
||||
"""更新 Provider 基础配置(display_name, description, priority, weight 等)"""
|
||||
"""
|
||||
更新提供商基础配置
|
||||
|
||||
更新提供商的基础配置信息,如显示名称、描述、优先级等。只需传入需要更新的字段。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**请求体字段**(所有字段可选):
|
||||
- `display_name`: 显示名称
|
||||
- `description`: 描述信息
|
||||
- `website`: 官网地址
|
||||
- `provider_priority`: 优先级
|
||||
- `is_active`: 是否启用
|
||||
- `billing_type`: 计费类型
|
||||
- `monthly_quota_usd`: 月度配额(美元)
|
||||
- `quota_reset_day`: 配额重置日期
|
||||
- `quota_last_reset_at`: 上次配额重置时间
|
||||
- `quota_expires_at`: 配额过期时间
|
||||
- `rpm_limit`: RPM 限制
|
||||
|
||||
**返回字段**: 返回更新后的提供商摘要信息(与 GET /summary 接口返回格式相同)
|
||||
"""
|
||||
|
||||
adapter = AdminUpdateProviderSettingsAdapter(provider_id=provider_id, update_data=update_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -18,7 +18,7 @@ from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
||||
|
||||
router = APIRouter(prefix="/api/admin/security/ip", tags=["IP Security"])
|
||||
router = APIRouter(prefix="/api/admin/security/ip", tags=["Admin - Security"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
@@ -56,42 +56,110 @@ class RemoveIPFromWhitelistRequest(BaseModel):
|
||||
|
||||
@router.post("/blacklist")
|
||||
async def add_to_blacklist(request: Request, db: Session = Depends(get_db)):
|
||||
"""Add IP to blacklist"""
|
||||
"""
|
||||
添加 IP 到黑名单
|
||||
|
||||
将指定 IP 地址添加到黑名单,被加入黑名单的 IP 将无法访问系统。需要管理员权限。
|
||||
|
||||
**请求体字段**:
|
||||
- `ip_address`: IP 地址
|
||||
- `reason`: 加入黑名单的原因
|
||||
- `ttl`: 可选,过期时间(秒),不指定表示永久
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 是否成功
|
||||
- `message`: 操作结果信息
|
||||
- `reason`: 加入黑名单的原因
|
||||
- `ttl`: 过期时间(秒或"永久")
|
||||
"""
|
||||
adapter = AddToBlacklistAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
|
||||
|
||||
|
||||
@router.delete("/blacklist/{ip_address}")
|
||||
async def remove_from_blacklist(ip_address: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Remove IP from blacklist"""
|
||||
"""
|
||||
从黑名单移除 IP
|
||||
|
||||
将指定 IP 地址从黑名单中移除。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `ip_address`: IP 地址
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 是否成功
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
adapter = RemoveFromBlacklistAdapter(ip_address=ip_address)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
|
||||
|
||||
|
||||
@router.get("/blacklist/stats")
|
||||
async def get_blacklist_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""Get blacklist statistics"""
|
||||
"""
|
||||
获取黑名单统计信息
|
||||
|
||||
获取黑名单的统计信息和列表。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `total`: 黑名单总数
|
||||
- `items`: 黑名单列表,每个项包含:
|
||||
- `ip`: IP 地址
|
||||
- `reason`: 加入原因
|
||||
- `added_at`: 添加时间
|
||||
- `ttl`: 剩余有效时间(秒)
|
||||
"""
|
||||
adapter = GetBlacklistStatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
|
||||
|
||||
|
||||
@router.post("/whitelist")
|
||||
async def add_to_whitelist(request: Request, db: Session = Depends(get_db)):
|
||||
"""Add IP to whitelist"""
|
||||
"""
|
||||
添加 IP 到白名单
|
||||
|
||||
将指定 IP 地址或 CIDR 网段添加到白名单,白名单中的 IP 将跳过速率限制检查。需要管理员权限。
|
||||
|
||||
**请求体字段**:
|
||||
- `ip_address`: IP 地址或 CIDR 格式(如 192.168.1.0/24)
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 是否成功
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
adapter = AddToWhitelistAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
|
||||
|
||||
|
||||
@router.delete("/whitelist/{ip_address}")
|
||||
async def remove_from_whitelist(ip_address: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Remove IP from whitelist"""
|
||||
"""
|
||||
从白名单移除 IP
|
||||
|
||||
将指定 IP 地址从白名单中移除。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `ip_address`: IP 地址
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 是否成功
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
adapter = RemoveFromWhitelistAdapter(ip_address=ip_address)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
|
||||
|
||||
|
||||
@router.get("/whitelist")
|
||||
async def get_whitelist(request: Request, db: Session = Depends(get_db)):
|
||||
"""Get whitelist"""
|
||||
"""
|
||||
获取白名单
|
||||
|
||||
获取当前的 IP 白名单列表。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `whitelist`: 白名单 IP 地址列表
|
||||
- `total`: 白名单总数
|
||||
"""
|
||||
adapter = GetWhitelistAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""系统设置API端点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@@ -13,15 +15,72 @@ from src.core.exceptions import InvalidRequestException, NotFoundException, tran
|
||||
from src.database import get_db
|
||||
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
|
||||
from src.models.database import ApiKey, Provider, Usage, User
|
||||
from src.services.email.email_template import EmailTemplate
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
|
||||
|
||||
|
||||
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 describe 获取,回退到静态版本文件。
|
||||
|
||||
**返回字段**:
|
||||
- `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()
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_system_settings(request: Request, db: Session = Depends(get_db)):
|
||||
"""获取系统设置(管理员)"""
|
||||
"""
|
||||
获取系统设置
|
||||
|
||||
获取系统的全局设置信息。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `default_provider`: 默认提供商名称
|
||||
- `default_model`: 默认模型名称
|
||||
- `enable_usage_tracking`: 是否启用使用情况追踪
|
||||
"""
|
||||
|
||||
adapter = AdminGetSystemSettingsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -29,7 +88,19 @@ async def get_system_settings(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_system_settings(http_request: Request, db: Session = Depends(get_db)):
|
||||
"""更新系统设置(管理员)"""
|
||||
"""
|
||||
更新系统设置
|
||||
|
||||
更新系统的全局设置。需要管理员权限。
|
||||
|
||||
**请求体字段**:
|
||||
- `default_provider`: 可选,默认提供商名称(空字符串表示清除设置)
|
||||
- `default_model`: 可选,默认模型名称(空字符串表示清除设置)
|
||||
- `enable_usage_tracking`: 可选,是否启用使用情况追踪
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
|
||||
adapter = AdminUpdateSystemSettingsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)
|
||||
@@ -37,7 +108,14 @@ async def update_system_settings(http_request: Request, db: Session = Depends(ge
|
||||
|
||||
@router.get("/configs")
|
||||
async def get_all_system_configs(request: Request, db: Session = Depends(get_db)):
|
||||
"""获取所有系统配置(管理员)"""
|
||||
"""
|
||||
获取所有系统配置
|
||||
|
||||
获取系统中所有的配置项。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- 配置项的键值对字典
|
||||
"""
|
||||
|
||||
adapter = AdminGetAllConfigsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -45,7 +123,19 @@ async def get_all_system_configs(request: Request, db: Session = Depends(get_db)
|
||||
|
||||
@router.get("/configs/{key}")
|
||||
async def get_system_config(key: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""获取特定系统配置(管理员)"""
|
||||
"""
|
||||
获取特定系统配置
|
||||
|
||||
获取指定配置项的值。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `key`: 配置项键名
|
||||
|
||||
**返回字段**:
|
||||
- `key`: 配置项键名
|
||||
- `value`: 配置项的值(敏感配置项不返回实际值)
|
||||
- `is_set`: 可选,对于敏感配置项,指示是否已设置
|
||||
"""
|
||||
|
||||
adapter = AdminGetSystemConfigAdapter(key=key)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -57,7 +147,24 @@ async def set_system_config(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""设置系统配置(管理员)"""
|
||||
"""
|
||||
设置系统配置
|
||||
|
||||
设置或更新指定配置项的值。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `key`: 配置项键名
|
||||
|
||||
**请求体字段**:
|
||||
- `value`: 配置项的值
|
||||
- `description`: 可选,配置项描述
|
||||
|
||||
**返回字段**:
|
||||
- `key`: 配置项键名
|
||||
- `value`: 配置项的值(敏感配置项显示为 ********)
|
||||
- `description`: 配置项描述
|
||||
- `updated_at`: 更新时间
|
||||
"""
|
||||
|
||||
adapter = AdminSetSystemConfigAdapter(key=key)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -65,7 +172,17 @@ async def set_system_config(
|
||||
|
||||
@router.delete("/configs/{key}")
|
||||
async def delete_system_config(key: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""删除系统配置(管理员)"""
|
||||
"""
|
||||
删除系统配置
|
||||
|
||||
删除指定的配置项。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `key`: 配置项键名
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
|
||||
adapter = AdminDeleteSystemConfigAdapter(key=key)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -73,20 +190,54 @@ async def delete_system_config(key: str, request: Request, db: Session = Depends
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_system_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取系统统计信息
|
||||
|
||||
获取系统的整体统计数据。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `users`: 用户统计(total: 总用户数, active: 活跃用户数)
|
||||
- `providers`: 提供商统计(total: 总提供商数, active: 活跃提供商数)
|
||||
- `api_keys`: API Key 总数
|
||||
- `requests`: 请求总数
|
||||
"""
|
||||
adapter = AdminSystemStatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def trigger_cleanup(request: Request, db: Session = Depends(get_db)):
|
||||
"""Manually trigger usage record cleanup task"""
|
||||
"""
|
||||
手动触发清理任务
|
||||
|
||||
手动触发使用记录清理任务,清理过期的请求/响应数据。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
- `stats`: 清理统计信息
|
||||
- `total_records`: 总记录数统计(before, after, deleted)
|
||||
- `body_fields`: 请求/响应体字段清理统计(before, after, cleaned)
|
||||
- `header_fields`: 请求/响应头字段清理统计(before, after, cleaned)
|
||||
- `timestamp`: 清理完成时间
|
||||
"""
|
||||
adapter = AdminTriggerCleanupAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/api-formats")
|
||||
async def get_api_formats(request: Request, db: Session = Depends(get_db)):
|
||||
"""获取所有可用的API格式列表"""
|
||||
"""
|
||||
获取所有可用的 API 格式列表
|
||||
|
||||
获取系统支持的所有 API 格式及其元数据。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `formats`: API 格式列表,每个格式包含:
|
||||
- `value`: 格式值
|
||||
- `label`: 显示名称
|
||||
- `default_path`: 默认路径
|
||||
- `aliases`: 别名列表
|
||||
"""
|
||||
adapter = AdminGetApiFormatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -119,6 +270,59 @@ async def import_users(request: Request, db: Session = Depends(get_db)):
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/smtp/test")
|
||||
async def test_smtp(request: Request, db: Session = Depends(get_db)):
|
||||
"""测试 SMTP 连接(管理员)"""
|
||||
adapter = AdminTestSmtpAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- 邮件模板 API --------
|
||||
|
||||
|
||||
@router.get("/email/templates")
|
||||
async def get_email_templates(request: Request, db: Session = Depends(get_db)):
|
||||
"""获取所有邮件模板(管理员)"""
|
||||
adapter = AdminGetEmailTemplatesAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/email/templates/{template_type}")
|
||||
async def get_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取指定类型的邮件模板(管理员)"""
|
||||
adapter = AdminGetEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/email/templates/{template_type}")
|
||||
async def update_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新邮件模板(管理员)"""
|
||||
adapter = AdminUpdateEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/email/templates/{template_type}/preview")
|
||||
async def preview_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""预览邮件模板(管理员)"""
|
||||
adapter = AdminPreviewEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/email/templates/{template_type}/reset")
|
||||
async def reset_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""重置邮件模板为默认值(管理员)"""
|
||||
adapter = AdminResetEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- 系统设置适配器 --------
|
||||
|
||||
|
||||
@@ -196,10 +400,16 @@ class AdminGetAllConfigsAdapter(AdminApiAdapter):
|
||||
class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
||||
key: str
|
||||
|
||||
# 敏感配置项,不返回实际值
|
||||
SENSITIVE_KEYS = {"smtp_password"}
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
value = SystemConfigService.get_config(context.db, self.key)
|
||||
if value is None:
|
||||
raise NotFoundException(f"配置项 '{self.key}' 不存在")
|
||||
# 对敏感配置,只返回是否已设置的标志,不返回实际值
|
||||
if self.key in self.SENSITIVE_KEYS:
|
||||
return {"key": self.key, "value": None, "is_set": bool(value)}
|
||||
return {"key": self.key, "value": value}
|
||||
|
||||
|
||||
@@ -207,18 +417,31 @@ class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
||||
class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
||||
key: str
|
||||
|
||||
# 需要加密存储的配置项
|
||||
ENCRYPTED_KEYS = {"smtp_password"}
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
payload = context.ensure_json_body()
|
||||
value = payload.get("value")
|
||||
|
||||
# 对敏感配置进行加密
|
||||
if self.key in self.ENCRYPTED_KEYS and value:
|
||||
from src.core.crypto import crypto_service
|
||||
value = crypto_service.encrypt(value)
|
||||
|
||||
config = SystemConfigService.set_config(
|
||||
context.db,
|
||||
self.key,
|
||||
payload.get("value"),
|
||||
value,
|
||||
payload.get("description"),
|
||||
)
|
||||
|
||||
# 返回时不暴露加密后的值
|
||||
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
|
||||
|
||||
return {
|
||||
"key": config.key,
|
||||
"value": config.value,
|
||||
"value": display_value,
|
||||
"description": config.description,
|
||||
"updated_at": config.updated_at.isoformat(),
|
||||
}
|
||||
@@ -461,7 +684,6 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
||||
"rpm_limit": provider.rpm_limit,
|
||||
"provider_priority": provider.provider_priority,
|
||||
"is_active": provider.is_active,
|
||||
"rate_limit": provider.rate_limit,
|
||||
"concurrent_limit": provider.concurrent_limit,
|
||||
"config": provider.config,
|
||||
"endpoints": endpoints_data,
|
||||
@@ -608,7 +830,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
"provider_priority", 100
|
||||
)
|
||||
existing_provider.is_active = prov_data.get("is_active", True)
|
||||
existing_provider.rate_limit = prov_data.get("rate_limit")
|
||||
existing_provider.concurrent_limit = prov_data.get(
|
||||
"concurrent_limit"
|
||||
)
|
||||
@@ -633,7 +854,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
rpm_limit=prov_data.get("rpm_limit"),
|
||||
provider_priority=prov_data.get("provider_priority", 100),
|
||||
is_active=prov_data.get("is_active", True),
|
||||
rate_limit=prov_data.get("rate_limit"),
|
||||
concurrent_limit=prov_data.get("concurrent_limit"),
|
||||
config=prov_data.get("config"),
|
||||
)
|
||||
@@ -877,6 +1097,30 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
|
||||
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_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 = db.query(User).filter(
|
||||
User.is_deleted.is_(False),
|
||||
@@ -884,31 +1128,12 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
).all()
|
||||
users_data = []
|
||||
for user in users:
|
||||
# 导出用户的 API Keys(保留加密数据)
|
||||
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
|
||||
api_keys_data = []
|
||||
for key in api_keys:
|
||||
api_keys_data.append(
|
||||
{
|
||||
"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,
|
||||
}
|
||||
)
|
||||
# 导出用户的 API Keys(排除独立余额Key,独立Key单独导出)
|
||||
api_keys = db.query(ApiKey).filter(
|
||||
ApiKey.user_id == user.id,
|
||||
ApiKey.is_standalone.is_(False)
|
||||
).all()
|
||||
api_keys_data = [_serialize_api_key(key, include_is_standalone=True) for key in api_keys]
|
||||
|
||||
users_data.append(
|
||||
{
|
||||
@@ -917,7 +1142,7 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
"password_hash": user.password_hash,
|
||||
"role": user.role.value if user.role else "user",
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_endpoints": user.allowed_endpoints,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"model_capability_settings": user.model_capability_settings,
|
||||
"quota_usd": user.quota_usd,
|
||||
@@ -928,10 +1153,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 {
|
||||
"version": "1.0",
|
||||
"version": "1.1",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"users": users_data,
|
||||
"standalone_keys": standalone_keys_data,
|
||||
}
|
||||
|
||||
|
||||
@@ -951,21 +1181,71 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
db = context.db
|
||||
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
|
||||
users_data = payload.get("users", [])
|
||||
standalone_keys_data = payload.get("standalone_keys", [])
|
||||
|
||||
stats = {
|
||||
"users": {"created": 0, "updated": 0, "skipped": 0},
|
||||
"api_keys": {"created": 0, "skipped": 0},
|
||||
"standalone_keys": {"created": 0, "skipped": 0},
|
||||
"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_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:
|
||||
for user_data in users_data:
|
||||
# 跳过管理员角色的导入(不区分大小写)
|
||||
@@ -997,7 +1277,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
if user_data.get("role"):
|
||||
existing_user.role = UserRole(user_data["role"])
|
||||
existing_user.allowed_providers = user_data.get("allowed_providers")
|
||||
existing_user.allowed_endpoints = user_data.get("allowed_endpoints")
|
||||
existing_user.allowed_api_formats = user_data.get("allowed_api_formats")
|
||||
existing_user.allowed_models = user_data.get("allowed_models")
|
||||
existing_user.model_capability_settings = user_data.get(
|
||||
"model_capability_settings"
|
||||
@@ -1021,7 +1301,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
password_hash=user_data.get("password_hash", ""),
|
||||
role=role,
|
||||
allowed_providers=user_data.get("allowed_providers"),
|
||||
allowed_endpoints=user_data.get("allowed_endpoints"),
|
||||
allowed_api_formats=user_data.get("allowed_api_formats"),
|
||||
allowed_models=user_data.get("allowed_models"),
|
||||
model_capability_settings=user_data.get("model_capability_settings"),
|
||||
quota_usd=user_data.get("quota_usd"),
|
||||
@@ -1036,40 +1316,31 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
|
||||
# 导入 API Keys
|
||||
for key_data in user_data.get("api_keys", []):
|
||||
# 检查是否已存在相同的 key_hash
|
||||
if key_data.get("key_hash"):
|
||||
existing_key = (
|
||||
db.query(ApiKey)
|
||||
.filter(ApiKey.key_hash == key_data["key_hash"])
|
||||
.first()
|
||||
)
|
||||
if existing_key:
|
||||
stats["api_keys"]["skipped"] += 1
|
||||
continue
|
||||
new_key, status = _create_api_key_from_data(key_data, user_id)
|
||||
if new_key:
|
||||
db.add(new_key)
|
||||
stats["api_keys"]["created"] += 1
|
||||
elif status == "skipped":
|
||||
stats["api_keys"]["skipped"] += 1
|
||||
# invalid 数据不计入统计
|
||||
|
||||
new_key = ApiKey(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
key_hash=key_data.get("key_hash", ""),
|
||||
key_encrypted=key_data.get("key_encrypted"),
|
||||
name=key_data.get("name"),
|
||||
is_standalone=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", 100),
|
||||
concurrent_limit=key_data.get("concurrent_limit", 5),
|
||||
force_capabilities=key_data.get("force_capabilities"),
|
||||
is_active=key_data.get("is_active", True),
|
||||
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
|
||||
# 导入独立余额 Keys(需要找一个管理员用户作为 owner)
|
||||
if standalone_keys_data:
|
||||
# 查找一个管理员用户作为独立Key的owner
|
||||
admin_user = db.query(User).filter(User.role == UserRole.ADMIN).first()
|
||||
if not admin_user:
|
||||
stats["errors"].append("无法导入独立余额Key: 系统中没有管理员用户")
|
||||
else:
|
||||
for key_data in standalone_keys_data:
|
||||
new_key, status = _create_api_key_from_data(
|
||||
key_data, admin_user.id, is_standalone=True
|
||||
)
|
||||
if new_key:
|
||||
db.add(new_key)
|
||||
stats["standalone_keys"]["created"] += 1
|
||||
elif status == "skipped":
|
||||
stats["standalone_keys"]["skipped"] += 1
|
||||
# invalid 数据不计入统计
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -1084,3 +1355,265 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise InvalidRequestException(f"导入失败: {str(e)}")
|
||||
|
||||
|
||||
class AdminTestSmtpAdapter(AdminApiAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""测试 SMTP 连接"""
|
||||
from src.core.crypto import crypto_service
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.email.email_sender import EmailSenderService
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body() or {}
|
||||
|
||||
# 获取密码:优先使用前端传入的明文密码,否则从数据库获取并解密
|
||||
smtp_password = payload.get("smtp_password")
|
||||
if not smtp_password:
|
||||
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
|
||||
if encrypted_password:
|
||||
try:
|
||||
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
|
||||
except Exception:
|
||||
# 解密失败,可能是旧的未加密密码
|
||||
smtp_password = encrypted_password
|
||||
|
||||
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
|
||||
config = {
|
||||
"smtp_host": payload.get("smtp_host") or SystemConfigService.get_config(db, "smtp_host"),
|
||||
"smtp_port": payload.get("smtp_port") or SystemConfigService.get_config(db, "smtp_port", default=587),
|
||||
"smtp_user": payload.get("smtp_user") or SystemConfigService.get_config(db, "smtp_user"),
|
||||
"smtp_password": smtp_password,
|
||||
"smtp_use_tls": payload.get("smtp_use_tls")
|
||||
if payload.get("smtp_use_tls") is not None
|
||||
else SystemConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||
"smtp_use_ssl": payload.get("smtp_use_ssl")
|
||||
if payload.get("smtp_use_ssl") is not None
|
||||
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||
"smtp_from_email": payload.get("smtp_from_email")
|
||||
or SystemConfigService.get_config(db, "smtp_from_email"),
|
||||
"smtp_from_name": payload.get("smtp_from_name")
|
||||
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
}
|
||||
|
||||
# 验证必要配置
|
||||
missing_fields = [
|
||||
field for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"] if not config.get(field)
|
||||
]
|
||||
if missing_fields:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}"
|
||||
}
|
||||
|
||||
# 测试连接
|
||||
try:
|
||||
success, error_msg = await EmailSenderService.test_smtp_connection(
|
||||
db=db, override_config=config
|
||||
)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "SMTP 连接测试成功"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": error_msg
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
|
||||
# -------- 邮件模板适配器 --------
|
||||
|
||||
|
||||
class AdminGetEmailTemplatesAdapter(AdminApiAdapter):
|
||||
"""获取所有邮件模板"""
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
db = context.db
|
||||
templates = []
|
||||
|
||||
for template_type, type_info in EmailTemplate.TEMPLATE_TYPES.items():
|
||||
# 获取自定义模板或默认模板
|
||||
template = EmailTemplate.get_template(db, template_type)
|
||||
default_template = EmailTemplate.get_default_template(template_type)
|
||||
|
||||
# 检查是否使用了自定义模板
|
||||
is_custom = (
|
||||
template["subject"] != default_template["subject"]
|
||||
or template["html"] != default_template["html"]
|
||||
)
|
||||
|
||||
templates.append(
|
||||
{
|
||||
"type": template_type,
|
||||
"name": type_info["name"],
|
||||
"variables": type_info["variables"],
|
||||
"subject": template["subject"],
|
||||
"html": template["html"],
|
||||
"is_custom": is_custom,
|
||||
}
|
||||
)
|
||||
|
||||
return {"templates": templates}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""获取指定类型的邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
template = EmailTemplate.get_template(db, self.template_type)
|
||||
default_template = EmailTemplate.get_default_template(self.template_type)
|
||||
|
||||
is_custom = (
|
||||
template["subject"] != default_template["subject"]
|
||||
or template["html"] != default_template["html"]
|
||||
)
|
||||
|
||||
return {
|
||||
"type": self.template_type,
|
||||
"name": type_info["name"],
|
||||
"variables": type_info["variables"],
|
||||
"subject": template["subject"],
|
||||
"html": template["html"],
|
||||
"is_custom": is_custom,
|
||||
"default_subject": default_template["subject"],
|
||||
"default_html": default_template["html"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminUpdateEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""更新邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
subject = payload.get("subject")
|
||||
html = payload.get("html")
|
||||
|
||||
# 至少需要提供一个字段
|
||||
if subject is None and html is None:
|
||||
raise InvalidRequestException("请提供 subject 或 html")
|
||||
|
||||
# 保存模板
|
||||
subject_key = f"email_template_{self.template_type}_subject"
|
||||
html_key = f"email_template_{self.template_type}_html"
|
||||
|
||||
if subject is not None:
|
||||
if subject:
|
||||
SystemConfigService.set_config(db, subject_key, subject)
|
||||
else:
|
||||
# 空字符串表示删除自定义值,恢复默认
|
||||
SystemConfigService.delete_config(db, subject_key)
|
||||
|
||||
if html is not None:
|
||||
if html:
|
||||
SystemConfigService.set_config(db, html_key, html)
|
||||
else:
|
||||
SystemConfigService.delete_config(db, html_key)
|
||||
|
||||
return {"message": "模板保存成功"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPreviewEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""预览邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body() or {}
|
||||
|
||||
# 获取模板 HTML(优先使用请求体中的,否则使用数据库中的)
|
||||
html = payload.get("html")
|
||||
if not html:
|
||||
template = EmailTemplate.get_template(db, self.template_type)
|
||||
html = template["html"]
|
||||
|
||||
# 获取预览变量
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
|
||||
# 构建预览变量,使用请求中的值或默认示例值
|
||||
preview_variables = {}
|
||||
default_values = {
|
||||
"app_name": SystemConfigService.get_config(db, "email_app_name")
|
||||
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
"code": "123456",
|
||||
"expire_minutes": "30",
|
||||
"email": "example@example.com",
|
||||
"reset_link": "https://example.com/reset?token=abc123",
|
||||
}
|
||||
|
||||
for var in type_info["variables"]:
|
||||
preview_variables[var] = payload.get(var, default_values.get(var, f"{{{{{var}}}}}"))
|
||||
|
||||
# 渲染模板
|
||||
rendered_html = EmailTemplate.render_template(html, preview_variables)
|
||||
|
||||
return {
|
||||
"html": rendered_html,
|
||||
"variables": preview_variables,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminResetEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""重置邮件模板为默认值"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
|
||||
# 删除自定义模板
|
||||
subject_key = f"email_template_{self.template_type}_subject"
|
||||
html_key = f"email_template_{self.template_type}_html"
|
||||
|
||||
SystemConfigService.delete_config(db, subject_key)
|
||||
SystemConfigService.delete_config(db, html_key)
|
||||
|
||||
# 返回默认模板
|
||||
default_template = EmailTemplate.get_default_template(self.template_type)
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
|
||||
return {
|
||||
"message": "模板已重置为默认值",
|
||||
"template": {
|
||||
"type": self.template_type,
|
||||
"name": type_info["name"],
|
||||
"subject": default_template["subject"],
|
||||
"html": default_template["html"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -39,12 +39,21 @@ async def get_usage_aggregation(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get usage aggregation by specified dimension.
|
||||
获取使用情况聚合统计
|
||||
|
||||
- group_by=model: Aggregate by model
|
||||
- group_by=user: Aggregate by user
|
||||
- group_by=provider: Aggregate by provider
|
||||
- group_by=api_format: Aggregate by API format
|
||||
按指定维度聚合使用情况统计数据。
|
||||
|
||||
**查询参数**:
|
||||
- `group_by`: 必需,聚合维度,可选值:model(按模型)、user(按用户)、provider(按提供商)、api_format(按 API 格式)
|
||||
- `start_date`: 可选,开始日期(ISO 格式)
|
||||
- `end_date`: 可选,结束日期(ISO 格式)
|
||||
- `limit`: 返回数量限制,默认 20,最大 100
|
||||
|
||||
**返回字段**:
|
||||
- 按模型聚合时:model, request_count, total_tokens, total_cost, actual_cost
|
||||
- 按用户聚合时:user_id, email, username, request_count, total_tokens, total_cost
|
||||
- 按提供商聚合时:provider_id, provider, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms, success_rate, error_count
|
||||
- 按 API 格式聚合时:api_format, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms
|
||||
"""
|
||||
if group_by == "model":
|
||||
adapter = AdminUsageByModelAdapter(start_date=start_date, end_date=end_date, limit=limit)
|
||||
@@ -69,15 +78,52 @@ async def get_usage_stats(
|
||||
end_date: Optional[datetime] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取使用情况总体统计
|
||||
|
||||
获取指定时间范围内的使用情况总体统计数据。
|
||||
|
||||
**查询参数**:
|
||||
- `start_date`: 可选,开始日期(ISO 格式)
|
||||
- `end_date`: 可选,结束日期(ISO 格式)
|
||||
|
||||
**返回字段**:
|
||||
- `total_requests`: 总请求数
|
||||
- `total_tokens`: 总 token 数
|
||||
- `total_cost`: 总成本(美元)
|
||||
- `total_actual_cost`: 实际总成本(美元)
|
||||
- `avg_response_time`: 平均响应时间(秒)
|
||||
- `error_count`: 错误请求数
|
||||
- `error_rate`: 错误率(百分比)
|
||||
- `cache_stats`: 缓存统计信息(cache_creation_tokens, cache_read_tokens, cache_creation_cost, cache_read_cost)
|
||||
"""
|
||||
adapter = AdminUsageStatsAdapter(start_date=start_date, end_date=end_date)
|
||||
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),
|
||||
):
|
||||
"""
|
||||
获取活动热力图数据
|
||||
|
||||
获取过去 365 天的活动热力图数据。此接口缓存 5 分钟以减少数据库负载。
|
||||
|
||||
**返回字段**:
|
||||
- 按日期聚合的请求数、token 数、成本等统计数据
|
||||
"""
|
||||
adapter = AdminActivityHeatmapAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/records")
|
||||
async def get_usage_records(
|
||||
request: Request,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
search: Optional[str] = None, # 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
user_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
@@ -87,9 +133,37 @@ async def get_usage_records(
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取使用记录列表
|
||||
|
||||
获取详细的使用记录列表,支持多种筛选条件。
|
||||
|
||||
**查询参数**:
|
||||
- `start_date`: 可选,开始日期(ISO 格式)
|
||||
- `end_date`: 可选,结束日期(ISO 格式)
|
||||
- `search`: 可选,通用搜索关键词(支持用户名、密钥名、模型名、提供商名模糊搜索,多个关键词用空格分隔)
|
||||
- `user_id`: 可选,用户 ID 筛选
|
||||
- `username`: 可选,用户名模糊搜索
|
||||
- `model`: 可选,模型名模糊搜索
|
||||
- `provider`: 可选,提供商名称搜索
|
||||
- `status`: 可选,状态筛选(stream: 流式请求,standard: 标准请求,error: 错误请求,pending: 等待中,streaming: 流式中,completed: 已完成,failed: 失败,active: 活跃请求)
|
||||
- `limit`: 返回数量限制,默认 100,最大 500
|
||||
- `offset`: 分页偏移量,默认 0
|
||||
|
||||
**返回字段**:
|
||||
- `records`: 使用记录列表,包含 id, user_id, user_email, username, api_key, provider, model, target_model,
|
||||
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, total_tokens,
|
||||
cost, actual_cost, rate_multiplier, response_time_ms, first_byte_time_ms, created_at, is_stream,
|
||||
input_price_per_1m, output_price_per_1m, cache_creation_price_per_1m, cache_read_price_per_1m,
|
||||
status_code, error_message, status, has_fallback, api_format, api_key_name, request_metadata
|
||||
- `total`: 符合条件的总记录数
|
||||
- `limit`: 当前分页限制
|
||||
- `offset`: 当前分页偏移量
|
||||
"""
|
||||
adapter = AdminUsageRecordsAdapter(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
search=search,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
model=model,
|
||||
@@ -108,10 +182,19 @@ async def get_active_requests(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取活跃请求的状态(轻量级接口,用于前端轮询)
|
||||
获取活跃请求的状态
|
||||
|
||||
获取当前活跃(pending/streaming 状态)请求的状态信息。这是一个轻量级接口,适合前端轮询。
|
||||
|
||||
**查询参数**:
|
||||
- `ids`: 可选,逗号分隔的请求 ID 列表,用于查询特定请求的状态
|
||||
|
||||
**行为说明**:
|
||||
- 如果提供 ids 参数,只返回这些 ID 对应请求的最新状态
|
||||
- 如果不提供 ids,返回所有 pending/streaming 状态的请求
|
||||
|
||||
**返回字段**:
|
||||
- `requests`: 活跃请求列表,包含请求状态信息
|
||||
"""
|
||||
adapter = AdminActiveRequestsAdapter(ids=ids)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -126,9 +209,48 @@ async def get_usage_detail(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get detailed information of a specific usage record.
|
||||
获取使用记录详情
|
||||
|
||||
Includes request/response headers and body.
|
||||
获取指定使用记录的详细信息,包括请求/响应的头部和正文。
|
||||
|
||||
**路径参数**:
|
||||
- `usage_id`: 使用记录 ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 记录 ID
|
||||
- `request_id`: 请求 ID
|
||||
- `user`: 用户信息(id, username, email)
|
||||
- `api_key`: API Key 信息(id, name, display)
|
||||
- `provider`: 提供商名称
|
||||
- `api_format`: API 格式
|
||||
- `model`: 请求的模型名称
|
||||
- `target_model`: 映射后的目标模型名称
|
||||
- `tokens`: Token 统计(input, output, total)
|
||||
- `cost`: 成本统计(input, output, total)
|
||||
- `cache_creation_input_tokens`: 缓存创建输入 token 数
|
||||
- `cache_read_input_tokens`: 缓存读取输入 token 数
|
||||
- `cache_creation_cost`: 缓存创建成本
|
||||
- `cache_read_cost`: 缓存读取成本
|
||||
- `request_cost`: 请求成本
|
||||
- `input_price_per_1m`: 输入价格(每百万 token)
|
||||
- `output_price_per_1m`: 输出价格(每百万 token)
|
||||
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token)
|
||||
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token)
|
||||
- `price_per_request`: 每请求价格
|
||||
- `request_type`: 请求类型
|
||||
- `is_stream`: 是否为流式请求
|
||||
- `status_code`: HTTP 状态码
|
||||
- `error_message`: 错误信息
|
||||
- `response_time_ms`: 响应时间(毫秒)
|
||||
- `first_byte_time_ms`: 首字节时间(TTFB,毫秒)
|
||||
- `created_at`: 创建时间
|
||||
- `request_headers`: 请求头
|
||||
- `request_body`: 请求体
|
||||
- `provider_request_headers`: 提供商请求头
|
||||
- `response_headers`: 响应头
|
||||
- `response_body`: 响应体
|
||||
- `metadata`: 提供商响应元数据
|
||||
- `tiered_pricing`: 阶梯计费信息(如适用)
|
||||
"""
|
||||
adapter = AdminUsageDetailAdapter(usage_id=usage_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -168,12 +290,6 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
|
||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
||||
).count()
|
||||
|
||||
activity_heatmap = UsageService.get_daily_activity(
|
||||
db=db,
|
||||
window_days=365,
|
||||
include_actual_cost=True,
|
||||
)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="usage_stats",
|
||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||
@@ -204,10 +320,22 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
|
||||
),
|
||||
"cache_read_cost": float(cache_stats.cache_read_cost or 0) if cache_stats else 0,
|
||||
},
|
||||
"activity_heatmap": activity_heatmap,
|
||||
}
|
||||
|
||||
|
||||
class AdminActivityHeatmapAdapter(AdminApiAdapter):
|
||||
"""Activity heatmap adapter with Redis caching."""
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
result = await UsageService.get_cached_heatmap(
|
||||
db=context.db,
|
||||
user_id=None,
|
||||
include_actual_cost=True,
|
||||
)
|
||||
context.add_audit_metadata(action="activity_heatmap")
|
||||
return result
|
||||
|
||||
|
||||
class AdminUsageByModelAdapter(AdminApiAdapter):
|
||||
def __init__(self, start_date: Optional[datetime], end_date: Optional[datetime], limit: int):
|
||||
self.start_date = start_date
|
||||
@@ -225,8 +353,8 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
|
||||
)
|
||||
# 过滤掉 pending/streaming 状态的请求(尚未完成的请求不应计入统计)
|
||||
query = query.filter(Usage.status.notin_(["pending", "streaming"]))
|
||||
# 过滤掉 unknown/pending provider(请求未到达任何提供商)
|
||||
query = query.filter(Usage.provider.notin_(["unknown", "pending"]))
|
||||
# 过滤掉 unknown/pending provider_name(请求未到达任何提供商)
|
||||
query = query.filter(Usage.provider_name.notin_(["unknown", "pending"]))
|
||||
|
||||
if self.start_date:
|
||||
query = query.filter(Usage.created_at >= self.start_date)
|
||||
@@ -437,8 +565,8 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
|
||||
)
|
||||
# 过滤掉 pending/streaming 状态的请求
|
||||
query = query.filter(Usage.status.notin_(["pending", "streaming"]))
|
||||
# 过滤掉 unknown/pending provider
|
||||
query = query.filter(Usage.provider.notin_(["unknown", "pending"]))
|
||||
# 过滤掉 unknown/pending provider_name
|
||||
query = query.filter(Usage.provider_name.notin_(["unknown", "pending"]))
|
||||
# 只统计有 api_format 的记录
|
||||
query = query.filter(Usage.api_format.isnot(None))
|
||||
|
||||
@@ -480,6 +608,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
self,
|
||||
start_date: Optional[datetime],
|
||||
end_date: Optional[datetime],
|
||||
search: Optional[str],
|
||||
user_id: Optional[str],
|
||||
username: Optional[str],
|
||||
model: Optional[str],
|
||||
@@ -490,6 +619,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
):
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
self.search = search
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.model = model
|
||||
@@ -499,25 +629,54 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
self.offset = offset
|
||||
|
||||
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
|
||||
query = (
|
||||
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey)
|
||||
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
|
||||
.outerjoin(User, Usage.user_id == User.id)
|
||||
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.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:
|
||||
query = query.filter(Usage.user_id == self.user_id)
|
||||
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:
|
||||
# 支持模型名模糊搜索
|
||||
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:
|
||||
# 支持提供商名称搜索(通过 Provider 表)
|
||||
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True)
|
||||
query = query.filter(Provider.name.ilike(f"%{self.provider}%"))
|
||||
# 支持提供商名称搜索
|
||||
escaped = escape_like_pattern(self.provider)
|
||||
query = query.filter(Provider.name.ilike(f"%{escaped}%", escape="\\"))
|
||||
if self.status:
|
||||
# 状态筛选
|
||||
# 旧的筛选值(基于 is_stream 和 status_code):stream, standard, error
|
||||
@@ -555,7 +714,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
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 = {}
|
||||
if request_ids:
|
||||
# 只统计实际执行的候选(success 或 failed),不包括 skipped/pending/available
|
||||
@@ -575,6 +734,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
action="usage_records",
|
||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||
search=self.search,
|
||||
user_id=self.user_id,
|
||||
username=self.username,
|
||||
model=self.model,
|
||||
@@ -586,7 +746,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
)
|
||||
|
||||
# 构建 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 = {}
|
||||
if provider_ids:
|
||||
providers_data = (
|
||||
@@ -595,7 +755,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
provider_map = {str(p.id): p.name for p in providers_data}
|
||||
|
||||
data = []
|
||||
for usage, user, endpoint, api_key in records:
|
||||
for usage, user, endpoint, provider_api_key, user_api_key in records:
|
||||
actual_cost = (
|
||||
float(usage.actual_total_cost_usd)
|
||||
if usage.actual_total_cost_usd is not None
|
||||
@@ -605,8 +765,8 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
float(usage.rate_multiplier) if usage.rate_multiplier is not None else 1.0
|
||||
)
|
||||
|
||||
# 提供商名称优先级:关联的 Provider 表 > usage.provider 字段
|
||||
provider_name = usage.provider
|
||||
# 提供商名称优先级:关联的 Provider 表 > usage.provider_name 字段
|
||||
provider_name = usage.provider_name
|
||||
if usage.provider_id and str(usage.provider_id) in provider_map:
|
||||
provider_name = provider_map[str(usage.provider_id)]
|
||||
|
||||
@@ -616,6 +776,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
"user_id": user.id if user else None,
|
||||
"user_email": user.email 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,
|
||||
"model": usage.model,
|
||||
"target_model": usage.target_model, # 映射后的目标模型名
|
||||
@@ -641,7 +810,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||
"has_fallback": fallback_map.get(usage.request_id, False),
|
||||
"api_format": usage.api_format
|
||||
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 响应元数据
|
||||
}
|
||||
)
|
||||
@@ -670,7 +839,9 @@ class AdminActiveRequestsAdapter(AdminApiAdapter):
|
||||
if not id_list:
|
||||
return {"requests": []}
|
||||
|
||||
requests = UsageService.get_active_requests_status(db=db, ids=id_list)
|
||||
requests = UsageService.get_active_requests_status(
|
||||
db=db, ids=id_list, include_admin_fields=True
|
||||
)
|
||||
return {"requests": requests}
|
||||
|
||||
|
||||
@@ -710,7 +881,7 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
||||
"name": api_key.name if api_key else None,
|
||||
"display": api_key.get_display_key() if api_key else None,
|
||||
},
|
||||
"provider": usage_record.provider,
|
||||
"provider": usage_record.provider_name,
|
||||
"api_format": usage_record.api_format,
|
||||
"model": usage_record.model,
|
||||
"target_model": usage_record.target_model,
|
||||
@@ -763,7 +934,7 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
||||
# 尝试获取模型的阶梯配置(带来源信息)
|
||||
cost_service = ModelCostService(db)
|
||||
pricing_result = await cost_service.get_tiered_pricing_with_source_async(
|
||||
usage_record.provider, usage_record.model
|
||||
usage_record.provider_name, usage_record.model
|
||||
)
|
||||
|
||||
if not pricing_result:
|
||||
|
||||
@@ -26,6 +26,18 @@ pipeline = ApiRequestPipeline()
|
||||
# 管理员端点
|
||||
@router.post("")
|
||||
async def create_user_endpoint(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
创建用户
|
||||
|
||||
创建新用户账号(管理员专用)。
|
||||
|
||||
**请求体**:
|
||||
- `email`: 邮箱地址
|
||||
- `username`: 用户名
|
||||
- `password`: 密码
|
||||
- `role`: 角色(user/admin)
|
||||
- `quota_usd`: 配额(USD)
|
||||
"""
|
||||
adapter = AdminCreateUserAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -33,18 +45,33 @@ async def create_user_endpoint(request: Request, db: Session = Depends(get_db)):
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
role: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="返回记录数"),
|
||||
role: Optional[str] = Query(None, description="按角色筛选(user/admin)"),
|
||||
is_active: Optional[bool] = Query(None, description="按状态筛选"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取用户列表
|
||||
|
||||
分页获取用户列表,支持按角色和状态筛选。
|
||||
|
||||
**返回字段**: id, email, username, role, quota_usd, used_usd, is_active, created_at 等
|
||||
"""
|
||||
adapter = AdminListUsersAdapter(skip=skip, limit=limit, role=role, is_active=is_active)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
async def get_user(user_id: str, request: Request, db: Session = Depends(get_db)): # UUID
|
||||
async def get_user(user_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取用户详情
|
||||
|
||||
获取指定用户的详细信息。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
"""
|
||||
adapter = AdminGetUserAdapter(user_id=user_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -55,19 +82,51 @@ async def update_user(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
更新指定用户的信息,包括角色、配额、权限等。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
|
||||
**请求体** (均为可选):
|
||||
- `email`: 邮箱地址
|
||||
- `username`: 用户名
|
||||
- `role`: 角色
|
||||
- `quota_usd`: 配额
|
||||
- `is_active`: 是否启用
|
||||
- `allowed_providers`: 允许的提供商列表
|
||||
- `allowed_models`: 允许的模型列表
|
||||
"""
|
||||
adapter = AdminUpdateUserAdapter(user_id=user_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(user_id: str, request: Request, db: Session = Depends(get_db)): # UUID
|
||||
async def delete_user(user_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
删除用户
|
||||
|
||||
永久删除指定用户。不能删除最后一个管理员账户。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
"""
|
||||
adapter = AdminDeleteUserAdapter(user_id=user_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{user_id}/quota")
|
||||
async def reset_user_quota(user_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Reset user quota (set used_usd to 0)"""
|
||||
"""
|
||||
重置用户配额
|
||||
|
||||
将用户的已用配额(used_usd)重置为 0。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
"""
|
||||
adapter = AdminResetUserQuotaAdapter(user_id=user_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -76,10 +135,17 @@ async def reset_user_quota(user_id: str, request: Request, db: Session = Depends
|
||||
async def get_user_api_keys(
|
||||
user_id: str,
|
||||
request: Request,
|
||||
is_active: Optional[bool] = None,
|
||||
is_active: Optional[bool] = Query(None, description="按状态筛选"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取用户的所有API Keys(不包括独立Keys)"""
|
||||
"""
|
||||
获取用户的 API 密钥列表
|
||||
|
||||
获取指定用户的所有 API 密钥(不包括独立密钥)。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
"""
|
||||
adapter = AdminGetUserKeysAdapter(user_id=user_id, is_active=is_active)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -90,7 +156,23 @@ async def create_user_api_key(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""为用户创建API Key"""
|
||||
"""
|
||||
为用户创建 API 密钥
|
||||
|
||||
为指定用户创建新的 API 密钥。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
|
||||
**请求体**:
|
||||
- `name`: 密钥名称
|
||||
- `allowed_providers`: 允许的提供商(可选)
|
||||
- `allowed_models`: 允许的模型(可选)
|
||||
- `rate_limit`: 速率限制(可选)
|
||||
- `expire_days`: 过期天数(可选)
|
||||
|
||||
**返回**: 包含完整密钥值的响应(仅此一次显示)
|
||||
"""
|
||||
adapter = AdminCreateUserKeyAdapter(user_id=user_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -102,7 +184,15 @@ async def delete_user_api_key(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除用户的API Key"""
|
||||
"""
|
||||
删除用户的 API 密钥
|
||||
|
||||
删除指定用户的指定 API 密钥。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
- `key_id`: 密钥 ID
|
||||
"""
|
||||
adapter = AdminDeleteUserKeyAdapter(user_id=user_id, key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -156,7 +246,7 @@ class AdminCreateUserAdapter(AdminApiAdapter):
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_endpoints": user.allowed_endpoints,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"quota_usd": user.quota_usd,
|
||||
"used_usd": user.used_usd,
|
||||
@@ -183,6 +273,9 @@ class AdminListUsersAdapter(AdminApiAdapter):
|
||||
"email": u.email,
|
||||
"username": u.username,
|
||||
"role": u.role.value,
|
||||
"allowed_providers": u.allowed_providers,
|
||||
"allowed_api_formats": u.allowed_api_formats,
|
||||
"allowed_models": u.allowed_models,
|
||||
"quota_usd": u.quota_usd,
|
||||
"used_usd": u.used_usd,
|
||||
"total_usd": getattr(u, "total_usd", 0),
|
||||
@@ -216,7 +309,7 @@ class AdminGetUserAdapter(AdminApiAdapter):
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_endpoints": user.allowed_endpoints,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"quota_usd": user.quota_usd,
|
||||
"used_usd": user.used_usd,
|
||||
@@ -248,6 +341,7 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
old_role = existing_user.role
|
||||
if "role" in update_data and update_data["role"]:
|
||||
if hasattr(update_data["role"], "value"):
|
||||
update_data["role"] = update_data["role"]
|
||||
@@ -258,6 +352,12 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
||||
if not user:
|
||||
raise NotFoundException("用户不存在", "user")
|
||||
|
||||
# 角色变更时清除热力图缓存(影响 include_actual_cost 权限)
|
||||
if "role" in update_data and update_data["role"] != old_role:
|
||||
from src.services.usage.service import UsageService
|
||||
|
||||
await UsageService.clear_user_heatmap_cache(self.user_id)
|
||||
|
||||
changed_fields = list(update_data.keys())
|
||||
context.add_audit_metadata(
|
||||
action="update_user",
|
||||
@@ -275,7 +375,7 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_endpoints": user.allowed_endpoints,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"quota_usd": user.quota_usd,
|
||||
"used_usd": user.used_usd,
|
||||
@@ -424,7 +524,7 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
|
||||
name=key_data.name,
|
||||
allowed_providers=key_data.allowed_providers,
|
||||
allowed_models=key_data.allowed_models,
|
||||
rate_limit=key_data.rate_limit or 100,
|
||||
rate_limit=key_data.rate_limit, # None = 无限制
|
||||
expire_days=key_data.expire_days,
|
||||
initial_balance_usd=None, # 普通Key不设置余额限制
|
||||
is_standalone=False, # 不是独立Key
|
||||
|
||||
@@ -35,7 +35,32 @@ async def list_announcements(
|
||||
offset: int = Query(0, description="偏移量"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取公告列表(包含已读状态)"""
|
||||
"""
|
||||
获取公告列表
|
||||
|
||||
获取公告列表,支持分页和筛选。如果用户已登录,返回包含已读状态。
|
||||
|
||||
**查询参数**:
|
||||
- `active_only`: 是否只返回有效公告,默认 true
|
||||
- `limit`: 返回数量限制,默认 50
|
||||
- `offset`: 分页偏移量,默认 0
|
||||
|
||||
**返回字段**:
|
||||
- `items`: 公告列表,每条公告包含:
|
||||
- `id`: 公告 ID
|
||||
- `title`: 标题
|
||||
- `content`: 内容
|
||||
- `type`: 类型(info/warning/error/success)
|
||||
- `priority`: 优先级
|
||||
- `is_pinned`: 是否置顶
|
||||
- `is_read`: 是否已读(仅登录用户)
|
||||
- `author`: 作者信息
|
||||
- `start_time`: 生效开始时间
|
||||
- `end_time`: 生效结束时间
|
||||
- `created_at`: 创建时间
|
||||
- `total`: 总数
|
||||
- `unread_count`: 未读数量(仅登录用户)
|
||||
"""
|
||||
adapter = ListAnnouncementsAdapter(active_only=active_only, limit=limit, offset=offset)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -45,7 +70,16 @@ async def get_active_announcements(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取当前有效的公告(首页展示)"""
|
||||
"""
|
||||
获取当前有效的公告
|
||||
|
||||
获取当前时间范围内有效的公告列表,用于首页展示。
|
||||
|
||||
**返回字段**:
|
||||
- `items`: 有效公告列表
|
||||
- `total`: 有效公告总数
|
||||
- `unread_count`: 未读数量(仅登录用户)
|
||||
"""
|
||||
adapter = GetActiveAnnouncementsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -56,7 +90,27 @@ async def get_announcement(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取单个公告详情"""
|
||||
"""
|
||||
获取单个公告详情
|
||||
|
||||
获取指定公告的详细信息。
|
||||
|
||||
**路径参数**:
|
||||
- `announcement_id`: 公告 ID(UUID)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 公告 ID
|
||||
- `title`: 标题
|
||||
- `content`: 内容
|
||||
- `type`: 类型(info/warning/error/success)
|
||||
- `priority`: 优先级
|
||||
- `is_pinned`: 是否置顶
|
||||
- `author`: 作者信息(id, username)
|
||||
- `start_time`: 生效开始时间
|
||||
- `end_time`: 生效结束时间
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
"""
|
||||
adapter = GetAnnouncementAdapter(announcement_id=announcement_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -67,7 +121,17 @@ async def mark_announcement_as_read(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Mark announcement as read"""
|
||||
"""
|
||||
标记公告为已读
|
||||
|
||||
将指定公告标记为当前用户已读。需要登录。
|
||||
|
||||
**路径参数**:
|
||||
- `announcement_id`: 公告 ID(UUID)
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
adapter = MarkAnnouncementReadAdapter(announcement_id=announcement_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -80,7 +144,25 @@ async def create_announcement(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""创建公告(管理员权限)"""
|
||||
"""
|
||||
创建公告
|
||||
|
||||
创建新的系统公告。需要管理员权限。
|
||||
|
||||
**请求体字段**:
|
||||
- `title`: 公告标题(必填)
|
||||
- `content`: 公告内容(必填)
|
||||
- `type`: 公告类型(info/warning/error/success),默认 info
|
||||
- `priority`: 优先级(0-100),默认 0
|
||||
- `is_pinned`: 是否置顶,默认 false
|
||||
- `start_time`: 生效开始时间(可选)
|
||||
- `end_time`: 生效结束时间(可选)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 新创建的公告 ID
|
||||
- `title`: 公告标题
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
adapter = CreateAnnouncementAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -91,7 +173,27 @@ async def update_announcement(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""更新公告(管理员权限)"""
|
||||
"""
|
||||
更新公告
|
||||
|
||||
更新指定公告的信息。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `announcement_id`: 公告 ID(UUID)
|
||||
|
||||
**请求体字段(均为可选)**:
|
||||
- `title`: 公告标题
|
||||
- `content`: 公告内容
|
||||
- `type`: 公告类型(info/warning/error/success)
|
||||
- `priority`: 优先级(0-100)
|
||||
- `is_active`: 是否启用
|
||||
- `is_pinned`: 是否置顶
|
||||
- `start_time`: 生效开始时间
|
||||
- `end_time`: 生效结束时间
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
adapter = UpdateAnnouncementAdapter(announcement_id=announcement_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -102,7 +204,17 @@ async def delete_announcement(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除公告(管理员权限)"""
|
||||
"""
|
||||
删除公告
|
||||
|
||||
删除指定的公告。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `announcement_id`: 公告 ID(UUID)
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
"""
|
||||
adapter = DeleteAnnouncementAdapter(announcement_id=announcement_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -115,7 +227,14 @@ async def get_my_unread_announcement_count(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取我的未读公告数量"""
|
||||
"""
|
||||
获取我的未读公告数量
|
||||
|
||||
获取当前用户的未读公告数量。需要登录。
|
||||
|
||||
**返回字段**:
|
||||
- `unread_count`: 未读公告数量
|
||||
"""
|
||||
adapter = UnreadAnnouncementCountAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
认证相关API端点
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -23,58 +23,218 @@ from src.models.api import (
|
||||
RefreshTokenResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
RegistrationSettingsResponse,
|
||||
SendVerificationCodeRequest,
|
||||
SendVerificationCodeResponse,
|
||||
VerificationStatusRequest,
|
||||
VerificationStatusResponse,
|
||||
VerifyEmailRequest,
|
||||
VerifyEmailResponse,
|
||||
)
|
||||
from src.models.database import AuditEventType, User, UserRole
|
||||
from src.services.auth.service import AuthService
|
||||
from src.services.auth.ldap import LDAPService
|
||||
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
||||
from src.services.system.audit import AuditService
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.user.service import UserService
|
||||
from src.services.email import EmailSenderService, EmailVerificationService
|
||||
from src.utils.request_utils import get_client_ip, get_user_agent
|
||||
|
||||
|
||||
def validate_email_suffix(db: Session, email: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证邮箱后缀是否允许注册
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
email: 邮箱地址
|
||||
|
||||
Returns:
|
||||
(是否允许, 错误信息)
|
||||
"""
|
||||
# 获取邮箱后缀限制配置
|
||||
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
|
||||
|
||||
if mode == "none":
|
||||
return True, None
|
||||
|
||||
# 获取邮箱后缀列表
|
||||
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
|
||||
if not suffix_list:
|
||||
# 没有配置后缀列表时,不限制
|
||||
return True, None
|
||||
|
||||
# 确保 suffix_list 是列表类型
|
||||
if isinstance(suffix_list, str):
|
||||
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
|
||||
|
||||
# 获取邮箱后缀
|
||||
if "@" not in email:
|
||||
return False, "邮箱格式无效"
|
||||
|
||||
email_suffix = email.split("@")[1].lower()
|
||||
|
||||
if mode == "whitelist":
|
||||
# 白名单模式:只允许列出的后缀
|
||||
if email_suffix not in suffix_list:
|
||||
return False, f"该邮箱后缀不在允许列表中,仅支持: {', '.join(suffix_list)}"
|
||||
elif mode == "blacklist":
|
||||
# 黑名单模式:拒绝列出的后缀
|
||||
if email_suffix in suffix_list:
|
||||
return False, f"该邮箱后缀 ({email_suffix}) 不允许注册"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
# API端点
|
||||
@router.get("/registration-settings", response_model=RegistrationSettingsResponse)
|
||||
async def registration_settings(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取注册相关配置
|
||||
|
||||
返回系统注册配置,包括是否开放注册、是否需要邮箱验证等。
|
||||
此接口为公开接口,无需认证。
|
||||
"""
|
||||
adapter = AuthRegistrationSettingsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def auth_settings(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取认证设置
|
||||
|
||||
返回系统支持的认证方式,如本地认证、LDAP 认证等。
|
||||
前端据此判断显示哪些登录选项。此接口为公开接口,无需认证。
|
||||
"""
|
||||
adapter = AuthSettingsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
用户登录
|
||||
|
||||
使用邮箱和密码登录,成功后返回 JWT access_token 和 refresh_token。
|
||||
|
||||
- **access_token**: 用于后续 API 调用,有效期 24 小时
|
||||
- **refresh_token**: 用于刷新 access_token
|
||||
|
||||
速率限制: 5次/分钟/IP
|
||||
"""
|
||||
adapter = AuthLoginAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshTokenResponse)
|
||||
async def refresh_token(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
刷新访问令牌
|
||||
|
||||
使用 refresh_token 获取新的 access_token 和 refresh_token。
|
||||
原 refresh_token 刷新后失效。
|
||||
"""
|
||||
adapter = AuthRefreshAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
用户注册
|
||||
|
||||
创建新用户账号。需要系统开放注册功能。
|
||||
如果系统开启了邮箱验证,需先通过 /send-verification-code 和 /verify-email 完成邮箱验证。
|
||||
|
||||
速率限制: 3次/分钟/IP
|
||||
"""
|
||||
adapter = AuthRegisterAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取当前用户信息
|
||||
|
||||
返回当前登录用户的基本信息,包括邮箱、用户名、角色、配额等。
|
||||
需要 Bearer Token 认证。
|
||||
"""
|
||||
adapter = AuthCurrentUserAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/password")
|
||||
async def change_password(request: Request, db: Session = Depends(get_db)):
|
||||
"""Change current user's password"""
|
||||
"""
|
||||
修改密码
|
||||
|
||||
修改当前用户的登录密码,需提供旧密码验证。
|
||||
密码长度至少 6 位。
|
||||
"""
|
||||
adapter = AuthChangePasswordAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=LogoutResponse)
|
||||
async def logout(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
用户登出
|
||||
|
||||
将当前 Token 加入黑名单,使其失效。
|
||||
"""
|
||||
adapter = AuthLogoutAdapter()
|
||||
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)):
|
||||
"""
|
||||
发送邮箱验证码
|
||||
|
||||
向指定邮箱发送验证码,用于注册前的邮箱验证。
|
||||
验证码有效期 5 分钟,同一邮箱 60 秒内只能发送一次。
|
||||
|
||||
速率限制: 3次/分钟/IP
|
||||
"""
|
||||
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)):
|
||||
"""
|
||||
验证邮箱验证码
|
||||
|
||||
验证邮箱收到的验证码是否正确。
|
||||
验证成功后,邮箱会被标记为已验证状态,可用于注册。
|
||||
|
||||
速率限制: 10次/分钟/IP
|
||||
"""
|
||||
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)):
|
||||
"""
|
||||
查询邮箱验证状态
|
||||
|
||||
查询指定邮箱的验证状态,包括是否有待验证的验证码、是否已验证等。
|
||||
|
||||
速率限制: 20次/分钟/IP
|
||||
"""
|
||||
adapter = AuthVerificationStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ============== 适配器实现 ==============
|
||||
|
||||
|
||||
@@ -111,7 +271,9 @@ class AuthLoginAdapter(AuthPublicAdapter):
|
||||
detail=f"登录请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
user = await AuthService.authenticate_user(db, login_request.email, login_request.password)
|
||||
user = await AuthService.authenticate_user(
|
||||
db, login_request.email, login_request.password, login_request.auth_type
|
||||
)
|
||||
if not user:
|
||||
AuditService.log_login_attempt(
|
||||
db=db,
|
||||
@@ -209,6 +371,35 @@ class AuthRefreshAdapter(AuthPublicAdapter):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败")
|
||||
|
||||
|
||||
class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""公开返回注册相关配置"""
|
||||
db = context.db
|
||||
|
||||
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
|
||||
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
|
||||
|
||||
return RegistrationSettingsResponse(
|
||||
enable_registration=bool(enable_registration),
|
||||
require_email_verification=bool(require_verification),
|
||||
).model_dump()
|
||||
|
||||
|
||||
class AuthSettingsAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""公开返回认证设置"""
|
||||
db = context.db
|
||||
|
||||
ldap_enabled = LDAPService.is_ldap_enabled(db)
|
||||
ldap_exclusive = LDAPService.is_ldap_exclusive(db)
|
||||
|
||||
return {
|
||||
"local_enabled": not ldap_exclusive,
|
||||
"ldap_enabled": ldap_enabled,
|
||||
"ldap_exclusive": ldap_exclusive,
|
||||
}
|
||||
|
||||
|
||||
class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from src.models.database import SystemConfig
|
||||
@@ -228,6 +419,12 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
detail=f"注册请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 仅允许 LDAP 登录时拒绝本地注册
|
||||
if LDAPService.is_ldap_exclusive(db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="系统已启用 LDAP 专属登录,禁止本地注册"
|
||||
)
|
||||
|
||||
allow_registration = db.query(SystemConfig).filter_by(key="enable_registration").first()
|
||||
if allow_registration and not allow_registration.value:
|
||||
AuditService.log_event(
|
||||
@@ -241,6 +438,37 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
|
||||
|
||||
# 检查邮箱后缀是否允许
|
||||
suffix_allowed, suffix_error = validate_email_suffix(db, register_request.email)
|
||||
if not suffix_allowed:
|
||||
logger.warning(f"注册失败:邮箱后缀不允许: {register_request.email}")
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
|
||||
description=f"Registration attempt rejected - email suffix not allowed: {register_request.email}",
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
metadata={"email": register_request.email, "reason": "email_suffix_not_allowed"},
|
||||
)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=suffix_error,
|
||||
)
|
||||
|
||||
# 检查是否需要邮箱验证
|
||||
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
|
||||
|
||||
if require_verification:
|
||||
# 检查邮箱是否已验证
|
||||
is_verified = await EmailVerificationService.is_email_verified(register_request.email)
|
||||
if not is_verified:
|
||||
logger.warning(f"注册失败:邮箱未验证: {register_request.email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
|
||||
)
|
||||
|
||||
try:
|
||||
user = UserService.create_user(
|
||||
db=db,
|
||||
@@ -258,7 +486,16 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
user_agent=user_agent,
|
||||
metadata={"email": user.email, "username": user.username, "role": user.role.value},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
|
||||
if require_verification:
|
||||
try:
|
||||
await EmailVerificationService.clear_verification(register_request.email)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理验证状态失败: {e}")
|
||||
|
||||
return RegisterResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
@@ -291,7 +528,7 @@ class AuthCurrentUserAdapter(AuthenticatedApiAdapter):
|
||||
"used_usd": user.used_usd,
|
||||
"total_usd": user.total_usd,
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_endpoints": user.allowed_endpoints,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
|
||||
@@ -308,8 +545,8 @@ class AuthChangePasswordAdapter(AuthenticatedApiAdapter):
|
||||
user = context.user
|
||||
if not user.verify_password(old_password):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
|
||||
if len(new_password) < 8:
|
||||
raise InvalidRequestException("密码长度至少8位")
|
||||
if len(new_password) < 6:
|
||||
raise InvalidRequestException("密码长度至少6位")
|
||||
user.set_password(new_password)
|
||||
context.db.commit()
|
||||
logger.info(f"用户修改密码: {user.email}")
|
||||
@@ -351,3 +588,177 @@ class AuthLogoutAdapter(AuthenticatedApiAdapter):
|
||||
else:
|
||||
logger.warning(f"用户登出失败(Redis不可用): {user.email}")
|
||||
return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump()
|
||||
|
||||
|
||||
class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""发送邮箱验证码"""
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
send_request = SendVerificationCodeRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = send_request.email
|
||||
|
||||
# IP 速率限制检查(验证码发送:3次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_send"
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证码发送请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 检查邮箱是否已注册
|
||||
existing_user = db.query(User).filter(User.email == email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册,请直接登录或使用其他邮箱",
|
||||
)
|
||||
|
||||
# 检查邮箱后缀是否允许
|
||||
suffix_allowed, suffix_error = validate_email_suffix(db, email)
|
||||
if not suffix_allowed:
|
||||
logger.warning(f"邮箱后缀不允许: {email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=suffix_error,
|
||||
)
|
||||
|
||||
# 生成并发送验证码(使用服务中的默认配置)
|
||||
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
|
||||
email
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(f"发送验证码失败: {email}, 错误: {code_or_error}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_detail or code_or_error,
|
||||
)
|
||||
|
||||
# 发送邮件
|
||||
expire_minutes = EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
|
||||
email_success, email_error = await EmailSenderService.send_verification_code(
|
||||
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
|
||||
)
|
||||
|
||||
if not email_success:
|
||||
logger.error(f"发送验证码邮件失败: {email}, 错误: {email_error}")
|
||||
# 不向用户暴露 SMTP 详细错误信息,防止信息泄露
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="发送验证码失败,请稍后重试",
|
||||
)
|
||||
|
||||
logger.info(f"验证码已发送: {email}")
|
||||
|
||||
return SendVerificationCodeResponse(
|
||||
message="验证码已发送,请查收邮件",
|
||||
success=True,
|
||||
expire_minutes=expire_minutes,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class AuthVerifyEmailAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""验证邮箱验证码"""
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
verify_request = VerifyEmailRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = verify_request.email
|
||||
code = verify_request.code
|
||||
|
||||
# IP 速率限制检查(验证码验证:10次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_verify"
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证码验证请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 验证验证码
|
||||
success, message = await EmailVerificationService.verify_code(email, code)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"验证码验证失败: {email}, 原因: {message}")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
logger.info(f"邮箱验证成功: {email}")
|
||||
|
||||
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()
|
||||
|
||||
|
||||
class AuthVerificationStatusAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""查询邮箱验证状态"""
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
status_request = VerificationStatusRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = status_request.email
|
||||
|
||||
# IP 速率限制检查(验证状态查询:20次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_status", limit=20
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证状态查询请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 获取验证状态
|
||||
status_data = await EmailVerificationService.get_verification_status(email)
|
||||
|
||||
# 计算冷却剩余时间
|
||||
cooldown_remaining = None
|
||||
if status_data.get("has_pending_code") and status_data.get("created_at"):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
created_at = datetime.fromisoformat(status_data["created_at"])
|
||||
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
|
||||
cooldown = EmailVerificationService.SEND_COOLDOWN_SECONDS - int(elapsed)
|
||||
if cooldown > 0:
|
||||
cooldown_remaining = cooldown
|
||||
|
||||
return VerificationStatusResponse(
|
||||
email=email,
|
||||
has_pending_code=status_data.get("has_pending_code", False),
|
||||
is_verified=status_data.get("is_verified", False),
|
||||
cooldown_remaining=cooldown_remaining,
|
||||
code_expires_in=status_data.get("code_expires_in"),
|
||||
).model_dump()
|
||||
|
||||
@@ -15,6 +15,7 @@ class ApiMode(str, Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user" # JWT 认证的普通用户(不要求管理员权限)
|
||||
PUBLIC = "public"
|
||||
MANAGEMENT = "management" # Management Token 认证
|
||||
|
||||
|
||||
class ApiAdapter(ABC):
|
||||
|
||||
@@ -10,7 +10,8 @@ from fastapi import HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ApiKey, User
|
||||
from src.models.database import ApiKey, ManagementToken, User
|
||||
from src.utils.request_utils import get_client_ip
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +38,9 @@ class ApiRequestContext:
|
||||
# URL 路径参数(如 Gemini API 的 /v1beta/models/{model}:generateContent)
|
||||
path_params: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Management Token(用于管理 API 认证)
|
||||
management_token: Optional[ManagementToken] = None
|
||||
|
||||
# 供适配器扩展的状态存储
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
audit_metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
@@ -86,7 +90,7 @@ class ApiRequestContext:
|
||||
setattr(request.state, "request_id", request_id)
|
||||
|
||||
start_time = time.time()
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
client_ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent", "unknown")
|
||||
|
||||
context = cls(
|
||||
|
||||
@@ -18,7 +18,15 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from src.config.constants import CacheTTL
|
||||
from src.core.cache_service import CacheService
|
||||
from src.core.logger import logger
|
||||
from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.models.database import (
|
||||
ApiKey,
|
||||
GlobalModel,
|
||||
Model,
|
||||
Provider,
|
||||
ProviderAPIKey,
|
||||
ProviderEndpoint,
|
||||
User,
|
||||
)
|
||||
|
||||
# 缓存 key 前缀
|
||||
_CACHE_KEY_PREFIX = "models:list"
|
||||
@@ -82,6 +90,7 @@ class ModelInfo:
|
||||
created_at: Optional[str] # ISO 格式
|
||||
created_timestamp: int # Unix 时间戳
|
||||
provider_name: str
|
||||
provider_id: str = "" # Provider ID,用于权限过滤
|
||||
# 能力配置
|
||||
streaming: bool = True
|
||||
vision: bool = False
|
||||
@@ -99,6 +108,93 @@ class ModelInfo:
|
||||
output_modalities: Optional[list[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessRestrictions:
|
||||
"""API Key 或 User 的访问限制"""
|
||||
|
||||
allowed_providers: Optional[list[str]] = None # 允许的 Provider ID 列表
|
||||
allowed_models: Optional[list[str]] = None # 允许的模型名称列表
|
||||
allowed_api_formats: Optional[list[str]] = None # 允许的 API 格式列表
|
||||
|
||||
@classmethod
|
||||
def from_api_key_and_user(
|
||||
cls, api_key: Optional[ApiKey], user: Optional[User]
|
||||
) -> "AccessRestrictions":
|
||||
"""
|
||||
从 API Key 和 User 合并访问限制
|
||||
|
||||
限制逻辑:
|
||||
- API Key 的限制优先于 User 的限制
|
||||
- 如果 API Key 有限制,使用 API Key 的限制
|
||||
- 如果 API Key 无限制但 User 有限制,使用 User 的限制
|
||||
- 两者都无限制则返回空限制
|
||||
"""
|
||||
allowed_providers: Optional[list[str]] = None
|
||||
allowed_models: Optional[list[str]] = None
|
||||
allowed_api_formats: Optional[list[str]] = None
|
||||
|
||||
# 优先使用 API Key 的限制
|
||||
if api_key:
|
||||
if api_key.allowed_providers is not None:
|
||||
allowed_providers = api_key.allowed_providers
|
||||
if api_key.allowed_models is not None:
|
||||
allowed_models = api_key.allowed_models
|
||||
if api_key.allowed_api_formats is not None:
|
||||
allowed_api_formats = api_key.allowed_api_formats
|
||||
|
||||
# 如果 API Key 没有限制,检查 User 的限制
|
||||
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
|
||||
if allowed_api_formats is None and user.allowed_api_formats is not None:
|
||||
allowed_api_formats = user.allowed_api_formats
|
||||
|
||||
return cls(
|
||||
allowed_providers=allowed_providers,
|
||||
allowed_models=allowed_models,
|
||||
allowed_api_formats=allowed_api_formats,
|
||||
)
|
||||
|
||||
def is_api_format_allowed(self, api_format: str) -> bool:
|
||||
"""
|
||||
检查 API 格式是否被允许
|
||||
|
||||
Args:
|
||||
api_format: API 格式 (如 "OPENAI", "CLAUDE", "GEMINI")
|
||||
|
||||
Returns:
|
||||
True 如果格式被允许,False 否则
|
||||
"""
|
||||
if self.allowed_api_formats is None:
|
||||
return True
|
||||
return api_format in self.allowed_api_formats
|
||||
|
||||
def is_model_allowed(self, model_id: str, provider_id: str) -> bool:
|
||||
"""
|
||||
检查模型是否被允许访问
|
||||
|
||||
Args:
|
||||
model_id: 模型 ID
|
||||
provider_id: Provider ID
|
||||
|
||||
Returns:
|
||||
True 如果模型被允许,False 否则
|
||||
"""
|
||||
# 检查 Provider 限制
|
||||
if self.allowed_providers is not None:
|
||||
if provider_id not in self.allowed_providers:
|
||||
return False
|
||||
|
||||
# 检查模型限制
|
||||
if self.allowed_models is not None:
|
||||
if model_id not in self.allowed_models:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]:
|
||||
"""
|
||||
返回有可用端点的 Provider IDs
|
||||
@@ -218,6 +314,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
|
||||
)
|
||||
created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0
|
||||
provider_name: str = model.provider.name if model.provider else "unknown"
|
||||
provider_id: str = model.provider_id or ""
|
||||
|
||||
# 从 GlobalModel.config 提取配置信息
|
||||
config: dict = {}
|
||||
@@ -233,6 +330,7 @@ def _extract_model_info(model: Any) -> ModelInfo:
|
||||
created_at=created_at,
|
||||
created_timestamp=created_timestamp,
|
||||
provider_name=provider_name,
|
||||
provider_id=provider_id,
|
||||
# 能力配置
|
||||
streaming=config.get("streaming", True),
|
||||
vision=config.get("vision", False),
|
||||
@@ -255,6 +353,7 @@ async def list_available_models(
|
||||
db: Session,
|
||||
available_provider_ids: set[str],
|
||||
api_formats: Optional[list[str]] = None,
|
||||
restrictions: Optional[AccessRestrictions] = None,
|
||||
) -> list[ModelInfo]:
|
||||
"""
|
||||
获取可用模型列表(已去重,带缓存)
|
||||
@@ -263,6 +362,7 @@ async def list_available_models(
|
||||
db: 数据库会话
|
||||
available_provider_ids: 有可用端点的 Provider ID 集合
|
||||
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
||||
restrictions: API Key/User 的访问限制
|
||||
|
||||
Returns:
|
||||
去重后的 ModelInfo 列表,按创建时间倒序
|
||||
@@ -270,8 +370,16 @@ async def list_available_models(
|
||||
if not available_provider_ids:
|
||||
return []
|
||||
|
||||
# 缓存策略:只有完全无访问限制时才使用缓存
|
||||
# - restrictions is None: 未传入限制对象
|
||||
# - restrictions 的两个字段都为 None: 传入了限制对象但无实际限制
|
||||
# 以上两种情况返回的结果相同,可以共享全局缓存
|
||||
use_cache = restrictions is None or (
|
||||
restrictions.allowed_providers is None and restrictions.allowed_models is None
|
||||
)
|
||||
|
||||
# 尝试从缓存获取
|
||||
if api_formats:
|
||||
if api_formats and use_cache:
|
||||
cached = await _get_cached_models(api_formats)
|
||||
if cached is not None:
|
||||
return cached
|
||||
@@ -306,14 +414,19 @@ async def list_available_models(
|
||||
if available_model_ids is not None and info.id not in available_model_ids:
|
||||
continue
|
||||
|
||||
# 检查 API Key/User 访问限制
|
||||
if restrictions is not None:
|
||||
if not restrictions.is_model_allowed(info.id, info.provider_id):
|
||||
continue
|
||||
|
||||
if info.id in seen_model_ids:
|
||||
continue
|
||||
seen_model_ids.add(info.id)
|
||||
|
||||
result.append(info)
|
||||
|
||||
# 写入缓存
|
||||
if api_formats:
|
||||
# 只有无限制的情况才写入缓存
|
||||
if api_formats and use_cache:
|
||||
await _set_cached_models(api_formats, result)
|
||||
|
||||
return result
|
||||
@@ -324,6 +437,7 @@ def find_model_by_id(
|
||||
model_id: str,
|
||||
available_provider_ids: set[str],
|
||||
api_formats: Optional[list[str]] = None,
|
||||
restrictions: Optional[AccessRestrictions] = None,
|
||||
) -> Optional[ModelInfo]:
|
||||
"""
|
||||
按 ID 查找模型
|
||||
@@ -338,6 +452,7 @@ def find_model_by_id(
|
||||
model_id: 模型 ID
|
||||
available_provider_ids: 有可用端点的 Provider ID 集合
|
||||
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
||||
restrictions: API Key/User 的访问限制
|
||||
|
||||
Returns:
|
||||
ModelInfo 或 None
|
||||
@@ -353,6 +468,11 @@ def find_model_by_id(
|
||||
if available_model_ids is not None and model_id not in available_model_ids:
|
||||
return None
|
||||
|
||||
# 快速检查:如果 restrictions 明确限制了模型列表且目标模型不在其中,直接返回 None
|
||||
if restrictions is not None and restrictions.allowed_models is not None:
|
||||
if model_id not in restrictions.allowed_models:
|
||||
return None
|
||||
|
||||
# 先按 GlobalModel.name 查找
|
||||
models_by_global = (
|
||||
db.query(Model)
|
||||
@@ -368,8 +488,19 @@ def find_model_by_id(
|
||||
.all()
|
||||
)
|
||||
|
||||
def is_model_accessible(m: Model) -> bool:
|
||||
"""检查模型是否可访问"""
|
||||
if m.provider_id not in available_provider_ids:
|
||||
return False
|
||||
# 检查 API Key/User 访问限制
|
||||
if restrictions is not None:
|
||||
provider_id = m.provider_id or ""
|
||||
if not restrictions.is_model_allowed(model_id, provider_id):
|
||||
return False
|
||||
return True
|
||||
|
||||
model = next(
|
||||
(m for m in models_by_global if m.provider_id in available_provider_ids),
|
||||
(m for m in models_by_global if is_model_accessible(m)),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -393,7 +524,7 @@ def find_model_by_id(
|
||||
)
|
||||
|
||||
model = next(
|
||||
(m for m in models_by_provider_name if m.provider_id in available_provider_ids),
|
||||
(m for m in models_by_provider_name if is_model_accessible(m)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,18 +2,23 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.config.settings import config
|
||||
from src.core.enums import UserRole
|
||||
from src.core.exceptions import QuotaExceededException
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ApiKey, AuditEventType, User, UserRole
|
||||
from src.models.database import ApiKey, AuditEventType, User
|
||||
from src.services.auth.service import AuthService
|
||||
from src.services.system.audit import AuditService
|
||||
from src.services.usage.service import UsageService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.database import ManagementToken
|
||||
|
||||
from .adapter import ApiAdapter, ApiMode
|
||||
from .context import ApiRequestContext
|
||||
|
||||
@@ -46,17 +51,22 @@ class ApiRequestPipeline:
|
||||
logger.debug(f"[Pipeline] Running with mode={mode}, adapter={adapter.__class__.__name__}, "
|
||||
f"adapter.mode={adapter.mode}, path={http_request.url.path}")
|
||||
if mode == ApiMode.ADMIN:
|
||||
user = await self._authenticate_admin(http_request, db)
|
||||
user, management_token = await self._authenticate_admin(http_request, db)
|
||||
api_key = None
|
||||
elif mode == ApiMode.USER:
|
||||
user = await self._authenticate_user(http_request, db)
|
||||
user, management_token = await self._authenticate_user(http_request, db)
|
||||
api_key = None
|
||||
elif mode == ApiMode.PUBLIC:
|
||||
user = None
|
||||
api_key = None
|
||||
management_token = None
|
||||
elif mode == ApiMode.MANAGEMENT:
|
||||
user, management_token = await self._authenticate_management(http_request, db)
|
||||
api_key = None
|
||||
else:
|
||||
logger.debug("[Pipeline] 调用 _authenticate_client")
|
||||
user, api_key = self._authenticate_client(http_request, db, adapter)
|
||||
management_token = None
|
||||
logger.debug(f"[Pipeline] 认证完成 | user={user.username if user else None}")
|
||||
|
||||
raw_body = None
|
||||
@@ -64,13 +74,17 @@ class ApiRequestPipeline:
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
# 添加30秒超时防止卡死
|
||||
raw_body = await asyncio.wait_for(http_request.body(), timeout=30.0)
|
||||
# 添加超时防止卡死
|
||||
raw_body = await asyncio.wait_for(
|
||||
http_request.body(), timeout=config.request_body_timeout
|
||||
)
|
||||
logger.debug(f"[Pipeline] Raw body读取完成 | size={len(raw_body) if raw_body is not None else 0} bytes")
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("读取请求体超时(30s),可能客户端未发送完整请求体")
|
||||
timeout_sec = int(config.request_body_timeout)
|
||||
logger.error(f"读取请求体超时({timeout_sec}s),可能客户端未发送完整请求体")
|
||||
raise HTTPException(
|
||||
status_code=408, detail="Request timeout: body not received within 30 seconds"
|
||||
status_code=408,
|
||||
detail=f"Request timeout: body not received within {timeout_sec} seconds",
|
||||
)
|
||||
else:
|
||||
logger.debug(f"[Pipeline] 非写请求跳过读取Body | method={http_request.method}")
|
||||
@@ -85,6 +99,9 @@ class ApiRequestPipeline:
|
||||
api_format_hint=api_format_hint,
|
||||
path_params=path_params,
|
||||
)
|
||||
# 存储 management_token 到 context(用于权限检查)
|
||||
if management_token:
|
||||
context.management_token = management_token
|
||||
logger.debug(f"[Pipeline] Context构建完成 | adapter={adapter.name} | request_id={context.request_id}")
|
||||
|
||||
if mode != ApiMode.ADMIN and user:
|
||||
@@ -172,12 +189,41 @@ class ApiRequestPipeline:
|
||||
|
||||
return user, api_key
|
||||
|
||||
async def _authenticate_admin(self, request: Request, db: Session) -> User:
|
||||
async def _authenticate_admin(
|
||||
self, request: Request, db: Session
|
||||
) -> Tuple[User, Optional["ManagementToken"]]:
|
||||
"""管理员认证,支持 JWT 和 Management Token 两种方式"""
|
||||
from src.models.database import ManagementToken
|
||||
from src.utils.request_utils import get_client_ip
|
||||
|
||||
authorization = request.headers.get("authorization")
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="缺少管理员凭证")
|
||||
|
||||
token = authorization[7:].strip()
|
||||
|
||||
# 检查是否为 Management Token(ae_ 前缀)
|
||||
if token.startswith(ManagementToken.TOKEN_PREFIX):
|
||||
client_ip = get_client_ip(request)
|
||||
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
|
||||
|
||||
user, management_token = result
|
||||
|
||||
# 检查管理员权限
|
||||
if user.role != UserRole.ADMIN:
|
||||
logger.warning(f"非管理员尝试通过 Management Token 访问管理端点: {user.email}")
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
|
||||
# 存储到 request.state
|
||||
request.state.user_id = user.id
|
||||
request.state.management_token_id = management_token.id
|
||||
|
||||
return user, management_token
|
||||
|
||||
# JWT 认证
|
||||
try:
|
||||
payload = await self.auth_service.verify_token(token, token_type="access")
|
||||
except HTTPException:
|
||||
@@ -195,16 +241,43 @@ class ApiRequestPipeline:
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户不存在或已禁用")
|
||||
|
||||
request.state.user_id = user.id
|
||||
return user
|
||||
# 检查管理员权限
|
||||
if user.role != UserRole.ADMIN:
|
||||
logger.warning(f"非管理员尝试通过 JWT 访问管理端点: {user.email}")
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
|
||||
request.state.user_id = user.id
|
||||
return user, None
|
||||
|
||||
async def _authenticate_user(
|
||||
self, request: Request, db: Session
|
||||
) -> Tuple[User, Optional["ManagementToken"]]:
|
||||
"""用户认证,支持 JWT 和 Management Token 两种方式"""
|
||||
from src.models.database import ManagementToken
|
||||
from src.utils.request_utils import get_client_ip
|
||||
|
||||
async def _authenticate_user(self, request: Request, db: Session) -> User:
|
||||
"""JWT 认证普通用户(不要求管理员权限)"""
|
||||
authorization = request.headers.get("authorization")
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="缺少用户凭证")
|
||||
|
||||
token = authorization[7:].strip()
|
||||
|
||||
# 检查是否为 Management Token(ae_ 前缀)
|
||||
if token.startswith(ManagementToken.TOKEN_PREFIX):
|
||||
client_ip = get_client_ip(request)
|
||||
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
|
||||
|
||||
user, management_token = result
|
||||
|
||||
request.state.user_id = user.id
|
||||
request.state.management_token_id = management_token.id
|
||||
|
||||
return user, management_token
|
||||
|
||||
# JWT 认证
|
||||
try:
|
||||
payload = await self.auth_service.verify_token(token, token_type="access")
|
||||
except HTTPException:
|
||||
@@ -217,13 +290,47 @@ class ApiRequestPipeline:
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="无效的用户令牌")
|
||||
|
||||
# 直接查询数据库,确保返回的是当前 Session 绑定的对象
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户不存在或已禁用")
|
||||
|
||||
request.state.user_id = user.id
|
||||
return user
|
||||
return user, None
|
||||
|
||||
async def _authenticate_management(
|
||||
self, request: Request, db: Session
|
||||
) -> Tuple[User, "ManagementToken"]:
|
||||
"""Management Token 认证"""
|
||||
from src.models.database import ManagementToken
|
||||
from src.utils.request_utils import get_client_ip
|
||||
|
||||
authorization = request.headers.get("authorization")
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="缺少 Management Token")
|
||||
|
||||
token = authorization[7:].strip()
|
||||
|
||||
# 检查是否为 Management Token 格式
|
||||
if not token.startswith(ManagementToken.TOKEN_PREFIX):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"无效的 Token 格式,需要 Management Token ({ManagementToken.TOKEN_PREFIX}xxx)",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
|
||||
|
||||
user, management_token = result
|
||||
|
||||
# 存储到 request.state
|
||||
request.state.user_id = user.id
|
||||
request.state.management_token_id = management_token.id
|
||||
|
||||
return user, management_token
|
||||
|
||||
def _calculate_quota_remaining(self, user: Optional[User]) -> Optional[float]:
|
||||
if not user:
|
||||
|
||||
@@ -45,6 +45,29 @@ def format_tokens(num: int) -> str:
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_dashboard_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取仪表盘统计数据
|
||||
|
||||
根据用户角色返回不同的统计数据。管理员可以看到全局数据,普通用户只能看到自己的数据。
|
||||
|
||||
**返回字段(管理员)**:
|
||||
- `stats`: 统计卡片数组,包含总请求、总费用、总Token、总缓存等信息
|
||||
- `today`: 今日统计(requests, cost, actual_cost, tokens, cache_creation_tokens, cache_read_tokens)
|
||||
- `api_keys`: API Key 统计(total, active)
|
||||
- `tokens`: 本月 Token 统计
|
||||
- `token_breakdown`: Token 详细分类(input, output, cache_creation, cache_read)
|
||||
- `system_health`: 系统健康指标(avg_response_time, error_rate, error_requests, fallback_count, total_requests)
|
||||
- `cost_stats`: 成本统计(total_cost, total_actual_cost, cost_savings)
|
||||
- `cache_stats`: 缓存统计信息
|
||||
- `users`: 用户统计(total, active)
|
||||
|
||||
**返回字段(普通用户)**:
|
||||
- `stats`: 统计卡片数组,包含 API 密钥、本月请求、配额使用、总Token 等信息
|
||||
- `today`: 今日统计
|
||||
- `token_breakdown`: Token 详细分类
|
||||
- `cache_stats`: 缓存统计信息
|
||||
- `monthly_cost`: 本月费用
|
||||
"""
|
||||
adapter = DashboardStatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -55,6 +78,23 @@ async def get_recent_requests(
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取最近请求列表
|
||||
|
||||
获取最近的 API 请求记录。管理员可以看到所有用户的请求,普通用户只能看到自己的请求。
|
||||
|
||||
**查询参数**:
|
||||
- `limit`: 返回记录数,默认 10,最大 100
|
||||
|
||||
**返回字段**:
|
||||
- `requests`: 请求列表,每条记录包含:
|
||||
- `id`: 请求 ID
|
||||
- `user`: 用户名
|
||||
- `model`: 使用的模型
|
||||
- `tokens`: Token 数量
|
||||
- `time`: 请求时间(HH:MM 格式)
|
||||
- `is_stream`: 是否为流式请求
|
||||
"""
|
||||
adapter = DashboardRecentRequestsAdapter(limit=limit)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -65,6 +105,17 @@ async def get_recent_requests(
|
||||
|
||||
@router.get("/provider-status")
|
||||
async def get_provider_status(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取提供商状态
|
||||
|
||||
获取所有活跃提供商的状态和最近 24 小时的请求统计。
|
||||
|
||||
**返回字段**:
|
||||
- `providers`: 提供商列表,每个提供商包含:
|
||||
- `name`: 提供商名称
|
||||
- `status`: 状态(active/inactive)
|
||||
- `requests`: 最近 24 小时的请求数
|
||||
"""
|
||||
adapter = DashboardProviderStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -75,6 +126,28 @@ async def get_daily_stats(
|
||||
days: int = Query(7, ge=1, le=30),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取每日统计数据
|
||||
|
||||
获取指定天数的每日使用统计数据,用于生成图表。
|
||||
|
||||
**查询参数**:
|
||||
- `days`: 统计天数,默认 7 天,最大 30 天
|
||||
|
||||
**返回字段**:
|
||||
- `daily_stats`: 每日统计数组,每天包含:
|
||||
- `date`: 日期(ISO 格式)
|
||||
- `requests`: 请求数
|
||||
- `tokens`: Token 数量
|
||||
- `cost`: 费用(USD)
|
||||
- `avg_response_time`: 平均响应时间(秒)
|
||||
- `unique_models`: 使用的模型数量(仅管理员)
|
||||
- `unique_providers`: 使用的提供商数量(仅管理员)
|
||||
- `fallback_count`: 故障转移次数(仅管理员)
|
||||
- `model_breakdown`: 按模型分解的统计(仅管理员)
|
||||
- `model_summary`: 模型使用汇总,按费用排序
|
||||
- `period`: 统计周期信息(start_date, end_date, days)
|
||||
"""
|
||||
adapter = DashboardDailyStatsAdapter(days=days)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
@@ -118,7 +191,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
|
||||
today = today_local.astimezone(timezone.utc)
|
||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
|
||||
# 本月第一天(自然月)
|
||||
month_start_local = today_local.replace(day=1)
|
||||
month_start = month_start_local.astimezone(timezone.utc)
|
||||
|
||||
# ==================== 使用预聚合数据 ====================
|
||||
# 从 stats_summary + 今日实时数据获取全局统计
|
||||
@@ -208,7 +283,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
|
||||
func.sum(StatsDaily.fallback_count).label("fallback_count"),
|
||||
)
|
||||
.filter(StatsDaily.date >= last_month, StatsDaily.date < today)
|
||||
.filter(StatsDaily.date >= month_start, StatsDaily.date < today)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -227,24 +302,24 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
else:
|
||||
# 回退到实时查询(没有预聚合数据时)
|
||||
total_requests = (
|
||||
db.query(func.count(Usage.id)).filter(Usage.created_at >= last_month).scalar() or 0
|
||||
db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||
)
|
||||
total_cost = (
|
||||
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= last_month).scalar() or 0
|
||||
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||
)
|
||||
total_actual_cost = (
|
||||
db.query(func.sum(Usage.actual_total_cost_usd))
|
||||
.filter(Usage.created_at >= last_month).scalar() or 0
|
||||
.filter(Usage.created_at >= month_start).scalar() or 0
|
||||
)
|
||||
error_requests = (
|
||||
db.query(func.count(Usage.id))
|
||||
.filter(
|
||||
Usage.created_at >= last_month,
|
||||
Usage.created_at >= month_start,
|
||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
|
||||
).scalar() or 0
|
||||
)
|
||||
total_tokens = (
|
||||
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= last_month).scalar() or 0
|
||||
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||
)
|
||||
cache_stats = (
|
||||
db.query(
|
||||
@@ -253,7 +328,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
||||
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
||||
)
|
||||
.filter(Usage.created_at >= last_month)
|
||||
.filter(Usage.created_at >= month_start)
|
||||
.first()
|
||||
)
|
||||
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
||||
@@ -267,7 +342,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
|
||||
)
|
||||
.filter(
|
||||
RequestCandidate.created_at >= last_month,
|
||||
RequestCandidate.created_at >= month_start,
|
||||
RequestCandidate.status.in_(["success", "failed"]),
|
||||
)
|
||||
.group_by(RequestCandidate.request_id)
|
||||
@@ -447,7 +522,9 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
# 转换为 UTC 用于数据库查询
|
||||
today = today_local.astimezone(timezone.utc)
|
||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
|
||||
# 本月第一天(自然月)
|
||||
month_start_local = today_local.replace(day=1)
|
||||
month_start = month_start_local.astimezone(timezone.utc)
|
||||
|
||||
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
|
||||
active_keys = (
|
||||
@@ -483,12 +560,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
# 本月请求统计
|
||||
user_requests = (
|
||||
db.query(func.count(Usage.id))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||
.scalar()
|
||||
)
|
||||
user_cost = (
|
||||
db.query(func.sum(Usage.total_cost_usd))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
@@ -532,18 +609,19 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
||||
func.sum(Usage.input_tokens).label("total_input_tokens"),
|
||||
)
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||
.first()
|
||||
)
|
||||
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
||||
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
|
||||
monthly_input_tokens = int(cache_stats.total_input_tokens or 0) if cache_stats else 0
|
||||
|
||||
# 计算缓存命中率:cache_read / (input_tokens + cache_read)
|
||||
# 计算本月缓存命中率:cache_read / (input_tokens + cache_read)
|
||||
# input_tokens 是实际发送给模型的输入(不含缓存读取),cache_read 是从缓存读取的
|
||||
# 总输入 = input_tokens + cache_read,缓存命中率 = cache_read / 总输入
|
||||
total_input_with_cache = all_time_input_tokens + all_time_cache_read
|
||||
total_input_with_cache = monthly_input_tokens + cache_read_tokens
|
||||
cache_hit_rate = (
|
||||
round((all_time_cache_read / total_input_with_cache) * 100, 1)
|
||||
round((cache_read_tokens / total_input_with_cache) * 100, 1)
|
||||
if total_input_with_cache > 0
|
||||
else 0
|
||||
)
|
||||
@@ -569,15 +647,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
quota_value = "无限制"
|
||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||
quota_high = False
|
||||
elif user.quota_usd and user.quota_usd > 0:
|
||||
elif user.quota_usd > 0:
|
||||
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
|
||||
quota_value = "无限制"
|
||||
quota_value = f"${user.quota_usd:.0f}"
|
||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||
quota_high = percent > 80
|
||||
else:
|
||||
quota_value = "0%"
|
||||
quota_value = "$0"
|
||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||
quota_high = False
|
||||
quota_high = True
|
||||
|
||||
return {
|
||||
"stats": [
|
||||
@@ -605,9 +683,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
"icon": "TrendingUp",
|
||||
},
|
||||
{
|
||||
"name": "本月费用",
|
||||
"value": f"${user_cost:.2f}",
|
||||
"icon": "DollarSign",
|
||||
"name": "总Token",
|
||||
"value": format_tokens(
|
||||
all_time_input_tokens
|
||||
+ all_time_output_tokens
|
||||
+ all_time_cache_creation
|
||||
+ all_time_cache_read
|
||||
),
|
||||
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
|
||||
"icon": "Hash",
|
||||
},
|
||||
],
|
||||
"today": {
|
||||
@@ -631,6 +715,8 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
||||
"cache_hit_rate": cache_hit_rate,
|
||||
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
|
||||
},
|
||||
# 本月费用(用于下方缓存区域显示)
|
||||
"monthly_cost": float(user_cost),
|
||||
}
|
||||
|
||||
|
||||
@@ -680,7 +766,7 @@ class DashboardProviderStatusAdapter(DashboardAdapter):
|
||||
for provider in providers:
|
||||
count = (
|
||||
db.query(func.count(Usage.id))
|
||||
.filter(and_(Usage.provider == provider.name, Usage.created_at >= since))
|
||||
.filter(and_(Usage.provider_name == provider.name, Usage.created_at >= since))
|
||||
.scalar()
|
||||
)
|
||||
entries.append(
|
||||
@@ -768,7 +854,7 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
|
||||
.scalar() or 0
|
||||
)
|
||||
today_unique_providers = (
|
||||
db.query(func.count(func.distinct(Usage.provider)))
|
||||
db.query(func.count(func.distinct(Usage.provider_name)))
|
||||
.filter(Usage.created_at >= today)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
@@ -47,7 +47,6 @@ if TYPE_CHECKING:
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
|
||||
|
||||
|
||||
class MessageTelemetry:
|
||||
"""
|
||||
负责记录 Usage/Audit,避免处理器里重复代码。
|
||||
@@ -406,7 +405,7 @@ class BaseMessageHandler:
|
||||
asyncio.create_task(_do_update())
|
||||
|
||||
def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None:
|
||||
"""更新 Usage 状态为 streaming,同时更新 provider 和 target_model
|
||||
"""更新 Usage 状态为 streaming,同时更新 provider 相关信息
|
||||
|
||||
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
|
||||
|
||||
@@ -414,7 +413,7 @@ class BaseMessageHandler:
|
||||
并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。
|
||||
|
||||
Args:
|
||||
ctx: 流式上下文,包含 provider_name 和 mapped_model
|
||||
ctx: 流式上下文,包含 provider 相关信息
|
||||
"""
|
||||
import asyncio
|
||||
from src.database.database import get_db
|
||||
@@ -422,6 +421,17 @@ class BaseMessageHandler:
|
||||
target_request_id = self.request_id
|
||||
provider = ctx.provider_name
|
||||
target_model = ctx.mapped_model
|
||||
provider_id = ctx.provider_id
|
||||
endpoint_id = ctx.endpoint_id
|
||||
key_id = ctx.key_id
|
||||
first_byte_time_ms = ctx.first_byte_time_ms
|
||||
|
||||
# 如果 provider 为空,记录警告(不应该发生,但用于调试)
|
||||
if not provider:
|
||||
logger.warning(
|
||||
f"[{target_request_id}] 更新 streaming 状态时 provider 为空: "
|
||||
f"ctx.provider_name={ctx.provider_name}, ctx.provider_id={ctx.provider_id}"
|
||||
)
|
||||
|
||||
async def _do_update() -> None:
|
||||
try:
|
||||
@@ -434,6 +444,10 @@ class BaseMessageHandler:
|
||||
status="streaming",
|
||||
provider=provider,
|
||||
target_model=target_model,
|
||||
provider_id=provider_id,
|
||||
provider_endpoint_id=endpoint_id,
|
||||
provider_api_key_id=key_id,
|
||||
first_byte_time_ms=first_byte_time_ms,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -40,6 +40,7 @@ from src.core.exceptions import (
|
||||
UpstreamClientException,
|
||||
)
|
||||
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.usage.recorder import UsageRecorder
|
||||
|
||||
@@ -63,6 +64,9 @@ class ChatAdapterBase(ApiAdapter):
|
||||
name: str = "chat.base"
|
||||
mode = ApiMode.STANDARD
|
||||
|
||||
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini")
|
||||
BILLING_TEMPLATE: str = "claude"
|
||||
|
||||
# 子类可以配置的特殊方法(用于check_endpoint)
|
||||
@classmethod
|
||||
def build_endpoint_url(cls, base_url: str) -> str:
|
||||
@@ -486,40 +490,6 @@ class ChatAdapterBase(ApiAdapter):
|
||||
"""
|
||||
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(
|
||||
self,
|
||||
input_tokens: int,
|
||||
@@ -537,8 +507,9 @@ class ChatAdapterBase(ApiAdapter):
|
||||
"""
|
||||
计算请求成本
|
||||
|
||||
默认实现:支持固定价格和阶梯计费
|
||||
子类可覆盖此方法实现完全不同的计费逻辑
|
||||
使用 billing 模块的配置驱动计费。
|
||||
子类可通过设置 BILLING_TEMPLATE 类属性来指定计费模板,
|
||||
或覆盖此方法实现完全自定义的计费逻辑。
|
||||
|
||||
Args:
|
||||
input_tokens: 输入 token 数
|
||||
@@ -566,88 +537,26 @@ class ChatAdapterBase(ApiAdapter):
|
||||
"tier_index": Optional[int], # 命中的阶梯索引
|
||||
}
|
||||
"""
|
||||
tier_index = None
|
||||
effective_input_price = input_price_per_1m
|
||||
effective_output_price = output_price_per_1m
|
||||
effective_cache_creation_price = cache_creation_price_per_1m
|
||||
effective_cache_read_price = cache_read_price_per_1m
|
||||
# 计算总输入上下文(使用子类可覆盖的方法)
|
||||
total_input_context = self.compute_total_input_context(
|
||||
input_tokens, cache_read_input_tokens, cache_creation_input_tokens
|
||||
)
|
||||
|
||||
# 检查阶梯计费
|
||||
if tiered_pricing and tiered_pricing.get("tiers"):
|
||||
total_input_context = self.compute_total_input_context(
|
||||
input_tokens, cache_read_input_tokens, cache_creation_input_tokens
|
||||
)
|
||||
tier = self._get_tier_for_tokens(tiered_pricing, total_input_context)
|
||||
|
||||
if tier:
|
||||
tier_index = tiered_pricing["tiers"].index(tier)
|
||||
effective_input_price = tier.get("input_price_per_1m", input_price_per_1m)
|
||||
effective_output_price = tier.get("output_price_per_1m", output_price_per_1m)
|
||||
effective_cache_creation_price = tier.get(
|
||||
"cache_creation_price_per_1m", cache_creation_price_per_1m
|
||||
)
|
||||
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
|
||||
return _calculate_request_cost(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_creation_input_tokens=cache_creation_input_tokens,
|
||||
cache_read_input_tokens=cache_read_input_tokens,
|
||||
input_price_per_1m=input_price_per_1m,
|
||||
output_price_per_1m=output_price_per_1m,
|
||||
cache_creation_price_per_1m=cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m=cache_read_price_per_1m,
|
||||
price_per_request=price_per_request,
|
||||
tiered_pricing=tiered_pricing,
|
||||
cache_ttl_minutes=cache_ttl_minutes,
|
||||
total_input_context=total_input_context,
|
||||
billing_template=self.BILLING_TEMPLATE,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 模型列表查询 - 子类应覆盖此方法
|
||||
|
||||
@@ -19,6 +19,7 @@ Chat Handler Base - Chat API 格式的通用基类
|
||||
- StreamTelemetryRecorder: 统计记录(Usage、Audit、Candidate)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, AsyncGenerator, Callable, Dict, Optional
|
||||
|
||||
@@ -36,6 +37,7 @@ from src.api.handlers.base.stream_processor import StreamProcessor
|
||||
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
|
||||
from src.api.handlers.base.utils import build_sse_headers
|
||||
from src.config.settings import config
|
||||
from src.core.error_utils import extract_error_message
|
||||
from src.core.exceptions import (
|
||||
EmbeddedErrorException,
|
||||
ProviderAuthException,
|
||||
@@ -54,7 +56,6 @@ from src.models.database import (
|
||||
from src.services.provider.transport import build_provider_url
|
||||
|
||||
|
||||
|
||||
class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
"""
|
||||
Chat Handler 基类
|
||||
@@ -88,7 +89,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
user_agent: str,
|
||||
start_time: float,
|
||||
allowed_api_formats: Optional[list] = None,
|
||||
adapter_detector: Optional[Callable[[Dict[str, str], Optional[Dict[str, Any]]], Dict[str, bool]]] = None,
|
||||
adapter_detector: Optional[
|
||||
Callable[[Dict[str, str], Optional[Dict[str, Any]]], Dict[str, bool]]
|
||||
] = None,
|
||||
):
|
||||
allowed = allowed_api_formats or [self.FORMAT_ID]
|
||||
super().__init__(
|
||||
@@ -458,14 +461,19 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
f"模型={ctx.model} -> {mapped_model or '无映射'}"
|
||||
)
|
||||
|
||||
# 发送请求(使用配置中的超时设置)
|
||||
# 配置 HTTP 超时
|
||||
# 注意:read timeout 用于检测连接断开,不是整体请求超时
|
||||
# 整体请求超时由 asyncio.wait_for 控制,使用 endpoint.timeout
|
||||
timeout_config = httpx.Timeout(
|
||||
connect=config.http_connect_timeout,
|
||||
read=float(endpoint.timeout),
|
||||
read=config.http_read_timeout, # 使用全局配置,用于检测连接断开
|
||||
write=config.http_write_timeout,
|
||||
pool=config.http_pool_timeout,
|
||||
)
|
||||
|
||||
# endpoint.timeout 作为整体请求超时(建立连接 + 获取首字节)
|
||||
request_timeout = float(endpoint.timeout or 300)
|
||||
|
||||
# 创建 HTTP 客户端(支持代理配置)
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
|
||||
@@ -473,7 +481,15 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
proxy_config=endpoint.proxy,
|
||||
timeout=timeout_config,
|
||||
)
|
||||
try:
|
||||
|
||||
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
|
||||
byte_iterator: Any = None
|
||||
prefetched_chunks: Any = None
|
||||
response_ctx: Any = None
|
||||
|
||||
async def _connect_and_prefetch() -> None:
|
||||
"""建立连接并预读首字节(受整体超时控制)"""
|
||||
nonlocal byte_iterator, prefetched_chunks, response_ctx
|
||||
response_ctx = http_client.stream(
|
||||
"POST", url, json=provider_payload, headers=provider_headers
|
||||
)
|
||||
@@ -496,15 +512,40 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
max_prefetch_lines=config.stream_prefetch_lines,
|
||||
)
|
||||
|
||||
try:
|
||||
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
|
||||
# endpoint.timeout 控制整体超时,避免上游长时间无响应
|
||||
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 整体请求超时(建立连接 + 获取首字节)
|
||||
# 清理可能已建立的连接上下文
|
||||
if response_ctx is not None:
|
||||
try:
|
||||
await response_ctx.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
await http_client.aclose()
|
||||
logger.warning(
|
||||
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
|
||||
)
|
||||
raise ProviderTimeoutException(
|
||||
provider_name=str(provider.name),
|
||||
timeout=int(request_timeout),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_text = await self._extract_error_text(e)
|
||||
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
|
||||
await http_client.aclose()
|
||||
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
|
||||
e.upstream_response = error_text # type: ignore[attr-defined]
|
||||
raise
|
||||
|
||||
except EmbeddedErrorException:
|
||||
try:
|
||||
await response_ctx.__aexit__(None, None, None)
|
||||
if response_ctx is not None:
|
||||
await response_ctx.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
await http_client.aclose()
|
||||
@@ -514,6 +555,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
await http_client.aclose()
|
||||
raise
|
||||
|
||||
# 类型断言:成功执行后这些变量不会为 None
|
||||
assert byte_iterator is not None
|
||||
assert prefetched_chunks is not None
|
||||
assert response_ctx is not None
|
||||
|
||||
# 创建流生成器(传入字节流迭代器)
|
||||
return stream_processor.create_response_stream(
|
||||
ctx,
|
||||
@@ -549,7 +595,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
model=ctx.model,
|
||||
response_time_ms=response_time_ms,
|
||||
status_code=status_code,
|
||||
error_message=str(error),
|
||||
error_message=extract_error_message(error),
|
||||
request_headers=original_headers,
|
||||
request_body=actual_request_body,
|
||||
is_stream=True,
|
||||
@@ -636,17 +682,23 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
is_stream=False,
|
||||
)
|
||||
|
||||
logger.info(f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
|
||||
f"模型={model} -> {mapped_model or '无映射'}")
|
||||
logger.info(
|
||||
f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
|
||||
f"模型={model} -> {mapped_model or '无映射'}"
|
||||
)
|
||||
logger.debug(f" [{self.request_id}] 请求URL: {url}")
|
||||
logger.debug(f" [{self.request_id}] 请求体stream字段: {provider_payload.get('stream', 'N/A')}")
|
||||
logger.debug(
|
||||
f" [{self.request_id}] 请求体stream字段: {provider_payload.get('stream', 'N/A')}"
|
||||
)
|
||||
|
||||
# 创建 HTTP 客户端(支持代理配置)
|
||||
# endpoint.timeout 作为整体请求超时
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
|
||||
request_timeout = float(endpoint.timeout or 300)
|
||||
http_client = HTTPClientPool.create_client_with_proxy(
|
||||
proxy_config=endpoint.proxy,
|
||||
timeout=httpx.Timeout(float(endpoint.timeout)),
|
||||
timeout=httpx.Timeout(request_timeout),
|
||||
)
|
||||
async with http_client:
|
||||
resp = await http_client.post(url, json=provider_payload, headers=provider_hdrs)
|
||||
@@ -667,7 +719,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
error_body = ""
|
||||
try:
|
||||
error_body = resp.text[:1000]
|
||||
logger.error(f" [{self.request_id}] 上游返回5xx错误: status={resp.status_code}, body={error_body[:500]}")
|
||||
logger.error(
|
||||
f" [{self.request_id}] 上游返回5xx错误: status={resp.status_code}, body={error_body[:500]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ProviderNotAvailableException(
|
||||
@@ -681,7 +735,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
error_body = ""
|
||||
try:
|
||||
error_body = resp.text[:1000]
|
||||
logger.warning(f" [{self.request_id}] 上游返回非200: status={resp.status_code}, body={error_body[:500]}")
|
||||
logger.warning(
|
||||
f" [{self.request_id}] 上游返回非200: status={resp.status_code}, body={error_body[:500]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ProviderNotAvailableException(
|
||||
@@ -762,8 +818,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
logger.debug(f"{self.FORMAT_ID} 非流式响应完成")
|
||||
|
||||
# 简洁的请求完成摘要
|
||||
logger.info(f"[OK] {self.request_id[:8]} | {model} | {provider_name or 'unknown'} | {response_time_ms}ms | "
|
||||
f"in:{input_tokens or 0} out:{output_tokens or 0}")
|
||||
logger.info(
|
||||
f"[OK] {self.request_id[:8]} | {model} | {provider_name or 'unknown'} | {response_time_ms}ms | "
|
||||
f"in:{input_tokens or 0} out:{output_tokens or 0}"
|
||||
)
|
||||
|
||||
return JSONResponse(status_code=status_code, content=response_json)
|
||||
|
||||
@@ -785,7 +843,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
model=model,
|
||||
response_time_ms=response_time_ms,
|
||||
status_code=status_code,
|
||||
error_message=str(e),
|
||||
error_message=extract_error_message(e),
|
||||
request_headers=original_headers,
|
||||
request_body=actual_request_body,
|
||||
is_stream=False,
|
||||
@@ -802,10 +860,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
try:
|
||||
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
|
||||
error_bytes = await e.response.aread()
|
||||
return error_bytes.decode("utf-8", errors="replace")[:500]
|
||||
return error_bytes.decode("utf-8", errors="replace")
|
||||
else:
|
||||
return (
|
||||
e.response.text[:500] if hasattr(e.response, "_content") else "Unable to read"
|
||||
)
|
||||
return e.response.text if hasattr(e.response, "_content") else "Unable to read"
|
||||
except Exception as decode_error:
|
||||
return f"Unable to read error: {decode_error}"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user