Files
Cyrene_v2/plugins/channels/weixin_ilink/session_state.go
T
AskaEth cdc370dc82
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(weixin-ilink,gateway): 会话过期显式化 — 不再静默变哑/假 OK 重连,面板可见「需重扫」
- 适配器新增过期状态机(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)
2026-10-03 18:00:01 +08:00

93 lines
3.8 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-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()
}