mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 优化批量余额查询和用户模型权限检查
- 添加批量余额查询并发限制,避免数据库连接池耗尽 - 支持余额加载 pending 状态和前端自动重试机制 - 添加用户可用模型 API,统一使用 AccessRestrictions - 修复用户表单编辑时数组引用共享导致的数据覆盖问题 - 添加数据库连接池配置说明到 .env.example
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import apiClient from './client'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
import type { TieredPricingConfig } from './endpoints/types'
|
||||
|
||||
export interface Profile {
|
||||
id: string // UUID
|
||||
@@ -235,6 +236,28 @@ export const meApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取用户可用的模型列表
|
||||
async getAvailableModels(params?: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}): Promise<{
|
||||
models: Array<{
|
||||
id: string
|
||||
name: string
|
||||
display_name: string | null
|
||||
is_active: boolean
|
||||
default_price_per_request: number | null
|
||||
default_tiered_pricing: TieredPricingConfig | null
|
||||
supported_capabilities: string[] | null
|
||||
config: Record<string, any> | null
|
||||
}>
|
||||
total: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/users/me/available-models', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取端点状态(不包含敏感信息)
|
||||
async getEndpointStatus(): Promise<any[]> {
|
||||
const response = await apiClient.get('/api/users/me/endpoint-status')
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface PublicGlobalModel {
|
||||
display_name: string | null
|
||||
is_active: boolean
|
||||
// 阶梯计费配置
|
||||
default_tiered_pricing: TieredPricingConfig
|
||||
default_tiered_pricing: TieredPricingConfig | null
|
||||
default_price_per_request: number | null // 按次计费价格
|
||||
// Key 能力支持
|
||||
supported_capabilities: string[] | null
|
||||
|
||||
@@ -101,11 +101,15 @@ export function useFormDialog<E>(
|
||||
})
|
||||
|
||||
// 监听实体变化(编辑模式切换)
|
||||
watch(entity, (newEntity) => {
|
||||
if (newEntity && isOpen()) {
|
||||
// 注意:不使用 deep: true,只在实体引用变化时触发(如从 user A 切换到 user B)
|
||||
// 使用 deep: true 会导致实体的深层属性变化时也触发,覆盖用户正在编辑的数据
|
||||
watch(entity, (newEntity, oldEntity) => {
|
||||
// 只在实体引用真正变化时(如切换用户)才重新加载
|
||||
// 避免深层属性变化时意外触发
|
||||
if (newEntity && isOpen() && newEntity !== oldEntity) {
|
||||
loadData()
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
isEditMode,
|
||||
|
||||
@@ -457,6 +457,7 @@ function loadUserData() {
|
||||
if (!props.user) return
|
||||
formNonce.value = createFieldNonce()
|
||||
passwordFocused.value = false
|
||||
// 创建数组副本,避免与 props 数据共享引用
|
||||
form.value = {
|
||||
username: props.user.username,
|
||||
password: '',
|
||||
@@ -466,9 +467,9 @@ function loadUserData() {
|
||||
role: props.user.role,
|
||||
unlimited: props.user.quota_usd == null,
|
||||
is_active: props.user.is_active ?? true,
|
||||
allowed_providers: props.user.allowed_providers || [],
|
||||
allowed_api_formats: props.user.allowed_api_formats || [],
|
||||
allowed_models: props.user.allowed_models || []
|
||||
allowed_providers: [...(props.user.allowed_providers || [])],
|
||||
allowed_api_formats: [...(props.user.allowed_api_formats || [])],
|
||||
allowed_models: [...(props.user.allowed_models || [])]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,9 +143,17 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<!-- 余额正在加载中 -->
|
||||
<div
|
||||
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<!-- 显示从上游 API 查询的余额 -->
|
||||
<div
|
||||
v-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<!-- 余额文字 -->
|
||||
@@ -445,9 +453,17 @@
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
|
||||
</Badge>
|
||||
<!-- 余额加载中 -->
|
||||
<span
|
||||
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
加载中...
|
||||
</span>
|
||||
<!-- 余额(从上游 API 查询) -->
|
||||
<span
|
||||
v-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
||||
@@ -586,7 +602,8 @@ import {
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
Power,
|
||||
KeyRound
|
||||
KeyRound,
|
||||
Loader2
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -737,17 +754,73 @@ async function loadBalances() {
|
||||
// 检查是否有新的请求已经开始,如果有则丢弃当前结果
|
||||
if (currentVersion !== balanceLoadVersion) return
|
||||
|
||||
// 将成功的结果存入缓存
|
||||
// 收集需要重试的 provider IDs
|
||||
const pendingProviderIds: string[] = []
|
||||
|
||||
// 将结果存入缓存(包括 pending 状态)
|
||||
for (const [providerId, result] of Object.entries(results)) {
|
||||
if (result.status === 'success') {
|
||||
// 存入缓存:success, auth_expired (带有效数据), pending
|
||||
if (result.status === 'success' || result.status === 'auth_expired' || result.status === 'pending') {
|
||||
balanceCache.value[providerId] = result
|
||||
}
|
||||
// 收集 pending 状态的 provider,稍后重试
|
||||
if (result.status === 'pending') {
|
||||
pendingProviderIds.push(providerId)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有 pending 状态的 provider,3秒后自动重试
|
||||
if (pendingProviderIds.length > 0) {
|
||||
const timerId = setTimeout(() => {
|
||||
pendingTimers.delete(timerId)
|
||||
// 检查版本号,确保没有新的加载请求
|
||||
if (currentVersion === balanceLoadVersion) {
|
||||
retryPendingBalances(pendingProviderIds, currentVersion, 0)
|
||||
}
|
||||
}, 3000)
|
||||
pendingTimers.add(timerId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[loadBalances] 加载余额数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 重试加载 pending 状态的余额
|
||||
const MAX_BALANCE_RETRIES = 3
|
||||
|
||||
// 追踪待处理的定时器,用于组件卸载时清理
|
||||
const pendingTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
async function retryPendingBalances(providerIds: string[], loadVersion: number, retryCount: number) {
|
||||
try {
|
||||
const results = await batchQueryBalance(providerIds)
|
||||
const stillPending: string[] = []
|
||||
|
||||
for (const [providerId, result] of Object.entries(results)) {
|
||||
if (result.status !== 'pending') {
|
||||
balanceCache.value[providerId] = result
|
||||
} else {
|
||||
stillPending.push(providerId)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还有 pending 且未达到最大重试次数,继续重试(指数退避)
|
||||
if (stillPending.length > 0 && retryCount < MAX_BALANCE_RETRIES) {
|
||||
const delay = 3000 * Math.pow(1.5, retryCount) // 3s, 4.5s, 6.75s
|
||||
const timerId = setTimeout(() => {
|
||||
pendingTimers.delete(timerId)
|
||||
// 检查版本号,确保没有新的加载请求
|
||||
if (loadVersion === balanceLoadVersion) {
|
||||
retryPendingBalances(stillPending, loadVersion, retryCount + 1)
|
||||
}
|
||||
}, delay)
|
||||
pendingTimers.add(timerId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[retryPendingBalances] 重试加载余额失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:检查是否为 BalanceInfo(简化版)
|
||||
* 只检查余额显示所需的字段,完整的 BalanceInfo 还包含 total_granted, total_used, expires_at, extra
|
||||
@@ -785,6 +858,10 @@ function getProviderBalanceError(providerId: string): { status: string; message:
|
||||
if (!result) {
|
||||
return null
|
||||
}
|
||||
// pending 状态不是错误,正在加载中
|
||||
if (result.status === 'pending') {
|
||||
return null
|
||||
}
|
||||
// 认证失败或过期
|
||||
if (result.status === 'auth_failed' || result.status === 'auth_expired') {
|
||||
return {
|
||||
@@ -802,6 +879,12 @@ function getProviderBalanceError(providerId: string): { status: string; message:
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查余额是否正在加载中
|
||||
function isBalanceLoading(providerId: string): boolean {
|
||||
const result = balanceCache.value[providerId]
|
||||
return result?.status === 'pending'
|
||||
}
|
||||
|
||||
// 获取 provider 的签到信息(从 extra 字段)
|
||||
function getProviderCheckin(providerId: string): { success: boolean | null; message: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
@@ -1098,5 +1181,8 @@ onUnmounted(() => {
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval)
|
||||
}
|
||||
// 清理余额重试的待处理定时器
|
||||
pendingTimers.forEach(clearTimeout)
|
||||
pendingTimers.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -920,6 +920,7 @@ function openCreateDialog() {
|
||||
}
|
||||
|
||||
function editUser(user: any) {
|
||||
// 创建数组副本,避免与 store 数据共享引用
|
||||
editingUser.value = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -927,9 +928,9 @@ function editUser(user: any) {
|
||||
quota_usd: user.quota_usd,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
allowed_providers: user.allowed_providers || [],
|
||||
allowed_api_formats: user.allowed_api_formats || [],
|
||||
allowed_models: user.allowed_models || []
|
||||
allowed_providers: [...(user.allowed_providers || [])],
|
||||
allowed_api_formats: [...(user.allowed_api_formats || [])],
|
||||
allowed_models: [...(user.allowed_models || [])]
|
||||
}
|
||||
showUserFormDialog.value = true
|
||||
}
|
||||
|
||||
@@ -357,10 +357,7 @@ import {
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
getPublicGlobalModels,
|
||||
type PublicGlobalModel,
|
||||
} from '@/api/public-models'
|
||||
import { type PublicGlobalModel } from '@/api/public-models'
|
||||
import { meApi } from '@/api/me'
|
||||
import {
|
||||
getUserConfigurableCapabilities,
|
||||
@@ -515,8 +512,9 @@ watch([searchQuery, capabilityFilters], () => {
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getPublicGlobalModels({ limit: 1000 })
|
||||
models.value = response.models || []
|
||||
// 使用用户认证端点,只获取用户有权限使用的模型
|
||||
const response = await meApi.getAvailableModels({ limit: 1000 })
|
||||
models.value = (response.models || []) as PublicGlobalModel[]
|
||||
} catch (err: any) {
|
||||
log.error('加载模型失败:', err)
|
||||
showError(err.response?.data?.detail || err.message, '加载模型失败')
|
||||
|
||||
Reference in New Issue
Block a user