Files
AskaEth 7505c46b70 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不
写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
2026-10-06 09:03:44 +08:00

135 lines
7.0 KiB
Go
Raw Permalink 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
// 本文件承载 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(),
})
}