Files
Cyrene_v2/plugins/channels/weixin_ilink/login_admin.go
T
Aska a9dfae4d5f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(weixin): 扫码QR超时自动重发——修'二维码过期'(旧码被幂等返回)
StartLogin 幂等会一直返回同一二维码,但微信bot绑定码有效期极短;此前测试触发过一次登录
会话,旧码过了有效期,用户刷新仍拿到它→扫即'过期'。修: loginSession 加 startedAt,
超过 qrRefreshTTL(90s) 时 StartLogin 取消旧会话并重发新鲜二维码(panel'刷新二维码'永远可扫)。
重启清除内存中旧会话。build/test绿,已部署.
2026-09-07 21:41:43 +08:00

136 lines
4.8 KiB
Go

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 (
"time"
"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
startedAt time.Time
// 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
}
// qrRefreshTTL bounds how long a WeChat bot QR is usable before StartLogin re-mints
// a fresh one. iLink bot QRs are short-lived; an idempotent StartLogin must not keep
// serving a long-expired QR (the user would scan it and get "二维码过期").
const qrRefreshTTL = 90 * time.Second
// 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 {
// Idempotent for a fresh QR, but re-mint once the current QR has aged past
// qrRefreshTTL so the panel's 刷新二维码 always yields a scannable code.
if time.Since(sess.startedAt) < qrRefreshTTL {
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
if sess.cancel != nil {
sess.cancel()
}
a.loginSession = 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,
startedAt: time.Now(),
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()
}