Files
Cyrene_v2/plugins/channels/weixin_ilink/login.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

256 lines
8.8 KiB
Go

package weixin_ilink
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
// loginPollInterval is the default how often Connect polls the QR confirmation
// status. It is the documented ~1s cadence; the Config.LoginInterval field
// overrides it (used by tests to keep the flow short).
const loginPollInterval = 1 * time.Second
// qrcodeResp is the get_bot_qrcode response.
type qrcodeResp struct {
Qrcode string `json:"qrcode"`
QrcodeImgContent string `json:"qrcode_img_content"`
}
// qrcodeStatusResp is the get_qrcode_status response. The token and ids are
// only populated once status is "confirmed".
type qrcodeStatusResp struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
BotID string `json:"ilink_bot_id"`
UserID string `json:"ilink_user_id"`
BaseURL string `json:"baseurl"`
// RedirectHost carries the IDC host to poll next when status is
// "scaned_but_redirect" (official currentApiBaseUrl redirect).
RedirectHost string `json:"redirect_host"`
}
// 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 {
_, err := a.StartLogin(ctx)
if errors.Is(err, ErrAlreadyLoggedIn) {
return nil
}
if err != nil {
return err
}
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()
prevStatus := ""
for {
status, err := a.pollQRStatus(timeoutCtx, sess.qrcode)
if err != nil {
// 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
}
// Track the raw wire phase so StartLogin can protect a scanned/confirming bind
// from being re-minted. Log only on a status change to avoid noise, and never
// the qrcode identifier or tokens.
sess.setPhase(status.Status)
if status.Status != prevStatus {
prevStatus = status.Status
a.log.Info("weixin_ilink: get_qrcode_status", "status", status.Status)
}
switch status.Status {
case "confirmed":
if status.BotToken == "" {
a.failLogin(sess, fmt.Errorf("weixin_ilink: login confirmed but no bot_token returned"))
return
}
a.completeLogin(sess, status)
return
case "binded_redirect":
// The scanned bot is already bound to this instance; no new credentials
// are issued. Treat it as an already-bound (successful) outcome.
a.completeBindedRedirect(sess)
return
case "verify_code_blocked":
a.failLogin(sess, fmt.Errorf("weixin_ilink: verify code blocked, too many failed attempts"))
return
case "scaned":
// The code has been scanned and awaits confirmation on the phone. Keep the
// session and keep polling — re-minting here would discard the pending
// confirmation. Expose the state through the backward-compatible waiting
// status plus a detail so the panel can prompt the user to confirm.
a.setLoginState(LoginStatusWaiting, loginScannedDetail)
case "scaned_but_redirect":
if status.RedirectHost != "" {
a.setPollBaseURL(status.RedirectHost)
a.log.Info("weixin_ilink: idc redirect, switching polling host", "host", status.RedirectHost)
} else {
a.log.Warn("weixin_ilink: scaned_but_redirect without redirect_host, continuing on current host")
}
case "need_verifycode":
// The server asks for a pairing code shown in the phone WeChat. Expose
// the state and a hint so the panel can prompt the user; the code itself
// is not yet plumbed back to the poll (see TODO).
a.setLoginState(LoginStatusNeedVerifyCode, loginNeedVerifyCodeHint)
}
// wait / scaned / expired / need_verifycode / scaned_but_redirect keep
// polling until confirmed or the window elapses.
select {
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.log.Info("weixin_ilink: login confirmed", "bot_id", status.BotID)
a.loginMu.Lock()
if a.loginSession == sess {
a.loginSession = nil
}
a.loginMu.Unlock()
sess.done <- nil
}
// completeBindedRedirect treats binded_redirect as an already-bound outcome: the
// scanned bot is already linked to this instance, so the server issues no new
// credentials and any existing token remains valid.
func (a *Adapter) completeBindedRedirect(sess *loginSession) {
a.log.Info("weixin_ilink: login already bound (binded_redirect)")
a.mu.Lock()
a.connected = a.cfg.BotToken != ""
a.loginStatus = LoginStatusConfirmed
a.loginDetail = loginBindedRedirectDetail
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. A
// superseded session (one cancelled so StartLogin could mint a fresh QR for an
// unscanned code) never clobbers the newer session's state, but its Connect wait
// must still be released.
func (a *Adapter) failLogin(sess *loginSession, err error) {
a.loginMu.Lock()
isCurrent := a.loginSession == sess
if isCurrent {
a.loginSession = nil
}
a.loginMu.Unlock()
if isCurrent {
a.log.Warn("weixin_ilink: login failed", "err", err)
a.setLoginState(LoginStatusFailed, err.Error())
}
sess.done <- err
}
// localTokenList returns the locally-held bot tokens to advertise on
// get_bot_qrcode, so an already-bound bot can be detected (binded_redirect). On a
// fresh bind it returns an empty (non-nil) slice so the wire carries `[]` rather
// than `null`, matching the official client.
func (a *Adapter) localTokenList() []string {
a.mu.RLock()
defer a.mu.RUnlock()
if a.cfg.BotToken == "" {
return []string{}
}
return []string{a.cfg.BotToken}
}
// fetchQRCode requests a new bot login QR code. The official client issues this as
// a POST carrying local_token_list so the server can detect an existing bind and
// answer binded_redirect instead of a fresh code.
func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType))
body, err := json.Marshal(map[string]any{"local_token_list": a.localTokenList()})
if err != nil {
return qrcodeResp{}, fmt.Errorf("weixin_ilink: marshal get_bot_qrcode body: %w", err)
}
var out qrcodeResp
if err := a.doJSON(ctx, http.MethodPost, path, bytes.NewReader(body), &out); err != nil {
return qrcodeResp{}, err
}
if out.Qrcode == "" {
return qrcodeResp{}, fmt.Errorf("weixin_ilink: get_bot_qrcode returned empty qrcode")
}
// Log only the renderable QR URL, never the raw qrcode identifier (it is a secret
// echoed to get_qrcode_status). The timestamp is attached by the logger.
a.log.Info("weixin_ilink: get_bot_qrcode ok", "url", out.QrcodeImgContent)
return out, nil
}
// pollQRStatus checks whether a QR code has been confirmed. It polls the IDC
// redirect host if one was recorded (scaned_but_redirect), else the base URL.
func (a *Adapter) pollQRStatus(ctx context.Context, qrcode string) (qrcodeStatusResp, error) {
path := "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
var out qrcodeStatusResp
if err := a.doJSON(ctx, http.MethodGet, joinURL(a.pollBase(), path), nil, &out); err != nil {
return qrcodeStatusResp{}, err
}
return out, nil
}