From bfa0a26d419d32bf4572adcf2d100618eeed2fca Mon Sep 17 00:00:00 2001 From: fawney19 Date: Mon, 5 Jan 2026 18:18:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=8B=AC=E7=AB=8B=E4=BD=99=E9=A2=9DKey?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E7=B3=BB=E7=BB=9F=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用户导出/导入支持独立余额 Key (standalone_keys) - API Key 导出增加 expires_at 字段 - 新增 /api/admin/system/version 接口获取版本信息 - 前端系统设置页面显示当前版本 - 移除导入对话框中多余的 bg-muted 背景样式 --- frontend/src/api/admin.ts | 14 ++ frontend/src/views/admin/SystemSettings.vue | 80 +++++-- src/api/admin/system.py | 223 ++++++++++++++------ 3 files changed, 237 insertions(+), 80 deletions(-) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 9c89212..fb210c1 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -13,6 +13,7 @@ export interface UsersExportData { version: string exported_at: string users: UserExport[] + standalone_keys?: StandaloneKeyExport[] } export interface UserExport { @@ -46,11 +47,15 @@ export interface UserApiKeyExport { concurrent_limit?: number | null force_capabilities?: any is_active: boolean + expires_at?: string | null auto_delete_on_expiry?: boolean total_requests?: number total_cost_usd?: number } +// 独立余额 Key 导出结构(与 UserApiKeyExport 相同,但不包含 is_standalone) +export type StandaloneKeyExport = Omit + export interface GlobalModelExport { name: string display_name: string @@ -189,6 +194,7 @@ export interface UsersImportResponse { stats: { users: { created: number; updated: number; skipped: number } api_keys: { created: number; skipped: number } + standalone_keys?: { created: number; skipped: number } errors: string[] } } @@ -473,5 +479,13 @@ export const adminApi = { `/api/admin/system/email/templates/${templateType}/reset` ) return response.data + }, + + // 获取系统版本信息 + async getSystemVersion(): Promise<{ version: string }> { + const response = await apiClient.get<{ version: string }>( + '/api/admin/system/version' + ) + return response.data } } diff --git a/frontend/src/views/admin/SystemSettings.vue b/frontend/src/views/admin/SystemSettings.vue index ae5a01b..d991617 100644 --- a/frontend/src/views/admin/SystemSettings.vue +++ b/frontend/src/views/admin/SystemSettings.vue @@ -464,6 +464,30 @@ + + + +
+
+ + + {{ systemVersion }} + + + 加载中... + +
+
+
@@ -475,7 +499,7 @@

配置预览 @@ -557,7 +581,7 @@ class="space-y-4" >

-
+

全局模型

@@ -567,7 +591,7 @@ 跳过: {{ importResult.stats.global_models.skipped }}

-
+

提供商

@@ -577,7 +601,7 @@ 跳过: {{ importResult.stats.providers.skipped }}

-
+

端点

@@ -587,7 +611,7 @@ 跳过: {{ importResult.stats.endpoints.skipped }}

-
+

API Keys

@@ -596,7 +620,7 @@ 跳过: {{ importResult.stats.keys.skipped }}

-
+

模型配置

@@ -642,7 +666,7 @@

数据预览 @@ -652,6 +676,9 @@

  • API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
  • +
  • + 独立余额 Keys: {{ importUsersPreview.standalone_keys.length }} 个 +
  • @@ -720,7 +747,7 @@ class="space-y-4" >
    -
    +

    用户

    @@ -730,7 +757,7 @@ 跳过: {{ importUsersResult.stats.users.skipped }}

    -
    +

    API Keys

    @@ -739,6 +766,18 @@ 跳过: {{ importUsersResult.stats.api_keys.skipped }}

    +
    +

    + 独立余额 Keys +

    +

    + 创建: {{ importUsersResult.stats.standalone_keys.created }}, + 跳过: {{ importUsersResult.stats.standalone_keys.skipped }} +

    +
    (null) const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip') const usersMergeModeSelectOpen = ref(false) +// 系统版本信息 +const systemVersion = ref('') + const systemConfig = ref({ // 基础配置 default_user_quota_usd: 10.0, @@ -890,9 +932,21 @@ const sensitiveHeadersStr = computed({ }) onMounted(async () => { - await loadSystemConfig() + await Promise.all([ + loadSystemConfig(), + loadSystemVersion() + ]) }) +async function loadSystemVersion() { + try { + const data = await adminApi.getSystemVersion() + systemVersion.value = data.version + } catch (err) { + log.error('加载系统版本失败:', err) + } +} + async function loadSystemConfig() { try { const configs = [ @@ -1178,12 +1232,6 @@ function handleUsersFileSelect(event: Event) { const content = e.target?.result as string const data = JSON.parse(content) as UsersExportData - // 验证版本 - if (data.version !== '1.0') { - error(`不支持的配置版本: ${data.version}`) - return - } - importUsersPreview.value = data usersMergeMode.value = 'skip' importUsersDialogOpen.value = true diff --git a/src/api/admin/system.py b/src/api/admin/system.py index b233cfa..45dfd23 100644 --- a/src/api/admin/system.py +++ b/src/api/admin/system.py @@ -1,5 +1,7 @@ """系统设置API端点。""" +from __future__ import annotations + from dataclasses import dataclass from typing import Optional @@ -17,6 +19,46 @@ from src.services.email.email_template import EmailTemplate from src.services.system.config import SystemConfigService router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"]) + + +def _get_version_from_git() -> str | None: + """从 git describe 获取版本号""" + import subprocess + + try: + result = subprocess.run( + ["git", "describe", "--tags", "--always"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + version = result.stdout.strip() + if version.startswith("v"): + version = version[1:] + return version + except Exception: + pass + return None + + +@router.get("/version") +async def get_system_version(): + """获取系统版本信息""" + # 优先从 git 获取 + version = _get_version_from_git() + if version: + return {"version": version} + + # 回退到静态版本文件 + try: + from src._version import __version__ + + return {"version": __version__} + except ImportError: + return {"version": "unknown"} + + pipeline = ApiRequestPipeline() @@ -950,6 +992,31 @@ class AdminExportUsersAdapter(AdminApiAdapter): db = context.db + def _serialize_api_key(key: ApiKey, include_is_standalone: bool = False) -> dict: + """序列化 API Key 为导出格式""" + data = { + "key_hash": key.key_hash, + "key_encrypted": key.key_encrypted, + "name": key.name, + "balance_used_usd": key.balance_used_usd, + "current_balance_usd": key.current_balance_usd, + "allowed_providers": key.allowed_providers, + "allowed_endpoints": key.allowed_endpoints, + "allowed_api_formats": key.allowed_api_formats, + "allowed_models": key.allowed_models, + "rate_limit": key.rate_limit, + "concurrent_limit": key.concurrent_limit, + "force_capabilities": key.force_capabilities, + "is_active": key.is_active, + "expires_at": key.expires_at.isoformat() if key.expires_at else None, + "auto_delete_on_expiry": key.auto_delete_on_expiry, + "total_requests": key.total_requests, + "total_cost_usd": key.total_cost_usd, + } + if include_is_standalone: + data["is_standalone"] = key.is_standalone + return data + # 导出 Users(排除管理员) users = db.query(User).filter( User.is_deleted.is_(False), @@ -957,31 +1024,12 @@ class AdminExportUsersAdapter(AdminApiAdapter): ).all() users_data = [] for user in users: - # 导出用户的 API Keys(保留加密数据) - api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all() - api_keys_data = [] - for key in api_keys: - api_keys_data.append( - { - "key_hash": key.key_hash, - "key_encrypted": key.key_encrypted, - "name": key.name, - "is_standalone": key.is_standalone, - "balance_used_usd": key.balance_used_usd, - "current_balance_usd": key.current_balance_usd, - "allowed_providers": key.allowed_providers, - "allowed_endpoints": key.allowed_endpoints, - "allowed_api_formats": key.allowed_api_formats, - "allowed_models": key.allowed_models, - "rate_limit": key.rate_limit, - "concurrent_limit": key.concurrent_limit, - "force_capabilities": key.force_capabilities, - "is_active": key.is_active, - "auto_delete_on_expiry": key.auto_delete_on_expiry, - "total_requests": key.total_requests, - "total_cost_usd": key.total_cost_usd, - } - ) + # 导出用户的 API Keys(排除独立余额Key,独立Key单独导出) + api_keys = db.query(ApiKey).filter( + ApiKey.user_id == user.id, + ApiKey.is_standalone.is_(False) + ).all() + api_keys_data = [_serialize_api_key(key, include_is_standalone=True) for key in api_keys] users_data.append( { @@ -1001,10 +1049,15 @@ class AdminExportUsersAdapter(AdminApiAdapter): } ) + # 导出独立余额 Keys(管理员创建的,不属于普通用户) + standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all() + standalone_keys_data = [_serialize_api_key(key) for key in standalone_keys] + return { - "version": "1.0", + "version": "1.1", "exported_at": datetime.now(timezone.utc).isoformat(), "users": users_data, + "standalone_keys": standalone_keys_data, } @@ -1024,21 +1077,72 @@ class AdminImportUsersAdapter(AdminApiAdapter): db = context.db payload = context.ensure_json_body() - # 验证配置版本 - version = payload.get("version") - if version != "1.0": - raise InvalidRequestException(f"不支持的配置版本: {version}") - # 获取导入选项 merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error users_data = payload.get("users", []) + standalone_keys_data = payload.get("standalone_keys", []) stats = { "users": {"created": 0, "updated": 0, "skipped": 0}, "api_keys": {"created": 0, "skipped": 0}, + "standalone_keys": {"created": 0, "skipped": 0}, "errors": [], } + def _create_api_key_from_data( + key_data: dict, + owner_id: str, + is_standalone: bool = False, + ) -> tuple[ApiKey | None, str]: + """从导入数据创建 ApiKey 对象 + + Returns: + (ApiKey, "created"): 成功创建 + (None, "skipped"): key 已存在,跳过 + (None, "invalid"): 数据无效,跳过 + """ + key_hash = key_data.get("key_hash", "").strip() + if not key_hash: + return None, "invalid" + + # 检查是否已存在 + existing = db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first() + if existing: + return None, "skipped" + + # 解析 expires_at + expires_at = None + if key_data.get("expires_at"): + try: + expires_at = datetime.fromisoformat(key_data["expires_at"]) + except ValueError: + stats["errors"].append( + f"API Key '{key_data.get('name', key_hash[:8])}' 的 expires_at 格式无效" + ) + + return ApiKey( + id=str(uuid.uuid4()), + user_id=owner_id, + key_hash=key_hash, + key_encrypted=key_data.get("key_encrypted"), + name=key_data.get("name"), + is_standalone=is_standalone or key_data.get("is_standalone", False), + balance_used_usd=key_data.get("balance_used_usd", 0.0), + current_balance_usd=key_data.get("current_balance_usd"), + allowed_providers=key_data.get("allowed_providers"), + allowed_endpoints=key_data.get("allowed_endpoints"), + allowed_api_formats=key_data.get("allowed_api_formats"), + allowed_models=key_data.get("allowed_models"), + rate_limit=key_data.get("rate_limit"), + concurrent_limit=key_data.get("concurrent_limit", 5), + force_capabilities=key_data.get("force_capabilities"), + is_active=key_data.get("is_active", True), + expires_at=expires_at, + auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False), + total_requests=key_data.get("total_requests", 0), + total_cost_usd=key_data.get("total_cost_usd", 0.0), + ), "created" + try: for user_data in users_data: # 跳过管理员角色的导入(不区分大小写) @@ -1109,40 +1213,31 @@ class AdminImportUsersAdapter(AdminApiAdapter): # 导入 API Keys for key_data in user_data.get("api_keys", []): - # 检查是否已存在相同的 key_hash - if key_data.get("key_hash"): - existing_key = ( - db.query(ApiKey) - .filter(ApiKey.key_hash == key_data["key_hash"]) - .first() - ) - if existing_key: - stats["api_keys"]["skipped"] += 1 - continue + new_key, status = _create_api_key_from_data(key_data, user_id) + if new_key: + db.add(new_key) + stats["api_keys"]["created"] += 1 + elif status == "skipped": + stats["api_keys"]["skipped"] += 1 + # invalid 数据不计入统计 - new_key = ApiKey( - id=str(uuid.uuid4()), - user_id=user_id, - key_hash=key_data.get("key_hash", ""), - key_encrypted=key_data.get("key_encrypted"), - name=key_data.get("name"), - is_standalone=key_data.get("is_standalone", False), - balance_used_usd=key_data.get("balance_used_usd", 0.0), - current_balance_usd=key_data.get("current_balance_usd"), - allowed_providers=key_data.get("allowed_providers"), - allowed_endpoints=key_data.get("allowed_endpoints"), - allowed_api_formats=key_data.get("allowed_api_formats"), - allowed_models=key_data.get("allowed_models"), - rate_limit=key_data.get("rate_limit"), # None = 无限制 - concurrent_limit=key_data.get("concurrent_limit", 5), - force_capabilities=key_data.get("force_capabilities"), - is_active=key_data.get("is_active", True), - auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False), - total_requests=key_data.get("total_requests", 0), - total_cost_usd=key_data.get("total_cost_usd", 0.0), - ) - db.add(new_key) - stats["api_keys"]["created"] += 1 + # 导入独立余额 Keys(需要找一个管理员用户作为 owner) + if standalone_keys_data: + # 查找一个管理员用户作为独立Key的owner + admin_user = db.query(User).filter(User.role == UserRole.ADMIN).first() + if not admin_user: + stats["errors"].append("无法导入独立余额Key: 系统中没有管理员用户") + else: + for key_data in standalone_keys_data: + new_key, status = _create_api_key_from_data( + key_data, admin_user.id, is_standalone=True + ) + if new_key: + db.add(new_key) + stats["standalone_keys"]["created"] += 1 + elif status == "skipped": + stats["standalone_keys"]["skipped"] += 1 + # invalid 数据不计入统计 db.commit()