mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17:23 +00:00
解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」), 而**服务端扣哪个包不由我们决定** —— 插件能做的是决定**用哪个号**: - 优先用「还有 15 天内到期积分」的号(那部分不用就作废) - 只剩永久积分的号排最后 - **锁定永久积分**时,只剩永久积分的号**等同于不可用** 为什么是 15 天(照参考的实测对照表,两站差别极大): - WorkBuddy 5 个号:Bonus Pack 250(8~9 天)+ Free Plan 100(3008 天) → 锁定后 4 个号仍可用、1 个不可用,正是期望效果 - CodeBuddy 中国版:裂变包距到期**最少 17 天** → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」—— 判据没错,这类池想用锁定要把窗口放宽 实现要点(三条都是参考点名的坑): 1. **只缓存原料,绝不冻结分类**。「临时/永久」是**当前时刻的函数**,而宿主长期 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就 越过了线。若把分桶结果也缓存,选号会继续按「永久」处理一笔马上要作废的积分 (锁定时更糟:本该可用的号被判成不可用)。故缓存 60s 的**包列表明细**, 每次都重新分桶。 2. **失效包必须跳过**(`active === false`):服务端仍返回它们的余额,并进任何 一桶都会虚增可用额度。 3. **到期时间未知归入永久桶**:保守方向 —— 最坏是锁定时少用一个号, 而不是误把长期积分当快到期烧掉。 口径与既有 `total_fn` 一致:只算 **remaining**(CycleCapacityRemain,本周期剩余), 与 IDE 顶部的 Credits Balance 一致。刻意不用 TotalDosage —— 那是**终身口径** 且取整(实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。 ★ 两个 Lua 环境的实测发现(写在这里免得下次再踩): - 本 VM 的 **os 表没有 setenv**(实测键:clock/date/difftime/execute/exit/ getenv/remove/rename/setlocale/time/tmpname),所以"面板改窗口"必须走 settings 而不是设环境变量;getenv 只用于读部署期注入值。 - `os.setenv and os.setenv(...)` 是**语法错误** —— `and` 是表达式,不能作语句。 判据(channels_balance_tier_test.go,假余额上游): 临时桶=100(只有 10 天内那个)、永久桶=50(远期+未知)、失效包的 9999 被跳过、 档位判定、锁定后仍可用(有临时积分)、窗口设 0 后临时桶归零且锁定后不可用。 回归:9 个包全绿。
123 lines
4.9 KiB
Go
123 lines
4.9 KiB
Go
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() }
|