fix(channel):修复渠道更新key后无法立即生效
This commit is contained in:
1 parent
e8dfe9499d
commit
96bcd3e46f
12 files changed
+211
-11
No files matched your search
@@ -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 获取渠道模型能力请求
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,让前端能显示剩余时间。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<keyID>,
|
||||
// 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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{}{}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -23,6 +23,13 @@ const breakerTagColor: Record<number, string> = { 0: 'green', 1: 'red', 2: 'oran
|
||||
const breakerLabel: Record<number, string> = { 0: '正常', 1: '熔断', 2: '半开' }
|
||||
const tierLabel: Record<string, string> = { primary: '首选', secondary: '备用', reserve: '保底' }
|
||||
const tierTagColor: Record<string, string> = { 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, string>): string {
|
||||
{{ detail.key_status === 'active' ? '正常' : detail.key_status || 'N/A' }}
|
||||
</ATag>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="凭证冷却">
|
||||
<ATag v-if="(detail.key_cooldown_remaining ?? 0) > 0" color="orangered" size="small">
|
||||
冷却中 · 剩余 {{ formatCooldown(detail.key_cooldown_remaining) }}
|
||||
</ATag>
|
||||
<ATag v-else color="green" size="small">正常</ATag>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem v-if="detail.token_expires_at" label="Token 过期时间">
|
||||
{{ detail.token_expires_at }}
|
||||
</ADescriptionsItem>
|
||||
</ADescriptions>
|
||||
<AAlert
|
||||
v-if="(detail.key_cooldown_remaining ?? 0) > 0"
|
||||
type="warning"
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
该 Key 因认证失败(401/403)被调度器冷却,冷却期内本渠道会被整体跳过且不会发起上游请求
|
||||
—— 此时渠道测试仍会成功(测试不走调度器),但实际调用会返回「无可用渠道」。
|
||||
更新 Key 或点击「重置健康度」都会立即解除冷却。
|
||||
</AAlert>
|
||||
<div style="margin-top: 12px;">
|
||||
<AButton type="outline" size="small" @click="showUpdateKeyModal = true">更新 Key</AButton>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user