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
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
"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
|
// 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 {
|
if err := ch.Connect(r.Context()); err != nil {
|
||||||
s.auditClaims(r, "channel.open", "error")
|
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)
|
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()))
|
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error()))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ var (
|
|||||||
ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out")
|
ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out")
|
||||||
// ErrSessionExpired is returned when iLink reports errcode=-14.
|
// ErrSessionExpired is returned when iLink reports errcode=-14.
|
||||||
ErrSessionExpired = errors.New("weixin_ilink: session expired, re-scan required")
|
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 is returned when a send lacks the peer's context_token.
|
||||||
ErrNoContextToken = errors.New("weixin_ilink: no context_token for peer")
|
ErrNoContextToken = errors.New("weixin_ilink: no context_token for peer")
|
||||||
// ErrProactiveWindow is returned when a proactive push is outside 24h.
|
// ErrProactiveWindow is returned when a proactive push is outside 24h.
|
||||||
@@ -234,9 +237,11 @@ func (a *Adapter) Connect(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if token == "" {
|
if token == "" {
|
||||||
if err := a.login(ctx); err != nil {
|
// No token yet: the scan-code bind must be driven out-of-band (the panel's
|
||||||
return fmt.Errorf("weixin_ilink: connect: %w", err)
|
// 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.mu.Lock()
|
||||||
a.connected = true
|
a.connected = true
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ package weixin_ilink
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestConnect_ScanLogin_StatusTransition drives the QR status flow
|
// TestStartLogin_ScanStatusTransition drives the QR bind flow via the out-of-band
|
||||||
// wait → scaned → confirmed and asserts the credentials are stored.
|
// API: StartLogin mints the QR, the background poll walks wait→scaned→confirmed and
|
||||||
func TestConnect_ScanLogin_StatusTransition(t *testing.T) {
|
// stores the credentials, and LoginStatus surfaces confirmed.
|
||||||
|
func TestStartLogin_ScanStatusTransition(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
rec := &reqRecorder{}
|
rec := &reqRecorder{}
|
||||||
statusCalls := 0
|
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}
|
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second}
|
||||||
ad := newAdapter(t, cfg, srv)
|
ad := newAdapter(t, cfg, srv)
|
||||||
if err := ad.Connect(context.Background()); err != nil {
|
qr, err := ad.StartLogin(context.Background())
|
||||||
t.Fatalf("Connect: %v", err)
|
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() {
|
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" {
|
if got := ad.Config()["bot_token"]; got != "tok-x" {
|
||||||
t.Fatalf("bot_token = %v, want tok-x", got)
|
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" {
|
if got := ad.Config()["bot_id"]; got != "bot-x" {
|
||||||
t.Fatalf("bot_id = %v, want bot-x", got)
|
t.Fatalf("bot_id = %v, want bot-x", got)
|
||||||
}
|
}
|
||||||
|
// Confirm the qrcode id is echoed in the status poll query and the auth header.
|
||||||
reqs := rec.all()
|
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" }) {
|
||||||
if len(reqs) < 3 {
|
if q.query != "qrcode=qr-1" {
|
||||||
t.Fatalf("expected >=3 requests, got %d", len(reqs))
|
t.Fatalf("status poll query = %q, want qrcode=qr-1", q.query)
|
||||||
}
|
}
|
||||||
// Confirm the qrcode id is echoed in the status poll query.
|
if q.authType != "ilink_bot_token" {
|
||||||
statusReqs := rec.find(func(q recordedReq) bool {
|
t.Fatalf("status poll AuthorizationType = %q, want ilink_bot_token", q.authType)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnect_ScanLogin_Timeout verifies the login aborts with ErrLoginTimeout
|
// TestStartLogin_Timeout verifies the bind aborts with ErrLoginTimeout when the QR
|
||||||
// when the QR is never confirmed.
|
// is never confirmed, and Connect with an empty token does NOT block (ErrNeedsLogin).
|
||||||
func TestConnect_ScanLogin_Timeout(t *testing.T) {
|
func TestStartLogin_Timeout(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
||||||
if req.path == "/ilink/bot/get_bot_qrcode" {
|
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}
|
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 25 * time.Millisecond}
|
||||||
ad := newAdapter(t, cfg, srv)
|
ad := newAdapter(t, cfg, srv)
|
||||||
err := ad.Connect(context.Background())
|
|
||||||
if !errors.Is(err, ErrLoginTimeout) {
|
// Connect with no token must NOT block on the QR — it surfaces ErrNeedsLogin so
|
||||||
t.Fatalf("err = %v, want ErrLoginTimeout", err)
|
// 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() {
|
if ad.IsConnected() {
|
||||||
t.Fatal("IsConnected() = true after failed login")
|
t.Fatal("IsConnected() = true after failed login")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnect_ScanLogin_EmptyQRCode verifies a qrcode-less response fails.
|
var _ = json.RawMessage{} // keep encoding/json imported for future cases
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user