From 29c87ce0154c40345cd5058fdfe3db0d267fad11 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 15:24:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E4=BD=99=E9=A2=9D=E5=88=86?= =?UTF-8?q?=E6=A1=A3=E9=80=89=E5=8F=B7=EF=BC=88=E7=85=A7=E5=8F=82=E8=80=83?= =?UTF-8?q?=20balance-rank/selector=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决什么:一个号常同时持有多**种**资源包(如「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 个包全绿。 --- internal/core/channels_balance_tier_test.go | 122 ++++++++++ internal/lua/plugins/channels.lua | 256 +++++++++++++++++++- 2 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 internal/core/channels_balance_tier_test.go diff --git a/internal/core/channels_balance_tier_test.go b/internal/core/channels_balance_tier_test.go new file mode 100644 index 0000000..25bf422 --- /dev/null +++ b/internal/core/channels_balance_tier_test.go @@ -0,0 +1,122 @@ +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() } diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 17f411c..52888d5 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -84,6 +84,11 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda local qoderMachineIdentity, rfc3986Escape, newToken local lobsteraiClientVersion +-- 前向声明(本文件第 10 次踩 local 前向引用):余额分档选号在前、具体抓取在后。 +-- expiringWindowDays / fetchAccountJSON / fetchBalanceOf 都定义在下方。 +local expiringWindowDays +local fetchAccountJSON + local PLATFORMS = { -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== -- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 @@ -400,6 +405,52 @@ local PLATFORMS = { balance = { url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource", method = "POST", body = "{}", + -- tier_fn:把余额拆成「快到期 / 永久」两桶(照参考 + -- buddy-balance-rank.ts:splitBuddyCreditsByExpiry)。口径必须与 total_fn + -- 一致 —— 两条路各算一遍必然会漂移,故共用同一套包遍历规则: + -- * **只算 remaining**(CycleCapacityRemain,本周期剩余),与 IDE 顶部 + -- 的 Credits Balance 一致。刻意不用 TotalDosage:那是**终身口径** + -- 且取整(实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。 + -- * `active === false` 的包一律**跳过**:失效包服务端仍会返回余额, + -- 并进任何一桶都会虚增可用额度。 + -- * 到期时间**未知**归入**永久**桶 —— 保守方向:最坏是锁定时少用一个号, + -- 而不是误把长期积分当快到期烧掉。 + tier_fn = function(rec) + local data = rec.data + if type(data) ~= "table" then return nil end + local resp = data.Response + if type(resp) ~= "table" then return nil end + local inner = resp.Data + if type(inner) ~= "table" then return nil end + local accounts = inner.Accounts + if type(accounts) ~= "table" then return nil end + local now = os.time and os.time() or 0 + local window = expiringWindowDays() * 86400 + local expiring, permanent = 0, 0 + for _, pkg in ipairs(accounts) do + if type(pkg) == "table" then + local active = true + local st = pkg.Status + if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end + local ded = tonumber(pkg.DeductionEndTime) + local endSec = nil + if ded and ded > 0 then + endSec = math.floor(ded / 1000) + if now >= endSec then active = false end + end + local remain = tonumber(pkg.CycleCapacityRemain) or 0 + if active and remain > 0 then + -- 到期时间已知且落在窗口内 → 临时桶;未知 → 永久桶(保守) + if endSec and (endSec - now) < window then + expiring = expiring + remain + else + permanent = permanent + remain + end + end + end + end + return { expiring_balance = expiring, permanent_balance = permanent } + end, total_fn = function(rec) local data = rec.data if type(data) ~= "table" then return nil end @@ -4985,6 +5036,148 @@ plugin.schedule = { -- ============================================================================ -- ============================================================================ +-- ============================================================================ +-- 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts) +-- ============================================================================ +-- 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」), +-- 而**服务端扣哪个包不由我们决定**。插件能做的是决定**用哪个号**: +-- * 优先用「还有 15 天内到期积分」的号(那部分再不用就作废) +-- * 只剩永久积分的号排最后 +-- * **锁定永久积分**时,只剩永久积分的号**等同于不可用** +-- +-- 为什么是 15 天(实测两站差别极大,参考有对照表): +-- WorkBuddy 5 个号:Bonus Pack 250(8~9 天)+ Free Plan 100(3008 天) +-- → 锁定后有 4 个号仍可用,1 个(Bonus Pack 已耗尽)不可用,正是期望效果 +-- CodeBuddy 中国版 1 个号:裂变包共 10064.6,**距到期最少 17 天** +-- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」 +-- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池 +-- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。 +local EXPIRING_WINDOW_DAYS_DEFAULT = 15 + +expiringWindowDays = function() + -- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。 + -- + -- ⚠️ **不能**写成 `tonumber(raw) or 默认`:0 是合法值(表示"没有临时积分", + -- 即全部算永久)。参考在这一点上专门写了注释。 + local cfg = plugin.state.settings and plugin.state.settings.expiring_window_days + if cfg ~= nil then + local n = tonumber(cfg) + if n and n >= 0 then return n end + end + -- ⚠️ 本 VM 的 os 表**没有** setenv(实测:只有 clock/date/difftime/execute/ + -- exit/getenv/remove/rename/setlocale/time/tmpname),所以面板改值必须走 + -- settings 而不是"设环境变量"。getenv 只用于读取部署期注入的值。 + local raw = os.getenv and os.getenv("DSH_BUDDY_EXPIRING_WINDOW_DAYS") + if type(raw) == "string" and raw ~= "" then + local n = tonumber(raw) + if n and n >= 0 then return n end + end + return EXPIRING_WINDOW_DAYS_DEFAULT +end + +-- balanceTier(expiring, permanent, allow_permanent): +-- "expiring" 有 15 天内到期的积分 → 最优先(不用就作废) +-- "permanent" 只剩永久积分 → 次优先;锁定永久积分时视为不可用 +-- "none" 都没有 → 不可用 +local function balanceTier(expiring, permanent, allow_permanent) + local function pos(v) + local n = tonumber(v) + if n and n > 0 then return n end + return 0 + end + if pos(expiring) > 0 then return "expiring" end + if allow_permanent == false then return "none" end + if pos(permanent) > 0 then return "permanent" end + return "none" +end + +local function tierUsable(tier) + return tier ~= "none" +end + +-- 余额缓存(TTL 60 秒,照参考)。**只缓存原料,绝不冻结分类** —— +-- 「临时/永久」是**当前时刻的函数**(到期时间 - now 与窗口比较),而宿主长期 +-- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就 +-- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分 +-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。 +local BALANCE_CACHE_TTL = 60 + +local function balanceCacheOf(provider, accountId) + plugin.state.balanceCache = plugin.state.balanceCache or {} + local k = provider .. ":" .. accountId + return plugin.state.balanceCache, k +end + +-- accountBalanceRaw(provider, accountId):取该账号的余额原料(带 TTL 缓存)。 +-- 失败**不抛错**(照参考:查询失败归入最后一档),返回 nil 让调用方降级。 +local function accountBalanceRaw(provider, accountId) + local cache, k = balanceCacheOf(provider, accountId) + local now = os.time and os.time() or 0 + local hit = cache[k] + if hit and (now - (hit.at or 0)) < BALANCE_CACHE_TTL then + return hit.raw + end + local plat = platformById(provider) + local acct = poolGet(provider, accountId) + if not plat or not acct or not plat.balance then return nil end + local tok = credentialOf(acct.credentialRef) + if not tok or tok == "" then return nil end + -- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移) + local rec, err = fetchAccountJSON(plat.balance, tok, credentialFull(acct.credentialRef), plat) + local raw = nil + if not err and type(rec) == "table" then + if plat.balance.tier_fn then + -- 平台声明了分档提取器(buddy/loomy 的包列表要按到期时间现算) + local okf, t = pcall(plat.balance.tier_fn, rec) + if okf and type(t) == "table" then raw = t end + elseif plat.balance.total_fn then + -- 只有总量的平台:全部算"永久"(无到期信息 ⇒ 不会凭空造出临时档) + local okf, total = pcall(plat.balance.total_fn, rec) + if okf and type(total) == "number" then + raw = { permanent_balance = total, expiring_balance = 0 } + end + end + end + cache[k] = { at = now, raw = raw } + return raw +end + +-- rankAccounts(provider):按"积分何时作废"优先排序该平台的账号。 +-- 返回 {usable={...}, all={...}}。查询失败的账号归入最后一档(照参考)。 +local function rankAccounts(provider) + local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits + local allowPermanent = not lockPermanent + local expiring, permanent, none = {}, {}, {} + for _, a in ipairs(poolList(provider)) do + if a.enabled ~= false then + local raw = accountBalanceRaw(provider, a.id) + local tier + if raw == nil then + tier = "none" -- 查不到 → 最后(不编造) + else + tier = balanceTier(raw.expiring_balance or raw.expiringBalance, + raw.permanent_balance or raw.permanentBalance, allowPermanent) + end + if tier == "expiring" then expiring[#expiring+1] = a + elseif tier == "permanent" then permanent[#permanent+1] = a + else none[#none+1] = a end + end + end + local all = {} + for _, a in ipairs(expiring) do all[#all+1] = a end + for _, a in ipairs(permanent) do all[#all+1] = a end + for _, a in ipairs(none) do all[#all+1] = a end + -- 可用 = 临时档(永远)+ 永久档(仅未锁定时) + local usable = {} + for _, a in ipairs(expiring) do usable[#usable+1] = a end + if allowPermanent then + for _, a in ipairs(permanent) do usable[#usable+1] = a end + end + return { usable = usable, all = all, expiring = #expiring, + permanent = #permanent, none = #none, + locked = lockPermanent and true or false } +end + -- 已失效模型(照参考 dead-model-store.ts) -- ============================================================================ -- 解决的真实报障:用户选中某平台的兜底模型,每轮都 404 —— @@ -5797,7 +5990,7 @@ end -- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances) -- ============================================================================ -- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。 -local function fetchAccountJSON(spec, tok, credTable, plat) +fetchAccountJSON = function(spec, tok, credTable, plat) local headers -- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。 -- buddy 在平台层声明一次,签到与余额复用同一套身份头。 @@ -8216,6 +8409,65 @@ function plugin.__store_dir_probe() return { json = { store_dir = storeDir() } } end +-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。 +-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则 +-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。 +function plugin.credits_tier_probe(args) + args = args or {} + local provider = tostring(args.platform or "") + local accountId = tostring(args.account_id or "") + local raw = accountBalanceRaw(provider, accountId) + if raw == nil then return { json = { error = "余额不可用" } } end + local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits + local allowPermanent = not lockPermanent + local tier = balanceTier(raw.expiring_balance, raw.permanent_balance, allowPermanent) + return { json = { + expiring = raw.expiring_balance or 0, + permanent = raw.permanent_balance or 0, + tier = tier, + usable = tierUsable(tier), + locked = lockPermanent and true or false, + window_days = expiringWindowDays(), + } } +end + +-- plugin.__set_lock_permanent_probe:只给测试用 —— 设置"锁定永久积分"开关。 +function plugin.__set_lock_permanent_probe(args) + plugin.state.settings = plugin.state.settings or {} + plugin.state.settings.lock_permanent_credits = (args or {}).lock and true or false + return { json = { ok = true, locked = plugin.state.settings.lock_permanent_credits } } +end + +-- plugin.__set_expiring_window_probe:只给测试用 —— 覆盖"临时积分"窗口天数。 +-- 用环境变量实现(与生产同一路径,避免测试走旁路而与真实行为脱节)。 +function plugin.__set_expiring_window_probe(args) + local d = tonumber((args or {}).days) + if d == nil then return { json = { error = "缺 days" } } end + plugin.state.settings = plugin.state.settings or {} + plugin.state.settings.expiring_window_days = d + -- 窗口变了必须让余额缓存失效:缓存的是**原料**(包列表),而分桶是每次现算的, + -- 但原料里的"有效/失效"判定也依赖窗口吗?不依赖 —— 依赖窗口的只有分桶。 + -- 清缓存是为了让判据能立即看到新窗口的效果(生产里 60s 后自然过期)。 + plugin.state.balanceCache = {} + return { json = { ok = true, days = expiringWindowDays() } } +end + +-- plugin.__rank_accounts_probe:只给测试用 —— 暴露余额分档选号的结果。 +function plugin.__rank_accounts_probe(args) + args = args or {} + local r = rankAccounts(tostring(args.platform or "")) + local ids = {} + for _, a in ipairs(r.all or {}) do ids[#ids+1] = a.id end + return { json = { expiring = r.expiring, permanent = r.permanent, + none = r.none, locked = r.locked, all = ids, + usable_n = #(r.usable or {}) } } +end + +-- plugin.__expiring_window_probe:只给测试用 —— 读当前生效的窗口天数。 +function plugin.__expiring_window_probe() + return { json = { days = expiringWindowDays() } } +end + -- plugin.__is_model_gone_probe:只给测试用 —— 暴露失效判据本体。 -- 判据必须能被逐条钉住:这些模式的误判代价是把**可用模型藏 30 天**。 function plugin.__is_model_gone_probe(args)