- 适配器新增过期状态机(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)
93 lines
3.8 KiB
Go
93 lines
3.8 KiB
Go
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()
|
||
}
|