2025-12-10 20:52:44 +08:00
|
|
|
|
"""用户个人 API 端点。"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from dataclasses import dataclass
|
2026-02-04 02:04:54 +08:00
|
|
|
|
from datetime import date, datetime, timedelta, timezone
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from typing import Any
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
2026-03-12 09:33:24 +08:00
|
|
|
|
from fastapi.concurrency import run_in_threadpool
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
from sqlalchemy import and_, func
|
2025-12-15 14:30:53 +08:00
|
|
|
|
from sqlalchemy.orm import Session
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from src.api.base.context import ApiRequestContext
|
2026-03-14 11:59:07 +08:00
|
|
|
|
from src.api.base.pipeline import get_pipeline
|
2026-03-03 22:04:40 +08:00
|
|
|
|
from src.config.constants import CacheTTL
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.core.crypto import crypto_service
|
2026-03-08 00:05:48 +08:00
|
|
|
|
from src.core.enums import UserRole
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from src.core.exceptions import (
|
|
|
|
|
|
ForbiddenException,
|
|
|
|
|
|
InvalidRequestException,
|
|
|
|
|
|
NotFoundException,
|
|
|
|
|
|
translate_pydantic_error,
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.core.logger import logger
|
2026-03-12 01:11:35 +08:00
|
|
|
|
from src.core.validators import PasswordValidator
|
2026-03-12 09:33:24 +08:00
|
|
|
|
from src.database import get_db, get_db_context
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.models.api import (
|
|
|
|
|
|
ChangePasswordRequest,
|
|
|
|
|
|
CreateMyApiKeyRequest,
|
2026-01-28 01:01:05 +08:00
|
|
|
|
PublicGlobalModelListResponse,
|
|
|
|
|
|
PublicGlobalModelResponse,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
UpdateApiKeyProvidersRequest,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
UpdateMyApiKeyRequest,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
UpdatePreferencesRequest,
|
|
|
|
|
|
UpdateProfileRequest,
|
2026-03-17 16:34:09 +08:00
|
|
|
|
UpdateSessionLabelRequest,
|
|
|
|
|
|
UserSessionResponse,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
2026-02-28 11:44:08 +08:00
|
|
|
|
from src.models.database import (
|
|
|
|
|
|
ApiKey,
|
|
|
|
|
|
GlobalModel,
|
|
|
|
|
|
Model,
|
|
|
|
|
|
Provider,
|
|
|
|
|
|
Usage,
|
|
|
|
|
|
User,
|
|
|
|
|
|
UserModelUsageCount,
|
|
|
|
|
|
)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
from src.services.auth.session_service import SessionService
|
2026-03-12 09:33:24 +08:00
|
|
|
|
from src.services.cache.user_cache import UserCacheService
|
2026-03-12 01:11:35 +08:00
|
|
|
|
from src.services.system.config import SystemConfigService
|
2026-02-04 02:04:54 +08:00
|
|
|
|
from src.services.system.time_range import TimeRangeParams
|
2026-03-14 00:28:28 +08:00
|
|
|
|
from src.services.usage.query import input_context_expr
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.services.usage.service import UsageService
|
|
|
|
|
|
from src.services.user.apikey import ApiKeyService
|
2026-03-08 14:31:15 +08:00
|
|
|
|
from src.services.user.bulk_cleanup import pre_clean_api_key
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.services.user.preference import PreferenceService
|
2026-03-08 00:05:48 +08:00
|
|
|
|
from src.services.wallet import WalletService
|
2026-03-03 22:04:40 +08:00
|
|
|
|
from src.utils.cache_decorator import cache_result
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
|
2026-03-14 11:59:07 +08:00
|
|
|
|
pipeline = get_pipeline()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 00:28:28 +08:00
|
|
|
|
def _calculate_token_cache_hit_rate(total_input_context: int, cache_read_tokens: int) -> float:
|
|
|
|
|
|
"""计算缓存命中率。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-03-19 01:35:00 +08:00
|
|
|
|
total_input_context: 已归一化的总输入上下文 token 数
|
|
|
|
|
|
(由 query.py 的 input_context_expr() 统一计算,为 input + cache_read)。
|
2026-03-14 00:28:28 +08:00
|
|
|
|
cache_read_tokens: 缓存读取 token 数。
|
|
|
|
|
|
"""
|
|
|
|
|
|
context = max(0, int(total_input_context))
|
|
|
|
|
|
cached = max(0, int(cache_read_tokens))
|
|
|
|
|
|
if context == 0:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
return round(cached / context * 100, 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
def _update_profile_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
request: UpdateProfileRequest,
|
|
|
|
|
|
) -> tuple[dict[str, Any], str | None, str | None]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
raise NotFoundException("用户不存在", "user")
|
|
|
|
|
|
|
|
|
|
|
|
old_email = user.email
|
|
|
|
|
|
new_email = old_email
|
|
|
|
|
|
|
|
|
|
|
|
if request.email:
|
|
|
|
|
|
existing = (
|
|
|
|
|
|
db.query(User).filter(User.email == request.email, User.id != user.id).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
raise InvalidRequestException("邮箱已被使用")
|
|
|
|
|
|
user.email = request.email
|
|
|
|
|
|
new_email = request.email
|
|
|
|
|
|
|
|
|
|
|
|
if request.username:
|
|
|
|
|
|
existing = (
|
|
|
|
|
|
db.query(User).filter(User.username == request.username, User.id != user.id).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
raise InvalidRequestException("用户名已被使用")
|
|
|
|
|
|
user.username = request.username
|
|
|
|
|
|
|
|
|
|
|
|
user.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
return {"message": "个人信息更新成功"}, old_email, new_email
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _change_password_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
request: ChangePasswordRequest,
|
2026-03-17 16:34:09 +08:00
|
|
|
|
current_session_id: str | None = None,
|
2026-03-12 09:33:24 +08:00
|
|
|
|
) -> tuple[dict[str, Any], str | None, str]:
|
|
|
|
|
|
from src.core.enums import AuthSource
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
raise NotFoundException("用户不存在", "user")
|
|
|
|
|
|
|
|
|
|
|
|
if user.auth_source == AuthSource.LDAP:
|
|
|
|
|
|
raise ForbiddenException("LDAP 用户不能在此修改密码")
|
|
|
|
|
|
|
|
|
|
|
|
has_password = bool(user.password_hash)
|
|
|
|
|
|
if has_password:
|
|
|
|
|
|
if not request.old_password:
|
|
|
|
|
|
raise InvalidRequestException("请输入当前密码")
|
|
|
|
|
|
if not user.verify_password(request.old_password):
|
|
|
|
|
|
raise InvalidRequestException("旧密码错误")
|
2026-03-17 16:34:09 +08:00
|
|
|
|
if user.verify_password(request.new_password):
|
|
|
|
|
|
raise InvalidRequestException("新密码不能与当前密码相同")
|
2026-03-12 09:33:24 +08:00
|
|
|
|
|
|
|
|
|
|
policy_level = SystemConfigService.get_password_policy_level(db)
|
|
|
|
|
|
valid, error_msg = PasswordValidator.validate(request.new_password, policy=policy_level)
|
|
|
|
|
|
if not valid:
|
|
|
|
|
|
raise InvalidRequestException(error_msg or "密码格式无效")
|
|
|
|
|
|
|
|
|
|
|
|
user.set_password(request.new_password)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
SessionService.revoke_all_user_sessions(
|
|
|
|
|
|
db,
|
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
|
reason="password_changed",
|
|
|
|
|
|
exclude_session_id=current_session_id,
|
|
|
|
|
|
)
|
2026-03-12 09:33:24 +08:00
|
|
|
|
user.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
action = "修改" if has_password else "设置"
|
|
|
|
|
|
return {"message": f"密码{action}成功"}, user.email, action
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
def _list_user_sessions_sync(user_id: str, current_session_id: str | None) -> list[dict[str, Any]]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
sessions = SessionService.list_user_sessions(db, user_id=user_id)
|
|
|
|
|
|
return [
|
|
|
|
|
|
UserSessionResponse.from_db(s, current_session_id=current_session_id) for s in sessions
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update_session_label_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
request: UpdateSessionLabelRequest,
|
|
|
|
|
|
current_session_id: str | None,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
session = SessionService.get_session_for_user(db, user_id=user_id, session_id=session_id)
|
|
|
|
|
|
if not session:
|
|
|
|
|
|
raise NotFoundException("会话不存在", "session")
|
|
|
|
|
|
SessionService.update_session_label(session, request.device_label)
|
|
|
|
|
|
return UserSessionResponse.from_db(session, current_session_id=current_session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _revoke_session_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
session = SessionService.get_session_for_user(db, user_id=user_id, session_id=session_id)
|
|
|
|
|
|
if not session:
|
|
|
|
|
|
raise NotFoundException("会话不存在", "session")
|
|
|
|
|
|
SessionService.revoke_session(
|
|
|
|
|
|
db,
|
|
|
|
|
|
session=session,
|
|
|
|
|
|
reason="user_session_revoked",
|
|
|
|
|
|
audit_user_id=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {"message": "设备已退出登录"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _revoke_other_sessions_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
current_session_id: str | None,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
revoked_count = SessionService.revoke_all_user_sessions(
|
|
|
|
|
|
db,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
reason="logout_other_sessions",
|
|
|
|
|
|
exclude_session_id=current_session_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {"message": "其他设备已退出登录", "revoked_count": revoked_count}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
def _create_my_api_key_sync(user_id: str, request: CreateMyApiKeyRequest) -> dict[str, Any]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
try:
|
|
|
|
|
|
api_key, plain_key = ApiKeyService.create_api_key(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
name=request.name,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
rate_limit=request.rate_limit,
|
2026-03-12 09:33:24 +08:00
|
|
|
|
)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise InvalidRequestException(str(exc)) from exc
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": api_key.id,
|
|
|
|
|
|
"name": api_key.name,
|
|
|
|
|
|
"key": plain_key,
|
|
|
|
|
|
"key_display": api_key.get_display_key(),
|
2026-03-15 14:22:59 +08:00
|
|
|
|
"rate_limit": api_key.rate_limit,
|
2026-03-12 09:33:24 +08:00
|
|
|
|
"message": "API密钥创建成功",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _delete_my_api_key_sync(user_id: str, key_id: str) -> dict[str, str]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user_id).first()
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
raise NotFoundException("API密钥不存在", "api_key")
|
|
|
|
|
|
if api_key.is_locked:
|
|
|
|
|
|
raise ForbiddenException("该密钥已被管理员锁定,无法删除")
|
|
|
|
|
|
|
|
|
|
|
|
pre_clean_api_key(db, api_key.id)
|
|
|
|
|
|
db.delete(api_key)
|
|
|
|
|
|
return {"message": "API密钥已删除"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _toggle_my_api_key_sync(user_id: str, key_id: str) -> dict[str, Any]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user_id).first()
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
raise NotFoundException("API密钥不存在", "api_key")
|
|
|
|
|
|
if api_key.is_locked:
|
|
|
|
|
|
raise ForbiddenException("该密钥已被管理员锁定,无法修改状态")
|
|
|
|
|
|
|
|
|
|
|
|
api_key.is_active = not api_key.is_active
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(api_key)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": api_key.id,
|
|
|
|
|
|
"is_active": api_key.is_active,
|
|
|
|
|
|
"message": f"API密钥已{'启用' if api_key.is_active else '禁用'}",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
def _update_my_api_key_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
key_id: str,
|
|
|
|
|
|
request: UpdateMyApiKeyRequest,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
api_key = (
|
|
|
|
|
|
db.query(ApiKey)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
ApiKey.id == key_id,
|
|
|
|
|
|
ApiKey.user_id == user_id,
|
|
|
|
|
|
ApiKey.is_standalone == False,
|
|
|
|
|
|
)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
raise NotFoundException("API密钥不存在", "api_key")
|
|
|
|
|
|
if api_key.is_locked:
|
|
|
|
|
|
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
|
|
|
|
|
|
|
|
|
|
|
|
update_data = request.model_dump(exclude_unset=True)
|
|
|
|
|
|
if "rate_limit" in update_data and update_data["rate_limit"] is None:
|
|
|
|
|
|
update_data["rate_limit"] = 0
|
|
|
|
|
|
|
|
|
|
|
|
updated = ApiKeyService.update_api_key(db, key_id, **update_data)
|
|
|
|
|
|
if not updated:
|
|
|
|
|
|
raise NotFoundException("API密钥不存在", "api_key")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": updated.id,
|
|
|
|
|
|
"name": updated.name,
|
|
|
|
|
|
"key_display": updated.get_display_key(),
|
|
|
|
|
|
"is_active": updated.is_active,
|
|
|
|
|
|
"is_locked": updated.is_locked,
|
|
|
|
|
|
"allowed_providers": updated.allowed_providers,
|
|
|
|
|
|
"force_capabilities": updated.force_capabilities,
|
|
|
|
|
|
"rate_limit": updated.rate_limit,
|
|
|
|
|
|
"last_used_at": updated.last_used_at.isoformat() if updated.last_used_at else None,
|
|
|
|
|
|
"expires_at": updated.expires_at.isoformat() if updated.expires_at else None,
|
|
|
|
|
|
"created_at": updated.created_at.isoformat(),
|
|
|
|
|
|
"message": "API密钥已更新",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
def _update_api_key_providers_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
api_key_id: str,
|
|
|
|
|
|
request: UpdateApiKeyProvidersRequest,
|
|
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
api_key = (
|
|
|
|
|
|
db.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.user_id == user_id).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
raise NotFoundException("API密钥不存在")
|
|
|
|
|
|
if api_key.is_locked:
|
|
|
|
|
|
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
|
|
|
|
|
|
|
|
|
|
|
|
if request.allowed_providers is not None and len(request.allowed_providers) > 0:
|
|
|
|
|
|
provider_ids = [cfg.provider_id for cfg in request.allowed_providers]
|
|
|
|
|
|
valid = (
|
|
|
|
|
|
db.query(Provider.id)
|
|
|
|
|
|
.filter(Provider.id.in_(provider_ids), Provider.is_active.is_(True))
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
valid_ids = {p.id for p in valid}
|
|
|
|
|
|
invalid = set(provider_ids) - valid_ids
|
|
|
|
|
|
if invalid:
|
|
|
|
|
|
raise InvalidRequestException(f"无效的提供商ID: {', '.join(invalid)}")
|
|
|
|
|
|
|
|
|
|
|
|
api_key.allowed_providers = (
|
|
|
|
|
|
[cfg.provider_id for cfg in request.allowed_providers]
|
|
|
|
|
|
if request.allowed_providers is not None
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
api_key.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
return {"message": "API密钥可用提供商已更新"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update_api_key_capabilities_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
api_key_id: str,
|
|
|
|
|
|
payload: dict[str, Any],
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode
|
|
|
|
|
|
from src.models.database import AuditEventType
|
|
|
|
|
|
from src.services.system.audit import audit_service
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
api_key = (
|
|
|
|
|
|
db.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.user_id == user_id).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
raise NotFoundException("API密钥不存在")
|
|
|
|
|
|
if api_key.is_locked:
|
|
|
|
|
|
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
|
|
|
|
|
|
|
|
|
|
|
|
old_capabilities = api_key.force_capabilities
|
|
|
|
|
|
force_capabilities = payload.get("force_capabilities")
|
|
|
|
|
|
if force_capabilities is not None:
|
|
|
|
|
|
if not isinstance(force_capabilities, dict):
|
|
|
|
|
|
raise InvalidRequestException("force_capabilities 必须是对象类型")
|
|
|
|
|
|
|
|
|
|
|
|
for cap_name, cap_value in force_capabilities.items():
|
|
|
|
|
|
cap_def = CAPABILITY_DEFINITIONS.get(cap_name)
|
|
|
|
|
|
if not cap_def:
|
|
|
|
|
|
raise InvalidRequestException(f"未知的能力类型: {cap_name}")
|
|
|
|
|
|
if cap_def.config_mode != CapabilityConfigMode.USER_CONFIGURABLE:
|
|
|
|
|
|
raise InvalidRequestException(f"能力 {cap_name} 不支持用户配置")
|
|
|
|
|
|
if not isinstance(cap_value, bool):
|
|
|
|
|
|
raise InvalidRequestException(f"能力 {cap_name} 的值必须是布尔类型")
|
|
|
|
|
|
|
|
|
|
|
|
api_key.force_capabilities = force_capabilities
|
|
|
|
|
|
api_key.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
audit_service.log_event(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
event_type=AuditEventType.CONFIG_CHANGED,
|
|
|
|
|
|
description="用户更新 API Key 能力配置",
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
api_key_id=api_key.id,
|
|
|
|
|
|
metadata={
|
|
|
|
|
|
"action": "update_api_key_capabilities",
|
|
|
|
|
|
"old_capabilities": old_capabilities,
|
|
|
|
|
|
"new_capabilities": force_capabilities,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "API密钥能力配置已更新",
|
|
|
|
|
|
"force_capabilities": api_key.force_capabilities,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update_preferences_sync(user_id: str, request: UpdatePreferencesRequest) -> dict[str, str]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
PreferenceService.update_preferences(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
avatar_url=request.avatar_url,
|
|
|
|
|
|
bio=request.bio,
|
|
|
|
|
|
default_provider_id=request.default_provider_id,
|
|
|
|
|
|
theme=request.theme,
|
|
|
|
|
|
language=request.language,
|
|
|
|
|
|
timezone=request.timezone,
|
|
|
|
|
|
email_notifications=request.email_notifications,
|
|
|
|
|
|
usage_alerts=request.usage_alerts,
|
|
|
|
|
|
announcement_notifications=request.announcement_notifications,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {"message": "偏好设置更新成功"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update_model_capability_settings_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
payload: dict[str, Any],
|
|
|
|
|
|
) -> tuple[dict[str, Any], str | None]:
|
|
|
|
|
|
from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode
|
|
|
|
|
|
from src.models.database import AuditEventType
|
|
|
|
|
|
from src.services.system.audit import audit_service
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
|
|
if not user:
|
|
|
|
|
|
raise NotFoundException("用户不存在")
|
|
|
|
|
|
|
|
|
|
|
|
old_settings = user.model_capability_settings
|
|
|
|
|
|
settings = payload.get("model_capability_settings")
|
|
|
|
|
|
if settings is not None:
|
|
|
|
|
|
if not isinstance(settings, dict):
|
|
|
|
|
|
raise InvalidRequestException("model_capability_settings 必须是对象类型")
|
|
|
|
|
|
|
|
|
|
|
|
for model_name, capabilities in settings.items():
|
|
|
|
|
|
if not isinstance(model_name, str):
|
|
|
|
|
|
raise InvalidRequestException("模型名称必须是字符串")
|
|
|
|
|
|
if not isinstance(capabilities, dict):
|
|
|
|
|
|
raise InvalidRequestException(f"模型 {model_name} 的能力配置必须是对象类型")
|
|
|
|
|
|
|
|
|
|
|
|
for cap_name, cap_value in capabilities.items():
|
|
|
|
|
|
cap_def = CAPABILITY_DEFINITIONS.get(cap_name)
|
|
|
|
|
|
if not cap_def:
|
|
|
|
|
|
raise InvalidRequestException(f"未知的能力类型: {cap_name}")
|
|
|
|
|
|
if cap_def.config_mode != CapabilityConfigMode.USER_CONFIGURABLE:
|
|
|
|
|
|
raise InvalidRequestException(f"能力 {cap_name} 不支持用户配置")
|
|
|
|
|
|
if not isinstance(cap_value, bool):
|
|
|
|
|
|
raise InvalidRequestException(f"能力 {cap_name} 的值必须是布尔类型")
|
|
|
|
|
|
|
|
|
|
|
|
user.model_capability_settings = settings
|
|
|
|
|
|
user.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
audit_service.log_event(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
event_type=AuditEventType.CONFIG_CHANGED,
|
|
|
|
|
|
description="用户更新模型能力配置",
|
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
|
metadata={
|
|
|
|
|
|
"action": "update_model_capability_settings",
|
|
|
|
|
|
"old_settings": old_settings,
|
|
|
|
|
|
"new_settings": settings,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "模型能力配置已更新",
|
|
|
|
|
|
"model_capability_settings": user.model_capability_settings,
|
|
|
|
|
|
}, user.email
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
def _build_time_range_params(
|
|
|
|
|
|
start_date: date | None,
|
|
|
|
|
|
end_date: date | None,
|
|
|
|
|
|
preset: str | None,
|
|
|
|
|
|
timezone_name: str | None,
|
|
|
|
|
|
tz_offset_minutes: int | None,
|
|
|
|
|
|
) -> TimeRangeParams | None:
|
|
|
|
|
|
if not preset and start_date is None and end_date is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return TimeRangeParams(
|
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
|
preset=preset,
|
|
|
|
|
|
timezone=timezone_name,
|
|
|
|
|
|
tz_offset_minutes=tz_offset_minutes or 0,
|
|
|
|
|
|
).validate_and_resolve()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@router.get("")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取当前用户信息
|
|
|
|
|
|
|
|
|
|
|
|
返回当前登录用户的完整信息,包括基本信息和偏好设置。
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
**返回字段**: id, email, username, role, is_active, billing, preferences 等
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = MeProfileAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def update_my_profile(request: Request, db: Session = Depends(get_db)) -> None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
更新个人信息
|
|
|
|
|
|
|
|
|
|
|
|
更新当前用户的邮箱或用户名。
|
|
|
|
|
|
|
|
|
|
|
|
**请求体**:
|
|
|
|
|
|
- `email`: 新邮箱地址(可选)
|
|
|
|
|
|
- `username`: 新用户名(可选)
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = UpdateProfileAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/password")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def change_my_password(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
修改密码
|
|
|
|
|
|
|
|
|
|
|
|
修改当前用户的登录密码。
|
|
|
|
|
|
|
|
|
|
|
|
**请求体**:
|
|
|
|
|
|
- `old_password`: 当前密码
|
|
|
|
|
|
- `new_password`: 新密码(至少 6 位)
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = ChangePasswordAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
@router.get("/sessions")
|
|
|
|
|
|
async def list_my_sessions(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""列出当前用户的登录会话。"""
|
|
|
|
|
|
adapter = ListMySessionsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/sessions/others")
|
|
|
|
|
|
async def revoke_other_sessions(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""退出当前设备之外的所有登录会话。"""
|
|
|
|
|
|
adapter = RevokeOtherSessionsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/sessions/{session_id}")
|
|
|
|
|
|
async def update_my_session_label(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
) -> Any:
|
|
|
|
|
|
"""修改某个登录设备的显示名称。"""
|
|
|
|
|
|
adapter = UpdateMySessionLabelAdapter(session_id=session_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/sessions/{session_id}")
|
|
|
|
|
|
async def revoke_my_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
) -> Any:
|
|
|
|
|
|
"""退出指定登录会话。"""
|
|
|
|
|
|
adapter = RevokeMySessionAdapter(session_id=session_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
# ============== API密钥管理 ==============
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api-keys")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def list_my_api_keys(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取 API 密钥列表
|
|
|
|
|
|
|
|
|
|
|
|
返回当前用户的所有 API 密钥,包含使用统计信息。
|
|
|
|
|
|
密钥值仅显示前后几位,完整密钥需通过详情接口获取。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**: id, name, key_display, is_active, total_requests, total_cost_usd, last_used_at 等
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = ListMyApiKeysAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api-keys")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def create_my_api_key(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
创建 API 密钥
|
|
|
|
|
|
|
|
|
|
|
|
为当前用户创建新的 API 密钥。创建成功后会返回完整的密钥值,请妥善保存。
|
|
|
|
|
|
|
|
|
|
|
|
**请求体**:
|
|
|
|
|
|
- `name`: 密钥名称
|
|
|
|
|
|
|
|
|
|
|
|
**返回**: 包含完整密钥值的响应(仅此一次显示完整密钥)
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = CreateMyApiKeyAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api-keys/{key_id}")
|
|
|
|
|
|
async def get_my_api_key(
|
|
|
|
|
|
key_id: str,
|
|
|
|
|
|
request: Request,
|
2026-01-07 14:55:07 +08:00
|
|
|
|
include_key: bool = Query(False, description="是否返回完整密钥"),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db: Session = Depends(get_db),
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取 API 密钥详情
|
|
|
|
|
|
|
|
|
|
|
|
获取指定 API 密钥的详细信息。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `key_id`: 密钥 ID
|
|
|
|
|
|
|
|
|
|
|
|
**查询参数**:
|
|
|
|
|
|
- `include_key`: 设为 true 时返回完整解密后的密钥值
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if include_key:
|
|
|
|
|
|
adapter = GetMyFullKeyAdapter(key_id=key_id)
|
|
|
|
|
|
else:
|
|
|
|
|
|
adapter = GetMyApiKeyDetailAdapter(key_id=key_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/api-keys/{key_id}")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def delete_my_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
删除 API 密钥
|
|
|
|
|
|
|
|
|
|
|
|
永久删除指定的 API 密钥,删除后无法恢复。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `key_id`: 密钥 ID
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = DeleteMyApiKeyAdapter(key_id=key_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@router.put("/api-keys/{key_id}")
|
|
|
|
|
|
async def update_my_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""
|
|
|
|
|
|
更新 API 密钥
|
|
|
|
|
|
|
|
|
|
|
|
更新指定 API 密钥的基础配置。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `key_id`: 密钥 ID
|
|
|
|
|
|
"""
|
|
|
|
|
|
adapter = UpdateMyApiKeyAdapter(key_id=key_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@router.patch("/api-keys/{key_id}")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def toggle_my_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
切换 API 密钥状态
|
|
|
|
|
|
|
|
|
|
|
|
启用或禁用指定的 API 密钥。禁用后该密钥将无法用于 API 调用。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `key_id`: 密钥 ID
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = ToggleMyApiKeyAdapter(key_id=key_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============== 使用统计 ==============
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/usage")
|
|
|
|
|
|
async def get_my_usage(
|
|
|
|
|
|
request: Request,
|
2026-02-04 02:04:54 +08:00
|
|
|
|
start_date: date | None = Query(None, description="开始日期(YYYY-MM-DD)"),
|
|
|
|
|
|
end_date: date | None = Query(None, description="结束日期(YYYY-MM-DD)"),
|
|
|
|
|
|
preset: str | None = Query(None, description="时间预设(today/last7days 等)"),
|
|
|
|
|
|
timezone_name: str | None = Query(None, alias="timezone"),
|
|
|
|
|
|
tz_offset_minutes: int | None = Query(None, description="时区偏移(分钟)"),
|
2026-01-30 03:10:21 +08:00
|
|
|
|
search: str | None = Query(None, description="搜索关键词(密钥名、模型名)"),
|
2026-01-04 18:02:47 +08:00
|
|
|
|
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
|
|
|
|
|
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db: Session = Depends(get_db),
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取使用统计
|
|
|
|
|
|
|
|
|
|
|
|
获取当前用户的 API 使用统计数据,包括总量汇总、按模型/提供商分组统计及详细记录。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `total_requests`: 总请求数
|
|
|
|
|
|
- `total_tokens`: 总 Token 数
|
|
|
|
|
|
- `total_cost`: 总成本(USD)
|
2026-03-14 00:28:28 +08:00
|
|
|
|
- `summary_by_model`: 按模型分组统计(含 `cache_read_tokens`、`cache_hit_rate`)
|
|
|
|
|
|
- `summary_by_provider`: 按提供商分组统计(含 `cache_read_tokens`、`cache_hit_rate`)
|
2026-01-07 14:55:07 +08:00
|
|
|
|
- `records`: 详细使用记录列表
|
|
|
|
|
|
- `pagination`: 分页信息
|
|
|
|
|
|
"""
|
2026-02-04 02:04:54 +08:00
|
|
|
|
time_range = _build_time_range_params(
|
|
|
|
|
|
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
2026-01-05 19:32:57 +08:00
|
|
|
|
)
|
2026-02-04 02:04:54 +08:00
|
|
|
|
adapter = GetUsageAdapter(time_range=time_range, search=search, limit=limit, offset=offset)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/usage/active")
|
|
|
|
|
|
async def get_my_active_requests(
|
|
|
|
|
|
request: Request,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
ids: str | None = Query(None, description="请求 ID 列表,逗号分隔"),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db: Session = Depends(get_db),
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取活跃请求状态
|
|
|
|
|
|
|
|
|
|
|
|
查询正在进行中的请求状态,用于前端轮询更新流式请求的进度。
|
|
|
|
|
|
|
|
|
|
|
|
**查询参数**:
|
|
|
|
|
|
- `ids`: 要查询的请求 ID 列表,逗号分隔
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = GetActiveRequestsAdapter(ids=ids)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-11 17:47:59 +08:00
|
|
|
|
@router.get("/usage/interval-timeline")
|
|
|
|
|
|
async def get_my_interval_timeline(
|
|
|
|
|
|
request: Request,
|
2025-12-11 19:39:51 +08:00
|
|
|
|
hours: int = Query(24, ge=1, le=720, description="分析最近多少小时的数据"),
|
2026-03-03 22:04:40 +08:00
|
|
|
|
limit: int = Query(2000, ge=100, le=20000, description="最大返回数据点数量"),
|
2025-12-11 17:47:59 +08:00
|
|
|
|
db: Session = Depends(get_db),
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取请求间隔时间线
|
|
|
|
|
|
|
|
|
|
|
|
获取请求间隔时间线数据,用于散点图展示请求分布情况。
|
|
|
|
|
|
|
|
|
|
|
|
**返回**: 包含时间戳和间隔时间的数据点列表
|
|
|
|
|
|
"""
|
2025-12-11 17:47:59 +08:00
|
|
|
|
adapter = GetMyIntervalTimelineAdapter(hours=hours, limit=limit)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-04 22:42:58 +08:00
|
|
|
|
@router.get("/usage/heatmap")
|
|
|
|
|
|
async def get_my_activity_heatmap(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-04 22:42:58 +08:00
|
|
|
|
"""
|
2026-01-07 14:55:07 +08:00
|
|
|
|
获取活动热力图数据
|
|
|
|
|
|
|
|
|
|
|
|
获取过去 365 天的活动热力图数据,用于展示每日使用频率。
|
|
|
|
|
|
此接口有 5 分钟缓存。
|
2026-01-04 22:42:58 +08:00
|
|
|
|
|
2026-01-07 14:55:07 +08:00
|
|
|
|
**返回**: 包含日期和请求数量的数据列表
|
2026-01-04 22:42:58 +08:00
|
|
|
|
"""
|
|
|
|
|
|
adapter = GetMyActivityHeatmapAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@router.get("/providers")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def list_available_providers(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取可用提供商列表
|
|
|
|
|
|
|
|
|
|
|
|
获取当前用户可用的所有提供商及其模型信息。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**: id, name, display_name, endpoints, models 等
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = ListAvailableProvidersAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
@router.get("/available-models")
|
|
|
|
|
|
async def list_available_models(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
skip: int = Query(0, ge=0, description="跳过记录数"),
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=1000, description="返回记录数限制"),
|
2026-01-30 03:10:21 +08:00
|
|
|
|
search: str | None = Query(None, description="搜索关键词"),
|
2026-01-28 01:01:05 +08:00
|
|
|
|
db: Session = Depends(get_db),
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-28 01:01:05 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取用户可用的模型列表
|
|
|
|
|
|
|
|
|
|
|
|
根据用户权限返回可用的 GlobalModel 列表。
|
|
|
|
|
|
- 管理员:可以看到所有活跃提供商的模型
|
|
|
|
|
|
- 普通用户:只能看到关联提供商的模型
|
|
|
|
|
|
|
|
|
|
|
|
**查询参数**:
|
|
|
|
|
|
- skip: 跳过的记录数,用于分页,默认 0
|
|
|
|
|
|
- limit: 返回记录数限制,默认 100,范围 1-1000
|
|
|
|
|
|
- search: 可选,搜索关键词,支持模糊匹配模型名称
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- models: 模型列表
|
|
|
|
|
|
- total: 符合条件的模型总数
|
|
|
|
|
|
"""
|
|
|
|
|
|
adapter = ListAvailableModelsAdapter(skip=skip, limit=limit, search=search)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@router.get("/endpoint-status")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_endpoint_status(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取端点健康状态
|
|
|
|
|
|
|
|
|
|
|
|
获取各 API 格式端点的健康状态(简化版,不包含敏感信息)。
|
|
|
|
|
|
|
|
|
|
|
|
**返回**: 按 API 格式分组的端点健康状态
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = GetEndpointStatusAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============== API密钥与提供商关联 ==============
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# UpdateApiKeyProvidersRequest 已移至 src/models/api.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/api-keys/{api_key_id}/providers")
|
|
|
|
|
|
async def update_api_key_providers(
|
|
|
|
|
|
api_key_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
更新 API 密钥可用提供商
|
|
|
|
|
|
|
|
|
|
|
|
设置指定 API 密钥可以使用哪些提供商。未设置时使用用户默认权限。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `api_key_id`: API 密钥 ID
|
|
|
|
|
|
|
|
|
|
|
|
**请求体**:
|
|
|
|
|
|
- `allowed_providers`: 允许的提供商 ID 列表
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = UpdateApiKeyProvidersAdapter(api_key_id=api_key_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/api-keys/{api_key_id}/capabilities")
|
|
|
|
|
|
async def update_api_key_capabilities(
|
|
|
|
|
|
api_key_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
更新 API 密钥能力配置
|
|
|
|
|
|
|
|
|
|
|
|
设置指定 API 密钥的强制能力配置(如是否启用代码执行等)。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `api_key_id`: API 密钥 ID
|
|
|
|
|
|
|
|
|
|
|
|
**请求体**:
|
|
|
|
|
|
- `force_capabilities`: 能力配置字典,如 `{"code_execution": true}`
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = UpdateApiKeyCapabilitiesAdapter(api_key_id=api_key_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============== 偏好设置 ==============
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/preferences")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_my_preferences(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取偏好设置
|
|
|
|
|
|
|
|
|
|
|
|
获取当前用户的偏好设置,包括主题、语言、通知配置等。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**: avatar_url, bio, theme, language, timezone, notifications 等
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = GetPreferencesAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/preferences")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def update_my_preferences(request: Request, db: Session = Depends(get_db)) -> None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
更新偏好设置
|
|
|
|
|
|
|
|
|
|
|
|
更新当前用户的偏好设置。
|
|
|
|
|
|
|
|
|
|
|
|
**请求体**:
|
|
|
|
|
|
- `theme`: 主题(light/dark)
|
|
|
|
|
|
- `language`: 语言
|
|
|
|
|
|
- `timezone`: 时区
|
|
|
|
|
|
- `email_notifications`: 邮件通知开关
|
|
|
|
|
|
- `usage_alerts`: 用量告警开关
|
|
|
|
|
|
- 等
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = UpdatePreferencesAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/model-capabilities")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_model_capability_settings(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取模型能力配置
|
|
|
|
|
|
|
|
|
|
|
|
获取用户针对各模型的能力配置(如是否启用特定功能)。
|
|
|
|
|
|
|
|
|
|
|
|
**返回**: model_capability_settings 字典
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = GetModelCapabilitySettingsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/model-capabilities")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def update_model_capability_settings(request: Request, db: Session = Depends(get_db)) -> None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
更新模型能力配置
|
|
|
|
|
|
|
|
|
|
|
|
更新用户针对各模型的能力配置。
|
|
|
|
|
|
|
|
|
|
|
|
**请求体**:
|
|
|
|
|
|
- `model_capability_settings`: 模型能力配置字典,格式为 `{"model_name": {"capability": true}}`
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = UpdateModelCapabilitySettingsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============== Pipeline 适配器 ==============
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MeProfileAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""获取当前用户信息的适配器"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return PreferenceService.get_user_with_preferences(context.db, context.user.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdateProfileAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""更新用户个人信息的适配器"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
try:
|
|
|
|
|
|
request = UpdateProfileRequest.model_validate(payload)
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
|
errors = e.errors()
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
|
|
|
|
raise InvalidRequestException("请求数据验证失败")
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
result, old_email, new_email = await run_in_threadpool(
|
|
|
|
|
|
_update_profile_sync,
|
|
|
|
|
|
user.id,
|
|
|
|
|
|
request,
|
|
|
|
|
|
)
|
|
|
|
|
|
await UserCacheService.invalidate_user_cache(user.id, old_email)
|
|
|
|
|
|
if new_email and new_email != old_email:
|
|
|
|
|
|
await UserCacheService.invalidate_user_cache(user.id, new_email)
|
|
|
|
|
|
return result
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChangePasswordAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""修改用户密码的适配器"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
try:
|
|
|
|
|
|
request = ChangePasswordRequest.model_validate(payload)
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
|
errors = e.errors()
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
|
|
|
|
raise InvalidRequestException("请求数据验证失败")
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
current_session_id = getattr(context.request.state, "user_session_id", None)
|
2026-03-12 09:33:24 +08:00
|
|
|
|
result, email, action = await run_in_threadpool(
|
|
|
|
|
|
_change_password_sync,
|
|
|
|
|
|
user.id,
|
|
|
|
|
|
request,
|
2026-03-17 16:34:09 +08:00
|
|
|
|
current_session_id,
|
2026-03-12 09:33:24 +08:00
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"用户{action}密码: {email}")
|
|
|
|
|
|
return result
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
class ListMySessionsAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
current_session_id = getattr(context.request.state, "user_session_id", None)
|
|
|
|
|
|
return await run_in_threadpool(
|
|
|
|
|
|
_list_user_sessions_sync,
|
|
|
|
|
|
context.user.id,
|
|
|
|
|
|
current_session_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdateMySessionLabelAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
def __init__(self, session_id: str):
|
|
|
|
|
|
self.session_id = session_id
|
|
|
|
|
|
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
try:
|
|
|
|
|
|
request = UpdateSessionLabelRequest.model_validate(payload)
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
|
errors = e.errors()
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
|
|
|
|
raise InvalidRequestException("请求数据验证失败")
|
|
|
|
|
|
|
|
|
|
|
|
current_session_id = getattr(context.request.state, "user_session_id", None)
|
|
|
|
|
|
return await run_in_threadpool(
|
|
|
|
|
|
_update_session_label_sync,
|
|
|
|
|
|
context.user.id,
|
|
|
|
|
|
self.session_id,
|
|
|
|
|
|
request,
|
|
|
|
|
|
current_session_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RevokeMySessionAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
def __init__(self, session_id: str):
|
|
|
|
|
|
self.session_id = session_id
|
|
|
|
|
|
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
return await run_in_threadpool(
|
|
|
|
|
|
_revoke_session_sync,
|
|
|
|
|
|
context.user.id,
|
|
|
|
|
|
self.session_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RevokeOtherSessionsAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
current_session_id = getattr(context.request.state, "user_session_id", None)
|
|
|
|
|
|
return await run_in_threadpool(
|
|
|
|
|
|
_revoke_other_sessions_sync,
|
|
|
|
|
|
context.user.id,
|
|
|
|
|
|
current_session_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""获取用户 API 密钥列表的适配器"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
|
|
|
|
|
|
# 一次性查询所有 API keys
|
|
|
|
|
|
api_keys = (
|
|
|
|
|
|
db.query(ApiKey)
|
|
|
|
|
|
.filter(ApiKey.user_id == user.id)
|
|
|
|
|
|
.order_by(ApiKey.created_at.desc())
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not api_keys:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
# 批量查询所有 API keys 的统计数据(单次查询)
|
|
|
|
|
|
api_key_ids = [key.id for key in api_keys]
|
|
|
|
|
|
stats_query = (
|
|
|
|
|
|
db.query(
|
|
|
|
|
|
Usage.api_key_id,
|
|
|
|
|
|
func.count(Usage.id).label("requests"),
|
|
|
|
|
|
func.sum(Usage.total_cost_usd).label("cost"),
|
|
|
|
|
|
func.max(Usage.created_at).label("last_used"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(Usage.api_key_id.in_(api_key_ids))
|
|
|
|
|
|
.group_by(Usage.api_key_id)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建统计数据映射
|
|
|
|
|
|
stats_map = {
|
|
|
|
|
|
row.api_key_id: {
|
|
|
|
|
|
"total_requests": row.requests or 0,
|
|
|
|
|
|
"total_cost_usd": float(row.cost or 0),
|
|
|
|
|
|
"last_used_at": row.last_used,
|
|
|
|
|
|
}
|
|
|
|
|
|
for row in stats_query
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for key in api_keys:
|
|
|
|
|
|
# 从映射中获取统计,没有则使用默认值
|
|
|
|
|
|
real_stats = stats_map.get(
|
|
|
|
|
|
key.id,
|
|
|
|
|
|
{"total_requests": 0, "total_cost_usd": 0.0, "last_used_at": None},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": key.id,
|
|
|
|
|
|
"name": key.name,
|
|
|
|
|
|
"key_display": key.get_display_key(),
|
|
|
|
|
|
"is_active": key.is_active,
|
2026-01-16 01:18:54 +08:00
|
|
|
|
"is_locked": key.is_locked,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"last_used_at": (
|
|
|
|
|
|
real_stats["last_used_at"].isoformat()
|
|
|
|
|
|
if real_stats["last_used_at"]
|
|
|
|
|
|
else None
|
|
|
|
|
|
),
|
|
|
|
|
|
"created_at": key.created_at.isoformat(),
|
|
|
|
|
|
"total_requests": real_stats["total_requests"],
|
|
|
|
|
|
"total_cost_usd": real_stats["total_cost_usd"],
|
2026-03-15 14:22:59 +08:00
|
|
|
|
"rate_limit": key.rate_limit,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"allowed_providers": key.allowed_providers,
|
|
|
|
|
|
"force_capabilities": key.force_capabilities,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CreateMyApiKeyAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""创建 API 密钥的适配器"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
try:
|
|
|
|
|
|
request = CreateMyApiKeyRequest.model_validate(payload)
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
|
errors = e.errors()
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
|
|
|
|
raise InvalidRequestException("请求数据验证失败")
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return await run_in_threadpool(_create_my_api_key_sync, context.user.id, request)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class GetMyFullKeyAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""获取 API 密钥完整密钥值的适配器"""
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
key_id: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
|
|
|
|
|
|
# 查找API密钥,确保属于当前用户
|
|
|
|
|
|
api_key = (
|
|
|
|
|
|
db.query(ApiKey).filter(ApiKey.id == self.key_id, ApiKey.user_id == user.id).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
raise NotFoundException("API密钥不存在", "api_key")
|
|
|
|
|
|
|
|
|
|
|
|
# 解密完整密钥
|
|
|
|
|
|
if not api_key.key_encrypted:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="该密钥没有存储完整密钥信息")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
full_key = crypto_service.decrypt(api_key.key_encrypted)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"解密API密钥失败: Key ID {self.key_id}, 错误: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="解密密钥失败")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"用户 {user.email} 查看完整API密钥: Key ID {self.key_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"key": full_key,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class GetMyApiKeyDetailAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""获取 API 密钥详情的适配器(不包含完整密钥值)"""
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
key_id: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
|
|
|
|
|
|
api_key = (
|
|
|
|
|
|
db.query(ApiKey).filter(ApiKey.id == self.key_id, ApiKey.user_id == user.id).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
raise NotFoundException("API密钥不存在", "api_key")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": api_key.id,
|
|
|
|
|
|
"name": api_key.name,
|
|
|
|
|
|
"key_display": api_key.get_display_key(),
|
|
|
|
|
|
"is_active": api_key.is_active,
|
2026-01-16 01:18:54 +08:00
|
|
|
|
"is_locked": api_key.is_locked,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"allowed_providers": api_key.allowed_providers,
|
|
|
|
|
|
"force_capabilities": api_key.force_capabilities,
|
|
|
|
|
|
"rate_limit": api_key.rate_limit,
|
|
|
|
|
|
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
|
|
|
|
|
|
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
|
|
|
|
|
"created_at": api_key.created_at.isoformat(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class UpdateMyApiKeyAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
"""更新 API 密钥基础配置的适配器"""
|
|
|
|
|
|
|
|
|
|
|
|
key_id: str
|
|
|
|
|
|
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
try:
|
|
|
|
|
|
request = UpdateMyApiKeyRequest.model_validate(payload)
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
|
errors = e.errors()
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
|
|
|
|
raise InvalidRequestException("请求数据验证失败")
|
|
|
|
|
|
|
|
|
|
|
|
return await run_in_threadpool(
|
|
|
|
|
|
_update_my_api_key_sync, context.user.id, self.key_id, request
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class DeleteMyApiKeyAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""删除 API 密钥的适配器"""
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
key_id: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return await run_in_threadpool(_delete_my_api_key_sync, context.user.id, self.key_id)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""切换 API 密钥启用/禁用状态的适配器"""
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
key_id: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return await run_in_threadpool(_toggle_my_api_key_sync, context.user.id, self.key_id)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class GetUsageAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""获取用户使用统计的适配器"""
|
|
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
time_range: TimeRangeParams | None
|
2026-01-30 03:10:21 +08:00
|
|
|
|
search: str | None = None
|
2026-01-04 18:02:47 +08:00
|
|
|
|
limit: int = 100
|
|
|
|
|
|
offset: int = 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
@cache_result(
|
|
|
|
|
|
key_prefix="user:usage:records",
|
2026-03-04 02:24:46 +08:00
|
|
|
|
ttl=3, # 使用记录页强调实时性,避免 15s 缓存导致列表滞后
|
2026-03-03 22:04:40 +08:00
|
|
|
|
user_specific=True,
|
|
|
|
|
|
vary_by=[
|
|
|
|
|
|
"time_range.start_date",
|
|
|
|
|
|
"time_range.end_date",
|
|
|
|
|
|
"time_range.preset",
|
|
|
|
|
|
"time_range.timezone",
|
|
|
|
|
|
"time_range.tz_offset_minutes",
|
|
|
|
|
|
"search",
|
|
|
|
|
|
"limit",
|
|
|
|
|
|
"offset",
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-01-05 19:32:57 +08:00
|
|
|
|
from sqlalchemy import or_
|
2026-02-02 03:16:52 +08:00
|
|
|
|
from sqlalchemy.orm import load_only
|
2026-01-05 19:32:57 +08:00
|
|
|
|
|
2026-01-28 11:07:23 +08:00
|
|
|
|
from src.models.database import ProviderEndpoint
|
2026-01-05 19:32:57 +08:00
|
|
|
|
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
2026-02-04 02:04:54 +08:00
|
|
|
|
start_utc = end_utc = None
|
|
|
|
|
|
if self.time_range:
|
|
|
|
|
|
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
summary_list = UsageService.get_usage_summary(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
user_id=user.id,
|
2026-02-04 02:04:54 +08:00
|
|
|
|
start_date=start_utc,
|
|
|
|
|
|
end_date=end_utc,
|
2026-03-09 22:57:23 +08:00
|
|
|
|
group_by=None,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 过滤掉 unknown/pending provider 的记录(请求未到达任何提供商)
|
|
|
|
|
|
filtered_summary = [
|
2026-02-01 17:28:00 +08:00
|
|
|
|
item
|
|
|
|
|
|
for item in summary_list
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if item.get("provider") not in ("unknown", "pending", None)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
total_requests = 0
|
|
|
|
|
|
total_input_tokens = 0
|
|
|
|
|
|
total_output_tokens = 0
|
|
|
|
|
|
total_tokens = 0
|
|
|
|
|
|
total_cost = 0.0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
total_actual_cost = 0.0
|
|
|
|
|
|
model_summary = {}
|
2026-03-09 22:57:23 +08:00
|
|
|
|
provider_summary = {}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
for item in filtered_summary:
|
2026-03-09 22:57:23 +08:00
|
|
|
|
total_requests += item["requests"]
|
|
|
|
|
|
total_input_tokens += item["input_tokens"]
|
|
|
|
|
|
total_output_tokens += item["output_tokens"]
|
|
|
|
|
|
total_tokens += item["total_tokens"]
|
|
|
|
|
|
total_cost += item["total_cost_usd"]
|
|
|
|
|
|
if user.role == UserRole.ADMIN:
|
|
|
|
|
|
total_actual_cost += item.get("actual_total_cost_usd", 0.0)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
model_name = item["model"]
|
|
|
|
|
|
base_stats = {
|
|
|
|
|
|
"model": model_name,
|
|
|
|
|
|
"requests": 0,
|
|
|
|
|
|
"input_tokens": 0,
|
|
|
|
|
|
"output_tokens": 0,
|
|
|
|
|
|
"total_tokens": 0,
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"cache_read_tokens": 0,
|
2026-03-14 16:11:56 +08:00
|
|
|
|
"cache_creation_tokens": 0,
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"total_input_context": 0,
|
|
|
|
|
|
"cache_hit_rate": 0.0,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"total_cost_usd": 0.0,
|
|
|
|
|
|
}
|
|
|
|
|
|
# 管理员可以看到真实成本
|
2026-03-08 00:05:48 +08:00
|
|
|
|
if user.role == UserRole.ADMIN:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
base_stats["actual_total_cost_usd"] = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
stats = model_summary.setdefault(model_name, base_stats)
|
|
|
|
|
|
stats["requests"] += item["requests"]
|
|
|
|
|
|
stats["input_tokens"] += item["input_tokens"]
|
|
|
|
|
|
stats["output_tokens"] += item["output_tokens"]
|
|
|
|
|
|
stats["total_tokens"] += item["total_tokens"]
|
2026-03-14 00:28:28 +08:00
|
|
|
|
stats["cache_read_tokens"] += int(item.get("cache_read_tokens", 0) or 0)
|
2026-03-14 16:11:56 +08:00
|
|
|
|
stats["cache_creation_tokens"] += int(item.get("cache_creation_tokens", 0) or 0)
|
2026-03-14 00:28:28 +08:00
|
|
|
|
stats["total_input_context"] += int(item.get("total_input_context", 0) or 0)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
stats["total_cost_usd"] += item["total_cost_usd"]
|
|
|
|
|
|
# 管理员可以看到真实成本
|
2026-03-08 00:05:48 +08:00
|
|
|
|
if user.role == UserRole.ADMIN:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
stats["actual_total_cost_usd"] += item.get("actual_total_cost_usd", 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
provider_name = item["provider"]
|
2026-03-09 22:57:23 +08:00
|
|
|
|
provider_base_stats = {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"provider": provider_name,
|
|
|
|
|
|
"requests": 0,
|
|
|
|
|
|
"total_tokens": 0,
|
2026-03-14 16:11:56 +08:00
|
|
|
|
"output_tokens": 0,
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"cache_read_tokens": 0,
|
2026-03-14 16:11:56 +08:00
|
|
|
|
"cache_creation_tokens": 0,
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"total_input_context": 0,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"total_cost_usd": 0.0,
|
|
|
|
|
|
"success_count": 0,
|
|
|
|
|
|
"total_response_time_ms": 0.0,
|
|
|
|
|
|
"response_time_count": 0,
|
|
|
|
|
|
}
|
2026-03-09 22:57:23 +08:00
|
|
|
|
provider_stats = provider_summary.setdefault(provider_name, provider_base_stats)
|
|
|
|
|
|
provider_stats["requests"] += item["requests"]
|
|
|
|
|
|
provider_stats["total_tokens"] += item["total_tokens"]
|
2026-03-14 16:11:56 +08:00
|
|
|
|
provider_stats["output_tokens"] += item.get("output_tokens", 0) or 0
|
2026-03-14 00:28:28 +08:00
|
|
|
|
provider_stats["cache_read_tokens"] += int(item.get("cache_read_tokens", 0) or 0)
|
2026-03-14 16:11:56 +08:00
|
|
|
|
provider_stats["cache_creation_tokens"] += int(
|
|
|
|
|
|
item.get("cache_creation_tokens", 0) or 0
|
|
|
|
|
|
)
|
2026-03-14 00:28:28 +08:00
|
|
|
|
provider_stats["total_input_context"] += int(item.get("total_input_context", 0) or 0)
|
2026-03-09 22:57:23 +08:00
|
|
|
|
provider_stats["total_cost_usd"] += item["total_cost_usd"]
|
|
|
|
|
|
provider_stats["success_count"] += int(item.get("success_count", 0) or 0)
|
|
|
|
|
|
success_response_time_count = int(item.get("success_response_time_count", 0) or 0)
|
|
|
|
|
|
if success_response_time_count > 0:
|
|
|
|
|
|
provider_stats["total_response_time_ms"] += float(
|
|
|
|
|
|
item.get("success_response_time_sum_ms", 0.0) or 0.0
|
|
|
|
|
|
)
|
|
|
|
|
|
provider_stats["response_time_count"] += success_response_time_count
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-14 00:28:28 +08:00
|
|
|
|
for model_stats in model_summary.values():
|
|
|
|
|
|
model_stats["cache_hit_rate"] = _calculate_token_cache_hit_rate(
|
|
|
|
|
|
total_input_context=int(model_stats.get("total_input_context", 0) or 0),
|
|
|
|
|
|
cache_read_tokens=int(model_stats.get("cache_read_tokens", 0) or 0),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
summary_by_model = sorted(model_summary.values(), key=lambda x: x["requests"], reverse=True)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
summary_by_provider = []
|
2026-03-09 22:57:23 +08:00
|
|
|
|
for provider_stats in provider_summary.values():
|
2025-12-10 20:52:44 +08:00
|
|
|
|
avg_response_time_ms = (
|
2026-03-09 22:57:23 +08:00
|
|
|
|
provider_stats["total_response_time_ms"] / provider_stats["response_time_count"]
|
|
|
|
|
|
if provider_stats["response_time_count"] > 0
|
2026-02-01 17:28:00 +08:00
|
|
|
|
else 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
success_rate = (
|
2026-03-09 22:57:23 +08:00
|
|
|
|
(provider_stats["success_count"] / provider_stats["requests"] * 100)
|
|
|
|
|
|
if provider_stats["requests"] > 0
|
|
|
|
|
|
else 100
|
2026-02-01 17:28:00 +08:00
|
|
|
|
)
|
|
|
|
|
|
summary_by_provider.append(
|
|
|
|
|
|
{
|
2026-03-09 22:57:23 +08:00
|
|
|
|
"provider": provider_stats["provider"],
|
|
|
|
|
|
"requests": provider_stats["requests"],
|
|
|
|
|
|
"total_tokens": provider_stats["total_tokens"],
|
2026-03-14 16:11:56 +08:00
|
|
|
|
"total_input_context": provider_stats["total_input_context"],
|
|
|
|
|
|
"output_tokens": provider_stats["output_tokens"],
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"cache_read_tokens": provider_stats["cache_read_tokens"],
|
2026-03-14 16:11:56 +08:00
|
|
|
|
"cache_creation_tokens": provider_stats["cache_creation_tokens"],
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"cache_hit_rate": _calculate_token_cache_hit_rate(
|
|
|
|
|
|
total_input_context=int(provider_stats.get("total_input_context", 0) or 0),
|
|
|
|
|
|
cache_read_tokens=int(provider_stats.get("cache_read_tokens", 0) or 0),
|
|
|
|
|
|
),
|
2026-03-09 22:57:23 +08:00
|
|
|
|
"total_cost_usd": provider_stats["total_cost_usd"],
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"success_rate": round(success_rate, 2),
|
|
|
|
|
|
"avg_response_time_ms": round(avg_response_time_ms, 2),
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
|
|
|
|
|
|
2026-03-14 00:28:28 +08:00
|
|
|
|
# 按 api_format 聚合统计(独立查询,因为 get_usage_summary 按 provider+model 分组无此维度)
|
|
|
|
|
|
api_format_query = db.query(
|
|
|
|
|
|
Usage.api_format,
|
|
|
|
|
|
func.count(Usage.id).label("request_count"),
|
|
|
|
|
|
func.sum(Usage.total_tokens).label("total_tokens"),
|
|
|
|
|
|
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
|
|
|
|
|
func.sum(input_context_expr()).label("total_input_context"),
|
2026-03-14 16:11:56 +08:00
|
|
|
|
func.sum(Usage.output_tokens).label("output_tokens"),
|
|
|
|
|
|
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
|
2026-03-14 00:28:28 +08:00
|
|
|
|
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
|
|
|
|
|
|
func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
|
|
|
|
|
|
).filter(
|
|
|
|
|
|
Usage.user_id == user.id,
|
|
|
|
|
|
Usage.status.notin_(["pending", "streaming"]),
|
|
|
|
|
|
Usage.provider_name.notin_(["unknown", "pending"]),
|
|
|
|
|
|
Usage.api_format.isnot(None),
|
|
|
|
|
|
)
|
|
|
|
|
|
if start_utc and end_utc:
|
|
|
|
|
|
api_format_query = api_format_query.filter(
|
|
|
|
|
|
Usage.created_at >= start_utc, Usage.created_at < end_utc
|
|
|
|
|
|
)
|
|
|
|
|
|
api_format_stats = (
|
|
|
|
|
|
api_format_query.group_by(Usage.api_format).order_by(func.count(Usage.id).desc()).all()
|
|
|
|
|
|
)
|
|
|
|
|
|
summary_by_api_format = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"api_format": api_format or "unknown",
|
|
|
|
|
|
"request_count": count,
|
|
|
|
|
|
"total_tokens": int(total_tokens or 0),
|
2026-03-14 16:11:56 +08:00
|
|
|
|
"total_input_context": int(total_input_context or 0),
|
|
|
|
|
|
"output_tokens": int(output_tokens or 0),
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"cache_read_tokens": int(cache_read_tokens or 0),
|
2026-03-14 16:11:56 +08:00
|
|
|
|
"cache_creation_tokens": int(cache_creation_tokens or 0),
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"cache_hit_rate": _calculate_token_cache_hit_rate(
|
|
|
|
|
|
total_input_context=total_input_context,
|
|
|
|
|
|
cache_read_tokens=cache_read_tokens,
|
|
|
|
|
|
),
|
|
|
|
|
|
"total_cost_usd": float(total_cost_usd or 0),
|
|
|
|
|
|
"avg_response_time_ms": float(avg_response_time_ms or 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
for (
|
|
|
|
|
|
api_format,
|
|
|
|
|
|
count,
|
|
|
|
|
|
total_tokens,
|
|
|
|
|
|
cache_read_tokens,
|
|
|
|
|
|
total_input_context,
|
2026-03-14 16:11:56 +08:00
|
|
|
|
output_tokens,
|
|
|
|
|
|
cache_creation_tokens,
|
2026-03-14 00:28:28 +08:00
|
|
|
|
total_cost_usd,
|
|
|
|
|
|
avg_response_time_ms,
|
|
|
|
|
|
) in api_format_stats
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-01-05 19:32:57 +08:00
|
|
|
|
query = (
|
2026-01-28 11:07:23 +08:00
|
|
|
|
db.query(Usage, ApiKey, ProviderEndpoint)
|
2026-01-05 19:32:57 +08:00
|
|
|
|
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
2026-01-28 11:07:23 +08:00
|
|
|
|
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
2026-01-05 19:32:57 +08:00
|
|
|
|
.filter(Usage.user_id == user.id)
|
|
|
|
|
|
)
|
2026-02-04 02:04:54 +08:00
|
|
|
|
if start_utc and end_utc:
|
|
|
|
|
|
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
2026-01-04 18:02:47 +08:00
|
|
|
|
|
2026-01-05 19:32:57 +08:00
|
|
|
|
# 通用搜索:密钥名、模型名
|
|
|
|
|
|
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
|
|
|
|
|
if self.search and self.search.strip():
|
|
|
|
|
|
keywords = [kw for kw in self.search.strip().split() if kw][:10]
|
|
|
|
|
|
for keyword in keywords:
|
|
|
|
|
|
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
|
|
|
|
|
|
search_pattern = f"%{escaped}%"
|
|
|
|
|
|
query = query.filter(
|
|
|
|
|
|
or_(
|
|
|
|
|
|
ApiKey.name.ilike(search_pattern, escape="\\"),
|
|
|
|
|
|
Usage.model.ilike(search_pattern, escape="\\"),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-04 18:02:47 +08:00
|
|
|
|
# 计算总数用于分页
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# Perf: avoid Query.count() building a subquery selecting many columns
|
|
|
|
|
|
total_records = int(query.with_entities(func.count(Usage.id)).scalar() or 0)
|
|
|
|
|
|
|
|
|
|
|
|
# Perf: do not load large request/response columns for list view
|
|
|
|
|
|
query = query.options(
|
|
|
|
|
|
load_only(
|
|
|
|
|
|
Usage.id,
|
|
|
|
|
|
Usage.user_id,
|
|
|
|
|
|
Usage.api_key_id,
|
|
|
|
|
|
Usage.provider_name,
|
|
|
|
|
|
Usage.model,
|
|
|
|
|
|
Usage.target_model,
|
|
|
|
|
|
Usage.input_tokens,
|
|
|
|
|
|
Usage.output_tokens,
|
|
|
|
|
|
Usage.total_tokens,
|
|
|
|
|
|
Usage.total_cost_usd,
|
|
|
|
|
|
Usage.response_time_ms,
|
|
|
|
|
|
Usage.first_byte_time_ms,
|
|
|
|
|
|
Usage.is_stream,
|
|
|
|
|
|
Usage.status,
|
|
|
|
|
|
Usage.created_at,
|
|
|
|
|
|
Usage.cache_creation_input_tokens,
|
|
|
|
|
|
Usage.cache_read_input_tokens,
|
|
|
|
|
|
Usage.status_code,
|
|
|
|
|
|
Usage.error_message,
|
|
|
|
|
|
Usage.api_format,
|
|
|
|
|
|
Usage.endpoint_api_format,
|
|
|
|
|
|
Usage.has_format_conversion,
|
|
|
|
|
|
Usage.input_price_per_1m,
|
|
|
|
|
|
Usage.output_price_per_1m,
|
|
|
|
|
|
Usage.cache_creation_price_per_1m,
|
|
|
|
|
|
Usage.cache_read_price_per_1m,
|
|
|
|
|
|
Usage.actual_total_cost_usd,
|
|
|
|
|
|
Usage.rate_multiplier,
|
|
|
|
|
|
),
|
|
|
|
|
|
load_only(ApiKey.id, ApiKey.name, ApiKey.key_encrypted),
|
|
|
|
|
|
load_only(ProviderEndpoint.id, ProviderEndpoint.api_format),
|
|
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
usage_records = (
|
|
|
|
|
|
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
# 复用 summary 聚合中的成功请求响应时间,避免额外 AVG SQL
|
|
|
|
|
|
total_success_response_time_ms = sum(
|
|
|
|
|
|
float(item.get("success_response_time_sum_ms", 0.0) or 0.0) for item in summary_list
|
|
|
|
|
|
)
|
|
|
|
|
|
total_success_response_count = sum(
|
|
|
|
|
|
int(item.get("success_response_time_count", 0) or 0) for item in summary_list
|
|
|
|
|
|
)
|
|
|
|
|
|
avg_response_time = (
|
|
|
|
|
|
total_success_response_time_ms / total_success_response_count / 1000.0
|
|
|
|
|
|
if total_success_response_count > 0
|
|
|
|
|
|
else 0.0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
wallet = WalletService.get_wallet(db, user_id=user.id)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
# 构建响应数据
|
|
|
|
|
|
response_data = {
|
|
|
|
|
|
"total_requests": total_requests,
|
|
|
|
|
|
"total_input_tokens": total_input_tokens,
|
|
|
|
|
|
"total_output_tokens": total_output_tokens,
|
|
|
|
|
|
"total_tokens": total_tokens,
|
|
|
|
|
|
"total_cost": total_cost,
|
|
|
|
|
|
"avg_response_time": avg_response_time,
|
2026-03-08 00:05:48 +08:00
|
|
|
|
"billing": WalletService.serialize_wallet_summary(wallet),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"summary_by_model": summary_by_model,
|
2026-03-09 22:57:23 +08:00
|
|
|
|
"summary_by_provider": summary_by_provider,
|
2026-03-14 00:28:28 +08:00
|
|
|
|
"summary_by_api_format": summary_by_api_format,
|
2026-01-04 18:02:47 +08:00
|
|
|
|
# 分页信息
|
|
|
|
|
|
"pagination": {
|
|
|
|
|
|
"total": total_records,
|
|
|
|
|
|
"limit": self.limit,
|
|
|
|
|
|
"offset": self.offset,
|
|
|
|
|
|
"has_more": self.offset + self.limit < total_records,
|
|
|
|
|
|
},
|
2026-03-08 00:05:48 +08:00
|
|
|
|
"records": self._build_usage_records(
|
|
|
|
|
|
usage_records, is_admin=(user.role == UserRole.ADMIN)
|
|
|
|
|
|
),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 管理员可以看到真实成本
|
2026-03-08 00:05:48 +08:00
|
|
|
|
if user.role == UserRole.ADMIN:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
response_data["total_actual_cost"] = total_actual_cost
|
|
|
|
|
|
# 为每条记录添加真实成本和倍率信息
|
2026-01-28 11:07:23 +08:00
|
|
|
|
for i, (r, _, _) in enumerate(usage_records):
|
2025-12-10 20:52:44 +08:00
|
|
|
|
# 确保字段有值,避免前端显示 -
|
|
|
|
|
|
actual_cost = (
|
|
|
|
|
|
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
|
|
|
|
|
)
|
|
|
|
|
|
rate_mult = r.rate_multiplier if r.rate_multiplier is not None else 1.0
|
|
|
|
|
|
response_data["records"][i]["actual_cost"] = actual_cost
|
|
|
|
|
|
response_data["records"][i]["rate_multiplier"] = rate_mult
|
|
|
|
|
|
|
|
|
|
|
|
return response_data
|
|
|
|
|
|
|
2026-01-28 12:41:29 +08:00
|
|
|
|
def _build_usage_records(self, usage_records: list, is_admin: bool = False) -> list:
|
|
|
|
|
|
"""构建使用记录列表,包含格式转换信息的回填逻辑
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 12:41:29 +08:00
|
|
|
|
Args:
|
|
|
|
|
|
usage_records: 使用记录列表
|
|
|
|
|
|
is_admin: 是否为管理员,管理员可以看到模型映射信息
|
|
|
|
|
|
"""
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from src.core.api_format.metadata import can_passthrough_endpoint
|
|
|
|
|
|
from src.core.api_format.signature import normalize_signature_key
|
2026-01-28 11:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
records = []
|
|
|
|
|
|
for r, api_key, endpoint in usage_records:
|
|
|
|
|
|
# 格式转换追踪(兼容历史数据:尽量回填可展示信息)
|
|
|
|
|
|
api_format = r.api_format
|
|
|
|
|
|
endpoint_api_format = r.endpoint_api_format or (
|
|
|
|
|
|
endpoint.api_format if endpoint else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
has_format_conversion = r.has_format_conversion
|
|
|
|
|
|
if has_format_conversion is None:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
# 新模式:仅对 signature 进行推断(历史旧值保持 False,避免解析失败)
|
|
|
|
|
|
client_raw = str(api_format or "").strip()
|
|
|
|
|
|
endpoint_raw = str(endpoint_api_format or "").strip()
|
|
|
|
|
|
if client_raw and endpoint_raw and ":" in client_raw and ":" in endpoint_raw:
|
|
|
|
|
|
client_fmt = normalize_signature_key(client_raw)
|
|
|
|
|
|
endpoint_fmt = normalize_signature_key(endpoint_raw)
|
|
|
|
|
|
has_format_conversion = not can_passthrough_endpoint(client_fmt, endpoint_fmt)
|
2026-01-28 11:07:23 +08:00
|
|
|
|
else:
|
|
|
|
|
|
has_format_conversion = False
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
records.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": r.id,
|
|
|
|
|
|
"model": r.model,
|
|
|
|
|
|
# 只有管理员可以看到模型映射信息,普通用户只能看到请求的模型
|
|
|
|
|
|
"target_model": r.target_model if is_admin else None,
|
|
|
|
|
|
"api_format": api_format,
|
|
|
|
|
|
"endpoint_api_format": endpoint_api_format,
|
|
|
|
|
|
"has_format_conversion": bool(has_format_conversion),
|
|
|
|
|
|
"input_tokens": r.input_tokens,
|
|
|
|
|
|
"output_tokens": r.output_tokens,
|
|
|
|
|
|
"total_tokens": r.total_tokens,
|
2026-03-08 16:44:16 +08:00
|
|
|
|
"cost": float(r.total_cost_usd or 0),
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"response_time_ms": r.response_time_ms,
|
|
|
|
|
|
"first_byte_time_ms": r.first_byte_time_ms,
|
|
|
|
|
|
"is_stream": r.is_stream,
|
|
|
|
|
|
"status": r.status, # 请求状态: pending, streaming, completed, failed
|
|
|
|
|
|
"created_at": r.created_at.isoformat(),
|
|
|
|
|
|
"cache_creation_input_tokens": r.cache_creation_input_tokens,
|
|
|
|
|
|
"cache_read_input_tokens": r.cache_read_input_tokens,
|
|
|
|
|
|
"status_code": r.status_code,
|
|
|
|
|
|
"error_message": r.error_message,
|
|
|
|
|
|
"input_price_per_1m": r.input_price_per_1m,
|
|
|
|
|
|
"output_price_per_1m": r.output_price_per_1m,
|
|
|
|
|
|
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
|
|
|
|
|
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
|
|
|
|
|
"api_key": (
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": str(api_key.id),
|
|
|
|
|
|
"name": api_key.name,
|
|
|
|
|
|
"display": api_key.get_display_key(),
|
|
|
|
|
|
}
|
|
|
|
|
|
if api_key
|
|
|
|
|
|
else None
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-01-28 11:07:23 +08:00
|
|
|
|
return records
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class GetActiveRequestsAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
"""轻量级活跃请求状态查询适配器(用于用户端轮询)"""
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
ids: str | None = None
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-11 10:04:15 +08:00
|
|
|
|
from src.services.usage import UsageService
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
2025-12-11 10:04:15 +08:00
|
|
|
|
id_list = None
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if self.ids:
|
|
|
|
|
|
id_list = [id.strip() for id in self.ids.split(",") if id.strip()]
|
|
|
|
|
|
if not id_list:
|
|
|
|
|
|
return {"requests": []}
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
requests = UsageService.get_active_requests_status(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
ids=id_list,
|
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
|
maintain_status=True,
|
|
|
|
|
|
)
|
2025-12-11 10:04:15 +08:00
|
|
|
|
return {"requests": requests}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
2025-12-11 17:47:59 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
"""获取当前用户的请求间隔时间线适配器"""
|
|
|
|
|
|
|
|
|
|
|
|
hours: int
|
|
|
|
|
|
limit: int
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
@cache_result(
|
|
|
|
|
|
key_prefix="user:usage:interval_timeline",
|
|
|
|
|
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
|
|
|
|
|
user_specific=True,
|
|
|
|
|
|
vary_by=["hours", "limit"],
|
|
|
|
|
|
)
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-11 17:47:59 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
|
|
|
|
|
|
result = UsageService.get_interval_timeline(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
hours=self.hours,
|
|
|
|
|
|
limit=self.limit,
|
|
|
|
|
|
user_id=str(user.id),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-04 22:42:58 +08:00
|
|
|
|
class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""获取用户活动热力图数据的适配器(带 Redis 缓存)"""
|
2026-01-04 22:42:58 +08:00
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-01-04 22:42:58 +08:00
|
|
|
|
user = context.user
|
|
|
|
|
|
result = await UsageService.get_cached_heatmap(
|
|
|
|
|
|
db=context.db,
|
|
|
|
|
|
user_id=user.id,
|
2026-03-08 00:05:48 +08:00
|
|
|
|
include_actual_cost=user.role == UserRole.ADMIN,
|
2026-01-04 22:42:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
context.add_audit_metadata(action="activity_heatmap")
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
2026-01-28 01:45:19 +08:00
|
|
|
|
"""获取用户可用模型列表的适配器
|
|
|
|
|
|
|
|
|
|
|
|
考虑格式转换:如果全局格式转换启用,会包含通过格式转换可访问的模型。
|
|
|
|
|
|
这与 /v1/models API 的逻辑保持一致。
|
|
|
|
|
|
"""
|
2026-01-28 01:01:05 +08:00
|
|
|
|
|
|
|
|
|
|
skip: int
|
|
|
|
|
|
limit: int
|
2026-01-30 03:10:21 +08:00
|
|
|
|
search: str | None
|
2026-01-28 01:01:05 +08:00
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-01-28 01:01:05 +08:00
|
|
|
|
from sqlalchemy import or_
|
|
|
|
|
|
|
|
|
|
|
|
from src.api.base.models_service import AccessRestrictions
|
2026-02-03 18:48:39 +08:00
|
|
|
|
from src.services.system.config import SystemConfigService
|
2026-01-28 01:01:05 +08:00
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
|
|
|
|
|
|
# 使用 AccessRestrictions 类来处理限制(与 /v1/models 逻辑一致)
|
|
|
|
|
|
restrictions = AccessRestrictions.from_api_key_and_user(api_key=None, user=user)
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
# 检查全局格式转换开关(从数据库配置读取)
|
|
|
|
|
|
global_conversion_enabled = SystemConfigService.is_format_conversion_enabled(db)
|
2026-01-28 01:01:05 +08:00
|
|
|
|
|
2026-01-28 01:45:19 +08:00
|
|
|
|
# 获取所有可用的 Provider ID(考虑格式转换)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
available_provider_ids = self._get_all_available_provider_ids(db, global_conversion_enabled)
|
2026-01-28 01:45:19 +08:00
|
|
|
|
|
|
|
|
|
|
if not available_provider_ids:
|
2026-01-28 01:01:05 +08:00
|
|
|
|
return {"models": [], "total": 0}
|
|
|
|
|
|
|
|
|
|
|
|
# 查询所有活跃的 GlobalModel 及其关联的 Model
|
|
|
|
|
|
id_query = (
|
|
|
|
|
|
db.query(GlobalModel.id, GlobalModel.name, Model.provider_id)
|
|
|
|
|
|
.join(Model, Model.global_model_id == GlobalModel.id)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
and_(
|
2026-01-28 01:45:19 +08:00
|
|
|
|
Model.provider_id.in_(available_provider_ids),
|
2026-01-28 01:01:05 +08:00
|
|
|
|
Model.is_active == True,
|
|
|
|
|
|
GlobalModel.is_active == True,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 搜索过滤
|
|
|
|
|
|
if self.search:
|
|
|
|
|
|
search_term = f"%{self.search}%"
|
|
|
|
|
|
id_query = id_query.filter(
|
|
|
|
|
|
or_(
|
|
|
|
|
|
GlobalModel.name.ilike(search_term),
|
|
|
|
|
|
GlobalModel.display_name.ilike(search_term),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取所有匹配的记录
|
|
|
|
|
|
all_matches = id_query.all()
|
|
|
|
|
|
|
|
|
|
|
|
# 应用访问限制过滤
|
|
|
|
|
|
allowed_global_model_ids = set()
|
|
|
|
|
|
for global_model_id, model_name, provider_id in all_matches:
|
|
|
|
|
|
# 使用 AccessRestrictions.is_model_allowed 检查模型是否可访问
|
|
|
|
|
|
# 它会同时检查 allowed_providers 和 allowed_models
|
|
|
|
|
|
if restrictions.is_model_allowed(model_name, provider_id):
|
|
|
|
|
|
allowed_global_model_ids.add(global_model_id)
|
|
|
|
|
|
|
|
|
|
|
|
# 统计总数
|
|
|
|
|
|
total = len(allowed_global_model_ids)
|
|
|
|
|
|
|
|
|
|
|
|
if not allowed_global_model_ids:
|
|
|
|
|
|
return {"models": [], "total": 0}
|
|
|
|
|
|
|
|
|
|
|
|
# 分页并获取完整的 GlobalModel 对象
|
|
|
|
|
|
models = (
|
|
|
|
|
|
db.query(GlobalModel)
|
|
|
|
|
|
.filter(GlobalModel.id.in_(allowed_global_model_ids))
|
|
|
|
|
|
.order_by(GlobalModel.name)
|
|
|
|
|
|
.offset(self.skip)
|
|
|
|
|
|
.limit(self.limit)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
# 查询当前用户的每模型调用次数
|
|
|
|
|
|
user_usage_rows = (
|
|
|
|
|
|
db.query(UserModelUsageCount.model, UserModelUsageCount.usage_count)
|
|
|
|
|
|
.filter(UserModelUsageCount.user_id == user.id)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
user_usage_map: dict[str, int] = {row.model: row.usage_count for row in user_usage_rows}
|
|
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
# 转换为响应格式(复用 PublicGlobalModelResponse schema)
|
|
|
|
|
|
model_responses = [
|
|
|
|
|
|
PublicGlobalModelResponse(
|
|
|
|
|
|
id=gm.id,
|
|
|
|
|
|
name=gm.name,
|
|
|
|
|
|
display_name=gm.display_name,
|
|
|
|
|
|
is_active=gm.is_active,
|
|
|
|
|
|
default_price_per_request=gm.default_price_per_request,
|
|
|
|
|
|
default_tiered_pricing=gm.default_tiered_pricing,
|
|
|
|
|
|
supported_capabilities=gm.supported_capabilities,
|
|
|
|
|
|
config=gm.config,
|
2026-02-28 11:44:08 +08:00
|
|
|
|
usage_count=user_usage_map.get(gm.name, 0),
|
2026-01-28 01:01:05 +08:00
|
|
|
|
)
|
|
|
|
|
|
for gm in models
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
logger.debug(f"用户 {user.email} 可用模型: {len(model_responses)} 个")
|
|
|
|
|
|
return PublicGlobalModelListResponse(models=model_responses, total=total)
|
|
|
|
|
|
|
2026-01-28 01:45:19 +08:00
|
|
|
|
def _get_all_available_provider_ids(
|
|
|
|
|
|
self, db: Session, global_conversion_enabled: bool
|
|
|
|
|
|
) -> set[str]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取所有可用的 Provider ID(考虑格式转换)
|
|
|
|
|
|
|
|
|
|
|
|
用户模型目录需要显示通过任何客户端格式(OPENAI/CLAUDE/GEMINI)可访问的模型并集。
|
|
|
|
|
|
与 /v1/models 逻辑一致,确保返回的 Provider 都有活跃的端点和 Key。
|
|
|
|
|
|
|
|
|
|
|
|
优化:将 DB 查询从 6 次减少到 2 次
|
|
|
|
|
|
- 一次性查询所有活跃端点
|
|
|
|
|
|
- 在内存中进行格式兼容性过滤
|
|
|
|
|
|
- 一次性查询 Key 可用性
|
|
|
|
|
|
"""
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from sqlalchemy import tuple_
|
|
|
|
|
|
|
2026-01-28 01:45:19 +08:00
|
|
|
|
from src.api.base.models_service import get_available_provider_ids
|
|
|
|
|
|
from src.core.api_format.conversion.compatibility import is_format_compatible
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from src.core.api_format.signature import make_signature_key
|
2026-01-28 01:45:19 +08:00
|
|
|
|
from src.models.database import ProviderEndpoint
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
# 所有 Chat/CLI endpoint signature(用于计算“可访问并集”)
|
2026-01-28 12:06:48 +08:00
|
|
|
|
all_formats = [
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"openai:chat",
|
|
|
|
|
|
"openai:cli",
|
2026-03-01 23:55:26 +08:00
|
|
|
|
"openai:compact",
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"claude:chat",
|
|
|
|
|
|
"claude:cli",
|
|
|
|
|
|
"gemini:chat",
|
|
|
|
|
|
"gemini:cli",
|
2026-01-28 12:06:48 +08:00
|
|
|
|
]
|
2026-01-28 01:45:19 +08:00
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
target_pairs = [(f.split(":", 1)[0], f.split(":", 1)[1]) for f in all_formats]
|
|
|
|
|
|
|
2026-01-28 01:45:19 +08:00
|
|
|
|
# 步骤 1:一次性查询所有活跃端点(单次 DB 查询)
|
|
|
|
|
|
endpoint_rows = (
|
|
|
|
|
|
db.query(
|
|
|
|
|
|
ProviderEndpoint.provider_id,
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ProviderEndpoint.api_family,
|
|
|
|
|
|
ProviderEndpoint.endpoint_kind,
|
2026-01-28 01:45:19 +08:00
|
|
|
|
ProviderEndpoint.format_acceptance_config,
|
2026-02-06 23:01:56 +08:00
|
|
|
|
Provider.enable_format_conversion,
|
2026-01-28 01:45:19 +08:00
|
|
|
|
)
|
|
|
|
|
|
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
Provider.is_active.is_(True),
|
|
|
|
|
|
ProviderEndpoint.is_active.is_(True),
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ProviderEndpoint.api_family.isnot(None),
|
|
|
|
|
|
ProviderEndpoint.endpoint_kind.isnot(None),
|
|
|
|
|
|
tuple_(ProviderEndpoint.api_family, ProviderEndpoint.endpoint_kind).in_(
|
|
|
|
|
|
target_pairs
|
|
|
|
|
|
),
|
2026-01-28 01:45:19 +08:00
|
|
|
|
)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not endpoint_rows:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
|
|
|
|
|
|
# 步骤 2:在内存中对每种客户端格式进行兼容性过滤
|
|
|
|
|
|
# 只要端点能被任意一种客户端格式访问,就将其 Provider 加入结果
|
|
|
|
|
|
provider_to_formats: dict[str, set[str]] = {}
|
|
|
|
|
|
|
2026-02-06 23:01:56 +08:00
|
|
|
|
for (
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
api_family,
|
|
|
|
|
|
endpoint_kind,
|
|
|
|
|
|
format_acceptance_config,
|
|
|
|
|
|
provider_conversion_enabled,
|
|
|
|
|
|
) in endpoint_rows:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
if not provider_id or not api_family or not endpoint_kind:
|
2026-01-28 01:45:19 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
endpoint_format = make_signature_key(str(api_family), str(endpoint_kind))
|
2026-02-06 23:01:56 +08:00
|
|
|
|
skip_endpoint_check = global_conversion_enabled or bool(provider_conversion_enabled)
|
2026-01-28 01:45:19 +08:00
|
|
|
|
|
|
|
|
|
|
# 检查该端点是否能被任意客户端格式访问
|
|
|
|
|
|
for client_format in all_formats:
|
|
|
|
|
|
is_compatible, _, _ = is_format_compatible(
|
|
|
|
|
|
client_format,
|
2026-02-01 17:28:00 +08:00
|
|
|
|
endpoint_format,
|
2026-01-28 01:45:19 +08:00
|
|
|
|
format_acceptance_config,
|
|
|
|
|
|
is_stream=False,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
effective_conversion_enabled=global_conversion_enabled,
|
2026-02-06 23:01:56 +08:00
|
|
|
|
skip_endpoint_check=skip_endpoint_check,
|
2026-01-28 01:45:19 +08:00
|
|
|
|
)
|
|
|
|
|
|
if is_compatible:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
provider_to_formats.setdefault(provider_id, set()).add(endpoint_format)
|
2026-01-28 01:45:19 +08:00
|
|
|
|
break # 只要有一种客户端格式能访问就够了
|
|
|
|
|
|
|
|
|
|
|
|
if not provider_to_formats:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
|
|
|
|
|
|
# 步骤 3:检查 Provider 是否有活跃的 Key(单次 DB 查询)
|
|
|
|
|
|
formats = sorted({f for fmts in provider_to_formats.values() for f in fmts})
|
|
|
|
|
|
return get_available_provider_ids(db, formats, provider_to_formats)
|
|
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""获取可用提供商列表的适配器"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
|
|
|
|
|
|
# 使用 selectinload 预加载所有关联数据,避免 N+1 查询
|
|
|
|
|
|
providers = (
|
|
|
|
|
|
db.query(Provider)
|
|
|
|
|
|
.options(
|
|
|
|
|
|
selectinload(Provider.endpoints),
|
|
|
|
|
|
selectinload(Provider.models).selectinload(Model.global_model),
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(Provider.is_active.is_(True))
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for provider in providers:
|
|
|
|
|
|
# 直接使用预加载的 endpoints,无需额外查询
|
|
|
|
|
|
endpoints_data = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": ep.id,
|
|
|
|
|
|
"api_format": ep.api_format if ep.api_format else None,
|
|
|
|
|
|
"base_url": ep.base_url,
|
|
|
|
|
|
"is_active": ep.is_active,
|
|
|
|
|
|
}
|
|
|
|
|
|
for ep in provider.endpoints
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
models_data = []
|
|
|
|
|
|
# 直接使用预加载的 models,无需额外查询
|
|
|
|
|
|
direct_models = provider.models
|
|
|
|
|
|
for model in direct_models:
|
|
|
|
|
|
global_model = model.global_model
|
|
|
|
|
|
display_name = (
|
|
|
|
|
|
global_model.display_name if global_model else model.provider_model_name
|
|
|
|
|
|
)
|
|
|
|
|
|
unified_name = global_model.name if global_model else model.provider_model_name
|
|
|
|
|
|
models_data.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": model.id,
|
|
|
|
|
|
"name": unified_name,
|
|
|
|
|
|
"display_name": display_name,
|
|
|
|
|
|
"input_price_per_1m": model.input_price_per_1m,
|
|
|
|
|
|
"output_price_per_1m": model.output_price_per_1m,
|
|
|
|
|
|
"cache_creation_price_per_1m": model.cache_creation_price_per_1m,
|
|
|
|
|
|
"cache_read_price_per_1m": model.cache_read_price_per_1m,
|
|
|
|
|
|
"supports_vision": model.supports_vision,
|
|
|
|
|
|
"supports_function_calling": model.supports_function_calling,
|
|
|
|
|
|
"supports_streaming": model.supports_streaming,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": provider.id,
|
|
|
|
|
|
"name": provider.name,
|
|
|
|
|
|
"description": provider.description,
|
|
|
|
|
|
"provider_priority": provider.provider_priority,
|
|
|
|
|
|
"endpoints": endpoints_data,
|
|
|
|
|
|
"models": models_data,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class UpdateApiKeyProvidersAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""更新 API 密钥可用提供商的适配器"""
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
api_key_id: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
try:
|
|
|
|
|
|
request = UpdateApiKeyProvidersRequest.model_validate(payload)
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
|
errors = e.errors()
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
|
|
|
|
raise InvalidRequestException("请求数据验证失败")
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
result = await run_in_threadpool(
|
|
|
|
|
|
_update_api_key_providers_sync,
|
|
|
|
|
|
user.id,
|
|
|
|
|
|
self.api_key_id,
|
|
|
|
|
|
request,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
logger.debug(f"用户 {user.id} 更新API密钥 {self.api_key_id} 的可用提供商")
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return result
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
"""更新 API Key 的强制能力配置"""
|
|
|
|
|
|
|
|
|
|
|
|
api_key_id: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode
|
|
|
|
|
|
from src.models.database import AuditEventType
|
|
|
|
|
|
from src.services.system.audit import audit_service
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
user = context.user
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
result = await run_in_threadpool(
|
|
|
|
|
|
_update_api_key_capabilities_sync,
|
|
|
|
|
|
user.id,
|
|
|
|
|
|
self.api_key_id,
|
|
|
|
|
|
payload,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
logger.debug(
|
2026-03-12 09:33:24 +08:00
|
|
|
|
f"用户 {user.id} 更新API密钥 {self.api_key_id} 的强制能力配置: {result['force_capabilities']}"
|
2026-02-01 17:28:00 +08:00
|
|
|
|
)
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return result
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GetPreferencesAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""获取用户偏好设置的适配器"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
preferences = PreferenceService.get_or_create_preferences(context.db, context.user.id)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"avatar_url": preferences.avatar_url,
|
|
|
|
|
|
"bio": preferences.bio,
|
|
|
|
|
|
"default_provider_id": preferences.default_provider_id,
|
|
|
|
|
|
"default_provider": (
|
|
|
|
|
|
preferences.default_provider.name if preferences.default_provider else None
|
|
|
|
|
|
),
|
|
|
|
|
|
"theme": preferences.theme,
|
|
|
|
|
|
"language": preferences.language,
|
|
|
|
|
|
"timezone": preferences.timezone,
|
|
|
|
|
|
"notifications": {
|
|
|
|
|
|
"email": preferences.email_notifications,
|
|
|
|
|
|
"usage_alerts": preferences.usage_alerts,
|
|
|
|
|
|
"announcements": preferences.announcement_notifications,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdatePreferencesAdapter(AuthenticatedApiAdapter):
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""更新用户偏好设置的适配器"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
try:
|
|
|
|
|
|
request = UpdatePreferencesRequest.model_validate(payload)
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
|
errors = e.errors()
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
|
|
|
|
raise InvalidRequestException("请求数据验证失败")
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return await run_in_threadpool(_update_preferences_sync, context.user.id, request)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GetModelCapabilitySettingsAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
"""获取用户的模型能力配置"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
user = context.user
|
|
|
|
|
|
return {
|
|
|
|
|
|
"model_capability_settings": user.model_capability_settings or {},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdateModelCapabilitySettingsAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
"""更新用户的模型能力配置"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode
|
|
|
|
|
|
from src.models.database import AuditEventType
|
|
|
|
|
|
from src.services.system.audit import audit_service
|
|
|
|
|
|
|
|
|
|
|
|
payload = context.ensure_json_body()
|
2026-03-12 09:33:24 +08:00
|
|
|
|
result, email = await run_in_threadpool(
|
|
|
|
|
|
_update_model_capability_settings_sync,
|
|
|
|
|
|
context.user.id,
|
|
|
|
|
|
payload,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
2026-03-12 09:33:24 +08:00
|
|
|
|
await UserCacheService.invalidate_user_cache(context.user.id, email)
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
f"用户 {context.user.id} 更新模型能力配置: {result['model_capability_settings']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return result
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GetEndpointStatusAdapter(AuthenticatedApiAdapter):
|
|
|
|
|
|
"""获取端点状态(简化版,不包含敏感信息)"""
|
|
|
|
|
|
|
|
|
|
|
|
# 类级别缓存实例(延迟初始化)
|
|
|
|
|
|
_cache_backend = None
|
|
|
|
|
|
_cache_ttl = 60 # 缓存60秒
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def _get_cache(cls) -> Any:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""获取缓存后端实例(懒加载)"""
|
|
|
|
|
|
if cls._cache_backend is None:
|
|
|
|
|
|
from src.services.cache.backend import get_cache_backend
|
|
|
|
|
|
|
|
|
|
|
|
cls._cache_backend = await get_cache_backend(
|
|
|
|
|
|
name="endpoint_status",
|
|
|
|
|
|
backend_type="auto",
|
|
|
|
|
|
ttl=cls._cache_ttl, # 使用 ttl 而不是 default_ttl
|
|
|
|
|
|
)
|
|
|
|
|
|
return cls._cache_backend
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.services.health.endpoint import EndpointHealthService
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
|
|
|
|
|
|
# 尝试从缓存获取
|
|
|
|
|
|
cache = await self._get_cache()
|
|
|
|
|
|
cache_key = "endpoint_status:all"
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
cached = await cache.get(cache_key)
|
|
|
|
|
|
if cached is not None:
|
|
|
|
|
|
return cached
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass # 缓存失败不影响正常流程
|
|
|
|
|
|
|
|
|
|
|
|
# 使用共享服务获取健康状态(普通用户视图)
|
|
|
|
|
|
result = EndpointHealthService.get_endpoint_health_by_format(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
lookback_hours=6,
|
|
|
|
|
|
include_admin_fields=False, # 不包含敏感的管理员字段
|
|
|
|
|
|
use_cache=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 写入缓存
|
|
|
|
|
|
try:
|
|
|
|
|
|
await cache.set(cache_key, result, ttl=self._cache_ttl)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass # 缓存失败不影响正常流程
|
|
|
|
|
|
|
|
|
|
|
|
return result
|