Files
Cyrene_v2/plugins/channels/weixin_ilink/login_admin.go
T
Aska c515de28bf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(weixin_ilink): 已扫待确认不重新取码 + scaned 状态暴露 + 扫码链路日志
StartLogin 换码保护: 当 runLogin 观测到 wire 状态已进入
scaned/scaned_but_redirect/need_verifycode(confirmingPhase)时,
复用当前 loginSession 的 QR 而不重新 fetchQRCode/cancel 旧 poll,
避免 45s 自动刷新/恢复前台把已扫未确认的会话砍掉导致微信 confirm 丢失。
仍保留 qrReuseWindow: 未扫描(等待中)且超窗口才重取, 保留显式刷新二维码要新码。

runLogin 补 case scaned: setLoginState(LoginStatusWaiting, loginScannedDetail),
沿用 Waiting 不新增破坏性状态; 顺带在会话上记录 wire phase 供 StartLogin 判别。

可观测性: fetchQRCode 取码成功记 get_bot_qrcode ok(url 脱敏, 不含 qrcode),
pollQRStatus 状态变化才记 get_qrcode_status(status), completeLogin/failLogin/
completeBindedRedirect 记结果; 均不落 qrcode/bot_token/user_id。

failLogin 修正: 被 re-mint superseded 的旧会话不再 clobber 新会话状态(不置 failed)。

新增测试: scaned 暴露到 LoginStatus; scaned 超窗口复用 QR 不重取; 等待中超窗口
重取; 日志脱敏断言。
2026-09-08 01:12:59 +08:00

203 lines
7.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 (
"context"
"errors"
"fmt"
"sync"
"time"
)
// 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"
// LoginStatusNeedVerifyCode means the WeChat server asks for a pairing code
// shown on the phone WeChat; the panel should prompt the user to enter it.
LoginStatusNeedVerifyCode = "need_verifycode"
)
// loginNeedVerifyCodeHint is the detail LoginStatus reports for need_verifycode so
// the panel can prompt the user; loginBindedRedirectDetail is what a binded_redirect
// outcome reports.
const (
loginNeedVerifyCodeHint = "请输入微信显示的数字"
loginBindedRedirectDetail = "已连接过此 OpenClaw,无需重复连接"
// loginScannedDetail is the detail reported while the QR has been scanned and is
// awaiting confirmation on the phone. It reuses LoginStatusWaiting so the panel
// stays backward-compatible; the raw wire "scaned" state is tracked separately on
// the session (loginSession.phase) so StartLogin can protect the bind from being
// re-minted mid-confirmation.
loginScannedDetail = "已被扫描,请在微信确认"
)
// 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
// phase is the latest get_qrcode_status value the poll goroutine observed. It is
// guarded by phaseMu and is distinct from loginStatus, which is the panel-observable
// state and stays backward-compatible ("waiting") during a "scaned" confirmation.
phaseMu sync.Mutex
phase string
}
// setPhase records the latest observed wire status.
func (s *loginSession) setPhase(p string) {
s.phaseMu.Lock()
s.phase = p
s.phaseMu.Unlock()
}
// currentPhase returns the latest observed wire status.
func (s *loginSession) currentPhase() string {
s.phaseMu.Lock()
defer s.phaseMu.Unlock()
return s.phase
}
// confirmingPhase reports whether the QR bind is mid-flow in a way that re-minting a
// fresh code would discard: the code has been scanned and awaits confirmation, the
// server redirected the polling host after a scan, or the server asked for a pairing
// code. Such sessions are reused, never re-minted.
func (s *loginSession) confirmingPhase() bool {
switch s.currentPhase() {
case "scaned", "scaned_but_redirect", "need_verifycode":
return true
default:
return false
}
}
// qrReuseWindow is how long a just-minted QR is reused on an immediate re-click
// before StartLogin re-mints a fresh one. WeChat bot QRs are short-lived, so a
// "扫码登录/刷新二维码" click must yield a brand-new code — reusing an old QR is
// exactly what makes WeChat report "二维码过期". A QR that is already scanned
// (confirmingPhase) is always reused regardless of this window so the bind is not
// dropped mid-confirmation.
const qrReuseWindow = 5 * 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 {
// A bind is in progress. Re-minting would cancel the poll and, if the user has
// already scanned or the server is mid-flow, discard the pending confirmation —
// WeChat's confirm would then never land. Protect those sessions by reusing the
// current QR unconditionally. Only a QR still waiting to be scanned and past the
// tiny reuse window is re-minted, so an explicit "刷新二维码" yields a fresh code.
if sess.confirmingPhase() {
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", sess.currentPhase())
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// A literal double-click within the reuse window: serve the same code.
if time.Since(sess.startedAt) < qrReuseWindow {
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// Not scanned yet and past the reuse window: mint a fresh code, cancelling the
// old (unscanned) poll. failLogin guards a cancelled goroutine from clobbering
// the new session's state.
a.log.Info("weixin_ilink: previous qrcode not scanned, minting a fresh one")
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, "")
// Reset any IDC redirect host recorded by a previous (re-)scan so a new login
// starts polling the configured base URL.
a.setPollBaseURL("")
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()
}