feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
(锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
This commit is contained in:
1 parent
0a597cfa42
commit
e037fab7d0
13 files changed
+1155
-46
No files matched your search
@@ -2,6 +2,7 @@ package weixin_ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -30,61 +31,107 @@ type qrcodeStatusResp struct {
|
||||
BaseURL string `json:"baseurl"`
|
||||
}
|
||||
|
||||
// login runs the one-time scan-code binding (design/08 §4): request a QR code,
|
||||
// then poll get_qrcode_status until the user confirms. On confirmation it
|
||||
// persists bot_token, bot_id, owner_id and any returned baseurl, so later
|
||||
// requests carry the Bearer token.
|
||||
// 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.
|
||||
func (a *Adapter) login(ctx context.Context) error {
|
||||
loginCtx, cancel := context.WithTimeout(ctx, a.cfg.LoginTimeout)
|
||||
defer cancel()
|
||||
|
||||
qr, err := a.fetchQRCode(loginCtx)
|
||||
_, err := a.StartLogin(ctx)
|
||||
if errors.Is(err, ErrAlreadyLoggedIn) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
if loginCtx.Err() != nil {
|
||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
|
||||
}
|
||||
return fmt.Errorf("weixin_ilink: fetch qrcode: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.qrcode = qr.Qrcode
|
||||
a.mu.Unlock()
|
||||
// Log only the renderable URL, never the raw qrcode (it is a secret).
|
||||
a.log.Info("scan the qr code to bind the bot", "url", qr.QrcodeImgContent)
|
||||
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 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.
|
||||
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()
|
||||
|
||||
for {
|
||||
status, err := a.pollQRStatus(loginCtx, qr.Qrcode)
|
||||
status, err := a.pollQRStatus(timeoutCtx, sess.qrcode)
|
||||
if err != nil {
|
||||
// A deadline/cancel during the poll means the overall window
|
||||
// elapsed; surface ErrLoginTimeout rather than the raw transport
|
||||
// error.
|
||||
if loginCtx.Err() != nil {
|
||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
|
||||
// A deadline/cancel during the poll means the whole window elapsed;
|
||||
// surface ErrLoginTimeout rather than the raw transport error.
|
||||
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 fmt.Errorf("weixin_ilink: poll qr status: %w", err)
|
||||
return
|
||||
}
|
||||
if status.Status == "confirmed" && status.BotToken != "" {
|
||||
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.mu.Unlock()
|
||||
return nil
|
||||
a.completeLogin(sess, status)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-loginCtx.Done():
|
||||
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
|
||||
case <-timeoutCtx.Done():
|
||||
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.connected = true
|
||||
a.loginStatus = LoginStatusConfirmed
|
||||
a.loginDetail = ""
|
||||
a.mu.Unlock()
|
||||
|
||||
a.loginMu.Lock()
|
||||
if a.loginSession == sess {
|
||||
a.loginSession = nil
|
||||
}
|
||||
a.loginMu.Unlock()
|
||||
|
||||
sess.done <- nil
|
||||
}
|
||||
|
||||
// failLogin records a failed scan-login and signals the waiting Connect path.
|
||||
func (a *Adapter) failLogin(sess *loginSession, err error) {
|
||||
a.setLoginState(LoginStatusFailed, err.Error())
|
||||
|
||||
a.loginMu.Lock()
|
||||
if a.loginSession == sess {
|
||||
a.loginSession = nil
|
||||
}
|
||||
a.loginMu.Unlock()
|
||||
|
||||
sess.done <- err
|
||||
}
|
||||
|
||||
// fetchQRCode requests a new bot login QR code.
|
||||
func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
|
||||
path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType))
|
||||
|
||||
Reference in new issue
Block a user