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() }