feat(weixin-ilink,gateway): 会话过期显式化 — 不再静默变哑/假 OK 重连,面板可见「需重扫」
- 适配器新增过期状态机(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:
1 parent
2092094d0a
commit
cdc370dc82
14 files changed
+975
-30
No files matched your search
@@ -0,0 +1,92 @@
|
||||
package weixin_ilink
|
||||
|
||||
// This file holds the adapter's session-expiry state machine (design/08 §4-§5).
|
||||
//
|
||||
// iLink answers errcode=-14 when the bot token is no longer valid. That is not a
|
||||
// transient failure the read loop can retry away: the token is dead until the
|
||||
// owner scans a fresh QR code. The adapter therefore keeps an EXPLICIT expired
|
||||
// flag instead of silently dropping the connection, so:
|
||||
//
|
||||
// - the read loop cannot exit and leave the adapter quietly mute while still
|
||||
// reporting "connected" (the "过期后静默变哑" defect);
|
||||
// - Connect/HealthCheck/send surface ErrSessionExpired — "re-scan required" —
|
||||
// rather than a fake OK or a generic "not connected";
|
||||
// - the admin panel can read RescanRequired() and show 需要重新扫码 (see
|
||||
// internal/services/gateway/admin.go).
|
||||
//
|
||||
// Lifecycle (set once, cleared on a usable credential):
|
||||
//
|
||||
// SET markExpired iLink returned errcode=-14 on getupdates,
|
||||
// sendmessage or getuploadurl.
|
||||
// CLEAR clearRescanLocked a scan-code login delivered a new bot_token
|
||||
// (completeLogin), or
|
||||
// expiredLocked saw a bot_token different from the one
|
||||
// iLink rejected — a config change / hot reload that
|
||||
// supplied a fresh token.
|
||||
|
||||
// Rescan reasons recorded on the expired state. They are short, human-readable
|
||||
// and carry no credential: the bot token itself never leaves cfg.
|
||||
const (
|
||||
rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(bot token 已过期)"
|
||||
rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(bot token 已过期)"
|
||||
rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(bot token 已过期)"
|
||||
)
|
||||
|
||||
// markExpired records that iLink rejected the current bot token (errcode=-14).
|
||||
// It drops the connected flag so IsConnected can never keep claiming a live
|
||||
// session for a dead token, and logs the transition at ERROR level exactly once
|
||||
// (a failing poll loop or a repeated send must not flood the log). Only the
|
||||
// non-secret bot_id and the reason are logged.
|
||||
func (a *Adapter) markExpired(reason string) {
|
||||
a.mu.Lock()
|
||||
first := !a.expired
|
||||
a.expired = true
|
||||
a.expiredToken = a.cfg.BotToken
|
||||
a.rescanDetail = reason
|
||||
a.connected = false
|
||||
botID := a.cfg.BotID
|
||||
a.mu.Unlock()
|
||||
|
||||
if first {
|
||||
a.log.Error("ilink session expired: 需要重新扫码登录(请用「扫码登录」重新绑定)",
|
||||
"bot_id", botID, "errcode", errSessionExpired, "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// clearRescanLocked drops the expired state. Callers must hold a.mu.
|
||||
func (a *Adapter) clearRescanLocked() {
|
||||
a.expired = false
|
||||
a.expiredToken = ""
|
||||
a.rescanDetail = ""
|
||||
}
|
||||
|
||||
// expiredLocked reports whether the session is expired. It first clears the state
|
||||
// when the configured bot_token is no longer the one iLink rejected, so a fresh
|
||||
// token (scan login, operator config change, hot reload) restores the session
|
||||
// without extra bookkeeping. Callers must hold a.mu.
|
||||
func (a *Adapter) expiredLocked() bool {
|
||||
if a.expired && a.expiredToken != a.cfg.BotToken {
|
||||
a.clearRescanLocked()
|
||||
}
|
||||
return a.expired
|
||||
}
|
||||
|
||||
// expiredState reports the expired flag together with the rescan reason in one
|
||||
// lock acquisition. reason is "" when the session is not expired.
|
||||
func (a *Adapter) expiredState() (bool, string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !a.expiredLocked() {
|
||||
return false, ""
|
||||
}
|
||||
return true, a.rescanDetail
|
||||
}
|
||||
|
||||
// RescanRequired implements the optional capability the admin panel reads by type
|
||||
// assertion (internal/services/gateway/admin.go): it reports whether this adapter
|
||||
// needs a new scan-code login instead of a plain reconnect, plus the
|
||||
// human-readable reason (never a secret). A healthy adapter reports false with an
|
||||
// empty reason.
|
||||
func (a *Adapter) RescanRequired() (bool, string) {
|
||||
return a.expiredState()
|
||||
}
|
||||
Reference in new issue
Block a user