Files
Cyrene_v2/plugins/channels/weixin_ilink/login.go
T
AskaEth bfbf5057bc refactor(weixin): runLogin 的守卫说明上移到函数头,回到 80 行以内
复审 r2:runLogin 70 → 82 行越过 AGENTS.md §10 的函数 ≤80 上限。代码行本来
只有 56,超的是 M50 加在循环体内的两段 🔴 说明。把 status/redirect_host 的
出镜口径整段上移到函数头注释(不参与计数),循环体内只留一行指向函数头;
**控制流与守卫代码零改动**。

改后 runLogin = 76 行(全 plugins/channels 最长 79,其余均 ≤80)。
2026-10-26 19:48:54 +08:00

278 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin_ilink
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// loginPollInterval 是 Connect 轮询二维码确认状态的默认间隔。它是文档记录的约 1s
// 节奏;Config.LoginInterval 字段可覆盖它(测试用它把流程压缩得很短)。
const loginPollInterval = 1 * time.Second
// qrcodeResp 是 get_bot_qrcode 的响应。
type qrcodeResp struct {
Qrcode string `json:"qrcode"`
QrcodeImgContent string `json:"qrcode_img_content"`
}
// qrcodeStatusResp 是 get_qrcode_status 的响应。只有 status 为 "confirmed" 时才会
// 填充 token 与各类 id。
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 携带 status 为 "scaned_but_redirect" 时下一个要轮询的 IDC 主机
// (官方的 currentApiBaseUrl 重定向)。
RedirectHost string `json:"redirect_host"`
}
// login 是 Connect 一侧的扫码绑定(design/08 §4)。它通过 StartLogin 加入(或发起)
// 一次扫码登录,并阻塞直到用户确认或窗口结束。由于它与面板的 StartLogin 共用同一套
// 会话机制,面板发起的登录与 Connect 绝不会同时跑两个二维码绑定。
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 轮询 get_qrcode_status,直到二维码被确认或登录窗口结束,然后把结果记录在
// 会话上。它运行在 goroutine 中,所以 StartLogin 能立即返回,而面板轮询 LoginStatus。
//
// 🔴 出镜口径(M50):`status` 与 `redirect_host` 都是**上游字段**,进日志前一律过
// trace.ErrorToken(协议枚举字段的那道守卫,判据见 internal/runtime/trace/errortoken.go):
// 真实取值(wait / scaned / confirmed / scaned_but_redirect / idc.weixin.qq.com …)原样
// 通过;上游若在枚举位上塞散文,那一栏整体变空串。
//
// 🔴 **控制流只用原值**:setPhase、下面的 switch、setPollBaseURL 与 confirmingPhase 读
// 的都是原始串 —— 渲染口径绝不参与判定。一个图省事的修法是把守卫后的值喂给
// setPollBaseURL,那会让带端口的合法重定向主机整体失效(用例
// TestStartLogin_ProseRedirectHostNeverReachesLog 钉的就是这个方向)。
//
// 同一个 status 还有第二个日志出口(复用二维码时那条 INFO 打 currentPhase),在
// login_admin.go 里过同一道守卫。
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 {
// 轮询期间出现期限到点/取消,意味着整个窗口已过:上报
// ErrLoginTimeout,而不是原始的传输层错误。
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
}
// status 进日志前过令牌守卫(口径见函数头);控制流仍用原值。
sess.setPhase(status.Status)
if status.Status != prevStatus {
prevStatus = status.Status
a.log.Info("weixin_ilink: get_qrcode_status", "status", trace.ErrorToken(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":
// 被扫描的机器人已绑定到本实例,不会签发新凭据。把它当作「已绑定」的
// 成功结果处理。
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":
// 码已被扫描,正在手机端等待确认。保留该会话并继续轮询——此处重新取码会
// 丢弃待处理的确认。用向后兼容的 waiting 状态加一条详情暴露该状态,
// 便于面板提示用户去确认。
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", trace.ErrorToken(status.RedirectHost))
} else {
a.log.Warn("weixin_ilink: scaned_but_redirect without redirect_host, continuing on current host")
}
case "need_verifycode":
// 服务端要求输入手机微信上显示的配对码。暴露该状态与提示,便于面板提示
// 用户;该码本身尚未回传到轮询(见 TODO)。
a.setLoginState(LoginStatusNeedVerifyCode, loginNeedVerifyCodeHint)
}
// wait / scaned / expired / need_verifycode / scaned_but_redirect 会持续轮询,
// 直到确认或窗口结束。
select {
case <-timeoutCtx.Done():
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
return
case <-ticker.C:
}
}
}
// completeLogin 持久化已确认的凭据并把会话标记为存活。它在 a.mu 下写 cfg,再在
// loginMu 下清空会话(绝不嵌套两把锁),最后唤醒等待中的 Connect 路径。确认的扫码
// 会交付新的 bot_token,这是走出「被拒绝」状态的正规途径(session_state.go):
// 重扫标志就在这里被清除。
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.clearRescanLocked()
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 把 binded_redirect 当作「已绑定」的结果:被扫描的机器人
// 已关联到本实例,因此服务端不签发新凭据,已有 token 继续有效。由于**没有**新 token
// 到达,被拒绝的会话在这里保持被拒绝——binded_redirect 并不证明一份已被 iLink 以
// errcode=-14 拒绝的 token 重新可用,所以适配器不凭它宣称会话存活。清除被拒绝状态的
// 是确认的扫码(completeLogin)。
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.rejectedLocked()
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 记录一次失败的扫码登录并唤醒等待中的 Connect 路径。被取代的会话
// (因 StartLogin 要为未扫描的码生成新二维码而被取消的那个)绝不会覆盖新会话的状态,
// 但它等待中的 Connect 仍必须被释放。
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 返回本机持有的 bot token,用于在 get_bot_qrcode 上上报,以便服务端
// 识别出已绑定的机器人(binded_redirect)。全新绑定时返回空的(非 nil)切片,使线上
// 载荷携带 `[]` 而不是 `null`,与官方客户端一致。已被 iLink **拒绝**的 token 刻意
// 不上报:它不是可用绑定,把它交出去会让服务端回以 binded_redirect(不签发新凭据)
// ——那会让主人卡在一份不可用的凭据上、没有回头路。上报 `[]` 才是请求一次真正的
// 重新绑定。
func (a *Adapter) localTokenList() []string {
if rejected, _ := a.rejectedState(); rejected {
return []string{}
}
a.mu.RLock()
defer a.mu.RUnlock()
if a.cfg.BotToken == "" {
return []string{}
}
return []string{a.cfg.BotToken}
}
// fetchQRCode 申请一个新的机器人登录二维码。官方客户端以 POST 携带 local_token_list
// 发出该请求,使服务端能探测到已存在的绑定并回以 binded_redirect,而不是新码。
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")
}
// 只记录可渲染的二维码 URL,绝不记录原始 qrcode 标识符(它是回传给
// get_qrcode_status 的密钥)。时间戳由 logger 附加。
//
// 🔴 URL 一律走 trace.MaskQuery(M50 复审补扫):它是**上游给的**串,可能是带签名
// 的图片地址,也可能整段是 `data:` 内联图 —— 原样落日志等于把二维码(扫码即绑定)
// 抄进一个会被 tail、采集、粘进工单的面。MaskQuery 打码凭据类 query 值并保留路径,
// 解析不出 host(data: URI、畸形串)时回空串而**不**回原串。
a.log.Info("weixin_ilink: get_bot_qrcode ok", "url", trace.MaskQuery(out.QrcodeImgContent))
return out, nil
}
// pollQRStatus 检查某个二维码是否已确认。若已记录 IDC 重定向主机
// (scaned_but_redirect)则轮询它,否则轮询基础 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
}