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
@@ -51,6 +51,10 @@ type AdminChannelAccount struct {
|
||||
BotID string `json:"botId,omitempty"`
|
||||
BotUsername string `json:"botUsername,omitempty"`
|
||||
BotNickname string `json:"botNickname,omitempty"`
|
||||
// Connected 是该账号**实时适配器**的连接状态(面板逐账号渲染连接/断开的依据)。
|
||||
// nil(字段缺省)表示「当前没有该账号的已注册适配器」—— 状态**未知**,面板不得
|
||||
// 画成「未连接」,也不得允许断开一条它根本没连上的账号。
|
||||
Connected *bool `json:"connected,omitempty"`
|
||||
// Capabilities 列出该平台适配器暴露的账号级管理操作
|
||||
// (friends/channels/user-chats/message-test),使面板
|
||||
// 渲染正确的每账号卡片。没有专用适配器表面的平台
|
||||
@@ -181,19 +185,34 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// adminDeleteChannelAccount 按 id 查询参数从某平台在 channels.json
|
||||
// 中移除一个账号,并热重载该平台使适配器停止。
|
||||
// adminDeleteChannelAccount 从某平台移除账号并热重载该平台。两种寻址方式,
|
||||
// **都必须显式**给出:
|
||||
//
|
||||
// - `?id=<账号 id>` → 删除该 id 的那一条(id 非空);
|
||||
// - `?empty=1` → 删除该平台上**全部无 id 的历史条目**(面板根本改不动它们:
|
||||
// 带空 id 保存会被 runtimeconfig.UpsertChannel 当成新账号另开一条,越改越多)。
|
||||
//
|
||||
// 绝不允许把「id 缺失/为空」默认解释成「删掉无 id 条目」:那是一次没有字面证据的
|
||||
// 批量删除,误删无从追溯。两个参数同时给出也一律 400(一次请求只针对一种条目)。
|
||||
func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) {
|
||||
platform := strings.TrimSpace(r.PathValue("platform"))
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
query := r.URL.Query()
|
||||
id := strings.TrimSpace(query.Get("id"))
|
||||
empty := queryFlag(query.Get("empty"))
|
||||
if platform == "" {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
if id != "" && empty {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id 与 empty=1 不能同时给出:一次请求只删除一种条目"))
|
||||
return
|
||||
}
|
||||
if id == "" && !empty {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request",
|
||||
"id is required(要删除无 id 的历史条目请显式传 ?empty=1)"))
|
||||
return
|
||||
}
|
||||
if s.channels == nil {
|
||||
@@ -202,7 +221,22 @@ func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
accounts := s.channels.Load()
|
||||
updated := runtimeconfig.DeleteChannel(accounts, platform, id)
|
||||
updated := accounts
|
||||
removed := 1
|
||||
if empty {
|
||||
updated, removed = deleteEmptyIDAccounts(accounts, platform)
|
||||
if removed == 0 {
|
||||
// 没有可删的空 id 条目:不写文件、不重载,明确告诉调用方「这里没有它要找的东西」,
|
||||
// 而不是回一个什么都没做的 200。
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found",
|
||||
"platform %q has no id-less account to delete", platform))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
updated = runtimeconfig.DeleteChannel(accounts, platform, id)
|
||||
removed = 1
|
||||
}
|
||||
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
s.log.Error("channel delete channels.json", "path", s.channels.Path(), "error", err)
|
||||
@@ -215,15 +249,59 @@ func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "channel.delete", "ok")
|
||||
s.auditClaimsAccount(r, "channel.delete", "ok", id)
|
||||
s.log.Info("channel account deleted", "platform", platform, "accountId", id, "empty", empty, "removed", removed)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"persisted": true,
|
||||
"path": s.channels.Path(),
|
||||
"platform": platform,
|
||||
"accountId": id,
|
||||
"empty": empty,
|
||||
"removed": removed,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteEmptyIDAccounts 移除某平台上**全部无 id 条目**,返回剩余账号与删除条数。
|
||||
// 空 id 条目之间没有任何可寻址的区别(没有 id 可指名),因此「只删某一条」在这类
|
||||
// 条目上没有可实现的语义;面板把这动作标成「删除无 id 条目」并展示实际条数,
|
||||
// 删除范围因此对调用方是完全可见的。平台再无账号时整个平台键被移除,与
|
||||
// runtimeconfig.DeleteChannel 的收尾口径一致。
|
||||
func deleteEmptyIDAccounts(accounts map[string][]config.ChannelAccount, platform string) (map[string][]config.ChannelAccount, int) {
|
||||
if accounts == nil {
|
||||
return map[string][]config.ChannelAccount{}, 0
|
||||
}
|
||||
kept := make([]config.ChannelAccount, 0, len(accounts[platform]))
|
||||
removed := 0
|
||||
for _, cur := range accounts[platform] {
|
||||
if strings.TrimSpace(cur.ID) == "" {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, cur)
|
||||
}
|
||||
if removed == 0 {
|
||||
return accounts, 0
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(accounts, platform)
|
||||
} else {
|
||||
accounts[platform] = kept
|
||||
}
|
||||
return accounts, removed
|
||||
}
|
||||
|
||||
// queryFlag 解析显式的布尔查询参数:只有字面表示「是」的取值才算给出
|
||||
// (1/true/yes/on)。空串与其它一切取值都是「否」,因此 `?empty=` / `?empty=0`
|
||||
// 绝不会被读成「删掉无 id 条目」。
|
||||
func queryFlag(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// channelConfigFromRequest 把请求的便捷字段合并进平台特有 config
|
||||
// map,优先采用面板已显式设置的键。它绝不注入面板
|
||||
// 未给出的键(空 token 保持缺位,这样用的是
|
||||
|
||||
Reference in new issue
Block a user