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(状态机迁移唯一权威实现)。