diff --git a/internal/core/channels_raccoon_test.go b/internal/core/channels_raccoon_test.go new file mode 100644 index 0000000..1cdb29a --- /dev/null +++ b/internal/core/channels_raccoon_test.go @@ -0,0 +1,165 @@ +package core + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。 +// +// 参考语义(raccoon-oauth.ts 点名): +// +// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code +// ② 轮询 POST /login_with_qrcode_code {qrcode_code} +// → data.status ∈ pending / logging / canceled / success +// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死) +// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status) +type raccoonHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + polls int32 + step int32 // 第几次轮询开始 success + gotCode string +} + +func newRaccoonHarness(t *testing.T) *raccoonHarness { + t.Helper() + h := &raccoonHarness{t: t, step: 3} + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") { + h.polls++ + if h.polls < atomic.LoadInt32(&h.step) { + fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`) + return + } + fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`) + return + } + w.WriteHeader(404) + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "raccoon", + "qr": map[string]interface{}{ + "api_base": h.ts.URL, + "auth_prefix": "/api/web/auth/v1", + "poll_path": "/login_with_qrcode_code", + "login_page": "/login/mp", + }, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +func (h *raccoonHarness) begin() (map[string]interface{}, string) { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"raccoon"}`, Role: "admin", + }) + if err != nil { + h.t.Fatal(err) + } + j := out.JSON.(map[string]interface{}) + return j, j["session"].(map[string]interface{})["id"].(string) +} + +func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} { + out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", + }) + if err != nil { + h.t.Fatal(err) + } + return out.JSON.(map[string]interface{}) +} + +// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。 +func TestRaccoonQrPollBehavior(t *testing.T) { + h := newRaccoonHarness(t) + + t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + j, _ := h.begin() + qr, _ := j["qr_url"].(string) + if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") { + t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr) + } + // code 应是 32 位 hex(本地随机生成) + code := qr[strings.Index(qr, "code=")+5:] + if i := strings.IndexAny(code, "&"); i > 0 { + code = code[:i] + } + if len(code) != 32 { + t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code) + } + }) + + t.Run("行2 logging保持pending", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + atomic.StoreInt32(&h.step, 99) + _, sid := h.begin() + for i := 0; i < 3; i++ { + if st := h.pollOnce(sid); st["state"] != "pending" { + t.Fatalf("logging 应 pending, 实得 %v", st["state"]) + } + } + }) + + t.Run("行3 success且带token才完成", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + atomic.StoreInt32(&h.step, 2) + _, sid := h.begin() + h.pollOnce(sid) + st := h.pollOnce(sid) + if st["state"] != "completed" { + t.Fatalf("success+token 应 completed, 实得 %v", st) + } + v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon") + if !ok || !strings.Contains(v, "RC-AT-1") { + t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v) + } + }) + + t.Run("行4 canceled是终态失败", func(t *testing.T) { + hh := newRaccoonHarness(t) + hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`) + }) + hh.ps.SetSecret("channels", "channels:raccoon", "") + _, sid := hh.begin() + if st := hh.pollOnce(sid); st["state"] != "failed" { + t.Fatalf("canceled 应 failed, 实得 %v", st["state"]) + } + }) + + t.Run("行5 success缺token视为未完成", func(t *testing.T) { + hh := newRaccoonHarness(t) + hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`) + }) + _, sid := hh.begin() + st := hh.pollOnce(sid) + if st["state"] != "pending" { + t.Fatalf("★ 缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"]) + } + }) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 3289b3b..15bd1e9 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -599,22 +599,47 @@ local PLATFORMS = { id = "raccoon", name = "Raccoon(商汤小浣熊)", vendor = "sensetime", - flow = "manual", - manual_hint = "Raccoon 登录要扫图片二维码(商汤小浣熊官网)。请扫码后," - .. "把 login_with_qrcode_code 拿到的凭证粘贴进来。", - base_url = "https://raccoon.sensetime.com", + -- ⚠️ 纠正第四处:此前标 manual,说"要扫码,做不了"。**扫码路径纯 HTTP 可做**: + -- ① 本地造 qrcode_code(16 字节随机 hex) + -- ② 二维码 URL {apiBase}/login/mp?code=...&appname=商汤小浣熊官网(前端渲染) + -- ③ 轮询 POST {apiBase}/api/web/auth/v1/login_with_qrcode_code {qrcode_code} + -- → data.status ∈ pending/logging/canceled/success;success 带 token + -- 参考注释:code 由**宿主侧本地随机生成**(服务端接受任意自造 code 并进入 + -- pending),故完全绕开收不到的自定义协议回调。 + -- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号 + -- AES 加密 —— 那一条才真需要人工,故不走。 + flow = "qr-poll", + qr = { + api_base = "https://xiaohuanxiong.com", + auth_prefix = "/api/web/auth/v1", + poll_path = "/login_with_qrcode_code", + login_page = "/login/mp", + app_name = "商汤小浣熊官网", + interval = 2, + }, + -- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions) + base_url = "https://xiaohuanxiong.com/api/web/llm/v2", adapter = "openai", models = { "raccoon-default" }, - -- 余额(对齐参考 raccoon-credits.ts:GET {apiBase}/points/v1/balance → data.available_points) + -- 余额(照参考:GET {apiBase}/api/web/points/v1/balance) balance = { - url = "https://raccoon.sensetime.com/points/v1/balance", + url = "https://xiaohuanxiong.com/api/web/points/v1/balance", method = "GET", + headers_fn = function(accessToken, cred) + return { + ["Authorization"] = "Bearer " .. accessToken, + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["X-Org-Code"] = (cred and cred.office_identity) or "", + ["X-Raccoon-Language"] = "zh", + } + end, total_fn = function(rec) local d = rec.data or rec - return tonumber(d.available_points or d.availablePoints) or 0 + return tonumber(d.available_points or d.availablePoints or d.points) end, }, - -- 一次性新人奖励(对齐 onboarding:桌面登录奖励 3000 分) + -- 一次性新人奖励(对齐参考 RACCOON_LOGIN_REWARD_POINTS) onboarding = { reward_points = 3000 }, }, } @@ -690,6 +715,7 @@ local function declaredHosts() addHost(p.wechat.poll_url); addHost(p.wechat.account_base) end if p.initpoll then addHost(p.initpoll.init_url); addHost(p.initpoll.poll_url) end + if p.qr then addHost(p.qr.api_base) end if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end if p.balance then addHost(p.balance.url) end @@ -2104,6 +2130,17 @@ function plugin.start_login(req) sess.collect_expr = plat.collect_expr logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")") end + elseif plat.flow == "qr-poll" then + -- 自带二维码的扫码登录(Raccoon):code 由**本地随机生成**,服务端接受 + -- 任意自造 code 并进入 pending(参考注释实测)。二维码图交给前端渲染。 + local q = plat.qr + local code = tostring(sha256_hex(tostring(os.time and os.time() or 0) .. tostring(math.random(1e12)))):sub(1, 32) + sess.qr_code = code + out.qr_url = q.api_base .. q.login_page .. "?code=" .. code + .. "&appname=" .. rfc3986Escape(q.app_name) + out.url = out.qr_url + out.session.interval = q.interval or 2 + logEvent("二维码已生成 " .. plat.id) elseif plat.flow == "wechat-poll" then -- 微信扫码:取 uuid → 把二维码给前端渲染 → 长轮询等扫码确认。 -- uuid 从授权页 HTML 里抽(参考 extractLoomyWechatUuid 的两条正则兜底)。 @@ -2294,6 +2331,57 @@ function plugin.poll_login(req) s.message = payload.message end end + elseif s.flow == "qr-poll" and s.state == "pending" and s.qr_code then + -- 轮询 login_with_qrcode_code:status ∈ pending/logging/canceled/success。 + -- 参考:**任何异常都降级为 pending**(网络抖动/响应畸形/未知 status), + -- success 但缺 token 也视为未完成(否则产出空凭据并卡死)。 + local plat = platformById(s.platform) + local q = plat and plat.qr + if q then + local resp, err = __llmsproxy_http({ + url = q.api_base .. q.auth_prefix .. q.poll_path, + method = "POST", + headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, + body = json.encode({ qrcode_code = s.qr_code }), + }) + if not err and resp then + local ok, rec = pcall(json.decode, resp.body or "") + local env = ok and type(rec) == "table" and rec or nil + if env and env.code == 0 and type(env.data) == "table" then + local st = env.data.status + if st == "canceled" then + s.state = "failed" + s.message = "用户取消了扫码授权" + elseif st == "success" then + local at = env.data.access_token + if type(at) ~= "string" or at == "" then + -- 缺 token 的 success 视为未完成(不产出空凭据) + s.wechat_hint = "扫码成功,等待凭据下发" + else + local cred = json.encode({ + access_token = at, + refresh_token = env.data.refresh_token, + office_identity = env.data.office_identity, + token_type = "Bearer", + account_type = "personal", + }) + local res = plugin.complete_login({ platform = s.platform, credential = cred, + account = "raccoon", account_id = s.account_id }) + if res.json and res.json.ok then + s.state = "completed" + s.account = "raccoon" + else + s.state = "failed" + s.message = (res.json and res.json.error) or "接入源失败" + end + end + elseif st == "logging" then + s.wechat_hint = "已扫码,正在登录" + end + -- pending 与未知值:继续等 + end + end + end elseif s.flow == "wechat-poll" and s.state == "pending" and s.wechat_uuid then -- 长轮询一步:判据照参考(**404 与 405 不可读反**)。 local plat = platformById(s.platform) @@ -3521,6 +3609,14 @@ function plugin.set_endpoints(req) for k in pairs(allowed) do if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end end + if type(args.qr) == "table" and plat.qr then + for _, k in ipairs({ "api_base", "auth_prefix", "poll_path", "login_page", "app_name" }) do + if args.qr[k] then + plat.qr[k] = tostring(args.qr[k]) + changed[#changed+1] = "qr." .. k + end + end + end if type(args.wechat) == "table" and plat.wechat then for _, k in ipairs({ "auth_url", "qr_url", "poll_url", "account_base", "bind_path", "user_agent", "access_key_id", "access_key_secret", "app_id" }) do