复审 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 例外一并写入。 每条用例都做了变异验证(去掉守卫 / 把守卫挪进控制流 / 改回原串,均打红)。
276 lines
11 KiB
Go
276 lines
11 KiB
Go
package weixin_ilink
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// loginPollInterval 是 Connect 轮询二维码确认状态的默认间隔。它是文档记录的约 1s
|
||
// 节奏;Config.LoginInterval 字段可覆盖它(测试用它把流程压缩得很短)。
|
||
const loginPollInterval = 1 * time.Second
|
||
|
||
// qrcodeResp 是 get_bot_qrcode 的响应。
|
||
type qrcodeResp struct {
|
||
Qrcode string `json:"qrcode"`
|
||
QrcodeImgContent string `json:"qrcode_img_content"`
|
||
}
|
||
|
||
// 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 携带 status 为 "scaned_but_redirect" 时下一个要轮询的 IDC 主机
|
||
// (官方的 currentApiBaseUrl 重定向)。
|
||
RedirectHost string `json:"redirect_host"`
|
||
}
|
||
|
||
// 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) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
a.loginMu.Lock()
|
||
sess := a.loginSession
|
||
a.loginMu.Unlock()
|
||
if sess == nil {
|
||
return fmt.Errorf("weixin_ilink: login session missing")
|
||
}
|
||
|
||
select {
|
||
case lerr := <-sess.done:
|
||
return lerr
|
||
case <-ctx.Done():
|
||
sess.cancel()
|
||
return fmt.Errorf("%w: %w", ErrLoginTimeout, ctx.Err())
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
ticker := time.NewTicker(a.cfg.LoginInterval)
|
||
defer ticker.Stop()
|
||
|
||
prevStatus := ""
|
||
for {
|
||
status, err := a.pollQRStatus(timeoutCtx, sess.qrcode)
|
||
if err != nil {
|
||
// 轮询期间出现期限到点/取消,意味着整个窗口已过:上报
|
||
// ErrLoginTimeout,而不是原始的传输层错误。
|
||
if timeoutCtx.Err() != nil {
|
||
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
|
||
} else {
|
||
a.failLogin(sess, fmt.Errorf("weixin_ilink: poll qr status: %w", err))
|
||
}
|
||
return
|
||
}
|
||
// 跟踪线上的原始阶段,便于 StartLogin 保护「已扫描/确认中」的绑定不被重新取码。
|
||
// 仅在状态变化时打日志以避免噪声,且绝不记录 qrcode 标识符或 token。
|
||
//
|
||
// 🔴 status 是**上游字段**:进日志前过令牌守卫(trace.ErrorToken)。真实取值
|
||
// (wait / scaned / confirmed / scaned_but_redirect / …)全是短令牌 ⇒ 原样通过;
|
||
// 上游若在这一栏塞散文,这一行就是空串 —— 与 qq_obv11 的 status、
|
||
// trace.UpstreamError 的 code/type 同一道守卫(M50 复审的第五个出口)。
|
||
// **控制流仍用原值**:setPhase 与下面的 switch 都读 status.Status,渲染口径
|
||
// 不参与判定。
|
||
sess.setPhase(status.Status)
|
||
if status.Status != prevStatus {
|
||
prevStatus = status.Status
|
||
a.log.Info("weixin_ilink: get_qrcode_status", "status", trace.ErrorToken(status.Status))
|
||
}
|
||
|
||
switch status.Status {
|
||
case "confirmed":
|
||
if status.BotToken == "" {
|
||
a.failLogin(sess, fmt.Errorf("weixin_ilink: login confirmed but no bot_token returned"))
|
||
return
|
||
}
|
||
a.completeLogin(sess, status)
|
||
return
|
||
case "binded_redirect":
|
||
// 被扫描的机器人已绑定到本实例,不会签发新凭据。把它当作「已绑定」的
|
||
// 成功结果处理。
|
||
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":
|
||
// 码已被扫描,正在手机端等待确认。保留该会话并继续轮询——此处重新取码会
|
||
// 丢弃待处理的确认。用向后兼容的 waiting 状态加一条详情暴露该状态,
|
||
// 便于面板提示用户去确认。
|
||
a.setLoginState(LoginStatusWaiting, loginScannedDetail)
|
||
case "scaned_but_redirect":
|
||
if status.RedirectHost != "" {
|
||
// 🔴 路由用**原值**(下一行),日志只留守卫后的形态:redirect_host 是上游
|
||
// 字段,与上面的 status 同一道令牌守卫(trace.ErrorToken)。真实主机名通过;
|
||
// 带端口(`h:8443`)或散文的整体丢弃 —— 宁可日志少一个字段,也不让这一栏
|
||
// 成为第二个正文通道。
|
||
a.setPollBaseURL(status.RedirectHost)
|
||
a.log.Info("weixin_ilink: idc redirect, switching polling host",
|
||
"host", trace.ErrorToken(status.RedirectHost))
|
||
} else {
|
||
a.log.Warn("weixin_ilink: scaned_but_redirect without redirect_host, continuing on current host")
|
||
}
|
||
case "need_verifycode":
|
||
// 服务端要求输入手机微信上显示的配对码。暴露该状态与提示,便于面板提示
|
||
// 用户;该码本身尚未回传到轮询(见 TODO)。
|
||
a.setLoginState(LoginStatusNeedVerifyCode, loginNeedVerifyCodeHint)
|
||
}
|
||
// wait / scaned / expired / need_verifycode / scaned_but_redirect 会持续轮询,
|
||
// 直到确认或窗口结束。
|
||
select {
|
||
case <-timeoutCtx.Done():
|
||
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
|
||
return
|
||
case <-ticker.C:
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
a.cfg.BotID = status.BotID
|
||
a.cfg.OwnerID = status.UserID
|
||
if status.BaseURL != "" {
|
||
a.cfg.BaseURL = status.BaseURL
|
||
}
|
||
a.clearRescanLocked()
|
||
a.connected = true
|
||
a.loginStatus = LoginStatusConfirmed
|
||
a.loginDetail = ""
|
||
a.mu.Unlock()
|
||
|
||
a.log.Info("weixin_ilink: login confirmed", "bot_id", status.BotID)
|
||
|
||
a.loginMu.Lock()
|
||
if a.loginSession == sess {
|
||
a.loginSession = nil
|
||
}
|
||
a.loginMu.Unlock()
|
||
|
||
sess.done <- nil
|
||
}
|
||
|
||
// 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()
|
||
a.connected = a.cfg.BotToken != "" && !a.rejectedLocked()
|
||
a.loginStatus = LoginStatusConfirmed
|
||
a.loginDetail = loginBindedRedirectDetail
|
||
a.mu.Unlock()
|
||
|
||
a.loginMu.Lock()
|
||
if a.loginSession == sess {
|
||
a.loginSession = nil
|
||
}
|
||
a.loginMu.Unlock()
|
||
|
||
sess.done <- nil
|
||
}
|
||
|
||
// failLogin 记录一次失败的扫码登录并唤醒等待中的 Connect 路径。被取代的会话
|
||
// (因 StartLogin 要为未扫描的码生成新二维码而被取消的那个)绝不会覆盖新会话的状态,
|
||
// 但它等待中的 Connect 仍必须被释放。
|
||
func (a *Adapter) failLogin(sess *loginSession, err error) {
|
||
a.loginMu.Lock()
|
||
isCurrent := a.loginSession == sess
|
||
if isCurrent {
|
||
a.loginSession = nil
|
||
}
|
||
a.loginMu.Unlock()
|
||
|
||
if isCurrent {
|
||
a.log.Warn("weixin_ilink: login failed", "err", err)
|
||
a.setLoginState(LoginStatusFailed, err.Error())
|
||
}
|
||
sess.done <- err
|
||
}
|
||
|
||
// 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{}
|
||
}
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
if a.cfg.BotToken == "" {
|
||
return []string{}
|
||
}
|
||
return []string{a.cfg.BotToken}
|
||
}
|
||
|
||
// 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()})
|
||
if err != nil {
|
||
return qrcodeResp{}, fmt.Errorf("weixin_ilink: marshal get_bot_qrcode body: %w", err)
|
||
}
|
||
var out qrcodeResp
|
||
if err := a.doJSON(ctx, http.MethodPost, path, bytes.NewReader(body), &out); err != nil {
|
||
return qrcodeResp{}, err
|
||
}
|
||
if out.Qrcode == "" {
|
||
return qrcodeResp{}, fmt.Errorf("weixin_ilink: get_bot_qrcode returned empty qrcode")
|
||
}
|
||
// 只记录可渲染的二维码 URL,绝不记录原始 qrcode 标识符(它是回传给
|
||
// get_qrcode_status 的密钥)。时间戳由 logger 附加。
|
||
//
|
||
// 🔴 URL 一律走 trace.MaskQuery(M50 复审补扫):它是**上游给的**串,可能是带签名
|
||
// 的图片地址,也可能整段是 `data:` 内联图 —— 原样落日志等于把二维码(扫码即绑定)
|
||
// 抄进一个会被 tail、采集、粘进工单的面。MaskQuery 打码凭据类 query 值并保留路径,
|
||
// 解析不出 host(data: URI、畸形串)时回空串而**不**回原串。
|
||
a.log.Info("weixin_ilink: get_bot_qrcode ok", "url", trace.MaskQuery(out.QrcodeImgContent))
|
||
return out, nil
|
||
}
|
||
|
||
// 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
|
||
if err := a.doJSON(ctx, http.MethodGet, joinURL(a.pollBase(), path), nil, &out); err != nil {
|
||
return qrcodeStatusResp{}, err
|
||
}
|
||
return out, nil
|
||
}
|