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:
JianFeeeee
2026-10-13 15:44:51 +08:00
parent 1586a051da
commit d1b7d94043
2 changed files with 240 additions and 8 deletions

View File

@ -0,0 +1,110 @@
package core
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"llmsproxy/internal/lua"
)
// 有道每日活动领取是**三步流程**,参考注释点名:无可用活动是**正常业务状态**,
// 不能显示成"领取失败"(否则用户会去查网络/账号,方向全错)。
// 本判据钉住决策表的四类结果:inactive / already-claimed / success / failed。
func TestLobsterActivityClaimDecisionTable(t *testing.T) {
type c struct {
name string
slot string
ctx string
wantKind string
wantCalled bool // 是否应发出 claim 请求
}
cases := []c{
{"槽位不可用→inactive", `{"code":0,"data":{"slotState":"unavailable","activity":{"activityCode":"A1"}}}`, "", "inactive", false},
{"无 activityCode→inactive", `{"code":0,"data":{"slotState":"available","activity":{}}}`, "", "inactive", false},
{"今天已领→already-claimed", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":true},"actions":["check_in"]}}`, "already-claimed", false},
{"无 check_in 动作→inactive", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["share"]}}`, "inactive", false},
{"可领→success", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["check_in"]}}`, "success", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claimHit := 0
var claimBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/client-activities/slot":
fmt.Fprint(w, tc.slot)
case r.URL.Path == "/api/client-activities/A1/context":
if tc.ctx == "" {
w.WriteHeader(500)
return
}
fmt.Fprint(w, tc.ctx)
case r.URL.Path == "/api/client-activities/A1/actions/check_in":
claimHit++
b := make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
claimBody = string(b)
fmt.Fprint(w, `{"code":0,"data":{"result":{"creditsGranted":30}}}`)
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 把账号基址指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "lobsterai",
"lobster_activity_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 存入凭据(claim 需要结构化 credential 对象)
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
body, _ := json.Marshal(map[string]string{
"platform": "lobsterai", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"lobsterai"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome 判别联合,实得 %v", j)
}
if k, _ := oc["kind"].(string); k != tc.wantKind {
t.Fatalf("kind 应为 %s,实得 %v(%v)", tc.wantKind, oc["kind"], oc["message"])
}
if (claimHit > 0) != tc.wantCalled {
t.Errorf("claim 请求次数 = %d,期望发出=%v", claimHit, tc.wantCalled)
}
if tc.wantCalled && claimBody == "" {
t.Error("claim 请求体不应为空(要带 configRevision 与幂等键)")
}
})
}
}

View File

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