refactor(channels): tighten types and error paths per second review

- service: drop groupRepo nil guard (DI must inject), switch SupportedModels to SliceStable to match doc
- frontend: reuse user-side DTO types in SupportedModelChip/AvailableChannelsTable instead of duplicating shapes; narrow admin statusLabel param to ChannelStatus
- tests: replace nil-groupRepo case with ListAll/ListActive error propagation and BillingModelSource default-backfill coverage
This commit is contained in:
erio
2026-04-21 01:42:18 +08:00
parent 365ef1fdf7
commit 88decb6e0c
6 changed files with 75 additions and 50 deletions

View File

@@ -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 { if result[i].Platform != result[j].Platform {
return result[i].Platform < result[j].Platform return result[i].Platform < result[j].Platform
} }

View File

@@ -38,19 +38,17 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
return nil, fmt.Errorf("list channels: %w", err) return nil, fmt.Errorf("list channels: %w", err)
} }
groupByID := make(map[int64]AvailableGroupRef) groups, err := s.groupRepo.ListActive(ctx)
if s.groupRepo != nil { if err != nil {
groups, err := s.groupRepo.ListActive(ctx) return nil, fmt.Errorf("list active groups: %w", err)
if err != nil { }
return nil, fmt.Errorf("list active groups: %w", err) groupByID := make(map[int64]AvailableGroupRef, len(groups))
} for i := range groups {
for i := range groups { g := groups[i]
g := groups[i] groupByID[g.ID] = AvailableGroupRef{
groupByID[g.ID] = AvailableGroupRef{ ID: g.ID,
ID: g.ID, Name: g.Name,
Name: g.Name, Platform: g.Platform,
Platform: g.Platform,
}
} }
} }

View File

@@ -4,6 +4,7 @@ package service
import ( import (
"context" "context"
"errors"
"testing" "testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -12,11 +13,16 @@ import (
// stubGroupRepoForAvailable 是 ListAvailable 测试用的 GroupRepository stub // stubGroupRepoForAvailable 是 ListAvailable 测试用的 GroupRepository stub
// 仅实现 ListActive其他方法对本测试无关返回零值即可。 // 仅实现 ListActive其他方法对本测试无关返回零值即可。
// listActiveErr 非 nil 时ListActive 返回该错误用于错误传播测试。
type stubGroupRepoForAvailable struct { type stubGroupRepoForAvailable struct {
activeGroups []Group activeGroups []Group
listActiveErr error
} }
func (s *stubGroupRepoForAvailable) ListActive(ctx context.Context) ([]Group, error) { func (s *stubGroupRepoForAvailable) ListActive(ctx context.Context) ([]Group, error) {
if s.listActiveErr != nil {
return nil, s.listActiveErr
}
return s.activeGroups, nil return s.activeGroups, nil
} }
@@ -61,7 +67,7 @@ func (s *stubGroupRepoForAvailable) UpdateSortOrders(ctx context.Context, update
} }
// newAvailableChannelService 构造一个 ChannelServicechannelRepo.ListAll 返回给定 channels // newAvailableChannelService 构造一个 ChannelServicechannelRepo.ListAll 返回给定 channels
// groupRepo 由参数决定(可传 nil 测试 nil 分支) // groupRepo 由参数决定。传入空 stub 表示「活跃分组列表为空」
func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) *ChannelService { func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) *ChannelService {
repo := &mockChannelRepository{ repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, 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) return NewChannelService(repo, groupRepo, nil)
} }
func TestListAvailable_NilGroupRepo_NoGroupsAttached(t *testing.T) { func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) {
// groupRepo 为 nil 时不应 panic且每个渠道的 Groups 应为空切片。 // 活跃分组列表为空时,渠道的 Groups 应为空切片,不报错
channels := []Channel{{ channels := []Channel{{
ID: 1, ID: 1,
Name: "chA", Name: "chA",
Status: StatusActive, Status: StatusActive,
GroupIDs: []int64{10, 20}, GroupIDs: []int64{10, 20},
}} }}
svc := newAvailableChannelService(channels, nil) svc := newAvailableChannelService(channels, &stubGroupRepoForAvailable{})
out, err := svc.ListAvailable(context.Background()) out, err := svc.ListAvailable(context.Background())
require.NoError(t, err) require.NoError(t, err)
require.Len(t, out, 1) require.Len(t, out, 1)
@@ -109,7 +115,7 @@ func TestListAvailable_SortedByName(t *testing.T) {
{ID: 2, Name: "Alpha"}, {ID: 2, Name: "Alpha"},
{ID: 3, Name: "charlie"}, {ID: 3, Name: "charlie"},
} }
svc := newAvailableChannelService(channels, nil) svc := newAvailableChannelService(channels, &stubGroupRepoForAvailable{})
out, err := svc.ListAvailable(context.Background()) out, err := svc.ListAvailable(context.Background())
require.NoError(t, err) require.NoError(t, err)
require.Len(t, out, 3) 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, "beta", out[1].Name)
require.Equal(t, "charlie", out[2].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)
}

View File

@@ -61,6 +61,7 @@ import { computed, useSlots } from 'vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import SupportedModelChip from './SupportedModelChip.vue' import SupportedModelChip from './SupportedModelChip.vue'
import type { UserSupportedModelPricing } from '@/api/channels'
interface GroupRef { interface GroupRef {
id: number id: number
@@ -75,7 +76,7 @@ interface Row {
supported_models: Array<{ supported_models: Array<{
name: string name: string
platform: string platform: string
pricing: unknown | null pricing: UserSupportedModelPricing | null
}> }>
[key: string]: unknown [key: string]: unknown
} }

View File

@@ -127,34 +127,15 @@ import {
BILLING_MODE_IMAGE, BILLING_MODE_IMAGE,
type BillingMode type BillingMode
} from '@/constants/channel' } from '@/constants/channel'
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
interface PricingInterval { /**
min_tokens: number * 复用 api/channels.ts 的用户侧最小形态 DTO
max_tokens: number | null * admin ChannelModelPricing 字段更多但结构上是用户 DTO 的超集
tier_label?: string * 因此 admin 视图传入时 TypeScript 结构化子类型会直接通过
input_price: number | null */
output_price: number | null type PricingInterval = UserPricingInterval
cache_write_price: number | null type SupportedModelLike = UserSupportedModel
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
}
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{

View File

@@ -78,7 +78,7 @@ import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable
import channelsAPI, { type AvailableChannel } from '@/api/admin/channels' import channelsAPI, { type AvailableChannel } from '@/api/admin/channels'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError' 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 { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
@@ -95,7 +95,7 @@ const columns = computed(() => [
{ key: 'supported_models', label: t('admin.availableChannels.columns.supportedModels') } { key: 'supported_models', label: t('admin.availableChannels.columns.supportedModels') }
]) ])
function statusLabel(status: string): string { function statusLabel(status: ChannelStatus): string {
return status === CHANNEL_STATUS_ACTIVE return status === CHANNEL_STATUS_ACTIVE
? t('admin.availableChannels.statusActive') ? t('admin.availableChannels.statusActive')
: t('admin.availableChannels.statusDisabled') : t('admin.availableChannels.statusDisabled')