diff --git a/backend/internal/repository/channel_repo.go b/backend/internal/repository/channel_repo.go index 5c86cc2a..1e2c2e4c 100644 --- a/backend/internal/repository/channel_repo.go +++ b/backend/internal/repository/channel_repo.go @@ -188,7 +188,7 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati // 查询 channel 列表 dataQuery := fmt.Sprintf( `SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.created_at, c.updated_at - FROM channels c WHERE %s ORDER BY c.id DESC LIMIT $%d OFFSET $%d`, + FROM channels c WHERE %s ORDER BY c.id ASC LIMIT $%d OFFSET $%d`, whereClause, argIdx, argIdx+1, ) args = append(args, pageSize, offset) diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index cbab9bfe..3d607ad7 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -278,7 +278,10 @@ func (s *ChannelService) buildCache(ctx context.Context) (*channelCache, error) groupPlatforms, err = s.repo.GetGroupPlatforms(dbCtx, allGroupIDs) if err != nil { slog.Warn("failed to load group platforms for channel cache", "error", err) - // 降级:继续构建缓存但无法按平台过滤 + errorCache := newEmptyChannelCache() + errorCache.loadedAt = time.Now().Add(-(channelCacheTTL - channelErrorTTL)) + s.cache.Store(errorCache) + return nil, fmt.Errorf("get group platforms: %w", err) } } diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 0232062c..56bde56c 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -1182,12 +1182,15 @@ func TestBuildCache_GroupPlatformError(t *testing.T) { } svc := newTestChannelService(repo) - // Should degrade gracefully: channel is found, but without platform info - // pricing won't match because platform will be "" and pricing platform is "anthropic" + // Should fail-close: error propagated when group platforms cannot be loaded result, err := svc.GetChannelForGroup(context.Background(), 10) - require.NoError(t, err) - require.NotNil(t, result) // channel still found - require.Equal(t, int64(1), result.ID) + require.Error(t, err) + require.Nil(t, result) + + // Within error-TTL, second call should hit cache (empty) and return nil, nil + result2, err2 := svc.GetChannelForGroup(context.Background(), 10) + require.NoError(t, err2) + require.Nil(t, result2) } func TestBuildCache_MultipleGroupsSameChannel(t *testing.T) { diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index b651be7d..7f379a6f 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -499,6 +499,9 @@ const activeTab = ref('basic') const allGroups = ref([]) const groupsLoading = ref(false) +// All channels for group-conflict detection (independent of current page) +const allChannelsForConflict = ref([]) + // Form data const form = reactive({ name: '', @@ -575,7 +578,7 @@ function getGroupsForPlatform(platform: GroupPlatform): AdminGroup[] { // ── Group helpers ── const groupToChannelMap = computed(() => { const map = new Map() - for (const ch of channels.value) { + for (const ch of allChannelsForConflict.value) { if (editingChannel.value && ch.id === editingChannel.value.id) continue for (const gid of ch.group_ids || []) { map.set(gid, ch) @@ -794,6 +797,16 @@ async function loadGroups() { } } +async function loadAllChannelsForConflict() { + try { + const response = await adminAPI.channels.list(1, 1000) + allChannelsForConflict.value = response.items || [] + } catch (error) { + // Fallback to current page data + allChannelsForConflict.value = channels.value + } +} + let searchTimeout: ReturnType function handleSearch() { clearTimeout(searchTimeout) @@ -828,7 +841,7 @@ function resetForm() { async function openCreateDialog() { editingChannel.value = null resetForm() - await loadGroups() + await Promise.all([loadGroups(), loadAllChannelsForConflict()]) showDialog.value = true } @@ -840,7 +853,7 @@ async function openEditDialog(channel: Channel) { form.restrict_models = channel.restrict_models || false form.billing_model_source = channel.billing_model_source || 'channel_mapped' // Must load groups first so apiToForm can map groupID → platform - await loadGroups() + await Promise.all([loadGroups(), loadAllChannelsForConflict()]) form.platforms = apiToForm(channel) showDialog.value = true } @@ -985,7 +998,12 @@ async function toggleChannelStatus(channel: Channel) { const newStatus = channel.status === 'active' ? 'disabled' : 'active' try { await adminAPI.channels.update(channel.id, { status: newStatus }) - channel.status = newStatus + if (filters.status && filters.status !== newStatus) { + // Item no longer matches the active filter — reload list + await loadChannels() + } else { + channel.status = newStatus + } } catch (error) { appStore.showError(t('admin.channels.updateError', 'Failed to update channel')) console.error('Error toggling channel status:', error)