fix(weixin): 空token''连接''不再阻塞+409请先扫码登录;Connect空token返回ErrNeedsLogin
面板点''连接''此前触发阻塞式扫码(最长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 全绿, 已部署.
This commit is contained in:
1 parent
abb137cfe2
commit
3f2c981cf8
3 files changed
+70
-81
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user