docs(comments): 注释中文化 · plugins/channels/weixin_ilink(20 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(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 行)
This commit is contained in:
AskaEth committed 2026-10-04 12:29:05 +08:00
1 parent 09fba77f87
commit 95f97f5df3
20 files changed
+488 -590

No files matched your search

+64 -84
View File
@@ -1,11 +1,9 @@
package weixin_ilink
// This file exposes the non-blocking scan-login surface the admin panel drives
// (design/08 §4): StartLogin mints (or returns the in-progress) QR code without
// blocking, and LoginStatus reports where the bind stands so the panel can poll
// idle → waiting → confirmed. Connect (login.go) reuses StartLogin internally and
// then waits, so a panel-initiated login and the bridge's Connect never start two
// concurrent QR binds.
// 本文件暴露管理面板驱动的非阻塞扫码登录接口(design/08 §4):StartLogin 不阻塞地
// 生成(或返回进行中的)二维码,LoginStatus 报告绑定的进度,便于面板轮询
// idle → waiting → confirmed。Connect(login.go)内部复用 StartLogin 再等待,因此
// 面板发起的登录与桥接层的 Connect 永远不会并发启动两次二维码绑定。
import (
"context"
@@ -15,84 +13,77 @@ import (
"time"
)
// Login status values reported by Adapter.LoginStatus.
// Adapter.LoginStatus 上报的登录状态取值。
const (
// LoginStatusIdle means no bot token and no scan-login in progress.
// LoginStatusIdle 表示既无 bot token,也无进行中的扫码登录。
LoginStatusIdle = "idle"
// LoginStatusWaiting means a QR code is displayed and the user has not yet
// confirmed the bind.
// LoginStatusWaiting 表示二维码已展示,用户尚未确认绑定。
LoginStatusWaiting = "waiting"
// LoginStatusConfirmed means a bot token is present (either a fresh scan or a
// pre-configured one).
// LoginStatusConfirmed 表示已有 bot token(无论是刚扫码得到,还是预先配置的)。
LoginStatusConfirmed = "confirmed"
// LoginStatusFailed means the last scan/login attempt errored or timed out.
// LoginStatusFailed 表示最近一次扫码/登录尝试出错或超时。
LoginStatusFailed = "failed"
// LoginStatusNeedVerifyCode means the WeChat server asks for a pairing code
// shown on the phone WeChat; the panel should prompt the user to enter it.
// LoginStatusNeedVerifyCode 表示微信服务端要求输入手机微信上显示的配对码;
// 面板应提示用户输入。
LoginStatusNeedVerifyCode = "need_verifycode"
)
// loginNeedVerifyCodeHint is the detail LoginStatus reports for need_verifycode so
// the panel can prompt the user; loginBindedRedirectDetail is what a binded_redirect
// outcome reports.
// loginNeedVerifyCodeHint 是 LoginStatus 在 need_verifycode 时上报的详情,便于面板
// 提示用户;loginBindedRedirectDetail 是 binded_redirect 结果上报的内容。
const (
loginNeedVerifyCodeHint = "请输入微信显示的数字"
loginBindedRedirectDetail = "已连接过此 OpenClaw,无需重复连接"
// loginScannedDetail is the detail reported while the QR has been scanned and is
// awaiting confirmation on the phone. It reuses LoginStatusWaiting so the panel
// stays backward-compatible; the raw wire "scaned" state is tracked separately on
// the session (loginSession.phase) so StartLogin can protect the bind from being
// re-minted mid-confirmation.
// loginScannedDetail 是二维码已被扫描、正在手机端等待确认期间上报的详情。它复用
// LoginStatusWaiting,使面板保持向后兼容;线上的原始 "scaned" 状态另行记录在会话上
// (loginSession.phase),使 StartLogin 能保护该绑定不在确认途中被重新取码。
loginScannedDetail = "已被扫描,请在微信确认"
)
// ErrAlreadyLoggedIn is returned by StartLogin when a bot token is already
// configured, so the scan flow is moot.
// ErrAlreadyLoggedIn 由 StartLogin 在已配置 bot token 时返回,此时扫码流程已无必要。
var ErrAlreadyLoggedIn = errors.New("weixin_ilink: already logged in")
// LoginQR is the artifact StartLogin returns for the panel to render: Qrcode is
// the raw scan identifier the adapter echoes to get_qrcode_status, and Image is
// the QR image content (currently the image URL returned by get_bot_qrcode).
// LoginQR 是 StartLogin 返回给面板渲染的产物:Qrcode 是适配器回传给
// get_qrcode_status 的原始扫码标识符,Image 是二维码图片内容(当前为
// get_bot_qrcode 返回的图片 URL)。
type LoginQR struct {
Qrcode string
Image string
}
// loginSession tracks one scan-login run. It is guarded by Adapter.loginMu.
// loginSession 跟踪一次扫码登录。它由 Adapter.loginMu 保护。
type loginSession struct {
qrcode string
image string
startedAt time.Time
// cancel stops the poll goroutine early (e.g. Connect's deadline elapsed).
// cancel 提前停止轮询 goroutine(例如 Connect 的期限已到)。
cancel context.CancelFunc
// done carries the login outcome; the poll goroutine writes exactly once.
// done 承载登录结果;轮询 goroutine 恰好写入一次。
done chan error
// phase is the latest get_qrcode_status value the poll goroutine observed. It is
// guarded by phaseMu and is distinct from loginStatus, which is the panel-observable
// state and stays backward-compatible ("waiting") during a "scaned" confirmation.
// phase 是轮询 goroutine 观测到的最新 get_qrcode_status 取值。它由 phaseMu 保护,
// 且不同于 loginStatus——后者是面板可见状态,在 "scaned" 确认期间保持向后兼容的
// "waiting"。
phaseMu sync.Mutex
phase string
}
// setPhase records the latest observed wire status.
// setPhase 记录最近观测到的线上状态。
func (s *loginSession) setPhase(p string) {
s.phaseMu.Lock()
s.phase = p
s.phaseMu.Unlock()
}
// currentPhase returns the latest observed wire status.
// currentPhase 返回最近观测到的线上状态。
func (s *loginSession) currentPhase() string {
s.phaseMu.Lock()
defer s.phaseMu.Unlock()
return s.phase
}
// confirmingPhase reports whether the QR bind is mid-flow in a way that re-minting a
// fresh code would discard: the code has been scanned and awaits confirmation, the
// server redirected the polling host after a scan, or the server asked for a pairing
// code. Such sessions are reused, never re-minted.
// confirmingPhase 报告该二维码绑定是否正处于「重新取码会丢弃进度」的流程中:
// 码已被扫描、等待确认;服务端在扫描后重定向了轮询主机;或服务端要求配对码。
// 这类会话一律复用,绝不重新取码。
func (s *loginSession) confirmingPhase() bool {
switch s.currentPhase() {
case "scaned", "scaned_but_redirect", "need_verifycode":
@@ -102,52 +93,45 @@ func (s *loginSession) confirmingPhase() bool {
}
}
// qrReuseWindow is how long a just-minted QR is reused on an immediate re-click
// before StartLogin re-mints a fresh one. WeChat bot QRs are short-lived, so a
// "扫码登录/刷新二维码" click must yield a brand-new code — reusing an old QR is
// exactly what makes WeChat report "二维码过期". A QR that is already scanned
// (confirmingPhase) is always reused regardless of this window so the bind is not
// dropped mid-confirmation.
// qrReuseWindow 是刚生成的二维码在立即重复点击时被复用的时长,超过它 StartLogin
// 才会重新取码。微信机器人二维码寿命很短,所以「扫码登录/刷新二维码」的点击必须
// 给出全新码——复用旧码正是让微信报「二维码过期」的原因。已扫描的二维码
// (confirmingPhase)无论该窗口如何都一律复用,使绑定不会在确认途中被丢弃。
const qrReuseWindow = 5 * time.Second
// StartLogin begins a scan-login and returns the QR code to display. It is
// idempotent: when a login is already in progress it returns the current QR
// without issuing a new get_bot_qrcode, and when a bot token is already present it
// returns ErrAlreadyLoggedIn. It never blocks on the user's scan — the status poll
// runs in a background goroutine that LoginStatus reports on.
// 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()
// Already bound → scanning is moot — unless iLink REJECTED the stored token
// (errcode=-14), so it is not a usable bind and the owner must be able to
// re-scan (otherwise the only way out of the rejected state would be
// unreachable). A live token still short-circuits the QR flow as before.
// 已绑定 → 扫码已无必要——除非 iLink 已**拒绝**该存量 token(errcode=-14),
// 此时它不是可用绑定,主人必须能够重新扫码(否则「离开被拒绝状态」的唯一出路
// 将不可达)。仍可用的 token 照旧直接短路扫码流程。
if rejected, _ := a.rejectedState(); a.hasToken() && !rejected {
return LoginQR{}, ErrAlreadyLoggedIn
}
if sess := a.loginSession; sess != nil {
// A bind is in progress. Re-minting would cancel the poll and, if the user has
// already scanned or the server is mid-flow, discard the pending confirmation —
// WeChat's confirm would then never land. Protect those sessions by reusing the
// current QR unconditionally. Only a QR still waiting to be scanned and past the
// tiny reuse window is re-minted, so an explicit "刷新二维码" yields a fresh code.
// 绑定进行中。重新取码会取消轮询;若用户已扫码或服务端正处于流程中途,
// 还会丢弃待处理的确认——微信那边的确认将永远无法落地。因此对这些会话
// 无条件复用当前二维码加以保护。只有仍未被扫描、且已过那个极小复用窗口的
// 二维码才会被重新生成,使显式的「刷新二维码」能拿到全新码。
if sess.confirmingPhase() {
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", sess.currentPhase())
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// Serve the same code while it is still fresh OR already scanned — always
// reuse, never swap, so an in-progress bind (which WeChat may be confirming
// right now) is never dropped. A frontend flooding login-qr calls therefore
// cannot cancel the session under the user's scan. Only a genuinely stale
// (>60s server TTL) and never-scanned code is re-minted.
// 码仍然新鲜或已被扫描时都返回同一个码——一律复用、绝不替换,使进行中的
// 绑定(微信此刻可能正在确认)永不被丢弃。于是前端的 login-qr 洪泛也无法在
// 用户扫码期间取消会话。只有真正过期(超过服务端 60s TTL)且从未被扫描的码
// 才会重新生成。
if time.Since(sess.startedAt) < 60*time.Second {
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", sess.currentPhase())
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// Code stale (>60s) and never scanned: mint a fresh one, cancelling the old
// (expired, unscanned) poll. failLogin guards a cancelled goroutine from
// clobbering the new session's state.
// 码已过期(>60s)且从未被扫描:生成新码,并取消旧的那次(已过期、未扫描的)
// 轮询。failLogin 会防止被取消的 goroutine 覆盖新会话的状态。
a.log.Info("weixin_ilink: previous qrcode not scanned, minting a fresh one")
if sess.cancel != nil {
sess.cancel()
@@ -171,29 +155,25 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
}
a.loginSession = sess
a.setLoginState(LoginStatusWaiting, "")
// Reset any IDC redirect host recorded by a previous (re-)scan so a new login
// starts polling the configured base URL.
// 清掉上一次(重新)扫码记录的 IDC 重定向主机,使新登录从已配置的基础 URL
// 开始轮询。
a.setPollBaseURL("")
go a.runLogin(sess, sessCtx)
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// LoginStatus reports the current scan-login state. A non-empty bot token
// overrides to "confirmed" even if a stale session is still recorded, so the panel
// always sees the true binding — except when iLink REJECTED that token
// (errcode=-14), so reporting "confirmed" would make the panel re-persist a dead
// credential and call the bind a success. A rejected bind is reported as failed
// with the re-scan reason instead, while a re-scan already in progress
// keeps its own state. detail provides the bot id on confirmation and the failure
// reason on failure.
// 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 {
// A re-scan the owner already started: report its progress, not the
// rejected state it is meant to replace.
// 主人已经发起的重扫:上报它自己的进度,而不是它本要取代的那个被拒绝状态。
return a.loginStatus, a.loginDetail
}
return LoginStatusFailed, a.rescanDetail
@@ -204,16 +184,16 @@ func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
return a.loginStatus, a.loginDetail
}
// hasToken reports whether a bot token is configured. It reads cfg, so callers
// must not rely on it while holding a.mu.
// hasToken 报告是否已配置 bot token。它会读 cfg,因此调用方不得在持有 a.mu 时
// 依赖它。
func (a *Adapter) hasToken() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg.BotToken != ""
}
// setLoginState writes the observable login status under a.mu. It may be called
// with loginMu held (StartLogin) or from the poll goroutine; a.mu outlives both.
// setLoginState 在 a.mu 下写入可观测的登录状态。它可能在持有 loginMu 时被调用
// (StartLogin),也可能从轮询 goroutine 调用;a.mu 的生命周期长于两者。
func (a *Adapter) setLoginState(status, detail string) {
a.mu.Lock()
a.loginStatus = status