Files
Cyrene_v2/internal/services/gateway/admin_channel_target.go
T
AskaEth 42279b7124 feat(channels): 按账号连接/断开 + 可删空 id 条目(修主人真机踩的两个缺口)
缺口①:「连接」是按平台连、且恒打第一条账号(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 行按普通行渲染)。
2026-10-05 10:37:56 +08:00

207 lines
8.9 KiB
Go
Raw 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 在指定时回显。凭据永不出现。
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, "、") + ")"
}