diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index c914d7e..4f95097 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math" "time" do "github.com/qianfree/team-api/internal/model/do" @@ -791,6 +792,9 @@ func (s *sAdmin) batchGetModelHealth(ctx context.Context, channelID int64, abili } redis := g.Redis() + // 健康分折算口径必须与调度 healthFactor / 维护快照落盘一致(succ^α×100), + // α 取当前生效的路由策略而非硬编码——策略支持热更新。 + alpha := dispatchadapter.HealthAlpha() 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) @@ -808,8 +812,8 @@ func (s *sAdmin) batchGetModelHealth(ctx context.Context, channelID int64, abili var succEWMA float64 if _, err := fmt.Sscanf(succStr, "%f", &succEWMA); err == nil && succEWMA >= 0 { data.SuccEWMA = &succEWMA - // 健康分 = succ_ewma * 100 - healthScore := succEWMA * 100 + // 健康分 = succ_ewma^α × 100(与调度 healthFactor 同源) + healthScore := math.Pow(succEWMA, alpha) * 100 data.HealthScore = &healthScore } } @@ -959,13 +963,17 @@ func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHea // 调度层:只复位指定模型的熔断 + 成功率恢复 dispatchadapter.ResetModelHealth(ctx, req.ID, req.ModelName) + // 单模型重置不直接写 chn_health_scores(渠道分是全模型聚合值,不能按单模型拍值), + // 改为请求重算——否则展示分永远停在旧值,管理员会以为重置没生效 + dispatchadapter.RequestHealthSnapshot(req.ID) return nil, nil } // 未指定模型名,重置整个渠道(含所有模型) - // 展示层:健康分落库为 80(调度不读此表,仅供仪表盘/审计展示;下次维护快照会按策略重算) + // 展示层:健康分落库为 80(调度不读此表,仅供仪表盘/审计展示;随后的按需重算/ + // 维护快照会按 Redis 实际读值重算,此处只是让界面立即有反馈) affected, err := dao.ChnHealthScores.Ctx(ctx). Where("channel_id", req.ID). Data(do.ChnHealthScores{ @@ -998,6 +1006,9 @@ func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHea // 调度层:复位渠道级/模型级熔断 + 成功率恢复,渠道立即恢复被选择能力 dispatchadapter.ResetChannelHealth(ctx, req.ID) + // 请求按 Redis 实际读值重算落盘:上面写死的 80 只是调度引擎未组装时的兜底, + // 重算后会收敛到与调度同源的 0.9^α×100 + dispatchadapter.RequestHealthSnapshot(req.ID) return nil, nil } diff --git a/internal/logic/admin/channel_testing.go b/internal/logic/admin/channel_testing.go index 5360107..f3932ad 100644 --- a/internal/logic/admin/channel_testing.go +++ b/internal/logic/admin/channel_testing.go @@ -121,6 +121,10 @@ func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.C // 渠道启用时 MarkChannelRecovered 会复位熔断,无需在禁用期间预热。 if ch.Status == "active" { dispatchadapter.ReportProbeOutcome(ctx, channelID, testModel, result.Success, float64(latencyMs)) + // 请求立即重算渠道健康快照:维护循环 5 分钟一轮,不触发的话管理员会看到 + // "测试成功了但渠道健康度纹丝不动"(模型级能力列表直读 Redis 会立刻变, + // 渠道健康分读的是 chn_health_scores 落盘值) + dispatchadapter.RequestHealthSnapshot(channelID) if dispatchadapter.CatalogHasModel(channelID, testModel) { g.Log().Infof(ctx, "[ChannelProbe] 渠道 %s (%d) 探测结果已投递健康上报 | 模型: %s | success: %v | 延迟: %dms(EWMA 变化将打印 [ChannelHealth] 日志,5 分钟内落盘刷新)", ch.Name, channelID, testModel, result.Success, latencyMs)