feat(channels): add "Available Channels" aggregate view
Add a read-only aggregate view per channel: its linked groups and a deterministic wildcard-free supported-model list with pricing details. Backend - service.Channel.SupportedModels(): combine ModelMapping keys with same-platform ModelPricing.Models; trailing "*" keys expand via pricing prefix match; platforms without a mapping produce no entries (intentional "no mapping = not shown" rule). - Extract splitWildcardSuffix() shared with toModelEntry. - Build a per-call pricing lookup map (platform+lowerName -> *pricing) to avoid O(N*M) scans in SupportedModels. - ChannelService.ListAvailable() aggregates channels + active groups; filters out group IDs no longer active. - Admin route GET /api/v1/admin/channels/available returns the full DTO (id, status, billing_model_source, restrict_models, groups, supported_models). - User route GET /api/v1/channels/available applies three filters: Status==active, visible-group intersection, and platform filter on supported_models (prevents cross-platform leak when a channel links to both a user-accessible group and an inaccessible one on another platform). Response is a plain array (matches the /groups/available sibling shape). Field whitelist omits billing_model_source, restrict_models, ids, status, sort_order. Frontend - New /admin/available-channels and /available-channels views backed by a shared AvailableChannelsTable component (admin adds status + billing-source columns via slots). - PricingRow extracted to its own SFC; SupportedModelChip references shared billing-mode constants in constants/channel.ts. - Sidebar: new entry above "渠道管理" for admin; matching entry in user nav. - i18n: zh + en coverage for both namespaces. Tests - SupportedModels: wildcard-only pricing skipped, prefix-matches- nothing, cross-platform bleed, case-insensitive dedup, empty platform mapping. - ListAvailable: nil groupRepo, inactive-group-ID dropped, stable case-insensitive name sort. - User handler: 401 on unauthenticated, visible-group intersection, platform filter on supported_models, JSON whitelist. - Admin handler: full DTO including default BillingModelSource fallback. Refs: issue #1729
This commit is contained in:
@@ -433,3 +433,207 @@ func TestValidateIntervals_UnboundedNotLast(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "unbounded")
|
||||
require.Contains(t, err.Error(), "last")
|
||||
}
|
||||
|
||||
func TestSupportedModels_ExactKeysAndPricing(t *testing.T) {
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 10, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(3e-6)},
|
||||
{ID: 11, Platform: "anthropic", Models: []string{"claude-opus-4-6"}, InputPrice: testPtrFloat64(1.5e-5)},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := ch.SupportedModels()
|
||||
require.Len(t, got, 2)
|
||||
require.Equal(t, "anthropic", got[0].Platform)
|
||||
require.Equal(t, "claude-opus-4-6", got[0].Name)
|
||||
require.NotNil(t, got[0].Pricing)
|
||||
require.Equal(t, int64(11), got[0].Pricing.ID)
|
||||
require.Equal(t, "claude-sonnet-4-6", got[1].Name)
|
||||
require.Equal(t, int64(10), got[1].Pricing.ID)
|
||||
}
|
||||
|
||||
func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) {
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6", "claude-sonnet-4-5"}},
|
||||
{ID: 2, Platform: "anthropic", Models: []string{"claude-opus-4-6"}},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {
|
||||
"claude-sonnet-*": "claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := ch.SupportedModels()
|
||||
names := make([]string, 0, len(got))
|
||||
for _, m := range got {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
require.ElementsMatch(t, []string{"claude-sonnet-4-5", "claude-sonnet-4-6"}, names)
|
||||
for _, m := range got {
|
||||
require.NotContains(t, m.Name, "*")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedModels_PlatformWithoutMappingSkipped(t *testing.T) {
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}},
|
||||
{ID: 2, Platform: "openai", Models: []string{"gpt-4o"}},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {"claude-sonnet-4-6": "claude-sonnet-4-6"},
|
||||
// openai 没有 mapping 条目
|
||||
},
|
||||
}
|
||||
|
||||
got := ch.SupportedModels()
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "anthropic", got[0].Platform)
|
||||
require.Equal(t, "claude-sonnet-4-6", got[0].Name)
|
||||
}
|
||||
|
||||
func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) {
|
||||
ch := &Channel{
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {"claude-sonnet-4-6": "claude-sonnet-4-6"},
|
||||
},
|
||||
}
|
||||
|
||||
got := ch.SupportedModels()
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "claude-sonnet-4-6", got[0].Name)
|
||||
require.Nil(t, got[0].Pricing)
|
||||
}
|
||||
|
||||
func TestSupportedModels_DedupAndSort(t *testing.T) {
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6", "claude-sonnet-4-5"}},
|
||||
{ID: 2, Platform: "openai", Models: []string{"gpt-4o"}},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {
|
||||
"claude-sonnet-4-6": "upstream-a",
|
||||
"claude-sonnet-*": "upstream-a",
|
||||
},
|
||||
"openai": {"gpt-4o": "gpt-4o"},
|
||||
},
|
||||
}
|
||||
|
||||
got := ch.SupportedModels()
|
||||
require.Len(t, got, 3)
|
||||
require.Equal(t, "anthropic", got[0].Platform)
|
||||
require.Equal(t, "claude-sonnet-4-5", got[0].Name)
|
||||
require.Equal(t, "anthropic", got[1].Platform)
|
||||
require.Equal(t, "claude-sonnet-4-6", got[1].Name)
|
||||
require.Equal(t, "openai", got[2].Platform)
|
||||
require.Equal(t, "gpt-4o", got[2].Name)
|
||||
}
|
||||
|
||||
func TestSupportedModels_NilChannelAndEmpty(t *testing.T) {
|
||||
var nilCh *Channel
|
||||
require.Nil(t, nilCh.SupportedModels())
|
||||
|
||||
empty := &Channel{}
|
||||
require.Nil(t, empty.SupportedModels())
|
||||
}
|
||||
|
||||
func TestGetModelPricingByPlatform(t *testing.T) {
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(3e-6)},
|
||||
{ID: 2, Platform: "openai", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(1e-6)},
|
||||
},
|
||||
}
|
||||
|
||||
ant := ch.GetModelPricingByPlatform("anthropic", "claude-sonnet-4-6")
|
||||
require.NotNil(t, ant)
|
||||
require.Equal(t, int64(1), ant.ID)
|
||||
|
||||
oa := ch.GetModelPricingByPlatform("openai", "claude-sonnet-4-6")
|
||||
require.NotNil(t, oa)
|
||||
require.Equal(t, int64(2), oa.ID)
|
||||
|
||||
require.Nil(t, ch.GetModelPricingByPlatform("gemini", "claude-sonnet-4-6"))
|
||||
}
|
||||
|
||||
func TestSupportedModels_WildcardOnlyPricingRowsSkipped(t *testing.T) {
|
||||
// 定价中含通配符条目(pattern),不应被当作具体模型名展开。
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-*", "claude-sonnet-4-6"}},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {"claude-sonnet-*": "claude-sonnet-4-6"},
|
||||
},
|
||||
}
|
||||
got := ch.SupportedModels()
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "claude-sonnet-4-6", got[0].Name)
|
||||
for _, m := range got {
|
||||
require.NotContains(t, m.Name, "*")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedModels_WildcardPrefixMatchesNothing(t *testing.T) {
|
||||
// 通配符模式无任何对应定价模型时,该平台应产出 0 个模型。
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "openai", Models: []string{"gpt-4o"}},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {"gpt-foo-*": "gpt-foo-1"},
|
||||
},
|
||||
}
|
||||
require.Empty(t, ch.SupportedModels())
|
||||
}
|
||||
|
||||
func TestSupportedModels_CrossPlatformPricingDoesNotBleed(t *testing.T) {
|
||||
// anthropic 的通配符不应拉入 openai 定价行,哪怕名字恰好前缀匹配。
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "openai", Models: []string{"claude-sonnet-4-6"}},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {"claude-sonnet-*": "x"},
|
||||
},
|
||||
}
|
||||
require.Empty(t, ch.SupportedModels())
|
||||
}
|
||||
|
||||
func TestSupportedModels_CaseInsensitiveDedup(t *testing.T) {
|
||||
// 两行定价用不同大小写定义了同一模型,结果应去重为 1 条;首次出现的原始大小写保留。
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "openai", Models: []string{"GPT-4o"}},
|
||||
{ID: 2, Platform: "openai", Models: []string{"gpt-4o"}},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"openai": {"gpt-*": "x"},
|
||||
},
|
||||
}
|
||||
got := ch.SupportedModels()
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "GPT-4o", got[0].Name)
|
||||
}
|
||||
|
||||
func TestSupportedModels_EmptyPlatformMapping(t *testing.T) {
|
||||
// ModelMapping 有一个 platform key 但 value 是空 map —— 该 platform 应被跳过。
|
||||
ch := &Channel{
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
"anthropic": {},
|
||||
},
|
||||
}
|
||||
require.Empty(t, ch.SupportedModels())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user