package gateway // 本文件锁定**连接态三态**在管理面上的如实转述(2026-10-07 的 UI 撒谎事故): // // - `POST /admin/channels/open` 之后 **不许**无条件报 connected=true; // 适配器自报「重试中」时必须是 connected=false + pending + reason; // - 适配器自报「就绪等接入」(server 模式)时是 connected=false + ready, // 既不是「已连接」,也不是一句静态的「未连接」; // - 没实现这条状态面的平台(绝大多数)响应逐字节不变 —— 可选键整键缺席。 // // 断言打在**线上响应**上(不是内部函数),因为「面板读到什么」才是这件事的全部。 import ( "context" "encoding/json" "net/http" "sync/atomic" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // outcomeChannel 是一个会自报连接态三态的假适配器:Connect 不致命(照常返回 // nil),真实状态与原因完全由测试给定,因此「网关有没有照实转述」是可断言的事实。 type outcomeChannel struct { name string state string reason string connected atomic.Bool connects atomic.Int32 } func (c *outcomeChannel) Name() string { return c.name } func (c *outcomeChannel) IsConnected() bool { return c.connected.Load() } func (c *outcomeChannel) HealthCheck(context.Context) error { return nil } func (c *outcomeChannel) ConnectOutcome() (string, string) { return c.state, c.reason } func (c *outcomeChannel) Connect(context.Context) error { c.connects.Add(1) // 「真连上」才置位;pending / ready 都必须保持未连上 —— 这正是三态要区分的。 c.connected.Store(c.state == "connected") return nil } func (c *outcomeChannel) Disconnect(context.Context) error { c.connected.Store(false) return nil } // openChannelData 发起一次 open 并解出 data 对象(原始 map,好断言「键在不在」)。 func openChannelData(t *testing.T, ch *outcomeChannel) map[string]any { t.Helper() caps := capreg.New() if err := caps.RegisterChannel(ch); err != nil { t.Fatalf("register: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", map[string]any{"name": ch.name}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body) } var out map[string]any if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil { t.Fatalf("decode open data: %v (body=%s)", err, body) } return out } // TestChannelOpenReportsConnectedTruthfully 场景①:适配器自报真连上 ⇒ connected=true, // 且三个可选键一律缺席(没有坏消息要额外说,老客户端形状不变)。 func TestChannelOpenReportsConnectedTruthfully(t *testing.T) { t.Parallel() out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "connected"}) if out["connected"] != true { t.Fatalf("connected = %v, want true", out["connected"]) } for _, key := range []string{"pending", "ready", "reason"} { if _, present := out[key]; present { t.Fatalf("%s 不该出现在「真连上」的响应里: %v", key, out) } } } // TestChannelOpenReportsPendingWithReason 场景②(本次事故的正题):适配器自报 // 「已在后台重试、当前未连上」时,响应**绝不能**是 connected=true —— 那正是面板弹出 // 「已连接」而卡片一直显示「未连接」的根源。必须如实给出 connected=false + pending // + reason,且 resolved/name 等既有键原样保留。 func TestChannelOpenReportsPendingWithReason(t *testing.T) { t.Parallel() const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused" out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "pending", reason: reason}) if out["connected"] != false { t.Fatalf("connected = %v, want false(重试中不许写成已连接)", out["connected"]) } if out["pending"] != true { t.Fatalf("pending = %v, want true(面板据此显示「正在连接」而不是「未连接」)", out["pending"]) } if out["reason"] != reason { t.Fatalf("reason = %v, want %q(必须把「为什么没连上」如实交出去)", out["reason"], reason) } if _, present := out["ready"]; present { t.Fatalf("ready 不该与 pending 同时出现: %v", out) } if out["name"] != "qq-obv11" || out["resolved"] != "qq-obv11" { t.Fatalf("既有键被破坏: %v", out) } } // TestChannelOpenReportsReady 场景③:ws+server 已绑定监听、还没有对端接入。 // IsConnected() 同样是 false,但它**不是失败**,因此报 ready 而不是 pending, // 也不带 reason(没有坏消息可说)。 func TestChannelOpenReportsReady(t *testing.T) { t.Parallel() out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "ready"}) if out["connected"] != false { t.Fatalf("connected = %v, want false(监听就绪 ≠ 已连上)", out["connected"]) } if out["ready"] != true { t.Fatalf("ready = %v, want true(就绪等接入不是失败)", out["ready"]) } for _, key := range []string{"pending", "reason"} { if _, present := out[key]; present { t.Fatalf("%s 不该出现在「就绪等接入」的响应里: %v", key, out) } } } // TestChannelOpenWithoutOutcomeKeepsLegacyShape 守住「不动别人」:没实现连接态三态 // 的适配器(绝大多数平台)响应与今天逐字节一致 —— 三个可选键整键缺席,只有既有四键。 func TestChannelOpenWithoutOutcomeKeepsLegacyShape(t *testing.T) { t.Parallel() caps := capreg.New() only := &spyChannel{name: "kook:3fc8"} if err := caps.RegisterChannel(only); err != nil { t.Fatalf("register: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", map[string]any{"name": "kook"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var out map[string]any if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if len(out) != 3 || out["connected"] != true { t.Fatalf("data = %v, want exactly {name,resolved,connected:true}", out) } for _, key := range []string{"pending", "ready", "reason"} { if _, present := out[key]; present { t.Fatalf("%s 不该出现(适配器没有这条状态面): %v", key, out) } } } // TestChannelCloseIsNotOptimistic 守住断开路径同样不乐观:报**真实的** IsConnected()。 // 一个「Disconnect 返回 nil 但其实没断开」的适配器必须被照实报成 connected=true, // 而不是假定调用成功就等于断开了。 func TestChannelCloseIsNotOptimistic(t *testing.T) { t.Parallel() stuck := &outcomeChannel{name: "qq-obv11", state: "pending", reason: "still retrying"} stuck.connected.Store(true) // 假装会话还活着,Disconnect 也没能拆掉它 stuckDisconnect := &stubbornChannel{outcomeChannel: stuck} caps := capreg.New() if err := caps.RegisterChannel(stuckDisconnect); err != nil { t.Fatalf("register: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/close", map[string]any{"name": "qq-obv11"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body) } var out map[string]any if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if out["connected"] != true { t.Fatalf("connected = %v, want true(实测仍未断开就必须照实报)", out["connected"]) } if _, present := out["pending"]; present { t.Fatalf("关闭路径不该投影三态: %v", out) } } // stubbornChannel 是一个 Disconnect 不真正断开连接的假适配器:用来证明关闭路径读的 // 是真实 IsConnected(),而不是「调用成功 ⇒ 一定断开了」。 type stubbornChannel struct { *outcomeChannel } func (c *stubbornChannel) Disconnect(context.Context) error { return nil } // TestChannelListExposesConnectOutcome 守住列表侧:面板卡片的状态来自 // GET /admin/channels,因此三态必须在那里也如实出现(平台行与账号行都要), // 否则点完连接刷新一次列表,「连接中」又会变回一句静态的「未连接」。 func TestChannelListExposesConnectOutcome(t *testing.T) { t.Parallel() const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused" ch := &outcomeChannel{name: "qq-obv11:9616b27a", state: "pending", reason: reason} store := channelsStoreOf(t, "qq-obv11", config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}}) caps := capreg.New() if err := caps.RegisterChannel(ch); err != nil { t.Fatalf("register: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) } var rows []struct { Name string `json:"name"` Connected bool `json:"connected"` Pending bool `json:"pending"` Reason string `json:"reason"` Accounts []struct { ID string `json:"id"` Connected *bool `json:"connected"` Pending bool `json:"pending"` Reason string `json:"reason"` } `json:"accounts"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil { t.Fatalf("decode list: %v (body=%s)", err, body) } if len(rows) != 1 { t.Fatalf("rows = %d, want 1 (body=%s)", len(rows), body) } row := rows[0] if row.Connected || !row.Pending || row.Reason != reason { t.Fatalf("平台行 = %+v, want connected=false pending=true reason=%q", row, reason) } if len(row.Accounts) != 1 { t.Fatalf("accounts = %d, want 1", len(row.Accounts)) } acct := row.Accounts[0] if acct.ID != "9616b27a" || acct.Connected == nil || *acct.Connected || !acct.Pending || acct.Reason != reason { t.Fatalf("账号行 = %+v, want id=9616b27a connected=false pending=true reason=%q", acct, reason) } }