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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 构造一个 ChannelService,channelRepo.ListAll 返回给定 channels,
|
// newAvailableChannelService 构造一个 ChannelService,channelRepo.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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user