diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 8ca619e..c89df67 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -789,3 +789,29 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { **修复**:`internal/dispatchadapter/state_redis.go` `ReadRuntime`/`readBreaker` 改为 `vals[i].String() != ""` 判有无数据;空串一律按无数据处理保留默认值。回归测试 `Test缺失健康键读默认值`。 **正确做法(通用规则)**:判断 gf redis `HMGET`/批量读的字段是否存在,**不要依赖 `IsNil()`**(驱动转换后 nil 会变空字符串);用 `String() != ""` 判断,或改用 Lua 脚本内 `HGET ... or '默认值'` 在 Redis 侧兜底。单 key `HGET` 缺失返回的是真 nil(`IsNil()==true`),两者行为不一致,混用时尤其注意。 + +### 2026-08-27:Redis HMGET 缺失字段的 gvar 判空失效(`IsNil()` 恒为 false),默认值兜底全线失守 + +**问题**:渠道探测一切正常(Redis succ_ewma=1.0),管理后台渠道健康度却长期停在 25,其余渠道全是 0。排查发现维护快照读到的几乎所有「从未被真实流量访问过」的渠道×模型健康值都是 `0.000`,而这些模型的 Redis key **根本不存在**——按设计应回落到默认满分 1.0。 + +**原因**:`RedisState.ReadRuntime` 用如下写法读健康 EWMA: + +```go +v, _ := g.Redis().Do(ctx, "HMGET", key, "succ_ewma", "lat_ewma") +vals := v.Vars() +if !vals[0].IsNil() { out.SuccEwma = vals[0].Float64() } // ← 判空失效 +``` + +HMGET 对不存在的 key/字段返回 Redis nil,但 gredis 把它们包装成**非 nil 的空 gvar**:实测 `IsNil()=false`、`IsEmpty()=true`、`String()=""`、`Float64()=0`。于是判空分支永远进入,`Float64("")` 静默返回 **0**,把「无数据」变成了「健康分 0 分」。影响远超展示:`ReadRuntime` 同时供调度目录构建 `healthFactor`,所有冷模型的路由权重被压成 0。 + +两个读取方对同一份「无数据」表现还不一致,极大增加了排查难度——管理后台 `batchGetModelHealth` 用 `fmt.Sscanf` 解析,解析失败就不赋值,UI 显示"无数据";维护循环用 gconv 读成 0。同一个模型,UI 说无数据、健康分算 0。 + +**修复**:`state_redis.go` 的 `ReadRuntime` 改为显式字符串校验——`TrimSpace(String())` 为空则保持默认值,非空再 `strconv.ParseFloat` 并做值域校验(succ 取 `0 < f <= 1`;EWMA 由正数衰减而来,数学上不会精确到达 0,精确 0 视为异常数据)。回归测试 `TestReadRuntime脏数据防御`。 + +**正确做法(通用规则)**: + +- **禁止用 `gvar.IsNil()` 判断 Redis 读取结果是否存在**——gredis 的空回复是非 nil 空 gvar,`IsNil()` 恒为 false。用 `IsEmpty()`,或取 `String()` 后判空串。 +- **禁止把 `gvar.Float64()`/`Int()` 直接用于「缺失应回落默认值」的场景**:gconv 对空串/非法值静默返回零值,会把「无数据」变成「0 分/0 次」这类**具有强业务含义的极端值**。默认值不是 0 的字段(健康分、成功率、权重、配额),必须显式校验后再赋值。 +- 同一份状态有多个读取方时(调度用 gconv、管理后台用 Sscanf),**解析方式必须统一**,否则同一数据在不同页面表现不同,故障期误导排查方向。 + +排查信号:某项指标「本该回落默认值却全是 0」;同一数据 UI 显示"无数据"而后台计算按 0 处理;`grep -n 'IsNil()' ` 命中 Redis 读取路径。 diff --git a/internal/dispatchadapter/maintenance.go b/internal/dispatchadapter/maintenance.go index de59ff1..b42dd84 100644 --- a/internal/dispatchadapter/maintenance.go +++ b/internal/dispatchadapter/maintenance.go @@ -2,7 +2,9 @@ package dispatchadapter import ( "context" + "fmt" "math" + "strings" "time" "github.com/gogf/gf/v2/frame/g" @@ -11,6 +13,7 @@ import ( "github.com/qianfree/team-api/internal/dao" lcommon "github.com/qianfree/team-api/internal/logic/common" do "github.com/qianfree/team-api/internal/model/do" + "github.com/qianfree/team-api/internal/model/entity" ) // 维护任务(阶段 3): @@ -53,18 +56,23 @@ func runMaintenance(ctx context.Context) { autoDisable := lcommon.Config().GetBool(ctx, "channel_auto_disable_enabled") now := time.Now().UnixMilli() - for channelID, models := range catalog.ChannelModels() { + channelModels := catalog.ChannelModels() + covered := make([]int64, 0, len(channelModels)) + for channelID, models := range channelModels { if len(models) == 0 { continue } + covered = append(covered, channelID) // 按渠道聚合各模型的健康读值 var succSum, latSum float64 var channelRt RuntimeReadout + succParts := make([]string, 0, len(models)) for i, model := range models { rt := redisState.ReadRuntime(ctx, channelID, model) succSum += rt.SuccEwma latSum += rt.LatEwmaMs + succParts = append(succParts, fmt.Sprintf("%s=%.3f", model, rt.SuccEwma)) if i == 0 { channelRt = rt // 渠道级熔断状态各模型读值相同,取第一个 } @@ -72,7 +80,7 @@ func runMaintenance(ctx context.Context) { avgSucc := succSum / float64(len(models)) avgLat := latSum / float64(len(models)) - snapshotHealthScore(ctx, channelID, avgSucc, avgLat, pol.Health.Alpha) + snapshotHealthScore(ctx, channelID, avgSucc, avgLat, pol.Health.Alpha, strings.Join(succParts, " ")) // 自动禁用:本轮故障期持续超过阈值(first_opened_ms 只在故障期存在, // 探测失败不重置——探测成功即被清除,见 luaBreakerFail/luaBreakerSuccess) @@ -81,13 +89,29 @@ func runMaintenance(ctx context.Context) { autoDisableChannel(ctx, channelID, now-channelRt.FirstOpenedMs) } } + + // 覆盖摘要:目录只装载 active × 有启用能力行的渠道,未覆盖渠道的健康分不会落盘 + // (探测却正常的渠道长期停在历史分数时,先看这里是否被跳过) + g.Log().Infof(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered) } // snapshotHealthScore 健康快照落盘(upsert)。 // health_score 用 succ^alpha 的百分制近似,与调度用的 healthFactor 同源。 -func snapshotHealthScore(ctx context.Context, channelID int64, succ, latMs, alpha float64) { +// 每轮固定输出一行明细(计算值 vs 库中值 + 各模型 succ):健康分不动时可直接看出 +// 是「算出来就是这个值」(某模型 succ 被拖低)还是「写入失败」(库中值持续落后于计算值)。 +func snapshotHealthScore(ctx context.Context, channelID int64, succ, latMs, alpha float64, modelDetail string) { healthScore := math.Pow(clampFloat(succ, 0, 1), alpha) * 100 + // 先读旧值:updated_at 每轮都会变(affected 恒为 1),比对必须基于 health_score + var prev *entity.ChnHealthScores + if err := dao.ChnHealthScores.Ctx(ctx).Where("channel_id", channelID).Scan(&prev); err != nil { + g.Log().Debugf(ctx, "[Dispatch] 健康快照旧值读取失败: channel=%d err=%v", channelID, err) + } + prevScore := -1.0 // 无记录 + if prev != nil { + prevScore = prev.HealthScore + } + affected, err := dao.ChnHealthScores.Ctx(ctx). Where("channel_id", channelID). Data(do.ChnHealthScores{ @@ -98,7 +122,8 @@ func snapshotHealthScore(ctx context.Context, channelID int64, succ, latMs, alph }). UpdateAndGetAffected() if err != nil { - g.Log().Debugf(ctx, "[Dispatch] 健康快照更新失败: channel=%d err=%v", channelID, err) + // 写入失败必须可见(此前 Debug 级静默,健康分冻结难以定位) + g.Log().Warningf(ctx, "[ChannelHealth] 渠道 %d 健康快照更新失败: %v", channelID, err) return } if affected == 0 { @@ -110,9 +135,20 @@ func snapshotHealthScore(ctx context.Context, channelID int64, succ, latMs, alph CalculatedAt: gtime.Now(), }).Insert() if err != nil { - g.Log().Debugf(ctx, "[Dispatch] 健康快照插入失败: channel=%d err=%v", channelID, err) + g.Log().Warningf(ctx, "[ChannelHealth] 渠道 %d 健康快照插入失败: %v", channelID, err) + return } } + + // 分数有实际变化才记 Info;稳态渠道降为 Debug(避免每 5 分钟 × 渠道数的固定噪音)。 + // 两种情况都带各模型 succ 明细:排查"健康分不符合预期"时可直接定位是哪个模型拉低了均值。 + if math.Abs(healthScore-prevScore) >= 0.05 { + g.Log().Infof(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s", + channelID, prevScore, healthScore, succ, modelDetail) + } else { + g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康快照无变化: %.1f | avg_succ %.4f | 模型: %s", + channelID, healthScore, succ, modelDetail) + } } // autoDisableChannel 熔断长期不恢复 → 落库禁用(标记 auto_disabled 供自动恢复探测识别) diff --git a/internal/dispatchadapter/state_redis.go b/internal/dispatchadapter/state_redis.go index dde4a30..707c810 100644 --- a/internal/dispatchadapter/state_redis.go +++ b/internal/dispatchadapter/state_redis.go @@ -7,7 +7,9 @@ package dispatchadapter import ( "context" + "math" "strconv" + "strings" "time" "github.com/gogf/gf/v2/frame/g" @@ -170,9 +172,12 @@ func (s *RedisState) ReportOutcome(o dispatch.Outcome) { // luaHealthObserve 健康 EWMA 原子更新(基线方案 §10.2 + 修订 R6 分档衰减)。 // ARGV: success(0/1), latencyMs, decay, nowMs, keyTtlMs +// 返回 {旧succ, 新succ, 旧lat, 新lat}(tostring 字符串,避免浮点被整数回复截断), +// 供适配层打印健康分变化日志。 const luaHealthObserve = ` local succ = tonumber(redis.call('HGET', KEYS[1], 'succ_ewma') or '1.0') local lat = tonumber(redis.call('HGET', KEYS[1], 'lat_ewma') or '0') +local oldSucc, oldLat = succ, lat if ARGV[1] == '1' then succ = succ * 0.9 + 0.1 local l = tonumber(ARGV[2]) @@ -184,7 +189,7 @@ else end redis.call('HSET', KEYS[1], 'succ_ewma', succ, 'lat_ewma', lat, 'updated_ms', ARGV[4]) redis.call('PEXPIRE', KEYS[1], ARGV[5]) -return 1` +return {tostring(oldSucc), tostring(succ), tostring(oldLat), tostring(lat)}` // luaBreakerOutcome 渠道级+模型级熔断转移(单脚本双 key 原子执行,与 dispatch/breaker.go // 纯函数规则保持一致,改动需同步)。 @@ -313,8 +318,6 @@ return 1` // processOutcome 消费一条结果事件:健康 EWMA + 渠道级/模型级熔断转移。 // 限流类不喂熔断窗口(429 属容量信号而非故障信号),改喂 softLimit 估计器。 -// 探测失败(Probe)不喂健康 EWMA 只喂熔断窗口:周期性探测持续失败会把无流量 -// 渠道的健康分指数拖垮(0.93^N)且无真实流量对冲;探测成功照常回升健康。 func (s *RedisState) processOutcome(ctx context.Context, o dispatch.Outcome) { pol := s.policy() now := time.Now().UnixMilli() @@ -325,11 +328,19 @@ func (s *RedisState) processOutcome(ctx context.Context, o dispatch.Outcome) { if o.Success { success = "1" } - if !o.Probe || o.Success { - if _, err := g.Redis().Do(ctx, "EVAL", luaHealthObserve, 1, healthKey, - success, o.LatencyMs, healthDecayFor(o.Class), now, stateKeyTTLMs); err != nil { - s.local.observe(o) // 降级:实例本地健康镜像 - return + v, err := g.Redis().Do(ctx, "EVAL", luaHealthObserve, 1, healthKey, + success, o.LatencyMs, healthDecayFor(o.Class), now, stateKeyTTLMs) + if err != nil { + s.local.observe(o) // 降级:实例本地健康镜像 + return + } + if vals := v.Strings(); len(vals) == 4 { + oldSucc, e1 := strconv.ParseFloat(vals[0], 64) + newSucc, e2 := strconv.ParseFloat(vals[1], 64) + oldLat, e3 := strconv.ParseFloat(vals[2], 64) + newLat, e4 := strconv.ParseFloat(vals[3], 64) + if e1 == nil && e2 == nil && e3 == nil && e4 == nil { + logHealthChange(ctx, o, pol, oldSucc, newSucc, oldLat, newLat) } } @@ -368,6 +379,34 @@ func (s *RedisState) processOutcome(ctx context.Context, o dispatch.Outcome) { } } +// logHealthChange 健康读值变化日志:按调度同源公式(succ^alpha 百分制,与 healthFactor +// 及 chn_health_scores 落盘一致)折算健康分,**仅在健康分实际移动时**记 Info——稳态成功流量 +// 的 succ 恒为 1.0(只有延迟 EWMA 在抖),若每次上报都打印会在生产流量下淹没日志。 +// 健康分持平时降为 Debug,保留延迟变化的排查能力。 +func logHealthChange(ctx context.Context, o dispatch.Outcome, pol *dispatch.RoutingPolicy, oldSucc, newSucc, oldLat, newLat float64) { + if oldSucc == newSucc && oldLat == newLat { + return + } + src := "真实流量" + if o.Probe { + src = "探测" + } + result := "成功" + if !o.Success { + result = "失败(" + o.Class.String() + ")" + } + oldScore := math.Pow(oldSucc, pol.Health.Alpha) * 100 + newScore := math.Pow(newSucc, pol.Health.Alpha) * 100 + + if math.Abs(newScore-oldScore) < 0.05 { + g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 模型 %s 健康分持平 %.1f | lat_ewma %.1fms → %.1fms | 来源: %s %s", + o.ChannelID, o.Model, newScore, oldLat, newLat, src, result) + return + } + g.Log().Infof(ctx, "[ChannelHealth] 渠道 %d 模型 %s 健康分变化: %.1f → %.1f | succ_ewma %.4f → %.4f | lat_ewma %.1fms → %.1fms | 来源: %s %s", + o.ChannelID, o.Model, oldScore, newScore, oldSucc, newSucc, oldLat, newLat, src, result) +} + // luaBreakerManualReset 手动恢复:熔断复位为 CLOSED 并记录 recovered_ms(触发爬坡因子)。 // ARGV: nowMs, keyTtlMs const luaBreakerManualReset = ` @@ -423,6 +462,8 @@ func (s *RedisState) ResetHealth(ctx context.Context, channelID int64, models [] g.Log().Debugf(ctx, "[Dispatch] 健康重置失败: channel=%d model=%s err=%v", channelID, model, err) } } + g.Log().Infof(ctx, "[ChannelHealth] 渠道 %d 健康度已重置: %d 个模型 succ_ewma → %.2f(健康分≈%.1f)、熔断复位", + channelID, len(models), resetHealthSuccEwma, math.Pow(resetHealthSuccEwma, s.policy().Health.Alpha)*100) } // resetBreakerState 熔断复位为 CLOSED 并记录 recovered_ms(开启爬坡窗口)。 @@ -566,6 +607,21 @@ func (s *RedisState) CoolCredential(ctx context.Context, keyID int64, ttl time.D } } +// CredentialCooldownRemaining 返回渠道 Key 的凭证冷却剩余秒数(0 = 未在冷却)。 +// 凭证冷却是调度器内部的瞬态状态(Redis credcd 标记 + TTL),无任何管理页面出口; +// 本函数供管理后台 Key 列表/渠道详情展示。读 TTL 而非 EXISTS,让前端能显示剩余时间。 +// Redis 故障时返回 0:仅影响展示精度,不影响调度行为(调度走 IsCredentialCooled 的本地镜像降级)。 +func CredentialCooldownRemaining(ctx context.Context, keyID int64) int { + v, err := g.Redis().Do(ctx, "TTL", keyCredCD+strconv.FormatInt(keyID, 10)) + if err != nil || v == nil { + return 0 + } + if s := v.Int(); s > 0 { + return s + } + return 0 +} + // --------------------------------------------------------------------------- // 运行时读值(供目录快照合并健康/熔断/负载) // --------------------------------------------------------------------------- @@ -592,15 +648,20 @@ func (s *RedisState) ReadRuntime(ctx context.Context, channelID int64, model str if v, err := g.Redis().Do(ctx, "HMGET", keyHealth+chStr+":"+model, "succ_ewma", "lat_ewma"); err == nil { vals := v.Vars() if len(vals) == 2 { - // gf redis 驱动对 HMGET 缺失字段返回空字符串而非 nil(IsNil()==false), - // 必须按无数据处理保留乐观默认值:否则无流量/键过期(TTL 24h)的模型 - // 读成 succ=0,健康快照取平均被拖到个位数(调度打分有 succ<=0 兜底, - // 快照聚合没有)。 - if str := vals[0].String(); str != "" { - out.SuccEwma = vals[0].Float64() + // 必须按字符串显式判空:HMGET 对不存在的 key/字段返回 Redis nil,但 gredis 把它 + // 包装成非 nil 空 gvar(IsNil() 恒为 false、Float64() 静默返回 0)。曾用 + // `if !vals[0].IsNil()` 判空,导致所有从未被真实流量访问过的模型被读成 0 分而非 + // 默认满分,拖垮渠道聚合健康分与调度 healthFactor。详见 goframe-conventions.md。 + // 值域校验:EWMA 由正数衰减而来,数学上不会精确到达 0,精确 0 视为异常数据。 + if s := strings.TrimSpace(vals[0].String()); s != "" { + if f, perr := strconv.ParseFloat(s, 64); perr == nil && f > 0 && f <= 1 { + out.SuccEwma = f + } } - if str := vals[1].String(); str != "" { - out.LatEwmaMs = vals[1].Float64() + if s := strings.TrimSpace(vals[1].String()); s != "" { + if f, perr := strconv.ParseFloat(s, 64); perr == nil && f >= 0 { + out.LatEwmaMs = f + } } } } @@ -622,8 +683,9 @@ func (s *RedisState) readBreaker(ctx context.Context, key string, nowMs int64) ( return dispatch.BreakerClosed, 0, 0 } vals := v.Vars() - // 缺失字段经 gf 驱动转换后为空字符串(IsNil()==false),与 nil 同判为无快照 - if len(vals) != 5 || vals[0].IsNil() || vals[0].String() == "" { + // 判空必须用 IsEmpty():HMGET 缺失字段的 gvar IsNil() 恒为 false(见 ReadRuntime 注释)。 + // 此处即使漏判也侥幸无害(默认 BreakerClosed 恰为零值 0),但保持与健康读取一致的正确写法。 + if len(vals) != 5 || vals[0].IsEmpty() { return dispatch.BreakerClosed, 0, 0 } snap := dispatch.BreakerSnapshot{ diff --git a/internal/dispatchadapter/state_redis_test.go b/internal/dispatchadapter/state_redis_test.go index d1c7987..ddca776 100644 --- a/internal/dispatchadapter/state_redis_test.go +++ b/internal/dispatchadapter/state_redis_test.go @@ -486,6 +486,53 @@ func TestReportOutcome异步消费(t *testing.T) { }, 3*time.Second, 20*time.Millisecond, "后台 worker 必须消费上报事件") } +// TestReadRuntime无数据回落默认值 从未被访问过的渠道×模型(Redis key 不存在)必须回落 +// 默认满分 1.0。HMGET 缺失字段经 gredis 包装成非 nil 空 gvar(IsNil() 恒为 false、 +// Float64() 静默返回 0),旧代码用 IsNil() 判空失效,把全部冷模型读成 0 分, +// 拖垮渠道聚合健康分与调度 healthFactor。 +func TestReadRuntime无数据回落默认值(t *testing.T) { + ctx := context.Background() + s := newTestState(t, nil) + + rt := s.ReadRuntime(ctx, 99, "never-touched") + assert.Equal(t, 1.0, rt.SuccEwma, "key 不存在必须回落默认满分,不能是 0") + assert.Equal(t, 0.0, rt.LatEwmaMs, "无延迟数据为 0") + assert.Equal(t, dispatch.BreakerClosed, rt.Breaker) + + // 只有 lat_ewma、无 succ_ewma 字段时,succ 同样回落默认值 + _, err := g.Redis().Do(ctx, "HSET", keyHealth+"99:partial", "lat_ewma", "100") + require.NoError(t, err) + rt = s.ReadRuntime(ctx, 99, "partial") + assert.Equal(t, 1.0, rt.SuccEwma, "字段缺失必须回落默认满分") + assert.Equal(t, 100.0, rt.LatEwmaMs) +} + +// TestReadRuntime异常值防御 succ_ewma 为空串/非法值/精确 0 时视为无数据(保持默认 1.0)。 +func TestReadRuntime异常值防御(t *testing.T) { + ctx := context.Background() + s := newTestState(t, nil) + + for _, bad := range []string{"", " ", "abc", "0"} { + _, err := g.Redis().Do(ctx, "HSET", keyHealth+"30:dirty", "succ_ewma", bad, "lat_ewma", "100") + require.NoError(t, err) + rt := s.ReadRuntime(ctx, 30, "dirty") + assert.Equal(t, 1.0, rt.SuccEwma, "succ_ewma=%q 应视为无数据", bad) + } + + // 真实低分(正数)不受影响 + _, err := g.Redis().Do(ctx, "HSET", keyHealth+"30:low", "succ_ewma", "0.0008", "lat_ewma", "100") + require.NoError(t, err) + rt := s.ReadRuntime(ctx, 30, "low") + assert.Equal(t, 0.0008, rt.SuccEwma, "合法低分必须如实读取") + + // lat_ewma 同样防御:脏值回落 0 + _, err = g.Redis().Do(ctx, "HSET", keyHealth+"30:dirtylat", "succ_ewma", "0.5", "lat_ewma", "xyz") + require.NoError(t, err) + rt = s.ReadRuntime(ctx, 30, "dirtylat") + assert.Equal(t, 0.5, rt.SuccEwma) + assert.Equal(t, 0.0, rt.LatEwmaMs) +} + // --------------------------------------------------------------------------- // 本地降级(修订 R4 + 基线方案 §13) // --------------------------------------------------------------------------- diff --git a/internal/dispatchadapter/wire.go b/internal/dispatchadapter/wire.go index f43c63e..524cb13 100644 --- a/internal/dispatchadapter/wire.go +++ b/internal/dispatchadapter/wire.go @@ -3,6 +3,7 @@ package dispatchadapter import ( "context" "math/rand" + "slices" "sync" "github.com/gogf/gf/v2/frame/g" @@ -86,6 +87,17 @@ func ReportProbeOutcome(ctx context.Context, channelID int64, model string, succ redisState.ReportOutcome(o) } +// CatalogHasModel 判断渠道×模型当前是否在调度目录中(探测可观测性检查用)。 +// 不在目录的探测目标(渠道无启用的能力行、或模型未配置能力)健康 EWMA 写入后无人消费: +// 调度不会选它、维护快照也不会落盘该渠道的健康分——管理后台将看不到探测效果。 +// 需已调用过 Coordinator 完成组装;未组装时返回 false。 +func CatalogHasModel(channelID int64, model string) bool { + if catalog == nil { + return false + } + return slices.Contains(catalog.ChannelModels()[channelID], model) +} + // MarkChannelRecovered 渠道被手动启用/恢复时复位熔断并开启爬坡窗口(管理后台调用)。 func MarkChannelRecovered(ctx context.Context, channelID int64) { if redisState != nil { diff --git a/internal/logic/admin/channel_test_request.go b/internal/logic/admin/channel_test_request.go index 06f11d9..b927484 100644 --- a/internal/logic/admin/channel_test_request.go +++ b/internal/logic/admin/channel_test_request.go @@ -56,11 +56,11 @@ func executeTest(ctx context.Context, provider, method, reqURL string, headers m maskedHdrs := maskHeaders(headers) bodyStr := string(body) - g.Log().Infof(ctx, "[ChannelTest] %s | 发送请求 | %s %s | 代理: %v", provider, method, reqURL, useProxy) + g.Log().Infof(ctx, "[ChannelProbe]%s | 发送请求 | %s %s | 代理: %v", provider, method, reqURL, useProxy) resp, err := doTestHTTPRequest(ctx, method, reqURL, headers, body, useProxy) if err != nil { - g.Log().Warningf(ctx, "[ChannelTest] %s | 请求失败 | %s | 错误: %v", provider, reqURL, err) + g.Log().Warningf(ctx, "[ChannelProbe]%s | 请求失败 | %s | 错误: %v", provider, reqURL, err) return testResult{ Error: fmt.Sprintf("请求失败: %v", err), Request: buildReqDetail(method, reqURL, maskedHdrs, bodyStr), @@ -72,7 +72,7 @@ func executeTest(ctx context.Context, provider, method, reqURL string, headers m respStr := string(respBody) if resp.StatusCode != 200 { - g.Log().Warningf(ctx, "[ChannelTest] %s | 测试失败 | HTTP %d | 响应: %s", provider, resp.StatusCode, truncateStr(respStr, 200)) + g.Log().Warningf(ctx, "[ChannelProbe]%s | 测试失败 | HTTP %d | 响应: %s", provider, resp.StatusCode, truncateStr(respStr, 200)) return testResult{ Success: false, Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncateStr(respStr, 500)), @@ -81,7 +81,7 @@ func executeTest(ctx context.Context, provider, method, reqURL string, headers m } } - g.Log().Infof(ctx, "[ChannelTest] %s | 测试成功 | HTTP %d | 响应: %s", provider, resp.StatusCode, truncateStr(respStr, 200)) + g.Log().Infof(ctx, "[ChannelProbe]%s | 测试成功 | HTTP %d | 响应: %s", provider, resp.StatusCode, truncateStr(respStr, 200)) return testResult{ Success: true, Response: truncateStr(respStr, 500), diff --git a/internal/logic/admin/channel_testing.go b/internal/logic/admin/channel_testing.go index 3669e2a..5360107 100644 --- a/internal/logic/admin/channel_testing.go +++ b/internal/logic/admin/channel_testing.go @@ -108,6 +108,8 @@ func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.C } // 发送最小测试请求 + g.Log().Infof(ctx, "[ChannelProbe] 渠道 %s (%d) 开始探测 | 模型: %s (上游: %s) | 渠道状态: %s | 代理: %v", + ch.Name, channelID, testModel, upstreamModel, ch.Status, useProxy) startTime := time.Now() result := sendTestRequest(ctx, ch.Type, ch.BaseURL, apiKey, upstreamModel, useProxy) latencyMs := time.Since(startTime).Milliseconds() @@ -119,14 +121,26 @@ 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)) + 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) + } else { + // 探测目标不在调度目录:EWMA 照常写入 Redis,但调度不读、维护快照不落盘, + // 管理后台健康分不会变化——探测看起来"生效了但没效果"的常见根因 + g.Log().Warningf(ctx, "[ChannelProbe] 渠道 %s (%d) 探测结果已投递健康上报,但模型 %s 不在调度目录(渠道无启用的模型能力行或该模型未配置能力)| 健康分不会落盘展示,渠道/模型不参与调度,请检查渠道的模型能力配置", + ch.Name, channelID, testModel) + } + } else { + g.Log().Infof(ctx, "[ChannelProbe] 渠道 %s (%d) 状态为 %s(非 active),探测结果不上报健康体系", + ch.Name, channelID, ch.Status) } // 记录测试结果日志 if result.Success { - g.Log().Infof(ctx, "[ChannelTest] 渠道 %s (%d) 测试成功 | 模型: %s (上游: %s) | 延迟: %dms | 代理: %v", + g.Log().Infof(ctx, "[ChannelProbe] 渠道 %s (%d) 探测成功 | 模型: %s (上游: %s) | 延迟: %dms | 代理: %v", ch.Name, channelID, testModel, upstreamModel, latencyMs, useProxy) } else { - g.Log().Warningf(ctx, "[ChannelTest] 渠道 %s (%d) 测试失败 | 模型: %s (上游: %s) | 延迟: %dms | 代理: %v | 错误: %s", + g.Log().Warningf(ctx, "[ChannelProbe] 渠道 %s (%d) 探测失败 | 模型: %s (上游: %s) | 延迟: %dms | 代理: %v | 错误: %s", ch.Name, channelID, testModel, upstreamModel, latencyMs, useProxy, result.Error) } diff --git a/internal/logic/task/channel_auto_test_cron.go b/internal/logic/task/channel_auto_test_cron.go index c30a8cb..8937f3c 100644 --- a/internal/logic/task/channel_auto_test_cron.go +++ b/internal/logic/task/channel_auto_test_cron.go @@ -30,9 +30,14 @@ func AutoTestChannels(ctx context.Context) { Fields("id, name, type, base_url, test_model"). Scan(&channels) if err != nil { - g.Log().Errorf(ctx, "[Cron] query active channels failed: %v", err) + g.Log().Errorf(ctx, "[ChannelProbe] 自动探测查询活跃渠道失败: %v", err) return } + if len(channels) == 0 { + g.Log().Infof(ctx, "[ChannelProbe] 自动探测开始: 无待探测渠道(活跃且配置了 test_model 的渠道为 0)") + return + } + g.Log().Infof(ctx, "[ChannelProbe] 自动探测开始: 待探测活跃渠道 %d 个", len(channels)) successCount := 0 failCount := 0 @@ -44,7 +49,8 @@ func AutoTestChannels(ctx context.Context) { if err != nil { failCount++ - g.Log().Warningf(ctx, "[Cron] channel %s (%d) test error: %v", ch.Name, ch.ID, err) + // 探测请求尚未发出(无可用 Key/渠道不存在等),TestChannel 内部无结果日志,此处记录 + g.Log().Warningf(ctx, "[ChannelProbe] 渠道 %s (%d) 探测执行出错: %v", ch.Name, ch.ID, err) continue } @@ -52,9 +58,10 @@ func AutoTestChannels(ctx context.Context) { successCount++ } else { failCount++ - if result.Error != "" { - g.Log().Warningf(ctx, "[Cron] channel %s (%d) test failed: %s", ch.Name, ch.ID, result.Error) - } } + // 成功/失败明细日志由 TestChannel 统一打印(含模型/延迟/错误),此处只做轮次汇总 } + + g.Log().Infof(ctx, "[ChannelProbe] 自动探测完成: 共 %d 个渠道 | 成功 %d | 失败 %d", + len(channels), successCount, failCount) } diff --git a/relaykit/dispatch/types.go b/relaykit/dispatch/types.go index b0ef3d2..a9f769f 100644 --- a/relaykit/dispatch/types.go +++ b/relaykit/dispatch/types.go @@ -159,8 +159,7 @@ type Outcome struct { Success bool Class ErrorClass LatencyMs float64 - // Probe 标记探测类结果(管理后台手动测试 / 自动探测 cron),非真实用户流量。 - // 探测失败只喂熔断窗口不衰减健康 EWMA:无流量渠道若探测持续失败(如 test_model - // 配置错误),周期性 ×decay 会把健康分指数拖垮且无真实流量对冲回升。 + // Probe 标记本结果来自探测请求(管理后台渠道测试 / 自动探测 cron)而非真实流量。 + // 纯核心库不消费,仅供适配层观测日志区分健康分变化来源。 Probe bool }