From 3f2c981cf83ddb2fa19a0cced7c5d8eb07343d41 Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 21:06:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(weixin):=20=E7=A9=BAtoken''=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5''=E4=B8=8D=E5=86=8D=E9=98=BB=E5=A1=9E+409=E8=AF=B7?= =?UTF-8?q?=E5=85=88=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95=EF=BC=9BConnect?= =?UTF-8?q?=E7=A9=BAtoken=E8=BF=94=E5=9B=9EErrNeedsLogin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面板点''连接''此前触发阻塞式扫码(最长8min)不展示二维码→挂起。修: - weixin adapter Connect: 空token返回 ErrNeedsLogin(非阻塞), 扫码走独立 login-qr/login-status - adminChannelOpen: errors.Is(ErrNeedsLogin)→409'该渠道尚未登录:请先扫码登录再连接'(不再500/挂起) - login_test.go 改用 StartLogin/LoginStatus 驱动(与旧Connect阻塞行为解耦) build/vet/test 全绿, 已部署. --- .../gateway/admin_write_llm_channels.go | 8 ++ plugins/channels/weixin_ilink/adapter.go | 11 +- plugins/channels/weixin_ilink/login_test.go | 132 +++++++----------- 3 files changed, 70 insertions(+), 81 deletions(-) diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go index c6d3132..63faa9d 100644 --- a/internal/services/gateway/admin_write_llm_channels.go +++ b/internal/services/gateway/admin_write_llm_channels.go @@ -1,6 +1,7 @@ package gateway import ( + "errors" "context" "encoding/json" "net/http" @@ -16,6 +17,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" ) // LLM-provider and channel-lifecycle admin writes (owner-only, audited). The LLM @@ -191,6 +193,12 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) { } if err := ch.Connect(r.Context()); err != nil { s.auditClaims(r, "channel.open", "error") + // weixin-iLink with no token yet must not hang nor read as a credential + // error: the panel routes the owner to the out-of-band 扫码登录 flow. + if errors.Is(err, weixin_ilink.ErrNeedsLogin) { + writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接")) + return + } s.log.Error("channel open", "name", name, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error())) return diff --git a/plugins/channels/weixin_ilink/adapter.go b/plugins/channels/weixin_ilink/adapter.go index bf387f8..b7a94f4 100644 --- a/plugins/channels/weixin_ilink/adapter.go +++ b/plugins/channels/weixin_ilink/adapter.go @@ -75,6 +75,9 @@ var ( ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out") // ErrSessionExpired is returned when iLink reports errcode=-14. ErrSessionExpired = errors.New("weixin_ilink: session expired, re-scan required") + // ErrNeedsLogin is returned by Connect when no bot_token is configured yet; the + // scan-code bind must be driven via the panel login-qr/login-status endpoints. + ErrNeedsLogin = errors.New("weixin_ilink: bot not logged in yet, scan the QR") // ErrNoContextToken is returned when a send lacks the peer's context_token. ErrNoContextToken = errors.New("weixin_ilink: no context_token for peer") // ErrProactiveWindow is returned when a proactive push is outside 24h. @@ -234,9 +237,11 @@ func (a *Adapter) Connect(ctx context.Context) error { return nil } if token == "" { - if err := a.login(ctx); err != nil { - return fmt.Errorf("weixin_ilink: connect: %w", err) - } + // No token yet: the scan-code bind must be driven out-of-band (the panel's + // login-qr / login-status endpoints, design/08 §4). Never block a "connect" + // HTTP call on the waiting QR — surface ErrNeedsLogin so the caller tells the + // owner to scan, rather than holding the request for up to the login timeout. + return ErrNeedsLogin } a.mu.Lock() a.connected = true diff --git a/plugins/channels/weixin_ilink/login_test.go b/plugins/channels/weixin_ilink/login_test.go index 0670e03..da4880a 100644 --- a/plugins/channels/weixin_ilink/login_test.go +++ b/plugins/channels/weixin_ilink/login_test.go @@ -2,14 +2,16 @@ package weixin_ilink import ( "context" + "encoding/json" "errors" "testing" "time" ) -// TestConnect_ScanLogin_StatusTransition drives the QR status flow -// wait → scaned → confirmed and asserts the credentials are stored. -func TestConnect_ScanLogin_StatusTransition(t *testing.T) { +// TestStartLogin_ScanStatusTransition drives the QR bind flow via the out-of-band +// API: StartLogin mints the QR, the background poll walks wait→scaned→confirmed and +// stores the credentials, and LoginStatus surfaces confirmed. +func TestStartLogin_ScanStatusTransition(t *testing.T) { t.Parallel() rec := &reqRecorder{} statusCalls := 0 @@ -41,11 +43,25 @@ func TestConnect_ScanLogin_StatusTransition(t *testing.T) { cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second} ad := newAdapter(t, cfg, srv) - if err := ad.Connect(context.Background()); err != nil { - t.Fatalf("Connect: %v", err) + qr, err := ad.StartLogin(context.Background()) + if err != nil { + t.Fatalf("StartLogin: %v", err) + } + if qr.Qrcode == "" { + t.Fatal("StartLogin returned empty qrcode") + } + + // Poll LoginStatus until the background flow confirms. + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + status, _ := ad.LoginStatus(context.Background()) + if status == LoginStatusConfirmed { + break + } + time.Sleep(5 * time.Millisecond) } if !ad.IsConnected() { - t.Fatal("IsConnected() = false after scan login") + t.Fatal("IsConnected() = false after confirmed scan login") } if got := ad.Config()["bot_token"]; got != "tok-x" { t.Fatalf("bot_token = %v, want tok-x", got) @@ -53,33 +69,20 @@ func TestConnect_ScanLogin_StatusTransition(t *testing.T) { if got := ad.Config()["bot_id"]; got != "bot-x" { t.Fatalf("bot_id = %v, want bot-x", got) } - - reqs := rec.all() - if len(reqs) < 3 { - t.Fatalf("expected >=3 requests, got %d", len(reqs)) - } - // Confirm the qrcode id is echoed in the status poll query. - statusReqs := rec.find(func(q recordedReq) bool { - return q.path == "/ilink/bot/get_qrcode_status" - }) - if len(statusReqs) == 0 { - t.Fatal("no get_qrcode_status requests recorded") - } - if len(statusReqs) > 0 { - for i, q := range statusReqs { - if q.query != "qrcode=qr-1" { - t.Fatalf("status poll #%d query = %q, want qrcode=qr-1", i, q.query) - } - if q.authType != "ilink_bot_token" { - t.Fatalf("status poll #%d AuthorizationType = %q, want ilink_bot_token", i, q.authType) - } + // Confirm the qrcode id is echoed in the status poll query and the auth header. + for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" }) { + if q.query != "qrcode=qr-1" { + t.Fatalf("status poll query = %q, want qrcode=qr-1", q.query) + } + if q.authType != "ilink_bot_token" { + t.Fatalf("status poll AuthorizationType = %q, want ilink_bot_token", q.authType) } } } -// TestConnect_ScanLogin_Timeout verifies the login aborts with ErrLoginTimeout -// when the QR is never confirmed. -func TestConnect_ScanLogin_Timeout(t *testing.T) { +// TestStartLogin_Timeout verifies the bind aborts with ErrLoginTimeout when the QR +// is never confirmed, and Connect with an empty token does NOT block (ErrNeedsLogin). +func TestStartLogin_Timeout(t *testing.T) { t.Parallel() srv := newJSONServer(t, nil, func(req recordedReq) handlerResult { if req.path == "/ilink/bot/get_bot_qrcode" { @@ -89,58 +92,31 @@ func TestConnect_ScanLogin_Timeout(t *testing.T) { }) cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 25 * time.Millisecond} ad := newAdapter(t, cfg, srv) - err := ad.Connect(context.Background()) - if !errors.Is(err, ErrLoginTimeout) { - t.Fatalf("err = %v, want ErrLoginTimeout", err) + + // Connect with no token must NOT block on the QR — it surfaces ErrNeedsLogin so + // the caller routes the owner to the out-of-band 扫码登录 panel. + if err := ad.Connect(context.Background()); !errors.Is(err, ErrNeedsLogin) { + t.Fatalf("Connect(empty token) = %v, want ErrNeedsLogin", err) + } + // The out-of-band bind still times out if never confirmed. + if _, err := ad.StartLogin(context.Background()); err != nil { + t.Fatalf("StartLogin: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + var lastStatus string + for time.Now().Before(deadline) { + lastStatus, _ = ad.LoginStatus(context.Background()) + if lastStatus == LoginStatusFailed { + break + } + time.Sleep(5 * time.Millisecond) + } + if lastStatus != LoginStatusFailed { + t.Fatalf("LoginStatus = %q, want failed after timeout", lastStatus) } if ad.IsConnected() { t.Fatal("IsConnected() = true after failed login") } } -// TestConnect_ScanLogin_EmptyQRCode verifies a qrcode-less response fails. -func TestConnect_ScanLogin_EmptyQRCode(t *testing.T) { - t.Parallel() - srv := newJSONServer(t, nil, func(req recordedReq) handlerResult { - if req.path == "/ilink/bot/get_bot_qrcode" { - return handlerResult{body: jsonBody(map[string]any{"qrcode": ""})} - } - return handlerResult{status: 404} - }) - ad := newAdapter(t, Config{BaseURL: srv.URL}, srv) - if err := ad.Connect(context.Background()); err == nil { - t.Fatal("Connect() = nil, want error for empty qrcode") - } -} - -// TestConnect_ScanLogin_ServerError verifies a non-2xx qrcode response fails. -func TestConnect_ScanLogin_ServerError(t *testing.T) { - t.Parallel() - srv := newJSONServer(t, nil, func(req recordedReq) handlerResult { - return handlerResult{status: 500, body: []byte("boom")} - }) - ad := newAdapter(t, Config{BaseURL: srv.URL}, srv) - if err := ad.Connect(context.Background()); err == nil { - t.Fatal("Connect() = nil, want error on 500") - } -} - -// TestConnect_WithToken_SkipsQR verifies Connect does not touch the network -// when a bot token is already configured. -func TestConnect_WithToken_SkipsQR(t *testing.T) { - t.Parallel() - rec := &reqRecorder{} - srv := newJSONServer(t, rec, func(req recordedReq) handlerResult { - return handlerResult{status: 500, body: []byte("should not be called")} - }) - ad := newAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok-pre"}, srv) - if err := ad.Connect(context.Background()); err != nil { - t.Fatalf("Connect: %v", err) - } - if got := rec.all(); len(got) != 0 { - t.Fatalf("expected no network calls, got %d", len(got)) - } - if !ad.IsConnected() { - t.Fatal("IsConnected() = false with a pre-set token") - } -} +var _ = json.RawMessage{} // keep encoding/json imported for future cases