fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。

改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。

线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。

凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。

验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
This commit is contained in:
AskaEth committed 2026-10-13 19:38:56 +08:00
1 parent d81635672e
commit b4d55f412e
23 files changed
+1440 -27

No files matched your search

@@ -0,0 +1,82 @@
package adapter
// 连接**就绪三态**的取值与判定:一次 Connect 返回之后,适配器到底处于哪一种状态。
//
// 为什么必须三态而不是一个 bool:`Connect` 返回 nil 只说明「这次调用没有致命失败」,
// 它至少覆盖三件互不相同的事——
//
// ConnectStateConnected —— 真连上了(client 拨号成功 / HTTP 传输已装配就绪);
// ConnectStatePending —— 已进入后台重试、当前还没连上(OneBot 实现比 Cyrene 后
// 启动是常态,首连失败刻意非致命);
// ConnectStateReady —— 已绑定监听、就绪等待对端接入(ws+server 模式)。
//
// pending 与 ready 的 IsConnected() 都是 false,但它们**不是**「未连接」这样一个静态
// 结论:前者是「正在连接」,后者是「就绪等接入」。用一个布尔去覆盖三态,管理面板就只能
// 编一个说法——编出来的正是 2026-10-07 那个「点了连接就弹已连接,而卡片一直显示未连接」
// 的谎。口径属于适配器(它才知道自己的 transport × mode 意味着什么),网关只做映射
// (internal/services/gateway 的 connectOutcomeAware,经类型断言探测,核心契约不扩展)。
//
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
// transport × mode);立场与 design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致 ——
// 一个布尔说不了两件事,更说不了三件。
//
// 与 SessionState* / AccountStatus 同一口径:核心 PlatformAdapter 契约**刻意不扩展**,
// 需要这条状态面的适配器自行实现该方法,网关用类型断言探测;没实现的适配器行为逐字不变。
const (
// ConnectStateConnected 表示真连上:会话已建立(client 拨号成功),或传输已装配
// 就绪而无可观察的长连接(HTTP)。此时 IsConnected() 应为 true。
ConnectStateConnected = "connected"
// ConnectStatePending 表示已进入后台重试、当前尚未连上。它既不是配置错误,也不是
// 「未连接」,而是「连接中」,Reason 应说明原因(脱敏,绝不含凭据)。
ConnectStatePending = "pending"
// ConnectStateReady 表示已就绪、等待对端接入:server 模式已绑定监听,但还没有
// 对端连上来。IsConnected() 此时为 false,却**不是失败**,因此没有 Reason。
ConnectStateReady = "ready"
)
// ConnectSignals 是判定三态所需的**全部**输入。用结构体而不是几个相邻的布尔参数,
// 一是让调用点自解释,二是避免两个 bool 被接反(那会静默地骗过所有测试)。
type ConnectSignals struct {
// Started 报告 Connect 是否已成功启动该适配器;false 表示尚未启动,无从判断。
Started bool
// Connected 报告当前是否有一条活的会话。
Connected bool
// Retrying 报告未连上时后台是否有重试循环在跑(ws+client 有一条;HTTP 没有会话
// 可重连,不算)。
Retrying bool
// Listening 报告适配器是否已绑定监听、在等对端接入(server 模式)。
Listening bool
// Reason 是最近一次失败的原因(**已脱敏**);没有失败或已连上时为空串。
Reason string
}
// ConnectStateOf 是**纯函数**:由信号判定三态与原因。它不读时钟、不触网、不加锁,
// 因此三态可以逐条单测(见 connect_outcome_test.go);适配器侧只负责在同一把锁下把
// 信号读齐。
//
// 判定顺序(自上而下,先匹配者胜):
//
// !Started → ("", "") —— 未启动的适配器无从判断:回「无意见」而不是
// 随便认一个状态,调用方据此让可选键整键缺席。
// Connected → ConnectStateConnected —— 有活会话就是真连上,先于其它分支。
// Retrying → ConnectStatePending —— 尚未连上但重试在跑,这才是「连接中」。
// Listening → ConnectStateReady —— 监听已就绪、等对端接入,不是失败。
// 否则 → ("", "") —— 已启动、未连上、也没有可解释的就绪面:**不编**
// 状态,让调用方退回只看 IsConnected()。
//
// 只有 pending 会带 Reason:connected 没有任何坏消息要说,ready 本来就不是坏消息。
func ConnectStateOf(sig ConnectSignals) (state, reason string) {
switch {
case !sig.Started:
return "", ""
case sig.Connected:
return ConnectStateConnected, ""
case sig.Retrying:
return ConnectStatePending, sig.Reason
case sig.Listening:
return ConnectStateReady, ""
default:
return "", ""
}
}
@@ -0,0 +1,75 @@
// 本文件只测连接态三态的**纯判定**(ConnectStateOf):它不读时钟、不触网、不加锁,
// 因此三态可以逐条单测。适配器侧的落点(qq_obv11 的 ConnectOutcome 与脱敏)见
// plugins/channels/qq_obv11/connect_outcome_test.go,网关侧的映射见
// internal/services/gateway/admin_channel_connect_outcome_test.go。
package adapter
import "testing"
// TestConnectStateOfNotStartedHasNoOpinion 断言未启动的适配器**不编状态**:回空串,
// 调用方据此退回只看 IsConnected(),而不是随便认一个。
func TestConnectStateOfNotStartedHasNoOpinion(t *testing.T) {
t.Parallel()
for _, sig := range []ConnectSignals{
{},
{Connected: true},
{Retrying: true, Reason: "boom"},
{Listening: true},
} {
if state, reason := ConnectStateOf(sig); state != "" || reason != "" {
t.Errorf("ConnectStateOf(%+v) = (%q,%q), want 无意见", sig, state, reason)
}
}
}
// TestConnectStateOfConnectedWins 断言真连上优先于其它分支(监听与重试信号同时为真
// 也不该改变结论),且**不**带原因 —— 「已连接」旁边不该挂一条过期的失败原因。
func TestConnectStateOfConnectedWins(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Connected: true, Retrying: true, Listening: true, Reason: "stale failure",
})
if state != ConnectStateConnected || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateConnected)
}
}
// TestConnectStateOfRetryingIsPending 断言「未连上但重试在跑」是 pending 且带上原因。
// 这正是 2026-10-07 事故里被写成「已连接」(或静态「未连接」)的那一格。
func TestConnectStateOfRetryingIsPending(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Retrying: true, Reason: "dial tcp 127.0.0.1:10301: connection refused",
})
if state != ConnectStatePending {
t.Fatalf("state = %q, want %q", state, ConnectStatePending)
}
if reason != "dial tcp 127.0.0.1:10301: connection refused" {
t.Fatalf("reason = %q, want the failure reason verbatim", reason)
}
// 三个取值互不相同:把 pending 写成 connected 是这次要根除的谎。
if ConnectStatePending == ConnectStateConnected || ConnectStatePending == ConnectStateReady {
t.Fatal("pending 的取值必须与 connected / ready 都不同")
}
}
// TestConnectStateOfListeningIsReady 断言「已绑定监听、等对端接入」是 ready 而不是
// pending:它不是失败,因此**没有**原因可说(哪怕结构体里带着一条陈旧的 reason)。
func TestConnectStateOfListeningIsReady(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Listening: true, Reason: "should not be surfaced",
})
if state != ConnectStateReady || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateReady)
}
}
// TestConnectStateOfUnexplainedStaysSilent 断言「已启动、未连上、也没有可解释的就绪面」
// 时**不编**状态:既不说「正在连接」(不知道有没有在连),也不说「就绪」。
func TestConnectStateOfUnexplainedStaysSilent(t *testing.T) {
t.Parallel()
if state, reason := ConnectStateOf(ConnectSignals{Started: true, Reason: "orphan"}); state != "" || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want 无意见", state, reason)
}
}
+8
View File
@@ -23,11 +23,19 @@ type PluginSummary struct {
// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权
// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器
// 确实要求时才出现,否则一律省略。
//
// Pending/Ready/Reason(v0.68)报告 `connected=false` 时**到底是哪种没连上**
// (见 admin_channel_connect_outcome.go 中的 connectOutcomeAware):pending =
// 已在后台重试、当前未连上(面板读作「连接中」),ready = 已就绪、等待对端接入。
// 三者同样只在适配器确实自报时才出现 —— 没有这条状态面的平台逐字节不受影响。
type ChannelSummary struct {
Name string `json:"name"`
Connected bool `json:"connected"`
NeedsRescan bool `json:"needsRescan,omitempty"`
LastError string `json:"lastError,omitempty"`
Pending bool `json:"pending,omitempty"`
Ready bool `json:"ready,omitempty"`
Reason string `json:"reason,omitempty"`
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
}
@@ -0,0 +1,98 @@
package gateway
// 本文件承载管理面读取**适配器连接态三态**的那一层:把适配器的 ConnectOutcome
// 映射成面板与线上契约能用的形状,使「点了连接之后到底怎么样了」不再只有一个布尔。
//
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」。ws+client 的首连
// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时 IsConnected() 为 false ——
// 它与「监听已就绪等对端接入」(server 模式,同样 false)在布尔上无从区分,更与
// 「配置错、起不来」分不开。网关**绝不**按适配器名或 transport 去猜:口径属于适配器,
// 它才知道自己的 transport × mode 意味着什么;网关只做映射。
//
// 与 accountStatusAware / rescanAware / sessionStatusAware 同一契约风格(见
// admin_channel_status.go 与 admin_channels_rescan.go):核心 SDK Channel 契约被刻意
// 不扩展,因此没有这条状态面的平台不受任何改动,也不会凭空多出一个字段。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// connectOutcomeAware 是渠道适配器暴露「Connect 之后处于哪种状态」这一可选能力的接口。
// 当前由 *qq_obv11.Adapter 实现(ConnectOutcome):它的 ws+client 首连失败非致命、
// ws+server 只需监听就绪,两种「没连上」都不是同一件事。
//
// 返回的 state 是**闭集**取值(internal/channel/adapter 的 ConnectState* 常量):
//
// "connected" —— 真连上;
// "pending" —— 已进入后台重试、当前未连上,reason 说明原因;
// "ready" —— 已绑定监听、就绪等对端接入,不是失败;
// "" —— 无意见(尚未启动),调用方退回只看 IsConnected()。
//
// 闭集而不是布尔,是因为「没连上」至少还有两种互不相同的现状,而它们对主人的意义
// 完全不同:一种是「还在连」,一种是「就等对面来连」。
type connectOutcomeAware interface {
// ConnectOutcome 报告连接态;state 取值见上,reason 在未连上时说明原因
// (如实的中文/技术短句,**绝不含凭据**)。
ConnectOutcome() (state string, reason string)
}
// asConnectOutcomeAware 报告已注册的渠道值是否暴露连接态三态。与 asRescanAware /
// asAccountStatusAware 同一套路:注册时会把适配器包进 cmd/cyrene 的 namedAdapter ——
// 一个内嵌 adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的方法,因此具体
// 适配器多出的能力只能通过拆开被内嵌字段才能抵达。直接的适配器值(测试)按原样接受。
func asConnectOutcomeAware(v any) (connectOutcomeAware, bool) {
if co, ok := v.(connectOutcomeAware); ok {
return co, true
}
inner, ok := unwrapNamedAdapter(v)
if !ok {
return nil, false
}
co, ok := inner.(connectOutcomeAware)
return co, ok
}
// channelConnectOutcome 是连接态三态在**线上**的投影:三态里只有两种「没连上」需要
// 额外说明(真连上已经由 connected 布尔说完),reason 只在 pending 时非空。
//
// 刻意用可选键而不是把三态塞进 connected:connected 的语义已被老客户端当作「连上了
// 没有」在读,改写它等于改契约;新增可选键则谁都不吃亏 —— 零值的三态投影让
// pending / ready / reason **整键缺席**,与 NeedsRescan / LastError 同一口径。
type channelConnectOutcome struct {
// Pending 表示「已在后台重试、当前未连上」——面板须把它读作「连接中」,
// 绝不是「未连接」,也绝不是「已连接」。
Pending bool
// Ready 表示「已就绪、等待对端接入」——同样不是失败。
Ready bool
// Reason 是未连上时的原因(已脱敏),仅在 Pending 时可能非空。
Reason string
}
// channelConnectStateOf 报告某渠道适配器当前的连接态:connected 布尔 + 需要额外说明的
// 三态投影。
//
// 适配器实现了 connectOutcomeAware ⇒ **以它为准**:闭集三态优先于 IsConnected() 的
// 单个布尔(pending 与 ready 的 IsConnected() 都是 false,但两者都不是「未连接」这样
// 一个静态结论)。没实现,或返回空串「无意见」⇒ 逐字退回今天的行为(只看
// IsConnected(),三态投影为零值、可选键缺席)。
func channelConnectStateOf(ch sdk.Channel) (bool, channelConnectOutcome) {
if ch == nil {
return false, channelConnectOutcome{}
}
aware, ok := asConnectOutcomeAware(ch)
if !ok {
return ch.IsConnected(), channelConnectOutcome{}
}
state, reason := aware.ConnectOutcome()
switch state {
case adapter.ConnectStateConnected:
return true, channelConnectOutcome{}
case adapter.ConnectStatePending:
return false, channelConnectOutcome{Pending: true, Reason: reason}
case adapter.ConnectStateReady:
return false, channelConnectOutcome{Ready: true}
default:
return ch.IsConnected(), channelConnectOutcome{}
}
}
@@ -0,0 +1,251 @@
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)
}
}
@@ -48,6 +48,58 @@ func enrichAccountsWithAccountOnline(accts []AdminChannelAccount, by map[string]
return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v })
}
// liveAccountConnectOutcomes 收集每个平台各账号的**连接态三态**(适配器自报的
// pending / ready / reason),键与 liveAccountConnections 相同(裸适配器名 ⇒ 空账号 id)。
//
// 只有确实自报了三态的账号才进 map:三态投影为零值(适配器没这条状态面,或它此刻自认
// 「真连上 / 无意见」)就缺席 —— 缺席 = 面板退回只看 connected 布尔,绝不凭空编一个
// 状态。补一个 false 顶上去,与本次要根除的「编一个状态」同属一类。
func (s *Server) liveAccountConnectOutcomes() map[string]map[string]channelConnectOutcome {
out := make(map[string]map[string]channelConnectOutcome)
if s.caps == nil {
return out
}
for _, cap := range s.caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
_, outcome := channelConnectStateOf(ch)
if outcome == (channelConnectOutcome{}) {
continue
}
platform, acct := splitAdapterName(ch.Name())
if out[platform] == nil {
out[platform] = make(map[string]channelConnectOutcome)
}
out[platform][acct] = outcome
}
return out
}
// enrichAccountsWithConnectOutcome 把**连接态三态**合并进平台中匹配的账号(按账号 id
// 配对)。不在 by 里的账号保持 Pending/Ready/Reason 全空 ⇒ 整键缺席 ⇒ 面板退回只看
// connected。入参切片不被修改。
func enrichAccountsWithConnectOutcome(
accts []AdminChannelAccount,
by map[string]channelConnectOutcome,
) []AdminChannelAccount {
if len(by) == 0 {
return accts
}
out := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
item := a
if o, ok := by[item.ID]; ok {
item.Pending = o.Pending
item.Ready = o.Ready
item.Reason = o.Reason
}
out = append(out, item)
}
return out
}
// enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的
// 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 ——
// 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。
@@ -135,11 +135,38 @@ func (s *Server) firstConfiguredChannel(name string) (sdk.Channel, bool) {
// channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端
// 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的
// 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。
func channelActionResult(target channelTarget, resolved string, connected bool) map[string]any {
//
// 除既有四个键外,另按适配器自报的**连接态三态**(见 admin_channel_connect_outcome.go)
// 补三个**可选**键,且取不到就整键缺席(同 NeedsRescan / LastError 口径):
//
// pending: true —— 已在后台重试、当前未连上 ⇒ 此时 connected=false。面板须读作
// 「连接中(reason)」,**不是**「未连接」,更不是「已连接」;
// ready: true —— 已就绪、等待对端接入(server 模式)⇒ 同样 connected=false,
// 但它不是失败;
// reason: "<已脱敏>" —— 未连上时的原因,仅在 pending 时可能出现。
//
// 真连上时这三个键一律缺席:connected=true 已经把话说完了,再补一个「没有坏消息」的
// 空串只会让老客户端多出一个要处理的键。适配器没实现这条状态面时同理 —— 响应与今天
// 逐字节一致。
func channelActionResult(
target channelTarget,
resolved string,
connected bool,
outcome channelConnectOutcome,
) map[string]any {
out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected}
if target.AccountID != "" {
out["accountId"] = target.AccountID
}
if outcome.Pending {
out["pending"] = true
}
if outcome.Ready {
out["ready"] = true
}
if outcome.Reason != "" {
out["reason"] = outcome.Reason
}
return out
}
+18 -4
View File
@@ -63,6 +63,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
botByPlatform := s.liveBotIdentity()
connectedByPlatform := s.liveAccountConnections()
onlineByPlatform := s.liveAccountOnline()
outcomeByPlatform := s.liveAccountConnectOutcomes()
out := make([]ChannelSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) {
@@ -87,7 +88,17 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
if s.hasChannelSummary(out, platform) {
continue
}
sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()}
// 连接态三态(v0.68):`connected=false` 可能只是「正在后台重试」或
// 「监听已就绪、等对端接入」,两者都不是「未连接」这样一个静态结论。
// 只有适配器确实自报了才写这三个键,否则一律省略(同 NeedsRescan 口径)。
connected, outcome := channelConnectStateOf(ch)
sum := ChannelSummary{
Name: platform,
Connected: connected,
Pending: outcome.Pending,
Ready: outcome.Ready,
Reason: outcome.Reason,
}
// 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器
// 确实要求重新扫码时才报告,因此健康的适配器保持字段省略,
// 而不是送出一个编造的值。
@@ -98,9 +109,12 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
}
}
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
sum.Accounts = enrichAccountsWithAccountOnline(
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
onlineByPlatform[platform],
sum.Accounts = enrichAccountsWithConnectOutcome(
enrichAccountsWithAccountOnline(
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
onlineByPlatform[platform],
),
outcomeByPlatform[platform],
)
}
out = append(out, sum)
@@ -70,6 +70,14 @@ type AdminChannelAccount struct {
// 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。
// 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。
AccountOnline *bool `json:"accountOnline,omitempty"`
// Pending/Ready/Reason 是该账号适配器自报的**连接态三态**(可选能力,见
// internal/services/gateway/admin_channel_connect_outcome.go):Connected=false 时
// 面板据此把「已在后台重试(连接中)」与「已就绪、等待对端接入」从「未连接」里
// 分出来。三者缺省 = 该适配器没有这条状态面 ⇒ 面板退回只看 Connected,
// 绝不凭空编一个状态。
Pending bool `json:"pending,omitempty"`
Ready bool `json:"ready,omitempty"`
Reason string `json:"reason,omitempty"`
// Capabilities 列出该平台适配器暴露的账号级管理操作
// (friends/channels/user-chats/message-test),使面板
// 渲染正确的每账号卡片。没有专用适配器表面的平台
@@ -303,7 +303,12 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
return
}
s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), true))
// `Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连失败刻意
// 非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪。所以这里
// **绝不**硬编码 connected=true:如实取适配器自报的连接态三态,并把「重试中(含
// 原因)」与「就绪等接入」作为可选键一并交出(见 admin_channel_connect_outcome.go)。
connected, outcome := channelConnectStateOf(ch)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), connected, outcome))
}
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId
@@ -324,7 +329,11 @@ func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
return
}
s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), false))
// 断开不乐观:报**真实的** IsConnected(),而不是假定「调用成功 ⇒ 一定断开了」。
// 关闭路径刻意不投影三态:pending / ready 说的是「连的过程」,而这里要回答的是
// 「会话到底还在不在」——那正是 IsConnected() 的语义。
writeJSON(w, http.StatusOK,
channelActionResult(target, ch.Name(), ch.IsConnected(), channelConnectOutcome{}))
}
// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道