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

208 lines
9.2 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
// 本文件暴露管理面板驱动的非阻塞扫码登录接口(design/08 §4):StartLogin 不阻塞地
// 生成(或返回进行中的)二维码,LoginStatus 报告绑定的进度,便于面板轮询
// idle → waiting → confirmed。Connect(login.go)内部复用 StartLogin 再等待,因此
// 面板发起的登录与桥接层的 Connect 永远不会并发启动两次二维码绑定。
import (
"context"
"errors"
"fmt"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Adapter.LoginStatus 上报的登录状态取值。
const (
// LoginStatusIdle 表示既无 bot token,也无进行中的扫码登录。
LoginStatusIdle = "idle"
// LoginStatusWaiting 表示二维码已展示,用户尚未确认绑定。
LoginStatusWaiting = "waiting"
// LoginStatusConfirmed 表示已有 bot token(无论是刚扫码得到,还是预先配置的)。
LoginStatusConfirmed = "confirmed"
// LoginStatusFailed 表示最近一次扫码/登录尝试出错或超时。
LoginStatusFailed = "failed"
// LoginStatusNeedVerifyCode 表示微信服务端要求输入手机微信上显示的配对码;
// 面板应提示用户输入。
LoginStatusNeedVerifyCode = "need_verifycode"
)
// loginNeedVerifyCodeHint 是 LoginStatus 在 need_verifycode 时上报的详情,便于面板
// 提示用户;loginBindedRedirectDetail 是 binded_redirect 结果上报的内容。
const (
loginNeedVerifyCodeHint = "请输入微信显示的数字"
loginBindedRedirectDetail = "已连接过此 OpenClaw,无需重复连接"
// loginScannedDetail 是二维码已被扫描、正在手机端等待确认期间上报的详情。它复用
// LoginStatusWaiting,使面板保持向后兼容;线上的原始 "scaned" 状态另行记录在会话上
// (loginSession.phase),使 StartLogin 能保护该绑定不在确认途中被重新取码。
loginScannedDetail = "已被扫描,请在微信确认"
)
// ErrAlreadyLoggedIn 由 StartLogin 在已配置 bot token 时返回,此时扫码流程已无必要。
var ErrAlreadyLoggedIn = errors.New("weixin_ilink: already logged in")
// LoginQR 是 StartLogin 返回给面板渲染的产物:Qrcode 是适配器回传给
// get_qrcode_status 的原始扫码标识符,Image 是二维码图片内容(当前为
// get_bot_qrcode 返回的图片 URL)。
type LoginQR struct {
Qrcode string
Image string
}
// loginSession 跟踪一次扫码登录。它由 Adapter.loginMu 保护。
type loginSession struct {
qrcode string
image string
startedAt time.Time
// cancel 提前停止轮询 goroutine(例如 Connect 的期限已到)。
cancel context.CancelFunc
// done 承载登录结果;轮询 goroutine 恰好写入一次。
done chan error
// phase 是轮询 goroutine 观测到的最新 get_qrcode_status 取值。它由 phaseMu 保护,
// 且不同于 loginStatus——后者是面板可见状态,在 "scaned" 确认期间保持向后兼容的
// "waiting"。
phaseMu sync.Mutex
phase string
}
// setPhase 记录最近观测到的线上状态。
func (s *loginSession) setPhase(p string) {
s.phaseMu.Lock()
s.phase = p
s.phaseMu.Unlock()
}
// currentPhase 返回最近观测到的线上状态。
func (s *loginSession) currentPhase() string {
s.phaseMu.Lock()
defer s.phaseMu.Unlock()
return s.phase
}
// confirmingPhase 报告该二维码绑定是否正处于「重新取码会丢弃进度」的流程中:
// 码已被扫描、等待确认;服务端在扫描后重定向了轮询主机;或服务端要求配对码。
// 这类会话一律复用,绝不重新取码。
func (s *loginSession) confirmingPhase() bool {
switch s.currentPhase() {
case "scaned", "scaned_but_redirect", "need_verifycode":
return true
default:
return false
}
}
// qrReuseWindow 是刚生成的二维码在立即重复点击时被复用的时长,超过它 StartLogin
// 才会重新取码。微信机器人二维码寿命很短,所以「扫码登录/刷新二维码」的点击必须
// 给出全新码——复用旧码正是让微信报「二维码过期」的原因。已扫描的二维码
// (confirmingPhase)无论该窗口如何都一律复用,使绑定不会在确认途中被丢弃。
const qrReuseWindow = 5 * time.Second
// StartLogin 发起一次扫码登录并返回待展示的二维码。它是幂等的:已有登录进行中时
// 直接返回当前二维码、不再请求新的 get_bot_qrcode;已有 bot token 时返回
// ErrAlreadyLoggedIn。它绝不阻塞等待用户扫码——状态轮询运行在后台 goroutine 中,
// 由 LoginStatus 上报。
func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
a.loginMu.Lock()
defer a.loginMu.Unlock()
// 已绑定 → 扫码已无必要——除非 iLink 已**拒绝**该存量 token(errcode=-14),
// 此时它不是可用绑定,主人必须能够重新扫码(否则「离开被拒绝状态」的唯一出路
// 将不可达)。仍可用的 token 照旧直接短路扫码流程。
if rejected, _ := a.rejectedState(); a.hasToken() && !rejected {
return LoginQR{}, ErrAlreadyLoggedIn
}
if sess := a.loginSession; sess != nil {
// 绑定进行中。重新取码会取消轮询;若用户已扫码或服务端正处于流程中途,
// 还会丢弃待处理的确认——微信那边的确认将永远无法落地。因此对这些会话
// 无条件复用当前二维码加以保护。只有仍未被扫描、且已过那个极小复用窗口的
// 二维码才会被重新生成,使显式的「刷新二维码」能拿到全新码。
if sess.confirmingPhase() {
// phase 是**上游字段**(`get_qrcode_status` 的 status 原值经 setPhase 存下),
// 因此进日志前过令牌守卫 —— 与 login.go 里那一行同一道(M50 复审:同一字段
// 的第二个出口)。`confirmingPhase()` 的判定用的是原值,不受渲染口径影响。
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", trace.ErrorToken(sess.currentPhase()))
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// 码仍然新鲜或已被扫描时都返回同一个码——一律复用、绝不替换,使进行中的
// 绑定(微信此刻可能正在确认)永不被丢弃。于是前端的 login-qr 洪泛也无法在
// 用户扫码期间取消会话。只有真正过期(超过服务端 60s TTL)且从未被扫描的码
// 才会重新生成。
if time.Since(sess.startedAt) < 60*time.Second {
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", trace.ErrorToken(sess.currentPhase()))
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// 码已过期(>60s)且从未被扫描:生成新码,并取消旧的那次(已过期、未扫描的)
// 轮询。failLogin 会防止被取消的 goroutine 覆盖新会话的状态。
a.log.Info("weixin_ilink: previous qrcode not scanned, minting a fresh one")
if sess.cancel != nil {
sess.cancel()
}
a.loginSession = nil
}
raw, err := a.fetchQRCode(ctx)
if err != nil {
a.setLoginState(LoginStatusFailed, err.Error())
return LoginQR{}, fmt.Errorf("weixin_ilink: fetch qrcode: %w", err)
}
sessCtx, cancel := context.WithCancel(context.Background())
sess := &loginSession{
qrcode: raw.Qrcode,
image: raw.QrcodeImgContent,
startedAt: time.Now(),
cancel: cancel,
done: make(chan error, 1),
}
a.loginSession = sess
a.setLoginState(LoginStatusWaiting, "")
// 清掉上一次(重新)扫码记录的 IDC 重定向主机,使新登录从已配置的基础 URL
// 开始轮询。
a.setPollBaseURL("")
go a.runLogin(sess, sessCtx)
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// LoginStatus 上报当前扫码登录状态。非空 bot token 会强制覆盖为 "confirmed",
// 即使仍记录着陈旧会话,从而面板总能看见真实绑定——除非 iLink **拒绝**了该 token
// (errcode=-14):此时上报 "confirmed" 会让面板重新持久化一份死凭据、并把绑定
// 当作成功。被拒绝的绑定改为以 failed 加上重扫原因上报,而已经进行中的重扫则保留
// 它自己的状态。detail 在确认时给出 bot id,在失败时给出失败原因。
func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
a.mu.Lock()
defer a.mu.Unlock()
if a.rejectedLocked() {
if a.loginStatus == LoginStatusWaiting || a.loginStatus == LoginStatusNeedVerifyCode {
// 主人已经发起的重扫:上报它自己的进度,而不是它本要取代的那个被拒绝状态。
return a.loginStatus, a.loginDetail
}
return LoginStatusFailed, a.rescanDetail
}
if a.cfg.BotToken != "" {
return LoginStatusConfirmed, a.cfg.BotID
}
return a.loginStatus, a.loginDetail
}
// hasToken 报告是否已配置 bot token。它会读 cfg,因此调用方不得在持有 a.mu 时
// 依赖它。
func (a *Adapter) hasToken() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg.BotToken != ""
}
// setLoginState 在 a.mu 下写入可观测的登录状态。它可能在持有 loginMu 时被调用
// (StartLogin),也可能从轮询 goroutine 调用;a.mu 的生命周期长于两者。
func (a *Adapter) setLoginState(status, detail string) {
a.mu.Lock()
a.loginStatus = status
a.loginDetail = detail
a.mu.Unlock()
}