复审 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 例外一并写入。 每条用例都做了变异验证(去掉守卫 / 把守卫挪进控制流 / 改回原串,均打红)。
208 lines
9.2 KiB
Go
208 lines
9.2 KiB
Go
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()
|
||
}
|