事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。 - 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected - 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留 - 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期) - Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go)
100 lines
4.4 KiB
Go
100 lines
4.4 KiB
Go
package weixin_ilink
|
||
|
||
// This file holds the adapter's session-rejection state machine (design/08 §4-§5).
|
||
//
|
||
// iLink answers errcode=-14 when it rejects or invalidates the session credential.
|
||
// The bot token is a LONG-LIVED credential — a token obtained by scanning a month
|
||
// ago still works — so -14 is NOT a time-based expiry: it means iLink no longer
|
||
// accepts this session (unbound, re-logged-in elsewhere, a protocol-level refusal).
|
||
// It is worth recording that no log has ever actually shown -14; the earlier
|
||
// reading of it as a time-based expiry was an inference, not an observation.
|
||
//
|
||
// Either way it is not a transient failure the read loop can retry away: the same
|
||
// credential keeps being refused until the owner scans a fresh QR code. The adapter
|
||
// therefore keeps an EXPLICIT rejected flag instead of silently dropping the
|
||
// connection, so:
|
||
//
|
||
// - the read loop cannot exit and leave the adapter quietly mute while still
|
||
// reporting "connected" (the "credential rejected → silently mute" defect);
|
||
// - Connect/HealthCheck/send surface ErrSessionRejected — "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 markRejected iLink returned errcode=-14 on getupdates,
|
||
// sendmessage or getuploadurl.
|
||
// CLEAR clearRescanLocked a scan-code login delivered a new bot_token
|
||
// (completeLogin), or
|
||
// rejectedLocked 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 rejected state. They are short, human-readable
|
||
// and carry no credential: the bot token itself never leaves cfg.
|
||
const (
|
||
rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||
rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||
rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||
)
|
||
|
||
// markRejected records that iLink rejected the current session credential
|
||
// (errcode=-14). It drops the connected flag so IsConnected can never keep claiming
|
||
// a live session for a rejected credential, 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) markRejected(reason string) {
|
||
a.mu.Lock()
|
||
first := !a.rejected
|
||
a.rejected = true
|
||
a.rejectedToken = a.cfg.BotToken
|
||
a.rescanDetail = reason
|
||
a.connected = false
|
||
botID := a.cfg.BotID
|
||
a.mu.Unlock()
|
||
|
||
if first {
|
||
a.log.Error("ilink session rejected(errcode=-14):本次会话已被 iLink 拒绝,需要重新扫码绑定(请用「扫码登录」)",
|
||
"bot_id", botID, "errcode", errcodeSessionRejected, "reason", reason)
|
||
}
|
||
}
|
||
|
||
// clearRescanLocked drops the rejected state. Callers must hold a.mu.
|
||
func (a *Adapter) clearRescanLocked() {
|
||
a.rejected = false
|
||
a.rejectedToken = ""
|
||
a.rescanDetail = ""
|
||
}
|
||
|
||
// rejectedLocked reports whether iLink rejected the session. 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) rejectedLocked() bool {
|
||
if a.rejected && a.rejectedToken != a.cfg.BotToken {
|
||
a.clearRescanLocked()
|
||
}
|
||
return a.rejected
|
||
}
|
||
|
||
// rejectedState reports the rejected flag together with the rescan reason in one
|
||
// lock acquisition. reason is "" when the session has not been rejected.
|
||
func (a *Adapter) rejectedState() (bool, string) {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if !a.rejectedLocked() {
|
||
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.rejectedState()
|
||
}
|