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)
This commit is contained in:
1 parent
cdc370dc82
commit
a51aadeef4
14 files changed
+205
-192
No files matched your search
@@ -1,82 +1,89 @@
|
||||
package weixin_ilink
|
||||
|
||||
// This file holds the adapter's session-expiry state machine (design/08 §4-§5).
|
||||
// This file holds the adapter's session-rejection 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:
|
||||
// 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 "过期后静默变哑" defect);
|
||||
// - Connect/HealthCheck/send surface ErrSessionExpired — "re-scan required" —
|
||||
// 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 markExpired iLink returned errcode=-14 on getupdates,
|
||||
// SET markRejected 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
|
||||
// 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 expired state. They are short, human-readable
|
||||
// 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(bot token 已过期)"
|
||||
rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(bot token 已过期)"
|
||||
rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(bot token 已过期)"
|
||||
rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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.expired
|
||||
a.expired = true
|
||||
a.expiredToken = a.cfg.BotToken
|
||||
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 expired: 需要重新扫码登录(请用「扫码登录」重新绑定)",
|
||||
"bot_id", botID, "errcode", errSessionExpired, "reason", reason)
|
||||
a.log.Error("ilink session rejected(errcode=-14):本次会话已被 iLink 拒绝,需要重新扫码绑定(请用「扫码登录」)",
|
||||
"bot_id", botID, "errcode", errcodeSessionRejected, "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// clearRescanLocked drops the expired state. Callers must hold a.mu.
|
||||
// clearRescanLocked drops the rejected state. Callers must hold a.mu.
|
||||
func (a *Adapter) clearRescanLocked() {
|
||||
a.expired = false
|
||||
a.expiredToken = ""
|
||||
a.rejected = false
|
||||
a.rejectedToken = ""
|
||||
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
|
||||
// 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) expiredLocked() bool {
|
||||
if a.expired && a.expiredToken != a.cfg.BotToken {
|
||||
func (a *Adapter) rejectedLocked() bool {
|
||||
if a.rejected && a.rejectedToken != a.cfg.BotToken {
|
||||
a.clearRescanLocked()
|
||||
}
|
||||
return a.expired
|
||||
return a.rejected
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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.expiredLocked() {
|
||||
if !a.rejectedLocked() {
|
||||
return false, ""
|
||||
}
|
||||
return true, a.rescanDetail
|
||||
@@ -88,5 +95,5 @@ func (a *Adapter) expiredState() (bool, string) {
|
||||
// human-readable reason (never a secret). A healthy adapter reports false with an
|
||||
// empty reason.
|
||||
func (a *Adapter) RescanRequired() (bool, string) {
|
||||
return a.expiredState()
|
||||
return a.rejectedState()
|
||||
}
|
||||
Reference in new issue
Block a user