feat(channels): Qoder 每日活动领取(多活动逐个领)+ 三条误报陷阱全部钉死

## 参考依据
`qoder-credits.ts` 的 `claimQoderDailyCheckin` / `parseQoderCampaigns` /
`claimQoderCampaign` / `hasQoderCampaignRefreshedToday`。
参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在实现与判据里钉死:

### ① 「无可领活动」必须是 inactive,不能报 already-claimed
旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项(含**请求头不完整**、
未到刷新时间、本就无活动三种情形),界面就谎报已领,与官方 IDE 的"可领取"直接矛盾。
用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」。

### ② 每日 10:00(UTC+8)刷新;刷新前的 CLAIMED 属于**昨天**
此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。
必须用**算术平移**判 UTC+8 —— 用本机时区在出差/改时区时会判错方向
(偏东提前把当天记为已处理、真漏领;偏西一天判两次)。

### ③ 未开通要给 action_required 的可操作提示
判据是**两条同时**满足(无 CLAIM_BENEFIT + 用量响应里 `addOnQuota` **字段缺失**)。
⚠️ 用「字段是否存在」而非「remaining 是否为 0」—— 后者对"额度用光"与"从未开通"
不可区分,会把用光额度的老账号误报成未开通。文案要可操作(去官方客户端登录一次),
并作为显式字段透传给 UI,而不是让 UI 猜文案。

### 其他要点
- 一个账号可能同时有**多个** CLAIM_BENEFIT 活动 → 逐个领取(实测有每日 100 与运营活动)
- 幂等判据是响应体的 **`replayed`**,不是 HTTP 状态码:重复领取同样回 200,
  但 `replayed:true` 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100")
- 请求体必须是**空串**(抓包实测 `content-length: 0`)
- 缺 `Cosy-MachineToken/MachineType` 时服务端不下发可领活动(见上一个提交)

## 判据
- `TestQoderClaimDecisionTable`:6 个用例覆盖四类结果 + claim 次数 + body 必须为空
- `TestQoderRefreshUsesUTC8NotLocalTime`:**注入 UTC 时刻**验证刷新判定

★ 修判据自身两处缺陷(首轮变异存活):
1. `TestMain` 把测试进程 TZ 钉成 **UTC** —— 本机恰是 HKT(+0800),于是
   「UTC 平移」与「本机时区」结果**完全相同**,误用本机时区的实现抓不到(判据不可达)。
2. 决策表缺「无任何活动且已开通(addOnQuota 存在)」用例 →
   把 inactive 误报成 already-claimed 的变异得以存活。补齐后两个变异都被抓。

## 统一前向声明(Lua 作用域,本文件第 N 次)
把 `qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion`
统一在 PLATFORMS 之前前向声明。此前逐个补、位置还放错过一次,导致运行时报
`attempt to call global 'x' (a nil value)` 而编译期静默。

