feat(channel): 微信 iLink 账号状态三态(诚实口径)

state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不
写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:44 +08:00
1 parent f75e7a9c9b
commit 7505c46b70
8 files changed
+808 -17

No files matched your search

+6
View File
@@ -174,6 +174,12 @@ type Adapter struct {
rejected bool
rejectedToken string
rescanDetail string
// verified 标记 iLink **接受过**当前凭据(一次成功的 getupdates/sendmessage):
// 它是三态里「有效」的唯一证据(session_status.go)。verifiedToken 记录被证实的是
// 哪一个 token,于是配置换了新 token 之后旧证据不会被继承 —— 与 rejectedToken
// 同一套路,方向相反。二者都由 mu 保护。
verified bool
verifiedToken string
qrcode string
// pollBaseURL 可选地覆盖 cfg.BaseURL,用于 get_qrcode_status 轮询。服务端返回
// scaned_but_redirect 且带 redirect_host 时会设置它,使后续状态轮询跟随该 IDC
+7
View File
@@ -75,6 +75,10 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
failures := 0
for {
// 先拍下本次调用将要使用的凭据快照:长轮询成功时用它置位「已证实」
// (session_status.go),于是「请求发出后配置就被换掉」不会把旧凭据的证据
// 记到新凭据头上。
token := a.botToken()
resp, err := a.fetchUpdates(ctx)
if err != nil {
if ctx.Err() != nil {
@@ -109,6 +113,9 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
}
continue
}
// 一次成功的 getupdates 就是「iLink 接受了这份凭据」的证据:三态由此从
// 「未知」变成「有效」(session_status.go)。
a.markVerified(token)
a.setCursor(resp.GetUpdatesBuf)
for _, raw := range resp.Msgs {
+5
View File
@@ -147,6 +147,9 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [
return fmt.Errorf("weixin_ilink: encode sendmessage: %w", err)
}
var out sendResult
// 与读循环同一口径:先拍下本次调用所用的凭据快照,成功时才能把「已证实」记到
// 对的那份凭据上(session_status.go)。
token := a.botToken()
if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/sendmessage"),
bytes.NewReader(body), &out); err != nil {
return fmt.Errorf("weixin_ilink: sendmessage: %w", err)
@@ -160,6 +163,8 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [
if out.Ret != 0 {
return fmt.Errorf("weixin_ilink: sendmessage ret=%d", out.Ret)
}
// 一次成功的 sendmessage 同样是「iLink 接受了这份凭据」的证据。
a.markVerified(token)
return nil
}
@@ -0,0 +1,134 @@
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(),
})
}
@@ -0,0 +1,223 @@
package weixin_ilink
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// 这些测试覆盖会话凭据三态(session_status.go):有效 / 被拒(errcode=-14) / 未知。
// 三态守的是**诚实口径**——被拒不是过期,未知既不是有效也不是过期。
// assertNoExpiryWording 钉住三态文案里绝不出现的措辞。`errcode=-14` 是 iLink
// **拒绝/作废**了会话凭据,不是基于时间的过期(bot token 是长期凭据);「未知」同理。
// 因此任何一条 detail 都不许出现「过期」(或英文 expired)——它会把主人引向
// 「等一会儿就好了」这个错误结论。
func assertNoExpiryWording(t *testing.T, label, detail string) {
t.Helper()
for _, banned := range []string{"过期", "expired"} {
if strings.Contains(detail, banned) {
t.Fatalf("%s detail 含 %q,但 -14 是「被拒」不是「过期」、未知也不许说成过期:%q",
label, banned, detail)
}
}
}
// TestSessionStateOf_ThreeStates 逐条钉住纯函数的判定与文案(三态各一条 + 优先级)。
func TestSessionStateOf_ThreeStates(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sig SessionSignals
want string
substr []string
}{
{
name: "有效:iLink 接受过这份凭据",
sig: SessionSignals{HasToken: true, Verified: true},
want: SessionStateActive,
substr: []string{"已接受"},
},
{
name: "被拒:iLink 以 errcode=-14 拒绝过(需重扫)",
sig: SessionSignals{HasToken: true, Verified: true, Rejected: true},
want: SessionStateRejected,
substr: []string{"拒绝", "重新扫码"},
},
{
name: "未知(一):还没有凭据",
sig: SessionSignals{},
want: SessionStateUnknown,
substr: []string{"未知", "尚未绑定"},
},
{
name: "未知(二):有凭据但从未被 iLink 接受过",
sig: SessionSignals{HasToken: true},
want: SessionStateUnknown,
substr: []string{"未知", "尚未确认"},
},
{
name: "被拒优先于历史证据:先前有效过,事后被 iLink 拒绝 ⇒ 仍是被拒",
sig: SessionSignals{HasToken: true, Verified: true, Rejected: true},
want: SessionStateRejected,
substr: []string{"拒绝"},
},
{
name: "被拒优先于「没有凭据」:拒绝状态不因 token 被清而伪装成未知",
sig: SessionSignals{Rejected: true},
want: SessionStateRejected,
substr: []string{"拒绝"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
state, detail := SessionStateOf(tc.sig)
if state != tc.want {
t.Fatalf("SessionStateOf(%+v) state = %q, want %q", tc.sig, state, tc.want)
}
if detail == "" {
t.Fatalf("SessionStateOf(%+v) detail 为空,面板将无从如实说明", tc.sig)
}
for _, want := range tc.substr {
if !strings.Contains(detail, want) {
t.Fatalf("SessionStateOf(%+v) detail = %q, want 含 %q", tc.sig, detail, want)
}
}
assertNoExpiryWording(t, tc.name, detail)
})
}
}
// TestSessionStatus_UnknownUntilIlinkAccepts 钉住「有凭据 ≠ 有效」:token 已配置、
// 适配器也 Connect 成功,但还没有任何一次被 iLink 接受的调用 ⇒ 状态是**未知**,
// 绝不是有效。
func TestSessionStatus_UnknownUntilIlinkAccepts(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
return handlerResult{body: jsonBody(map[string]any{"msgs": []any{}, "get_updates_buf": "c1"})}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
state, detail := ad.SessionStatus()
if state != SessionStateUnknown {
t.Fatalf("SessionStatus() = %q, want unknown before iLink ever accepted the token", state)
}
if detail == "" {
t.Fatal("SessionStatus() detail 为空,want 一条如实的「尚未确认」说明")
}
assertNoExpiryWording(t, "未知", detail)
// 一次成功的长轮询就是证据:三态随之变成有效。
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(ctx, func(adapter.UnifiedMessage) {})
}()
waitFor(t, func() bool { st, _ := ad.SessionStatus(); return st == SessionStateActive })
cancel()
waitDone(t, done)
state, detail = ad.SessionStatus()
if state != SessionStateActive {
t.Fatalf("SessionStatus() = %q, want active after a successful getupdates", state)
}
if detail == "" {
t.Fatal("SessionStatus() detail 为空,want 一条写明依据的说明")
}
assertNoExpiryWording(t, "有效", detail)
}
// TestSessionStatus_RejectedSaysRejectedNotExpired 钉住铁律:errcode=-14 上报的是
// **被拒 + 需要重新扫码**,文案里绝不出现「过期」。
func TestSessionStatus_RejectedSaysRejectedNotExpired(t *testing.T) {
t.Parallel()
srv := rejectingServer(t)
ad := newRejectedByPollAdapter(t, srv)
state, detail := ad.SessionStatus()
if state != SessionStateRejected {
t.Fatalf("SessionStatus() = %q, want rejected after errcode=-14", state)
}
if !strings.Contains(detail, "拒绝") {
t.Fatalf("detail = %q, want 明说「被拒」", detail)
}
if !strings.Contains(detail, "重新扫码") {
t.Fatalf("detail = %q, want 给出出路(重新扫码)", detail)
}
assertNoExpiryWording(t, "被拒", detail)
}
// TestSessionStatus_NewTokenClearsEvidence 钉住证据不被继承:换了新 token 之后,
// 旧凭据被证实有效这件事**不算**新凭据的战功 —— 新凭据在它自己被证明之前是未知。
func TestSessionStatus_NewTokenClearsEvidence(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
return handlerResult{body: jsonBody(map[string]any{"msgs": []any{}, "get_updates_buf": "c1"})}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-1"}, srv)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(ctx, func(adapter.UnifiedMessage) {})
}()
waitFor(t, func() bool { st, _ := ad.SessionStatus(); return st == SessionStateActive })
cancel()
waitDone(t, done)
// 面板保存 + 热重载换了一个新 token:旧证据必须作废。
setBotToken(ad, "tok-2")
if state, detail := ad.SessionStatus(); state != SessionStateUnknown {
t.Fatalf("SessionStatus() = %q after a token swap, want unknown(新凭据尚未被 iLink 接受); detail = %q", state, detail)
}
// 再换回那个被证实过的 token:证据同样不作废得而复生(它已被清除)。
setBotToken(ad, "tok-1")
if state, _ := ad.SessionStatus(); state != SessionStateUnknown {
t.Fatalf("SessionStatus() = %q after switching back, want unknown(证据只对「当时被证实的那一份」成立)", state)
}
}
// TestSessionStatus_RejectedOverridesEarlierEvidence 钉住「事后被拒」:一份先前被
// iLink 接受的凭据,一旦 iLink 回以 errcode=-14,三态立即是**被拒**,绝不会因为
// 「历史上有过成功」而继续报有效。
func TestSessionStatus_RejectedOverridesEarlierEvidence(t *testing.T) {
t.Parallel()
calls := 0
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
calls++
if calls == 1 {
return handlerResult{body: jsonBody(map[string]any{"msgs": []any{}, "get_updates_buf": "c1"})}
}
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(context.Background(), func(adapter.UnifiedMessage) {})
}()
// 第一次轮询成功 ⇒ 有效;第二次回 -14 ⇒ 被拒并结束读循环。
waitFor(t, func() bool { st, _ := ad.SessionStatus(); return st == SessionStateRejected })
waitDone(t, done)
state, detail := ad.SessionStatus()
if state != SessionStateRejected {
t.Fatalf("SessionStatus() = %q, want rejected(事后被拒优先于历史证据)", state)
}
assertNoExpiryWording(t, "事后被拒", detail)
}