feat(weixin-ilink,gateway): 会话过期显式化 — 不再静默变哑/假 OK 重连,面板可见「需重扫」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 适配器新增过期状态机(plugins/channels/weixin_ilink/session_state.go):getupdates/sendmessage/getuploadurl 收到 errcode=-14 即 markExpired(ERROR 仅一次,只带 bot_id,token 永不入日志),扫码登录拿到新 token 或配置换新 token 时清除(惰性比对)
- Connect/HealthCheck 在过期时返回 ErrSessionExpired(可与 ErrNotConnected 区分):不再对死 token 设 connected=true 报假成功;发送路径同样返回该错误而不是笼统 ErrNotConnected
- 网关:POST /admin/channels/open 遇过期 → 409 session_expired + 明确文案(不再吞错回 200);GET /admin/channels 摘要新增 needsRescan/lastError(经可选接口类型断言读取,取不到不伪造)
- channelbridge:读循环非取消退出时把自身记账的 connected 置 false(热重载被替换者退出不抹掉新实例)
- login/login_admin:过期时 StartLogin 允许重新出码、LoginStatus 如实报 failed(原先可能把死 token 当 confirmed 重新落盘)
- Docs/api-client v0.39;新增 5 组测试 + 变异验证(去掉标记/恢复假成功/网关不填字段 均 FAIL)
This commit is contained in:
AskaEth committed 2026-10-03 18:00:01 +08:00
1 parent 2092094d0a
commit cdc370dc82
14 files changed
+975 -30

No files matched your search

+38 -6
View File
@@ -159,7 +159,15 @@ type Adapter struct {
mu sync.RWMutex
connected bool
qrcode string
// expired marks a session iLink rejected with errcode=-14: the bot token is
// dead and only a fresh scan-code login re-binds it (session_state.go).
// expiredToken records WHICH token was rejected, so a subsequently configured
// token clears the state; rescanDetail is the panel-visible reason. All three
// are guarded by mu.
expired bool
expiredToken string
rescanDetail string
qrcode string
// pollBaseURL optionally overrides cfg.BaseURL for get_qrcode_status polling.
// It is set when the server returns scaned_but_redirect with a redirect_host,
// so subsequent status polls follow the IDC host. Guarded by mu.
@@ -230,9 +238,19 @@ func (a *Adapter) Config() map[string]any {
}
// Connect establishes the iLink session. It is idempotent. When a bot_token is
// already configured it is treated as a live session; otherwise it runs the
// scan-code login flow (see login.go).
// already configured and usable it is treated as a live session; otherwise it
// runs the scan-code login flow (see login.go).
//
// A token iLink already rejected with errcode=-14 is NOT usable: Connect refuses
// it with ErrSessionExpired (re-scan required) instead of reporting a session
// that the very next poll would tear down, and the scan-code login flow is the
// way back (see login_admin.go StartLogin).
func (a *Adapter) Connect(ctx context.Context) error {
if expired, reason := a.expiredState(); expired {
a.log.Error("ilink connect refused: session expired, 需要重新扫码登录",
"bot_id", a.botID(), "reason", reason)
return fmt.Errorf("%w: %s", ErrSessionExpired, reason)
}
a.mu.RLock()
connected := a.connected
token := a.cfg.BotToken
@@ -250,10 +268,18 @@ func (a *Adapter) Connect(ctx context.Context) error {
a.mu.Lock()
a.connected = true
a.mu.Unlock()
a.log.Info("ilink session established", "bot_id", a.cfg.BotID)
a.log.Info("ilink session established", "bot_id", a.botID())
return nil
}
// botID returns the configured bot identifier; it is the only account identifier
// safe to log (the bot token is a credential and never reaches a log line).
func (a *Adapter) botID() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg.BotID
}
// Disconnect tears down the session. It is idempotent.
func (a *Adapter) Disconnect(ctx context.Context) error {
a.mu.Lock()
@@ -274,9 +300,15 @@ func (a *Adapter) IsConnected() bool {
}
// HealthCheck verifies the session is usable: it must be connected and carry a
// bot_token. It does not probe the network so callers can use it cheaply after
// a reconnect without incurring a request.
// bot_token. An expired session (iLink rejected the token with errcode=-14) is
// reported as ErrSessionExpired — "re-scan required" — so a caller can tell a
// dead credential apart from a plain disconnected adapter. It does not probe the
// network so callers can use it cheaply after a reconnect without incurring a
// request.
func (a *Adapter) HealthCheck(ctx context.Context) error {
if expired, reason := a.expiredState(); expired {
return fmt.Errorf("%w: %s", ErrSessionExpired, reason)
}
a.mu.RLock()
defer a.mu.RUnlock()
if !a.connected || a.cfg.BotToken == "" {