Files
Cyrene_v2/plugins/channels/weixin_ilink/login.go
T
AskaEth 95f97f5df3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · plugins/channels/weixin_ilink(20 文件,纯替换)
- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动)
- 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等
- 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误
- 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
2026-10-04 12:29:05 +08:00

257 lines
9.5 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"
)
// 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。
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
}
// 跟踪线上的原始阶段,便于 StartLogin 保护「已扫描/确认中」的绑定不被重新取码。
// 仅在状态变化时打日志以避免噪声,且绝不记录 qrcode 标识符或 token。
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":
// 被扫描的机器人已绑定到本实例,不会签发新凭据。把它当作「已绑定」的
// 成功结果处理。
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", 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 附加。
a.log.Info("weixin_ilink: get_bot_qrcode ok", "url", 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
}