state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不 写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。 ⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
135 lines
7.0 KiB
Go
135 lines
7.0 KiB
Go
package weixin_ilink
|
||
|
||
// 本文件承载 weixin-iLink **会话凭据状态三态**(有效 / 被拒 / 未知)的判定与文案。
|
||
//
|
||
// 为什么要三态而不是一个 bool:`RescanRequired()` 只能回答「要不要重新扫码」,于是
|
||
// 「凭据确实被 iLink 接受过」与「有凭据、但从没被 iLink 接受过」在面板上长得一模一样 ——
|
||
// 而这两件事完全不同:前者可以放心用,后者**只是还没被否定**。把后者画成「有效」,
|
||
// 就是 2026-10-05「面板一片绿、账号其实已掉线」那类坑的同一族:画出来的与事实无关。
|
||
//
|
||
// 🔴 诚实口径(本文件的全部理由):`errcode=-14` 是 **iLink 拒绝/作废了这份会话凭据**
|
||
// (已解绑、在别处重新登录、或协议层拒绝),**不是基于时间的过期** —— bot token 是长期
|
||
// 凭据(一个月前扫码拿到的 token 依然可用)。因此本文件的文案里:
|
||
//
|
||
// 被拒 → 一律写「已被 iLink 拒绝,需要重新扫码绑定」,**绝不**写「过期」;
|
||
// 未知 → 一律写「尚未确认 / 尚未绑定」,**绝不**把它说成「过期」,也不说成「未连接」。
|
||
//
|
||
// 依据:DevDocs/reference/weixin_ClawBot_Bridge/对照清单.md §2.2「`errcode=-14` =
|
||
// 会话/token 被拒」与 §3「-14 处理」对照行;口径背景见 session_state.go 的文件头。
|
||
|
||
// 会话凭据三态的取值。它们是**闭集**,也是线上/面板可见的取值(网关侧字段见
|
||
// internal/services/gateway/admin_channels_weixin.go 的 weixinLoginStatusResponse)。
|
||
const (
|
||
// SessionStateActive 表示有效:iLink 接受过当前凭据。
|
||
SessionStateActive = "active"
|
||
// SessionStateRejected 表示被拒:iLink 以 errcode=-14 拒绝过当前凭据,
|
||
// 需要重新扫码绑定(**不是**过期)。
|
||
SessionStateRejected = "rejected"
|
||
// SessionStateUnknown 表示未知:没有凭据,或有凭据但还没有任何一次被 iLink
|
||
// 接受的调用可以作证。
|
||
SessionStateUnknown = "unknown"
|
||
)
|
||
|
||
// 三态的说明文案。它们是人类可读的中文短句,随 state 一起交给面板,使面板不必自己
|
||
// 编造措辞(编造正是「未知」被写成「过期」的来源)。文案里绝不含任何凭据。
|
||
const (
|
||
// detailSessionActive 刻意写明**证据**(iLink 接受过一次调用):光有凭据不等于
|
||
// 被接受,所以「有效」这句话必须带着它的依据说出口。
|
||
detailSessionActive = "iLink 已接受当前会话凭据"
|
||
// detailSessionRejected 只说「被拒」与出路(重新扫码)。errcode=-14 不是过期,
|
||
// 因此这里**没有**「过期」二字。
|
||
detailSessionRejected = "本次会话已被 iLink 拒绝(errcode=-14),需要重新扫码绑定"
|
||
// detailSessionNoToken 是「未知」的一种:还没有凭据,无从判断。
|
||
detailSessionNoToken = "状态未知:尚未绑定(没有 bot_token,请用「扫码登录」绑定)"
|
||
// detailSessionUnverified 是「未知」的另一种:有凭据,但还没有一次成功的 iLink
|
||
// 调用证明它被接受。**绝不**把它写成「过期」,也不写成「未连接」——它可能马上就
|
||
// 会变成有效,只是现在还没有证据。
|
||
detailSessionUnverified = "状态未知:尚未确认 iLink 是否接受当前凭据(还没有一次成功的调用)"
|
||
)
|
||
|
||
// SessionSignals 是判定三态所需的**全部**输入。用结构体而不是几个相邻的布尔参数,
|
||
// 一是让调用点自解释,二是避免两个 bool 被接反(那会静默地骗过所有测试)。
|
||
type SessionSignals struct {
|
||
// HasToken 报告当前是否配置了 bot token。
|
||
HasToken bool
|
||
// Rejected 报告 iLink 是否以 errcode=-14 拒绝过当前凭据。
|
||
Rejected bool
|
||
// Verified 报告 iLink 是否**接受过**当前凭据(至少一次成功的 getupdates/sendmessage)。
|
||
Verified bool
|
||
}
|
||
|
||
// SessionStateOf 是**纯函数**:由信号判定三态与文案。它不读时钟、不触网、不加锁,
|
||
// 因此三态可以逐条单测;适配器侧只负责在同一把锁下把三个信号读齐(见 SessionStatus)。
|
||
//
|
||
// 判定顺序(自上而下,先匹配者胜):
|
||
//
|
||
// Rejected → SessionStateRejected —— 被拒优先。iLink 事后拒绝一份先前可用的凭据时,
|
||
// 绝不能因为「历史上成功过」就继续报有效;而这份凭据在重新扫码之前
|
||
// 一直都是被拒的。
|
||
// !HasToken → SessionStateUnknown —— 没有凭据,无从判断。
|
||
// !Verified → SessionStateUnknown —— 有凭据,但从未被 iLink 接受过。**没有证据**
|
||
// 不等于**有证据表明有效**:不把它画成有效,也不画成离线/过期。
|
||
// 否则 → SessionStateActive —— iLink 接受过这份凭据。
|
||
func SessionStateOf(sig SessionSignals) (state, detail string) {
|
||
switch {
|
||
case sig.Rejected:
|
||
return SessionStateRejected, detailSessionRejected
|
||
case !sig.HasToken:
|
||
return SessionStateUnknown, detailSessionNoToken
|
||
case !sig.Verified:
|
||
return SessionStateUnknown, detailSessionUnverified
|
||
default:
|
||
return SessionStateActive, detailSessionActive
|
||
}
|
||
}
|
||
|
||
// markVerified 记录 iLink 接受过 token 这份凭据 —— 「有效」的唯一证据来源。
|
||
//
|
||
// 调用方传入**发出该次请求时所用凭据的快照**(a.botToken()),且只有在它仍是当前配置
|
||
// 时才置位:调用成功之后、置位之前配置被换掉时,这份证据属于**旧**凭据,绝不能被算到
|
||
// 新凭据头上(与 rejectedLocked 的 token 比对同一套路,只是方向相反)。
|
||
func (a *Adapter) markVerified(token string) {
|
||
if token == "" {
|
||
return
|
||
}
|
||
a.mu.RLock()
|
||
done := a.verified && a.verifiedToken == token
|
||
a.mu.RUnlock()
|
||
if done {
|
||
return
|
||
}
|
||
a.mu.Lock()
|
||
if a.cfg.BotToken == token {
|
||
a.verified = true
|
||
a.verifiedToken = token
|
||
}
|
||
a.mu.Unlock()
|
||
}
|
||
|
||
// verifiedLocked 报告当前凭据是否被 iLink 接受过。已配置的 bot_token 不再是当初被证实
|
||
// 的那一个时(扫码登录拿到新 token、运维改配置、热重载)它先清除该证据:新凭据在被自己
|
||
// 证明有效之前一律是「未知」,绝不继承旧凭据的战功。调用方须持有 a.mu。
|
||
func (a *Adapter) verifiedLocked() bool {
|
||
if a.verified && a.verifiedToken != a.cfg.BotToken {
|
||
a.verified = false
|
||
a.verifiedToken = ""
|
||
}
|
||
return a.verified
|
||
}
|
||
|
||
// SessionStatus 报告该账号的**会话凭据三态**:state ∈ {active, rejected, unknown},
|
||
// detail 是如实的说明。被拒只说被拒、未知只说未知,两者都不说「过期」;detail 绝不含凭据。
|
||
//
|
||
// 它是可选的适配器能力:网关经类型断言读取(internal/services/gateway/
|
||
// admin_channel_status.go 的 sessionStatusAware,同时经 admin_channels_weixin.go
|
||
// 浮现在既有的登录状态端点上)。
|
||
func (a *Adapter) SessionStatus() (state, detail string) {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
return SessionStateOf(SessionSignals{
|
||
HasToken: a.cfg.BotToken != "",
|
||
Rejected: a.rejectedLocked(),
|
||
Verified: a.verifiedLocked(),
|
||
})
|
||
}
|