refactor(weixin-ilink): 措辞纠正 —「会话过期」→「会话被 iLink 拒绝」(零逻辑改动)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

事实(主人 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:
AskaEth committed 2026-10-03 18:26:27 +08:00
1 parent cdc370dc82
commit a51aadeef4
14 files changed
+205 -192

No files matched your search

+45 -38
View File
@@ -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()
}