2025-12-10 20:52:44 +08:00
|
|
|
|
"""系统设置API端点。"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-01-28 12:56:37 +08:00
|
|
|
|
import copy
|
2026-02-15 17:14:14 +08:00
|
|
|
|
import json
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from dataclasses import dataclass
|
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, Request, status
|
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
|
2026-03-03 22:04:40 +08:00
|
|
|
|
from sqlalchemy import case, func
|
2026-03-09 22:57:23 +08:00
|
|
|
|
from sqlalchemy.orm import Session, selectinload
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
from src.api.base.admin_adapter import AdminApiAdapter
|
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.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
|
2026-01-28 12:56:37 +08:00
|
|
|
|
from src.core.logger import logger
|
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 SystemSettingsRequest, SystemSettingsResponse
|
|
|
|
|
|
from src.models.database import ApiKey, Provider, Usage, User
|
2026-03-06 21:06:45 +08:00
|
|
|
|
from src.services.provider_ops.types import SENSITIVE_CREDENTIAL_FIELDS
|
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/admin/system", tags=["Admin - System"])
|
2026-01-05 18:18:45 +08:00
|
|
|
|
|
2026-02-11 23:27:48 +08:00
|
|
|
|
CONFIG_EXPORT_VERSION = "2.2"
|
|
|
|
|
|
CONFIG_SUPPORTED_VERSIONS = ("2.0", "2.1", "2.2")
|
|
|
|
|
|
MAX_IMPORT_SIZE = 10 * 1024 * 1024 # 10MB
|
|
|
|
|
|
|
2026-01-05 18:18:45 +08:00
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
def _email_template_service() -> Any:
|
|
|
|
|
|
from src.services.email.email_template import EmailTemplate
|
|
|
|
|
|
|
|
|
|
|
|
return EmailTemplate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _system_config_service() -> Any:
|
|
|
|
|
|
from src.services.system.config import SystemConfigService
|
|
|
|
|
|
|
|
|
|
|
|
return SystemConfigService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wallet_service() -> Any:
|
|
|
|
|
|
from src.services.wallet import WalletService
|
|
|
|
|
|
|
|
|
|
|
|
return WalletService
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-05 18:18:45 +08:00
|
|
|
|
def _get_version_from_git() -> str | None:
|
|
|
|
|
|
"""从 git describe 获取版本号"""
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
["git", "describe", "--tags", "--always"],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=5,
|
|
|
|
|
|
)
|
|
|
|
|
|
if result.returncode == 0:
|
|
|
|
|
|
version = result.stdout.strip()
|
|
|
|
|
|
if version.startswith("v"):
|
|
|
|
|
|
version = version[1:]
|
|
|
|
|
|
return version
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-08 03:01:54 +08:00
|
|
|
|
def _get_current_version() -> str:
|
|
|
|
|
|
"""获取当前版本号"""
|
|
|
|
|
|
version = _get_version_from_git()
|
|
|
|
|
|
if version:
|
|
|
|
|
|
return version
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src._version import __version__
|
|
|
|
|
|
|
|
|
|
|
|
return __version__
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
return "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_version(version_str: str) -> tuple:
|
|
|
|
|
|
"""解析版本号为可比较的元组,支持 3-4 段版本号
|
|
|
|
|
|
|
|
|
|
|
|
例如:
|
|
|
|
|
|
- '0.2.5' -> (0, 2, 5, 0)
|
|
|
|
|
|
- '0.2.5.1' -> (0, 2, 5, 1)
|
|
|
|
|
|
- 'v0.2.5-4-g1234567' -> (0, 2, 5, 0)
|
|
|
|
|
|
"""
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
version_str = version_str.lstrip("v")
|
|
|
|
|
|
main_version = re.split(r"[-+]", version_str)[0]
|
|
|
|
|
|
try:
|
|
|
|
|
|
parts = main_version.split(".")
|
|
|
|
|
|
# 标准化为 4 段,便于比较
|
|
|
|
|
|
int_parts = [int(p) for p in parts]
|
|
|
|
|
|
while len(int_parts) < 4:
|
|
|
|
|
|
int_parts.append(0)
|
|
|
|
|
|
return tuple(int_parts[:4])
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return (0, 0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-05 18:18:45 +08:00
|
|
|
|
@router.get("/version")
|
2026-03-31 19:19:04 +08:00
|
|
|
|
async def get_system_version(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取系统版本信息
|
|
|
|
|
|
|
|
|
|
|
|
获取当前系统的版本号。优先从 git describe 获取,回退到静态版本文件。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `version`: 版本号字符串
|
|
|
|
|
|
"""
|
2026-03-31 19:19:04 +08:00
|
|
|
|
adapter = AdminSystemVersionAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
2026-01-05 18:18:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-01-08 03:01:54 +08:00
|
|
|
|
@router.get("/check-update")
|
2026-03-31 19:19:04 +08:00
|
|
|
|
async def check_update(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-08 03:01:54 +08:00
|
|
|
|
"""
|
|
|
|
|
|
检查系统更新
|
|
|
|
|
|
|
2026-01-15 12:30:05 +08:00
|
|
|
|
从 GitHub Tags 获取最新版本并与当前版本对比。
|
|
|
|
|
|
更新内容从 annotated tag 的 message 中获取。
|
2026-01-08 03:01:54 +08:00
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `current_version`: 当前版本号
|
|
|
|
|
|
- `latest_version`: 最新版本号
|
|
|
|
|
|
- `has_update`: 是否有更新可用
|
|
|
|
|
|
- `release_url`: 最新版本的 GitHub 页面链接
|
2026-01-15 12:30:05 +08:00
|
|
|
|
- `release_notes`: 更新日志 (Markdown 格式,来自 tag message)
|
2026-01-13 20:29:16 +08:00
|
|
|
|
- `published_at`: 发布时间 (ISO 8601 格式)
|
2026-01-08 03:01:54 +08:00
|
|
|
|
"""
|
2026-03-31 19:19:04 +08:00
|
|
|
|
adapter = AdminSystemCheckUpdateAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
2026-01-08 03:01:54 +08:00
|
|
|
|
|
2026-01-15 12:30:05 +08:00
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
pipeline = get_pipeline()
|
2026-01-15 12:30:05 +08:00
|
|
|
|
|
2026-01-08 03:01:54 +08:00
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
def _build_check_update_unavailable_response() -> dict[str, Any]:
|
|
|
|
|
|
current_version = _get_current_version()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"current_version": current_version,
|
|
|
|
|
|
"latest_version": None,
|
|
|
|
|
|
"has_update": False,
|
|
|
|
|
|
"release_url": None,
|
|
|
|
|
|
"release_notes": None,
|
|
|
|
|
|
"published_at": None,
|
|
|
|
|
|
"error": "检查更新需要 Rust 管理后端",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminSystemVersionAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
return {"version": _get_current_version()}
|
2026-01-05 18:18:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
class AdminSystemCheckUpdateAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
return _build_check_update_unavailable_response()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/settings")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_system_settings(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取系统设置
|
|
|
|
|
|
|
|
|
|
|
|
获取系统的全局设置信息。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `default_provider`: 默认提供商名称
|
|
|
|
|
|
- `default_model`: 默认模型名称
|
|
|
|
|
|
- `enable_usage_tracking`: 是否启用使用情况追踪
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
adapter = AdminGetSystemSettingsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/settings")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def update_system_settings(http_request: Request, db: Session = Depends(get_db)) -> None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
更新系统设置
|
|
|
|
|
|
|
|
|
|
|
|
更新系统的全局设置。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**请求体字段**:
|
|
|
|
|
|
- `default_provider`: 可选,默认提供商名称(空字符串表示清除设置)
|
|
|
|
|
|
- `default_model`: 可选,默认模型名称(空字符串表示清除设置)
|
|
|
|
|
|
- `enable_usage_tracking`: 可选,是否启用使用情况追踪
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `message`: 操作结果信息
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
adapter = AdminUpdateSystemSettingsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/configs")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_all_system_configs(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取所有系统配置
|
|
|
|
|
|
|
|
|
|
|
|
获取系统中所有的配置项。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- 配置项的键值对字典
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
adapter = AdminGetAllConfigsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/configs/{key}")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_system_config(key: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取特定系统配置
|
|
|
|
|
|
|
|
|
|
|
|
获取指定配置项的值。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `key`: 配置项键名
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `key`: 配置项键名
|
|
|
|
|
|
- `value`: 配置项的值(敏感配置项不返回实际值)
|
|
|
|
|
|
- `is_set`: 可选,对于敏感配置项,指示是否已设置
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
adapter = AdminGetSystemConfigAdapter(key=key)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/configs/{key}")
|
|
|
|
|
|
async def set_system_config(
|
|
|
|
|
|
key: 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
|
|
|
|
"""
|
|
|
|
|
|
设置系统配置
|
|
|
|
|
|
|
|
|
|
|
|
设置或更新指定配置项的值。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `key`: 配置项键名
|
|
|
|
|
|
|
|
|
|
|
|
**请求体字段**:
|
|
|
|
|
|
- `value`: 配置项的值
|
|
|
|
|
|
- `description`: 可选,配置项描述
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `key`: 配置项键名
|
|
|
|
|
|
- `value`: 配置项的值(敏感配置项显示为 ********)
|
|
|
|
|
|
- `description`: 配置项描述
|
|
|
|
|
|
- `updated_at`: 更新时间
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
adapter = AdminSetSystemConfigAdapter(key=key)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/configs/{key}")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def delete_system_config(key: str, request: Request, db: Session = Depends(get_db)) -> None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
删除系统配置
|
|
|
|
|
|
|
|
|
|
|
|
删除指定的配置项。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `key`: 配置项键名
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `message`: 操作结果信息
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
adapter = AdminDeleteSystemConfigAdapter(key=key)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/stats")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_system_stats(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取系统统计信息
|
|
|
|
|
|
|
|
|
|
|
|
获取系统的整体统计数据。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `users`: 用户统计(total: 总用户数, active: 活跃用户数)
|
|
|
|
|
|
- `providers`: 提供商统计(total: 总提供商数, active: 活跃提供商数)
|
|
|
|
|
|
- `api_keys`: API Key 总数
|
|
|
|
|
|
- `requests`: 请求总数
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = AdminSystemStatsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/cleanup")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def trigger_cleanup(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
手动触发清理任务
|
|
|
|
|
|
|
|
|
|
|
|
手动触发使用记录清理任务,清理过期的请求/响应数据。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `message`: 操作结果信息
|
|
|
|
|
|
- `stats`: 清理统计信息
|
|
|
|
|
|
- `total_records`: 总记录数统计(before, after, deleted)
|
|
|
|
|
|
- `body_fields`: 请求/响应体字段清理统计(before, after, cleaned)
|
|
|
|
|
|
- `header_fields`: 请求/响应头字段清理统计(before, after, cleaned)
|
|
|
|
|
|
- `timestamp`: 清理完成时间
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = AdminTriggerCleanupAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api-formats")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_api_formats(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取所有可用的 API 格式列表
|
|
|
|
|
|
|
|
|
|
|
|
获取系统支持的所有 API 格式及其元数据。需要管理员权限。
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `formats`: API 格式列表,每个格式包含:
|
|
|
|
|
|
- `value`: 格式值
|
|
|
|
|
|
- `label`: 显示名称
|
|
|
|
|
|
- `default_path`: 默认路径
|
|
|
|
|
|
- `aliases`: 别名列表
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
adapter = AdminGetApiFormatsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
@router.get("/config/export")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def export_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"""导出提供商和模型配置(管理员)"""
|
|
|
|
|
|
adapter = AdminExportConfigAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/config/import")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def import_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"""导入提供商和模型配置(管理员)"""
|
|
|
|
|
|
adapter = AdminImportConfigAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/users/export")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def export_users(request: Request, db: Session = Depends(get_db)) -> Any:
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"""导出用户数据(管理员)"""
|
|
|
|
|
|
adapter = AdminExportUsersAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/users/import")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def import_users(request: Request, db: Session = Depends(get_db)) -> Any:
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"""导入用户数据(管理员)"""
|
|
|
|
|
|
adapter = AdminImportUsersAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 17:15:48 +08:00
|
|
|
|
@router.post("/smtp/test")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def test_smtp(request: Request, db: Session = Depends(get_db)) -> Any:
|
2025-12-30 17:15:48 +08:00
|
|
|
|
"""测试 SMTP 连接(管理员)"""
|
|
|
|
|
|
adapter = AdminTestSmtpAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# -------- 邮件模板 API --------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/email/templates")
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def get_email_templates(request: Request, db: Session = Depends(get_db)) -> Any:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
"""获取所有邮件模板(管理员)"""
|
|
|
|
|
|
adapter = AdminGetEmailTemplatesAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/email/templates/{template_type}")
|
|
|
|
|
|
async def get_email_template(
|
|
|
|
|
|
template_type: str, request: Request, db: Session = Depends(get_db)
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
"""获取指定类型的邮件模板(管理员)"""
|
|
|
|
|
|
adapter = AdminGetEmailTemplateAdapter(template_type=template_type)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/email/templates/{template_type}")
|
|
|
|
|
|
async def update_email_template(
|
|
|
|
|
|
template_type: str, request: Request, db: Session = Depends(get_db)
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
"""更新邮件模板(管理员)"""
|
|
|
|
|
|
adapter = AdminUpdateEmailTemplateAdapter(template_type=template_type)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/email/templates/{template_type}/preview")
|
|
|
|
|
|
async def preview_email_template(
|
|
|
|
|
|
template_type: str, request: Request, db: Session = Depends(get_db)
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
"""预览邮件模板(管理员)"""
|
|
|
|
|
|
adapter = AdminPreviewEmailTemplateAdapter(template_type=template_type)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/email/templates/{template_type}/reset")
|
|
|
|
|
|
async def reset_email_template(
|
|
|
|
|
|
template_type: str, request: Request, db: Session = Depends(get_db)
|
2026-01-30 14:30:57 +08:00
|
|
|
|
) -> Any:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
"""重置邮件模板为默认值(管理员)"""
|
|
|
|
|
|
adapter = AdminResetEmailTemplateAdapter(template_type=template_type)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-26 01:20:24 +08:00
|
|
|
|
# -------- 数据清空 API --------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/purge/config")
|
|
|
|
|
|
async def purge_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""清空所有提供商配置(管理员)"""
|
|
|
|
|
|
adapter = AdminPurgeConfigAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/purge/users")
|
|
|
|
|
|
async def purge_users(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""清空所有非管理员用户(管理员)"""
|
|
|
|
|
|
adapter = AdminPurgeUsersAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/purge/usage")
|
|
|
|
|
|
async def purge_usage(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""清空全部使用记录(管理员)"""
|
|
|
|
|
|
adapter = AdminPurgeUsageAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/purge/audit-logs")
|
|
|
|
|
|
async def purge_audit_logs(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""清空全部审计日志(管理员)"""
|
|
|
|
|
|
adapter = AdminPurgeAuditLogsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/purge/request-bodies")
|
|
|
|
|
|
async def purge_request_bodies(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""清空全部请求体(管理员)"""
|
|
|
|
|
|
adapter = AdminPurgeRequestBodiesAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/purge/stats")
|
|
|
|
|
|
async def purge_stats(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""清空全部聚合统计数据(管理员)"""
|
|
|
|
|
|
adapter = AdminPurgeStatsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
# -------- 系统设置适配器 --------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminGetSystemSettingsAdapter(AdminApiAdapter):
|
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
|
2026-03-14 11:59:07 +08:00
|
|
|
|
default_provider = _system_config_service().get_default_provider(db)
|
|
|
|
|
|
default_model = _system_config_service().get_config(db, "default_model")
|
2025-12-10 20:52:44 +08:00
|
|
|
|
enable_usage_tracking = (
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().get_config(db, "enable_usage_tracking", "true") == "true"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return SystemSettingsResponse(
|
|
|
|
|
|
default_provider=default_provider,
|
|
|
|
|
|
default_model=default_model,
|
|
|
|
|
|
enable_usage_tracking=enable_usage_tracking,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
password_policy_level=_system_config_service().get_password_policy_level(db),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminUpdateSystemSettingsAdapter(AdminApiAdapter):
|
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
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
try:
|
|
|
|
|
|
settings_request = SystemSettingsRequest.model_validate(payload)
|
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
|
errors = e.errors()
|
|
|
|
|
|
if errors:
|
|
|
|
|
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
|
|
|
|
|
raise InvalidRequestException("请求数据验证失败")
|
|
|
|
|
|
|
|
|
|
|
|
if settings_request.default_provider is not None:
|
|
|
|
|
|
provider = (
|
|
|
|
|
|
db.query(Provider)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
Provider.name == settings_request.default_provider,
|
|
|
|
|
|
Provider.is_active.is_(True),
|
|
|
|
|
|
)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not provider and settings_request.default_provider != "":
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
|
detail=f"提供商 '{settings_request.default_provider}' 不存在或未启用",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if settings_request.default_provider:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().set_default_provider(db, settings_request.default_provider)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
else:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().delete_config(db, "default_provider")
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
if settings_request.default_model is not None:
|
|
|
|
|
|
if settings_request.default_model:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().set_config(
|
|
|
|
|
|
db, "default_model", settings_request.default_model
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
else:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().delete_config(db, "default_model")
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
if settings_request.enable_usage_tracking is not None:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().set_config(
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db,
|
|
|
|
|
|
"enable_usage_tracking",
|
|
|
|
|
|
str(settings_request.enable_usage_tracking).lower(),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-12 01:11:35 +08:00
|
|
|
|
if settings_request.password_policy_level is not None:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().set_config(
|
2026-03-12 01:11:35 +08:00
|
|
|
|
db,
|
|
|
|
|
|
"password_policy_level",
|
|
|
|
|
|
settings_request.password_policy_level,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return {"message": "系统设置更新成功"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminGetAllConfigsAdapter(AdminApiAdapter):
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-03-14 11:59:07 +08:00
|
|
|
|
return _system_config_service().get_all_configs(context.db)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
|
|
|
|
|
key: str
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 敏感配置项,不返回实际值
|
|
|
|
|
|
SENSITIVE_KEYS = {"smtp_password"}
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-03-14 11:59:07 +08:00
|
|
|
|
value = _system_config_service().get_config(context.db, self.key)
|
|
|
|
|
|
if value is None and self.key not in _system_config_service().DEFAULT_CONFIGS:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
raise NotFoundException(f"配置项 '{self.key}' 不存在")
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 对敏感配置,只返回是否已设置的标志,不返回实际值
|
|
|
|
|
|
if self.key in self.SENSITIVE_KEYS:
|
|
|
|
|
|
return {"key": self.key, "value": None, "is_set": bool(value)}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return {"key": self.key, "value": value}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
|
|
|
|
|
key: str
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 需要加密存储的配置项
|
|
|
|
|
|
ENCRYPTED_KEYS = {"smtp_password"}
|
|
|
|
|
|
|
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()
|
2026-01-01 02:10:19 +08:00
|
|
|
|
value = payload.get("value")
|
|
|
|
|
|
|
|
|
|
|
|
# 对敏感配置进行加密
|
|
|
|
|
|
if self.key in self.ENCRYPTED_KEYS and value:
|
|
|
|
|
|
from src.core.crypto import crypto_service
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
value = crypto_service.encrypt(value)
|
|
|
|
|
|
|
2026-03-12 01:11:35 +08:00
|
|
|
|
try:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
config = _system_config_service().set_config(
|
2026-03-12 01:11:35 +08:00
|
|
|
|
context.db,
|
|
|
|
|
|
self.key,
|
|
|
|
|
|
value,
|
|
|
|
|
|
payload.get("description"),
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise InvalidRequestException(str(exc))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-03 19:03:55 +08:00
|
|
|
|
# 如果更新的是签到任务时间,动态更新调度器
|
|
|
|
|
|
if self.key == "provider_checkin_time" and value:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
|
|
|
|
|
|
|
|
|
|
|
|
scheduler = get_maintenance_scheduler()
|
|
|
|
|
|
scheduler.update_checkin_time(value)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"更新签到任务时间失败: {e}")
|
|
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
# 如果更新的是调度模式或优先级模式,立即更新当前 Worker 的 Scheduler 单例
|
|
|
|
|
|
if self.key in ("scheduling_mode", "provider_priority_mode"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.clients.redis_client import get_redis_client_sync
|
2026-02-16 11:00:48 +08:00
|
|
|
|
from src.services.scheduling.aware_scheduler import get_cache_aware_scheduler
|
2026-02-09 18:21:34 +08:00
|
|
|
|
|
|
|
|
|
|
redis_client = get_redis_client_sync()
|
|
|
|
|
|
# 从数据库读取两个调度配置的最新值,确保一致性
|
2026-03-14 11:59:07 +08:00
|
|
|
|
priority_mode = _system_config_service().get_config(
|
2026-02-09 18:21:34 +08:00
|
|
|
|
context.db,
|
|
|
|
|
|
"provider_priority_mode",
|
|
|
|
|
|
"provider",
|
|
|
|
|
|
)
|
2026-03-14 11:59:07 +08:00
|
|
|
|
scheduling_mode = _system_config_service().get_config(
|
2026-02-09 18:21:34 +08:00
|
|
|
|
context.db,
|
|
|
|
|
|
"scheduling_mode",
|
|
|
|
|
|
"cache_affinity",
|
|
|
|
|
|
)
|
|
|
|
|
|
await get_cache_aware_scheduler(
|
|
|
|
|
|
redis_client,
|
|
|
|
|
|
priority_mode=priority_mode,
|
|
|
|
|
|
scheduling_mode=scheduling_mode,
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[AdminSetSystemConfig] 已同步更新 Scheduler: "
|
|
|
|
|
|
"priority_mode={}, scheduling_mode={}",
|
|
|
|
|
|
priority_mode,
|
|
|
|
|
|
scheduling_mode,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("同步更新 Scheduler 失败: {}", e)
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 返回时不暴露加密后的值
|
|
|
|
|
|
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"key": config.key,
|
2026-01-01 02:10:19 +08:00
|
|
|
|
"value": display_value,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"description": config.description,
|
|
|
|
|
|
"updated_at": config.updated_at.isoformat(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminDeleteSystemConfigAdapter(AdminApiAdapter):
|
|
|
|
|
|
key: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-03-14 11:59:07 +08:00
|
|
|
|
deleted = _system_config_service().delete_config(context.db, self.key)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if not deleted:
|
|
|
|
|
|
raise NotFoundException(f"配置项 '{self.key}' 不存在")
|
|
|
|
|
|
return {"message": f"配置项 '{self.key}' 已删除"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminSystemStatsAdapter(AdminApiAdapter):
|
2026-03-03 22:04:40 +08:00
|
|
|
|
@cache_result(
|
|
|
|
|
|
key_prefix="admin:system:stats",
|
|
|
|
|
|
ttl=CacheTTL.DASHBOARD_STATS,
|
|
|
|
|
|
user_specific=False,
|
|
|
|
|
|
)
|
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
|
2026-03-03 22:04:40 +08:00
|
|
|
|
user_stats = db.query(
|
|
|
|
|
|
func.count(User.id).label("total"),
|
|
|
|
|
|
func.sum(case((User.is_active.is_(True), 1), else_=0)).label("active"),
|
|
|
|
|
|
).first()
|
|
|
|
|
|
provider_stats = db.query(
|
|
|
|
|
|
func.count(Provider.id).label("total"),
|
|
|
|
|
|
func.sum(case((Provider.is_active.is_(True), 1), else_=0)).label("active"),
|
|
|
|
|
|
).first()
|
|
|
|
|
|
total_api_keys = int(db.query(func.count(ApiKey.id)).scalar() or 0)
|
|
|
|
|
|
total_requests = int(db.query(func.count(Usage.id)).scalar() or 0)
|
|
|
|
|
|
total_users = int(user_stats.total or 0) if user_stats else 0
|
|
|
|
|
|
active_users = int(user_stats.active or 0) if user_stats else 0
|
|
|
|
|
|
total_providers = int(provider_stats.total or 0) if provider_stats else 0
|
|
|
|
|
|
active_providers = int(provider_stats.active or 0) if provider_stats else 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"users": {"total": total_users, "active": active_users},
|
|
|
|
|
|
"providers": {"total": total_providers, "active": active_providers},
|
|
|
|
|
|
"api_keys": total_api_keys,
|
|
|
|
|
|
"requests": total_requests,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminTriggerCleanupAdapter(AdminApiAdapter):
|
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
|
|
|
|
"""手动触发清理任务"""
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from datetime import datetime, timezone
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-28 02:03:57 +08:00
|
|
|
|
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
|
|
|
|
|
|
# 获取清理前的统计信息
|
2026-03-03 22:04:40 +08:00
|
|
|
|
total_before = int(db.query(func.count(Usage.id)).scalar() or 0)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
with_body_before = (
|
2026-03-03 22:04:40 +08:00
|
|
|
|
db.query(func.count(Usage.id))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.filter((Usage.request_body.isnot(None)) | (Usage.response_body.isnot(None)))
|
2026-03-03 22:04:40 +08:00
|
|
|
|
.scalar()
|
|
|
|
|
|
or 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
with_headers_before = (
|
2026-03-03 22:04:40 +08:00
|
|
|
|
db.query(func.count(Usage.id))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.filter((Usage.request_headers.isnot(None)) | (Usage.response_headers.isnot(None)))
|
2026-03-03 22:04:40 +08:00
|
|
|
|
.scalar()
|
|
|
|
|
|
or 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 触发清理
|
2026-01-28 02:03:57 +08:00
|
|
|
|
maintenance_scheduler = get_maintenance_scheduler()
|
|
|
|
|
|
await maintenance_scheduler._perform_cleanup()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
# 获取清理后的统计信息
|
2026-03-03 22:04:40 +08:00
|
|
|
|
total_after = int(db.query(func.count(Usage.id)).scalar() or 0)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
with_body_after = (
|
2026-03-03 22:04:40 +08:00
|
|
|
|
db.query(func.count(Usage.id))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.filter((Usage.request_body.isnot(None)) | (Usage.response_body.isnot(None)))
|
2026-03-03 22:04:40 +08:00
|
|
|
|
.scalar()
|
|
|
|
|
|
or 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
with_headers_after = (
|
2026-03-03 22:04:40 +08:00
|
|
|
|
db.query(func.count(Usage.id))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.filter((Usage.request_headers.isnot(None)) | (Usage.response_headers.isnot(None)))
|
2026-03-03 22:04:40 +08:00
|
|
|
|
.scalar()
|
|
|
|
|
|
or 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "清理任务执行完成",
|
|
|
|
|
|
"stats": {
|
|
|
|
|
|
"total_records": {
|
|
|
|
|
|
"before": total_before,
|
|
|
|
|
|
"after": total_after,
|
|
|
|
|
|
"deleted": total_before - total_after,
|
|
|
|
|
|
},
|
|
|
|
|
|
"body_fields": {
|
|
|
|
|
|
"before": with_body_before,
|
|
|
|
|
|
"after": with_body_after,
|
|
|
|
|
|
"cleaned": with_body_before - with_body_after,
|
|
|
|
|
|
},
|
|
|
|
|
|
"header_fields": {
|
|
|
|
|
|
"before": with_headers_before,
|
|
|
|
|
|
"after": with_headers_after,
|
|
|
|
|
|
"cleaned": with_headers_before - with_headers_after,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminGetApiFormatsAdapter(AdminApiAdapter):
|
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
|
|
|
|
"""获取所有可用的API格式"""
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from src.core.api_format import list_endpoint_definitions
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
_ = context # 参数保留以符合接口规范
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
def _label_for(sig: str) -> str:
|
|
|
|
|
|
fam, kind = (sig.split(":", 1) + [""])[:2]
|
|
|
|
|
|
fam_title = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
|
2026-03-01 23:55:26 +08:00
|
|
|
|
kind_title = {
|
|
|
|
|
|
"chat": "Chat",
|
|
|
|
|
|
"cli": "CLI",
|
|
|
|
|
|
"compact": "Compact",
|
|
|
|
|
|
"video": "Video",
|
|
|
|
|
|
"image": "Image",
|
|
|
|
|
|
}.get(kind, kind)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
return f"{fam_title} {kind_title}".strip()
|
|
|
|
|
|
|
|
|
|
|
|
endpoint_defs = list_endpoint_definitions()
|
|
|
|
|
|
preferred_order = [
|
|
|
|
|
|
"openai:chat",
|
|
|
|
|
|
"openai:cli",
|
2026-03-01 23:55:26 +08:00
|
|
|
|
"openai:compact",
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"openai:video",
|
|
|
|
|
|
"claude:chat",
|
|
|
|
|
|
"claude:cli",
|
|
|
|
|
|
"gemini:chat",
|
|
|
|
|
|
"gemini:cli",
|
|
|
|
|
|
"gemini:video",
|
|
|
|
|
|
]
|
|
|
|
|
|
order_map = {key: i for i, key in enumerate(preferred_order)}
|
|
|
|
|
|
endpoint_defs.sort(key=lambda d: order_map.get(d.signature_key, 999))
|
|
|
|
|
|
|
|
|
|
|
|
formats = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": d.signature_key,
|
|
|
|
|
|
"label": _label_for(d.signature_key),
|
|
|
|
|
|
"default_path": d.default_path,
|
|
|
|
|
|
"aliases": list(d.aliases or []),
|
|
|
|
|
|
}
|
|
|
|
|
|
for d in endpoint_defs
|
|
|
|
|
|
]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
return {"formats": formats}
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminExportConfigAdapter(AdminApiAdapter):
|
2026-01-28 12:56:37 +08:00
|
|
|
|
"""导出提供商和模型配置"""
|
|
|
|
|
|
|
|
|
|
|
|
# Provider Ops 中需要解密的敏感字段
|
2026-03-06 21:06:45 +08:00
|
|
|
|
SENSITIVE_CREDENTIALS = SENSITIVE_CREDENTIAL_FIELDS
|
2026-01-28 12:56:37 +08:00
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _normalize_api_formats(raw_formats: Any) -> list[str]:
|
|
|
|
|
|
"""规范化 api_formats 为 endpoint signature 列表。"""
|
|
|
|
|
|
from src.core.api_format.signature import normalize_signature_key
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(raw_formats, list):
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
normalized: list[str] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for raw in raw_formats:
|
|
|
|
|
|
if not isinstance(raw, str):
|
|
|
|
|
|
continue
|
|
|
|
|
|
value = raw.strip()
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
fmt = normalize_signature_key(value)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if fmt in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(fmt)
|
|
|
|
|
|
normalized.append(fmt)
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_export_key_api_formats(
|
|
|
|
|
|
self, raw_formats: Any, provider_endpoint_formats: list[str]
|
|
|
|
|
|
) -> list[str]:
|
|
|
|
|
|
"""导出 Key 时解析支持端点:
|
|
|
|
|
|
|
|
|
|
|
|
- 优先使用 Key 自身 api_formats(规范化后)
|
|
|
|
|
|
- 当 api_formats 为 None(历史语义:全支持)时回退为 Provider 端点列表
|
|
|
|
|
|
- 当 api_formats 显式为空列表时保留空列表
|
|
|
|
|
|
"""
|
|
|
|
|
|
normalized = self._normalize_api_formats(raw_formats)
|
|
|
|
|
|
if normalized:
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
if raw_formats is None:
|
|
|
|
|
|
return list(provider_endpoint_formats)
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
def _collect_provider_endpoint_formats(self, endpoints: list[Any]) -> list[str]:
|
|
|
|
|
|
"""收集 Provider 下所有 endpoint signature(去重后排序)。"""
|
|
|
|
|
|
normalized: list[str] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for ep in endpoints:
|
|
|
|
|
|
raw = getattr(ep, "api_format", None)
|
|
|
|
|
|
if hasattr(raw, "value"):
|
|
|
|
|
|
raw = raw.value
|
|
|
|
|
|
fmt_list = self._normalize_api_formats([raw])
|
|
|
|
|
|
if not fmt_list:
|
|
|
|
|
|
continue
|
|
|
|
|
|
fmt = fmt_list[0]
|
|
|
|
|
|
if fmt in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(fmt)
|
|
|
|
|
|
normalized.append(fmt)
|
|
|
|
|
|
return sorted(normalized)
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
def _decrypt_provider_config(self, config: dict, crypto_service: Any) -> dict:
|
2026-01-28 12:56:37 +08:00
|
|
|
|
"""解密 Provider config 中的 provider_ops credentials"""
|
|
|
|
|
|
if not config:
|
|
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
decrypted_config = copy.deepcopy(config)
|
|
|
|
|
|
|
|
|
|
|
|
# 解密 provider_ops.connector.credentials 中的敏感字段
|
|
|
|
|
|
provider_ops = decrypted_config.get("provider_ops")
|
|
|
|
|
|
if provider_ops and isinstance(provider_ops, dict):
|
|
|
|
|
|
connector = provider_ops.get("connector")
|
|
|
|
|
|
if connector and isinstance(connector, dict):
|
|
|
|
|
|
credentials = connector.get("credentials")
|
|
|
|
|
|
if credentials and isinstance(credentials, dict):
|
|
|
|
|
|
for field in self.SENSITIVE_CREDENTIALS:
|
|
|
|
|
|
if field in credentials and isinstance(credentials[field], str):
|
|
|
|
|
|
try:
|
|
|
|
|
|
credentials[field] = crypto_service.decrypt(credentials[field])
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
# 解密失败保持原值(可能本来就是明文)
|
|
|
|
|
|
logger.debug(f"解密 provider_ops credential '{field}' 失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
return decrypted_config
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"""导出提供商和模型配置(解密数据)"""
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
|
|
from src.core.crypto import crypto_service
|
2026-03-09 13:26:54 +08:00
|
|
|
|
from src.models.database import (
|
|
|
|
|
|
GlobalModel,
|
|
|
|
|
|
ProxyNode,
|
|
|
|
|
|
)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
|
|
|
|
|
|
# 导出 GlobalModels
|
|
|
|
|
|
global_models = db.query(GlobalModel).all()
|
2026-02-15 17:14:14 +08:00
|
|
|
|
global_models_data = [gm.to_export_dict() for gm in global_models]
|
|
|
|
|
|
|
|
|
|
|
|
# 预建 global_model_id -> name 映射,避免导出 Model 时 N+1 查询
|
|
|
|
|
|
gm_name_map: dict[str, str] = {gm.id: gm.name for gm in global_models}
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
# 导出 Providers 及其关联数据(分批加载,避免全量 ORM 对象常驻内存)
|
|
|
|
|
|
batch_size = 50
|
|
|
|
|
|
provider_ids = [provider_id for (provider_id,) in db.query(Provider.id).all()]
|
|
|
|
|
|
provider_order = {provider_id: idx for idx, provider_id in enumerate(provider_ids)}
|
2025-12-16 18:33:14 +08:00
|
|
|
|
providers_data = []
|
2026-03-09 22:57:23 +08:00
|
|
|
|
|
|
|
|
|
|
def _normalize_created_at_for_sort(value: datetime | None) -> datetime:
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return datetime.min.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
for offset in range(0, len(provider_ids), batch_size):
|
|
|
|
|
|
batch_ids = provider_ids[offset : offset + batch_size]
|
|
|
|
|
|
providers_batch = (
|
|
|
|
|
|
db.query(Provider)
|
|
|
|
|
|
.options(
|
|
|
|
|
|
selectinload(Provider.endpoints),
|
|
|
|
|
|
selectinload(Provider.api_keys),
|
|
|
|
|
|
selectinload(Provider.models),
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(Provider.id.in_(batch_ids))
|
|
|
|
|
|
.all()
|
2026-01-10 18:43:53 +08:00
|
|
|
|
)
|
2026-03-14 11:59:07 +08:00
|
|
|
|
providers_batch.sort(key=lambda item: provider_order.get(item.id, 0))
|
|
|
|
|
|
|
|
|
|
|
|
for provider in providers_batch:
|
|
|
|
|
|
# 导出 Endpoints
|
|
|
|
|
|
endpoints = list(provider.endpoints)
|
|
|
|
|
|
endpoints_data = [ep.to_export_dict() for ep in endpoints]
|
|
|
|
|
|
provider_endpoint_formats = self._collect_provider_endpoint_formats(endpoints)
|
|
|
|
|
|
|
|
|
|
|
|
# 导出 Provider Keys(按 provider_id 归属,包含 api_formats)
|
|
|
|
|
|
keys = sorted(
|
|
|
|
|
|
provider.api_keys,
|
|
|
|
|
|
key=lambda key: (
|
|
|
|
|
|
(
|
|
|
|
|
|
key.internal_priority
|
|
|
|
|
|
if key.internal_priority is not None
|
|
|
|
|
|
else float("inf")
|
|
|
|
|
|
),
|
|
|
|
|
|
_normalize_created_at_for_sort(key.created_at),
|
|
|
|
|
|
),
|
2026-03-05 23:22:25 +08:00
|
|
|
|
)
|
2026-03-14 11:59:07 +08:00
|
|
|
|
keys_data = []
|
|
|
|
|
|
for key in keys:
|
|
|
|
|
|
key_data = key.to_export_dict()
|
|
|
|
|
|
key_formats = self._resolve_export_key_api_formats(
|
|
|
|
|
|
key_data.get("api_formats"),
|
|
|
|
|
|
provider_endpoint_formats,
|
2026-02-20 21:37:40 +08:00
|
|
|
|
)
|
2026-03-14 11:59:07 +08:00
|
|
|
|
# 保持现有字段名 api_formats,并补充可读别名 supported_endpoints。
|
|
|
|
|
|
key_data["api_formats"] = key_formats
|
|
|
|
|
|
key_data["supported_endpoints"] = list(key_formats)
|
|
|
|
|
|
# 解密 API Key
|
2026-02-15 17:14:14 +08:00
|
|
|
|
try:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
key_data["api_key"] = crypto_service.decrypt(key.api_key)
|
2026-02-15 17:14:14 +08:00
|
|
|
|
except Exception:
|
2026-02-20 21:37:40 +08:00
|
|
|
|
logger.warning(
|
2026-03-14 11:59:07 +08:00
|
|
|
|
"API Key 解密失败: provider={}, key_id={}, api_formats={}",
|
2026-02-20 21:37:40 +08:00
|
|
|
|
provider.name,
|
|
|
|
|
|
key.id,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
key.api_formats,
|
2026-02-20 21:37:40 +08:00
|
|
|
|
)
|
2026-03-14 11:59:07 +08:00
|
|
|
|
key_data["api_key"] = ""
|
|
|
|
|
|
# 解密 auth_config(OAuth 等认证配置)
|
|
|
|
|
|
# 导出值为解密后的 JSON 字符串(非 dict),导入时需按字符串重新加密
|
|
|
|
|
|
if key.auth_config:
|
|
|
|
|
|
try:
|
|
|
|
|
|
key_data["auth_config"] = crypto_service.decrypt(key.auth_config)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"auth_config 解密失败: provider={}, key_id={}",
|
|
|
|
|
|
provider.name,
|
|
|
|
|
|
key.id,
|
|
|
|
|
|
)
|
|
|
|
|
|
pass # 解密失败则不导出 auth_config
|
|
|
|
|
|
keys_data.append(key_data)
|
|
|
|
|
|
|
|
|
|
|
|
# 导出 Provider Models
|
|
|
|
|
|
# 注意:提供商模型(Model)必须关联全局模型(GlobalModel)才能参与路由
|
|
|
|
|
|
# 导入时未关联 GlobalModel 的模型会被跳过,这是业务规则而非 bug
|
|
|
|
|
|
models = list(provider.models)
|
|
|
|
|
|
models_data = []
|
|
|
|
|
|
for model in models:
|
|
|
|
|
|
model_data = model.to_export_dict()
|
|
|
|
|
|
# 追加关联的 GlobalModel 名称(导入时通过名称查找)
|
|
|
|
|
|
model_data["global_model_name"] = gm_name_map.get(model.global_model_id)
|
|
|
|
|
|
models_data.append(model_data)
|
|
|
|
|
|
|
|
|
|
|
|
# 解密 Provider config 中的 credentials
|
|
|
|
|
|
provider_data = provider.to_export_dict()
|
|
|
|
|
|
provider_data["config"] = self._decrypt_provider_config(
|
|
|
|
|
|
provider.config, crypto_service
|
|
|
|
|
|
)
|
|
|
|
|
|
provider_data["endpoints"] = endpoints_data
|
|
|
|
|
|
provider_data["api_keys"] = keys_data
|
|
|
|
|
|
provider_data["models"] = models_data
|
|
|
|
|
|
providers_data.append(provider_data)
|
|
|
|
|
|
|
|
|
|
|
|
# 每批完成后清空会话身份映射,降低导出峰值内存
|
|
|
|
|
|
db.expunge_all()
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
2026-01-28 12:56:37 +08:00
|
|
|
|
# 导出 LDAP 配置
|
|
|
|
|
|
from src.models.database import LDAPConfig
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 12:56:37 +08:00
|
|
|
|
ldap_config = db.query(LDAPConfig).first()
|
|
|
|
|
|
ldap_data = None
|
|
|
|
|
|
if ldap_config:
|
|
|
|
|
|
# 解密绑定密码
|
|
|
|
|
|
bind_password = ""
|
|
|
|
|
|
if ldap_config.bind_password_encrypted:
|
|
|
|
|
|
try:
|
|
|
|
|
|
bind_password = crypto_service.decrypt(ldap_config.bind_password_encrypted)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"解密 LDAP bind_password 失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
ldap_data = {
|
|
|
|
|
|
"server_url": ldap_config.server_url,
|
|
|
|
|
|
"bind_dn": ldap_config.bind_dn,
|
|
|
|
|
|
"bind_password": bind_password,
|
|
|
|
|
|
"base_dn": ldap_config.base_dn,
|
|
|
|
|
|
"user_search_filter": ldap_config.user_search_filter,
|
|
|
|
|
|
"username_attr": ldap_config.username_attr,
|
|
|
|
|
|
"email_attr": ldap_config.email_attr,
|
|
|
|
|
|
"display_name_attr": ldap_config.display_name_attr,
|
|
|
|
|
|
"is_enabled": ldap_config.is_enabled,
|
|
|
|
|
|
"is_exclusive": ldap_config.is_exclusive,
|
|
|
|
|
|
"use_starttls": ldap_config.use_starttls,
|
|
|
|
|
|
"connect_timeout": ldap_config.connect_timeout,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 20:13:41 +08:00
|
|
|
|
# 导出 SystemConfig 配置
|
|
|
|
|
|
from src.models.database import SystemConfig
|
|
|
|
|
|
|
|
|
|
|
|
# 敏感配置项需要解密导出
|
|
|
|
|
|
SENSITIVE_CONFIG_KEYS = {"smtp_password"}
|
|
|
|
|
|
system_configs = db.query(SystemConfig).all()
|
|
|
|
|
|
system_configs_data = []
|
|
|
|
|
|
for cfg in system_configs:
|
|
|
|
|
|
cfg_data = {
|
|
|
|
|
|
"key": cfg.key,
|
|
|
|
|
|
"value": cfg.value,
|
|
|
|
|
|
"description": cfg.description,
|
|
|
|
|
|
}
|
|
|
|
|
|
# 解密敏感配置
|
|
|
|
|
|
if cfg.key in SENSITIVE_CONFIG_KEYS and cfg.value:
|
|
|
|
|
|
try:
|
|
|
|
|
|
cfg_data["value"] = crypto_service.decrypt(cfg.value)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"解密 SystemConfig '{cfg.key}' 失败: {e}")
|
|
|
|
|
|
system_configs_data.append(cfg_data)
|
|
|
|
|
|
|
2026-01-28 12:56:37 +08:00
|
|
|
|
# 导出 OAuth Providers 配置
|
|
|
|
|
|
from src.models.database import OAuthProvider
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 12:56:37 +08:00
|
|
|
|
oauth_providers = db.query(OAuthProvider).all()
|
|
|
|
|
|
oauth_data = []
|
|
|
|
|
|
for oauth in oauth_providers:
|
|
|
|
|
|
# 解密 client secret
|
|
|
|
|
|
client_secret = ""
|
|
|
|
|
|
if oauth.client_secret_encrypted:
|
|
|
|
|
|
try:
|
|
|
|
|
|
client_secret = crypto_service.decrypt(oauth.client_secret_encrypted)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"解密 OAuth '{oauth.provider_type}' client_secret 失败: {e}")
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
oauth_data.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_type": oauth.provider_type,
|
|
|
|
|
|
"display_name": oauth.display_name,
|
|
|
|
|
|
"client_id": oauth.client_id,
|
|
|
|
|
|
"client_secret": client_secret,
|
|
|
|
|
|
"authorization_url_override": oauth.authorization_url_override,
|
|
|
|
|
|
"token_url_override": oauth.token_url_override,
|
|
|
|
|
|
"userinfo_url_override": oauth.userinfo_url_override,
|
|
|
|
|
|
"scopes": oauth.scopes,
|
|
|
|
|
|
"redirect_uri": oauth.redirect_uri,
|
|
|
|
|
|
"frontend_callback_url": oauth.frontend_callback_url,
|
|
|
|
|
|
"attribute_mapping": oauth.attribute_mapping,
|
|
|
|
|
|
"extra_config": oauth.extra_config,
|
|
|
|
|
|
"is_enabled": oauth.is_enabled,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-01-28 12:56:37 +08:00
|
|
|
|
|
2026-03-09 13:26:54 +08:00
|
|
|
|
# 导出 ProxyNode(手动节点 + 隧道节点,不含运行时状态)
|
|
|
|
|
|
proxy_nodes = db.query(ProxyNode).all()
|
|
|
|
|
|
proxy_nodes_data = []
|
|
|
|
|
|
for node in proxy_nodes:
|
|
|
|
|
|
proxy_nodes_data.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": node.id,
|
|
|
|
|
|
"name": node.name,
|
|
|
|
|
|
"ip": node.ip,
|
|
|
|
|
|
"port": node.port,
|
|
|
|
|
|
"region": node.region,
|
|
|
|
|
|
"is_manual": node.is_manual,
|
|
|
|
|
|
"proxy_url": node.proxy_url,
|
|
|
|
|
|
"proxy_username": node.proxy_username,
|
|
|
|
|
|
"proxy_password": node.proxy_password,
|
|
|
|
|
|
"tunnel_mode": node.tunnel_mode,
|
|
|
|
|
|
"heartbeat_interval": node.heartbeat_interval,
|
|
|
|
|
|
"remote_config": node.remote_config,
|
|
|
|
|
|
"config_version": node.config_version,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
return {
|
2026-02-11 17:06:06 +08:00
|
|
|
|
"version": CONFIG_EXPORT_VERSION,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
"global_models": global_models_data,
|
|
|
|
|
|
"providers": providers_data,
|
2026-03-09 13:26:54 +08:00
|
|
|
|
"proxy_nodes": proxy_nodes_data,
|
2026-01-28 12:56:37 +08:00
|
|
|
|
"ldap_config": ldap_data,
|
|
|
|
|
|
"oauth_providers": oauth_data,
|
2026-02-05 20:13:41 +08:00
|
|
|
|
"system_configs": system_configs_data,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminImportConfigAdapter(AdminApiAdapter):
|
2026-01-28 12:56:37 +08:00
|
|
|
|
"""导入提供商和模型配置"""
|
|
|
|
|
|
|
|
|
|
|
|
# Provider Ops 中需要加密的敏感字段
|
2026-03-06 21:06:45 +08:00
|
|
|
|
SENSITIVE_CREDENTIALS = SENSITIVE_CREDENTIAL_FIELDS
|
2026-01-28 12:56:37 +08:00
|
|
|
|
|
2026-03-09 13:26:54 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _remap_proxy_node_id(
|
|
|
|
|
|
proxy: dict[str, Any] | None,
|
|
|
|
|
|
node_id_map: dict[str, str],
|
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
|
"""替换 proxy 配置中的 node_id 为新实例的 ID。
|
|
|
|
|
|
|
|
|
|
|
|
- node_id 在映射表中:替换为新 ID
|
|
|
|
|
|
- node_id 不在映射表中(节点未导入):清除 proxy 配置
|
|
|
|
|
|
- 无 node_id(手动 URL 模式):原样返回
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not proxy or not isinstance(proxy, dict):
|
|
|
|
|
|
return proxy
|
|
|
|
|
|
|
|
|
|
|
|
old_node_id = proxy.get("node_id")
|
|
|
|
|
|
if not old_node_id or not isinstance(old_node_id, str):
|
|
|
|
|
|
return proxy # 手动 URL 模式,无需映射
|
|
|
|
|
|
|
|
|
|
|
|
new_node_id = node_id_map.get(old_node_id)
|
|
|
|
|
|
if new_node_id is None:
|
|
|
|
|
|
return None # 节点未导入,清除代理配置
|
|
|
|
|
|
|
|
|
|
|
|
remapped = dict(proxy)
|
|
|
|
|
|
remapped["node_id"] = new_node_id
|
|
|
|
|
|
return remapped
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_import_key_api_formats(
|
|
|
|
|
|
key_data: dict[str, Any], endpoint_formats: set[str]
|
|
|
|
|
|
) -> list[str]:
|
|
|
|
|
|
"""导入 Key 时提取 api_formats(兼容历史字段与旧语义)。"""
|
|
|
|
|
|
raw_formats = key_data.get("api_formats")
|
|
|
|
|
|
if isinstance(raw_formats, list):
|
|
|
|
|
|
if raw_formats:
|
|
|
|
|
|
return raw_formats
|
|
|
|
|
|
legacy_formats = key_data.get("supported_endpoints")
|
|
|
|
|
|
if isinstance(legacy_formats, list) and legacy_formats:
|
|
|
|
|
|
return legacy_formats
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
legacy_formats = key_data.get("supported_endpoints")
|
|
|
|
|
|
if isinstance(legacy_formats, list) and legacy_formats:
|
|
|
|
|
|
return legacy_formats
|
|
|
|
|
|
|
|
|
|
|
|
# 兼容历史数据:api_formats=None 代表支持 Provider 的全部端点。
|
|
|
|
|
|
if raw_formats is None and endpoint_formats:
|
|
|
|
|
|
return sorted(endpoint_formats)
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
2026-03-12 17:11:14 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _normalize_import_endpoint_payload(
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
ep_data: dict[str, Any],
|
|
|
|
|
|
existing_ep: Any | None = None,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""校验并规范化导入的 Endpoint 数据。"""
|
|
|
|
|
|
from src.models.endpoint_models import ProviderEndpointCreate
|
|
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"provider_id": provider_id,
|
|
|
|
|
|
"api_format": ep_data.get("api_format", getattr(existing_ep, "api_format", None)),
|
|
|
|
|
|
"base_url": ep_data.get("base_url", getattr(existing_ep, "base_url", None)),
|
|
|
|
|
|
"custom_path": ep_data.get("custom_path", getattr(existing_ep, "custom_path", None)),
|
|
|
|
|
|
"header_rules": ep_data.get("header_rules", getattr(existing_ep, "header_rules", None)),
|
|
|
|
|
|
"body_rules": ep_data.get("body_rules", getattr(existing_ep, "body_rules", None)),
|
|
|
|
|
|
"max_retries": ep_data.get("max_retries", getattr(existing_ep, "max_retries", 2)),
|
|
|
|
|
|
"config": ep_data.get("config", getattr(existing_ep, "config", None)),
|
|
|
|
|
|
"proxy": ep_data.get("proxy", getattr(existing_ep, "proxy", None)),
|
|
|
|
|
|
"format_acceptance_config": ep_data.get(
|
|
|
|
|
|
"format_acceptance_config",
|
|
|
|
|
|
getattr(existing_ep, "format_acceptance_config", None),
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
validated = ProviderEndpointCreate.model_validate(payload)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
api_format = payload.get("api_format") or "unknown"
|
|
|
|
|
|
raise InvalidRequestException(
|
|
|
|
|
|
f"导入 Endpoint 失败: provider_id={provider_id}, api_format={api_format}, error={exc}"
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
return validated.model_dump(mode="python")
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
def _encrypt_provider_config(self, config: dict, crypto_service: Any) -> dict:
|
2026-01-28 12:56:37 +08:00
|
|
|
|
"""加密 Provider config 中的 provider_ops credentials"""
|
|
|
|
|
|
if not config:
|
|
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
encrypted_config = copy.deepcopy(config)
|
|
|
|
|
|
|
|
|
|
|
|
# 加密 provider_ops.connector.credentials 中的敏感字段
|
|
|
|
|
|
provider_ops = encrypted_config.get("provider_ops")
|
|
|
|
|
|
if provider_ops and isinstance(provider_ops, dict):
|
|
|
|
|
|
connector = provider_ops.get("connector")
|
|
|
|
|
|
if connector and isinstance(connector, dict):
|
|
|
|
|
|
credentials = connector.get("credentials")
|
|
|
|
|
|
if credentials and isinstance(credentials, dict):
|
|
|
|
|
|
for field in self.SENSITIVE_CREDENTIALS:
|
|
|
|
|
|
if field in credentials and isinstance(credentials[field], str):
|
|
|
|
|
|
value = credentials[field]
|
|
|
|
|
|
if value: # 只加密非空值
|
|
|
|
|
|
credentials[field] = crypto_service.encrypt(value)
|
|
|
|
|
|
|
|
|
|
|
|
return encrypted_config
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"""导入提供商和模型配置"""
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
|
|
from src.core.crypto import crypto_service
|
|
|
|
|
|
from src.core.enums import ProviderBillingType
|
2026-03-09 13:26:54 +08:00
|
|
|
|
from src.models.database import (
|
|
|
|
|
|
GlobalModel,
|
|
|
|
|
|
Model,
|
|
|
|
|
|
ProviderAPIKey,
|
|
|
|
|
|
ProviderEndpoint,
|
|
|
|
|
|
ProxyNode,
|
|
|
|
|
|
)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
# 检查请求体大小
|
|
|
|
|
|
if context.raw_body and len(context.raw_body) > MAX_IMPORT_SIZE:
|
|
|
|
|
|
raise InvalidRequestException("请求体大小不能超过 10MB")
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
|
2026-02-11 17:06:06 +08:00
|
|
|
|
# 验证配置版本
|
2025-12-16 18:33:14 +08:00
|
|
|
|
version = payload.get("version")
|
2026-02-11 17:06:06 +08:00
|
|
|
|
if version not in CONFIG_SUPPORTED_VERSIONS:
|
2026-02-15 17:14:14 +08:00
|
|
|
|
raise InvalidRequestException(
|
|
|
|
|
|
f"不支持的配置版本: {version},支持的版本: {', '.join(CONFIG_SUPPORTED_VERSIONS)}"
|
|
|
|
|
|
)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
# 获取导入选项
|
|
|
|
|
|
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
|
|
|
|
|
|
global_models_data = payload.get("global_models", [])
|
|
|
|
|
|
providers_data = payload.get("providers", [])
|
2026-03-09 13:26:54 +08:00
|
|
|
|
proxy_nodes_data = payload.get("proxy_nodes", [])
|
2026-01-28 12:56:37 +08:00
|
|
|
|
ldap_data = payload.get("ldap_config") # 2.1 新增
|
|
|
|
|
|
oauth_data = payload.get("oauth_providers", []) # 2.1 新增
|
2026-02-05 20:13:41 +08:00
|
|
|
|
system_configs_data = payload.get("system_configs", []) # 2.2 新增
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
stats = {
|
|
|
|
|
|
"global_models": {"created": 0, "updated": 0, "skipped": 0},
|
2026-03-09 13:26:54 +08:00
|
|
|
|
"proxy_nodes": {"created": 0, "updated": 0, "skipped": 0},
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"providers": {"created": 0, "updated": 0, "skipped": 0},
|
|
|
|
|
|
"endpoints": {"created": 0, "updated": 0, "skipped": 0},
|
|
|
|
|
|
"keys": {"created": 0, "updated": 0, "skipped": 0},
|
|
|
|
|
|
"models": {"created": 0, "updated": 0, "skipped": 0},
|
2026-01-28 12:56:37 +08:00
|
|
|
|
"ldap": {"created": 0, "updated": 0, "skipped": 0},
|
|
|
|
|
|
"oauth": {"created": 0, "updated": 0, "skipped": 0},
|
2026-02-05 20:13:41 +08:00
|
|
|
|
"system_configs": {"created": 0, "updated": 0, "skipped": 0}, # 2.2 新增
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"errors": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 导入 GlobalModels
|
|
|
|
|
|
global_model_map = {} # name -> id 映射
|
|
|
|
|
|
for gm_data in global_models_data:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing = db.query(GlobalModel).filter(GlobalModel.name == gm_data["name"]).first()
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
global_model_map[gm_data["name"]] = existing.id
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["global_models"]["skipped"] += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
elif merge_mode == "error":
|
2026-02-01 17:28:00 +08:00
|
|
|
|
raise InvalidRequestException(f"GlobalModel '{gm_data['name']}' 已存在")
|
2025-12-16 18:33:14 +08:00
|
|
|
|
elif merge_mode == "overwrite":
|
|
|
|
|
|
# 更新现有记录
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing.display_name = gm_data.get("display_name", existing.display_name)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing.default_price_per_request = gm_data.get(
|
|
|
|
|
|
"default_price_per_request"
|
|
|
|
|
|
)
|
|
|
|
|
|
existing.default_tiered_pricing = gm_data.get(
|
|
|
|
|
|
"default_tiered_pricing", existing.default_tiered_pricing
|
|
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing.supported_capabilities = gm_data.get("supported_capabilities")
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing.config = gm_data.get("config")
|
|
|
|
|
|
existing.is_active = gm_data.get("is_active", True)
|
|
|
|
|
|
existing.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
stats["global_models"]["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 创建新记录
|
|
|
|
|
|
new_gm = GlobalModel(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
|
|
|
|
|
name=gm_data["name"],
|
|
|
|
|
|
display_name=gm_data.get("display_name", gm_data["name"]),
|
|
|
|
|
|
default_price_per_request=gm_data.get("default_price_per_request"),
|
|
|
|
|
|
default_tiered_pricing=gm_data.get(
|
|
|
|
|
|
"default_tiered_pricing",
|
2026-02-01 17:28:00 +08:00
|
|
|
|
{
|
|
|
|
|
|
"tiers": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"up_to": None,
|
|
|
|
|
|
"input_price_per_1m": 0,
|
|
|
|
|
|
"output_price_per_1m": 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
2025-12-16 18:33:14 +08:00
|
|
|
|
),
|
|
|
|
|
|
supported_capabilities=gm_data.get("supported_capabilities"),
|
|
|
|
|
|
config=gm_data.get("config"),
|
|
|
|
|
|
is_active=gm_data.get("is_active", True),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(new_gm)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
global_model_map[gm_data["name"]] = new_gm.id
|
|
|
|
|
|
stats["global_models"]["created"] += 1
|
|
|
|
|
|
|
2026-03-09 13:26:54 +08:00
|
|
|
|
# 导入 ProxyNodes(在 Providers 之前,建立 old_id -> new_id 映射)
|
|
|
|
|
|
proxy_node_id_map: dict[str, str] = {} # old_node_id -> new_node_id
|
|
|
|
|
|
for node_data in proxy_nodes_data:
|
|
|
|
|
|
old_id = node_data.get("id", "")
|
|
|
|
|
|
ip = node_data.get("ip", "")
|
|
|
|
|
|
port = node_data.get("port", 0)
|
|
|
|
|
|
|
|
|
|
|
|
if not ip or not port:
|
|
|
|
|
|
stats["errors"].append(f"跳过无效的代理节点: {node_data.get('name', '?')}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
existing_node = (
|
|
|
|
|
|
db.query(ProxyNode).filter(ProxyNode.ip == ip, ProxyNode.port == port).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if existing_node:
|
|
|
|
|
|
proxy_node_id_map[old_id] = existing_node.id
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["proxy_nodes"]["skipped"] += 1
|
|
|
|
|
|
elif merge_mode == "error":
|
|
|
|
|
|
raise InvalidRequestException(
|
|
|
|
|
|
f"代理节点 '{node_data.get('name')}' ({ip}:{port}) 已存在"
|
|
|
|
|
|
)
|
|
|
|
|
|
elif merge_mode == "overwrite":
|
|
|
|
|
|
existing_node.name = node_data.get("name", existing_node.name)
|
|
|
|
|
|
existing_node.region = node_data.get("region", existing_node.region)
|
|
|
|
|
|
existing_node.is_manual = node_data.get(
|
|
|
|
|
|
"is_manual", existing_node.is_manual
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_node.proxy_url = node_data.get(
|
|
|
|
|
|
"proxy_url", existing_node.proxy_url
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_node.proxy_username = node_data.get(
|
|
|
|
|
|
"proxy_username", existing_node.proxy_username
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_node.proxy_password = node_data.get(
|
|
|
|
|
|
"proxy_password", existing_node.proxy_password
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_node.tunnel_mode = node_data.get(
|
|
|
|
|
|
"tunnel_mode", existing_node.tunnel_mode
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_node.remote_config = node_data.get(
|
|
|
|
|
|
"remote_config", existing_node.remote_config
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_node.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
stats["proxy_nodes"]["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
from src.models.database import ProxyNodeStatus
|
|
|
|
|
|
|
|
|
|
|
|
is_manual = node_data.get("is_manual", False)
|
|
|
|
|
|
new_node = ProxyNode(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
|
|
|
|
|
name=node_data.get("name", "Imported Node"),
|
|
|
|
|
|
ip=ip,
|
|
|
|
|
|
port=port,
|
|
|
|
|
|
region=node_data.get("region"),
|
|
|
|
|
|
is_manual=is_manual,
|
|
|
|
|
|
proxy_url=node_data.get("proxy_url"),
|
|
|
|
|
|
proxy_username=node_data.get("proxy_username"),
|
|
|
|
|
|
proxy_password=node_data.get("proxy_password"),
|
|
|
|
|
|
tunnel_mode=node_data.get("tunnel_mode", False),
|
|
|
|
|
|
heartbeat_interval=node_data.get("heartbeat_interval", 0),
|
|
|
|
|
|
remote_config=node_data.get("remote_config"),
|
|
|
|
|
|
config_version=node_data.get("config_version", 0),
|
|
|
|
|
|
status=ProxyNodeStatus.ONLINE if is_manual else ProxyNodeStatus.OFFLINE,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(new_node)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
proxy_node_id_map[old_id] = new_node.id
|
|
|
|
|
|
stats["proxy_nodes"]["created"] += 1
|
|
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
# 导入 Providers
|
|
|
|
|
|
for prov_data in providers_data:
|
|
|
|
|
|
existing_provider = (
|
|
|
|
|
|
db.query(Provider).filter(Provider.name == prov_data["name"]).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if existing_provider:
|
|
|
|
|
|
provider_id = existing_provider.id
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["providers"]["skipped"] += 1
|
|
|
|
|
|
# 仍然需要处理 endpoints 和 models(如果存在)
|
|
|
|
|
|
elif merge_mode == "error":
|
2026-02-01 17:28:00 +08:00
|
|
|
|
raise InvalidRequestException(f"Provider '{prov_data['name']}' 已存在")
|
2025-12-16 18:33:14 +08:00
|
|
|
|
elif merge_mode == "overwrite":
|
|
|
|
|
|
# 更新现有记录
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_provider.name = prov_data.get("name", existing_provider.name)
|
2026-02-15 17:14:14 +08:00
|
|
|
|
existing_provider.provider_type = prov_data.get(
|
|
|
|
|
|
"provider_type", existing_provider.provider_type
|
|
|
|
|
|
)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_provider.description = prov_data.get("description")
|
|
|
|
|
|
existing_provider.website = prov_data.get("website")
|
|
|
|
|
|
if prov_data.get("billing_type"):
|
|
|
|
|
|
existing_provider.billing_type = ProviderBillingType(
|
|
|
|
|
|
prov_data["billing_type"]
|
|
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_provider.monthly_quota_usd = prov_data.get("monthly_quota_usd")
|
|
|
|
|
|
existing_provider.quota_reset_day = prov_data.get("quota_reset_day", 30)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_provider.provider_priority = prov_data.get(
|
|
|
|
|
|
"provider_priority", 100
|
|
|
|
|
|
)
|
2026-02-15 17:14:14 +08:00
|
|
|
|
existing_provider.keep_priority_on_conversion = prov_data.get(
|
|
|
|
|
|
"keep_priority_on_conversion",
|
|
|
|
|
|
existing_provider.keep_priority_on_conversion,
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_provider.enable_format_conversion = prov_data.get(
|
|
|
|
|
|
"enable_format_conversion",
|
|
|
|
|
|
existing_provider.enable_format_conversion,
|
|
|
|
|
|
)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_provider.is_active = prov_data.get("is_active", True)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_provider.concurrent_limit = prov_data.get("concurrent_limit")
|
2026-01-10 18:43:53 +08:00
|
|
|
|
existing_provider.max_retries = prov_data.get(
|
|
|
|
|
|
"max_retries", existing_provider.max_retries
|
|
|
|
|
|
)
|
2026-02-15 17:14:14 +08:00
|
|
|
|
existing_provider.stream_first_byte_timeout = prov_data.get(
|
|
|
|
|
|
"stream_first_byte_timeout",
|
|
|
|
|
|
existing_provider.stream_first_byte_timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_provider.request_timeout = prov_data.get(
|
|
|
|
|
|
"request_timeout", existing_provider.request_timeout
|
|
|
|
|
|
)
|
2026-03-09 13:26:54 +08:00
|
|
|
|
if "proxy" in prov_data:
|
|
|
|
|
|
existing_provider.proxy = self._remap_proxy_node_id(
|
|
|
|
|
|
prov_data["proxy"],
|
|
|
|
|
|
proxy_node_id_map,
|
|
|
|
|
|
)
|
|
|
|
|
|
# 未提供 proxy 字段时保留现有配置
|
2026-01-28 12:56:37 +08:00
|
|
|
|
# 加密 provider_ops credentials 后再保存
|
|
|
|
|
|
existing_provider.config = self._encrypt_provider_config(
|
|
|
|
|
|
prov_data.get("config"), crypto_service
|
|
|
|
|
|
)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_provider.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
stats["providers"]["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 创建新 Provider
|
|
|
|
|
|
billing_type = ProviderBillingType.PAY_AS_YOU_GO
|
|
|
|
|
|
if prov_data.get("billing_type"):
|
|
|
|
|
|
billing_type = ProviderBillingType(prov_data["billing_type"])
|
|
|
|
|
|
|
2026-01-28 12:56:37 +08:00
|
|
|
|
# 加密 provider_ops credentials 后再保存
|
|
|
|
|
|
encrypted_config = self._encrypt_provider_config(
|
|
|
|
|
|
prov_data.get("config"), crypto_service
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
new_provider = Provider(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
|
|
|
|
|
name=prov_data["name"],
|
2026-02-15 17:14:14 +08:00
|
|
|
|
provider_type=prov_data.get("provider_type", "custom"),
|
2025-12-16 18:33:14 +08:00
|
|
|
|
description=prov_data.get("description"),
|
|
|
|
|
|
website=prov_data.get("website"),
|
|
|
|
|
|
billing_type=billing_type,
|
|
|
|
|
|
monthly_quota_usd=prov_data.get("monthly_quota_usd"),
|
|
|
|
|
|
quota_reset_day=prov_data.get("quota_reset_day", 30),
|
|
|
|
|
|
provider_priority=prov_data.get("provider_priority", 100),
|
2026-02-15 17:14:14 +08:00
|
|
|
|
keep_priority_on_conversion=prov_data.get(
|
|
|
|
|
|
"keep_priority_on_conversion", False
|
|
|
|
|
|
),
|
|
|
|
|
|
enable_format_conversion=prov_data.get("enable_format_conversion", False),
|
2025-12-16 18:33:14 +08:00
|
|
|
|
is_active=prov_data.get("is_active", True),
|
|
|
|
|
|
concurrent_limit=prov_data.get("concurrent_limit"),
|
2026-01-10 18:43:53 +08:00
|
|
|
|
max_retries=prov_data.get("max_retries"),
|
2026-02-15 17:14:14 +08:00
|
|
|
|
stream_first_byte_timeout=prov_data.get("stream_first_byte_timeout"),
|
|
|
|
|
|
request_timeout=prov_data.get("request_timeout"),
|
2026-03-09 13:26:54 +08:00
|
|
|
|
proxy=self._remap_proxy_node_id(prov_data.get("proxy"), proxy_node_id_map),
|
2026-01-28 12:56:37 +08:00
|
|
|
|
config=encrypted_config,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.add(new_provider)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
provider_id = new_provider.id
|
|
|
|
|
|
stats["providers"]["created"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
# 导入 Endpoints
|
|
|
|
|
|
for ep_data in prov_data.get("endpoints", []):
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from src.core.api_format.signature import (
|
|
|
|
|
|
normalize_signature_key,
|
|
|
|
|
|
parse_signature_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
ep_format = normalize_signature_key(ep_data["api_format"])
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_ep = (
|
|
|
|
|
|
db.query(ProviderEndpoint)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
ProviderEndpoint.provider_id == provider_id,
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ProviderEndpoint.api_format == ep_format,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if existing_ep:
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["endpoints"]["skipped"] += 1
|
|
|
|
|
|
elif merge_mode == "error":
|
|
|
|
|
|
raise InvalidRequestException(
|
2026-02-01 17:28:00 +08:00
|
|
|
|
f"Endpoint '{ep_format}' 已存在于 Provider '{prov_data['name']}'"
|
2025-12-16 18:33:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
elif merge_mode == "overwrite":
|
2026-03-12 17:11:14 +08:00
|
|
|
|
normalized_ep = self._normalize_import_endpoint_payload(
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
{**ep_data, "api_format": ep_format},
|
|
|
|
|
|
existing_ep=existing_ep,
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_ep.base_url = normalized_ep["base_url"]
|
|
|
|
|
|
existing_ep.header_rules = normalized_ep.get("header_rules")
|
|
|
|
|
|
existing_ep.body_rules = normalized_ep.get("body_rules")
|
|
|
|
|
|
existing_ep.max_retries = normalized_ep.get("max_retries", 2)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_ep.is_active = ep_data.get("is_active", True)
|
2026-03-12 17:11:14 +08:00
|
|
|
|
existing_ep.custom_path = normalized_ep.get("custom_path")
|
|
|
|
|
|
existing_ep.config = normalized_ep.get("config")
|
|
|
|
|
|
existing_ep.format_acceptance_config = normalized_ep.get(
|
2026-02-15 17:14:14 +08:00
|
|
|
|
"format_acceptance_config"
|
|
|
|
|
|
)
|
2026-03-09 13:26:54 +08:00
|
|
|
|
existing_ep.proxy = self._remap_proxy_node_id(
|
2026-03-12 17:11:14 +08:00
|
|
|
|
normalized_ep.get("proxy"), proxy_node_id_map
|
2026-03-09 13:26:54 +08:00
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
sig = parse_signature_key(ep_format)
|
|
|
|
|
|
existing_ep.api_format = sig.key # 使用归一化后的格式
|
|
|
|
|
|
existing_ep.api_family = sig.api_family.value
|
|
|
|
|
|
existing_ep.endpoint_kind = sig.endpoint_kind.value
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_ep.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
stats["endpoints"]["updated"] += 1
|
|
|
|
|
|
else:
|
2026-03-12 17:11:14 +08:00
|
|
|
|
normalized_ep = self._normalize_import_endpoint_payload(
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
{**ep_data, "api_format": ep_format},
|
|
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
sig = parse_signature_key(ep_format)
|
|
|
|
|
|
api_family = sig.api_family.value
|
|
|
|
|
|
endpoint_kind = sig.endpoint_kind.value
|
2025-12-16 18:33:14 +08:00
|
|
|
|
new_ep = ProviderEndpoint(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
|
|
|
|
|
provider_id=provider_id,
|
2026-02-01 17:28:00 +08:00
|
|
|
|
api_format=sig.key, # 使用归一化后的格式
|
|
|
|
|
|
api_family=api_family,
|
|
|
|
|
|
endpoint_kind=endpoint_kind,
|
2026-03-12 17:11:14 +08:00
|
|
|
|
base_url=normalized_ep["base_url"],
|
|
|
|
|
|
header_rules=normalized_ep.get("header_rules"),
|
|
|
|
|
|
body_rules=normalized_ep.get("body_rules"),
|
|
|
|
|
|
max_retries=normalized_ep.get("max_retries", 2),
|
2025-12-16 18:33:14 +08:00
|
|
|
|
is_active=ep_data.get("is_active", True),
|
2026-03-12 17:11:14 +08:00
|
|
|
|
custom_path=normalized_ep.get("custom_path"),
|
|
|
|
|
|
config=normalized_ep.get("config"),
|
|
|
|
|
|
format_acceptance_config=normalized_ep.get("format_acceptance_config"),
|
2026-03-09 13:26:54 +08:00
|
|
|
|
proxy=self._remap_proxy_node_id(
|
2026-03-12 17:11:14 +08:00
|
|
|
|
normalized_ep.get("proxy"), proxy_node_id_map
|
2026-03-09 13:26:54 +08:00
|
|
|
|
),
|
2025-12-16 18:33:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.add(new_ep)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
stats["endpoints"]["created"] += 1
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
# 导入 Provider Keys(按 provider_id 归属)
|
2026-02-05 16:51:52 +08:00
|
|
|
|
from src.core.api_format.signature import normalize_signature_key
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
endpoint_format_rows = (
|
|
|
|
|
|
db.query(ProviderEndpoint.api_format)
|
|
|
|
|
|
.filter(ProviderEndpoint.provider_id == provider_id)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
endpoint_formats: set[str] = set()
|
|
|
|
|
|
for (api_format,) in endpoint_format_rows:
|
|
|
|
|
|
fmt = api_format.value if hasattr(api_format, "value") else str(api_format)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
endpoint_formats.add(normalize_signature_key(fmt))
|
2026-01-10 18:43:53 +08:00
|
|
|
|
existing_keys = (
|
2026-02-01 17:28:00 +08:00
|
|
|
|
db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == provider_id).all()
|
2026-01-10 18:43:53 +08:00
|
|
|
|
)
|
|
|
|
|
|
existing_key_values = set()
|
|
|
|
|
|
for ek in existing_keys:
|
|
|
|
|
|
try:
|
|
|
|
|
|
decrypted = crypto_service.decrypt(ek.api_key)
|
|
|
|
|
|
existing_key_values.add(decrypted)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
for key_data in prov_data.get("api_keys", []):
|
|
|
|
|
|
if not key_data.get("api_key"):
|
2026-02-01 17:28:00 +08:00
|
|
|
|
stats["errors"].append(f"跳过空 API Key (Provider: {prov_data['name']})")
|
2026-01-10 18:43:53 +08:00
|
|
|
|
continue
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
plaintext_key = key_data["api_key"]
|
|
|
|
|
|
if plaintext_key in existing_key_values:
|
|
|
|
|
|
stats["keys"]["skipped"] += 1
|
|
|
|
|
|
continue
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
raw_formats = self._extract_import_key_api_formats(key_data, endpoint_formats)
|
|
|
|
|
|
if len(raw_formats) == 0:
|
2026-01-10 18:43:53 +08:00
|
|
|
|
stats["errors"].append(
|
|
|
|
|
|
f"跳过无 api_formats 的 Key (Provider: {prov_data['name']})"
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
normalized_formats: list[str] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
missing_formats: list[str] = []
|
|
|
|
|
|
for fmt in raw_formats:
|
|
|
|
|
|
if not isinstance(fmt, str):
|
|
|
|
|
|
continue
|
2026-02-05 16:51:52 +08:00
|
|
|
|
fmt_stripped = fmt.strip()
|
|
|
|
|
|
if not fmt_stripped:
|
|
|
|
|
|
continue
|
|
|
|
|
|
# 使用 normalize_signature_key 归一化,与 endpoint_formats 保持一致
|
|
|
|
|
|
try:
|
|
|
|
|
|
fmt_normalized = normalize_signature_key(fmt_stripped)
|
|
|
|
|
|
except (ValueError, KeyError):
|
|
|
|
|
|
# 无效的格式字符串,跳过
|
|
|
|
|
|
missing_formats.append(fmt_stripped.upper())
|
|
|
|
|
|
continue
|
|
|
|
|
|
if fmt_normalized in seen:
|
2026-01-10 18:43:53 +08:00
|
|
|
|
continue
|
2026-02-05 16:51:52 +08:00
|
|
|
|
seen.add(fmt_normalized)
|
|
|
|
|
|
if endpoint_formats and fmt_normalized not in endpoint_formats:
|
|
|
|
|
|
missing_formats.append(fmt_normalized.upper())
|
2026-01-10 18:43:53 +08:00
|
|
|
|
continue
|
2026-03-09 13:26:54 +08:00
|
|
|
|
normalized_formats.append(fmt_normalized)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
|
|
|
|
|
|
if missing_formats:
|
|
|
|
|
|
stats["errors"].append(
|
|
|
|
|
|
f"Key (Provider: {prov_data['name']}) 的 api_formats 未配置对应 Endpoint,已跳过: {missing_formats}"
|
2025-12-16 18:33:14 +08:00
|
|
|
|
)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
|
|
|
|
|
|
if len(normalized_formats) == 0:
|
|
|
|
|
|
stats["keys"]["skipped"] += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
encrypted_key = crypto_service.encrypt(plaintext_key)
|
|
|
|
|
|
|
2026-02-15 17:14:14 +08:00
|
|
|
|
# 加密 auth_config(如果有)
|
|
|
|
|
|
encrypted_auth_config = None
|
|
|
|
|
|
raw_auth_config = key_data.get("auth_config")
|
|
|
|
|
|
if raw_auth_config:
|
|
|
|
|
|
# auth_config 导出时是解密后的 JSON 字符串,需要重新加密
|
|
|
|
|
|
auth_config_str = (
|
|
|
|
|
|
raw_auth_config
|
|
|
|
|
|
if isinstance(raw_auth_config, str)
|
|
|
|
|
|
else json.dumps(raw_auth_config)
|
|
|
|
|
|
)
|
|
|
|
|
|
encrypted_auth_config = crypto_service.encrypt(auth_config_str)
|
|
|
|
|
|
|
2026-03-05 02:07:34 +08:00
|
|
|
|
from src.services.provider.fingerprint import generate_fingerprint
|
|
|
|
|
|
|
|
|
|
|
|
new_key_id = str(uuid.uuid4())
|
2026-01-10 18:43:53 +08:00
|
|
|
|
new_key = ProviderAPIKey(
|
2026-03-05 02:07:34 +08:00
|
|
|
|
id=new_key_id,
|
2026-01-10 18:43:53 +08:00
|
|
|
|
provider_id=provider_id,
|
|
|
|
|
|
api_formats=normalized_formats,
|
2026-02-15 17:14:14 +08:00
|
|
|
|
auth_type=key_data.get("auth_type", "api_key"),
|
2026-01-10 18:43:53 +08:00
|
|
|
|
api_key=encrypted_key,
|
2026-02-15 17:14:14 +08:00
|
|
|
|
auth_config=encrypted_auth_config,
|
2026-01-10 18:43:53 +08:00
|
|
|
|
name=key_data.get("name") or "Imported Key",
|
|
|
|
|
|
note=key_data.get("note"),
|
|
|
|
|
|
rate_multipliers=key_data.get("rate_multipliers"),
|
|
|
|
|
|
internal_priority=key_data.get("internal_priority", 50),
|
2026-01-16 17:53:27 +08:00
|
|
|
|
global_priority_by_format=key_data.get("global_priority_by_format"),
|
2026-01-10 18:43:53 +08:00
|
|
|
|
rpm_limit=key_data.get("rpm_limit"),
|
|
|
|
|
|
allowed_models=key_data.get("allowed_models"),
|
|
|
|
|
|
capabilities=key_data.get("capabilities"),
|
|
|
|
|
|
cache_ttl_minutes=key_data.get("cache_ttl_minutes", 5),
|
|
|
|
|
|
max_probe_interval_minutes=key_data.get("max_probe_interval_minutes", 32),
|
2026-01-14 20:05:52 +08:00
|
|
|
|
auto_fetch_models=key_data.get("auto_fetch_models", False),
|
|
|
|
|
|
locked_models=key_data.get("locked_models"),
|
2026-02-15 17:14:14 +08:00
|
|
|
|
model_include_patterns=key_data.get("model_include_patterns"),
|
|
|
|
|
|
model_exclude_patterns=key_data.get("model_exclude_patterns"),
|
2026-01-10 18:43:53 +08:00
|
|
|
|
is_active=key_data.get("is_active", True),
|
2026-03-09 13:26:54 +08:00
|
|
|
|
proxy=self._remap_proxy_node_id(key_data.get("proxy"), proxy_node_id_map),
|
2026-03-05 02:07:34 +08:00
|
|
|
|
fingerprint=generate_fingerprint(seed=new_key_id),
|
2026-01-10 18:43:53 +08:00
|
|
|
|
health_by_format={},
|
|
|
|
|
|
circuit_breaker_by_format={},
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(new_key)
|
|
|
|
|
|
existing_key_values.add(plaintext_key)
|
|
|
|
|
|
stats["keys"]["created"] += 1
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
2026-01-14 20:05:52 +08:00
|
|
|
|
# 如果开启了 auto_fetch_models,记录需要触发获取的 Key ID
|
|
|
|
|
|
if key_data.get("auto_fetch_models", False):
|
|
|
|
|
|
if "keys_to_fetch" not in stats:
|
|
|
|
|
|
stats["keys_to_fetch"] = []
|
|
|
|
|
|
stats["keys_to_fetch"].append(new_key.id)
|
|
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
# 导入 Models
|
2026-02-05 20:13:41 +08:00
|
|
|
|
# 注意:提供商模型(Model)必须关联全局模型(GlobalModel)才能参与路由
|
|
|
|
|
|
# 未关联 GlobalModel 的模型会被跳过,这是业务规则而非 bug
|
2025-12-16 18:33:14 +08:00
|
|
|
|
for model_data in prov_data.get("models", []):
|
|
|
|
|
|
global_model_name = model_data.get("global_model_name")
|
|
|
|
|
|
if not global_model_name:
|
|
|
|
|
|
stats["errors"].append(
|
|
|
|
|
|
f"跳过无 global_model_name 的模型 (Provider: {prov_data['name']})"
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
global_model_id = global_model_map.get(global_model_name)
|
|
|
|
|
|
if not global_model_id:
|
|
|
|
|
|
# 尝试从数据库查找
|
|
|
|
|
|
existing_gm = (
|
|
|
|
|
|
db.query(GlobalModel)
|
|
|
|
|
|
.filter(GlobalModel.name == global_model_name)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if existing_gm:
|
|
|
|
|
|
global_model_id = existing_gm.id
|
|
|
|
|
|
else:
|
|
|
|
|
|
stats["errors"].append(
|
|
|
|
|
|
f"GlobalModel '{global_model_name}' 不存在,跳过模型"
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
existing_model = (
|
|
|
|
|
|
db.query(Model)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
Model.provider_id == provider_id,
|
|
|
|
|
|
Model.provider_model_name == model_data["provider_model_name"],
|
|
|
|
|
|
)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if existing_model:
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["models"]["skipped"] += 1
|
|
|
|
|
|
elif merge_mode == "error":
|
|
|
|
|
|
raise InvalidRequestException(
|
|
|
|
|
|
f"Model '{model_data['provider_model_name']}' 已存在于 Provider '{prov_data['name']}'"
|
|
|
|
|
|
)
|
|
|
|
|
|
elif merge_mode == "overwrite":
|
|
|
|
|
|
existing_model.global_model_id = global_model_id
|
2025-12-20 02:39:10 +08:00
|
|
|
|
existing_model.provider_model_mappings = model_data.get(
|
|
|
|
|
|
"provider_model_mappings"
|
2025-12-16 18:33:14 +08:00
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_model.price_per_request = model_data.get("price_per_request")
|
|
|
|
|
|
existing_model.tiered_pricing = model_data.get("tiered_pricing")
|
|
|
|
|
|
existing_model.supports_vision = model_data.get("supports_vision")
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_model.supports_function_calling = model_data.get(
|
|
|
|
|
|
"supports_function_calling"
|
|
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_model.supports_streaming = model_data.get("supports_streaming")
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_model.supports_extended_thinking = model_data.get(
|
|
|
|
|
|
"supports_extended_thinking"
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_model.supports_image_generation = model_data.get(
|
|
|
|
|
|
"supports_image_generation"
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_model.is_active = model_data.get("is_active", True)
|
|
|
|
|
|
existing_model.config = model_data.get("config")
|
|
|
|
|
|
existing_model.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
stats["models"]["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
new_model = Model(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
|
|
|
|
|
provider_id=provider_id,
|
|
|
|
|
|
global_model_id=global_model_id,
|
|
|
|
|
|
provider_model_name=model_data["provider_model_name"],
|
2026-02-01 17:28:00 +08:00
|
|
|
|
provider_model_mappings=model_data.get("provider_model_mappings"),
|
2025-12-16 18:33:14 +08:00
|
|
|
|
price_per_request=model_data.get("price_per_request"),
|
|
|
|
|
|
tiered_pricing=model_data.get("tiered_pricing"),
|
|
|
|
|
|
supports_vision=model_data.get("supports_vision"),
|
2026-02-01 17:28:00 +08:00
|
|
|
|
supports_function_calling=model_data.get("supports_function_calling"),
|
2025-12-16 18:33:14 +08:00
|
|
|
|
supports_streaming=model_data.get("supports_streaming"),
|
2026-02-01 17:28:00 +08:00
|
|
|
|
supports_extended_thinking=model_data.get("supports_extended_thinking"),
|
|
|
|
|
|
supports_image_generation=model_data.get("supports_image_generation"),
|
2025-12-16 18:33:14 +08:00
|
|
|
|
is_active=model_data.get("is_active", True),
|
|
|
|
|
|
config=model_data.get("config"),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(new_model)
|
|
|
|
|
|
stats["models"]["created"] += 1
|
|
|
|
|
|
|
2026-01-28 12:56:37 +08:00
|
|
|
|
# 导入 LDAP 配置(2.1 新增)
|
|
|
|
|
|
if ldap_data:
|
|
|
|
|
|
from src.models.database import LDAPConfig
|
|
|
|
|
|
|
|
|
|
|
|
# 校验必填字段
|
|
|
|
|
|
required_ldap_fields = ["server_url", "bind_dn", "base_dn"]
|
|
|
|
|
|
missing = [f for f in required_ldap_fields if not ldap_data.get(f)]
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
raise InvalidRequestException(f"LDAP 配置缺少必填字段: {', '.join(missing)}")
|
|
|
|
|
|
|
|
|
|
|
|
existing_ldap = db.query(LDAPConfig).first()
|
|
|
|
|
|
|
|
|
|
|
|
if existing_ldap:
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["ldap"]["skipped"] += 1
|
|
|
|
|
|
elif merge_mode == "error":
|
|
|
|
|
|
raise InvalidRequestException("LDAP 配置已存在")
|
|
|
|
|
|
elif merge_mode == "overwrite":
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_ldap.server_url = ldap_data.get(
|
|
|
|
|
|
"server_url", existing_ldap.server_url
|
|
|
|
|
|
)
|
2026-01-28 12:56:37 +08:00
|
|
|
|
existing_ldap.bind_dn = ldap_data.get("bind_dn", existing_ldap.bind_dn)
|
|
|
|
|
|
# 加密绑定密码
|
|
|
|
|
|
if ldap_data.get("bind_password"):
|
|
|
|
|
|
existing_ldap.bind_password_encrypted = crypto_service.encrypt(
|
|
|
|
|
|
ldap_data["bind_password"]
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_ldap.base_dn = ldap_data.get("base_dn", existing_ldap.base_dn)
|
|
|
|
|
|
existing_ldap.user_search_filter = ldap_data.get(
|
|
|
|
|
|
"user_search_filter", existing_ldap.user_search_filter
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_ldap.username_attr = ldap_data.get(
|
|
|
|
|
|
"username_attr", existing_ldap.username_attr
|
|
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_ldap.email_attr = ldap_data.get(
|
|
|
|
|
|
"email_attr", existing_ldap.email_attr
|
|
|
|
|
|
)
|
2026-01-28 12:56:37 +08:00
|
|
|
|
existing_ldap.display_name_attr = ldap_data.get(
|
|
|
|
|
|
"display_name_attr", existing_ldap.display_name_attr
|
|
|
|
|
|
)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_ldap.is_enabled = ldap_data.get(
|
|
|
|
|
|
"is_enabled", existing_ldap.is_enabled
|
|
|
|
|
|
)
|
2026-01-28 12:56:37 +08:00
|
|
|
|
existing_ldap.is_exclusive = ldap_data.get(
|
|
|
|
|
|
"is_exclusive", existing_ldap.is_exclusive
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_ldap.use_starttls = ldap_data.get(
|
|
|
|
|
|
"use_starttls", existing_ldap.use_starttls
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_ldap.connect_timeout = ldap_data.get(
|
|
|
|
|
|
"connect_timeout", existing_ldap.connect_timeout
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_ldap.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
stats["ldap"]["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 创建新的 LDAP 配置
|
|
|
|
|
|
new_ldap = LDAPConfig(
|
|
|
|
|
|
server_url=ldap_data["server_url"],
|
|
|
|
|
|
bind_dn=ldap_data["bind_dn"],
|
|
|
|
|
|
bind_password_encrypted=(
|
|
|
|
|
|
crypto_service.encrypt(ldap_data["bind_password"])
|
2026-02-01 17:28:00 +08:00
|
|
|
|
if ldap_data.get("bind_password")
|
|
|
|
|
|
else None
|
2026-01-28 12:56:37 +08:00
|
|
|
|
),
|
|
|
|
|
|
base_dn=ldap_data["base_dn"],
|
|
|
|
|
|
user_search_filter=ldap_data.get("user_search_filter", "(uid={username})"),
|
|
|
|
|
|
username_attr=ldap_data.get("username_attr", "uid"),
|
|
|
|
|
|
email_attr=ldap_data.get("email_attr", "mail"),
|
|
|
|
|
|
display_name_attr=ldap_data.get("display_name_attr", "cn"),
|
|
|
|
|
|
is_enabled=ldap_data.get("is_enabled", False),
|
|
|
|
|
|
is_exclusive=ldap_data.get("is_exclusive", False),
|
|
|
|
|
|
use_starttls=ldap_data.get("use_starttls", False),
|
|
|
|
|
|
connect_timeout=ldap_data.get("connect_timeout", 10),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(new_ldap)
|
|
|
|
|
|
stats["ldap"]["created"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
# 导入 OAuth Providers(2.1 新增)
|
|
|
|
|
|
if oauth_data:
|
|
|
|
|
|
from src.models.database import OAuthProvider
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-28 12:56:37 +08:00
|
|
|
|
for oauth_item in oauth_data:
|
|
|
|
|
|
provider_type = oauth_item.get("provider_type")
|
|
|
|
|
|
if not provider_type:
|
|
|
|
|
|
stats["errors"].append("跳过无 provider_type 的 OAuth 配置")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
existing_oauth = (
|
|
|
|
|
|
db.query(OAuthProvider)
|
|
|
|
|
|
.filter(OAuthProvider.provider_type == provider_type)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if existing_oauth:
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["oauth"]["skipped"] += 1
|
|
|
|
|
|
elif merge_mode == "error":
|
|
|
|
|
|
raise InvalidRequestException(
|
|
|
|
|
|
f"OAuth Provider '{provider_type}' 已存在"
|
|
|
|
|
|
)
|
|
|
|
|
|
elif merge_mode == "overwrite":
|
|
|
|
|
|
existing_oauth.display_name = oauth_item.get(
|
|
|
|
|
|
"display_name", existing_oauth.display_name
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_oauth.client_id = oauth_item.get(
|
|
|
|
|
|
"client_id", existing_oauth.client_id
|
|
|
|
|
|
)
|
|
|
|
|
|
# 加密 client_secret
|
|
|
|
|
|
if oauth_item.get("client_secret"):
|
|
|
|
|
|
existing_oauth.client_secret_encrypted = crypto_service.encrypt(
|
|
|
|
|
|
oauth_item["client_secret"]
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_oauth.authorization_url_override = oauth_item.get(
|
|
|
|
|
|
"authorization_url_override"
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_oauth.token_url_override = oauth_item.get("token_url_override")
|
|
|
|
|
|
existing_oauth.userinfo_url_override = oauth_item.get(
|
|
|
|
|
|
"userinfo_url_override"
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_oauth.scopes = oauth_item.get("scopes")
|
|
|
|
|
|
existing_oauth.redirect_uri = oauth_item.get(
|
|
|
|
|
|
"redirect_uri", existing_oauth.redirect_uri
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_oauth.frontend_callback_url = oauth_item.get(
|
|
|
|
|
|
"frontend_callback_url", existing_oauth.frontend_callback_url
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_oauth.attribute_mapping = oauth_item.get("attribute_mapping")
|
|
|
|
|
|
existing_oauth.extra_config = oauth_item.get("extra_config")
|
|
|
|
|
|
existing_oauth.is_enabled = oauth_item.get(
|
|
|
|
|
|
"is_enabled", existing_oauth.is_enabled
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_oauth.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
stats["oauth"]["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 创建新的 OAuth Provider - 校验必填字段
|
2026-02-01 17:28:00 +08:00
|
|
|
|
required_oauth_fields = [
|
|
|
|
|
|
"client_id",
|
|
|
|
|
|
"redirect_uri",
|
|
|
|
|
|
"frontend_callback_url",
|
|
|
|
|
|
]
|
2026-01-28 12:56:37 +08:00
|
|
|
|
missing = [f for f in required_oauth_fields if not oauth_item.get(f)]
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
stats["errors"].append(
|
|
|
|
|
|
f"OAuth Provider '{provider_type}' 缺少必填字段: {', '.join(missing)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
new_oauth = OAuthProvider(
|
|
|
|
|
|
provider_type=provider_type,
|
|
|
|
|
|
display_name=oauth_item.get("display_name", provider_type),
|
|
|
|
|
|
client_id=oauth_item["client_id"],
|
|
|
|
|
|
client_secret_encrypted=(
|
|
|
|
|
|
crypto_service.encrypt(oauth_item["client_secret"])
|
2026-02-01 17:28:00 +08:00
|
|
|
|
if oauth_item.get("client_secret")
|
|
|
|
|
|
else None
|
2026-01-28 12:56:37 +08:00
|
|
|
|
),
|
|
|
|
|
|
authorization_url_override=oauth_item.get("authorization_url_override"),
|
|
|
|
|
|
token_url_override=oauth_item.get("token_url_override"),
|
|
|
|
|
|
userinfo_url_override=oauth_item.get("userinfo_url_override"),
|
|
|
|
|
|
scopes=oauth_item.get("scopes"),
|
|
|
|
|
|
redirect_uri=oauth_item["redirect_uri"],
|
|
|
|
|
|
frontend_callback_url=oauth_item["frontend_callback_url"],
|
|
|
|
|
|
attribute_mapping=oauth_item.get("attribute_mapping"),
|
|
|
|
|
|
extra_config=oauth_item.get("extra_config"),
|
|
|
|
|
|
is_enabled=oauth_item.get("is_enabled", False),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(new_oauth)
|
|
|
|
|
|
stats["oauth"]["created"] += 1
|
|
|
|
|
|
|
2026-02-05 20:13:41 +08:00
|
|
|
|
# 导入 SystemConfig(2.2 新增)
|
|
|
|
|
|
if system_configs_data:
|
|
|
|
|
|
from src.models.database import SystemConfig
|
|
|
|
|
|
|
|
|
|
|
|
# 敏感配置项需要加密存储
|
|
|
|
|
|
SENSITIVE_CONFIG_KEYS = {"smtp_password"}
|
|
|
|
|
|
|
|
|
|
|
|
for cfg_item in system_configs_data:
|
|
|
|
|
|
cfg_key = cfg_item.get("key")
|
|
|
|
|
|
if not cfg_key:
|
|
|
|
|
|
stats["errors"].append("跳过无 key 的 SystemConfig 配置")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
existing_cfg = (
|
|
|
|
|
|
db.query(SystemConfig).filter(SystemConfig.key == cfg_key).first()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
cfg_value = cfg_item.get("value")
|
|
|
|
|
|
# 加密敏感配置
|
|
|
|
|
|
if cfg_key in SENSITIVE_CONFIG_KEYS and cfg_value:
|
|
|
|
|
|
cfg_value = crypto_service.encrypt(cfg_value)
|
|
|
|
|
|
|
|
|
|
|
|
if existing_cfg:
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["system_configs"]["skipped"] += 1
|
|
|
|
|
|
elif merge_mode == "error":
|
|
|
|
|
|
raise InvalidRequestException(f"SystemConfig '{cfg_key}' 已存在")
|
|
|
|
|
|
elif merge_mode == "overwrite":
|
|
|
|
|
|
existing_cfg.value = cfg_value
|
|
|
|
|
|
existing_cfg.description = cfg_item.get(
|
|
|
|
|
|
"description", existing_cfg.description
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_cfg.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
stats["system_configs"]["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
new_cfg = SystemConfig(
|
|
|
|
|
|
key=cfg_key,
|
|
|
|
|
|
value=cfg_value,
|
|
|
|
|
|
description=cfg_item.get("description"),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(new_cfg)
|
|
|
|
|
|
stats["system_configs"]["created"] += 1
|
|
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# 失效缓存
|
|
|
|
|
|
from src.services.cache.invalidation import get_cache_invalidation_service
|
|
|
|
|
|
|
|
|
|
|
|
cache_service = get_cache_invalidation_service()
|
2025-12-17 19:15:08 +08:00
|
|
|
|
cache_service.clear_all_caches()
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
2026-01-14 20:05:52 +08:00
|
|
|
|
# 触发开启了 auto_fetch_models 的 Key 的模型获取
|
|
|
|
|
|
keys_to_fetch = stats.get("keys_to_fetch", [])
|
|
|
|
|
|
if keys_to_fetch:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
f"[AUTO_FETCH] 导入了 {len(keys_to_fetch)} 个开启自动获取模型的 Key,触发模型获取"
|
|
|
|
|
|
)
|
2026-01-14 20:05:52 +08:00
|
|
|
|
try:
|
|
|
|
|
|
import asyncio
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
|
|
|
|
|
from src.services.model.fetch_scheduler import get_model_fetch_scheduler
|
2026-03-10 14:42:33 +08:00
|
|
|
|
from src.utils.async_utils import safe_create_task
|
2026-02-01 17:28:00 +08:00
|
|
|
|
|
2026-01-14 20:05:52 +08:00
|
|
|
|
scheduler = get_model_fetch_scheduler()
|
|
|
|
|
|
for key_id in keys_to_fetch:
|
2026-03-10 14:42:33 +08:00
|
|
|
|
safe_create_task(scheduler._fetch_models_for_key_by_id(key_id))
|
2026-01-14 20:05:52 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"触发模型获取失败: {e}")
|
|
|
|
|
|
# 不影响导入成功的返回
|
|
|
|
|
|
# 从统计信息中移除内部字段
|
|
|
|
|
|
stats.pop("keys_to_fetch", None)
|
|
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"message": "配置导入成功",
|
|
|
|
|
|
"stats": stats,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except InvalidRequestException:
|
|
|
|
|
|
db.rollback()
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
db.rollback()
|
|
|
|
|
|
raise InvalidRequestException(f"导入失败: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminExportUsersAdapter(AdminApiAdapter):
|
2026-03-06 21:06:45 +08:00
|
|
|
|
@staticmethod
|
2026-03-08 00:05:48 +08:00
|
|
|
|
def _serialize_api_key(
|
2026-03-09 13:26:54 +08:00
|
|
|
|
key: ApiKey,
|
|
|
|
|
|
include_is_standalone: bool = False,
|
|
|
|
|
|
db: Any = None,
|
2026-03-08 00:05:48 +08:00
|
|
|
|
) -> dict[str, Any]:
|
2026-03-06 21:06:45 +08:00
|
|
|
|
"""序列化用户 API Key 为导出格式。"""
|
|
|
|
|
|
from src.core.crypto import crypto_service
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
wallet = None
|
|
|
|
|
|
if db is not None and key.is_standalone:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
wallet = _wallet_service().get_wallet(db, api_key_id=key.id)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
data: dict[str, Any] = {
|
2026-03-06 21:06:45 +08:00
|
|
|
|
"key_hash": key.key_hash,
|
|
|
|
|
|
"name": key.name,
|
|
|
|
|
|
"allowed_providers": key.allowed_providers,
|
|
|
|
|
|
"allowed_api_formats": key.allowed_api_formats,
|
|
|
|
|
|
"allowed_models": key.allowed_models,
|
|
|
|
|
|
"rate_limit": key.rate_limit,
|
|
|
|
|
|
"concurrent_limit": key.concurrent_limit,
|
|
|
|
|
|
"force_capabilities": key.force_capabilities,
|
|
|
|
|
|
"is_active": key.is_active,
|
|
|
|
|
|
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
|
|
|
|
|
|
"auto_delete_on_expiry": key.auto_delete_on_expiry,
|
|
|
|
|
|
"total_requests": key.total_requests,
|
|
|
|
|
|
"total_cost_usd": key.total_cost_usd,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
"wallet": _wallet_service().serialize_wallet_summary(wallet) if wallet else None,
|
2026-03-06 21:06:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if key.key_encrypted:
|
|
|
|
|
|
try:
|
|
|
|
|
|
data["key"] = crypto_service.decrypt(key.key_encrypted, silent=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[USERS_EXPORT] API Key 解密失败,回退为 legacy 密文字段: key_id={}", key.id
|
|
|
|
|
|
)
|
|
|
|
|
|
data["key_encrypted"] = key.key_encrypted
|
|
|
|
|
|
|
|
|
|
|
|
if include_is_standalone:
|
|
|
|
|
|
data["is_standalone"] = key.is_standalone
|
|
|
|
|
|
|
|
|
|
|
|
return data
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-03-06 21:06:45 +08:00
|
|
|
|
"""导出用户数据(优先导出解密后的完整 Key,排除管理员)"""
|
2025-12-16 18:33:14 +08:00
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
|
|
from src.core.enums import UserRole
|
|
|
|
|
|
from src.models.database import ApiKey, User
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
wallet_service = _wallet_service()
|
|
|
|
|
|
|
|
|
|
|
|
# 导出 Users(排除管理员),预加载非独立余额 Key,避免 N+1
|
|
|
|
|
|
users = (
|
|
|
|
|
|
db.query(User)
|
|
|
|
|
|
.options(selectinload(User.api_keys))
|
|
|
|
|
|
.filter(User.is_deleted.is_(False), User.role != UserRole.ADMIN)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
wallet_map = wallet_service.get_wallets_by_user_ids(db, [user.id for user in users])
|
2025-12-16 18:33:14 +08:00
|
|
|
|
users_data = []
|
|
|
|
|
|
for user in users:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
wallet = wallet_map.get(user.id)
|
2026-01-05 18:18:45 +08:00
|
|
|
|
# 导出用户的 API Keys(排除独立余额Key,独立Key单独导出)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
api_keys_data = [
|
2026-03-14 11:59:07 +08:00
|
|
|
|
self._serialize_api_key(key, include_is_standalone=True)
|
|
|
|
|
|
for key in user.api_keys
|
|
|
|
|
|
if not key.is_standalone
|
2026-02-01 17:28:00 +08:00
|
|
|
|
]
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
users_data.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"email": user.email,
|
2026-03-06 21:06:45 +08:00
|
|
|
|
"email_verified": user.email_verified,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"username": user.username,
|
|
|
|
|
|
"password_hash": user.password_hash,
|
|
|
|
|
|
"role": user.role.value if user.role else "user",
|
|
|
|
|
|
"allowed_providers": user.allowed_providers,
|
2026-01-07 19:53:32 +08:00
|
|
|
|
"allowed_api_formats": user.allowed_api_formats,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"allowed_models": user.allowed_models,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
"rate_limit": user.rate_limit,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"model_capability_settings": user.model_capability_settings,
|
2026-03-14 11:59:07 +08:00
|
|
|
|
"unlimited": wallet_service.is_unlimited_wallet(wallet),
|
|
|
|
|
|
"wallet": (wallet_service.serialize_wallet_summary(wallet) if wallet else None),
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"is_active": user.is_active,
|
|
|
|
|
|
"api_keys": api_keys_data,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-05 18:18:45 +08:00
|
|
|
|
# 导出独立余额 Keys(管理员创建的,不属于普通用户)
|
|
|
|
|
|
standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all()
|
2026-03-08 00:05:48 +08:00
|
|
|
|
standalone_keys_data = [self._serialize_api_key(key, db=db) for key in standalone_keys]
|
2026-01-05 18:18:45 +08:00
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
return {
|
2026-03-15 14:22:59 +08:00
|
|
|
|
"version": "1.3",
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
"users": users_data,
|
2026-01-05 18:18:45 +08:00
|
|
|
|
"standalone_keys": standalone_keys_data,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminImportUsersAdapter(AdminApiAdapter):
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_legacy_users_export(version: object) -> bool:
|
|
|
|
|
|
if version is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
normalized = str(version).strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
parts = normalized.split(".")
|
|
|
|
|
|
return (int(parts[0]), int(parts[1])) < (1, 3)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-03-06 21:06:45 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_api_key_material(key_data: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
|
|
|
|
"""解析用户 API Key 导入材料,优先使用明文 key。"""
|
|
|
|
|
|
from src.core.crypto import crypto_service
|
|
|
|
|
|
from src.models.database import ApiKey
|
|
|
|
|
|
|
|
|
|
|
|
plaintext_key = key_data.get("key")
|
|
|
|
|
|
if isinstance(plaintext_key, str):
|
|
|
|
|
|
normalized = plaintext_key.strip()
|
|
|
|
|
|
if normalized:
|
|
|
|
|
|
return ApiKey.hash_key(normalized), crypto_service.encrypt(normalized)
|
|
|
|
|
|
|
|
|
|
|
|
key_hash = str(key_data.get("key_hash") or "").strip() or None
|
|
|
|
|
|
key_encrypted = key_data.get("key_encrypted")
|
|
|
|
|
|
return key_hash, key_encrypted
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _normalize_imported_user_rate_limit(user_data: dict[str, Any]) -> int | None:
|
|
|
|
|
|
if "rate_limit" not in user_data:
|
|
|
|
|
|
return None
|
|
|
|
|
|
value = user_data.get("rate_limit")
|
|
|
|
|
|
return int(value) if value is not None else None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _normalize_imported_api_key_rate_limit(
|
|
|
|
|
|
key_data: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
is_standalone: bool,
|
|
|
|
|
|
legacy_export: bool,
|
|
|
|
|
|
) -> int | None:
|
|
|
|
|
|
if "rate_limit" not in key_data:
|
|
|
|
|
|
return None if is_standalone and not legacy_export else 0
|
|
|
|
|
|
|
|
|
|
|
|
value = key_data.get("rate_limit")
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
if is_standalone and not legacy_export:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return 0
|
|
|
|
|
|
return int(value)
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _normalize_imported_access_list(raw_value: Any) -> list[str] | None | Any:
|
|
|
|
|
|
if raw_value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
candidate = raw_value
|
|
|
|
|
|
if isinstance(candidate, str):
|
|
|
|
|
|
normalized = candidate.strip()
|
|
|
|
|
|
if not normalized or normalized.lower() == "null":
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
decoded = json.loads(normalized)
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
decoded = normalized
|
|
|
|
|
|
if isinstance(decoded, list):
|
|
|
|
|
|
candidate = decoded
|
|
|
|
|
|
elif isinstance(decoded, str):
|
|
|
|
|
|
candidate = [decoded]
|
|
|
|
|
|
elif decoded is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
else:
|
|
|
|
|
|
candidate = [normalized]
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(candidate, list):
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
|
|
|
|
|
|
normalized_items: list[str] = []
|
|
|
|
|
|
for item in candidate:
|
|
|
|
|
|
if not isinstance(item, str):
|
|
|
|
|
|
continue
|
|
|
|
|
|
value = item.strip()
|
|
|
|
|
|
if value:
|
|
|
|
|
|
normalized_items.append(value)
|
|
|
|
|
|
return normalized_items
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"""导入用户数据"""
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
|
|
from src.core.enums import UserRole
|
|
|
|
|
|
from src.models.database import ApiKey, User
|
|
|
|
|
|
|
|
|
|
|
|
# 检查请求体大小
|
|
|
|
|
|
if context.raw_body and len(context.raw_body) > MAX_IMPORT_SIZE:
|
|
|
|
|
|
raise InvalidRequestException("请求体大小不能超过 10MB")
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
|
|
|
|
|
|
# 获取导入选项
|
|
|
|
|
|
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
|
2026-03-15 14:22:59 +08:00
|
|
|
|
legacy_export = self._is_legacy_users_export(payload.get("version"))
|
2025-12-16 18:33:14 +08:00
|
|
|
|
users_data = payload.get("users", [])
|
2026-01-05 18:18:45 +08:00
|
|
|
|
standalone_keys_data = payload.get("standalone_keys", [])
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
stats = {
|
|
|
|
|
|
"users": {"created": 0, "updated": 0, "skipped": 0},
|
|
|
|
|
|
"api_keys": {"created": 0, "skipped": 0},
|
2026-01-05 18:18:45 +08:00
|
|
|
|
"standalone_keys": {"created": 0, "skipped": 0},
|
2025-12-16 18:33:14 +08:00
|
|
|
|
"errors": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-05 18:18:45 +08:00
|
|
|
|
def _create_api_key_from_data(
|
|
|
|
|
|
key_data: dict,
|
|
|
|
|
|
owner_id: str,
|
|
|
|
|
|
is_standalone: bool = False,
|
|
|
|
|
|
) -> tuple[ApiKey | None, str]:
|
|
|
|
|
|
"""从导入数据创建 ApiKey 对象
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(ApiKey, "created"): 成功创建
|
|
|
|
|
|
(None, "skipped"): key 已存在,跳过
|
|
|
|
|
|
(None, "invalid"): 数据无效,跳过
|
|
|
|
|
|
"""
|
2026-03-06 21:06:45 +08:00
|
|
|
|
key_hash, key_encrypted = self._resolve_api_key_material(key_data)
|
2026-01-05 18:18:45 +08:00
|
|
|
|
if not key_hash:
|
|
|
|
|
|
return None, "invalid"
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否已存在
|
|
|
|
|
|
existing = db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
return None, "skipped"
|
|
|
|
|
|
|
|
|
|
|
|
# 解析 expires_at
|
|
|
|
|
|
expires_at = None
|
|
|
|
|
|
if key_data.get("expires_at"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
expires_at = datetime.fromisoformat(key_data["expires_at"])
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
stats["errors"].append(
|
|
|
|
|
|
f"API Key '{key_data.get('name', key_hash[:8])}' 的 expires_at 格式无效"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
return (
|
|
|
|
|
|
ApiKey(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
|
|
|
|
|
user_id=owner_id,
|
|
|
|
|
|
key_hash=key_hash,
|
2026-03-06 21:06:45 +08:00
|
|
|
|
key_encrypted=key_encrypted,
|
2026-02-01 17:28:00 +08:00
|
|
|
|
name=key_data.get("name"),
|
|
|
|
|
|
is_standalone=is_standalone or key_data.get("is_standalone", False),
|
2026-03-31 19:19:04 +08:00
|
|
|
|
allowed_providers=self._normalize_imported_access_list(
|
|
|
|
|
|
key_data.get("allowed_providers")
|
|
|
|
|
|
),
|
|
|
|
|
|
allowed_api_formats=self._normalize_imported_access_list(
|
|
|
|
|
|
key_data.get("allowed_api_formats")
|
|
|
|
|
|
),
|
|
|
|
|
|
allowed_models=self._normalize_imported_access_list(
|
|
|
|
|
|
key_data.get("allowed_models")
|
|
|
|
|
|
),
|
2026-03-15 14:22:59 +08:00
|
|
|
|
rate_limit=self._normalize_imported_api_key_rate_limit(
|
|
|
|
|
|
key_data,
|
|
|
|
|
|
is_standalone=is_standalone or key_data.get("is_standalone", False),
|
|
|
|
|
|
legacy_export=legacy_export,
|
|
|
|
|
|
),
|
2026-02-01 17:28:00 +08:00
|
|
|
|
concurrent_limit=key_data.get("concurrent_limit", 5),
|
|
|
|
|
|
force_capabilities=key_data.get("force_capabilities"),
|
|
|
|
|
|
is_active=key_data.get("is_active", True),
|
|
|
|
|
|
expires_at=expires_at,
|
|
|
|
|
|
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
|
|
|
|
|
|
total_requests=key_data.get("total_requests", 0),
|
|
|
|
|
|
total_cost_usd=key_data.get("total_cost_usd", 0.0),
|
|
|
|
|
|
),
|
|
|
|
|
|
"created",
|
|
|
|
|
|
)
|
2026-01-05 18:18:45 +08:00
|
|
|
|
|
2025-12-16 18:33:14 +08:00
|
|
|
|
try:
|
|
|
|
|
|
for user_data in users_data:
|
|
|
|
|
|
# 跳过管理员角色的导入(不区分大小写)
|
|
|
|
|
|
role_str = str(user_data.get("role", "")).lower()
|
|
|
|
|
|
if role_str == "admin":
|
|
|
|
|
|
stats["errors"].append(f"跳过管理员用户: {user_data.get('email')}")
|
|
|
|
|
|
stats["users"]["skipped"] += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-01-19 03:19:17 +08:00
|
|
|
|
# 导入必须有邮箱(email 是导入的主键)
|
|
|
|
|
|
import_email = user_data.get("email")
|
|
|
|
|
|
if not import_email:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
stats["errors"].append(f"跳过无邮箱用户: {user_data.get('username', '未知')}")
|
2026-01-19 03:19:17 +08:00
|
|
|
|
stats["users"]["skipped"] += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_user = db.query(User).filter(User.email == import_email).first()
|
2026-03-08 00:05:48 +08:00
|
|
|
|
wallet_payload = (
|
|
|
|
|
|
user_data.get("wallet") if isinstance(user_data.get("wallet"), dict) else None
|
|
|
|
|
|
)
|
|
|
|
|
|
wallet_limit_mode = (
|
|
|
|
|
|
str(wallet_payload.get("limit_mode"))
|
2026-03-09 13:26:54 +08:00
|
|
|
|
if wallet_payload
|
|
|
|
|
|
and wallet_payload.get("limit_mode") in {"finite", "unlimited"}
|
2026-03-08 00:05:48 +08:00
|
|
|
|
else ("unlimited" if user_data.get("unlimited") else "finite")
|
|
|
|
|
|
)
|
2026-03-15 14:22:59 +08:00
|
|
|
|
imported_user_rate_limit = self._normalize_imported_user_rate_limit(user_data)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
if existing_user:
|
|
|
|
|
|
user_id = existing_user.id
|
|
|
|
|
|
if merge_mode == "skip":
|
|
|
|
|
|
stats["users"]["skipped"] += 1
|
|
|
|
|
|
elif merge_mode == "error":
|
2026-02-01 17:28:00 +08:00
|
|
|
|
raise InvalidRequestException(f"用户 '{import_email}' 已存在")
|
2025-12-16 18:33:14 +08:00
|
|
|
|
elif merge_mode == "overwrite":
|
|
|
|
|
|
# 更新现有用户
|
2026-02-01 17:28:00 +08:00
|
|
|
|
existing_user.username = user_data.get("username", existing_user.username)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
if user_data.get("password_hash"):
|
|
|
|
|
|
existing_user.password_hash = user_data["password_hash"]
|
|
|
|
|
|
if user_data.get("role"):
|
|
|
|
|
|
existing_user.role = UserRole(user_data["role"])
|
2026-03-31 19:19:04 +08:00
|
|
|
|
existing_user.allowed_providers = self._normalize_imported_access_list(
|
|
|
|
|
|
user_data.get("allowed_providers")
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_user.allowed_api_formats = self._normalize_imported_access_list(
|
|
|
|
|
|
user_data.get("allowed_api_formats")
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_user.allowed_models = self._normalize_imported_access_list(
|
|
|
|
|
|
user_data.get("allowed_models")
|
|
|
|
|
|
)
|
2026-03-15 14:22:59 +08:00
|
|
|
|
existing_user.rate_limit = imported_user_rate_limit
|
2025-12-16 18:33:14 +08:00
|
|
|
|
existing_user.model_capability_settings = user_data.get(
|
|
|
|
|
|
"model_capability_settings"
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_user.is_active = user_data.get("is_active", True)
|
|
|
|
|
|
existing_user.updated_at = datetime.now(timezone.utc)
|
2026-03-14 11:59:07 +08:00
|
|
|
|
wallet = _wallet_service().get_or_create_wallet(db, user=existing_user)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
if wallet is not None:
|
|
|
|
|
|
wallet.limit_mode = wallet_limit_mode
|
|
|
|
|
|
if wallet_payload:
|
|
|
|
|
|
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
|
|
|
|
|
|
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
|
2026-03-09 13:26:54 +08:00
|
|
|
|
wallet.total_recharged = (
|
|
|
|
|
|
wallet_payload.get("total_recharged", 0) or 0
|
|
|
|
|
|
)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
wallet.total_consumed = wallet_payload.get("total_consumed", 0) or 0
|
|
|
|
|
|
wallet.total_refunded = wallet_payload.get("total_refunded", 0) or 0
|
|
|
|
|
|
wallet.total_adjusted = wallet_payload.get("total_adjusted", 0) or 0
|
|
|
|
|
|
wallet.status = wallet_payload.get("status", "active") or "active"
|
|
|
|
|
|
wallet.updated_at = datetime.now(timezone.utc)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
stats["users"]["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 创建新用户
|
|
|
|
|
|
role = UserRole.USER
|
|
|
|
|
|
if user_data.get("role"):
|
|
|
|
|
|
role = UserRole(user_data["role"])
|
|
|
|
|
|
|
|
|
|
|
|
new_user = User(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
2026-01-19 03:19:17 +08:00
|
|
|
|
email=import_email,
|
|
|
|
|
|
email_verified=user_data.get("email_verified", True),
|
|
|
|
|
|
username=user_data.get("username") or import_email.split("@")[0],
|
2025-12-16 18:33:14 +08:00
|
|
|
|
password_hash=user_data.get("password_hash", ""),
|
|
|
|
|
|
role=role,
|
2026-03-31 19:19:04 +08:00
|
|
|
|
allowed_providers=self._normalize_imported_access_list(
|
|
|
|
|
|
user_data.get("allowed_providers")
|
|
|
|
|
|
),
|
|
|
|
|
|
allowed_api_formats=self._normalize_imported_access_list(
|
|
|
|
|
|
user_data.get("allowed_api_formats")
|
|
|
|
|
|
),
|
|
|
|
|
|
allowed_models=self._normalize_imported_access_list(
|
|
|
|
|
|
user_data.get("allowed_models")
|
|
|
|
|
|
),
|
2026-03-15 14:22:59 +08:00
|
|
|
|
rate_limit=imported_user_rate_limit,
|
2025-12-16 18:33:14 +08:00
|
|
|
|
model_capability_settings=user_data.get("model_capability_settings"),
|
|
|
|
|
|
is_active=user_data.get("is_active", True),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(new_user)
|
|
|
|
|
|
db.flush()
|
2026-03-14 11:59:07 +08:00
|
|
|
|
wallet = _wallet_service().get_or_create_wallet(db, user=new_user)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
if wallet is not None:
|
|
|
|
|
|
wallet.limit_mode = wallet_limit_mode
|
|
|
|
|
|
if wallet_payload:
|
|
|
|
|
|
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
|
|
|
|
|
|
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
|
|
|
|
|
|
wallet.total_recharged = wallet_payload.get("total_recharged", 0) or 0
|
|
|
|
|
|
wallet.total_consumed = wallet_payload.get("total_consumed", 0) or 0
|
|
|
|
|
|
wallet.total_refunded = wallet_payload.get("total_refunded", 0) or 0
|
|
|
|
|
|
wallet.total_adjusted = wallet_payload.get("total_adjusted", 0) or 0
|
|
|
|
|
|
wallet.status = wallet_payload.get("status", "active") or "active"
|
|
|
|
|
|
wallet.updated_at = datetime.now(timezone.utc)
|
2025-12-16 18:33:14 +08:00
|
|
|
|
user_id = new_user.id
|
|
|
|
|
|
stats["users"]["created"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
# 导入 API Keys
|
|
|
|
|
|
for key_data in user_data.get("api_keys", []):
|
2026-01-05 18:18:45 +08:00
|
|
|
|
new_key, status = _create_api_key_from_data(key_data, user_id)
|
|
|
|
|
|
if new_key:
|
|
|
|
|
|
db.add(new_key)
|
|
|
|
|
|
stats["api_keys"]["created"] += 1
|
|
|
|
|
|
elif status == "skipped":
|
|
|
|
|
|
stats["api_keys"]["skipped"] += 1
|
|
|
|
|
|
# invalid 数据不计入统计
|
|
|
|
|
|
|
|
|
|
|
|
# 导入独立余额 Keys(需要找一个管理员用户作为 owner)
|
|
|
|
|
|
if standalone_keys_data:
|
|
|
|
|
|
# 查找一个管理员用户作为独立Key的owner
|
|
|
|
|
|
admin_user = db.query(User).filter(User.role == UserRole.ADMIN).first()
|
|
|
|
|
|
if not admin_user:
|
|
|
|
|
|
stats["errors"].append("无法导入独立余额Key: 系统中没有管理员用户")
|
|
|
|
|
|
else:
|
|
|
|
|
|
for key_data in standalone_keys_data:
|
|
|
|
|
|
new_key, status = _create_api_key_from_data(
|
|
|
|
|
|
key_data, admin_user.id, is_standalone=True
|
2025-12-16 18:33:14 +08:00
|
|
|
|
)
|
2026-01-05 18:18:45 +08:00
|
|
|
|
if new_key:
|
|
|
|
|
|
db.add(new_key)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
db.flush()
|
2026-03-14 11:59:07 +08:00
|
|
|
|
wallet = _wallet_service().get_or_create_wallet(db, api_key=new_key)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
wallet_payload = (
|
|
|
|
|
|
key_data.get("wallet")
|
|
|
|
|
|
if isinstance(key_data.get("wallet"), dict)
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
if wallet is not None:
|
|
|
|
|
|
wallet.limit_mode = (
|
|
|
|
|
|
str(wallet_payload.get("limit_mode"))
|
|
|
|
|
|
if wallet_payload
|
2026-03-09 13:26:54 +08:00
|
|
|
|
and wallet_payload.get("limit_mode") in {"finite", "unlimited"}
|
2026-03-08 00:05:48 +08:00
|
|
|
|
else ("unlimited" if key_data.get("unlimited") else "finite")
|
|
|
|
|
|
)
|
|
|
|
|
|
if wallet_payload:
|
|
|
|
|
|
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
|
|
|
|
|
|
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
|
|
|
|
|
|
wallet.total_recharged = (
|
|
|
|
|
|
wallet_payload.get("total_recharged", 0) or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
wallet.total_consumed = (
|
|
|
|
|
|
wallet_payload.get("total_consumed", 0) or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
wallet.total_refunded = (
|
|
|
|
|
|
wallet_payload.get("total_refunded", 0) or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
wallet.total_adjusted = (
|
|
|
|
|
|
wallet_payload.get("total_adjusted", 0) or 0
|
|
|
|
|
|
)
|
2026-03-09 13:26:54 +08:00
|
|
|
|
wallet.status = (
|
|
|
|
|
|
wallet_payload.get("status", "active") or "active"
|
|
|
|
|
|
)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
wallet.updated_at = datetime.now(timezone.utc)
|
2026-01-05 18:18:45 +08:00
|
|
|
|
stats["standalone_keys"]["created"] += 1
|
|
|
|
|
|
elif status == "skipped":
|
|
|
|
|
|
stats["standalone_keys"]["skipped"] += 1
|
|
|
|
|
|
# invalid 数据不计入统计
|
2025-12-16 18:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "用户数据导入成功",
|
|
|
|
|
|
"stats": stats,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except InvalidRequestException:
|
|
|
|
|
|
db.rollback()
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
db.rollback()
|
|
|
|
|
|
raise InvalidRequestException(f"导入失败: {str(e)}")
|
2025-12-30 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminTestSmtpAdapter(AdminApiAdapter):
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-30 17:15:48 +08:00
|
|
|
|
"""测试 SMTP 连接"""
|
2026-01-01 02:10:19 +08:00
|
|
|
|
from src.core.crypto import crypto_service
|
|
|
|
|
|
from src.services.email.email_sender import EmailSenderService
|
2025-12-30 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
payload = context.ensure_json_body() or {}
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 获取密码:优先使用前端传入的明文密码,否则从数据库获取并解密
|
|
|
|
|
|
smtp_password = payload.get("smtp_password")
|
|
|
|
|
|
if not smtp_password:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
encrypted_password = _system_config_service().get_config(db, "smtp_password")
|
2026-01-01 02:10:19 +08:00
|
|
|
|
if encrypted_password:
|
|
|
|
|
|
try:
|
|
|
|
|
|
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# 解密失败,可能是旧的未加密密码
|
|
|
|
|
|
smtp_password = encrypted_password
|
|
|
|
|
|
|
2025-12-30 17:15:48 +08:00
|
|
|
|
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
|
|
|
|
|
|
config = {
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"smtp_host": payload.get("smtp_host")
|
2026-03-14 11:59:07 +08:00
|
|
|
|
or _system_config_service().get_config(db, "smtp_host"),
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"smtp_port": payload.get("smtp_port")
|
2026-03-14 11:59:07 +08:00
|
|
|
|
or _system_config_service().get_config(db, "smtp_port", default=587),
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"smtp_user": payload.get("smtp_user")
|
2026-03-14 11:59:07 +08:00
|
|
|
|
or _system_config_service().get_config(db, "smtp_user"),
|
2026-01-01 02:10:19 +08:00
|
|
|
|
"smtp_password": smtp_password,
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"smtp_use_tls": (
|
|
|
|
|
|
payload.get("smtp_use_tls")
|
|
|
|
|
|
if payload.get("smtp_use_tls") is not None
|
2026-03-14 11:59:07 +08:00
|
|
|
|
else _system_config_service().get_config(db, "smtp_use_tls", default=True)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
),
|
|
|
|
|
|
"smtp_use_ssl": (
|
|
|
|
|
|
payload.get("smtp_use_ssl")
|
|
|
|
|
|
if payload.get("smtp_use_ssl") is not None
|
2026-03-14 11:59:07 +08:00
|
|
|
|
else _system_config_service().get_config(db, "smtp_use_ssl", default=False)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
),
|
2025-12-30 17:15:48 +08:00
|
|
|
|
"smtp_from_email": payload.get("smtp_from_email")
|
2026-03-14 11:59:07 +08:00
|
|
|
|
or _system_config_service().get_config(db, "smtp_from_email"),
|
2025-12-30 17:15:48 +08:00
|
|
|
|
"smtp_from_name": payload.get("smtp_from_name")
|
2026-03-14 11:59:07 +08:00
|
|
|
|
or _system_config_service().get_config(db, "smtp_from_name", default="Aether"),
|
2025-12-30 17:15:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 验证必要配置
|
|
|
|
|
|
missing_fields = [
|
2026-02-01 17:28:00 +08:00
|
|
|
|
field
|
|
|
|
|
|
for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"]
|
|
|
|
|
|
if not config.get(field)
|
2025-12-30 17:15:48 +08:00
|
|
|
|
]
|
|
|
|
|
|
if missing_fields:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": False,
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}",
|
2025-12-30 17:15:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 测试连接
|
|
|
|
|
|
try:
|
|
|
|
|
|
success, error_msg = await EmailSenderService.test_smtp_connection(
|
|
|
|
|
|
db=db, override_config=config
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if success:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
return {"success": True, "message": "SMTP 连接测试成功"}
|
2025-12-30 17:15:48 +08:00
|
|
|
|
else:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
return {"success": False, "message": error_msg}
|
2025-12-30 17:15:48 +08:00
|
|
|
|
except Exception as e:
|
2026-02-01 17:28:00 +08:00
|
|
|
|
return {"success": False, "message": str(e)}
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -------- 邮件模板适配器 --------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminGetEmailTemplatesAdapter(AdminApiAdapter):
|
|
|
|
|
|
"""获取所有邮件模板"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-01-01 02:10:19 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
templates = []
|
|
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
for template_type, type_info in _email_template_service().TEMPLATE_TYPES.items():
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 获取自定义模板或默认模板
|
2026-03-14 11:59:07 +08:00
|
|
|
|
template = _email_template_service().get_template(db, template_type)
|
|
|
|
|
|
default_template = _email_template_service().get_default_template(template_type)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
# 检查是否使用了自定义模板
|
|
|
|
|
|
is_custom = (
|
|
|
|
|
|
template["subject"] != default_template["subject"]
|
|
|
|
|
|
or template["html"] != default_template["html"]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
templates.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": template_type,
|
|
|
|
|
|
"name": type_info["name"],
|
|
|
|
|
|
"variables": type_info["variables"],
|
|
|
|
|
|
"subject": template["subject"],
|
|
|
|
|
|
"html": template["html"],
|
|
|
|
|
|
"is_custom": is_custom,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"templates": templates}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminGetEmailTemplateAdapter(AdminApiAdapter):
|
|
|
|
|
|
"""获取指定类型的邮件模板"""
|
|
|
|
|
|
|
|
|
|
|
|
template_type: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 验证模板类型
|
2026-03-14 11:59:07 +08:00
|
|
|
|
if self.template_type not in _email_template_service().TEMPLATE_TYPES:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
2026-03-14 11:59:07 +08:00
|
|
|
|
type_info = _email_template_service().TEMPLATE_TYPES[self.template_type]
|
|
|
|
|
|
template = _email_template_service().get_template(db, self.template_type)
|
|
|
|
|
|
default_template = _email_template_service().get_default_template(self.template_type)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
is_custom = (
|
|
|
|
|
|
template["subject"] != default_template["subject"]
|
|
|
|
|
|
or template["html"] != default_template["html"]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"type": self.template_type,
|
|
|
|
|
|
"name": type_info["name"],
|
|
|
|
|
|
"variables": type_info["variables"],
|
|
|
|
|
|
"subject": template["subject"],
|
|
|
|
|
|
"html": template["html"],
|
|
|
|
|
|
"is_custom": is_custom,
|
|
|
|
|
|
"default_subject": default_template["subject"],
|
|
|
|
|
|
"default_html": default_template["html"],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminUpdateEmailTemplateAdapter(AdminApiAdapter):
|
|
|
|
|
|
"""更新邮件模板"""
|
|
|
|
|
|
|
|
|
|
|
|
template_type: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 验证模板类型
|
2026-03-14 11:59:07 +08:00
|
|
|
|
if self.template_type not in _email_template_service().TEMPLATE_TYPES:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
payload = context.ensure_json_body()
|
|
|
|
|
|
|
|
|
|
|
|
subject = payload.get("subject")
|
|
|
|
|
|
html = payload.get("html")
|
|
|
|
|
|
|
|
|
|
|
|
# 至少需要提供一个字段
|
|
|
|
|
|
if subject is None and html is None:
|
|
|
|
|
|
raise InvalidRequestException("请提供 subject 或 html")
|
|
|
|
|
|
|
|
|
|
|
|
# 保存模板
|
|
|
|
|
|
subject_key = f"email_template_{self.template_type}_subject"
|
|
|
|
|
|
html_key = f"email_template_{self.template_type}_html"
|
|
|
|
|
|
|
|
|
|
|
|
if subject is not None:
|
|
|
|
|
|
if subject:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().set_config(db, subject_key, subject)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 空字符串表示删除自定义值,恢复默认
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().delete_config(db, subject_key)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
if html is not None:
|
|
|
|
|
|
if html:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().set_config(db, html_key, html)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
else:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().delete_config(db, html_key)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
return {"message": "模板保存成功"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminPreviewEmailTemplateAdapter(AdminApiAdapter):
|
|
|
|
|
|
"""预览邮件模板"""
|
|
|
|
|
|
|
|
|
|
|
|
template_type: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 验证模板类型
|
2026-03-14 11:59:07 +08:00
|
|
|
|
if self.template_type not in _email_template_service().TEMPLATE_TYPES:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
payload = context.ensure_json_body() or {}
|
|
|
|
|
|
|
|
|
|
|
|
# 获取模板 HTML(优先使用请求体中的,否则使用数据库中的)
|
|
|
|
|
|
html = payload.get("html")
|
|
|
|
|
|
if not html:
|
2026-03-14 11:59:07 +08:00
|
|
|
|
template = _email_template_service().get_template(db, self.template_type)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
html = template["html"]
|
|
|
|
|
|
|
|
|
|
|
|
# 获取预览变量
|
2026-03-14 11:59:07 +08:00
|
|
|
|
type_info = _email_template_service().TEMPLATE_TYPES[self.template_type]
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
# 构建预览变量,使用请求中的值或默认示例值
|
|
|
|
|
|
preview_variables = {}
|
|
|
|
|
|
default_values = {
|
2026-03-14 11:59:07 +08:00
|
|
|
|
"app_name": _system_config_service().get_config(db, "email_app_name")
|
|
|
|
|
|
or _system_config_service().get_config(db, "smtp_from_name", default="Aether"),
|
2026-01-01 02:10:19 +08:00
|
|
|
|
"code": "123456",
|
|
|
|
|
|
"expire_minutes": "30",
|
|
|
|
|
|
"email": "example@example.com",
|
|
|
|
|
|
"reset_link": "https://example.com/reset?token=abc123",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for var in type_info["variables"]:
|
|
|
|
|
|
preview_variables[var] = payload.get(var, default_values.get(var, f"{{{{{var}}}}}"))
|
|
|
|
|
|
|
|
|
|
|
|
# 渲染模板
|
2026-03-14 11:59:07 +08:00
|
|
|
|
rendered_html = _email_template_service().render_template(html, preview_variables)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"html": rendered_html,
|
|
|
|
|
|
"variables": preview_variables,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminResetEmailTemplateAdapter(AdminApiAdapter):
|
|
|
|
|
|
"""重置邮件模板为默认值"""
|
|
|
|
|
|
|
|
|
|
|
|
template_type: str
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2026-01-01 02:10:19 +08:00
|
|
|
|
# 验证模板类型
|
2026-03-14 11:59:07 +08:00
|
|
|
|
if self.template_type not in _email_template_service().TEMPLATE_TYPES:
|
2026-01-01 02:10:19 +08:00
|
|
|
|
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
|
|
|
|
|
|
# 删除自定义模板
|
|
|
|
|
|
subject_key = f"email_template_{self.template_type}_subject"
|
|
|
|
|
|
html_key = f"email_template_{self.template_type}_html"
|
|
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
_system_config_service().delete_config(db, subject_key)
|
|
|
|
|
|
_system_config_service().delete_config(db, html_key)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
# 返回默认模板
|
2026-03-14 11:59:07 +08:00
|
|
|
|
default_template = _email_template_service().get_default_template(self.template_type)
|
|
|
|
|
|
type_info = _email_template_service().TEMPLATE_TYPES[self.template_type]
|
2026-01-01 02:10:19 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "模板已重置为默认值",
|
|
|
|
|
|
"template": {
|
|
|
|
|
|
"type": self.template_type,
|
|
|
|
|
|
"name": type_info["name"],
|
|
|
|
|
|
"subject": default_template["subject"],
|
|
|
|
|
|
"html": default_template["html"],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
2026-02-26 01:20:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -------- 数据清空适配器 --------
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
def _purge_config_sync() -> dict[str, Any]:
|
|
|
|
|
|
from src.models.database import (
|
|
|
|
|
|
GeminiFileMapping,
|
|
|
|
|
|
GlobalModel,
|
|
|
|
|
|
Model,
|
|
|
|
|
|
ProviderAPIKey,
|
|
|
|
|
|
ProviderEndpoint,
|
|
|
|
|
|
UserPreference,
|
|
|
|
|
|
VideoTask,
|
|
|
|
|
|
)
|
|
|
|
|
|
from src.models.database_extensions import ApiKeyProviderMapping, ProviderUsageTracking
|
2026-02-26 01:20:24 +08:00
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
with get_db_context() as db:
|
2026-03-03 22:04:40 +08:00
|
|
|
|
providers_count = int(db.query(func.count(Provider.id)).scalar() or 0)
|
|
|
|
|
|
endpoints_count = int(db.query(func.count(ProviderEndpoint.id)).scalar() or 0)
|
|
|
|
|
|
keys_count = int(db.query(func.count(ProviderAPIKey.id)).scalar() or 0)
|
|
|
|
|
|
models_count = int(db.query(func.count(Model.id)).scalar() or 0)
|
|
|
|
|
|
global_models_count = int(db.query(func.count(GlobalModel.id)).scalar() or 0)
|
2026-02-26 01:20:24 +08:00
|
|
|
|
|
|
|
|
|
|
db.query(VideoTask).filter(
|
|
|
|
|
|
(VideoTask.provider_id.isnot(None))
|
|
|
|
|
|
| (VideoTask.endpoint_id.isnot(None))
|
|
|
|
|
|
| (VideoTask.key_id.isnot(None))
|
|
|
|
|
|
).update(
|
|
|
|
|
|
{
|
|
|
|
|
|
VideoTask.provider_id: None,
|
|
|
|
|
|
VideoTask.endpoint_id: None,
|
|
|
|
|
|
VideoTask.key_id: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
synchronize_session=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.query(GeminiFileMapping).delete()
|
|
|
|
|
|
db.query(ApiKeyProviderMapping).delete()
|
|
|
|
|
|
db.query(ProviderUsageTracking).delete()
|
|
|
|
|
|
|
|
|
|
|
|
db.query(UserPreference).filter(UserPreference.default_provider_id.isnot(None)).update(
|
|
|
|
|
|
{UserPreference.default_provider_id: None}, synchronize_session=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.query(Model).delete()
|
|
|
|
|
|
db.query(ProviderAPIKey).delete()
|
|
|
|
|
|
db.query(ProviderEndpoint).delete()
|
|
|
|
|
|
db.query(Provider).delete()
|
|
|
|
|
|
db.query(GlobalModel).delete()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "配置已清空",
|
|
|
|
|
|
"deleted": {
|
|
|
|
|
|
"providers": providers_count,
|
|
|
|
|
|
"endpoints": endpoints_count,
|
|
|
|
|
|
"api_keys": keys_count,
|
|
|
|
|
|
"models": models_count,
|
|
|
|
|
|
"global_models": global_models_count,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
def _purge_users_sync() -> dict[str, Any]:
|
|
|
|
|
|
from src.core.enums import UserRole
|
|
|
|
|
|
from src.models.database import VideoTask
|
2026-02-26 01:20:24 +08:00
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
with get_db_context() as db:
|
2026-02-26 02:16:48 +08:00
|
|
|
|
user_ids = [uid for (uid,) in db.query(User.id).filter(User.role != UserRole.ADMIN).all()]
|
2026-02-26 01:20:24 +08:00
|
|
|
|
users_count = len(user_ids)
|
|
|
|
|
|
|
|
|
|
|
|
if user_ids:
|
|
|
|
|
|
db.query(VideoTask).filter(VideoTask.user_id.in_(user_ids)).delete(
|
|
|
|
|
|
synchronize_session=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
keys_count = int(
|
|
|
|
|
|
db.query(func.count(ApiKey.id)).filter(ApiKey.user_id.in_(user_ids)).scalar() or 0
|
|
|
|
|
|
)
|
2026-02-26 01:20:24 +08:00
|
|
|
|
|
|
|
|
|
|
db.query(Usage).filter(Usage.user_id.in_(user_ids)).update(
|
|
|
|
|
|
{Usage.user_id: None}, synchronize_session=False
|
|
|
|
|
|
)
|
|
|
|
|
|
db.query(User).filter(User.id.in_(user_ids)).delete(synchronize_session=False)
|
|
|
|
|
|
else:
|
|
|
|
|
|
keys_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "非管理员用户已清空",
|
|
|
|
|
|
"deleted": {
|
|
|
|
|
|
"users": users_count,
|
|
|
|
|
|
"api_keys": keys_count,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 09:33:24 +08:00
|
|
|
|
def _purge_usage_sync() -> dict[str, Any]:
|
|
|
|
|
|
from src.models.database import RequestCandidate, UserModelUsageCount
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
usage_count = int(db.query(func.count(Usage.id)).scalar() or 0)
|
|
|
|
|
|
candidates_count = int(db.query(func.count(RequestCandidate.id)).scalar() or 0)
|
|
|
|
|
|
usage_counts_count = int(db.query(func.count(UserModelUsageCount.id)).scalar() or 0)
|
|
|
|
|
|
|
|
|
|
|
|
db.query(RequestCandidate).delete()
|
|
|
|
|
|
db.query(Usage).delete()
|
|
|
|
|
|
db.query(UserModelUsageCount).delete()
|
|
|
|
|
|
_purge_stats_and_reset_counters(db)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "使用记录已清空",
|
|
|
|
|
|
"deleted": {
|
|
|
|
|
|
"usage_records": usage_count,
|
|
|
|
|
|
"request_candidates": candidates_count,
|
|
|
|
|
|
"user_model_usage_counts": usage_counts_count,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _purge_audit_logs_sync() -> dict[str, Any]:
|
|
|
|
|
|
from src.models.database import AuditLog
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
count = int(db.query(func.count(AuditLog.id)).scalar() or 0)
|
|
|
|
|
|
db.query(AuditLog).delete()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "审计日志已清空",
|
|
|
|
|
|
"deleted": {
|
|
|
|
|
|
"audit_logs": count,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _purge_request_bodies_sync() -> dict[str, Any]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
with_body = int(
|
|
|
|
|
|
db.query(func.count(Usage.id))
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(Usage.request_body.isnot(None))
|
|
|
|
|
|
| (Usage.response_body.isnot(None))
|
|
|
|
|
|
| (Usage.provider_request_body.isnot(None))
|
|
|
|
|
|
| (Usage.client_response_body.isnot(None))
|
|
|
|
|
|
| (Usage.request_body_compressed.isnot(None))
|
|
|
|
|
|
| (Usage.response_body_compressed.isnot(None))
|
|
|
|
|
|
| (Usage.provider_request_body_compressed.isnot(None))
|
|
|
|
|
|
| (Usage.client_response_body_compressed.isnot(None))
|
|
|
|
|
|
)
|
|
|
|
|
|
.scalar()
|
|
|
|
|
|
or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.query(Usage).update(
|
|
|
|
|
|
{
|
|
|
|
|
|
Usage.request_body: None,
|
|
|
|
|
|
Usage.response_body: None,
|
|
|
|
|
|
Usage.provider_request_body: None,
|
|
|
|
|
|
Usage.client_response_body: None,
|
|
|
|
|
|
Usage.request_body_compressed: None,
|
|
|
|
|
|
Usage.response_body_compressed: None,
|
|
|
|
|
|
Usage.provider_request_body_compressed: None,
|
|
|
|
|
|
Usage.client_response_body_compressed: None,
|
|
|
|
|
|
Usage.request_headers: None,
|
|
|
|
|
|
Usage.response_headers: None,
|
|
|
|
|
|
Usage.provider_request_headers: None,
|
|
|
|
|
|
Usage.client_response_headers: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
synchronize_session=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message": "请求体已清空",
|
|
|
|
|
|
"cleaned": {
|
|
|
|
|
|
"records_with_body": with_body,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _purge_stats_sync() -> dict[str, Any]:
|
|
|
|
|
|
with get_db_context() as db:
|
|
|
|
|
|
_purge_stats_and_reset_counters(db)
|
|
|
|
|
|
return {"message": "聚合统计数据已清空"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminPurgeConfigAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
"""清空所有提供商配置(Provider、Endpoint、API Key、Model、GlobalModel)"""
|
|
|
|
|
|
return await run_in_threadpool(_purge_config_sync)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminPurgeUsersAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
"""清空所有非管理员用户及其关联数据"""
|
|
|
|
|
|
return await run_in_threadpool(_purge_users_sync)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-26 01:20:24 +08:00
|
|
|
|
def _purge_stats_and_reset_counters(db: Session) -> None:
|
|
|
|
|
|
"""清空预聚合统计表、重置累计计数字段、清除缓存。"""
|
|
|
|
|
|
from src.models.database import (
|
|
|
|
|
|
ProviderAPIKey,
|
|
|
|
|
|
StatsDaily,
|
|
|
|
|
|
StatsDailyApiKey,
|
|
|
|
|
|
StatsDailyError,
|
|
|
|
|
|
StatsDailyModel,
|
|
|
|
|
|
StatsDailyProvider,
|
|
|
|
|
|
StatsHourly,
|
|
|
|
|
|
StatsHourlyModel,
|
|
|
|
|
|
StatsHourlyProvider,
|
|
|
|
|
|
StatsHourlyUser,
|
|
|
|
|
|
StatsSummary,
|
|
|
|
|
|
StatsUserDaily,
|
|
|
|
|
|
)
|
|
|
|
|
|
from src.services.cache.invalidation import get_cache_invalidation_service
|
|
|
|
|
|
|
|
|
|
|
|
# 清空预聚合统计表
|
|
|
|
|
|
db.query(StatsHourly).delete()
|
|
|
|
|
|
db.query(StatsHourlyUser).delete()
|
|
|
|
|
|
db.query(StatsHourlyModel).delete()
|
|
|
|
|
|
db.query(StatsHourlyProvider).delete()
|
|
|
|
|
|
db.query(StatsDaily).delete()
|
|
|
|
|
|
db.query(StatsDailyModel).delete()
|
|
|
|
|
|
db.query(StatsDailyProvider).delete()
|
|
|
|
|
|
db.query(StatsDailyApiKey).delete()
|
|
|
|
|
|
db.query(StatsDailyError).delete()
|
|
|
|
|
|
db.query(StatsSummary).delete()
|
|
|
|
|
|
db.query(StatsUserDaily).delete()
|
|
|
|
|
|
|
|
|
|
|
|
# 重置 ApiKey 上的缓存统计字段
|
|
|
|
|
|
db.query(ApiKey).update(
|
|
|
|
|
|
{
|
|
|
|
|
|
ApiKey.total_requests: 0,
|
|
|
|
|
|
ApiKey.total_cost_usd: 0.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
synchronize_session=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 重置 ProviderAPIKey 上的使用统计
|
|
|
|
|
|
db.query(ProviderAPIKey).update(
|
|
|
|
|
|
{
|
|
|
|
|
|
ProviderAPIKey.request_count: 0,
|
2026-03-11 15:56:58 +08:00
|
|
|
|
ProviderAPIKey.total_tokens: 0,
|
|
|
|
|
|
ProviderAPIKey.total_cost_usd: 0.0,
|
2026-02-26 01:20:24 +08:00
|
|
|
|
ProviderAPIKey.success_count: 0,
|
|
|
|
|
|
ProviderAPIKey.error_count: 0,
|
|
|
|
|
|
ProviderAPIKey.total_response_time_ms: 0,
|
|
|
|
|
|
ProviderAPIKey.last_used_at: None,
|
|
|
|
|
|
ProviderAPIKey.last_error_at: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
synchronize_session=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 清除缓存
|
|
|
|
|
|
try:
|
|
|
|
|
|
cache_service = get_cache_invalidation_service()
|
|
|
|
|
|
cache_service.clear_all_caches()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminPurgeUsageAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
"""清空全部使用记录及相关统计数据"""
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return await run_in_threadpool(_purge_usage_sync)
|
2026-02-26 01:20:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminPurgeAuditLogsAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
"""清空全部审计日志"""
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return await run_in_threadpool(_purge_audit_logs_sync)
|
2026-02-26 01:20:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminPurgeRequestBodiesAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
"""清空全部请求体/响应体(保留使用记录的统计信息)"""
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return await run_in_threadpool(_purge_request_bodies_sync)
|
2026-02-26 01:20:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminPurgeStatsAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
"""清空全部聚合统计数据(保留原始使用记录)"""
|
2026-03-12 09:33:24 +08:00
|
|
|
|
return await run_in_threadpool(_purge_stats_sync)
|
2026-02-27 18:10:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# AWS Regions (从 AWS Regional Table API 获取,Redis 缓存 24h)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_AWS_REGIONS_CACHE_KEY = "aws_regions"
|
|
|
|
|
|
_AWS_REGIONS_CACHE_TTL = 86400 # 24h
|
|
|
|
|
|
# 内存级 fallback(进程生命周期内有效,Redis 不可用时兜底)
|
|
|
|
|
|
_aws_regions_mem_cache: list[str] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _fetch_aws_regions() -> list[str]:
|
2026-03-31 19:19:04 +08:00
|
|
|
|
raise HTTPException(status_code=503, detail="AWS regions requires Rust admin backend")
|
2026-02-27 18:10:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
@router.get("/aws-regions")
|
|
|
|
|
|
async def get_aws_regions(request: Request, db: Session = Depends(get_db)) -> Any:
|
|
|
|
|
|
"""获取 AWS 全部可用 Region 列表(缓存 24h)"""
|
|
|
|
|
|
adapter = AdminAwsRegionsAdapter()
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
2026-02-27 18:10:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
async def _get_aws_regions_response() -> dict[str, Any]:
|
2026-02-27 18:10:27 +08:00
|
|
|
|
"""获取 AWS 全部可用 Region 列表(缓存 24h)"""
|
|
|
|
|
|
global _aws_regions_mem_cache
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 尝试 Redis 缓存
|
|
|
|
|
|
from src.core.cache_service import CacheService
|
|
|
|
|
|
|
|
|
|
|
|
cached = await CacheService.get(_AWS_REGIONS_CACHE_KEY)
|
|
|
|
|
|
if cached and isinstance(cached, list):
|
|
|
|
|
|
return {"regions": cached}
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 尝试内存 fallback
|
|
|
|
|
|
if _aws_regions_mem_cache:
|
|
|
|
|
|
return {"regions": _aws_regions_mem_cache}
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
# 3. Python 不再承担远程拉取职责
|
|
|
|
|
|
raise HTTPException(status_code=503, detail="AWS regions requires Rust admin backend")
|
2026-02-27 18:10:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
class AdminAwsRegionsAdapter(AdminApiAdapter):
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
return await _get_aws_regions_response()
|