Files
ModelRouter/internal/core/channels_pool_reason_test.go
JianFeeeee 969c72bfb1 feat(channels): credits_pool_reason —— 区分「真的用尽」与「查不到」
## 为什么需要(参考同款修复,用户报障 2026-09-29)
把两者混成一句"额度都已用尽"会让**一次网络抖动伪装成钱花完了** —— 用户于是
去解锁永久积分、或白等到明天,而号里其实有钱。两者的**正确动作完全相反**:
  exhausted → 等额度重置
  unknown   → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
  locked    → 可解锁永久积分(同样不是"用尽")

我们此前**没有**这个出口:`credits_balances` 只报逐账号错误,调用方拿不到
"为什么整池不可用"的结论,只能自己猜。

## 实现
新增 `channels/credits/pool_reason`,返回 `reason` ∈
`no_accounts | unknown | usable | locked | exhausted` + 可读 detail。
判定优先级(顺序即语义):
  no_accounts(无启用账号)
  → unknown(平台未声明余额端点 —— **无法判定 ≠ 用尽**)
  → usable(有可用账号)
  → unknown(**有查询失败** —— 不能当成用尽)
  → locked(锁定且只剩永久积分)
  → exhausted(都查到了且确为 0)

## 判据(6 条 + 变异 2/2)
internal/core/channels_pool_reason_test.go
- 余额确为 0 → exhausted
- 查询失败 → unknown(且说明必须点明"查询失败")
- 无启用账号 → no_accounts
- 有钱 → usable
- 无余额端点 → unknown(不编造用尽)
- 锁定且只剩永久积分 → locked,且**解锁后同一份余额变 usable**
  (证明 locked ≠ exhausted)
变异:查询失败也报 exhausted → 报红;locked 混同 exhausted → 报红。

## 踩到的坑(都是我的判据/夹具问题,非实现)
① 夹具用扁平 `{"total":100}`,而 buddy 的余额是**深层嵌套**
   `data.Response.Data.Accounts[]`(每包含 Status/CycleCapacityRemain/
   DeductionEndTime)—— 夹具形状不对导致全部走 unknown。
② `addAccount` 写死 platform="buddy",于是"无余额端点"那条用例在 minimax 上
   其实没有账号,命中优先级更高的 no_accounts(**正确**行为)却被判成错。
   加 `addAccountOn(platform, id)` 修正。
③ Lua 里嵌套引号:`"(...不是"额度用尽")"` 语法错 ⇒ 改用中文引号「」。
2026-10-16 09:01:01 +08:00

226 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 的真实包列表)。
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
h.fail.Store(false)
h.body.Store(buddyPkgBody(expiring, permanent))
}
// 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)
}
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)
}