From 540817a6a7918eda7221412d952cf18ebfe6bdb4 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 8 Aug 2026 09:09:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=E6=96=B0=E5=A2=9E=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E8=B0=83=E5=BA=A6=E7=8A=B6=E6=80=81=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/channel.go | 4 ++ internal/dispatchadapter/catalog.go | 35 ++++++++++ internal/dispatchadapter/catalog_test.go | 43 ++++++++++++ internal/logic/admin/channel.go | 24 ++++++- web/admin/src/views/ai/ChannelDetailPage.vue | 14 ++++ web/admin/src/views/ai/ChannelsPage.vue | 70 +++++++++++++++----- 6 files changed, 170 insertions(+), 20 deletions(-) diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index c0c2cba..8205d27 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -45,6 +45,8 @@ type ChannelItem struct { BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds"` CreatedAt string `json:"created_at"` HealthScore *float64 `json:"health_score"` + BreakerState int `json:"breaker_state"` // 调度熔断状态:0=正常 1=熔断中 2=半开探活 + BreakerModels int `json:"breaker_models"` // 处于熔断/半开的模型数量(渠道×模型级汇总) } // ChannelCreateReq 创建渠道请求 @@ -131,6 +133,8 @@ type ChannelDetailRes struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` HealthScore *float64 `json:"health_score"` + BreakerState int `json:"breaker_state"` // 调度熔断状态:0=正常 1=熔断中 2=半开探活 + BreakerModels int `json:"breaker_models"` // 处于熔断/半开的模型数量(渠道×模型级汇总) KeyType string `json:"key_type"` KeyStatus string `json:"key_status"` KeyName string `json:"key_name"` diff --git a/internal/dispatchadapter/catalog.go b/internal/dispatchadapter/catalog.go index 730076b..5973117 100644 --- a/internal/dispatchadapter/catalog.go +++ b/internal/dispatchadapter/catalog.go @@ -147,6 +147,41 @@ func (c *Catalog) ChannelModels() map[int64][]string { return out } +// ChannelRuntimeState 单个渠道的调度运行状态聚合(管理后台展示用)。 +// 数据来源:目录内存快照(每 5s 刷新),与调度器 buildCandidates 使用同一份数据; +// Redis 读值已在 Rebuild 时经 EffectiveBreakerState 做惰性 OPEN→HALF_OPEN 判定。 +type ChannelRuntimeState struct { + Breaker dispatch.BreakerState // 渠道级熔断状态(同一渠道所有模型取值相同) + BreakerModels int // 处于 OPEN/HALF_OPEN 的模型数量(渠道×模型级汇总) +} + +// ChannelRuntimeStates 返回目录快照中各渠道的熔断聚合视图。 +// 遍历 byModel 按渠道 ID 去重;渠道级 Breaker 取首次遇到值(各模型行相同), +// 模型级 ModelBreaker 按行计数(同一渠道在 byModel 中每个模型至多出现一次 → 计数即按模型去重)。 +// 注意:disabled / 无 enabled 能力的渠道不在快照(loadCatalogFromDB 仅载 c.status='active'), +// 不会出现在返回 map 中——调用方需按运营状态自行区分展示。 +// 并发安全:c.current 为 atomic.Pointer,Load() 取到的快照不可变,无锁。 +func (c *Catalog) ChannelRuntimeStates() map[int64]ChannelRuntimeState { + idx := c.current.Load() + if idx == nil { + return nil + } + out := make(map[int64]ChannelRuntimeState) + for _, chans := range idx.byModel { + for _, ch := range chans { + agg, ok := out[ch.ID] + if !ok { + agg = ChannelRuntimeState{Breaker: ch.Breaker} + } + if ch.ModelBreaker != dispatch.BreakerClosed { + agg.BreakerModels++ + } + out[ch.ID] = agg + } + } + return out +} + // Invalidate 主动触发一次刷新(渠道/能力/Key 管理操作后调用;跨实例走 pub/sub)。 func (c *Catalog) Invalidate() { select { diff --git a/internal/dispatchadapter/catalog_test.go b/internal/dispatchadapter/catalog_test.go index e9b089b..dafb0d8 100644 --- a/internal/dispatchadapter/catalog_test.go +++ b/internal/dispatchadapter/catalog_test.go @@ -148,3 +148,46 @@ func TestRampElapsed(t *testing.T) { // 窗口未配置:不爬坡 assert.Equal(t, int64(-1), rampElapsed(now, now-10, 0, 0)) } + +// TestCatalog_渠道运行状态聚合 验证 ChannelRuntimeStates 按渠道去重、渠道级熔断取同值、 +// 模型级熔断按模型计数。 +func TestCatalog_渠道运行状态聚合(t *testing.T) { + rows := []catalogRow{ + {ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary"}, + {ChannelID: 1, ChannelName: "A", ModelName: "claude-sonnet", Weight: 10, Tier: "primary"}, + {ChannelID: 2, ChannelName: "B", ModelName: "gpt-4o", Weight: 5, Tier: "secondary"}, + {ChannelID: 3, ChannelName: "C", ModelName: "gpt-4o", Weight: 5, Tier: "secondary"}, + } + c := testCatalog(rows, nil, func(_ context.Context, channelID int64, _ string) RuntimeReadout { + switch channelID { + case 1: + return RuntimeReadout{Breaker: dispatch.BreakerOpen, ModelBreaker: dispatch.BreakerOpen} + case 2: + return RuntimeReadout{Breaker: dispatch.BreakerClosed, ModelBreaker: dispatch.BreakerHalfOpen} + default: + return RuntimeReadout{Breaker: dispatch.BreakerClosed, ModelBreaker: dispatch.BreakerClosed} + } + }) + + got := c.ChannelRuntimeStates() + require.Len(t, got, 3) + + // 渠道 1:渠道级熔断 OPEN,两个模型均熔断 + assert.Equal(t, dispatch.BreakerOpen, got[1].Breaker) + assert.Equal(t, 2, got[1].BreakerModels, "两个模型均熔断") + + // 渠道 2:渠道级正常,仅 gpt-4o 模型半开 + assert.Equal(t, dispatch.BreakerClosed, got[2].Breaker) + assert.Equal(t, 1, got[2].BreakerModels, "仅 gpt-4o 半开") + + // 渠道 3:完全正常 + assert.Equal(t, dispatch.BreakerClosed, got[3].Breaker) + assert.Equal(t, 0, got[3].BreakerModels) +} + +// TestCatalog_运行状态_未Rebuild返回空 验证目录未构建时返回空 map(而非 nil), +// 调用方遍历时安全。 +func TestCatalog_运行状态_未Rebuild返回空(t *testing.T) { + c := NewCatalog(func() *dispatch.RoutingPolicy { return dispatch.DefaultRoutingPolicy() }, nil, nil) + assert.Empty(t, c.ChannelRuntimeStates()) +} diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index 6a4bd9b..b36ca6f 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -44,6 +44,16 @@ var providerTypeNames = map[int]string{ 42: "Sub2API", } +// channelRuntimeStates 返回目录快照的渠道熔断聚合(管理后台列表/详情展示用)。 +// 目录未初始化(首次 relay 请求前)时返回 nil,调用方按「正常」处理。 +func channelRuntimeStates() map[int64]dispatchadapter.ChannelRuntimeState { + cat := dispatchadapter.CatalogInstance() + if cat == nil { + return nil + } + return cat.ChannelRuntimeStates() +} + // ListChannels 获取渠道列表 func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) { query := dao.ChnChannels.Ctx(ctx). @@ -104,6 +114,7 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. } list := make([]v1.ChannelItem, 0, len(channels)) + runtimeStates := channelRuntimeStates() for _, ch := range channels { typeName := providerTypeNames[ch.Type] if typeName == "" { @@ -112,7 +123,7 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. settings := relay.ParseChannelSettings(ch.Settings) - list = append(list, v1.ChannelItem{ + item := v1.ChannelItem{ ID: ch.ID, Name: ch.Name, Type: ch.Type, @@ -133,7 +144,12 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. BorrowingCooldownSeconds: ch.BorrowingCooldownSeconds, CreatedAt: ch.CreatedAt.String(), HealthScore: ch.HealthScore, - }) + } + if rs, ok := runtimeStates[ch.ID]; ok { + item.BreakerState = int(rs.Breaker) + item.BreakerModels = rs.BreakerModels + } + list = append(list, item) } return &v1.ChannelListRes{ @@ -523,6 +539,10 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) KeyStatus: keyInfo.Status, KeyName: keyInfo.Name, } + if rs, ok := channelRuntimeStates()[req.ID]; ok { + res.BreakerState = int(rs.Breaker) + res.BreakerModels = rs.BreakerModels + } if res.KeyType == "" { res.KeyType = "apikey" } diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index 75072f0..0d15244 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -19,6 +19,8 @@ const activeTab = ref('info') const statusTagColor: Record = { active: 'green', disabled: 'orangered', testing: 'arcoblue' } const statusLabel: Record = { active: '启用', disabled: '禁用', testing: '测试中' } +const breakerTagColor: Record = { 0: 'green', 1: 'red', 2: 'orange' } +const breakerLabel: Record = { 0: '正常', 1: '熔断', 2: '半开' } const tierLabel: Record = { primary: '首选', secondary: '备用', reserve: '保底' } const tierTagColor: Record = { primary: 'arcoblue', secondary: 'orange', reserve: 'gray' } const tierOptions = [ @@ -530,6 +532,18 @@ function formatHeaders(headers: Record): string { N/A + + + + — + VIP - diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index 22fa49b..8bf5ceb 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -1,7 +1,7 @@