feat: 用户导出支持独立余额Key,新增系统版本接口

- 用户导出/导入支持独立余额 Key (standalone_keys)
- API Key 导出增加 expires_at 字段
- 新增 /api/admin/system/version 接口获取版本信息
- 前端系统设置页面显示当前版本
- 移除导入对话框中多余的 bg-muted 背景样式
This commit is contained in:
fawney19
2026-01-05 18:18:45 +08:00
parent 35e29d46bd
commit bfa0a26d41
3 changed files with 237 additions and 80 deletions

View File

@@ -13,6 +13,7 @@ export interface UsersExportData {
version: string version: string
exported_at: string exported_at: string
users: UserExport[] users: UserExport[]
standalone_keys?: StandaloneKeyExport[]
} }
export interface UserExport { export interface UserExport {
@@ -46,11 +47,15 @@ export interface UserApiKeyExport {
concurrent_limit?: number | null concurrent_limit?: number | null
force_capabilities?: any force_capabilities?: any
is_active: boolean is_active: boolean
expires_at?: string | null
auto_delete_on_expiry?: boolean auto_delete_on_expiry?: boolean
total_requests?: number total_requests?: number
total_cost_usd?: number total_cost_usd?: number
} }
// 独立余额 Key 导出结构(与 UserApiKeyExport 相同,但不包含 is_standalone
export type StandaloneKeyExport = Omit<UserApiKeyExport, 'is_standalone'>
export interface GlobalModelExport { export interface GlobalModelExport {
name: string name: string
display_name: string display_name: string
@@ -189,6 +194,7 @@ export interface UsersImportResponse {
stats: { stats: {
users: { created: number; updated: number; skipped: number } users: { created: number; updated: number; skipped: number }
api_keys: { created: number; skipped: number } api_keys: { created: number; skipped: number }
standalone_keys?: { created: number; skipped: number }
errors: string[] errors: string[]
} }
} }
@@ -473,5 +479,13 @@ export const adminApi = {
`/api/admin/system/email/templates/${templateType}/reset` `/api/admin/system/email/templates/${templateType}/reset`
) )
return response.data return response.data
},
// 获取系统版本信息
async getSystemVersion(): Promise<{ version: string }> {
const response = await apiClient.get<{ version: string }>(
'/api/admin/system/version'
)
return response.data
} }
} }

View File

@@ -464,6 +464,30 @@
</div> </div>
</div> </div>
</CardSection> </CardSection>
<!-- 系统版本信息 -->
<CardSection
title="系统信息"
description="当前系统版本和构建信息"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Label class="text-sm font-medium text-muted-foreground">版本:</Label>
<span
v-if="systemVersion"
class="text-sm font-mono"
>
{{ systemVersion }}
</span>
<span
v-else
class="text-sm text-muted-foreground"
>
加载中...
</span>
</div>
</div>
</CardSection>
</div> </div>
<!-- 导入配置对话框 --> <!-- 导入配置对话框 -->
@@ -475,7 +499,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div <div
v-if="importPreview" v-if="importPreview"
class="p-3 bg-muted rounded-lg text-sm" class="text-sm"
> >
<p class="font-medium mb-2"> <p class="font-medium mb-2">
配置预览 配置预览
@@ -557,7 +581,7 @@
class="space-y-4" class="space-y-4"
> >
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
全局模型 全局模型
</p> </p>
@@ -567,7 +591,7 @@
跳过: {{ importResult.stats.global_models.skipped }} 跳过: {{ importResult.stats.global_models.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
提供商 提供商
</p> </p>
@@ -577,7 +601,7 @@
跳过: {{ importResult.stats.providers.skipped }} 跳过: {{ importResult.stats.providers.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
端点 端点
</p> </p>
@@ -587,7 +611,7 @@
跳过: {{ importResult.stats.endpoints.skipped }} 跳过: {{ importResult.stats.endpoints.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
API Keys API Keys
</p> </p>
@@ -596,7 +620,7 @@
跳过: {{ importResult.stats.keys.skipped }} 跳过: {{ importResult.stats.keys.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg col-span-2"> <div class="col-span-2">
<p class="font-medium"> <p class="font-medium">
模型配置 模型配置
</p> </p>
@@ -642,7 +666,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div <div
v-if="importUsersPreview" v-if="importUsersPreview"
class="p-3 bg-muted rounded-lg text-sm" class="text-sm"
> >
<p class="font-medium mb-2"> <p class="font-medium mb-2">
数据预览 数据预览
@@ -652,6 +676,9 @@
<li> <li>
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }}
</li> </li>
<li v-if="importUsersPreview.standalone_keys?.length">
独立余额 Keys: {{ importUsersPreview.standalone_keys.length }}
</li>
</ul> </ul>
</div> </div>
@@ -720,7 +747,7 @@
class="space-y-4" class="space-y-4"
> >
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
用户 用户
</p> </p>
@@ -730,7 +757,7 @@
跳过: {{ importUsersResult.stats.users.skipped }} 跳过: {{ importUsersResult.stats.users.skipped }}
</p> </p>
</div> </div>
<div class="p-3 bg-muted rounded-lg"> <div>
<p class="font-medium"> <p class="font-medium">
API Keys API Keys
</p> </p>
@@ -739,6 +766,18 @@
跳过: {{ importUsersResult.stats.api_keys.skipped }} 跳过: {{ importUsersResult.stats.api_keys.skipped }}
</p> </p>
</div> </div>
<div
v-if="importUsersResult.stats.standalone_keys"
class="col-span-2"
>
<p class="font-medium">
独立余额 Keys
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.standalone_keys.created }},
跳过: {{ importUsersResult.stats.standalone_keys.skipped }}
</p>
</div>
</div> </div>
<div <div
@@ -839,6 +878,9 @@ const importUsersResult = ref<UsersImportResponse | null>(null)
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip') const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const usersMergeModeSelectOpen = ref(false) const usersMergeModeSelectOpen = ref(false)
// 系统版本信息
const systemVersion = ref<string>('')
const systemConfig = ref<SystemConfig>({ const systemConfig = ref<SystemConfig>({
// 基础配置 // 基础配置
default_user_quota_usd: 10.0, default_user_quota_usd: 10.0,
@@ -890,9 +932,21 @@ const sensitiveHeadersStr = computed({
}) })
onMounted(async () => { onMounted(async () => {
await loadSystemConfig() await Promise.all([
loadSystemConfig(),
loadSystemVersion()
])
}) })
async function loadSystemVersion() {
try {
const data = await adminApi.getSystemVersion()
systemVersion.value = data.version
} catch (err) {
log.error('加载系统版本失败:', err)
}
}
async function loadSystemConfig() { async function loadSystemConfig() {
try { try {
const configs = [ const configs = [
@@ -1178,12 +1232,6 @@ function handleUsersFileSelect(event: Event) {
const content = e.target?.result as string const content = e.target?.result as string
const data = JSON.parse(content) as UsersExportData const data = JSON.parse(content) as UsersExportData
// 验证版本
if (data.version !== '1.0') {
error(`不支持的配置版本: ${data.version}`)
return
}
importUsersPreview.value = data importUsersPreview.value = data
usersMergeMode.value = 'skip' usersMergeMode.value = 'skip'
importUsersDialogOpen.value = true importUsersDialogOpen.value = true

View File

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