mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): Raccoon 扫码登录实现 —— 12 个平台零 manual
第四处也是最后一处「把没实现说成做不到」:此前标 manual,理由"要扫图片二维码"。
参考事实(raccoon-oauth.ts / raccoon-login-page.ts 注释):
· `qrcode_code` 由**宿主侧本地随机生成**(16 字节 hex),服务端接受任意
自造 code 并进入 pending —— 完全绕开收不到的自定义协议回调
· 二维码只是一张公开页面 {apiBase}/login/mp?code=...&appname=...
· 轮询 POST /api/web/auth/v1/login_with_qrcode_code {qrcode_code}
→ data.status ∈ pending / logging / canceled / success
· **success 但缺 token 视为未完成**(否则产出空凭据并让流程卡死)
· 参考明确:任何异常都降级为 pending(网络抖动/畸形响应/未知 status)
真正需要人工的只有短信路径(阿里云滑块 captcha_param + 手机号 AES 加密)——
那一条不做,但扫码这条完全自动化。
## 同时按参考修正的两处错误
- `base_url` 写成 raccoon.sensetime.com,实际是 **xiaohuanxiong.com**;
推理路径是 `/api/web/llm/v2`(标准 OpenAI 兼容)
- 余额路径缺 `/api/web` 前缀,且未带 `X-Org-Code` / `X-Raccoon-Language` 头
(照参考 raccoonHeaders)
## 判据
TestRaccoonQrPollBehavior(5 项):本地生成 32 位 hex code + 二维码 URL;
logging 保持 pending;success 且带 token 才完成;canceled 是终态失败;
**缺 token 的 success 必须 pending**(防空凭据卡死)。
全量 core 回归通过;内核对照 v2.0.2 diff 为空。
## 十二个平台现状(零 manual)
trae/minimax/cline/buddy/gemini/lobsterai/opencode/codearts 原有流程 +
qoder(device-poll) / zcode(init-poll) / loomy(wechat-poll) / raccoon(qr-poll)
This commit is contained in:
165
internal/core/channels_raccoon_test.go
Normal file
165
internal/core/channels_raccoon_test.go
Normal file
@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user