diff --git a/internal/services/gateway/admin_channel_status.go b/internal/services/gateway/admin_channel_status.go index 3e0fc63..318c560 100644 --- a/internal/services/gateway/admin_channel_status.go +++ b/internal/services/gateway/admin_channel_status.go @@ -135,3 +135,77 @@ func (s *Server) liveAccountOnline() map[string]map[string]bool { } return out } + +// sessionStatusAware 是渠道适配器暴露「**会话凭据三态**」(有效 / 被拒 / 未知)的 +// 可选能力。*weixin_ilink.Adapter 实现了它(SessionStatus)。 +// +// 为什么需要它:rescanAware(admin_channels_rescan.go)只回答「要不要重新扫码」,于是 +// 「凭据确实被 iLink 接受过」与「有凭据、但从没被接受过」在面板上长得一模一样 —— 而 +// 后者只是**还没被否定**。三态把这两者分开,并把如实的说明(detail)一起交给面板, +// 使面板不必自己编造措辞(编造正是「未知」被写成「过期」的来源)。 +// +// 与 accountStatusAware 同一契约风格:核心 SDK 契约被刻意不扩展,因此没有这条状态面 +// 的适配器不受任何改动,也不会凭空多出一个字段。 +type sessionStatusAware interface { + // SessionStatus 报告会话凭据状态。state 是闭集取值(当前为 + // "active"/"rejected"/"unknown",定义在 plugins/channels/weixin_ilink 的 + // SessionState* 常量),detail 是如实的中文说明(绝不含凭据)。 + SessionStatus() (state string, detail string) +} + +// asSessionStatusAware 报告已注册的渠道值是否暴露会话三态能力。与 asAccountStatusAware +// 同一套路:注册时会把适配器包进 cmd/cyrene 的 namedAdapter —— 一个内嵌 +// adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的方法,因此具体适配器多出的 +// 能力只能通过拆开被内嵌字段才能抵达。 +func asSessionStatusAware(v any) (sessionStatusAware, bool) { + if ss, ok := v.(sessionStatusAware); ok { + return ss, true + } + inner, ok := unwrapNamedAdapter(v) + if !ok { + return nil, false + } + ss, ok := inner.(sessionStatusAware) + return ss, ok +} + +// sessionStatus 是一个账号的会话凭据三态快照:State ∈ {"active","rejected","unknown"} +// (定义在 plugins/channels/weixin_ilink 的 SessionState* 常量),Detail 是如实的说明。 +type sessionStatus struct { + State string + Detail string +} + +// liveAccountAuthStates 收集每个平台各账号的**会话凭据三态**,键与 +// liveAccountConnections 相同(裸平台名 ⇒ 空账号 id)。 +// +// 与 liveAccountOnline 的关键差别:三态里的「未知」是一个**值**("unknown"),不是 +// 缺席 —— 它必须带着「为什么还未知」一起上屏,否则面板只能写一句它自己编的话(而 +// 「未知」被编成「过期」正是本切片要根除的那句话)。缺席只留给「该账号没有这条状态面」 +// (适配器没实现 sessionStatusAware),两者不可互相冒充。 +func (s *Server) liveAccountAuthStates() map[string]map[string]sessionStatus { + out := make(map[string]map[string]sessionStatus) + if s.caps == nil { + return out + } + for _, cap := range s.caps.List(capreg.KindChannel) { + ch, ok := cap.Value.(sdk.Channel) + if !ok { + continue + } + ss, ok := asSessionStatusAware(cap.Value) + if !ok { + continue + } + state, detail := ss.SessionStatus() + if state == "" { + continue + } + platform, acct := splitAdapterName(ch.Name()) + if out[platform] == nil { + out[platform] = make(map[string]sessionStatus) + } + out[platform][acct] = sessionStatus{State: state, Detail: detail} + } + return out +} diff --git a/internal/services/gateway/admin_channels_weixin.go b/internal/services/gateway/admin_channels_weixin.go index a6339b0..b40ff26 100644 --- a/internal/services/gateway/admin_channels_weixin.go +++ b/internal/services/gateway/admin_channels_weixin.go @@ -48,11 +48,23 @@ type weixinLoginQRResponse struct { } // weixinLoginStatusResponse 是 GET /admin/channels/{platform}/login-status 的线上结构。 +// +// Status 是**扫码登录流程**的状态机(idle/waiting/confirmed/failed/need_verifycode); +// AuthState/AuthDetail 是**会话凭据**的三态与说明("active" / "rejected" / "unknown"), +// 两者是两件事:一次 confirmed 的扫码只说明「拿到了凭据」,凭据是否被 iLink 接受要等 +// 一次成功的调用(那时 AuthState 才从 unknown 变成 active)。 +// +// 🔴 errcode=-14 是「被拒」不是「过期」:AuthState=rejected 的说明只说被拒与需要重新 +// 扫码,绝不写「过期」;尚未确认(unknown)同样绝不被写成「过期」,也不写成「未连接」。 +// 适配器没暴露该能力(gateway 的 sessionStatusAware)时这两个键**缺席** —— 缺席是 +// 「该账号没有这条状态面」,与 AuthState=unknown(有状态面但尚未确认)不是同一件事。 type weixinLoginStatusResponse struct { - Status string `json:"status"` - BotID string `json:"botId,omitempty"` - BotToken string `json:"botToken,omitempty"` - Err string `json:"err,omitempty"` + Status string `json:"status"` + BotID string `json:"botId,omitempty"` + BotToken string `json:"botToken,omitempty"` + Err string `json:"err,omitempty"` + AuthState string `json:"authState,omitempty"` + AuthDetail string `json:"authDetail,omitempty"` } // adminWeixinLoginQR 为 weixin-ilink 账号触发(或返回进行中的)二维码。 @@ -98,6 +110,12 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) } status, detail := wx.LoginStatus(r.Context()) resp := weixinLoginStatusResponse{Status: status} + // 会话凭据三态(有效 / 被拒 / 未知)与**扫码登录流程**的状态各自独立:一次 + // 扫码确认只说明拿到了凭据,凭据是否被 iLink 接受由适配器侧的证据(一次成功的 + // 调用)决定。适配器没有该能力时这两个键保持缺席。 + if ss, ok := s.weixinSessionStatus(accountID); ok { + resp.AuthState, resp.AuthDetail = ss.SessionStatus() + } if status == weixin_ilink.LoginStatusConfirmed { cfg := wx.Config() token, _ := cfg["bot_token"].(string) @@ -205,18 +223,20 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID return nil } -// weixinAccountFor 解析为某平台/账号注册的 *weixin_ilink.Adapter。 -// 对未知账号,或未暴露登录表面的包装适配器, -// 它返回 nil。 -func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) { +// weixinRawValueFor 返回为该平台/账号注册、且**满足 usable** 的已注册值本身(未拆包), +// 于是各条可选能力(扫码登录面、会话凭据三态……)可以各自对它做类型断言,而账号匹配 +// 规则只写一遍。weixin 适配器可能以裸 "weixin-ilink" 名注册(单账号),而非 +// "weixin-ilink:",因此先尝试复合匹配,当调用方指定了具体账号、但没有复合名 +// 承载它时,回退到裸 weixin 适配器(面板传的就是配置里的账号 id)。 +// +// usable 先把「不暴露该能力的注册值」滤掉再去匹配账号:这样每条能力的匹配口径与 +// 重构前各自独立查找时**逐字一致**(否则一个混装了多种适配器的注册表里,首个匹配到 +// 却缺该能力的值会把后面真正可用的那个挡掉)。 +func weixinRawValueFor(caps CapabilityStore, platform, accountID string, usable func(any) bool) (any, bool) { if caps == nil { return nil, false } - // weixin 适配器可能以裸 "weixin-ilink" 名注册(单账号), - // 而非 "weixin-ilink:",因此先尝试复合匹配, - // 当调用方指定了具体账号、但没有复合名承载它时,回退到 - // 裸 weixin 适配器(面板传的就是配置里的账号 id)。 - var bare weixinLoginAdmin + var bare any var bareFound bool for _, cap := range caps.List(capreg.KindChannel) { ch, ok := cap.Value.(sdk.Channel) @@ -227,15 +247,14 @@ func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinL if plat != platform { continue } - w, ok := asWeixinLoginAdmin(cap.Value) - if !ok { + if !usable(cap.Value) { continue } if accountID == "" || acct == accountID { - return w, true + return cap.Value, true } if acct == "" { - bare, bareFound = w, true + bare, bareFound = cap.Value, true } } if bareFound { @@ -244,6 +263,40 @@ func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinL return nil, false } +// weixinLoginUsable 报告该注册值是否暴露扫码登录面。 +func weixinLoginUsable(v any) bool { + _, ok := asWeixinLoginAdmin(v) + return ok +} + +// weixinAccountFor 解析为某平台/账号注册的 *weixin_ilink.Adapter。 +// 对未知账号,或未暴露登录表面的包装适配器, +// 它返回 nil。 +func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) { + v, ok := weixinRawValueFor(caps, platform, accountID, weixinLoginUsable) + if !ok { + return nil, false + } + return asWeixinLoginAdmin(v) +} + +// weixinSessionUsable 报告该注册值是否暴露会话凭据三态。 +func weixinSessionUsable(v any) bool { + _, ok := asSessionStatusAware(v) + return ok +} + +// weixinSessionStatus 读取该账号适配器的**会话凭据三态**能力。适配器没暴露该能力 +// (或账号未注册)时 found=false,调用方随后让两个键**缺席** —— 缺席 = 该账号没有 +// 这条状态面,与 AuthState="unknown"(有状态面但尚未确认)是两件事。 +func (s *Server) weixinSessionStatus(accountID string) (sessionStatusAware, bool) { + v, ok := weixinRawValueFor(s.caps, "weixin-ilink", accountID, weixinSessionUsable) + if !ok { + return nil, false + } + return asSessionStatusAware(v) +} + // asWeixinLoginAdmin 把已注册的渠道值拆到其内层 weixin-ilink // 适配器,仿照 asKookAdmin:注册时会把适配器包进 cmd/cyrene 的 // namedAdapter(一个内嵌 adapter.PlatformAdapter 的未导出结构体); diff --git a/internal/services/gateway/admin_channels_weixin_status_test.go b/internal/services/gateway/admin_channels_weixin_status_test.go new file mode 100644 index 0000000..43c1183 --- /dev/null +++ b/internal/services/gateway/admin_channels_weixin_status_test.go @@ -0,0 +1,289 @@ +package gateway + +// 演练 weixin-iLink **会话凭据三态**(有效 / 被拒 / 未知)在**既有**登录状态端点上的 +// 浮现(design/08 §4 的 weixin-ilink 条目)。判定与文案的权威在适配器侧 +// (plugins/channels/weixin_ilink/session_status.go);这里守的是**线上口径**: +// +// - 有凭据但还没有一次被 iLink 接受的调用 ⇒ `authState:"unknown"`,**不是**「有效」; +// - errcode=-14 ⇒ `authState:"rejected"` + 明说「已被 iLink 拒绝,需要重新扫码」; +// - 除了断言取值,还直接断言**整个响应体**里不出现「过期」—— -14 是被拒不是过期, +// 未知同样不许说成过期(那是一句会让主人「等一会儿就好了」的假话)。 +// +// 适配器没有这条状态面时两个键**缺席**(≠ unknown)。 + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" +) + +// weixinAuthStatus 是登录状态端点里本切片关心的字段。 +type weixinAuthStatus struct { + Status string `json:"status"` + AuthState string `json:"authState"` + AuthDetail string `json:"authDetail"` +} + +// fetchWeixinAuthStatus 打既有的登录状态端点并解析三态,同时钉住「全文不出现过期」。 +func fetchWeixinAuthStatus(t *testing.T, base, tok string) weixinAuthStatus { + t.Helper() + resp, body := doReq(t, http.MethodGet, + base+"/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body) + } + if strings.Contains(string(body), "过期") { + t.Fatalf("响应体里出现「过期」:-14 是「被拒」不是「过期」,未知也不许说成过期\nbody=%s", body) + } + var out struct { + Data weixinAuthStatus `json:"data"` + } + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode login-status: %v", err) + } + return out.Data +} + +// newWeixinGatewayWithAdapter 用**给定的** weixin 适配器接线一个网关(仿照 +// newWeixinGateway,但适配器由测试掌控,以便先把状态驱到某一点再打端点)。 +func newWeixinGatewayWithAdapter(t *testing.T, name string, wa *weixin_ilink.Adapter) (string, string) { + t.Helper() + reg := registry.New() + if err := reg.RegisterChannel(&gatewayWeixinWrap{PlatformAdapter: wa, name: name}); err != nil { + t.Fatalf("register channel: %v", err) + } + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + return ts.URL, tok +} + +// newWeixinPollAdapter 构造一个**已连接**、带 token、并按 fn(收到第 call 次 +// getupdates)作答的 weixin 适配器,同时跑起读循环(读循环是「有效」的证据来源)。 +// 清理时先停读循环、再关假服务端。 +func newWeixinPollAdapter(t *testing.T, fn func(call int) map[string]any) *weixin_ilink.Adapter { + t.Helper() + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/ilink/bot/getupdates" { + http.NotFound(w, r) + return + } + calls++ + _ = json.NewEncoder(w).Encode(fn(calls)) + })) + t.Cleanup(srv.Close) + + wa, err := weixin_ilink.New( + weixin_ilink.Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-gw"}, + srv.Client(), + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err != nil { + t.Fatalf("weixin new: %v", err) + } + if err := wa.Connect(context.Background()); err != nil { + t.Fatalf("weixin connect: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + wa.ReadMessages(ctx, func(adapter.UnifiedMessage) {}) + }() + t.Cleanup(func() { // 后注册 ⇒ 先执行:先把读循环停干净,再让假服务端关闭。 + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("weixin ReadMessages 未在取消后返回") + } + }) + return wa +} + +// waitWeixinAuthState 轮询适配器直到它报告想要的会话三态,超时则失败。 +func waitWeixinAuthState(t *testing.T, wa *weixin_ilink.Adapter, want string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if state, _ := wa.SessionStatus(); state == want { + return + } + time.Sleep(2 * time.Millisecond) + } + state, detail := wa.SessionStatus() + t.Fatalf("SessionStatus() = (%q,%q), want %q within timeout", state, detail, want) +} + +// TestAdminWeixinLoginStatus_AuthStateUnknownUntilAccepted 钉住「有凭据 ≠ 有效」: +// 预置了 token、但 iLink 从未接受过它 ⇒ 端点上必须是 unknown(带如实的说明), +// 而**扫码登录面**照旧报 confirmed(两件事,互不影响)。 +func TestAdminWeixinLoginStatus_AuthStateUnknownUntilAccepted(t *testing.T) { + t.Parallel() + wa, err := weixin_ilink.New( + weixin_ilink.Config{BaseURL: "https://example.invalid", BotToken: "tok-pre", BotID: "bot-pre"}, + nil, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err != nil { + t.Fatalf("weixin new: %v", err) + } + base, tok := newWeixinGatewayWithAdapter(t, "weixin-ilink:conf0", wa) + + got := fetchWeixinAuthStatus(t, base, tok) + if got.AuthState != weixin_ilink.SessionStateUnknown { + t.Fatalf("authState = %q, want unknown(尚未被 iLink 接受过,不能画成有效)", got.AuthState) + } + if got.AuthDetail == "" { + t.Fatal("authDetail 为空,面板将无从如实说明「为什么是未知」") + } + if got.Status != weixin_ilink.LoginStatusConfirmed { + t.Fatalf("status = %q, want confirmed(登录面看「有没有凭据」,与会话三态是两件事)", got.Status) + } +} + +// TestAdminWeixinLoginStatus_AuthStateActiveAfterAcceptedPoll 一次成功的长轮询就是 +// iLink 接受该凭据的证据 ⇒ 三态变 active。 +func TestAdminWeixinLoginStatus_AuthStateActiveAfterAcceptedPoll(t *testing.T) { + t.Parallel() + wa := newWeixinPollAdapter(t, func(int) map[string]any { + return map[string]any{"msgs": []any{}, "get_updates_buf": "c1"} + }) + base, tok := newWeixinGatewayWithAdapter(t, "weixin-ilink:conf0", wa) + waitWeixinAuthState(t, wa, weixin_ilink.SessionStateActive) + + got := fetchWeixinAuthStatus(t, base, tok) + if got.AuthState != weixin_ilink.SessionStateActive { + t.Fatalf("authState = %q, want active after a successful getupdates", got.AuthState) + } + if got.AuthDetail == "" { + t.Fatal("authDetail 为空,want 一条写明依据的说明") + } +} + +// TestAdminWeixinLoginStatus_AuthStateRejectedIsNotExpired 钉住铁律:iLink 回 +// errcode=-14 ⇒ authState=rejected + 要求重新扫码;登录面同时报 failed(不假 OK)。 +func TestAdminWeixinLoginStatus_AuthStateRejectedIsNotExpired(t *testing.T) { + t.Parallel() + wa := newWeixinPollAdapter(t, func(call int) map[string]any { + if call == 1 { + return map[string]any{"msgs": []any{}, "get_updates_buf": "c1"} + } + return map[string]any{"errcode": -14} + }) + base, tok := newWeixinGatewayWithAdapter(t, "weixin-ilink:conf0", wa) + waitWeixinAuthState(t, wa, weixin_ilink.SessionStateRejected) + + got := fetchWeixinAuthStatus(t, base, tok) + if got.AuthState != weixin_ilink.SessionStateRejected { + t.Fatalf("authState = %q, want rejected after errcode=-14", got.AuthState) + } + if !strings.Contains(got.AuthDetail, "拒绝") { + t.Fatalf("authDetail = %q, want 明说「被拒」", got.AuthDetail) + } + if !strings.Contains(got.AuthDetail, "重新扫码") { + t.Fatalf("authDetail = %q, want 给出出路(重新扫码)", got.AuthDetail) + } + if got.Status != weixin_ilink.LoginStatusFailed { + t.Fatalf("status = %q, want failed(被拒的凭据绝不能被报成 confirmed)", got.Status) + } +} + +// weixinNoSessionChannel 是一个**没有**会话三态能力、但暴露扫码登录面的渠道值: +// 用来证明「适配器没这条状态面 ⇒ 两个键缺席」,而不是凭空编一个 unknown 出来。 +type weixinNoSessionChannel struct { + adapter.PlatformAdapter + name string +} + +func (c weixinNoSessionChannel) Name() string { return c.name } +func (weixinNoSessionChannel) StartLogin(context.Context) (weixin_ilink.LoginQR, error) { + return weixin_ilink.LoginQR{}, nil +} +func (weixinNoSessionChannel) LoginStatus(context.Context) (string, string) { + return weixin_ilink.LoginStatusIdle, "" +} +func (weixinNoSessionChannel) Config() map[string]any { return map[string]any{} } + +func TestAdminWeixinLoginStatus_AuthStateAbsentWithoutCapability(t *testing.T) { + t.Parallel() + reg := registry.New() + if err := reg.RegisterChannel(weixinNoSessionChannel{name: "weixin-ilink:conf0"}); err != nil { + t.Fatalf("register channel: %v", err) + } + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodGet, + ts.URL+"/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body) + } + if strings.Contains(string(body), "authState") || strings.Contains(string(body), "authDetail") { + t.Fatalf("适配器没有会话三态能力,两个键必须缺席(缺席 ≠ unknown)\nbody=%s", body) + } +} + +// TestLiveAccountAuthStates_三态齐全且不丢未知 钉住通用账号状态面的收集口径 +// (liveAccountAuthStates):① 能穿过注册包装器(namedAdapter 式)抵达具体适配器; +// ② 「未知」是**值**不是缺席(有状态面但尚未确认 ⇒ 带说明进 map);③ 没有这条状态面 +// 的渠道值干脆不进 map(缺席 = 该账号没有这条状态面,绝不冒充 unknown)。 +// +// ⚠️ 该收集器当前**尚未接线**到 GET /admin/channels 的 accounts[](那需要给 +// AdminChannelAccount 新增两个字段,落在本片文件面之外)⇒ 接线清单见 +// DevDocs/_dispatch/CODE-F3-iLink账号状态三态.md。 +func TestLiveAccountAuthStates_三态齐全且不丢未知(t *testing.T) { + t.Parallel() + wa, err := weixin_ilink.New( + weixin_ilink.Config{BaseURL: "https://example.invalid", BotToken: "tok"}, + nil, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err != nil { + t.Fatalf("weixin new: %v", err) + } + reg := registry.New() + if err := reg.RegisterChannel(&gatewayWeixinWrap{PlatformAdapter: wa, name: "weixin-ilink:conf0"}); err != nil { + t.Fatalf("register channel: %v", err) + } + s := &Server{caps: reg} + + got := s.liveAccountAuthStates()["weixin-ilink"]["conf0"] + if got.State != weixin_ilink.SessionStateUnknown { + t.Fatalf("state = %q, want unknown(有凭据但尚未被 iLink 接受 ⇒ 是值,不是缺席)", got.State) + } + if got.Detail == "" { + t.Fatal("detail 为空:未知必须带着「为什么未知」上屏") + } + assertNoExpiryWording(t, "liveAccountAuthStates 的未知", got.Detail) + + // 没有三态能力的渠道值:不进 map(缺席 ≠ unknown,面板必须能区分这两件事)。 + plain := registry.New() + if err := plain.RegisterChannel(weixinNoSessionChannel{name: "weixin-ilink:conf0"}); err != nil { + t.Fatalf("register channel: %v", err) + } + if _, ok := (&Server{caps: plain}).liveAccountAuthStates()["weixin-ilink"]["conf0"]; ok { + t.Fatal("没有三态能力的账号不该出现在 map 里(缺席只留给「没有这条状态面」)") + } +} + +// assertNoExpiryWording 钉住「-14 是被拒不是过期」这条铁律在**网关侧**也不许破: +// 任何交给客户端的说明里都不出现「过期」。 +func assertNoExpiryWording(t *testing.T, label, detail string) { + t.Helper() + if strings.Contains(detail, "过期") || strings.Contains(detail, "expired") { + t.Fatalf("%s detail 含「过期/expired」:errcode=-14 是被拒不是过期,未知同样不许说成过期:%q", label, detail) + } +} diff --git a/plugins/channels/weixin_ilink/adapter.go b/plugins/channels/weixin_ilink/adapter.go index 331bf65..20be2b0 100644 --- a/plugins/channels/weixin_ilink/adapter.go +++ b/plugins/channels/weixin_ilink/adapter.go @@ -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 diff --git a/plugins/channels/weixin_ilink/poll.go b/plugins/channels/weixin_ilink/poll.go index cf81188..f286514 100644 --- a/plugins/channels/weixin_ilink/poll.go +++ b/plugins/channels/weixin_ilink/poll.go @@ -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 { diff --git a/plugins/channels/weixin_ilink/send.go b/plugins/channels/weixin_ilink/send.go index eb41641..dd4971c 100644 --- a/plugins/channels/weixin_ilink/send.go +++ b/plugins/channels/weixin_ilink/send.go @@ -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 } diff --git a/plugins/channels/weixin_ilink/session_status.go b/plugins/channels/weixin_ilink/session_status.go new file mode 100644 index 0000000..b54dc23 --- /dev/null +++ b/plugins/channels/weixin_ilink/session_status.go @@ -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(), + }) +} diff --git a/plugins/channels/weixin_ilink/session_status_test.go b/plugins/channels/weixin_ilink/session_status_test.go new file mode 100644 index 0000000..b3075d7 --- /dev/null +++ b/plugins/channels/weixin_ilink/session_status_test.go @@ -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) +}