diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index d142146d..dcb68dc5 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -509,7 +509,7 @@ func (c *Channel) SupportedModels() []SupportedModel { } } - sort.Slice(result, func(i, j int) bool { + sort.SliceStable(result, func(i, j int) bool { if result[i].Platform != result[j].Platform { return result[i].Platform < result[j].Platform } diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 8e055518..62406cd0 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -38,19 +38,17 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, return nil, fmt.Errorf("list channels: %w", err) } - groupByID := make(map[int64]AvailableGroupRef) - if s.groupRepo != nil { - groups, err := s.groupRepo.ListActive(ctx) - if err != nil { - return nil, fmt.Errorf("list active groups: %w", err) - } - for i := range groups { - g := groups[i] - groupByID[g.ID] = AvailableGroupRef{ - ID: g.ID, - Name: g.Name, - Platform: g.Platform, - } + groups, err := s.groupRepo.ListActive(ctx) + if err != nil { + return nil, fmt.Errorf("list active groups: %w", err) + } + groupByID := make(map[int64]AvailableGroupRef, len(groups)) + for i := range groups { + g := groups[i] + groupByID[g.ID] = AvailableGroupRef{ + ID: g.ID, + Name: g.Name, + Platform: g.Platform, } } diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go index 6a11fa4b..5da5e6e1 100644 --- a/backend/internal/service/channel_available_test.go +++ b/backend/internal/service/channel_available_test.go @@ -4,6 +4,7 @@ package service import ( "context" + "errors" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" @@ -12,11 +13,16 @@ import ( // stubGroupRepoForAvailable 是 ListAvailable 测试用的 GroupRepository stub, // 仅实现 ListActive;其他方法对本测试无关,返回零值即可。 +// listActiveErr 非 nil 时,ListActive 返回该错误用于错误传播测试。 type stubGroupRepoForAvailable struct { - activeGroups []Group + activeGroups []Group + listActiveErr error } func (s *stubGroupRepoForAvailable) ListActive(ctx context.Context) ([]Group, error) { + if s.listActiveErr != nil { + return nil, s.listActiveErr + } return s.activeGroups, nil } @@ -61,7 +67,7 @@ func (s *stubGroupRepoForAvailable) UpdateSortOrders(ctx context.Context, update } // newAvailableChannelService 构造一个 ChannelService,channelRepo.ListAll 返回给定 channels, -// groupRepo 由参数决定(可传 nil 测试 nil 分支)。 +// groupRepo 由参数决定。传入空 stub 表示「活跃分组列表为空」。 func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) *ChannelService { repo := &mockChannelRepository{ listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, @@ -69,15 +75,15 @@ func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) * return NewChannelService(repo, groupRepo, nil) } -func TestListAvailable_NilGroupRepo_NoGroupsAttached(t *testing.T) { - // groupRepo 为 nil 时不应 panic,且每个渠道的 Groups 应为空切片。 +func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) { + // 活跃分组列表为空时,渠道的 Groups 应为空切片,不报错。 channels := []Channel{{ ID: 1, Name: "chA", Status: StatusActive, GroupIDs: []int64{10, 20}, }} - svc := newAvailableChannelService(channels, nil) + svc := newAvailableChannelService(channels, &stubGroupRepoForAvailable{}) out, err := svc.ListAvailable(context.Background()) require.NoError(t, err) require.Len(t, out, 1) @@ -109,7 +115,7 @@ func TestListAvailable_SortedByName(t *testing.T) { {ID: 2, Name: "Alpha"}, {ID: 3, Name: "charlie"}, } - svc := newAvailableChannelService(channels, nil) + svc := newAvailableChannelService(channels, &stubGroupRepoForAvailable{}) out, err := svc.ListAvailable(context.Background()) require.NoError(t, err) require.Len(t, out, 3) @@ -117,3 +123,42 @@ func TestListAvailable_SortedByName(t *testing.T) { require.Equal(t, "beta", out[1].Name) require.Equal(t, "charlie", out[2].Name) } + +func TestListAvailable_ListAllErrorPropagates(t *testing.T) { + // ListAll 返回错误时 ListAvailable 应直接返回包装后的错误,不再访问 groupRepo。 + sentinel := errors.New("list-all-boom") + repo := &mockChannelRepository{ + listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel }, + } + svc := NewChannelService(repo, &stubGroupRepoForAvailable{}, nil) + out, err := svc.ListAvailable(context.Background()) + require.Nil(t, out) + require.ErrorIs(t, err, sentinel) +} + +func TestListAvailable_ListActiveErrorPropagates(t *testing.T) { + // groupRepo.ListActive 返回错误时 ListAvailable 应直接返回包装后的错误。 + sentinel := errors.New("list-active-boom") + svc := newAvailableChannelService( + []Channel{{ID: 1, Name: "chA"}}, + &stubGroupRepoForAvailable{listActiveErr: sentinel}, + ) + out, err := svc.ListAvailable(context.Background()) + require.Nil(t, out) + require.ErrorIs(t, err, sentinel) +} + +func TestListAvailable_DefaultsEmptyBillingModelSource(t *testing.T) { + // 渠道 BillingModelSource 为空时应回填为 BillingModelSourceChannelMapped, + // 显式值应原样保留(由 service 层统一处理,避免各 handler 重复默认逻辑)。 + channels := []Channel{ + {ID: 1, Name: "empty", BillingModelSource: ""}, + {ID: 2, Name: "explicit", BillingModelSource: BillingModelSourceUpstream}, + } + svc := newAvailableChannelService(channels, &stubGroupRepoForAvailable{}) + out, err := svc.ListAvailable(context.Background()) + require.NoError(t, err) + require.Len(t, out, 2) + require.Equal(t, BillingModelSourceChannelMapped, out[0].BillingModelSource) + require.Equal(t, BillingModelSourceUpstream, out[1].BillingModelSource) +} diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 13f5d71e..e9011ec5 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -61,6 +61,7 @@ import { computed, useSlots } from 'vue' import DataTable from '@/components/common/DataTable.vue' import Icon from '@/components/icons/Icon.vue' import SupportedModelChip from './SupportedModelChip.vue' +import type { UserSupportedModelPricing } from '@/api/channels' interface GroupRef { id: number @@ -75,7 +76,7 @@ interface Row { supported_models: Array<{ name: string platform: string - pricing: unknown | null + pricing: UserSupportedModelPricing | null }> [key: string]: unknown } diff --git a/frontend/src/components/channels/SupportedModelChip.vue b/frontend/src/components/channels/SupportedModelChip.vue index 8f586974..f3e5549b 100644 --- a/frontend/src/components/channels/SupportedModelChip.vue +++ b/frontend/src/components/channels/SupportedModelChip.vue @@ -127,34 +127,15 @@ import { BILLING_MODE_IMAGE, type BillingMode } from '@/constants/channel' +import type { UserPricingInterval, UserSupportedModel } from '@/api/channels' -interface PricingInterval { - min_tokens: number - max_tokens: number | null - tier_label?: string - input_price: number | null - output_price: number | null - cache_write_price: number | null - cache_read_price: number | null - per_request_price: number | null -} - -interface SupportedModelPricing { - billing_mode: BillingMode - input_price: number | null - output_price: number | null - cache_write_price: number | null - cache_read_price: number | null - image_output_price: number | null - per_request_price: number | null - intervals: PricingInterval[] -} - -interface SupportedModelLike { - name: string - platform: string - pricing: SupportedModelPricing | null -} +/** + * 复用 api/channels.ts 的用户侧最小形态 DTO。 + * admin 侧 ChannelModelPricing 字段更多,但结构上是用户 DTO 的超集, + * 因此 admin 视图传入时 TypeScript 结构化子类型会直接通过。 + */ +type PricingInterval = UserPricingInterval +type SupportedModelLike = UserSupportedModel const props = withDefaults( defineProps<{ diff --git a/frontend/src/views/admin/AvailableChannelsView.vue b/frontend/src/views/admin/AvailableChannelsView.vue index f1dc6610..c7c27154 100644 --- a/frontend/src/views/admin/AvailableChannelsView.vue +++ b/frontend/src/views/admin/AvailableChannelsView.vue @@ -78,7 +78,7 @@ import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable import channelsAPI, { type AvailableChannel } from '@/api/admin/channels' import { useAppStore } from '@/stores/app' import { extractApiErrorMessage } from '@/utils/apiError' -import { CHANNEL_STATUS_ACTIVE } from '@/constants/channel' +import { CHANNEL_STATUS_ACTIVE, type ChannelStatus } from '@/constants/channel' const { t } = useI18n() const appStore = useAppStore() @@ -95,7 +95,7 @@ const columns = computed(() => [ { key: 'supported_models', label: t('admin.availableChannels.columns.supportedModels') } ]) -function statusLabel(status: string): string { +function statusLabel(status: ChannelStatus): string { return status === CHANNEL_STATUS_ACTIVE ? t('admin.availableChannels.statusActive') : t('admin.availableChannels.statusDisabled')