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:
erio
2026-04-01 01:51:19 +08:00
parent 669bff78c4
commit 2555951be4
33 changed files with 3633 additions and 262 deletions

View File

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

View File

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

View File

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

View File

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