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

+49 -61
View File
@@ -12,34 +12,32 @@ import (
"time"
)
// loginPollInterval is the default how often Connect polls the QR confirmation
// status. It is the documented ~1s cadence; the Config.LoginInterval field
// overrides it (used by tests to keep the flow short).
// loginPollInterval 是 Connect 轮询二维码确认状态的默认间隔。它是文档记录的约 1s
// 节奏;Config.LoginInterval 字段可覆盖它(测试用它把流程压缩得很短)。
const loginPollInterval = 1 * time.Second
// qrcodeResp is the get_bot_qrcode response.
// qrcodeResp 是 get_bot_qrcode 的响应。
type qrcodeResp struct {
Qrcode string `json:"qrcode"`
QrcodeImgContent string `json:"qrcode_img_content"`
}
// qrcodeStatusResp is the get_qrcode_status response. The token and ids are
// only populated once status is "confirmed".
// 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 carries the IDC host to poll next when status is
// "scaned_but_redirect" (official currentApiBaseUrl redirect).
// RedirectHost 携带 status 为 "scaned_but_redirect" 时下一个要轮询的 IDC 主机
// (官方的 currentApiBaseUrl 重定向)。
RedirectHost string `json:"redirect_host"`
}
// login is the Connect-side scan-code binding (design/08 §4). It joins (or
// starts) a scan-login via StartLogin and blocks until the user confirms or the
// window elapses. Because it shares the session machinery with the panel's
// StartLogin, a panel-initiated login and Connect never run two QR binds at once.
// 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) {
@@ -65,9 +63,8 @@ func (a *Adapter) login(ctx context.Context) error {
}
}
// runLogin polls get_qrcode_status until the QR is confirmed or the login window
// elapses, then records the outcome under the session. It runs in a goroutine so
// StartLogin returns immediately while the panel polls LoginStatus.
// 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()
@@ -78,8 +75,8 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
for {
status, err := a.pollQRStatus(timeoutCtx, sess.qrcode)
if err != nil {
// A deadline/cancel during the poll means the whole window elapsed;
// surface ErrLoginTimeout rather than the raw transport error.
// 轮询期间出现期限到点/取消,意味着整个窗口已过:上报
// ErrLoginTimeout,而不是原始的传输层错误。
if timeoutCtx.Err() != nil {
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
} else {
@@ -87,9 +84,8 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
}
return
}
// Track the raw wire phase so StartLogin can protect a scanned/confirming bind
// from being re-minted. Log only on a status change to avoid noise, and never
// the qrcode identifier or tokens.
// 跟踪线上的原始阶段,便于 StartLogin 保护「已扫描/确认中」的绑定不被重新取码。
// 仅在状态变化时打日志以避免噪声,且绝不记录 qrcode 标识符或 token。
sess.setPhase(status.Status)
if status.Status != prevStatus {
prevStatus = status.Status
@@ -105,18 +101,17 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
a.completeLogin(sess, status)
return
case "binded_redirect":
// The scanned bot is already bound to this instance; no new credentials
// are issued. Treat it as an already-bound (successful) outcome.
// 被扫描的机器人已绑定到本实例,不会签发新凭据。把它当作「已绑定」的
// 成功结果处理。
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":
// The code has been scanned and awaits confirmation on the phone. Keep the
// session and keep polling — re-minting here would discard the pending
// confirmation. Expose the state through the backward-compatible waiting
// status plus a detail so the panel can prompt the user to confirm.
// 码已被扫描,正在手机端等待确认。保留该会话并继续轮询——此处重新取码会
// 丢弃待处理的确认。用向后兼容的 waiting 状态加一条详情暴露该状态,
// 便于面板提示用户去确认。
a.setLoginState(LoginStatusWaiting, loginScannedDetail)
case "scaned_but_redirect":
if status.RedirectHost != "" {
@@ -126,13 +121,12 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
a.log.Warn("weixin_ilink: scaned_but_redirect without redirect_host, continuing on current host")
}
case "need_verifycode":
// The server asks for a pairing code shown in the phone WeChat. Expose
// the state and a hint so the panel can prompt the user; the code itself
// is not yet plumbed back to the poll (see TODO).
// 服务端要求输入手机微信上显示的配对码。暴露该状态与提示,便于面板提示
// 用户;该码本身尚未回传到轮询(见 TODO)。
a.setLoginState(LoginStatusNeedVerifyCode, loginNeedVerifyCodeHint)
}
// wait / scaned / expired / need_verifycode / scaned_but_redirect keep
// polling until confirmed or the window elapses.
// wait / scaned / expired / need_verifycode / scaned_but_redirect 会持续轮询,
// 直到确认或窗口结束。
select {
case <-timeoutCtx.Done():
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
@@ -142,11 +136,10 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
}
}
// completeLogin persists the confirmed credentials and marks the session live.
// It writes cfg under a.mu, then clears the session under loginMu (never nesting
// the two locks), and finally signals the waiting Connect path. A confirmed scan
// delivers a fresh bot_token, which is the canonical way OUT of the rejected state
// (session_state.go): the rescan flag is cleared here.
// 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
@@ -172,13 +165,11 @@ func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
sess.done <- nil
}
// completeBindedRedirect treats binded_redirect as an already-bound outcome: the
// scanned bot is already linked to this instance, so the server issues no new
// credentials and any existing token remains valid. Because NO new token arrives,
// a rejected session stays rejected here — binded_redirect carries no proof that a
// token iLink already rejected with errcode=-14 works again, so the adapter does
// not claim a live session on the strength of it. A confirmed scan (completeLogin)
// is what clears the rejected state.
// 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()
@@ -196,10 +187,9 @@ func (a *Adapter) completeBindedRedirect(sess *loginSession) {
sess.done <- nil
}
// failLogin records a failed scan-login and signals the waiting Connect path. A
// superseded session (one cancelled so StartLogin could mint a fresh QR for an
// unscanned code) never clobbers the newer session's state, but its Connect wait
// must still be released.
// failLogin 记录一次失败的扫码登录并唤醒等待中的 Connect 路径。被取代的会话
// (因 StartLogin 要为未扫描的码生成新二维码而被取消的那个)绝不会覆盖新会话的状态,
// 但它等待中的 Connect 仍必须被释放。
func (a *Adapter) failLogin(sess *loginSession, err error) {
a.loginMu.Lock()
isCurrent := a.loginSession == sess
@@ -215,13 +205,12 @@ func (a *Adapter) failLogin(sess *loginSession, err error) {
sess.done <- err
}
// localTokenList returns the locally-held bot tokens to advertise on
// get_bot_qrcode, so an already-bound bot can be detected (binded_redirect). On a
// fresh bind it returns an empty (non-nil) slice so the wire carries `[]` rather
// than `null`, matching the official client. A token iLink REJECTED is deliberately
// not advertised: it is not a usable bind, and offering it lets the server answer
// binded_redirect (no new credentials) — which would leave the owner stuck with an
// unusable credential and no way back. Reporting `[]` asks for a real rebind.
// 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{}
@@ -234,9 +223,8 @@ func (a *Adapter) localTokenList() []string {
return []string{a.cfg.BotToken}
}
// fetchQRCode requests a new bot login QR code. The official client issues this as
// a POST carrying local_token_list so the server can detect an existing bind and
// answer binded_redirect instead of a fresh code.
// 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()})
@@ -250,14 +238,14 @@ func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
if out.Qrcode == "" {
return qrcodeResp{}, fmt.Errorf("weixin_ilink: get_bot_qrcode returned empty qrcode")
}
// Log only the renderable QR URL, never the raw qrcode identifier (it is a secret
// echoed to get_qrcode_status). The timestamp is attached by the logger.
// 只记录可渲染的二维码 URL,绝不记录原始 qrcode 标识符(它是回传给
// get_qrcode_status 的密钥)。时间戳由 logger 附加。
a.log.Info("weixin_ilink: get_bot_qrcode ok", "url", out.QrcodeImgContent)
return out, nil
}
// pollQRStatus checks whether a QR code has been confirmed. It polls the IDC
// redirect host if one was recorded (scaned_but_redirect), else the base URL.
// 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