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
@@ -0,0 +1,119 @@
|
||||
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.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Login status values reported by Adapter.LoginStatus.
|
||||
const (
|
||||
// LoginStatusIdle means no bot token and no scan-login in progress.
|
||||
LoginStatusIdle = "idle"
|
||||
// LoginStatusWaiting means a QR code is displayed and the user has not yet
|
||||
// confirmed the bind.
|
||||
LoginStatusWaiting = "waiting"
|
||||
// LoginStatusConfirmed means a bot token is present (either a fresh scan or a
|
||||
// pre-configured one).
|
||||
LoginStatusConfirmed = "confirmed"
|
||||
// LoginStatusFailed means the last scan/login attempt errored or timed out.
|
||||
LoginStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// ErrAlreadyLoggedIn is returned by StartLogin when a bot token is already
|
||||
// configured, so the scan flow is moot.
|
||||
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).
|
||||
type LoginQR struct {
|
||||
Qrcode string
|
||||
Image string
|
||||
}
|
||||
|
||||
// loginSession tracks one scan-login run. It is guarded by Adapter.loginMu.
|
||||
type loginSession struct {
|
||||
qrcode string
|
||||
image string
|
||||
// cancel stops the poll goroutine early (e.g. Connect's deadline elapsed).
|
||||
cancel context.CancelFunc
|
||||
// done carries the login outcome; the poll goroutine writes exactly once.
|
||||
done chan error
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
|
||||
a.loginMu.Lock()
|
||||
defer a.loginMu.Unlock()
|
||||
|
||||
// Already bound → scanning is moot.
|
||||
if a.hasToken() {
|
||||
return LoginQR{}, ErrAlreadyLoggedIn
|
||||
}
|
||||
if sess := a.loginSession; sess != nil {
|
||||
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, 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,
|
||||
cancel: cancel,
|
||||
done: make(chan error, 1),
|
||||
}
|
||||
a.loginSession = sess
|
||||
a.setLoginState(LoginStatusWaiting, "")
|
||||
// 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", raw.QrcodeImgContent)
|
||||
|
||||
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. detail provides the bot id on confirmation and the
|
||||
// failure reason on failure.
|
||||
func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.cfg.BotToken != "" {
|
||||
return LoginStatusConfirmed, a.cfg.BotID
|
||||
}
|
||||
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.
|
||||
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.
|
||||
func (a *Adapter) setLoginState(status, detail string) {
|
||||
a.mu.Lock()
|
||||
a.loginStatus = status
|
||||
a.loginDetail = detail
|
||||
a.mu.Unlock()
|
||||
}
|
||||
Reference in new issue
Block a user