package pool import ( "os" "reflect" "strings" "testing" "time" "github.com/linguo2625469/workbuddy2api-panel/internal/auth" ) // --------------------------------------------------------------------------- // 6004 模型级 limit 独立冷却(issue:多模型独立计时) // --------------------------------------------------------------------------- // TestModelCooldownsIndependent 核心:模型 A 触发 6004(重置 2h 后),模型 B 再触发 // 6004(重置 1h 后)→ // 1. A 的冷却独立保留:1h 后 A 仍在限额中、B 已恢复; // 2. until(全账号级)不被任何模型的 6004 覆盖。 func TestModelCooldownsIndependent(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) base := time.Now() resetA := base.Add(2 * time.Hour) resetB := base.Add(1 * time.Hour) p.mu.Lock() e := p.byUID["u1"] e.modelCooldowns = map[string]modelCooldown{ "glm-5.3": {Until: resetA, ResetAt: resetA}, "hy3-x": {Until: resetB, ResetAt: resetB}, } p.mu.Unlock() now := base.Add(90 * time.Minute) if e.healthyForModel(now, "glm-5.3") { t.Fatalf("90m 后 A(glm-5.3, reset 2h) 仍应限额中,但 healthyForModel 放行了") } if !e.healthyForModel(now, "hy3-x") { t.Fatalf("90m 后 B(hy3-x, reset 1h) 应已恢复,但 healthyForModel 仍拦截") } if !e.until.IsZero() { t.Errorf("until=%v 应为零值(6004 模型级冷却不写 until)", e.until) } } // TestModelCooldownsBDoesNotOverwriteA 模型 B 触发 6004 后,A 的冷却截止不被覆盖: // 这是本 issue 的核心——旧实现用单 until 字段,B 会覆盖 A。 func TestModelCooldownsBDoesNotOverwriteA(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) resetA := time.Now().Add(2 * time.Hour) // 模拟真实路径两次 6004:A(2h) 然后 B(1h)。 p.CooldownSoftForModel("u1", 600*time.Second, resetA, "glm-5.3", "6004 model rate limit") p.CooldownSoftForModel("u1", 600*time.Second, time.Now().Add(1*time.Hour), "hy3-x", "6004 model rate limit") p.mu.RLock() e := p.byUID["u1"] mcA, okA := e.modelCooldowns["glm-5.3"] until := e.until p.mu.RUnlock() if !okA { t.Fatalf("A(glm-5.3) 的模型冷却条目丢失(被 B 覆盖?)") } if d := mcA.Until.Sub(resetA); d < -time.Second || d > time.Second { t.Errorf("A until=%v want ~%v(B 的冷却不得覆盖 A 的截止)", mcA.Until, resetA) } if !until.IsZero() { t.Errorf("until=%v 应为零值(6004 从不写账号级 until)", until) } } // TestCooldownSoftForModelDoesNotClobberUntil 带解析时间的 6004 不写 until // (否则全账号级冷却被模型重置时间污染),只写 modelCooldowns[model]。 func TestCooldownSoftForModelDoesNotClobberUntil(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) reset := time.Now().Add(30 * time.Minute) p.CooldownSoftForModel("u1", 600*time.Second, reset, "glm-5.3", "6004 model rate limit") p.mu.RLock() e := p.byUID["u1"] until := e.until mc, ok := e.modelCooldowns["glm-5.3"] p.mu.RUnlock() if !until.IsZero() { t.Errorf("until=%v 应零值(6004 不写 until)", until) } if !ok || mc.Until.IsZero() { t.Errorf("modelCooldowns[glm-5.3]=%+v ok=%v,应已记录模型冷却", mc, ok) } } // TestCooldownSoftForModelCapsUntilKeepsResetAt 6004 写 modelCooldowns: // until 截断到 soft_rate_max,reset_at 保留上游原始墙钟(issue #36 台账语义迁移)。 func TestCooldownSoftForModelCapsUntilKeepsResetAt(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.SetSoftRateMax(10 * time.Minute) reset := time.Now().Add(2 * time.Hour) // 远超封顶 → until 截断到 10m,reset_at 保留 2h p.CooldownSoftForModel("u1", 600*time.Second, reset, "glm-5.3", "6004 model rate limit") p.mu.RLock() mc, ok := p.byUID["u1"].modelCooldowns["glm-5.3"] p.mu.RUnlock() if !ok { t.Fatal("modelCooldowns 缺少 glm-5.3") } if rem := mc.Until.Sub(time.Now()); rem <= 0 || rem > 10*time.Minute+time.Second { t.Errorf("Until 应在 (0,10m] 区间,实际剩余 %v", rem) } if d := mc.ResetAt.Sub(reset); d < -time.Second || d > time.Second { t.Errorf("ResetAt=%v want ~2h 后=%v", mc.ResetAt, reset) } } // TestHealthyForModelAfterModelSpecific6004 6004 只锁该模型: // 账号对触发模型不可选、对其他模型仍可选(issue #31 豁免保持);账号级 healthy 仍真。 func TestHealthyForModelAfterModelSpecific6004(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) reset := time.Now().Add(30 * time.Minute) p.CooldownSoftForModel("u1", 600*time.Second, reset, "glm-5.3", "6004 model rate limit") p.mu.RLock() e := p.byUID["u1"] p.mu.RUnlock() now := time.Now() if e.healthyForModel(now, "glm-5.3") { t.Fatal("触发模型 glm-5.3 应不可选") } if !e.healthyForModel(now, "hy3-x") { t.Fatal("其他模型 hy3-x 应可选(模型豁免)") } if !e.healthy(now) { t.Fatal("账号级 healthy 应仍 true(6004 只锁模型,不锁账号)") } } // TestModelCooldownsTwoLimitsBothBlock 同一账号两个模型同时 6004:这两个模型都不可选 // (无账号级冷却),其他模型仍可选。 func TestModelCooldownsTwoLimitsBothBlock(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) resetA := time.Now().Add(2 * time.Hour) resetB := time.Now().Add(2 * time.Hour) p.mu.Lock() e := p.byUID["u1"] e.modelCooldowns = map[string]modelCooldown{ "glm-5.3": {Until: resetA, ResetAt: resetA}, "hy3-x": {Until: resetB, ResetAt: resetB}, } p.mu.Unlock() now := time.Now() if e.healthyForModel(now, "glm-5.3") { t.Fatal("glm-5.3 应被自身冷却拦截") } if e.healthyForModel(now, "hy3-x") { t.Fatal("hy3-x 应被自身冷却拦截") } if !e.healthyForModel(now, "other") { t.Fatal("other 模型应可选(多模型限流不应让账号级不可选)") } } // TestModelCooldownsPreservedByNoteSuccess 成功(NoteSuccess)不得清除模型级 6004 冷却: // 若清除,B 模型成功会抹掉 A 模型的独立冷却——正是本 issue 要修的核心缺陷。 func TestModelCooldownsPreservedByNoteSuccess(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) resetA := time.Now().Add(2 * time.Hour) p.CooldownSoftForModel("u1", 600*time.Second, resetA, "glm-5.3", "6004 model rate limit") p.NoteSuccess("u1") // 其他模型成功 p.mu.RLock() mc, ok := p.byUID["u1"].modelCooldowns["glm-5.3"] p.mu.RUnlock() if !ok { t.Fatalf("NoteSuccess 后 A(glm-5.3) 独立冷却被清除——模型独立性被破坏") } if d := mc.Until.Sub(resetA); d < -time.Second || d > time.Second { t.Errorf("A until=%v want ~%v(不得被 NoteSuccess 干扰)", mc.Until, resetA) } } // TestModelCooldownsSurviveRevive 签到/余额刷新解冻(reviveCoolingLocked)不得清 // 模型级 6004 冷却——限流的恢复证据是上游重置墙钟到期,不是余额恢复;余额刷新 // 周期任务每 5 分钟经 ReenableIfCredits 到达这里,若在此清台账,撞限号会被误判 // 健康、重新选中再撞 429,全池冷却保护形同虚设(两号池实测复现:expiring==0 的 // 号每 5 分钟被抹一次台账,expiring>0 的号走 SetCreditsDetailed 幸免,行为不对称)。 func TestModelCooldownsSurviveRevive(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.CooldownSoftForModel("u1", 600*time.Second, time.Now().Add(time.Hour), "glm-5.3", "6004") p.ReenableIfCredits("u1", 500, 0) p.mu.RLock() mc, ok := p.byUID["u1"].modelCooldowns["glm-5.3"] p.mu.RUnlock() if !ok { t.Fatal("revive 后 modelCooldowns[glm-5.3] 应保留——余额恢复不构成限流解除证据") } if rem := time.Until(mc.Until); rem < 55*time.Minute || rem > time.Hour+time.Minute { t.Errorf("6004 until 应保持 ~1h 不变, got remaining=%v", rem) } } // TestModelCooldownsLazyCleanup 已过期的模型冷却在 pick(写锁路径)时被惰性清理。 func TestModelCooldownsLazyCleanup(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.mu.Lock() e := p.byUID["u1"] e.modelCooldowns = map[string]modelCooldown{ "old": {Until: time.Now().Add(-time.Minute), ResetAt: time.Now().Add(-time.Minute)}, } p.mu.Unlock() got := p.PickExcludingForModel(nil, "fresh") if got == nil || got.UID != "u1" { t.Fatalf("过期模型冷却不应拦截 u1, got %+v", got) } p.mu.RLock() _, still := e.modelCooldowns["old"] p.mu.RUnlock() if still { t.Error("过期模型冷却条目应在 pick 时被清理") } } // TestModelCooldownsExpiredAllowsSameModel 过期后同模型请求也放行(read 路径无清理也可选)。 func TestModelCooldownsExpiredAllowsSameModel(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.mu.Lock() e := p.byUID["u1"] e.modelCooldowns = map[string]modelCooldown{ "glm-5.3": {Until: time.Now().Add(-time.Nanosecond), ResetAt: time.Now().Add(-time.Nanosecond)}, } p.mu.Unlock() if !e.healthyForModel(time.Now(), "glm-5.3") { t.Fatal("过期后同模型请求应放行") } } // TestRateLimitedModelsMultiModel 6004 多模型同时限流 → /status 台账全部展示。 func TestRateLimitedModelsMultiModel(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) resetA := time.Now().Add(2 * time.Hour) resetB := time.Now().Add(1 * time.Hour) p.mu.Lock() e := p.byUID["u1"] e.modelCooldowns = map[string]modelCooldown{ "glm-5.3": {Until: resetA, ResetAt: resetA, Reason: "6004 model rate limit"}, "hy3-x": {Until: resetB, ResetAt: resetB, Reason: "6004 model rate limit"}, } p.mu.Unlock() st, ok := p.Status("u1") if !ok { t.Fatal("status missing") } if len(st.RateLimitedModels) != 2 { t.Fatalf("rate_limited_models=%+v want 2 行", st.RateLimitedModels) } wantModels := map[string]bool{"glm-5.3": true, "hy3-x": true} for _, row := range st.RateLimitedModels { if !wantModels[row.Model] { t.Errorf("unexpected row model=%q", row.Model) } if row.Kind != "rate_limit" { t.Errorf("%s kind=%q want rate_limit", row.Model, row.Kind) } delete(wantModels, row.Model) } if len(wantModels) != 0 { t.Errorf("缺行: %v", wantModels) } } // 无重置时间的 6004 只写 AuditOnly 台账:账号页可见,但不得改变模型路由。 func TestModelRateLimitAuditDoesNotAffectRouting(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.CooldownSoftRate("u1", time.Minute, time.Time{}, "429 rate limit") p.RecordModelRateLimitAudit("u1", "glm-5.3", "6004 model rate limit (reset unknown)") p.mu.Lock() e := p.byUID["u1"] mc, ok := e.modelCooldowns["glm-5.3"] if ok && mc.AuditOnly { mc.Until = time.Now().Add(time.Hour) e.modelCooldowns["glm-5.3"] = mc } p.mu.Unlock() if !ok || !mc.AuditOnly { t.Fatalf("audit entry missing: %+v ok=%v", mc, ok) } if e.modelCooled(time.Now(), "glm-5.3") { t.Fatal("AuditOnly 台账不得参与 modelCooled") } if e.modelExempt() { t.Fatal("仅 AuditOnly 台账不得让账号进入模型豁免形态") } st, _ := p.Status("u1") if len(st.RateLimitedModels) != 1 || st.RateLimitedModels[0].Kind != "rate_limit" { t.Fatalf("audit status = %+v, want one rate_limit row", st.RateLimitedModels) } } // 11102 与 6004 共用台账但必须输出不同 kind。 func TestRateLimitedModelKindModelUnavailable(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.BlockModelBackoff("u1", "missing-model", "11102 model unavailable") st, _ := p.Status("u1") if len(st.RateLimitedModels) != 1 || st.RateLimitedModels[0].Kind != "model_unavailable" { t.Fatalf("rows=%+v want model_unavailable", st.RateLimitedModels) } } // AuditOnly 标记必须跨重启保留,否则无重置时间的 6004 展示项会失忆并参与路由。 func TestModelRateLimitAuditPersists(t *testing.T) { dir := t.TempDir() fp := dir + "/state.json" p := New(fp) p.Add(&auth.Auth{UID: "u1"}) p.CooldownSoftRate("u1", time.Minute, time.Time{}, "429 rate limit") p.RecordModelRateLimitAudit("u1", "glm-5.3", "6004 model rate limit (reset unknown)") p.Flush() p2 := New(fp) p2.Add(&auth.Auth{UID: "u1"}) p2.mu.RLock() mc, ok := p2.byUID["u1"].modelCooldowns["glm-5.3"] p2.mu.RUnlock() if !ok || !mc.AuditOnly { t.Fatalf("audit entry not restored: %+v ok=%v", mc, ok) } if p2.byUID["u1"].modelExempt() { t.Fatal("restored AuditOnly entry must not create model exemption") } } // TestRateLimitedModelsMultiModelStableOutput 多模型台账行按模型名排序(稳定输出)。 func TestRateLimitedModelsMultiModelStableOutput(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) resetA := time.Now().Add(2 * time.Hour) resetB := time.Now().Add(1 * time.Hour) p.mu.Lock() e := p.byUID["u1"] e.modelCooldowns = map[string]modelCooldown{ "hy3-x": {Until: resetB, ResetAt: resetB, Reason: "r"}, "glm-5.3": {Until: resetA, ResetAt: resetA, Reason: "r"}, } p.mu.Unlock() st, _ := p.Status("u1") got := []string{st.RateLimitedModels[0].Model, st.RateLimitedModels[1].Model} want := []string{"glm-5.3", "hy3-x"} if !reflect.DeepEqual(got, want) { t.Errorf("rows=%v want sorted %v", got, want) } } // TestRateLimitedModelsEachModelHasOwnUntil 台账行 Until = 该模型独立冷却截止, // Status.Until(账号级)不受 6004 影响(无账号级冷却时为零值)。 func TestRateLimitedModelsEachModelHasOwnUntil(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) resetA := time.Now().Add(2 * time.Hour) resetB := time.Now().Add(1 * time.Hour) p.mu.Lock() e := p.byUID["u1"] e.modelCooldowns = map[string]modelCooldown{ "glm-5.3": {Until: resetA, ResetAt: resetA, Reason: "r"}, "hy3-x": {Until: resetB, ResetAt: resetB, Reason: "r"}, } p.mu.Unlock() st, ok := p.Status("u1") if !ok { t.Fatal("status missing") } if !st.Until.IsZero() { t.Fatalf("Status.Until 应零值(无账号级冷却),got %v", st.Until) } for _, row := range st.RateLimitedModels { want := resetA if row.Model == "hy3-x" { want = resetB } if d := row.Until.Sub(want); d < -time.Second || d > time.Second { t.Errorf("%s row.Until=%v want ~%v", row.Model, row.Until, want) } } } // TestModelCooldownsPickSkipsLimitedModel 被模型 X 6004 的账号,请求 X 时选到别的号, // 请求其他模型时可选到该号(模型豁免进入 normal 选号)。 func TestModelCooldownsPickSkipsLimitedModel(t *testing.T) { withNoPickGap(t) p := New("") p.Add(&auth.Auth{UID: "u1"}) p.Add(&auth.Auth{UID: "u2"}) p.SetCredits("u1", 1000, 0) p.SetCredits("u2", 1, 0) p.SetRandomSource(func(n int64) int64 { return 0 }) p.CooldownSoftForModel("u1", time.Minute, time.Now().Add(5*time.Minute), "glm-5.3", "6004") if got := p.PickExcludingForModel(nil, "glm-5.3"); got == nil || got.UID != "u2" { t.Fatalf("glm-5.3 请求应跳过 u1, got %+v", got) } if got := p.PickExcludingForModel(nil, "hy3-x"); got == nil || got.UID != "u1" { t.Fatalf("hy3-x 请求应豁免 u1, got %+v", got) } } // TestServableNowModelCooldownStillServable 单模型 6004 限流不破坏探活(池还可服务), // 全账号冷却(until)则不可服务。 func TestServableNowModelCooldownStillServable(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.CooldownSoftForModel("u1", time.Minute, time.Now().Add(5*time.Minute), "glm-5.3", "6004") if !p.ServableNow() { t.Fatal("6004 模型冷却不锁账号,ServableNow 应 true") } } // --------------------------------------------------------------------------- // healthyForModel 优先级(全账号级先判,模型级 6004 后判) // --------------------------------------------------------------------------- // TestHealthyForModelAccountCooledBeatsModelNotCooled 全账号冷却(until)优先于模型 // 独立冷却:账号级 until 未到期的账号,即使该模型没有 6004 独立冷却也不可选 // (旧实现先查 modelCooled 再查 healthy,逻辑上等价的短路位置不同;锁死新语义: // 全账号冷却优先)。 func TestHealthyForModelAccountCooledBeatsModelNotCooled(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.Cooldown("u1", CoolSoft, time.Hour, "429 rate limit") // 全账号级 until 冷却,无 modelCooldowns p.mu.RLock() e := p.byUID["u1"] p.mu.RUnlock() if e.healthyForModel(time.Now(), "glm-5.3") { t.Fatal("全账号 until 冷却中且模型无独立冷却:该模型也不可选(全账号冷却优先)") } if e.healthyForModel(time.Now(), "") { t.Fatal("空模型名同样不可选(等价 healthy 短路到全账号冷却)") } } // TestHealthyForModelAccountCooledWithModelCooldownStillBlocked 全账号冷却 + 该模型 // 也有 6004 独立冷却 → 不可选(无论哪条拦截都一致,优先级短路不误放行)。 func TestHealthyForModelAccountCooledWithModelCooldownStillBlocked(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.Cooldown("u1", CoolSoft, time.Hour, "429 rate limit") p.CooldownSoftForModel("u1", time.Minute, time.Now().Add(5*time.Minute), "glm-5.3", "6004") p.mu.RLock() e := p.byUID["u1"] p.mu.RUnlock() if e.healthyForModel(time.Now(), "glm-5.3") { t.Fatal("全账号冷却 + 模型独立冷却双拦截,仍应不可选") } } // TestHealthyForModelDisabledBeatsModelNotCooled disabled 是全账号级的最强冷却: // 即使模型没有独立冷却也永不可选(disabled > 模型级)。 func TestHealthyForModelDisabledBeatsModelNotCooled(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.Disable("u1", "session dead") p.mu.RLock() e := p.byUID["u1"] p.mu.RUnlock() if e.healthyForModel(time.Now(), "glm-5.3") { t.Fatal("disabled 账号即使模型无独立冷却也不可选") } } // TestHealthyForModelBreakerBeatsModelNotCooled 熔断(breakerUntil)是全账号级的 // 冷却:熔断期内即使模型没有独立冷却也不可选。 func TestHealthyForModelBreakerBeatsModelNotCooled(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.SetBreaker(1, time.Hour, time.Hour) p.NoteError("u1") // 触发熔断 p.mu.RLock() e := p.byUID["u1"] bt := e.breakerUntil p.mu.RUnlock() if bt.IsZero() { t.Fatal("precondition: breaker should be open") } if e.healthyForModel(time.Now(), "glm-5.3") { t.Fatal("熔断期内即使模型无独立冷却也不可选") } } // TestHealthyForModelHealthyAccountNoModelCooldown 全账号健康 + 无任何模型独立冷却 → // 所有模型都可选(基线)。 func TestHealthyForModelHealthyAccountNoModelCooldown(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.mu.RLock() e := p.byUID["u1"] p.mu.RUnlock() for _, m := range []string{"glm-5.3", "hy3-x", ""} { if !e.healthyForModel(time.Now(), m) { t.Errorf("健康账号对模型 %q 应可选", m) } } } // TestHealthyForModelAccountCooledAllowsNothingAfterUntil 优先级锁死的另一端: // 全账号 until 冷却到期后,模型无独立冷却的请求恢复正常(模型级判定接管)。 func TestHealthyForModelAccountCooledAllowsNothingAfterUntil(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) p.Cooldown("u1", CoolSoft, time.Millisecond, "429") p.mu.RLock() e := p.byUID["u1"] p.mu.RUnlock() // until 尚未到期:不可选(优先级:全账号级先判)。 if e.healthyForModel(time.Now(), "glm-5.3") { t.Fatal("until 冷却内不可选") } // 等 until 过期:模型无独立冷却 → 恢复可选。 time.Sleep(10 * time.Millisecond) if !e.healthyForModel(time.Now(), "glm-5.3") { t.Fatal("until 过期后应恢复可选") } } // TestHealthyForModelPriorityViaPick 端到端:全账号冷却中的账号即使对某模型无独立冷却 // 也不被选出;健康 + 仅该模型 6004 的账号走模型豁免(同一账号对不同模型口径分离)。 func TestHealthyForModelPriorityViaPick(t *testing.T) { withNoPickGap(t) p := New("") p.Add(&auth.Auth{UID: "cooled"}) p.Add(&auth.Auth{UID: "exempt"}) p.SetCredits("cooled", 100, 0) p.SetCredits("exempt", 50, 0) p.SetRandomSource(func(n int64) int64 { return 0 }) // r=0 → 最高分 cooled p.Cooldown("cooled", CoolSoft, time.Hour, "429") // 全账号级冷却,无模型级记录 p.CooldownSoftForModel("exempt", time.Minute, time.Now().Add(5*time.Minute), "glm-5.3", "6004") // 请求 other:cooled 被全账号冷却拦截(即使无模型独立冷却),exempt 模型豁免 // (6004 只锁 glm-5.3)→ 唯一候选 exempt。 if got := p.PickExcludingForModel(nil, "other"); got == nil || got.UID != "exempt" { t.Fatalf("other 模型请求应豁免 exempt(全账号冷却的 cooled 仍拦截),got %+v", got) } // 请求 glm-5.3:cooled 全账号冷却拦截;exempt 自身 6004 拦截 → 无健康候选 → // 全冷却兜底只认账号级冷却(exempt 无 until/breakerUntil,expiry 零值被排除), // 选 cooled(软冷却参与兜底)。 if got := p.PickExcludingForModel(nil, "glm-5.3"); got == nil || got.UID != "cooled" { t.Fatalf("glm-5.3 请求:exempt 被自身 6004 拦截,兜底应选全账号冷却的 cooled,got %+v", got) } } // TestModelCooldownsNotPersisted modelCooldowns 运行态、不持久化(重启清零)。 func TestModelCooldownsPersist(t *testing.T) { // 6004 重置墙钟可长达数小时,跨重启是常态:model_cooldowns 持久化, // 恢复后 healthyForModel 不失忆(吸收上游 2f4c77b)。 dir := t.TempDir() fp := dir + "/state.json" p := New(fp) p.Add(&auth.Auth{UID: "u1"}) p.CooldownSoftForModel("u1", time.Minute, time.Now().Add(5*time.Minute), "glm-5.3", "6004") p.Flush() raw, err := os.ReadFile(fp) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), "model_cooldowns") { t.Errorf("state.json 应持久化 model_cooldowns: %s", raw) } p2 := New(fp) p2.Add(&auth.Auth{UID: "u1"}) p2.mu.RLock() n := len(p2.byUID["u1"].modelCooldowns) p2.mu.RUnlock() if n != 1 { t.Errorf("重载后 modelCooldowns=%d want 1(持久化恢复)", n) } } // TestModelCooldownsPersistCompatOldState 旧 state.json 无 modelCooldowns 字段正常加载 // (缺字段零值,模型冷却清零退化为账号级,向后兼容)。 func TestModelCooldownsPersistCompatOldState(t *testing.T) { dir := t.TempDir() fp := dir + "/state.json" old := `{"accounts":{"u1":{"credits":100,"until":"2099-01-01T00:00:00Z","cool_kind":1}}}` if err := os.WriteFile(fp, []byte(old), 0o600); err != nil { t.Fatal(err) } p := New(fp) p.Add(&auth.Auth{UID: "u1"}) p.mu.RLock() n := len(p.byUID["u1"].modelCooldowns) until := p.byUID["u1"].until p.mu.RUnlock() if n != 0 { t.Errorf("旧文件加载后 modelCooldowns=%d want 0", n) } if until.IsZero() { t.Error("旧文件 until 应照常加载(账号级冷却兼容)") } }