feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
(锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
This commit is contained in:
1 parent
0a597cfa42
commit
e037fab7d0
13 files changed
+1155
-46
No files matched your search
@@ -160,6 +160,15 @@ type Adapter struct {
|
||||
contexts map[string]string
|
||||
lastMsgAt map[string]time.Time
|
||||
handler adapter.MessageHandler
|
||||
|
||||
// loginMu serializes the scan-login session lifecycle (StartLogin/Connect),
|
||||
// so only one QR bind runs at a time. loginSession / loginStatus / loginDetail
|
||||
// carry the panel-observable login state; loginStatus/loginDetail are guarded
|
||||
// by mu, loginSession by loginMu.
|
||||
loginMu sync.Mutex
|
||||
loginSession *loginSession
|
||||
loginStatus string
|
||||
loginDetail string
|
||||
}
|
||||
|
||||
// New builds an Adapter. client may be nil to use http.DefaultClient; log may be
|
||||
@@ -179,12 +188,13 @@ func New(cfg Config, client *http.Client, log *slog.Logger) (*Adapter, error) {
|
||||
log = logger.WithSource(log, source)
|
||||
|
||||
return &Adapter{
|
||||
cfg: cfg,
|
||||
httpClient: client,
|
||||
log: log,
|
||||
clock: time.Now,
|
||||
contexts: map[string]string{},
|
||||
lastMsgAt: map[string]time.Time{},
|
||||
cfg: cfg,
|
||||
httpClient: client,
|
||||
log: log,
|
||||
clock: time.Now,
|
||||
contexts: map[string]string{},
|
||||
lastMsgAt: map[string]time.Time{},
|
||||
loginStatus: LoginStatusIdle,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package weixin_ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -30,61 +31,107 @@ type qrcodeStatusResp struct {
|
||||
BaseURL string `json:"baseurl"`
|
||||
}
|
||||
|
||||
// login runs the one-time scan-code binding (design/08 §4): request a QR code,
|
||||
// then poll get_qrcode_status until the user confirms. On confirmation it
|
||||
// persists bot_token, bot_id, owner_id and any returned baseurl, so later
|
||||
// requests carry the Bearer token.
|
||||
// login is the Connect-side scan-code binding (design/08 §4). It joins (or
|
||||
// starts) a scan-login via StartLogin and blocks until the user confirms or the
|
||||
// window elapses. Because it shares the session machinery with the panel's
|
||||
// StartLogin, a panel-initiated login and Connect never run two QR binds at once.
|
||||
func (a *Adapter) login(ctx context.Context) error {
|
||||
loginCtx, cancel := context.WithTimeout(ctx, a.cfg.LoginTimeout)
|
||||
defer cancel()
|
||||
|
||||
qr, err := a.fetchQRCode(loginCtx)
|
||||
_, err := a.StartLogin(ctx)
|
||||
if errors.Is(err, ErrAlreadyLoggedIn) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
if loginCtx.Err() != nil {
|
||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
|
||||
}
|
||||
return fmt.Errorf("weixin_ilink: fetch qrcode: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.qrcode = qr.Qrcode
|
||||
a.mu.Unlock()
|
||||
// Log only the renderable URL, never the raw qrcode (it is a secret).
|
||||
a.log.Info("scan the qr code to bind the bot", "url", qr.QrcodeImgContent)
|
||||
a.loginMu.Lock()
|
||||
sess := a.loginSession
|
||||
a.loginMu.Unlock()
|
||||
if sess == nil {
|
||||
return fmt.Errorf("weixin_ilink: login session missing")
|
||||
}
|
||||
|
||||
select {
|
||||
case lerr := <-sess.done:
|
||||
return lerr
|
||||
case <-ctx.Done():
|
||||
sess.cancel()
|
||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// runLogin polls get_qrcode_status until the QR is confirmed or the login window
|
||||
// elapses, then records the outcome under the session. It runs in a goroutine so
|
||||
// StartLogin returns immediately while the panel polls LoginStatus.
|
||||
func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
|
||||
timeoutCtx, cancel := context.WithTimeout(sessCtx, a.cfg.LoginTimeout)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(a.cfg.LoginInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
status, err := a.pollQRStatus(loginCtx, qr.Qrcode)
|
||||
status, err := a.pollQRStatus(timeoutCtx, sess.qrcode)
|
||||
if err != nil {
|
||||
// A deadline/cancel during the poll means the overall window
|
||||
// elapsed; surface ErrLoginTimeout rather than the raw transport
|
||||
// error.
|
||||
if loginCtx.Err() != nil {
|
||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
|
||||
// A deadline/cancel during the poll means the whole window elapsed;
|
||||
// surface ErrLoginTimeout rather than the raw transport error.
|
||||
if timeoutCtx.Err() != nil {
|
||||
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
|
||||
} else {
|
||||
a.failLogin(sess, fmt.Errorf("weixin_ilink: poll qr status: %w", err))
|
||||
}
|
||||
return fmt.Errorf("weixin_ilink: poll qr status: %w", err)
|
||||
return
|
||||
}
|
||||
if status.Status == "confirmed" && status.BotToken != "" {
|
||||
a.mu.Lock()
|
||||
a.cfg.BotToken = status.BotToken
|
||||
a.cfg.BotID = status.BotID
|
||||
a.cfg.OwnerID = status.UserID
|
||||
if status.BaseURL != "" {
|
||||
a.cfg.BaseURL = status.BaseURL
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
a.completeLogin(sess, status)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-loginCtx.Done():
|
||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
|
||||
case <-timeoutCtx.Done():
|
||||
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// completeLogin persists the confirmed credentials and marks the session live.
|
||||
// It writes cfg under a.mu, then clears the session under loginMu (never nesting
|
||||
// the two locks), and finally signals the waiting Connect path.
|
||||
func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
|
||||
a.mu.Lock()
|
||||
a.cfg.BotToken = status.BotToken
|
||||
a.cfg.BotID = status.BotID
|
||||
a.cfg.OwnerID = status.UserID
|
||||
if status.BaseURL != "" {
|
||||
a.cfg.BaseURL = status.BaseURL
|
||||
}
|
||||
a.connected = true
|
||||
a.loginStatus = LoginStatusConfirmed
|
||||
a.loginDetail = ""
|
||||
a.mu.Unlock()
|
||||
|
||||
a.loginMu.Lock()
|
||||
if a.loginSession == sess {
|
||||
a.loginSession = nil
|
||||
}
|
||||
a.loginMu.Unlock()
|
||||
|
||||
sess.done <- nil
|
||||
}
|
||||
|
||||
// failLogin records a failed scan-login and signals the waiting Connect path.
|
||||
func (a *Adapter) failLogin(sess *loginSession, err error) {
|
||||
a.setLoginState(LoginStatusFailed, err.Error())
|
||||
|
||||
a.loginMu.Lock()
|
||||
if a.loginSession == sess {
|
||||
a.loginSession = nil
|
||||
}
|
||||
a.loginMu.Unlock()
|
||||
|
||||
sess.done <- err
|
||||
}
|
||||
|
||||
// fetchQRCode requests a new bot login QR code.
|
||||
func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
|
||||
path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType))
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package weixin_ilink
|
||||
|
||||
// This file exposes the non-blocking scan-login surface the admin panel drives
|
||||
// (design/08 §4): StartLogin mints (or returns the in-progress) QR code without
|
||||
// blocking, and LoginStatus reports where the bind stands so the panel can poll
|
||||
// idle → waiting → confirmed. Connect (login.go) reuses StartLogin internally and
|
||||
// then waits, so a panel-initiated login and the bridge's Connect never start two
|
||||
// concurrent QR binds.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Login status values reported by Adapter.LoginStatus.
|
||||
const (
|
||||
// LoginStatusIdle means no bot token and no scan-login in progress.
|
||||
LoginStatusIdle = "idle"
|
||||
// LoginStatusWaiting means a QR code is displayed and the user has not yet
|
||||
// confirmed the bind.
|
||||
LoginStatusWaiting = "waiting"
|
||||
// LoginStatusConfirmed means a bot token is present (either a fresh scan or a
|
||||
// pre-configured one).
|
||||
LoginStatusConfirmed = "confirmed"
|
||||
// LoginStatusFailed means the last scan/login attempt errored or timed out.
|
||||
LoginStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// ErrAlreadyLoggedIn is returned by StartLogin when a bot token is already
|
||||
// configured, so the scan flow is moot.
|
||||
var ErrAlreadyLoggedIn = errors.New("weixin_ilink: already logged in")
|
||||
|
||||
// LoginQR is the artifact StartLogin returns for the panel to render: Qrcode is
|
||||
// the raw scan identifier the adapter echoes to get_qrcode_status, and Image is
|
||||
// the QR image content (currently the image URL returned by get_bot_qrcode).
|
||||
type LoginQR struct {
|
||||
Qrcode string
|
||||
Image string
|
||||
}
|
||||
|
||||
// loginSession tracks one scan-login run. It is guarded by Adapter.loginMu.
|
||||
type loginSession struct {
|
||||
qrcode string
|
||||
image string
|
||||
// cancel stops the poll goroutine early (e.g. Connect's deadline elapsed).
|
||||
cancel context.CancelFunc
|
||||
// done carries the login outcome; the poll goroutine writes exactly once.
|
||||
done chan error
|
||||
}
|
||||
|
||||
// StartLogin begins a scan-login and returns the QR code to display. It is
|
||||
// idempotent: when a login is already in progress it returns the current QR
|
||||
// without issuing a new get_bot_qrcode, and when a bot token is already present it
|
||||
// returns ErrAlreadyLoggedIn. It never blocks on the user's scan — the status poll
|
||||
// runs in a background goroutine that LoginStatus reports on.
|
||||
func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
|
||||
a.loginMu.Lock()
|
||||
defer a.loginMu.Unlock()
|
||||
|
||||
// Already bound → scanning is moot.
|
||||
if a.hasToken() {
|
||||
return LoginQR{}, ErrAlreadyLoggedIn
|
||||
}
|
||||
if sess := a.loginSession; sess != nil {
|
||||
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
|
||||
}
|
||||
|
||||
raw, err := a.fetchQRCode(ctx)
|
||||
if err != nil {
|
||||
a.setLoginState(LoginStatusFailed, err.Error())
|
||||
return LoginQR{}, fmt.Errorf("weixin_ilink: fetch qrcode: %w", err)
|
||||
}
|
||||
|
||||
sessCtx, cancel := context.WithCancel(context.Background())
|
||||
sess := &loginSession{
|
||||
qrcode: raw.Qrcode,
|
||||
image: raw.QrcodeImgContent,
|
||||
cancel: cancel,
|
||||
done: make(chan error, 1),
|
||||
}
|
||||
a.loginSession = sess
|
||||
a.setLoginState(LoginStatusWaiting, "")
|
||||
// Log only the renderable URL, never the raw qrcode (it is a secret).
|
||||
a.log.Info("scan the qr code to bind the bot", "url", raw.QrcodeImgContent)
|
||||
|
||||
go a.runLogin(sess, sessCtx)
|
||||
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
|
||||
}
|
||||
|
||||
// LoginStatus reports the current scan-login state. A non-empty bot token
|
||||
// overrides to "confirmed" even if a stale session is still recorded, so the panel
|
||||
// always sees the true binding. detail provides the bot id on confirmation and the
|
||||
// failure reason on failure.
|
||||
func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.cfg.BotToken != "" {
|
||||
return LoginStatusConfirmed, a.cfg.BotID
|
||||
}
|
||||
return a.loginStatus, a.loginDetail
|
||||
}
|
||||
|
||||
// hasToken reports whether a bot token is configured. It reads cfg, so callers
|
||||
// must not rely on it while holding a.mu.
|
||||
func (a *Adapter) hasToken() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.cfg.BotToken != ""
|
||||
}
|
||||
|
||||
// setLoginState writes the observable login status under a.mu. It may be called
|
||||
// with loginMu held (StartLogin) or from the poll goroutine; a.mu outlives both.
|
||||
func (a *Adapter) setLoginState(status, detail string) {
|
||||
a.mu.Lock()
|
||||
a.loginStatus = status
|
||||
a.loginDetail = detail
|
||||
a.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package weixin_ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStartLogin_WaitThenConfirm drives the panel flow: StartLogin returns the QR,
|
||||
// the background poll confirms the bind, and LoginStatus transitions.
|
||||
func TestStartLogin_WaitThenConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
|
||||
switch req.path {
|
||||
case "/ilink/bot/get_bot_qrcode":
|
||||
return handlerResult{body: jsonBody(map[string]any{
|
||||
"qrcode": "qr-1", "qrcode_img_content": "data:image/png;base64,QUJD",
|
||||
})}
|
||||
case "/ilink/bot/get_qrcode_status":
|
||||
return handlerResult{body: jsonBody(map[string]any{
|
||||
"status": "confirmed", "bot_token": "tok-x",
|
||||
"ilink_bot_id": "bot-x", "ilink_user_id": "o-1",
|
||||
})}
|
||||
}
|
||||
return handlerResult{status: 404}
|
||||
})
|
||||
|
||||
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second}
|
||||
ad := newAdapter(t, cfg, srv)
|
||||
|
||||
qr, err := ad.StartLogin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("StartLogin: %v", err)
|
||||
}
|
||||
if qr.Qrcode != "qr-1" || qr.Image != "data:image/png;base64,QUJD" {
|
||||
t.Fatalf("StartLogin returned %+v, want qr-1 + image", qr)
|
||||
}
|
||||
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
|
||||
t.Fatalf("LoginStatus right after StartLogin = %q, want waiting", status)
|
||||
}
|
||||
|
||||
waitFor(t, func() bool {
|
||||
s, _ := ad.LoginStatus(context.Background())
|
||||
return s == LoginStatusConfirmed
|
||||
})
|
||||
if got := ad.Config()["bot_token"]; got != "tok-x" {
|
||||
t.Fatalf("bot_token = %v, want tok-x", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_IdempotentWhileWaiting verifies a second StartLogin while a login
|
||||
// is in progress returns the same QR and never issues a second get_bot_qrcode.
|
||||
func TestStartLogin_IdempotentWhileWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
|
||||
if req.path == "/ilink/bot/get_bot_qrcode" {
|
||||
return handlerResult{body: jsonBody(map[string]any{
|
||||
"qrcode": "qr-2", "qrcode_img_content": "https://img/qr",
|
||||
})}
|
||||
}
|
||||
return handlerResult{body: jsonBody(map[string]any{"status": "wait"})}
|
||||
})
|
||||
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Hour, LoginTimeout: 5 * time.Second}
|
||||
ad := newAdapter(t, cfg, srv)
|
||||
|
||||
first, err := ad.StartLogin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("first StartLogin: %v", err)
|
||||
}
|
||||
second, err := ad.StartLogin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("second StartLogin: %v", err)
|
||||
}
|
||||
if first.Qrcode != second.Qrcode {
|
||||
t.Fatalf("second StartLogin qrcode = %q, want %q (idempotent)", second.Qrcode, first.Qrcode)
|
||||
}
|
||||
if got := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })); got != 1 {
|
||||
t.Fatalf("get_bot_qrcode called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_AlreadyLoggedIn verifies StartLogin refuses when a token exists and
|
||||
// LoginStatus reports confirmed.
|
||||
func TestStartLogin_AlreadyLoggedIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := newAdapter(t, Config{BaseURL: "https://example.invalid", BotToken: "tok-pre"}, nil)
|
||||
_, err := ad.StartLogin(context.Background())
|
||||
if !errors.Is(err, ErrAlreadyLoggedIn) {
|
||||
t.Fatalf("StartLogin err = %v, want ErrAlreadyLoggedIn", err)
|
||||
}
|
||||
if status, detail := ad.LoginStatus(context.Background()); status != LoginStatusConfirmed || detail != "" {
|
||||
t.Fatalf("LoginStatus = (%q, %q), want confirmed/empty", status, detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginStatus_Idle verifies a fresh adapter reports idle.
|
||||
func TestLoginStatus_Idle(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := newAdapter(t, Config{BaseURL: "https://example.invalid"}, nil)
|
||||
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusIdle {
|
||||
t.Fatalf("LoginStatus = %q, want idle", status)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user