Files
Cyrene_v2/plugins/channels/weixin_ilink/login.go
T
AskaEth 1b2576ecaf fix(weixin): 扫码上游字段过令牌守卫,errortoken 契约改写
复审 p2 两条:

① 第五个出口:get_qrcode_status 的 status 与 scaned_but_redirect 的
redirect_host 此前原样进 INFO;同一 status 经 setPhase 还从复用二维码那条
INFO(phase)二次出镜。三处一律过 trace.ErrorToken;**控制流仍用原值**
(状态机读 status.Status、路由读 status.RedirectHost、confirmingPhase 读
phase),用例同时钉住「日志变空串」与「路由/判定不变」两个方向。

② 顺带补扫出一处 URL 类:get_bot_qrcode 的 qrcode_img_content(上游给的
图片地址,可能是带签名的 URL 或 data: 内联图)原样进 INFO ⇒ 改走
trace.MaskQuery(凭据类 query 打码、保留路径、解析不出 host 回空串)。

③ errortoken.go 的契约改写:原「本包之外不应出现 ErrorToken 调用」已被
M50 的正当调用点证伪。新口径按**字段类别**判定:协议枚举字段(status/
code/type/request_id)可在渲染点调用;自由文本字段(message/description/
msg/wording)不得靠它出镜 —— 那类默认不带,要带必须 opt-in + RedactText。
dashscope 的 request_id 例外一并写入。

每条用例都做了变异验证(去掉守卫 / 把守卫挪进控制流 / 改回原串,均打红)。
2026-10-26 19:41:28 +08:00

276 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。
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。
//
// 🔴 status 是**上游字段**:进日志前过令牌守卫(trace.ErrorToken)。真实取值
// (wait / scaned / confirmed / scaned_but_redirect / …)全是短令牌 ⇒ 原样通过;
// 上游若在这一栏塞散文,这一行就是空串 —— 与 qq_obv11 的 status、
// trace.UpstreamError 的 code/type 同一道守卫(M50 复审的第五个出口)。
// **控制流仍用原值**:setPhase 与下面的 switch 都读 status.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 != "" {
// 🔴 路由用**原值**(下一行),日志只留守卫后的形态:redirect_host 是上游
// 字段,与上面的 status 同一道令牌守卫(trace.ErrorToken)。真实主机名通过;
// 带端口(`h:8443`)或散文的整体丢弃 —— 宁可日志少一个字段,也不让这一栏
// 成为第二个正文通道。
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
}