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:
AskaEth committed 2026-10-05 10:37:56 +08:00
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 保持缺位,这样用的是