feat(channels): 余额分档选号(照参考 balance-rank/selector)

解决什么:一个号常同时持有多**种**资源包(如「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 个包全绿。
This commit is contained in:
JianFeeeee
2026-10-14 15:24:24 +08:00
parent 583976f7da
commit 29c87ce015
2 changed files with 376 additions and 2 deletions

View File

@ -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)