diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index 3a3abb5..d5a1e41 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -180,6 +180,11 @@ type AbilityItem struct { CostRatio float64 `json:"cost_ratio" d:"1" v:"between:0,100" dc:"成本比例:上游实际价/平台基准价,1.0=等价(参与调度 costFactor)"` SupportsResponses bool `json:"supports_responses" d:"false" dc:"支持 OpenAI Responses 协议(/v1/responses 原生直连,responses 入站软偏好)"` ChatViaResponses bool `json:"chat_via_responses" d:"false" dc:"上游仅有 Responses 协议(responses-only),chat 入站经桥接发送 /v1/responses"` + // 模型级健康状态(从 Redis 实时读取) + HealthScore *float64 `json:"health_score" dc:"健康分 0-100,null=无数据"` + SuccEWMA *float64 `json:"succ_ewma" dc:"成功率 EWMA 0-1"` + LatencyEWMA *float64 `json:"latency_ewma" dc:"延迟 EWMA (ms)"` + BreakerState *int `json:"breaker_state" dc:"熔断状态:0=正常 1=熔断 2=半开,null=无熔断器"` } // ProviderDefaultURLReq 获取供应商默认 URL @@ -282,8 +287,9 @@ type ChannelCloneRes struct { // ChannelResetHealthReq 重置渠道健康度请求 type ChannelResetHealthReq struct { - g.Meta `path:"/channels/{id}/reset-health" method:"post" mime:"json" tags:"管理后台-渠道" summary:"重置渠道健康度"` - ID int64 `json:"id" in:"path" v:"required" dc:"渠道ID"` + g.Meta `path:"/channels/{id}/reset-health" method:"post" mime:"json" tags:"管理后台-渠道" summary:"重置渠道健康度"` + ID int64 `json:"id" in:"path" v:"required" dc:"渠道ID"` + ModelName string `json:"model_name" dc:"可选:指定模型名则只重置该模型的健康度,留空则重置整个渠道(含所有模型)"` } // ChannelResetHealthRes 重置渠道健康度响应 diff --git a/internal/dispatchadapter/wire.go b/internal/dispatchadapter/wire.go index c6a2a7b..6453679 100644 --- a/internal/dispatchadapter/wire.go +++ b/internal/dispatchadapter/wire.go @@ -104,6 +104,14 @@ func ResetChannelHealth(ctx context.Context, channelID int64) { redisState.ResetHealth(ctx, channelID, models) } +// ResetModelHealth 重置指定模型的健康度(只重置该模型,不影响其他模型) +func ResetModelHealth(ctx context.Context, channelID int64, modelName string) { + if redisState == nil { + return + } + redisState.ResetHealth(ctx, channelID, []string{modelName}) +} + // InvalidateChannel 渠道禁用/删除时的联动清理:清绑定 + 跨实例目录失效。 // 管理后台渠道写操作(更新/删除/Key 变更/能力变更)后调用。 func InvalidateChannel(ctx context.Context, channelID int64) { diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index 989deea..ac7c61a 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -692,9 +692,10 @@ func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbiliti return nil, err } - list := make([]v1.AbilityItem, len(abilities)) + // 转换为具名类型以传递给 batchGetModelHealth + abilityRecords := make([]abilityRecord, len(abilities)) for i, a := range abilities { - list[i] = v1.AbilityItem{ + abilityRecords[i] = abilityRecord{ ID: a.ID, ModelName: a.ModelName, UpstreamModel: a.UpstreamModel, @@ -705,9 +706,121 @@ func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbiliti } } + // 批量读取模型级健康状态(从 Redis) + healthMap := s.batchGetModelHealth(ctx, req.ChannelID, abilityRecords) + + list := make([]v1.AbilityItem, len(abilities)) + for i, a := range abilities { + item := v1.AbilityItem{ + ID: a.ID, + ModelName: a.ModelName, + UpstreamModel: a.UpstreamModel, + Enabled: a.Enabled, + CostRatio: a.CostRatio, + SupportsResponses: a.SupportsResponses, + ChatViaResponses: a.ChatViaResponses, + } + + // 填充健康数据 + if h, ok := healthMap[a.ModelName]; ok { + item.HealthScore = h.HealthScore + item.SuccEWMA = h.SuccEWMA + item.LatencyEWMA = h.LatencyEWMA + item.BreakerState = h.BreakerState + } + + list[i] = item + } + return &v1.ChannelAbilitiesGetRes{List: list}, nil } +// ModelHealthData 模型健康数据 +type ModelHealthData struct { + HealthScore *float64 + SuccEWMA *float64 + LatencyEWMA *float64 + BreakerState *int +} + +// abilityRecord 能力记录(用于类型匹配) +type abilityRecord struct { + ID int64 + ModelName string + UpstreamModel string + Enabled bool + CostRatio float64 + SupportsResponses bool + ChatViaResponses bool +} + +// batchGetModelHealth 批量读取模型级健康状态(从 Redis) +// Redis key 模式: +// - dispatch:v1:health:: (HASH) → succ_ewma, lat_ewma +// - dispatch:v1:breaker:: (HASH) → state, half_open_at, first_opened_ms +func (s *sAdmin) batchGetModelHealth(ctx context.Context, channelID int64, abilities []abilityRecord) map[string]*ModelHealthData { + healthMap := make(map[string]*ModelHealthData) + if len(abilities) == 0 { + return healthMap + } + + redis := g.Redis() + for _, ab := range abilities { + healthKey := fmt.Sprintf("dispatch:v1:health:%d:%s", channelID, ab.ModelName) + breakerKey := fmt.Sprintf("dispatch:v1:breaker:%d:%s", channelID, ab.ModelName) + + data := &ModelHealthData{} + + // 读取健康分数(HGETALL health key) + healthHash, err := redis.HGetAll(ctx, healthKey) + if err == nil && !healthHash.IsEmpty() { + hashMap := healthHash.Map() + + // succ_ewma: 成功率 EWMA (0-1) + if succEWMAVal, ok := hashMap["succ_ewma"]; ok && succEWMAVal != nil { + if succStr, ok := succEWMAVal.(string); ok { + var succEWMA float64 + if _, err := fmt.Sscanf(succStr, "%f", &succEWMA); err == nil && succEWMA >= 0 { + data.SuccEWMA = &succEWMA + // 健康分 = succ_ewma * 100 + healthScore := succEWMA * 100 + data.HealthScore = &healthScore + } + } + } + + // lat_ewma: 延迟 EWMA (ms) + if latEWMAVal, ok := hashMap["lat_ewma"]; ok && latEWMAVal != nil { + if latStr, ok := latEWMAVal.(string); ok { + var latEWMA float64 + if _, err := fmt.Sscanf(latStr, "%f", &latEWMA); err == nil && latEWMA >= 0 { + data.LatencyEWMA = &latEWMA + } + } + } + } + + // 读取熔断状态(HGET breaker key state) + stateVal, err := redis.HGet(ctx, breakerKey, "state") + if err == nil && !stateVal.IsNil() { + if stateStr := stateVal.String(); stateStr != "" { + var state int + if _, err := fmt.Sscanf(stateStr, "%d", &state); err == nil { + // 0=CLOSED(正常), 1=OPEN(熔断), 2=HALF_OPEN(半开) + data.BreakerState = &state + } + } + } + + // 只有至少有一个健康指标时才加入 map + if data.HealthScore != nil || data.SuccEWMA != nil || data.LatencyEWMA != nil || data.BreakerState != nil { + healthMap[ab.ModelName] = data + } + } + + return healthMap +} + // GetProviderDefaultURLs 获取供应商默认 API 地址 func (s *sAdmin) GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefaultURLReq) (*v1.ProviderDefaultURLRes, error) { return &v1.ProviderDefaultURLRes{URLs: defaultProviderURLs}, nil @@ -804,6 +917,28 @@ func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHea return nil, common.NewBusinessError(404, "渠道不存在") } + // 如果指定了模型名,只重置该模型的健康度 + if req.ModelName != "" { + // 验证模型能力是否存在 + abilityCount, err := dao.ChnAbilities.Ctx(ctx). + Where("channel_id", req.ID). + Where("model_name", req.ModelName). + Count() + if err != nil { + return nil, err + } + if abilityCount == 0 { + return nil, common.NewBusinessError(404, fmt.Sprintf("模型 %s 不存在于该渠道", req.ModelName)) + } + + // 调度层:只复位指定模型的熔断 + 成功率恢复 + dispatchadapter.ResetModelHealth(ctx, req.ID, req.ModelName) + + return nil, nil + } + + // 未指定模型名,重置整个渠道(含所有模型) + // 展示层:健康分落库为 80(调度不读此表,仅供仪表盘/审计展示;下次维护快照会按策略重算) affected, err := dao.ChnHealthScores.Ctx(ctx). Where("channel_id", req.ID). diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index 7da29f7..6ff9a41 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -132,7 +132,9 @@ const abilitiesData = ref([]) const abilityColumns: TableColumnData[] = [ { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '平台模型名', dataIndex: 'model_name', width: 200 }, + { title: '平台模型名', dataIndex: 'model_name', width: 180 }, + + // 配置操作列 { title: '上游模型名', dataIndex: 'upstream_model', width: 200, render({ record }) { @@ -189,12 +191,40 @@ const abilityColumns: TableColumnData[] = [ }, () => record.enabled ? '启用' : '禁用') }, }, + + // 健康监控列(放在右侧,操作按钮前面) { - title: '操作', dataIndex: 'actions', width: 80, fixed: 'right', + title: '健康状态', dataIndex: 'health_score', width: 120, render({ record }) { - return h(Popconfirm, { content: '确定移除该能力?', onOk: () => handleDeleteAbility(record.id) }, () => - h(Button, { size: 'mini', status: 'danger' }, () => '移除') - ) + return renderHealthBadge(record) + }, + }, + { + title: '熔断', dataIndex: 'breaker_state', width: 80, + render({ record }) { + if (record.breaker_state === null || record.breaker_state === undefined) { + return h('span', { style: 'color: var(--color-text-4); font-size: 12px' }, '—') + } + const color = breakerTagColor[record.breaker_state] + const label = breakerLabel[record.breaker_state] + return h(Tag, { color, size: 'small' }, () => label) + }, + }, + + // 操作列(固定在最右侧) + { + title: '操作', dataIndex: 'actions', width: 150, fixed: 'right', + render({ record }) { + return h('div', { style: 'display: flex; gap: 8px' }, [ + h(Button, { + size: 'mini', + onClick: () => handleResetModelHealth(record), + disabled: !record.health_score && record.breaker_state === null, + }, () => '重置健康'), + h(Popconfirm, { content: '确定移除该能力?', onOk: () => handleDeleteAbility(record.id) }, () => + h(Button, { size: 'mini', status: 'danger' }, () => '移除') + ), + ]) }, }, ] @@ -273,6 +303,43 @@ async function handleDeleteAbility(id: number) { } catch { /* error handled by interceptor */ } } +// 新增:健康徽章渲染函数 +function renderHealthBadge(record: any) { + if (record.health_score === null || record.health_score === undefined) { + return h('div', { style: 'display: flex; align-items: center; gap: 6px' }, [ + h('div', { + style: 'width: 8px; height: 8px; border-radius: 50%; background: #94a3b8' + }), + h('span', { style: 'color: var(--color-text-3); font-size: 13px' }, '无数据'), + ]) + } + + const score = Math.round(record.health_score) + const color = healthColor(score) + const statusText = score >= 80 ? '健康' : score >= 50 ? '降级' : '故障' + + return h('div', { style: 'display: flex; align-items: center; gap: 6px' }, [ + h('div', { + style: `width: 8px; height: 8px; border-radius: 50%; background: ${color}` + }), + h('span', { style: `color: ${color}; font-weight: 500; font-size: 13px` }, statusText), + h('span', { style: 'color: var(--color-text-3); font-size: 12px; margin-left: 2px' }, `(${score})`), + ]) +} + +// 新增:重置模型级健康 +async function handleResetModelHealth(record: any) { + try { + await request.post(`/admin/channels/${channelId}/reset_health`, { + model_name: record.model_name, + }) + Message.success(`模型 ${record.model_name} 健康已重置`) + fetchAbilities() + } catch { + // error handled by interceptor + } +} + // Add Ability Modal const showAddAbilityModal = ref(false) const addAbilityForm = reactive({ model_name: '', upstream_model: '', enabled: true })