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 行按普通行渲染)。
This commit is contained in:
1 parent
c4ea02a887
commit
42279b7124
21 files changed
+1719
-164
No files matched your search
@@ -269,25 +269,20 @@ func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens})
|
||||
}
|
||||
|
||||
// channelActionRequest 是 POST /api/v1/admin/channels/open|close 的请求体。
|
||||
type channelActionRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// adminChannelOpen 连接一个已注册渠道适配器(真实的生命周期调用)。
|
||||
// adminChannelOpen 连接一个已注册渠道适配器(真实的生命周期调用)。请求可选的
|
||||
// accountId 决定多账号平台上**连的是哪一条**(见 channelForAction);缺省时走平台级
|
||||
// 旧语义(精确同名适配器 → 该平台配置顺序第一条),因此老客户端/旧面板不受影响。
|
||||
func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := s.channelName(r, w, "channel.open")
|
||||
target, ok := s.channelAction(r, w, "channel.open")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ch, found := findChannel(s.caps, name)
|
||||
if !found {
|
||||
s.auditClaims(r, "channel.open", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
|
||||
ch, ok := s.channelForAction(w, r, target, "channel.open")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := ch.Connect(r.Context()); err != nil {
|
||||
s.auditClaims(r, "channel.open", "error")
|
||||
s.auditClaimsAccount(r, "channel.open", "error", target.AccountID)
|
||||
// 单纯重连修不好的状态不得读作通用失败:
|
||||
// weixin-iLink 机器人 token 被 iLink 拒绝(errcode=-14),因此
|
||||
// 主人需要一次新的扫码登录。用该指引返回 409,
|
||||
@@ -303,34 +298,33 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接"))
|
||||
return
|
||||
}
|
||||
s.log.Error("channel open", "name", name, "err", err)
|
||||
s.log.Error("channel open", "name", ch.Name(), "accountId", target.AccountID, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error()))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "channel.open", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true})
|
||||
s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID)
|
||||
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), true))
|
||||
}
|
||||
|
||||
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。
|
||||
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId
|
||||
// 的语义与 open 完全一致(给出时只断该账号,缺省时同 open 的平台级解析)。
|
||||
func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := s.channelName(r, w, "channel.close")
|
||||
target, ok := s.channelAction(r, w, "channel.close")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ch, found := findChannel(s.caps, name)
|
||||
if !found {
|
||||
s.auditClaims(r, "channel.close", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
|
||||
ch, ok := s.channelForAction(w, r, target, "channel.close")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := ch.Disconnect(r.Context()); err != nil {
|
||||
s.auditClaims(r, "channel.close", "error")
|
||||
s.log.Error("channel close", "name", name, "err", err)
|
||||
s.auditClaimsAccount(r, "channel.close", "error", target.AccountID)
|
||||
s.log.Error("channel close", "name", ch.Name(), "accountId", target.AccountID, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "channel.close", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false})
|
||||
s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID)
|
||||
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), false))
|
||||
}
|
||||
|
||||
// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道
|
||||
@@ -379,27 +373,14 @@ func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// channelName 为 open/close 路由解码 {name} 请求体,失败时写出 400。
|
||||
// 它通过 ok 报告解码的唯一性。
|
||||
func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, 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 "", false
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
s.auditClaims(r, op, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return "", false
|
||||
}
|
||||
return req.Name, true
|
||||
}
|
||||
|
||||
// findChannel 返回名为 name 的已注册渠道适配器(若有)。精确的
|
||||
// 适配器名("kook:conf0")胜出;非精确名的裸平台名("kook")
|
||||
// 仍会解析到该平台首个已注册账号,因此面板/CLI 的
|
||||
// "connect kook" 能抵达携带 token 的账号,即使它名为
|
||||
// "kook:conf0"(空 token 的裸占位符从不注册)。
|
||||
//
|
||||
// 这就是 open/close 在**没有** accountId 时的旧语义;多账号平台上要指名某条
|
||||
// 账号请走 findChannelByAccount(见 admin_channel_target.go),那条路径没有回落。
|
||||
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
|
||||
if caps == nil {
|
||||
return nil, false
|
||||
|
||||
Reference in new issue
Block a user