mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
## 用真上游实测逼出来的两处错误
批准出站主机后逐个平台打真上游,Loomy 报「授权页未包含二维码 uuid」。
核对参考常量后发现:
1. **appId 是我编的**:我写 `wx16e5c2b8e0c0e1f2`,参考是
`wx18d60be432287cf8`。上游不认这个 appid,返回的页面里自然没有二维码。
同时补上微信要求的固定片段 `#wechat_redirect`。
2. **uuid 正则不是参考的那条**:我写的是 `uuid="..."` 形式,而真实微信页把
uuid 内嵌在 `<img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>`,
兜底路径是 `l/qrconnect?uuid=<uuid>`。照参考 extractLoomyWechatUuid 的两条
正则改写(它还带一个 `^[A-Za-z0-9_\-=+/]{6,64}$` 的字符集校验)。
3. UA 逐字统一为参考的 WECHAT_UA(`Chrome/120.0.0.0`)。
## 教训(值得记)
「照参考逐行移植」这一条,我在这两处做成了「凭印象写个看起来像的」。
**判据是端到端打真上游**,不是测试通过 —— 单测用的是我自己编的夹具,
夹具与生产同源出错时测试照样绿。这次是真上游把它逼出来的。
## 判据
- 测试夹具改为**真实微信页面形态**(`<img ... src="/connect/qrcode/<uuid>">`):
用别的形态(如 `var uuid = "..."`)测出来的正则不是生产要用的那条。
- 生产实测:Loomy 从真微信拿到二维码 uuid(`open.weixin.qq.com/connect/qrcode/<uuid>`)。
- 四个新平台真上游全部 pending 且返回有效授权入口:
qoder→qoder.com(真实 PKCE challenge)、zcode→bigmodel.cn、
loomy→open.weixin.qq.com(真实 uuid)、raccoon→xiaohuanxiong.com(本地 code)
- 全量 core 回归通过;内核对照 v2.0.2 diff 为空。
## 出站主机授权
channels 的 27 个声明主机已全部在设置页批准(8 个新增 + 陈旧条目自动清理),
落盘到 .kernel-perms.json,重启后保留。
178 lines
5.8 KiB
Go
178 lines
5.8 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// loomyHarness:Loomy 微信扫码长轮询的测试台。
|
||
//
|
||
// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"):
|
||
//
|
||
// 408 = 等待扫码;404 = 已扫码待确认(继续轮询);
|
||
// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效
|
||
type loomyHarness struct {
|
||
t *testing.T
|
||
ps *lua.Plugins
|
||
ts *httptest.Server
|
||
polls int32
|
||
confirmAt int32
|
||
gotAuth string
|
||
}
|
||
|
||
func newLoomyHarness(t *testing.T) *loomyHarness {
|
||
t.Helper()
|
||
h := &loomyHarness{t: t, confirmAt: 3}
|
||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch {
|
||
case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"):
|
||
w.Header().Set("Content-Type", "text/html")
|
||
// 夹具必须用**真实微信页面的形态**:uuid 内嵌在
|
||
// <img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>。
|
||
// 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。
|
||
fmt.Fprint(w, `<html><img class="js_qrcode_img" src="/connect/qrcode/UUID-TEST-1"/></html>`)
|
||
case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"):
|
||
n := atomic.AddInt32(&h.polls, 1)
|
||
w.Header().Set("Content-Type", "text/plain")
|
||
if n == 1 {
|
||
fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码
|
||
return
|
||
}
|
||
if n < atomic.LoadInt32(&h.confirmAt) {
|
||
fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认
|
||
return
|
||
}
|
||
fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认
|
||
case r.URL.Path == "/login/thirdAccount/bind/auth":
|
||
h.gotAuth = r.Header.Get("Authorization")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`)
|
||
default:
|
||
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": "loomy",
|
||
"wechat": map[string]interface{}{
|
||
"auth_url": h.ts.URL + "/connect/qrconnect?state={state}",
|
||
"qr_url": h.ts.URL + "/connect/qrcode/{uuid}",
|
||
"poll_url": h.ts.URL + "/connect/l/qrconnect",
|
||
"account_base": h.ts.URL,
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("set_endpoints: %v", err)
|
||
}
|
||
return h
|
||
}
|
||
|
||
func (h *loomyHarness) begin() (map[string]interface{}, string) {
|
||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"loomy"}`, 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 *loomyHarness) 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{})
|
||
}
|
||
|
||
// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。
|
||
func TestLoomyWechatPollBehavior(t *testing.T) {
|
||
h := newLoomyHarness(t)
|
||
|
||
t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.polls, 0)
|
||
j, _ := h.begin()
|
||
qr, _ := j["qr_url"].(string)
|
||
if !strings.Contains(qr, "UUID-TEST-1") {
|
||
t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr)
|
||
}
|
||
})
|
||
|
||
t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.polls, 0)
|
||
atomic.StoreInt32(&h.confirmAt, 99)
|
||
_, sid := h.begin()
|
||
// 第一拍 408
|
||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||
t.Fatalf("408 应 pending, 实得 %v", st["state"])
|
||
}
|
||
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
|
||
st := h.pollOnce(sid)
|
||
if st["state"] != "pending" {
|
||
t.Fatalf("★ 404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
|
||
}
|
||
})
|
||
|
||
t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.polls, 0)
|
||
atomic.StoreInt32(&h.confirmAt, 3)
|
||
_, sid := h.begin()
|
||
h.pollOnce(sid) // 408
|
||
h.pollOnce(sid) // 404
|
||
st := h.pollOnce(sid) // 405
|
||
if st["state"] != "completed" {
|
||
t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"])
|
||
}
|
||
// 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer
|
||
if !strings.HasPrefix(h.gotAuth, "account ") {
|
||
t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth)
|
||
}
|
||
if !strings.Contains(h.gotAuth, ":") {
|
||
t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth)
|
||
}
|
||
v, ok, _ := h.ps.GetSecret("channels", "channels:loomy")
|
||
if !ok || !strings.Contains(v, "RCODE-1") {
|
||
t.Fatalf("凭据应含 rcode, 实得 %q", v)
|
||
}
|
||
})
|
||
|
||
t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) {
|
||
for _, tc := range []struct{ errcode, want string }{
|
||
{"403", "取消"}, {"402", "失效"},
|
||
} {
|
||
hh := newLoomyHarness(t)
|
||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") {
|
||
fmt.Fprint(w, `<img src="/connect/qrcode/U">`)
|
||
return
|
||
}
|
||
fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode)
|
||
})
|
||
_, sid := hh.begin()
|
||
st := hh.pollOnce(sid)
|
||
if st["state"] != "failed" {
|
||
t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"])
|
||
}
|
||
}
|
||
})
|
||
}
|