mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 为什么需要(参考同款修复,用户报障 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 里嵌套引号:`"(...不是"额度用尽")"` 语法错 ⇒ 改用中文引号「」。
226 lines
7.5 KiB
Go
226 lines
7.5 KiB
Go
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)
|
||
}
|