From 81b3661336958c5c157719e103a1f16c09ad3d4b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 18:34:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E8=87=AA=E5=8A=A8=E9=A2=86?= =?UTF-8?q?=E5=8F=96=E6=AF=8F=E5=A4=A9=E9=9D=99=E9=BB=98=E6=BC=8F=E9=A2=86?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E6=8C=89=20covers=5Ftoday=20=E8=AE=B0?= =?UTF-8?q?=E8=B4=A6=EF=BC=8C=E4=B8=94=E9=97=B4=E9=9A=94=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=202=20=E5=B0=8F=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 真实缺陷(照参考 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 个包全绿;内核非测试代码零厂商名。 --- .../core/channels_autoclaim_cover_test.go | 111 +++++++++++++ internal/core/channels_autoclaim_test.go | 15 +- internal/lua/plugins/channels.lua | 147 +++++++++++++++++- 3 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 internal/core/channels_autoclaim_cover_test.go diff --git a/internal/core/channels_autoclaim_cover_test.go b/internal/core/channels_autoclaim_cover_test.go new file mode 100644 index 0000000..37d8df2 --- /dev/null +++ b/internal/core/channels_autoclaim_cover_test.go @@ -0,0 +1,111 @@ +package core + +import ( + "strings" + "testing" +) + +// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。 +// +// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动 +// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天** +// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也 +// 不去领。 +// +// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答** +// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数 +// 「有几条能证明今天已被处理」。 +func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) { + _, ps := poolCore(t) + + // ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修) + out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{ + "refreshed": false, + }) + if err != nil { + t.Fatalf("探针: %v", err) + } + j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) + if j == nil || j["kind"] == nil { + t.Fatalf("探针应返回 kind,实得 %#v", out) + } + if j["covers_today"] != false { + t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"]) + } + if j["kind"] == "already-claimed" { + t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)") + } + + // ② 刷新后:正常报已领,且 covers_today=true + out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{ + "refreshed": true, + }) + j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{}) + if j2["covers_today"] != true { + t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"]) + } + + // ③ 记账判据必须**只看 covers_today**,不能用 kind 反推 + //(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹) + cases := []struct { + name string + kind string + cov interface{} + want bool + }{ + {"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false}, + {"刷新后的 already-claimed 算已处理", "already-claimed", true, true}, + {"success 算已处理", "success", true, true}, + {"covers_today 缺省为 true", "claimed", nil, true}, + {"inactive 不算已处理", "inactive", true, false}, + {"failed 不算已处理", "failed", true, false}, + } + for _, c := range cases { + o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{ + "kind": c.kind, "covers_today": c.cov, + }) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + oj, _ := o.(map[string]interface{})["json"].(map[string]interface{}) + if got, _ := oj["covers"].(bool); got != c.want { + t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got) + } + } +} + +// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。 +func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) { + _, ps := poolCore(t) + out, err := ps.Call("channels", "__schedule_probe", nil) + if err != nil { + t.Fatalf("探针: %v", err) + } + j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) + every, _ := j["auto_claim_every"].(float64) + if every <= 0 { + t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j) + } + // 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8), + // 一天只跑一次就必然有平台漏领。 + if every >= 86400 { + t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every) + } + if j["first_due_now"] != true { + t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)") + } + // 已覆盖今天的平台必须靠记账跳过,否则会重复打上游 + if !strings.Contains(toString(j["has_coverage_key"]), "true") { + t.Error("需要「今天已覆盖」的持久化记账键") + } +} + +func toString(v interface{}) string { + if b, ok := v.(bool); ok { + if b { + return "true" + } + return "false" + } + return "" +} diff --git a/internal/core/channels_autoclaim_test.go b/internal/core/channels_autoclaim_test.go index e3168b1..84d7b7e 100644 --- a/internal/core/channels_autoclaim_test.go +++ b/internal/core/channels_autoclaim_test.go @@ -106,9 +106,18 @@ func TestAutoClaimScheduledWithModelRefresh(t *testing.T) { } return "" } - // auto_claim 是每日任务,**不该** first_due_now(重启就重领是错的) - if ln := lineOf("auto_claim"); strings.Contains(ln, "first_due_now") { - t.Errorf("auto_claim 不该 first_due_now(每日任务重启后重领是错的):%s", ln) + // auto_claim **现在**也 first_due_now,且间隔远小于一天。 + // + // ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务, + // 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。 + // 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次 + // 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半: + // ① 频率提高 + first_due_now(任何时刻重启都能补上); + // ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**, + // 而不是靠"一天只跑一次"来碰巧避开)。 + // 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。 + if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") { + t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln) } // 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈) for _, fn := range []string{"refresh_models", "refresh_creds"} { diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 9891e9c..a213eb8 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", "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