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

@@ -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
}
@@ -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:<acct>",因此先尝试复合匹配,当调用方指定了具体账号、但没有复合名
// 承载它时,回退到裸 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:<acct>",因此先尝试复合匹配,
// 当调用方指定了具体账号、但没有复合名承载它时,回退到
// 裸 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 的未导出结构体);
@@ -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)
}
}