缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条
qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的
client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel
空 id = 新建),越改越多。
后端:
- POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响);
按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409
- DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400;
无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id
- 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty;
GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知)
- 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段
前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注
+ 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。
测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、
另一条一次都没被碰」)。
变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId /
删空 id 退化成 ?id= / 空 id 行按普通行渲染)。
207 lines
8.9 KiB
Go
207 lines
8.9 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 在指定时回显。凭据永不出现。
|
||
func channelActionResult(target channelTarget, resolved string, connected bool) map[string]any {
|
||
out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected}
|
||
if target.AccountID != "" {
|
||
out["accountId"] = target.AccountID
|
||
}
|
||
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, "、") + ")"
|
||
}
|