Files
Cyrene_v2/plugins/channels/weixin_ilink/session_state.go
T
AskaEth a51aadeef4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(weixin-ilink): 措辞纠正 —「会话过期」→「会话被 iLink 拒绝」(零逻辑改动)
事实(主人 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)
2026-10-03 18:26:27 +08:00

100 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}