package core import ( "fmt" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "llmsproxy/internal/lua" ) // ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。 // // 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让 // **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天, // 而号里其实有钱。两者的**正确动作完全相反**: // // exhausted → 等额度重置 // unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定) // // 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。 func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) { t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) { h := newPoolReasonHarness(t) h.addAccount("a1") h.serveBalance(0, 0) got := h.reason() if got["reason"] != "exhausted" { t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v", got["reason"], got["detail"]) } }) t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) { h := newPoolReasonHarness(t) h.addAccount("a1") h.serveBalanceError(500) got := h.reason() if got["reason"] != "unknown" { t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+ "(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"]) } if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") { t.Fatalf("说明必须点明是查询失败,实得 %q", d) } }) t.Run("没有启用的账号 → no_accounts", func(t *testing.T) { h := newPoolReasonHarness(t) got := h.reason() if got["reason"] != "no_accounts" { t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"]) } }) t.Run("有可用账号 → usable", func(t *testing.T) { h := newPoolReasonHarness(t) h.addAccount("a1") h.serveBalance(100, 0) got := h.reason() if got["reason"] != "usable" { t.Fatalf("有钱时应报 usable,实得 %v", got["reason"]) } if u, _ := got["usable"].(float64); int(u) != 1 { t.Fatalf("usable 应为 1,实得 %v", got["usable"]) } }) t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) { h := newPoolReasonHarness(t) // ⚠ 必须有**启用的账号**:判定的优先级是 // no_accounts → 无余额端点 → usable → unknown/exhausted // 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高, // 那是**正确**行为),把正确的实现判成了错。 h.addAccountOn("minimax", "m1") // minimax 未声明 balance 端点 got := h.reasonOn("minimax") if got["reason"] != "unknown" { t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v", got["reason"], got["detail"]) } if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") { t.Fatalf("说明必须点明无法判定,实得 %q", d) } }) } // 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted // (用户动作不同:解锁 vs 等重置)。 func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) { h := newPoolReasonHarness(t) h.addAccount("a1") // total_fn 取包列表:只有永久积分(到期未知) h.serveBalance(0, 50) if _, err := h.ps.Call("channels", "__set_lock_permanent_probe", map[string]interface{}{"lock": true}); err != nil { t.Fatal(err) } got := h.reason() if got["reason"] != "locked" { t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v", got["reason"], got["detail"]) } // 解锁后同一份余额应为 usable(证明 locked 不是"用尽") if _, err := h.ps.Call("channels", "__set_lock_permanent_probe", map[string]interface{}{"lock": false}); err != nil { t.Fatal(err) } h.serveBalance(0, 50) if got2 := h.reason(); got2["reason"] != "usable" { t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"]) } } // ── harness ── type poolReasonHarness struct { t *testing.T ps *lua.Plugins ts *httptest.Server body atomic.Value fail atomic.Bool hasAcct atomic.Bool } func newPoolReasonHarness(t *testing.T) *poolReasonHarness { t.Helper() h := &poolReasonHarness{t: t} // buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts h.body.Store(buddyPkgBody(0, 0)) h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if h.fail.Load() { w.WriteHeader(500) return } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, h.body.Load().(string)) })) t.Cleanup(h.ts.Close) c := kernelTestCore(t) ps := c.Plugins() src, err := readBundledChannels(t) if err != nil { t.Fatal(err) } if err := ps.LoadSource("channels", src); err != nil { t.Fatal(err) } h.ps = ps // buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶 if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ "platform": "buddy", "balance": map[string]interface{}{"url": h.ts.URL}, }); err != nil { t.Logf("set_endpoints(balance) 未支持: %v", err) } return h } func (h *poolReasonHarness) addAccount(id string) { h.t.Helper() h.addAccountOn("buddy", id) } // addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致 // "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的 // no_accounts —— 判据写错而非实现错)。 func (h *poolReasonHarness) addAccountOn(platform, id string) { h.t.Helper() if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{ "platform": platform, "account_id": id, "credential": `{"access_token":"AT","uid":"u1"}`, }); err != nil { h.t.Fatalf("account_add(%s): %v", platform, err) } h.hasAcct.Store(true) } // serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。 // // ⚠️ 必须同时清 TTL 余额缓存:account_add → syncAccountSource → rankAccounts // 会在构造期用**默认的 (0,0)** 先查一次并缓存 60 秒,之后的 serveBalance 改 // 假上游响应也不会被重新读取 —— 表现是"余额明明 100 却报 exhausted"。 // __ttl_reset_probe 是插件自己的清缓存出口(与生产同一实现)。 func (h *poolReasonHarness) serveBalance(expiring, permanent int) { h.fail.Store(false) h.body.Store(buddyPkgBody(expiring, permanent)) if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil { h.t.Fatalf("清 TTL 缓存: %v", err) } } // buddyPkgBody 拼一份 buddy 真实形态的余额响应: // 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain / // DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。 func buddyPkgBody(expiring, permanent int) string { pkgs := []string{} if expiring > 0 { // 3 天到期(< 15 天窗口) endMs := (time.Now().Unix() + 3*86400) * 1000 pkgs = append(pkgs, fmt.Sprintf( `{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs)) } if permanent > 0 { // DeductionEndTime=0(未知)⇒ 永久桶 pkgs = append(pkgs, fmt.Sprintf( `{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent)) } return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}` } func (h *poolReasonHarness) serveBalanceError(code int) { h.fail.Store(true) // 同上:清掉构造期缓存,否则"查询失败"用例会命中旧的成功缓存。 if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil { h.t.Fatalf("清 TTL 缓存: %v", err) } } func (h *poolReasonHarness) reason() map[string]interface{} { h.t.Helper() return h.reasonOn("buddy") } func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} { h.t.Helper() res, err := h.ps.Call("channels", "credits_pool_reason", map[string]interface{}{"platform": platform}) if err != nil { h.t.Fatalf("credits_pool_reason: %v", err) } return jsonOf(h.t, res) }