File size: 19,163 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 | // 冷却与熔断: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(状态机迁移唯一权威实现)。
|