mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): 有道每日活动领取(3 步流程)+ 修 claim_daily 前置条件
## 参考依据
`lobsterai-credits.ts:claimLobsteraiDailyCheckin`。此前只实现了余额查询
(profile-summary),**每日活动领取缺失** —— 用户的余额能看,活动奖励领不到。
## 实现(逐行对齐参考的判定顺序)
① `GET /api/client-activities/slot?placement=desktop_sidebar&clientVersion=…
&containerApiVersion=2&platform=win32`
- 查询失败 → failed;`slotState≠available` 或无 `activityCode` → **inactive**
② `GET /api/client-activities/{code}/context?configRevision=…`
- 查询失败 → failed;`claimedToday` → already-claimed;`actions` 不含 `check_in` → inactive
③ `POST /api/client-activities/{code}/actions/check_in`
body 带 `configRevision` 与**客户端幂等键**(对齐参考的 uuid4)
积分字段三级回退:`creditsGranted` → `rewardCredits` → `credits`
参考注释点名:**无可用活动是正常业务状态**,不能显示成"领取失败"(否则用户会去查
网络/账号,方向全错)。这条在判据里被钉死(含反向变异)。
## 顺带修掉一个阻断性缺陷
`claim_daily` 原本硬要求 `plat.checkin`,而**有道根本没声明 checkin** ⇒ 新钩子
永远走不到(测试直接暴露:返回"平台未声明 checkin")。改为「有 claim_fn 即放行」
(codearts 的 4 步流程同样受益)。通用路径对 `plat.checkin` 的取值也加了保护。
## Lua 前向引用(本文件第 N 次踩)
新代码段用到定义在文件后段的 `local` 助手,Lua 作用域看不到后面的定义:
`lobsteraiClientVersion` / `rfc3986Escape` / `newToken` 依次报
`attempt to call global 'x' (a nil value)`。统一在段首前向声明
(改为赋值形式)。这类错误只在运行时暴露,编译期完全静默。
## 判据
`TestLobsterActivityClaimDecisionTable`:5 个用例覆盖决策表四类结果
(inactive×2 / already-claimed / success)+ 「claim 是否该发出」+ 「body 不得为空」。
变异验证:把 inactive 误报为 failed、忽略 claimedToday —— 两个都被抓。
## 可测性
`LOBSTERAI_ACTIVITY_BASE` 可由 `set_endpoints` 覆盖(测试指到假上游)。
This commit is contained in:
@ -986,6 +986,114 @@ local function codeartsClaim(plat, tok, args)
|
||||
return { done = true, kind = "success", message = msg }
|
||||
end
|
||||
|
||||
-- ---- 有道 LobsterAI 每日活动领取(照参考 lobsterai-credits.ts 逐行移植)----
|
||||
--
|
||||
-- 三步流程,判定顺序把「业务正常状态」与「真失败」严格分开:
|
||||
-- ① 槽位查询失败 → failed;slotState≠available 或无 activityCode → inactive
|
||||
-- ② 上下文查询失败 → failed;claimedToday → already-claimed;
|
||||
-- actions 不含 check_in → inactive
|
||||
-- ③ POST .../{activityCode}/actions/check_in(带客户端幂等键)→ 成功
|
||||
-- 参考注释特别强调:无可用活动是**正常业务状态**,不能显示成"领取失败"。
|
||||
-- 前向声明:本段(活动领取)要用到的这几个助手都定义在文件后段。
|
||||
-- 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"
|
||||
|
||||
local function lobsteraiActivityHeaders(cred)
|
||||
return {
|
||||
["Authorization"] = "Bearer " .. tostring(cred.access_token or ""),
|
||||
["Accept"] = "application/json",
|
||||
["Content-Type"] = "application/json",
|
||||
["User-Agent"] = "LobsterAI/0.1.0",
|
||||
}
|
||||
end
|
||||
|
||||
-- lobsteraiClaim(plat, tok, args):完整 3 步领取。
|
||||
-- 与 codearts 同款:自己发多个请求,用 done 哨兵回给 claim_daily。
|
||||
local function lobsteraiClaim(plat, tok, args)
|
||||
local cred = (args and args.credential) or nil
|
||||
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
|
||||
local hdrs = lobsteraiActivityHeaders(cred)
|
||||
-- clientVersion 必须是**动态真值**(与交换/续期/签到同一口径)
|
||||
local cver = lobsteraiClientVersion(plat)
|
||||
|
||||
-- ① 槽位
|
||||
local slotURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/slot?placement=desktop_sidebar"
|
||||
.. "&clientVersion=" .. rfc3986Escape(cver)
|
||||
.. "&containerApiVersion=2&platform=win32"
|
||||
local sresp, serr = __llmsproxy_http({ url = slotURL, method = "GET", headers = hdrs })
|
||||
if serr or not sresp or sresp.status ~= 200 then
|
||||
return { done = true, kind = "failed",
|
||||
message = "活动槽位查询失败: " .. tostring(serr or (sresp and sresp.status)) }
|
||||
end
|
||||
local sok, srec = pcall(json.decode, sresp.body or "")
|
||||
if not sok or type(srec) ~= "table" or srec.code ~= 0 or type(srec.data) ~= "table" then
|
||||
return { done = true, kind = "failed", message = "活动槽位信封异常" }
|
||||
end
|
||||
local act = type(srec.data.activity) == "table" and srec.data.activity or {}
|
||||
local slotState = tostring(srec.data.slotState or "")
|
||||
local activityCode = tostring(act.activityCode or "")
|
||||
local configRevision = tonumber(act.configRevision) or 0
|
||||
if slotState ~= "available" or activityCode == "" then
|
||||
-- 正常业务状态,不是失败
|
||||
return { done = true, kind = "inactive", message = "无可用活动(slotState=" .. slotState .. ")" }
|
||||
end
|
||||
|
||||
-- ② 上下文
|
||||
local ctxURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/"
|
||||
.. rfc3986Escape(activityCode) .. "/context?configRevision=" .. tostring(configRevision)
|
||||
local cresp, cerr = __llmsproxy_http({ url = ctxURL, method = "GET", headers = hdrs })
|
||||
if cerr or not cresp or cresp.status ~= 200 then
|
||||
return { done = true, kind = "failed",
|
||||
message = "活动上下文查询失败: " .. tostring(cerr or (cresp and cresp.status)) }
|
||||
end
|
||||
local cok, crec = pcall(json.decode, cresp.body or "")
|
||||
if not cok or type(crec) ~= "table" or crec.code ~= 0 or type(crec.data) ~= "table" then
|
||||
return { done = true, kind = "failed", message = "活动上下文信封异常" }
|
||||
end
|
||||
local state = type(crec.data.state) == "table" and crec.data.state or {}
|
||||
if state.claimedToday == true then
|
||||
return { done = true, kind = "already-claimed", message = "今天已签到" }
|
||||
end
|
||||
local hasCheckIn = false
|
||||
for _, a in ipairs(crec.data.actions or {}) do
|
||||
if a == "check_in" then hasCheckIn = true break end
|
||||
end
|
||||
if not hasCheckIn then
|
||||
return { done = true, kind = "inactive", message = "当前不可签到" }
|
||||
end
|
||||
|
||||
-- ③ 领取(客户端幂等键)
|
||||
local body = json.encode({ configRevision = configRevision,
|
||||
idempotencyKey = newToken():sub(1, 8) .. "-" .. newToken():sub(9, 12) .. "-4"
|
||||
.. newToken():sub(14, 16) .. "-8" .. newToken():sub(18, 20) .. "-" .. newToken():sub(21, 32),
|
||||
payload = {} })
|
||||
local url = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/"
|
||||
.. rfc3986Escape(activityCode) .. "/actions/check_in"
|
||||
local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = hdrs, body = body })
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
return { done = true, kind = "failed",
|
||||
message = "领取请求失败: " .. tostring(err or (resp and resp.status)) }
|
||||
end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" or rec.code ~= 0 then
|
||||
local why = (ok and type(rec) == "table") and tostring(rec.msg or rec.message or rec.code) or "信封异常"
|
||||
return { done = true, kind = "failed", message = "领取失败: " .. why }
|
||||
end
|
||||
-- 积分字段三级回退(参考:creditsGranted → rewardCredits → credits)
|
||||
local result = type(rec.data) == "table" and type(rec.data.result) == "table" and rec.data.result or {}
|
||||
local credit = tonumber(result.creditsGranted) or tonumber(result.rewardCredits) or tonumber(result.credits)
|
||||
local msg = "已签到"
|
||||
if credit then msg = msg .. ",获得 " .. tostring(credit) .. " 积分" end
|
||||
return { done = true, kind = "success", message = msg }
|
||||
end
|
||||
|
||||
-- 绑定 codearts 的领取钩子。它自己跑完 4 步流程(账户→活动→claim→confirm),
|
||||
-- 用 done 哨兵返回,claim_daily 识别后不再发送单个请求。
|
||||
do
|
||||
@ -995,6 +1103,12 @@ do
|
||||
break
|
||||
end
|
||||
end
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
if p.id == "lobsterai" then
|
||||
p.claim_fn = function(plat, tok, args) return lobsteraiClaim(plat, tok, args) end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@ -1102,7 +1216,7 @@ end
|
||||
-- 认证头前缀是 `account {ak}:{sig}`(**不是** Bearer)。
|
||||
|
||||
-- rfc3986Escape:encodeURIComponent 等价 + 补转 ! ' ( ) *
|
||||
local function rfc3986Escape(v)
|
||||
rfc3986Escape = function(v)
|
||||
v = tostring(v or "")
|
||||
v = v:gsub("([^A-Za-z0-9%-%._~])", function(c)
|
||||
return string.format("%%%02X", string.byte(c))
|
||||
@ -2111,7 +2225,7 @@ end
|
||||
-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。
|
||||
local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理
|
||||
|
||||
local function newToken()
|
||||
newToken = function()
|
||||
-- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex)
|
||||
return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels"))
|
||||
end
|
||||
@ -3274,7 +3388,7 @@ end
|
||||
-- - 远程成功 → 用远程值并缓存;
|
||||
-- - 失败 → 用兜底值但**不缓存**(缓存会让一次瞬时故障在 TTL 内持续发酵)。
|
||||
-- **绝不能写死**:实测 2026-10-13 上游已是 2026.9.23。
|
||||
local function lobsteraiClientVersion(plat)
|
||||
lobsteraiClientVersion = function(plat)
|
||||
local cv = plat.client_version
|
||||
if type(cv) ~= "table" then return "2026.9.4" end
|
||||
local cache = plugin.state.clientVersion
|
||||
@ -3861,7 +3975,10 @@ function plugin.claim_daily(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
if not plat.checkin or not plat.checkin.url then
|
||||
-- 有 claim_fn 的平台即使没声明 checkin 端点也能领(如 codearts 的 4 步流程、
|
||||
-- 有道的 3 步活动领取 —— 它们自己发多个请求,不走 checkin 表驱动)。
|
||||
-- 此前这里硬要求 plat.checkin,导致这两个平台的钩子**永远走不到**。
|
||||
if (not plat.checkin or not plat.checkin.url) and not plat.claim_fn then
|
||||
return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } }
|
||||
end
|
||||
-- 账号池路径:claim 某个池账号;否则旧路径单凭据。
|
||||
@ -3885,10 +4002,11 @@ function plugin.claim_daily(req)
|
||||
-- 参考实现的特殊形状(SDK-HMAC-SHA256 签名等)由该平台的 claim 钩子生成
|
||||
-- (见 PLATFORMS 的 claim_fn),这里是通用路径。
|
||||
local spec = {
|
||||
url = plat.checkin.url,
|
||||
method = plat.checkin.method or "POST",
|
||||
headers = plat.checkin.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
|
||||
body = plat.checkin.body or "{}",
|
||||
url = plat.checkin and plat.checkin.url or "",
|
||||
method = (plat.checkin and plat.checkin.method) or "POST",
|
||||
headers = (plat.checkin and plat.checkin.headers)
|
||||
or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
|
||||
body = (plat.checkin and plat.checkin.body) or "{}",
|
||||
}
|
||||
if plat.claim_fn then
|
||||
local custom, cerr = plat.claim_fn(plat, tok, credArgs)
|
||||
@ -4851,6 +4969,10 @@ function plugin.set_endpoints(req)
|
||||
end
|
||||
-- client_version:有道 exchange/续期/签到的必填版本号来源。测试需把它指到假上游;
|
||||
-- 同时清掉已缓存值(否则上一次测试的值会跨用例泄漏)。
|
||||
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"
|
||||
end
|
||||
if type(args.client_version) == "table" and plat.client_version then
|
||||
for _, k in ipairs({ "url", "path", "fallback" }) do
|
||||
if args.client_version[k] then
|
||||
|
||||
Reference in New Issue
Block a user