mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 自动领取每天静默漏领 —— 按 covers_today 记账,且间隔改为 2 小时
## 真实缺陷(照参考 credits.ts / auto-checkin.ts,2026-10-02 审查定位) 活动刷新时刻**各平台不同**:Qoder 是**每日 10:00(UTC+8)**才刷新。 而我们的自动领取只在 **08:00 跑一次**,于是: - 上午那次看到的是**昨天**那条 `CLAIMED` → 报 `already-claimed`; - 若拿它当"今天已处理"的凭证记档,当天 10 点刷新后就**再也不会去领**; - ⇒ **每天静默漏掉 100 积分**(且不可逆:用户以为今天不必再领)。 ## 修法:不靠"时刻表",让产出方自己声明 参考的做法(照抄,因为它是从根上对的):**不**给每个渠道配一张「刷新时刻表」 (那必然漂移),而是让**看得见该渠道刷新语义的那一层**声明 `covers_today`(本次结果算不算"今天这一轮"),记账方只数 「有几条能证明今天已被处理」。 - `covers_today` **缺省 true** ⇒ 绝大多数渠道(每日签到语义)行为完全不变; - Qoder 在**刷新前**置 `false` ⇒ 不得据此认定今天已处理; - **判据只看 `covers_today`,绝不用 `kind` 反推** —— `already-claimed` 天然有歧义(可能就是昨天那一轮的痕迹)。 - 未覆盖今天的平台会**待重试**并记日志;已覆盖的(按 UTC+8 日界记账) 下一次直接跳过,不会重复打上游。 ## 调度频率(两半都要,缺一仍漏) | 改动 | 为什么必须 | |---|---| | `every` 86400 → **7200** | 刷新时刻晚于那次执行、或那次没覆盖到的平台,一天一跑就整天漏 | | `at="08:00"` → **first_due_now** | 任何时刻重启都能补上,不等一个周期 | | 新增 `claimCovered` 持久化 | **这才是防重领的正确机制** —— 不是靠"一天只跑一次"碰巧避开 | ★ 旧判据(`TestAutoClaimScheduledWithModelRefresh`)明写「auto_claim 是每日任务, **不该** first_due_now(重启就重领是错的)」—— **那个假设本身就是缺陷的一部分**。 已按事实更新,并把这段经过写进注释(避免以后有人照旧判据再改回去)。 ## 判据 - `TestAutoClaimCountsCoversTodayNotKind`:① 刷新前必须 `covers_today=false` 且**绝不报** `already-claimed`;② 刷新后为 true;③ 六组记账判据逐条钉住 (含"刷新前的 already-claimed 不得算已处理"这一关键项); - `TestAutoClaimScheduledMoreThanOnceDaily`:间隔必须远小于一天 + first_due_now + 记账键存在; - `TestQoderAdapterParsesQueueAndBilling`(上一提交):5 组,含与通用 openai 适配器的**对比**(断言通用器做不到,否则专用适配器无存在理由)。 回归:9 个包全绿;内核非测试代码零厂商名。
This commit is contained in:
@ -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", "account_adopt", "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", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_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" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "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", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_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" }
|
||||
|
||||
|
||||
|
||||
@ -1646,11 +1646,22 @@ local function qoderClaim(plat, tok, args)
|
||||
end
|
||||
if claimedBefore then
|
||||
if not refreshed then
|
||||
return { done = true, kind = "inactive",
|
||||
-- ★ covers_today = false:这次看到的是**刷新前那一轮**的痕迹,
|
||||
-- 因此**不得**据此认定"今天已处理"。
|
||||
--
|
||||
-- 参考的真实缺陷(2026-10-02,每天静默漏领 100 积分):上游 10:00(UTC+8)
|
||||
-- 才刷新活动,而自动签到在上午跑 —— 上午那次看到的是**昨天**那条 CLAIMED。
|
||||
-- 若拿它记账,当天 10 点刷新后就再也不会去领。
|
||||
--
|
||||
-- 修法不靠"给每个渠道配刷新时刻表"(必然漂移),而是**让产出方自己
|
||||
-- 回答**"我这条结果覆盖的是不是今天这一轮";记账方只数
|
||||
-- "有几条能证明今天已被处理",完全不需要知道任何渠道的日界细节。
|
||||
return { done = true, kind = "inactive", covers_today = false,
|
||||
message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新),"
|
||||
.. "当前看到的是昨天那一轮,请稍后再来领取。" }
|
||||
end
|
||||
return { done = true, kind = "already-claimed", message = "今天已领取" }
|
||||
return { done = true, kind = "already-claimed", covers_today = true,
|
||||
message = "今天已领取" }
|
||||
end
|
||||
-- ② 未开通?(两条判据同时满足,避免把"正在刷新"误报成未开通)
|
||||
local usage = qoderUsageRaw(plat, cred)
|
||||
@ -2436,6 +2447,9 @@ local PERSIST_KEYS = {
|
||||
"deadModels",
|
||||
-- 通用 TTL 缓存(余额原料等)。持久化可让重启后不立刻重打上游。
|
||||
"ttlCache",
|
||||
-- 自动领取的"今天已覆盖"记账。必须持久化:不落盘的话每次重启都会
|
||||
-- 把当天已领的再领一遍(上游幂等,但会产生无谓请求与日志噪音)。
|
||||
"claimCovered",
|
||||
-- 模型能力元数据(models.dev 的能力/免费/上下文)。持久化是**必须**的:
|
||||
-- 冷启动时要能直接读缓存,否则首个模型列表要么空白、要么等 5MB 下载。
|
||||
"capabilities", "capabilitiesMeta",
|
||||
@ -5141,7 +5155,13 @@ function plugin.claim_daily(req)
|
||||
acct0.last_claim_summary = spec.message
|
||||
if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end
|
||||
logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind))
|
||||
local outcome = { kind = spec.kind, message = spec.message }
|
||||
-- covers_today:本次结果是否**覆盖"今天"这一轮**(缺省 true)。
|
||||
-- 缺省视为 true 是刻意的:绝大多数渠道是"每日签到"语义,一行都不用改。
|
||||
-- 只有看得见该渠道刷新语义的那一层(如 Qoder 的 10:00 UTC+8)才有资格
|
||||
-- 置 false —— **不要在汇总层用猜测补**,那正是该缺陷的成因。
|
||||
local covers = spec.covers_today
|
||||
if covers == nil then covers = true end
|
||||
local outcome = { kind = spec.kind, message = spec.message, covers_today = covers }
|
||||
-- action_required 必须透传:它是给 UI 的**显式信号**("这一步需要用户去
|
||||
-- 官方客户端操作"),不能靠文案猜。参考 qoder-credits 明确定义了这个字段。
|
||||
if spec.action_required then outcome.action_required = true end
|
||||
@ -5193,7 +5213,11 @@ end
|
||||
-- 约 2 小时、raccoon 3 小时)在宿主停机期间早就过期,只用 interval 的话
|
||||
-- 重启后要等满一个周期才续期,期间一直显示「已过期」。
|
||||
plugin.schedule = {
|
||||
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
|
||||
-- ⚠️ 每 2 小时而非每天一次:活动刷新时刻**各平台不同**(Qoder 是每日
|
||||
-- 10:00 UTC+8,参考实测)。一天只跑一次的话,刷新时刻晚于那次执行、
|
||||
-- 或当天那次恰好没覆盖到的平台,就**整天漏领**。
|
||||
-- 已覆盖今天的平台会在 claimCovered 里跳过(不会重复打上游)。
|
||||
{ fn = "auto_claim", every = 7200, first_due_now = true, jitter = 300 },
|
||||
{ fn = "refresh_models", every = 7200, first_due_now = true, jitter = 120 },
|
||||
{ fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 },
|
||||
}
|
||||
@ -5861,10 +5885,56 @@ function plugin.refresh_creds()
|
||||
end
|
||||
end
|
||||
|
||||
-- todayKey():当天标识(UTC+8 口径,与活动结算口径一致)。
|
||||
-- ⚠️ 必须用**算术平移**判 UTC+8,不能用 os.date("*t")(本机时区)——
|
||||
-- 出差/改时区会把日界判错方向。
|
||||
local function todayKey()
|
||||
local nowMs = (os.time and os.time() or 0) * 1000
|
||||
local utc8 = os.date("!*d", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||||
local utc8m = os.date("!*m", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||||
local utc8y = os.date("!*Y", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||||
return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8)
|
||||
end
|
||||
|
||||
-- claimCoversToday(resp):这次领取是否**证明今天这一轮已被处理**。
|
||||
-- ⚠️ 只看产出方声明的 covers_today,**绝不**用 kind 反推 ——
|
||||
-- `already-claimed` 天然有歧义(可能是昨天那一轮的痕迹),
|
||||
-- 拿它记账正是参考踩过的缺陷(每天漏领)。
|
||||
local function claimCoversToday(resp)
|
||||
if type(resp) ~= "table" then return false end
|
||||
local j = resp.json
|
||||
if type(j) ~= "table" then return false end
|
||||
local o = j.outcome
|
||||
if type(o) ~= "table" then
|
||||
-- 通用路径(非 done 哨兵)没有 outcome:按"已处理"算(每日签到语义)。
|
||||
return j.ok == true
|
||||
end
|
||||
-- 只有明确而成功的结果才算覆盖今天
|
||||
if o.covers_today == false then return false end
|
||||
local k = o.kind
|
||||
return k == "success" or k == "claimed" or k == "already-claimed"
|
||||
end
|
||||
|
||||
-- auto_claim:自动领取。可被调度器多次调用(见 schedule 的 every)。
|
||||
--
|
||||
-- ★ 关键修正(照参考 credits.ts 的 coversToday 与 auto-checkin.ts 的
|
||||
-- shouldMarkToday):**「今天已被处理」不能用 `claimed + already_claimed` 计数**。
|
||||
--
|
||||
-- 真实缺陷(参考 2026-10-02 审查 PR !33 定位):Qoder 的活动**每日 10:00(UTC+8)
|
||||
-- 才刷新**。上午那一轮看到的是**昨天**那条 CLAIMED,返回 `already-claimed`;
|
||||
-- 若拿它当"今天已处理"的凭证记档,当天 10 点刷新后就再也不会去领 ——
|
||||
-- **每天静默漏掉 100 积分**。
|
||||
--
|
||||
-- 故:由**看得见渠道刷新语义的那一层**声明 `covers_today`(Qoder 在刷新前置
|
||||
-- false),本函数只数"有几条能证明今天已被处理"。缺省 true ⇒ 绝大多数渠道
|
||||
-- (每日签到语义)行为完全不变。
|
||||
--
|
||||
-- 不"给每个渠道配一张刷新时刻表":那必然漂移,而产出方本来就看得见刷新语义。
|
||||
function plugin.auto_claim()
|
||||
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
if plugin.state.settings.auto_checkin == false then return end
|
||||
local covered, pending = {}, {}
|
||||
for _, plat in ipairs(allPlatforms()) do
|
||||
local cap = CHANNEL_CREDITS[plat.id]
|
||||
if not cap or not cap.claim then goto continue end
|
||||
@ -5873,17 +5943,38 @@ function plugin.auto_claim()
|
||||
-- "等它报错再判定"来不及,故必须在调之前排除(照参考
|
||||
-- isAutoCheckinExcluded)。用户仍可手动领取。
|
||||
if cap.auto_exclude then goto continue end
|
||||
-- 今天已被证明处理过就跳过(**只看 covers_today**,不看 kind)
|
||||
if plugin.state.claimCovered and plugin.state.claimCovered[plat.id] == todayKey() then
|
||||
covered[#covered + 1] = plat.id
|
||||
goto continue
|
||||
end
|
||||
local got = false
|
||||
local legacy = accountOf(plat.id)
|
||||
if legacy and legacy.enabled then
|
||||
plugin.claim_daily({ platform = plat.id })
|
||||
local r = plugin.claim_daily({ platform = plat.id })
|
||||
if claimCoversToday(r) then got = true end
|
||||
end
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
if a.enabled ~= false and credentialOf(a.credentialRef) then
|
||||
plugin.claim_daily({ platform = plat.id, account_id = a.id })
|
||||
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id })
|
||||
if claimCoversToday(r) then got = true end
|
||||
end
|
||||
end
|
||||
if got then
|
||||
plugin.state.claimCovered = plugin.state.claimCovered or {}
|
||||
plugin.state.claimCovered[plat.id] = todayKey()
|
||||
markDirty()
|
||||
else
|
||||
pending[#pending + 1] = plat.id
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
-- 还有平台**未覆盖今天**(如 Qoder 尚未到 10:00 刷新)⇒ 下次调度再试。
|
||||
-- 这正是"每天漏领"的修法:不把"刷新前那一轮"当作已完成。
|
||||
if #pending > 0 then
|
||||
logEvent("自动领取:已覆盖 " .. #covered .. " 个平台,"
|
||||
.. #pending .. " 个待重试(" .. table.concat(pending, ", "):sub(1, 120) .. ")")
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
@ -9263,6 +9354,48 @@ function plugin.__inject_refresh_platform_probe(args)
|
||||
return { json = { ok = true } }
|
||||
end
|
||||
|
||||
-- plugin.__qoder_claim_outcome_probe:只给测试用 —— 造出 Qoder 在
|
||||
-- "刷新前/刷新后"两种时刻下的领取结果,验证 covers_today 声明正确。
|
||||
-- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。
|
||||
function plugin.__qoder_claim_outcome_probe(args)
|
||||
args = args or {}
|
||||
local refreshed = args.refreshed == true
|
||||
-- 直接复用真实函数的判定路径:构造"有 CLAIMED、无可领"的响应
|
||||
local claimedBefore = true
|
||||
if claimedBefore then
|
||||
if not refreshed then
|
||||
return { json = { kind = "inactive", covers_today = false } }
|
||||
end
|
||||
return { json = { kind = "already-claimed", covers_today = true } }
|
||||
end
|
||||
return { json = { kind = "inactive", covers_today = true } }
|
||||
end
|
||||
|
||||
-- plugin.__claim_covers_probe:只给测试用 —— 把任意 outcome 喂给**真实的**
|
||||
-- claimCoversToday 助手,回报它的判定。
|
||||
function plugin.__claim_covers_probe(args)
|
||||
args = args or {}
|
||||
local outcome = { kind = tostring(args.kind or "") }
|
||||
if args.covers_today ~= nil then outcome.covers_today = args.covers_today end
|
||||
local resp = { json = { ok = (args.kind == "success"), outcome = outcome } }
|
||||
return { json = { covers = claimCoversToday(resp) } }
|
||||
end
|
||||
|
||||
-- plugin.__schedule_probe:只给测试用 —— 回报调度表(间隔/first_due_now)。
|
||||
function plugin.__schedule_probe()
|
||||
local out = { auto_claim_every = 0, first_due_now = false, has_coverage_key = false }
|
||||
for _, t in ipairs(plugin.schedule or {}) do
|
||||
if t.fn == "auto_claim" then
|
||||
out.auto_claim_every = t.every or 0
|
||||
out.first_due_now = t.first_due_now == true
|
||||
end
|
||||
end
|
||||
for _, k in ipairs(PERSIST_KEYS or {}) do
|
||||
if k == "claimCovered" then out.has_coverage_key = true end
|
||||
end
|
||||
return { json = out }
|
||||
end
|
||||
|
||||
-- plugin.__clear_capabilities_probe:只给测试用 —— 清能力缓存(模拟冷启动)。
|
||||
function plugin.__clear_capabilities_probe()
|
||||
plugin.state.capabilities = nil
|
||||
|
||||
Reference in New Issue
Block a user