// 冷却与熔断:Cooldown(固定时长账号级冷却)、CooldownSoftRate(账号级软冷却,对齐 // 上游重置时间或有界退避)、CooldownSoftForModel(模型级软冷却,对齐重置墙钟)、 // BlockModelBackoff/Clear(11102 负缓存)、软冷却封顶、熔断失败累计、签到解冻。 package pool import ( "log" "strings" "time" ) func (p *Pool) SetCredits(uid string, credits, total int64) { p.mu.Lock() defer p.mu.Unlock() if e, ok := p.byUID[uid]; ok { e.credits = credits e.creditsTotal = total p.dirty.Store(true) } } // SetNickname 更新账号昵称并回写 auths 凭证文件(issue #94:上游改名后同步)。 // 昵称未变化时不写盘;uid 不存在 / 昵称为空返回 false。与 token 刷新共用 // auth 自身的锁与 SaveAtomic 原子写,无半更新窗口。 func (p *Pool) SetNickname(uid, nickname string) bool { if uid == "" || nickname == "" { return false } p.mu.RLock() e, ok := p.byUID[uid] p.mu.RUnlock() if !ok { return false } a := e.a a.Lock() changed := a.Nickname != nickname if changed { a.Nickname = nickname } a.Unlock() if !changed { return false } if err := a.SaveAtomic(); err != nil { log.Printf("WARN: [pool] nickname save %s: %v", uid, err) return false } return true } // NoteCheckinDone 标记账号今日已签到(签到成功与上游"今天已签到"幂等拒绝均算)。 // 记录本地日期,跨零点自然过期;不触碰冷却/禁用状态(签到与冷却域正交)。 func (p *Pool) NoteCheckinDone(uid string) { p.mu.Lock() defer p.mu.Unlock() if e, ok := p.byUID[uid]; ok { day := time.Now().Format("2006-01-02") if e.lastCheckinDay != day { e.lastCheckinDay = day p.dirty.Store(true) } } } // SetCreditsDetailed 更新账号余额/总额、配置窗口内的快过架子集,以及最早未来 // 到期批次。earliestAt 为零或不在未来时清空最早批次;expiring/earliestRemaining // 均钳到 [0, credits],避免上游脏数据污染选号。 func (p *Pool) SetCreditsDetailed(uid string, credits, total, expiring int64, earliestAt time.Time, earliestRemaining int64) { p.mu.Lock() defer p.mu.Unlock() if e, ok := p.byUID[uid]; ok { if credits < 0 { credits = 0 } if expiring < 0 { expiring = 0 } if expiring > credits { expiring = credits } now := time.Now() if earliestRemaining < 0 { earliestRemaining = 0 } if earliestRemaining > credits { earliestRemaining = credits } if earliestAt.IsZero() || !earliestAt.After(now) || earliestRemaining == 0 { earliestAt = time.Time{} earliestRemaining = 0 } e.credits = credits e.creditsTotal = total e.creditsExpiring = expiring e.creditsEarliestExpiry = earliestAt e.creditsEarliestRemaining = earliestRemaining p.dirty.Store(true) } } // ClearExpiringSnapshots 清空所有账号的快过期/最早到期缓存。配置窗口改变时调用, // 避免在新快照写入前继续使用旧窗口得到的路由数据;下一次签到或余额刷新会重建。 func (p *Pool) ClearExpiringSnapshots() { p.mu.Lock() defer p.mu.Unlock() for _, e := range p.byUID { e.creditsExpiring = 0 e.creditsEarliestExpiry = time.Time{} e.creditsEarliestRemaining = 0 } p.dirty.Store(true) } // Cooldown 冷却账号至 now+d(即时冷却:CoolHard 余额耗尽 / CoolSoft 固定短冷却)。 // // 重构后本入口是「固定时长的账号级冷却」,不再做两件旧事: // - 不再喂熔断器失败计数:熔断器只对「反复失败」(NoteError,5xx)退避。 // 软限流/余额耗尽各有权威恢复时刻(重置墙钟 / 04:00 签到),再并入"连续失败" // 会让用户正常重试越堆越厚。熔断语义由 NoteError 唯一驱动(与 until 正交保持)。 // - 不再做 softStreak 指数堆加:固定 d 即最终时长。CoolSoft 的精确对齐请用 // CooldownSoftRate(有界、对齐上游重置时间)。 func (p *Pool) Cooldown(uid string, kind CoolKind, d time.Duration, reason string) { p.mu.Lock() defer p.mu.Unlock() if e, ok := p.byUID[uid]; ok { e.until = time.Now().Add(d) e.coolKind = kind e.reason = reason // 非模型级冷却入口:清空会参与路由的模型级冷却,避免上一次 // 模型豁免泄漏到账号级冷却上;AuditOnly 条目不影响路由,保留展示。 clearRoutingModelCooldownsLocked(e) p.dirty.Store(true) } } // CooldownSoftForModel 429 的**模型级**软冷却入口(issue #31):把该模型的冷却截止 // 精确对齐到上游重置墙钟(不做指数堆加、不做 softStreak 计数)。 // // - resetAt 非零(带解析时间)→ modelCooldowns[model].Until = min(resetAt, // now+softRateMax),ResetAt 记录上游原始墙钟(台账 ResetAt)。不写 until // (全账号级冷却不受模型级限流污染),切模型即可用(模型豁免)。 // - resetAt 零值(无时间文案)→ 有界退避:base 起按 softStreak 翻倍、封顶 // softRateMax,且**在软冷却中**(until 未到期)时不推进/不延长(兜底探测不再把 // 冷却越堆越厚)。不记录模型(不豁免)。 // // 与旧实现的差异:有上游重置时间时绝对不做指数堆加;无重置时间时,「冷却中兜底 // 探测再 429」不再 softStreak++ 翻倍——这正是用户「全池被推到 2h 封顶」的元凶。 func (p *Pool) CooldownSoftForModel(uid string, base time.Duration, resetAt time.Time, model, reason string) { p.mu.Lock() defer p.mu.Unlock() if e, ok := p.byUID[uid]; ok { now := time.Now() if !resetAt.IsZero() { // 有上游重置时间:冷却截止 = min(resetAt, now+softRateMax),不做指数放大。 if e.modelCooldowns == nil { e.modelCooldowns = map[string]modelCooldown{} } e.modelCooldowns[model] = modelCooldown{ Until: p.cappedSoftUntilLocked(now, resetAt), ResetAt: resetAt, Reason: reason, } } else { // 无解析时间(普通软冷却):有界退避(base 起按 softStreak 翻倍、封顶 // softRateMax)。注意:**在软冷却中**(until 未到期)时不推进/不延长。 if e.coolKind != CoolSoft || !now.Before(e.until) { d := p.softDurationLocked(base, e.softStreak+1) e.softStreak++ e.until = now.Add(d) } e.coolKind = CoolSoft e.reason = reason clearRoutingModelCooldownsLocked(e) } p.dirty.Store(true) } } // RecordModelRateLimitAudit 记录无法参与模型路由的 6004 展示项。 // 典型场景是 6004 没有可解析重置时间:账号仍按原有有界退避冷却, // 本方法只把模型名挂到 e.until 上供账号页展示,不影响 healthyForModel。 func (p *Pool) RecordModelRateLimitAudit(uid, model, reason string) { if uid == "" || model == "" { return } p.mu.Lock() defer p.mu.Unlock() e, ok := p.byUID[uid] if !ok { return } now := time.Now() if old, exists := e.modelCooldowns[model]; exists && !old.AuditOnly && old.Until.After(now) { return // 已有真实模型冷却,审计记录不得覆盖路由截止 } until := e.until if until.IsZero() || !until.After(now) { until = now.Add(p.softRateMaxOr()) } if e.modelCooldowns == nil { e.modelCooldowns = make(map[string]modelCooldown) } e.modelCooldowns[model] = modelCooldown{ Until: until, Reason: reason, AuditOnly: true, } p.dirty.Store(true) } // clearRoutingModelCooldownsLocked 删除参与选号豁免的模型冷却,保留 AuditOnly 台账。 // 调用方必须已持有 p.mu 写锁。 func clearRoutingModelCooldownsLocked(e *entry) { for model, mc := range e.modelCooldowns { if !mc.AuditOnly { delete(e.modelCooldowns, model) } } if len(e.modelCooldowns) == 0 { e.modelCooldowns = nil } } // modelBlock TTL 常量(11102 负缓存退避): // 首次命中 6h;半开到期后允许放行重试,再次命中 TTL = base × 2^min(hits-1, shift); // 封顶 24h(最多一天再试一次)。该模型请求成功即由 BlockModelClear 清除。 const ( modelBlockBaseTTL = 6 * time.Hour modelBlockShift = 4 modelBlockMaxTTL = 24 * time.Hour ) // BlockModelBackoff 11102「该后端无此模型」的 (账号, 模型) 负缓存入口 // (handler.applyErrorPolicy 调用)。复用 modelCooldowns 机制(不新建平行状态): // 写 modelCooldowns[model],Until 为指数退避 TTL,选号侧 healthyForModel 自动对该 // 账号避开该模型。 // // 语义与 6004 正交:6004 是「模型被限流、对齐重置墙钟」,本入口是「官方确定该后端 // 无此模型、重试无意义,只能换模型/换账号」。resetAt 无需传(11102 无重置文案), // ResetAt 保持零值,与 6004 台账共用 Until 判定——11102 条目会以 11102 reason 出现在 // /status 台账,运维可见。 func (p *Pool) BlockModelBackoff(uid, model, reason string) { if uid == "" || model == "" { return } p.mu.Lock() defer p.mu.Unlock() e, ok := p.byUID[uid] if !ok { return } now := time.Now() hits := 0 if e.modelCooldowns != nil { hits = e.modelCooldowns[model].Hits } hits++ ttl := modelBlockBaseTTL if d := ttl * (1 << uint(min(hits-1, modelBlockShift))); d < modelBlockMaxTTL { ttl = d } else { ttl = modelBlockMaxTTL } if e.modelCooldowns == nil { e.modelCooldowns = map[string]modelCooldown{} } e.modelCooldowns[model] = modelCooldown{ Until: now.Add(ttl), Reason: reason, Hits: hits, } p.dirty.Store(true) } // BlockModelClear 清除 (账号, 模型) 的 11102 负缓存条目(该模型实测又通了)。半开探测 // 或正常请求对该模型成功后调用(handler 成功路径)。只清 11102 条目、不碰 6004 独立 // 冷却表——6004 有自身上游重置墙钟语义,成功不该抹掉。reason 前缀判定区分两者: // 11102 条目的 reason 恒以 "11102" 开头(见 upstream.BlockModelReason)。 func (p *Pool) BlockModelClear(uid, model string) { if uid == "" || model == "" { return } p.mu.Lock() defer p.mu.Unlock() e, ok := p.byUID[uid] if !ok || len(e.modelCooldowns) == 0 { return } mc, exists := e.modelCooldowns[model] if !exists || !strings.HasPrefix(mc.Reason, "11102") { return } delete(e.modelCooldowns, model) if len(e.modelCooldowns) == 0 { e.modelCooldowns = nil } p.dirty.Store(true) } // ModelBlockStatus 描述某模型在全池范围内因模型级冷却而不可选的情况。 type ModelBlockStatus struct { Blocked bool // true = 每个非禁用账号都对该模型处于冷却中 Reason string // 冷却原因(通常为上游原文,如 "11102 model ... not found") Until time.Time // 最早解封时间(零值 = 上游未给重置时刻) Count int // 因此被挡的账号数 } // ModelBlocked 报告该模型是否在全池范围内被模型级冷却挡住。 // // 为什么需要它:选号失败时客户端只会拿到 no_healthy_account("没有可用账号"), // 但真实原因常常是「号都在、只是都对这个模型关闭」。两者对调用方的处置完全不同 // ——前者该等,后者换个模型才有用——此前却无法区分:首次请求还能看到上游原文 // (lastErr 非空),一旦负缓存写入,后续请求 lastErr 为空,就只剩"池子没号" // (issue #102 附带发现 1)。上游原文与解封时间在那里被丢掉。 // // 跨 realm 判定:调用方失败前已依次尝试过各域,所以只要有**任意**账号还能服务该 // 模型,就不能算全池阻塞 —— 此时返回 Blocked=false,让上层继续用原有的 // no_healthy_account 文案(选号失败另有原因:在途占满/积分保底/账号级冷却)。 // // 口径必须与选号一致:用 modelCooled 而非直接查 map,这样 AuditOnly 条目 // (只审计不拦截)不会被误报成阻塞。 func (p *Pool) ModelBlocked(model string) ModelBlockStatus { if model == "" { return ModelBlockStatus{} } p.mu.Lock() defer p.mu.Unlock() now := time.Now() var st ModelBlockStatus for _, e := range p.byUID { // 暂停选号(paused)号与禁用号同口径跳过:它此刻不可选,「能服务」的 // 证明不成立——否则一个暂停的健康号会掩盖「其余号全被模型级冷却挡住」。 if e.disabled || e.paused { continue // 禁用/暂停号不参与:它们的不可用与模型无关 } if !e.modelCooled(now, model) { // 还有账号能服务这个模型 → 不是模型级阻塞。 return ModelBlockStatus{} } st.Count++ if mc, ok := e.modelCooldowns[model]; ok { if st.Reason == "" { st.Reason = mc.Reason } // 取最早解封:那才是"再等多久值得重试"的答案。 if !mc.Until.IsZero() && (st.Until.IsZero() || mc.Until.Before(st.Until)) { st.Until = mc.Until } } } if st.Count == 0 { // 池里压根没有非禁用账号:这是"真的没号",不是模型问题。 return ModelBlockStatus{} } st.Blocked = true return st } // CooldownSoftRate 429/限流文案的**账号级**软冷却入口(handler.applyErrorPolicy 调用)。 // // 语义: // - resetAt 非零(上游带权威重置时间,无论 6004 还是 11140 rate-limiting)→ // 账号级直到该墙钟(截断到 softRateMax,绝不指数堆加);**不**在 // modelCooldowns 记模型(账号级语义,不产生切模型豁免——普通账号级限流不该 // 因切模型绕过)。 // - resetAt 零值且**不在冷却中**(首次/恢复后的新限流)→ 有界退避:按 softStreak // 指数退避并封顶 softRateMax。softStreak 只在真正进入一次新冷却时计数,由 // NoteSuccess/reviveCoolingLocked 清零(既有恢复语义)。 // - resetAt 零值且**已在软冷却中**(兜底探测再次撞 429)→ 不推进 streak、不延长 // until:用户重试/并发兜底探测不得把冷却越堆越厚——这正是旧实现「越重试越冷、 // 全池被推到 2h 封顶」的元凶(每次探测都 softStreak++ 指数翻倍)。 func (p *Pool) CooldownSoftRate(uid string, base time.Duration, resetAt time.Time, reason string) { p.mu.Lock() defer p.mu.Unlock() if e, ok := p.byUID[uid]; ok { now := time.Now() if !resetAt.IsZero() { e.until = p.cappedSoftUntilLocked(now, resetAt) } else if e.coolKind != CoolSoft || !now.Before(e.until) { // 新限流(不在有效软冷却中):推进有界退避;兜底探测(仍在软冷却中)不翻倍。 d := p.softDurationLocked(base, e.softStreak+1) e.softStreak++ e.until = now.Add(d) } e.coolKind = CoolSoft e.reason = reason clearRoutingModelCooldownsLocked(e) // 账号级软冷却:清路由豁免,保留审计台账 p.dirty.Store(true) } } // cappedSoftUntilLocked 把上游重置墙钟截断到 softRateMax(now+softRateMax 与 resetAt // 取较早者)。resetAt 已过期(时钟偏移/文案过期)时时长钳到时间零点附近,立即恢复。 // 调用方必须已持有 p.mu。 func (p *Pool) cappedSoftUntilLocked(now, resetAt time.Time) time.Time { cap := now.Add(p.softRateMaxOr()) if resetAt.After(cap) { return cap } if resetAt.After(now) { return resetAt } return now.Add(time.Millisecond) } // softRateMaxOr 返回生效的 softRateMax(未注入时按默认 2h),供封顶计算。 // 调用方必须已持有 p.mu。 func (p *Pool) softRateMaxOr() time.Duration { if p.softRateMax > 0 { return p.softRateMax } return defaultSoftRateMax } // softDurationLocked 按连续软冷却次数把基数 d 指数放大:d << (streak-1),封顶 softRateMax。 // softRateMax 未注入(<=0)时按 defaultSoftRateMax 算。streak<=1 时原样返回 d。 // 左移位数受 softStreakShiftMax 限制,避免 streak 极大时移位溢出。 // 调用方必须已持有 p.mu。 func (p *Pool) softDurationLocked(d time.Duration, streak int) time.Duration { if streak <= 1 { return d } shift := streak - 1 if shift > softStreakShiftMax { shift = softStreakShiftMax } d <<= shift max := p.softRateMax if max <= 0 { max = defaultSoftRateMax } if d > max || d <= 0 { // d<=0:左移溢出成负数/零,同样按封顶兜底 d = max } return d } // recordBreakerFailureLocked 累计一次熔断失败;达到阈值则按指数退避熔断。 // 熔断与冷却(until)解耦:冷却按错误类别给固定时长,熔断则对"反复失败"逐次加长封禁。 // 调用方必须已持有 p.mu。 func (p *Pool) recordBreakerFailureLocked(e *entry) { e.fails++ if e.fails < p.breakerThreshold { return } d := p.breakerCooldown for i := 0; i < e.retryCount; i++ { d *= 2 if d >= p.breakerCooldownMax { d = p.breakerCooldownMax break } } // 触发熔断:重置失败计数供下一轮重新累计;retryCount 递增放大退避指数。 e.fails = 0 e.retryCount++ e.breakerUntil = time.Now().Add(d) } // CooldownUntilTomorrow4AM 冷却到下一个 04:00(本地时区)。 // 用于 ErrHardCredit 场景:积分耗尽账号等签到任务(09:00/21:00)恢复。 func (p *Pool) CooldownUntilTomorrow4AM(uid string, reason string) { now := time.Now() p.Cooldown(uid, CoolHard, nextDay4AM(now).Sub(now), reason) } // nextDay4AM 返回 now 之后最近的一个 04:00(与 now 同一时区)。 // now 在当天 04:00 之前(凌晨 00:00~04:00)时返回当天 04:00——此时签到尚未执行, // 该窗内触发的硬冷却等当天签到即可恢复;返回次日会白冷约一天。 // 04:00 整及之后返回次日 04:00。 // time.Date 对日溢出自动进位(月末→下月 1 号、年末→下年 1 号),天然覆盖跨日/跨月/跨年。 func nextDay4AM(now time.Time) time.Time { if now.Hour() < 4 { return time.Date(now.Year(), now.Month(), now.Day(), 4, 0, 0, 0, now.Location()) } return time.Date(now.Year(), now.Month(), now.Day()+1, 4, 0, 0, 0, now.Location()) } // ReenableIfCredits 签到/余额刷新后解冻:仅当 remain > 0 且账号非禁用时,解冻 // **余额耗尽冷却**(CoolHard)。软限流(CoolSoft)与模型级台账(modelCooldowns) // 不在此清除——它们的恢复证据是上游重置墙钟到期,不是余额恢复(余额刷新周期 // 任务每 5 分钟到达这里,全清会把限流冷却实际寿命压到一个刷新周期内)。 // 注意:不碰熔断器——熔断到期(breakerUntil 过期)或下次 chat 成功(NoteSuccess)才恢复。 // reviveCoolingLocked 已迁至 transition.go(状态机迁移唯一权威实现)。