feat(channel): 渠道管理全链路集成 — 模型映射、定价、限制、用量统计
- 渠道模型映射:支持精确匹配和通配符映射,按平台隔离 - 渠道模型定价:支持 token/按次/图片三种计费模式,区间分层定价 - 模型限制:渠道可限制仅允许定价列表中的模型 - 计费模型来源:支持 requested/upstream 两种计费模型选择 - 用量统计:usage_logs 新增 channel_id/model_mapping_chain/billing_tier/billing_mode 字段 - Dashboard 支持 model_source 维度(requested/upstream/mapping)查看模型统计 - 全部 gateway handler 统一接入 ResolveChannelMappingAndRestrict - 修复测试:同步 SoraGenerationRepository 接口、SQL INSERT 参数、scan 字段
This commit is contained in:
@@ -418,7 +418,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest } from '@/api/admin/channels'
|
||||
import type { PricingFormEntry } from '@/components/admin/channel/types'
|
||||
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI } from '@/components/admin/channel/types'
|
||||
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict } from '@/components/admin/channel/types'
|
||||
import type { AdminGroup, GroupPlatform } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
@@ -875,19 +875,35 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check duplicate models per platform (same model in different platforms is allowed)
|
||||
// Check model pattern conflicts per platform (duplicate / wildcard overlap)
|
||||
for (const section of form.platforms.filter(s => s.enabled)) {
|
||||
const seen = new Set()
|
||||
// Collect all pricing models for this platform
|
||||
const allModels: string[] = []
|
||||
for (const entry of section.model_pricing) {
|
||||
for (const m of entry.models) {
|
||||
const key = m.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
|
||||
appStore.showError(t('admin.channels.duplicateModels', `${platformLabel} 平台下模型 "${m}" 在多个定价条目中重复`))
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
allModels.push(...entry.models)
|
||||
}
|
||||
const pricingConflict = findModelConflict(allModels)
|
||||
if (pricingConflict) {
|
||||
appStore.showError(
|
||||
t('admin.channels.modelConflict',
|
||||
{ model1: pricingConflict[0], model2: pricingConflict[1] },
|
||||
`模型模式 '${pricingConflict[0]}' 和 '${pricingConflict[1]}' 冲突:匹配范围重叠`)
|
||||
)
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
}
|
||||
// Check model mapping source pattern conflicts
|
||||
const mappingKeys = Object.keys(section.model_mapping)
|
||||
if (mappingKeys.length > 0) {
|
||||
const mappingConflict = findModelConflict(mappingKeys)
|
||||
if (mappingConflict) {
|
||||
appStore.showError(
|
||||
t('admin.channels.mappingConflict',
|
||||
{ model1: mappingConflict[0], model2: mappingConflict[1] },
|
||||
`模型映射源 '${mappingConflict[0]}' 和 '${mappingConflict[1]}' 冲突:匹配范围重叠`)
|
||||
)
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
:show-metric-toggle="true"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
:filters="breakdownFilters"
|
||||
/>
|
||||
<GroupDistributionChart
|
||||
v-model:metric="groupDistributionMetric"
|
||||
@@ -42,6 +43,7 @@
|
||||
:show-metric-toggle="true"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
:filters="breakdownFilters"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
@@ -57,6 +59,7 @@
|
||||
:title="t('usage.endpointDistribution')"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
:filters="breakdownFilters"
|
||||
/>
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
@@ -169,6 +172,17 @@ const cleanupDialogVisible = ref(false)
|
||||
const showBalanceHistoryModal = ref(false)
|
||||
const balanceHistoryUser = ref<AdminUser | null>(null)
|
||||
|
||||
const breakdownFilters = computed(() => {
|
||||
const f: Record<string, any> = {}
|
||||
if (filters.value.user_id) f.user_id = filters.value.user_id
|
||||
if (filters.value.api_key_id) f.api_key_id = filters.value.api_key_id
|
||||
if (filters.value.account_id) f.account_id = filters.value.account_id
|
||||
if (filters.value.group_id) f.group_id = filters.value.group_id
|
||||
if (filters.value.request_type != null) f.request_type = filters.value.request_type
|
||||
if (filters.value.billing_type != null) f.billing_type = filters.value.billing_type
|
||||
return f
|
||||
})
|
||||
|
||||
const handleUserClick = async (userId: number) => {
|
||||
try {
|
||||
const user = await adminAPI.users.getById(userId)
|
||||
|
||||
Reference in New Issue
Block a user