Files
ModelRouter/internal/core/channels_balance_tier_test.go
JianFeeeee 9bec158d2e chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:

1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
   本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
   仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
   - `main` 独有的**文件**:0 个;
   - `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
     main 独有行数:0;
   - `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
   - `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
   - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
     (`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
   且不会丢任何 main 独有的修复。

本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth

校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
2026-10-14 16:52:58 +08:00

123 lines
4.9 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 (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)。
//
// 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
// 而**服务端扣哪个包不由我们决定** —— 插件能决定的是**用哪个号**:
// 优先用「还有 15 天内到期积分」的号(那部分不用就作废),只剩永久积分的号排最后;
// **锁定永久积分**时只剩永久积分的号等同于不可用。
//
// 判据用假余额上游,逐条钉住分档规则(含三个易错点)。
func TestBalanceTierSelection(t *testing.T) {
// 假 buddy 余额端点:返回三个包,覆盖三种情形
// pkg A: 有效 + 10 天内到期 + 剩余 100 → 临时桶
// pkg B: 有效 + 到期时间未知 + 剩余 50 → 永久桶(保守)
// pkg C: **已失效** + 剩余 9999 → 必须跳过(否则虚增可用额度)
future := nowMs() + 300*24*3600*1000 // 300 天后
soon := nowMs() + 10*24*3600*1000 // 10 天后
body := map[string]interface{}{
"data": map[string]interface{}{
"Response": map[string]interface{}{
"Data": map[string]interface{}{
"Accounts": []map[string]interface{}{
{"Status": 1, "CycleCapacityRemain": 100, "DeductionEndTime": soon},
{"Status": 1, "CycleCapacityRemain": 50, "DeductionEndTime": future},
{"Status": 2, "CycleCapacityRemain": 9999, "DeductionEndTime": future},
},
},
},
},
}
raw, _ := json.Marshal(body)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(raw)
}))
defer ts.Close()
_, ps := poolCore(t)
// 把一个 buddy 账号加进池,并把余额端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "tieracct",
"credential": `{"access_token":"TOK"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 失败: %#v", m)
}
// ① 分档:临时=100(只有 10 天内那个),永久=50(未知/远期),失效包被跳过
out, err := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
if err != nil {
t.Fatalf("分档探针: %v", err)
}
tw, _ := out.(map[string]interface{})
tj, _ := tw["json"].(map[string]interface{})
if tj == nil {
t.Fatalf("无 json: %#v", out)
}
if tj["error"] != nil {
t.Fatalf("分档报错: %v", tj["error"])
}
if got, _ := tj["expiring"].(float64); got != 100 {
t.Errorf("临时余额应为 100(仅 10 天内到期的包),实得 %v", tj["expiring"])
}
if got, _ := tj["permanent"].(float64); got != 50 {
t.Errorf("永久余额应为 50(到期未知/远期),实得 %v(若为 10049 说明失效包没跳过)",
tj["permanent"])
}
// ② 档位判定:有临时积分 → "expiring"(最优先)
if tj["tier"] != "expiring" {
t.Errorf("有 15 天内到期积分时档位应为 expiring,实得 %v", tj["tier"])
}
// ③ 锁定永久积分:该账号**仍可用**(因为有临时积分)
if _, err := ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": true}); err != nil {
t.Fatalf("锁定探针: %v", err)
}
out2, _ := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
tj2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if tj2["usable"] != true {
t.Errorf("锁定永久积分时,有临时积分的号仍应可用,实得 usable=%v", tj2["usable"])
}
// ④ 窗口可被显式覆盖:把窗口设成 0 ⇒ 没有"临时积分" ⇒ 只剩永久 ⇒ 锁定后不可用
if _, err := ps.Call("channels", "__set_expiring_window_probe",
map[string]interface{}{"days": 0}); err != nil {
t.Fatalf("窗口探针: %v", err)
}
out3, _ := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
tj3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
if got, _ := tj3["expiring"].(float64); got != 0 {
t.Errorf("窗口=0 时临时桶应为 0,实得 %v", tj3["expiring"])
}
if tj3["usable"] != false {
t.Errorf("窗口=0 且锁定永久积分时该号应不可用,实得 usable=%v", tj3["usable"])
}
}
// nowMs 返回当前毫秒时间戳(与插件里 DeductionEndTime 的口径一致)。
func nowMs() int64 { return time.Now().UnixMilli() }