复审 r2:runLogin 70 → 82 行越过 AGENTS.md §10 的函数 ≤80 上限。代码行本来
只有 56,超的是 M50 加在循环体内的两段 🔴 说明。把 status/redirect_host 的
出镜口径整段上移到函数头注释(不参与计数),循环体内只留一行指向函数头;
**控制流与守卫代码零改动**。
改后 runLogin = 76 行(全 plugins/channels 最长 79,其余均 ≤80)。
278 lines
11 KiB
Go
278 lines
11 KiB
Go
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
|
||
}
|