diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index b151590..dbb1838 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -150,7 +150,11 @@ type ChannelDetailRes struct { KeyType string `json:"key_type"` KeyStatus string `json:"key_status"` KeyName string `json:"key_name"` - TokenExpiresAt string `json:"token_expires_at"` + // KeyCooldownRemaining 凭证冷却剩余秒数(0=未冷却)。冷却期内该 Key 会被调度器 + // 整段跳过且不发起上游请求,渠道全部 Key 冷却即整体不可用——必须可见,否则 + // 表现为"健康分正常、探测也正常,但就是无可用渠道"。 + KeyCooldownRemaining int `json:"key_cooldown_remaining"` + TokenExpiresAt string `json:"token_expires_at"` } // ChannelKeyCreateReq 添加渠道 Key 请求 @@ -236,6 +240,8 @@ type ChannelKeyItem struct { KeyType string `json:"key_type"` TokenExpiresAt string `json:"token_expires_at"` CreatedAt string `json:"created_at"` + // CooldownRemaining 凭证冷却剩余秒数(0=未冷却),含义见 ChannelDetailRes.KeyCooldownRemaining + CooldownRemaining int `json:"cooldown_remaining"` } // ChannelAbilitiesGetReq 获取渠道模型能力请求 diff --git a/internal/dispatchadapter/state_local.go b/internal/dispatchadapter/state_local.go index 03773d5..241251b 100644 --- a/internal/dispatchadapter/state_local.go +++ b/internal/dispatchadapter/state_local.go @@ -106,6 +106,13 @@ func (l *localState) coolCred(keyID int64, ttl time.Duration) { l.credCD[keyID] = time.Now().Add(ttl) } +// clearCred 解除本地凭证冷却镜像(更换 Key / 管理员重置时调用)。 +func (l *localState) clearCred(keyID int64) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.credCD, keyID) +} + func (l *localState) isCredCooled(keyID int64) bool { l.mu.Lock() defer l.mu.Unlock() diff --git a/internal/dispatchadapter/state_redis.go b/internal/dispatchadapter/state_redis.go index 8e6b7fe..64a6dea 100644 --- a/internal/dispatchadapter/state_redis.go +++ b/internal/dispatchadapter/state_redis.go @@ -571,6 +571,21 @@ func (s *RedisState) CoolCredential(ctx context.Context, keyID int64, ttl time.D } } +// ClearCredentialCooldown 解除渠道 Key 的凭证冷却(更换 Key / 管理员重置健康度时调用)。 +// 必须连同本地镜像一起清:只删 Redis 的话,Redis 故障降级期间本实例仍会按镜像跳过该 Key。 +func (s *RedisState) ClearCredentialCooldown(ctx context.Context, keyID int64) { + s.local.clearCred(keyID) + clearCredCooldownKey(ctx, keyID) +} + +// clearCredCooldownKey 删除 Redis 冷却标记。独立于 RedisState:管理后台可能在调度引擎 +// 组装前就更换 Key,而上个进程写下的标记仍在 Redis 里(TTL 未到),此时也必须能清掉。 +func clearCredCooldownKey(ctx context.Context, keyID int64) { + if _, err := g.Redis().Do(ctx, "DEL", keyCredCD+strconv.FormatInt(keyID, 10)); err != nil { + g.Log().Warningf(ctx, "[Dispatch] 凭证冷却解除失败: key=%d err=%v", keyID, err) + } +} + // CredentialCooldownRemaining 返回渠道 Key 的凭证冷却剩余秒数(0 = 未在冷却)。 // 凭证冷却是调度器内部的瞬态状态(Redis credcd 标记 + TTL),无任何管理页面出口; // 本函数供管理后台 Key 列表/渠道详情展示。读 TTL 而非 EXISTS,让前端能显示剩余时间。 diff --git a/internal/dispatchadapter/state_redis_test.go b/internal/dispatchadapter/state_redis_test.go index 1c4adb3..a71f44a 100644 --- a/internal/dispatchadapter/state_redis_test.go +++ b/internal/dispatchadapter/state_redis_test.go @@ -472,6 +472,26 @@ func Test凭证冷却(t *testing.T) { assert.False(t, s.IsCredentialCooled(ctx, 101), "冷却到期后必须恢复可用") } +// Test凭证冷却可手动解除 覆盖「更换 Key / 重置健康度」的解除路径:冷却按 keyID 打标, +// 而换 Key 是原地改同一行(keyID 不变),不解除的话新 Key 会被整段跳过直到 TTL 到期。 +func Test凭证冷却可手动解除(t *testing.T) { + ctx := context.Background() + s := newTestState(t, nil) + + s.CoolCredential(ctx, 202, 5*time.Minute) + require.True(t, s.IsCredentialCooled(ctx, 202)) + require.Positive(t, CredentialCooldownRemaining(ctx, 202), "冷却中必须能读到剩余秒数") + + s.ClearCredentialCooldown(ctx, 202) + assert.False(t, s.IsCredentialCooled(ctx, 202), "解除后必须立即可用,不等 TTL") + assert.Zero(t, CredentialCooldownRemaining(ctx, 202)) + assert.False(t, s.local.isCredCooled(202), "本地镜像必须一并清除,否则 Redis 降级时仍会跳过该 Key") + + // 解除未冷却的 Key 是幂等空操作 + s.ClearCredentialCooldown(ctx, 999) + assert.False(t, s.IsCredentialCooled(ctx, 999)) +} + func TestReportOutcome异步消费(t *testing.T) { ctx := context.Background() s := newTestState(t, nil) @@ -618,6 +638,15 @@ func TestLocal凭证冷却过期(t *testing.T) { assert.False(t, l.isCredCooled(9)) } +func TestLocal凭证冷却解除(t *testing.T) { + l := newLocalState() + l.coolCred(9, time.Hour) + require.True(t, l.isCredCooled(9)) + l.clearCred(9) + assert.False(t, l.isCredCooled(9), "解除后不等 TTL 立即可用") + l.clearCred(9) // 幂等 +} + func Test429估计器水位收敛(t *testing.T) { ctx := context.Background() s := newTestState(t, nil) diff --git a/internal/dispatchadapter/wire.go b/internal/dispatchadapter/wire.go index 8dd623e..1a7e4fa 100644 --- a/internal/dispatchadapter/wire.go +++ b/internal/dispatchadapter/wire.go @@ -9,6 +9,7 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gctx" + "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/relaykit/dispatch" ) @@ -141,6 +142,44 @@ func ResetModelHealth(ctx context.Context, channelID int64, modelName string) { redisState.ResetHealth(ctx, channelID, []string{modelName}) } +// ClearChannelCredentialCooldown 解除渠道下所有 Key 的凭证冷却,返回被解除的 Key 数量。 +// +// 更换 Key / 重置健康度后必须调用。凭证冷却按 keyID 打标(dispatch:v1:credcd:, +// 401/403 时写入、默认 300s TTL),而管理后台更换 Key 是原地改同一行、keyID 不变: +// 不清标记的话调度器仍认为该 Key 在冷却中,渠道全部 Key 冷却即整体不可用 +// (日志表现为「无可用渠道 … 凭证全部冷却×N」),且期间从不发起上游请求, +// 新 Key 一次都得不到验证,只能干等 TTL 自愈。 +// +// 不按 status 过滤:被禁用后重新启用的 Key 同样需要解除。 +func ClearChannelCredentialCooldown(ctx context.Context, channelID int64) int { + var keys []struct { + ID int64 `json:"id"` + } + if err := dao.ChnChannelKeys.Ctx(ctx). + Where("channel_id", channelID). + Fields("id"). + Scan(&keys); err != nil { + g.Log().Warningf(ctx, "[Dispatch] 解除凭证冷却时查询渠道 Key 失败: channel=%d err=%v", channelID, err) + return 0 + } + for _, k := range keys { + ClearCredentialCooldownByKey(ctx, k.ID) + } + return len(keys) +} + +// ClearCredentialCooldownByKey 解除单个 Key 的凭证冷却。 +// 凭证被就地更新(换 Key / OAuth 令牌刷新)后必须调用:keyID 不变, +// 旧凭证留下的冷却标记会继续把新凭证挡在调度之外,直到 TTL 自然到期。 +func ClearCredentialCooldownByKey(ctx context.Context, keyID int64) { + if redisState != nil { + redisState.ClearCredentialCooldown(ctx, keyID) // 含本地镜像 + return + } + // 调度引擎尚未组装:没有本地镜像可清,只需删 Redis 标记 + clearCredCooldownKey(ctx, keyID) +} + // InvalidateChannel 渠道禁用/删除时的联动清理:清绑定 + 跨实例目录失效。 // 管理后台渠道写操作(更新/删除/Key 变更/能力变更)后调用。 func InvalidateChannel(ctx context.Context, channelID int64) { diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index 69ab27d..809a674 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -456,16 +456,26 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (* if err != nil { return nil, gerror.Wrapf(err, "encrypt api key failed") } + // 用 g.Map 而非 do 结构体:需要把 token_expires_at 写回 NULL(do 的零值会被 gf 忽略)。 + // 由 OAuth 改回静态 Key 时旧的 access_token 过期时间必须清掉,否则详情页会显示 + // 一个与当前 Key 无关的过期时间;last_error 同理属于旧 Key 的历史。 _, err = dao.ChnChannelKeys.Ctx(ctx). Where("channel_id", req.ID). - Data(do.ChnChannelKeys{ - EncryptedKey: encrypted, - KeyType: "apikey", + Data(g.Map{ + "encrypted_key": encrypted, + "key_type": "apikey", + "token_expires_at": nil, + "last_error": "", }). Update() if err != nil { return nil, err } + // 换 Key 必须解除凭证冷却:冷却按 keyID 打标而这里是原地改同一行(keyID 不变), + // 不解除的话新 Key 会被调度器整段跳过直到 TTL 到期,期间连试都不试。 + if n := dispatchadapter.ClearChannelCredentialCooldown(ctx, req.ID); n > 0 { + g.Log().Infof(ctx, "[Channel] 渠道 %d 更换 Key,已解除 %d 个 Key 的凭证冷却", req.ID, n) + } } dispatchadapter.InvalidateChannel(ctx, req.ID) if req.Status == "active" { @@ -545,6 +555,7 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) // 查询 Key 信息 var keyInfo *struct { + ID int64 `json:"id"` KeyType string `json:"key_type"` Status string `json:"status"` Name string `json:"name"` @@ -552,7 +563,7 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) } err = dao.ChnChannelKeys.Ctx(ctx). Where("channel_id", req.ID). - Fields("key_type, status, name, token_expires_at"). + Fields("id, key_type, status, name, token_expires_at"). Scan(&keyInfo) if err != nil { return nil, err @@ -592,6 +603,7 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) KeyType: keyInfo.KeyType, KeyStatus: keyInfo.Status, KeyName: keyInfo.Name, + KeyCooldownRemaining: dispatchadapter.CredentialCooldownRemaining(ctx, keyInfo.ID), } if rs, ok := channelRuntimeStates()[req.ID]; ok { res.BreakerState = int(rs.Breaker) @@ -685,11 +697,12 @@ func (s *sAdmin) GetChannelKeys(ctx context.Context, req *v1.ChannelKeyListReq) list := make([]v1.ChannelKeyItem, len(keys)) for i, k := range keys { item := v1.ChannelKeyItem{ - ID: k.ID, - Name: k.Name, - Status: k.Status, - KeyType: k.KeyType, - CreatedAt: k.CreatedAt.String(), + ID: k.ID, + Name: k.Name, + Status: k.Status, + KeyType: k.KeyType, + CreatedAt: k.CreatedAt.String(), + CooldownRemaining: dispatchadapter.CredentialCooldownRemaining(ctx, k.ID), } if item.KeyType == "" { item.KeyType = "apikey" @@ -1009,6 +1022,12 @@ func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHea // 调度层:复位渠道级/模型级熔断 + 成功率恢复,渠道立即恢复被选择能力 dispatchadapter.ResetChannelHealth(ctx, req.ID) + // 凭证冷却同属"渠道不可用"状态,且只靠 TTL 自愈、无其它人工出口: + // 管理员点重置的语义就是"这个渠道我修好了",只复位熔断不解除冷却等于重置了一半 + // ——渠道会继续以「凭证全部冷却」被整体跳过,界面上却显示健康已恢复。 + if n := dispatchadapter.ClearChannelCredentialCooldown(ctx, req.ID); n > 0 { + g.Log().Infof(ctx, "[Channel] 渠道 %d 重置健康度,已解除 %d 个 Key 的凭证冷却", req.ID, n) + } // 请求按 Redis 实际读值重算落盘:上面写死的 80 只是调度引擎未组装时的兜底, // 重算后会收敛到与调度同源的 0.9^α×100 dispatchadapter.RequestHealthSnapshot(req.ID) diff --git a/internal/logic/admin/channel_oauth.go b/internal/logic/admin/channel_oauth.go index 1609258..78bbcc7 100644 --- a/internal/logic/admin/channel_oauth.go +++ b/internal/logic/admin/channel_oauth.go @@ -12,6 +12,7 @@ import ( v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/dispatchadapter" "github.com/qianfree/team-api/internal/logic/common/oauth" relayLogic "github.com/qianfree/team-api/internal/logic/relay" "github.com/qianfree/team-api/internal/model/do" @@ -246,6 +247,8 @@ func (s *sAdmin) ChannelOAuthRefresh(ctx context.Context, req *v1.ChannelOAuthRe if err != nil { return nil, gerror.Wrap(err, "更新 OAuth 凭证失败") } + // 令牌已换新,解除旧令牌 401 留下的凭证冷却(keyID 不变,标记不会自己跟着凭证走) + dispatchadapter.ClearCredentialCooldownByKey(ctx, key.ID) return &v1.ChannelOAuthRefreshRes{ ExpiresAt: expiresAt.String(), diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 0661b5a..6f0485b 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -1238,6 +1238,10 @@ func refreshOAuthKeyLocked(ctx context.Context, keyID int64, encKey []byte) (str if err != nil { return "", err } + // 凭证已换新即解除冷却(与管理后台换 Key / cron 刷新同一不变量):冷却按 keyID 打标, + // 就地刷新不改 keyID,标记不会自己跟着凭证走。本路径下本请求已被调度选中(说明当时 + // 未冷却),解除主要防的是并发实例刚因旧令牌 401 冷却、新令牌却要陪等 TTL。 + dispatchadapter.ClearCredentialCooldownByKey(ctx, keyID) return string(jsonData), nil } diff --git a/internal/logic/task/oauth_token_refresh.go b/internal/logic/task/oauth_token_refresh.go index 6ae27e6..2ea79e1 100644 --- a/internal/logic/task/oauth_token_refresh.go +++ b/internal/logic/task/oauth_token_refresh.go @@ -9,6 +9,7 @@ import ( "github.com/gogf/gf/v2/os/gtime" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/dispatchadapter" "github.com/qianfree/team-api/internal/logic/common/oauth" "github.com/qianfree/team-api/internal/logic/relay" "github.com/qianfree/team-api/internal/model/do" @@ -131,6 +132,10 @@ func RefreshExpiringOAuthTokens(ctx context.Context) error { g.Log().Warningf(ctx, "[OAuthRefresh] Update failed for key %d: %v", key.ID, err) continue } + // 令牌已换新,旧令牌 401 留下的凭证冷却必须一并解除:冷却按 keyID 打标而刷新是 + // 就地更新(keyID 不变),不解除的话新令牌仍会被调度器跳过直到 TTL 到期 + // ——OAuth 渠道每次令牌过期都会踩到,表现为刷新成功但渠道仍「无可用渠道」。 + dispatchadapter.ClearCredentialCooldownByKey(ctx, key.ID) refreshed++ } diff --git a/relaykit/dispatch/coordinator.go b/relaykit/dispatch/coordinator.go index 20ec2ee..7462874 100644 --- a/relaykit/dispatch/coordinator.go +++ b/relaykit/dispatch/coordinator.go @@ -3,6 +3,8 @@ package dispatch import ( "context" "fmt" + "slices" + "strconv" "strings" "sync/atomic" "time" @@ -92,6 +94,9 @@ type NoChannelDiag struct { LeaseFull int // 容量租约满(渠道并发已达 softLimit / max_concurrency) RequestExcluded int // 本请求内先前已失败而被排除的渠道 CredUnavailable int // 渠道全部 Key 处于冷却/已排除 + // CredCooledKeys 上述渠道中不可用的 Key ID(去重、升序)。凭证冷却只存在于 Redis + // 且只靠 TTL 自愈,没有 keyID 就无从判断该等 TTL 还是该去解除,故直接打进日志。 + CredCooledKeys []int64 } // Summary 人类可读的原因摘要(仅列出非零项),供无可用渠道时的日志定位。 @@ -110,7 +115,15 @@ func (d NoChannelDiag) Summary() string { parts = append(parts, fmt.Sprintf("容量租约满×%d", d.LeaseFull)) } if d.CredUnavailable > 0 { - parts = append(parts, fmt.Sprintf("凭证全部冷却×%d", d.CredUnavailable)) + part := fmt.Sprintf("凭证全部冷却×%d", d.CredUnavailable) + if len(d.CredCooledKeys) > 0 { + ids := make([]string, len(d.CredCooledKeys)) + for i, id := range d.CredCooledKeys { + ids[i] = strconv.FormatInt(id, 10) + } + part += "(key=" + strings.Join(ids, ",") + ")" + } + parts = append(parts, part) } if d.RequestExcluded > 0 { parts = append(parts, fmt.Sprintf("本请求已排除×%d", d.RequestExcluded)) @@ -259,6 +272,7 @@ func (s *RouteSession) selectChannel(ctx context.Context) *Decision { // 本轮选择中被探测令牌 / 租约 / 凭证拒绝的渠道(不进入 excludedChans,不影响绑定守卫语义) roundExcluded := make(map[int64]struct{}) var probeDenied, leaseDenied, credDenied int + var credCooledKeys []int64 for range len(snapshot) + 3 { // 每轮至少排除一个渠道,防御性上限 scored, halfOpen, exclRound := s.buildCandidates(snapshot, roundExcluded, pol) @@ -275,6 +289,7 @@ func (s *RouteSession) selectChannel(ctx context.Context) *Decision { LeaseFull: leaseDenied, RequestExcluded: exclRound.Request, CredUnavailable: credDenied, + CredCooledKeys: credCooledKeys, } if len(scored) == 0 { @@ -303,6 +318,7 @@ func (s *RouteSession) selectChannel(ctx context.Context) *Decision { if !keyOK { roundExcluded[chosen.ID] = struct{}{} credDenied++ + credCooledKeys = appendMissingIDs(credCooledKeys, chosen.Channel.KeyIDs) continue } @@ -484,6 +500,17 @@ func (s *RouteSession) alternateKeyAvailable(ctx context.Context) bool { return false } +// appendMissingIDs 把 src 中尚未出现的 ID 并入 dst 并保持升序(诊断用,规模仅候选渠道的 Key 数)。 +func appendMissingIDs(dst, src []int64) []int64 { + for _, id := range src { + if !slices.Contains(dst, id) { + dst = append(dst, id) + } + } + slices.Sort(dst) + return dst +} + func (s *RouteSession) excludeCurrent(ctx context.Context) { if s.current != nil { s.excludedChans[s.current.Channel.ID] = struct{}{} diff --git a/relaykit/dispatch/coordinator_test.go b/relaykit/dispatch/coordinator_test.go index 0e2897b..ca894ca 100644 --- a/relaykit/dispatch/coordinator_test.go +++ b/relaykit/dispatch/coordinator_test.go @@ -320,6 +320,30 @@ func TestCoordinator_全Key冷却渠道跳过(t *testing.T) { assert.Equal(t, 1, d.Excluded.Request) } +// TestCoordinator_无可用渠道诊断带冷却KeyID 凭证冷却只存在于 Redis 且只靠 TTL 自愈, +// 诊断不给出 keyID 的话运维无从判断该等 TTL 还是该去解除(换 Key 不改 keyID, +// 陈旧标记会让新 Key 被整段跳过,且期间连上游请求都不会发)。 +func TestCoordinator_无可用渠道诊断带冷却KeyID(t *testing.T) { + ctx := context.Background() + state := newFakeState() + state.cooled[11] = true + state.cooled[21] = true + + ch1 := healthyChannel(1, TierPrimary, 100) + ch1.KeyIDs = []int64{11} + ch2 := healthyChannel(2, TierPrimary, 50) + ch2.KeyIDs = []int64{21} + co, _ := newTestCoordinator(state, ch1, ch2) + + s := co.Route(ctx, testProfile()) + require.Nil(t, s.Next(ctx), "两个渠道的 Key 全部冷却 → 无可用渠道") + + diag := s.NoChannelDiagnosis() + assert.Equal(t, 2, diag.CredUnavailable) + assert.Equal(t, []int64{11, 21}, diag.CredCooledKeys, "去重升序") + assert.Contains(t, diag.Summary(), "凭证全部冷却×2(key=11,21)") +} + func TestCoordinator_租约拒绝不扣预算(t *testing.T) { ctx := context.Background() state := newFakeState() diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index 352ad99..5c5b7d3 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -23,6 +23,13 @@ const breakerTagColor: Record = { 0: 'green', 1: 'red', 2: 'oran const breakerLabel: Record = { 0: '正常', 1: '熔断', 2: '半开' } const tierLabel: Record = { primary: '首选', secondary: '备用', reserve: '保底' } const tierTagColor: Record = { primary: 'arcoblue', secondary: 'orange', reserve: 'gray' } + +// 凭证冷却剩余秒数 → 人类可读(后端返回 Redis TTL,最长为策略里的 credCooldownSeconds) +function formatCooldown(seconds: number): string { + const s = Math.max(0, Math.floor(seconds || 0)) + if (s < 60) return `${s} 秒` + return `${Math.floor(s / 60)} 分 ${String(s % 60).padStart(2, '0')} 秒` +} const tierOptions = [ { label: '首选(承接主要流量)', value: 'primary' }, { label: '备用(零星保温流量,主力饱和时溢出承接)', value: 'secondary' }, @@ -1114,10 +1121,25 @@ function formatHeaders(headers: Record): string { {{ detail.key_status === 'active' ? '正常' : detail.key_status || 'N/A' }} + + + 冷却中 · 剩余 {{ formatCooldown(detail.key_cooldown_remaining) }} + + 正常 + {{ detail.token_expires_at }} + + 该 Key 因认证失败(401/403)被调度器冷却,冷却期内本渠道会被整体跳过且不会发起上游请求 + —— 此时渠道测试仍会成功(测试不走调度器),但实际调用会返回「无可用渠道」。 + 更新 Key 或点击「重置健康度」都会立即解除冷却。 +
更新 Key