Files
AskaEth b4d55f412e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
根因: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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00

234 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
// 渠道 open/close 的「操作目标」解析:请求体里的 name(平台名或精确适配器名)
// 加上**可选**的 accountId。
//
// 这个文件的全部意义是把「连的是哪一条账号」变成不可含糊的事实:主人有两条
// qq-obv11 时,旧契约(只有 name)让面板完全没有指定权 —— 后端只能自己挑,而
// 挑中的可能正是他不想动的历史条目(如 mode=server 且 listen_addr 为空),
// 他新建的、配置正确的 client 账号永远轮不到;连「这次会挑哪一条」都无法预告。
// 因此 accountId 给出时**只**作用于该账号,找不到就报错,绝不静默回落到第一条。
import (
"encoding/json"
"net/http"
"strings"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// defaultChannelAccountID 是「该平台的(唯一)默认账号」这一账号 id 的线上写法。
// 组合根 buildAccountAdapter 对**无 id** 的账号用裸平台名注册适配器,面板则把
// 空 id / "default" 都映射到这个裸名(channelUtils.adapterNameOf),两侧口径在此对齐。
const defaultChannelAccountID = "default"
// channelActionRequest 是 POST /api/v1/admin/channels/open|close 的请求体。
type channelActionRequest struct {
// Name 是渠道名:裸平台名("qq-obv11"),只有一条账号、或想沿用旧行为时用它。
Name string `json:"name"`
// AccountID 是可选的多账号定位器(channels.json 里该账号的 id)。给出时只作用于
// 该账号;缺省时走平台级旧语义(精确同名适配器 → 该平台配置顺序第一条),
// 因此老客户端零影响。
AccountID string `json:"accountId"`
}
// channelTarget 是一次 open/close 的已校验目标。
type channelTarget struct {
Name string
AccountID string
}
// channelAction 解码并校验 open/close 的请求体;name 缺失时写出 400 并返回 ok=false。
func (s *Server) channelAction(r *http.Request, w http.ResponseWriter, op string) (channelTarget, bool) {
var req channelActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return channelTarget{}, false
}
name := strings.TrimSpace(req.Name)
if name == "" {
s.auditClaims(r, op, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return channelTarget{}, false
}
return channelTarget{Name: name, AccountID: strings.TrimSpace(req.AccountID)}, true
}
// channelForAction 解析出要驱动的适配器;失败时写出错误响应并返回 ok=false。
//
// - 无 accountId → 平台级旧语义(见 legacyChannelTarget:精确同名适配器 →
// 该平台配置顺序第一条 → 旧的裸平台名回落);
// - 有 accountId → **只**认该账号的适配器,找不到一律明确报错:
// 账号在 channels.json 里存在 ⇒ 409 account_not_registered(已配置但没有活
// 适配器,多半是缺少必要凭据/构建被跳过);完全不存在 ⇒ 404 account_not_found。
//
// 两条路径都绝不回落到「第一条」:静默回落正是「点连接连错账号」的根因,
// 面板会把「连上了别的账号」读成「连上了我点的那条」。
func (s *Server) channelForAction(w http.ResponseWriter, r *http.Request, target channelTarget, op string) (sdk.Channel, bool) {
if target.AccountID == "" {
if ch, found := s.legacyChannelTarget(target.Name); found {
return ch, true
}
s.auditClaims(r, op, "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", target.Name))
return nil, false
}
if ch, found := findChannelByAccount(s.caps, target.Name, target.AccountID); found {
return ch, true
}
s.auditClaimsAccount(r, op, "error", target.AccountID)
if s.accountConfigured(target.Name, target.AccountID) {
writeError(w, apperr.Wrap(apperr.ErrConflict, "account_not_registered",
"账号 %q 已配置但当前没有已注册的适配器(常见原因:缺少该账号必需凭据,或保存后尚未重载);请先修好该账号配置再连接",
target.AccountID))
return nil, false
}
writeError(w, apperr.Wrap(apperr.ErrNotFound, "account_not_found",
"channel %q has no account %q%s",
channelBasePlatform(target.Name), target.AccountID, s.knownAccountHint(target.Name)))
return nil, false
}
// legacyChannelTarget 解析**没有** accountId 的 open/close 目标(老客户端契约),
// 按三步优先级:
//
// 1. 精确同名适配器("kook" / "kook:conf0")—— 与旧 findChannel 的第一步一致;
// 2. 该平台**有效配置顺序**(channels.json,面板列表用的同一份)里第一条能命中的账号;
// 3. 旧 findChannel 的裸平台名回落(尽力而为)。
//
// 第 2 步是必需的而不是锦上添花:注册表是 map,`List` 的顺序**不保证**,因此旧代码
// 的「该平台第一条」在多账号平台上每次请求都可能落到不同账号 —— 面板根本没法如实
// 写出「这次会连哪一条」。本函数把这一步变成确定性的(配置顺序第一条),
// 而第 1/3 步保持旧语义,所以单账号平台的行为逐字不变。
func (s *Server) legacyChannelTarget(name string) (sdk.Channel, bool) {
if ch, ok := findChannelExact(s.caps, name); ok {
return ch, true
}
if ch, ok := s.firstConfiguredChannel(name); ok {
return ch, true
}
return findChannel(s.caps, name)
}
// firstConfiguredChannel 把「该平台配置顺序里的第一条账号」解析成它已注册的适配器。
// 账号没有 id 时对应的是裸平台适配器名(组合根 buildAccountAdapter 的注册口径)。
// 配置存储未接线(测试)/该平台没有账号/这些账号都没有活适配器时返回 found=false。
func (s *Server) firstConfiguredChannel(name string) (sdk.Channel, bool) {
if s.channels == nil {
return nil, false
}
base := channelBasePlatform(name)
for _, acct := range s.channels.Load()[base] {
id := strings.TrimSpace(acct.ID)
if id == "" {
id = defaultChannelAccountID
}
if ch, ok := findChannelByAccount(s.caps, base, id); ok {
return ch, true
}
}
return nil, false
}
// channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端
// 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的
// 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。
//
// 除既有四个键外,另按适配器自报的**连接态三态**(见 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
}
// findChannelByAccount 精确查找某账号的实时适配器(绝不回落)。账号 id 为
// "default" 时对应的是该平台的裸适配器名(无 id 的账号正是这样注册的)。
func findChannelByAccount(caps CapabilityStore, name, accountID string) (sdk.Channel, bool) {
base := channelBasePlatform(name)
if accountID == defaultChannelAccountID {
return findChannelExact(caps, base)
}
return findChannelExact(caps, base+":"+accountID)
}
// findChannelExact 按注册名精确匹配渠道适配器。它与 findChannel 的区别是**没有**
// 裸平台名回落,因此可用作「就是这一条账号」的判据。
func findChannelExact(caps CapabilityStore, name string) (sdk.Channel, bool) {
if caps == nil || name == "" {
return nil, false
}
for _, cap := range caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
if ch.Name() == name {
return ch, true
}
}
return nil, false
}
// accountConfigured 报告某平台的某账号是否在有效渠道配置里(channels.json → env)。
// 它只用于把「账号不存在」与「账号存在但没有活适配器」区分开,好让面板给出正确
// 指引;返回的账号 id 非敏感,不带任何凭据。
func (s *Server) accountConfigured(name, accountID string) bool {
if s.channels == nil {
return false
}
base := channelBasePlatform(name)
for _, acct := range s.channels.Load()[base] {
if strings.TrimSpace(acct.ID) == accountID {
return true
}
}
return false
}
// knownAccountHint 列出该平台已知的账号 id,附在「账号不存在」错误后,好让面板/
// 调用方看到可用取值(id 非敏感)。没有任何已知账号时返回空串。
func (s *Server) knownAccountHint(name string) string {
if s.channels == nil {
return ""
}
var ids []string
for _, acct := range s.channels.Load()[channelBasePlatform(name)] {
if id := strings.TrimSpace(acct.ID); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return ""
}
return "(该平台已知账号:" + strings.Join(ids, "、") + ")"
}