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:
@@ -167,6 +167,13 @@ export interface UserBreakdownParams {
|
||||
endpoint?: string
|
||||
endpoint_type?: 'inbound' | 'upstream' | 'path'
|
||||
limit?: number
|
||||
// Additional filter conditions
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
request_type?: number
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UserBreakdownResponse {
|
||||
|
||||
@@ -73,6 +73,45 @@ export function formIntervalsToAPI(intervals: IntervalFormEntry[]): PricingInter
|
||||
}))
|
||||
}
|
||||
|
||||
// ── 模型模式冲突检测 ──────────────────────────────────────
|
||||
|
||||
interface ModelPattern {
|
||||
pattern: string
|
||||
prefix: string // lowercase, 通配符去掉尾部 *
|
||||
wildcard: boolean
|
||||
}
|
||||
|
||||
function toModelPattern(model: string): ModelPattern {
|
||||
const lower = model.toLowerCase()
|
||||
const wildcard = lower.endsWith('*')
|
||||
return {
|
||||
pattern: model,
|
||||
prefix: wildcard ? lower.slice(0, -1) : lower,
|
||||
wildcard,
|
||||
}
|
||||
}
|
||||
|
||||
function patternsConflict(a: ModelPattern, b: ModelPattern): boolean {
|
||||
if (!a.wildcard && !b.wildcard) return a.prefix === b.prefix
|
||||
if (a.wildcard && !b.wildcard) return b.prefix.startsWith(a.prefix)
|
||||
if (!a.wildcard && b.wildcard) return a.prefix.startsWith(b.prefix)
|
||||
// 双通配符:任一前缀是另一前缀的前缀即冲突
|
||||
return a.prefix.startsWith(b.prefix) || b.prefix.startsWith(a.prefix)
|
||||
}
|
||||
|
||||
/** 检测模型模式列表中的冲突,返回冲突的两个模式名;无冲突返回 null */
|
||||
export function findModelConflict(models: string[]): [string, string] | null {
|
||||
const patterns = models.map(toModelPattern)
|
||||
for (let i = 0; i < patterns.length; i++) {
|
||||
for (let j = i + 1; j < patterns.length; j++) {
|
||||
if (patternsConflict(patterns[i], patterns[j])) {
|
||||
return [patterns[i].pattern, patterns[j].pattern]
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 平台对应的模型 tag 样式(背景+文字) */
|
||||
export function getPlatformTagClass(platform: string): string {
|
||||
switch (platform) {
|
||||
|
||||
@@ -161,6 +161,7 @@ const props = withDefaults(
|
||||
showSourceToggle?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
filters?: Record<string, any>
|
||||
}>(),
|
||||
{
|
||||
upstreamEndpointStats: () => [],
|
||||
@@ -193,6 +194,7 @@ const toggleBreakdown = async (endpoint: string) => {
|
||||
breakdownItems.value = []
|
||||
try {
|
||||
const res = await getUserBreakdown({
|
||||
...props.filters,
|
||||
start_date: props.startDate,
|
||||
end_date: props.endDate,
|
||||
endpoint,
|
||||
|
||||
@@ -125,6 +125,7 @@ const props = withDefaults(defineProps<{
|
||||
showMetricToggle?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
filters?: Record<string, any>
|
||||
}>(), {
|
||||
loading: false,
|
||||
metric: 'tokens',
|
||||
@@ -150,6 +151,7 @@ const toggleBreakdown = async (type: string, id: number | string) => {
|
||||
breakdownItems.value = []
|
||||
try {
|
||||
const res = await getUserBreakdown({
|
||||
...props.filters,
|
||||
start_date: props.startDate,
|
||||
end_date: props.endDate,
|
||||
group_id: Number(id),
|
||||
|
||||
@@ -270,6 +270,7 @@ const props = withDefaults(defineProps<{
|
||||
rankingError?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
filters?: Record<string, any>
|
||||
}>(), {
|
||||
upstreamModelStats: () => [],
|
||||
mappingModelStats: () => [],
|
||||
@@ -302,6 +303,7 @@ const toggleBreakdown = async (type: string, id: string) => {
|
||||
breakdownItems.value = []
|
||||
try {
|
||||
const res = await getUserBreakdown({
|
||||
...props.filters,
|
||||
start_date: props.startDate,
|
||||
end_date: props.endDate,
|
||||
model: id,
|
||||
|
||||
@@ -1744,6 +1744,8 @@ export default {
|
||||
deleteError: 'Failed to delete channel',
|
||||
nameRequired: 'Please enter a channel name',
|
||||
duplicateModels: 'Model "{0}" appears in multiple pricing entries',
|
||||
modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range",
|
||||
mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range",
|
||||
deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
|
||||
@@ -1824,6 +1824,8 @@ export default {
|
||||
deleteError: '删除渠道失败',
|
||||
nameRequired: '请输入渠道名称',
|
||||
duplicateModels: '模型「{0}」在多个定价条目中重复',
|
||||
modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠",
|
||||
mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠",
|
||||
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
|
||||
columns: {
|
||||
name: '名称',
|
||||
|
||||
@@ -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