根因: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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
234 lines
10 KiB
Go
234 lines
10 KiB
Go
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, "、") + ")"
|
||
}
|