feat: 优化批量余额查询和用户模型权限检查

- 添加批量余额查询并发限制,避免数据库连接池耗尽
- 支持余额加载 pending 状态和前端自动重试机制
- 添加用户可用模型 API,统一使用 AccessRestrictions
- 修复用户表单编辑时数组引用共享导致的数据覆盖问题
- 添加数据库连接池配置说明到 .env.example
This commit is contained in:
fawney19
2026-01-28 01:01:05 +08:00
parent 8e0695e9d9
commit 1e0255d0ed
12 changed files with 411 additions and 64 deletions

View File

@@ -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')

View File

@@ -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

View File

@@ -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,

View File

@@ -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 || [])]
}
}

View File

@@ -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 状态的 provider3秒后自动重试
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>

View File

@@ -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
}

View File

@@ -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, '加载模型失败')