## 文档
`docs/channels-parity.md` 更新:gemini-sigstore 标注为**⛔ 不适用**
(llmsproxy 的推理走无状态适配器,插件不参与推理 —— 插件侧缓存会成为死代码;
若将来要支持,位置在适配器侧,与"适配器无状态"约定冲突,须单独决策)。
This commit is contained in:
JianFeeeee
2026-10-13 16:11:11 +08:00
parent 5b433b3266
commit c1733feeaa
4 changed files with 492 additions and 16 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" }
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" }
@ -76,10 +76,13 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda
-- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。
-- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua
-- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。
-- 前向声明:qoder 平台表里的 headers_fn 要在运行时调用它,而真实定义在
-- 文件后段。Lua 的 local 作用域看不到后面的定义 —— 必须在**使用点之前**声明
-- (此处就是 PLATFORMS 表之前),否则 headers_fn 里拿到 nil(真实踩过)。
local qoderMachineIdentity
-- 前向声明:本文件里**所有**会被「更早定义的代码」引用的 local 助手都要在此
-- 统一占坑。Lua 的 local 作用域只覆盖其后的代码 —— 漏一个就是运行时的
-- "attempt to call global 'x' (a nil value)",而编译期完全静默(本文件已踩多次:
-- qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion /
-- traeCallback 各一次)。
local qoderMachineIdentity, rfc3986Escape, newToken
local lobsteraiClientVersion
local PLATFORMS = {
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
@ -1009,6 +1012,223 @@ local function codeartsClaim(plat, tok, args)
return { done = true, kind = "success", message = msg }
end
-- ---- Qoder 每日活动领取(照参考 qoder-credits.ts 逐行移植)----
--
-- 参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在此钉死:
-- ① 「无可领活动」必须报 inactive,**不能**报 already-claimed。
-- 旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项
-- (含请求头不完整 / 未到刷新时间 / 本就无活动)界面就谎报已领,
-- 与官方 IDE 的"可领取"直接矛盾(用户报障原文:
-- 「没领过却显示今日已领取,去 IDE 看还能领」)。
-- ② 活动每日 **10:00(UTC+8)**刷新。刷新**之前**看到的 CLAIMED 属于
-- **昨天** —— 此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。
-- 必须用**算术平移**判 UTC+8,不能用本机时区(出差/改时区会判错)。
-- ③ 未开通的账号要给出**可操作**提示(去官方客户端登录一次),
-- 而不是含糊的"没有可领取的活动"。
-- 另:一个账号可能同时有**多个** CLAIM_BENEFIT 活动,逐个领取而非只领第一个。
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
QODER_SASH_BASE = "https://openapi.qoder.sh"
-- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。
local QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000
local QODER_REFRESH_HOUR_UTC8 = 10
-- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。
local function qoderCampaignRefreshed(nowMs)
-- nowMs 可注入(判据要覆盖"刷新前/刷新后"两个方向;本机恰是 UTC+8,
-- 只用真实时刻会让「用本机时区判」这个变异在这台机上不可判定 —— 判据不可达)。
nowMs = nowMs or ((os.time and os.time() or 0) * 1000)
-- **必须用算术平移**判 UTC+8,不能用 os.date("*t")(本机时区):
-- 活动按 UTC+8 结算,取本机时区在出差/改时区时会判错方向
-- (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。
local utc8 = os.date("!*t", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
return (utc8.hour or 0) >= QODER_REFRESH_HOUR_UTC8
end
-- 读数字/字符串(容忍字符串形式与缺失),照参考 readNumber/readString
local function qReadNum(src, key)
if type(src) ~= "table" then return nil end
local v = src[key]
if type(v) == "number" then return v end
if type(v) == "string" and v ~= "" then return tonumber(v) end
return nil
end
local function qReadStr(src, key)
if type(src) ~= "table" then return nil end
local v = src[key]
if type(v) == "string" and v ~= "" then return v end
return nil
end
-- parseQoderCampaigns:解析活动列表响应
local function qoderParseCampaigns(body)
if type(body) ~= "table" or type(body.campaigns) ~= "table" then
-- 形状非法时仍可取 showCampaign/claimable(参考对非数组容忍)
if type(body) ~= "table" then return nil end
end
local out = { campaigns = {}, claimable = body.claimable == true, showCampaign = body.showCampaign == true }
for _, it in ipairs(body.campaigns or {}) do
local id = qReadStr(it, "campaignId")
if id then
out.campaigns[#out.campaigns + 1] = {
campaignId = id,
actionType = qReadStr(it, "actionType"),
claimStatus = qReadStr(it, "claimStatus"),
amount = qReadNum(it.benefit, "amount"),
}
end
end
return out
end
-- qoderLoadCampaigns(plat, cred):拉活动列表,返回 (parsed, nil) 或 (nil, err)
local function qoderLoadCampaigns(plat, cred)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
if not hdrs then
hdrs = { ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""),
["Accept"] = "application/json", ["Cosy-ClientType"] = "10", ["User-Agent"] = "Qoder" }
end
local resp, err = __llmsproxy_http({
url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs,
})
if err then return nil, "活动列表查询失败: " .. tostring(err) end
if not resp or resp.status ~= 200 then
return nil, "活动列表查询失败: HTTP " .. tostring(resp and resp.status)
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "活动列表响应无法解析" end
return qoderParseCampaigns(rec), nil
end
-- qoderUsageRaw(plat, cred):取用量原始响应(判"未开通"用)
local function qoderUsageRaw(plat, cred)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
local resp = nil
if hdrs then
resp, _ = __llmsproxy_http({ url = QODER_SASH_BASE .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs })
end
if not resp or resp.status ~= 200 then return nil end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil end
return rec
end
-- qoderClaimCampaign(plat, cred, campaignId):领取单个活动。
-- ⚠️ 幂等判据是响应体的 `replayed`,**不是** HTTP 状态码:重复领取同样回 200,
-- 但 replayed=true 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100")。
-- ⚠️ 请求体必须是**空串**(抓包实测 content-length: 0)。
local function qoderClaimCampaign(plat, cred, campaignId)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
if not hdrs then hdrs = { ["Cosy-ClientType"] = "10" } end
hdrs["Content-Type"] = "application/json"
local resp, err = __llmsproxy_http({
url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns/"
.. rfc3986Escape(campaignId) .. "/claim",
method = "POST", headers = hdrs, body = "",
})
if err then return { kind = "failed", message = tostring(err) } end
if not resp or resp.status ~= 200 then
return { kind = "failed", message = "领取失败: HTTP " .. tostring(resp and resp.status) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return { kind = "failed", message = "领取响应无法解析" } end
if rec.replayed == true then return { kind = "already-claimed", message = "今天已领取" } end
local st = rec.status
if st ~= nil and st ~= "CLAIMED" then
return { kind = "failed", message = "领取未成功(status=" .. tostring(st) .. ")" }
end
local amount = qReadNum(rec, "amount") or qReadNum(rec.benefit, "amount") or 0
return { kind = "claimed", amount = amount }
end
-- qoderClaim(plat, tok, args):完整活动领取(多活动逐个领)。
local function qoderClaim(plat, tok, args)
local cred = (args and args.credential) or nil
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
local parsed, lerr = qoderLoadCampaigns(plat, cred)
if not parsed then return { done = true, kind = "failed", message = lerr } end
local refreshed = qoderCampaignRefreshed()
local targets = {}
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMABLE" then
targets[#targets + 1] = c
end
end
if #targets == 0 then
-- ① 领过?—— 但**仅限刷新之后**(刷新前那条 CLAIMED 属于昨天)
local claimedBefore = false
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMED" then claimedBefore = true break end
end
if claimedBefore then
if not refreshed then
return { done = true, kind = "inactive",
message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新),"
.. "当前看到的是昨天那一轮,请稍后再来领取。" }
end
return { done = true, kind = "already-claimed", message = "今天已领取" }
end
-- ② 未开通?(两条判据同时满足,避免把"正在刷新"误报成未开通)
local usage = qoderUsageRaw(plat, cred)
local hasBenefit = false
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" then hasBenefit = true break end
end
local addOnMissing = false
if type(usage) == "table" then
-- ⚠️ 判「字段是否存在」而非「remaining 是否为 0」:
-- 后者对"额度用光"与"从未开通"不可区分。
local d = type(usage.data) == "table" and usage.data or usage
addOnMissing = (d.addOnQuota == nil)
end
if (not hasBenefit) and addOnMissing then
return { done = true, kind = "inactive", action_required = true,
message = "该账号尚未在 Qoder 侧开通每日领取(每日 100 Credits)。"
.. "请先用 Qoder 官方客户端登录一次该账号,开通后再回来领取。" }
end
return { done = true, kind = "inactive", message = "当前没有可领取的活动" }
end
-- 逐个领取
local total, firstErr, anyClaimed, allReplayed = 0, nil, false, true
for _, t in ipairs(targets) do
local r = qoderClaimCampaign(plat, cred, t.campaignId)
if r.kind == "claimed" then
anyClaimed = true
allReplayed = false
total = total + (tonumber(r.amount) or 0)
elseif r.kind == "already-claimed" then
-- 保持 allReplayed(这个活动此前已领)
else
allReplayed = false
if not firstErr then firstErr = r.message end
end
end
if anyClaimed then
return { done = true, kind = "success", message = "已领取 " .. tostring(total) .. " Credits" }
end
if allReplayed then
return { done = true, kind = "already-claimed", message = "今天已领取" }
end
return { done = true, kind = "failed", message = firstErr or "领取失败" }
end
-- ---- 有道 LobsterAI 每日活动领取(照参考 lobsterai-credits.ts 逐行移植)----
--
-- 三步流程,判定顺序把「业务正常状态」与「真失败」严格分开:
@ -1141,14 +1361,6 @@ qoderMachineIdentity = function()
return id
end
-- 前向声明:本段(活动领取)要用到的这几个助手都定义在文件后段。
-- Lua 的 local 作用域**看不到后面的定义**,不声明就是
-- "attempt to call global 'xxx' (a nil value)"(本文件已在这上面踩过多次:
-- traeCallback / lobsteraiClientVersion / rfc3986Escape 各一次)。
-- 注意:json / newToken / hexToBin 之类的全局(内核注入或全局函数)不需要。
-- newToken 也在此列:幂等键要用它。
local lobsteraiClientVersion, rfc3986Escape, newToken
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
LOBSTERAI_ACTIVITY_BASE = "https://lobsterai-server.youdao.com"
@ -1256,6 +1468,12 @@ do
break
end
end
for _, p in ipairs(PLATFORMS) do
if p.id == "qoder" then
p.claim_fn = function(plat, tok, args) return qoderClaim(plat, tok, args) end
break
end
end
end
@ -4170,8 +4388,11 @@ 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))
return { status = 200, json = { ok = spec.kind == "success",
outcome = { kind = spec.kind, message = spec.message } } }
local outcome = { kind = spec.kind, message = spec.message }
-- action_required 必须透传:它是给 UI 的**显式信号**("这一步需要用户去
-- 官方客户端操作"),不能靠文案猜。参考 qoder-credits 明确定义了这个字段。
if spec.action_required then outcome.action_required = true end
return { status = 200, json = { ok = spec.kind == "success", outcome = outcome } }
end
local resp, err = __llmsproxy_http(spec)
@ -5116,6 +5337,10 @@ function plugin.set_endpoints(req)
end
-- client_version:有道 exchange/续期/签到的必填版本号来源。测试需把它指到假上游;
-- 同时清掉已缓存值(否则上一次测试的值会跨用例泄漏)。
if type(args.qoder_sash_base) == "table" and args.qoder_sash_base.url then
QODER_SASH_BASE = tostring(args.qoder_sash_base.url)
changed[#changed+1] = "qoder_sash_base.url"
end
if type(args.lobster_activity_base) == "table" and args.lobster_activity_base.url then
LOBSTERAI_ACTIVITY_BASE = tostring(args.lobster_activity_base.url)
changed[#changed+1] = "lobster_activity_base.url"
@ -5795,6 +6020,13 @@ end
-- plugin.__qoder_machine_probe:只给测试用 —— 走真实的 headers_fn 路径,
-- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。
-- plugin.__qoder_refresh_probe(nowMs):只给测试用 —— 用**注入的时刻**验证
-- UTC+8 刷新判定(本机恰是 UTC+8,否则无法区分"算术平移"与"本机时区")。
function plugin.__qoder_refresh_probe(args)
local ms = tonumber((type(args) == "table" and args.now_ms) or 0) or 0
return { json = { refreshed = qoderCampaignRefreshed(ms) } }
end
function plugin.__qoder_machine_probe(args)
local ph = nil
for _, p in ipairs(PLATFORMS) do