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, "、") + ")" }