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

@@ -0,0 +1,92 @@
package gateway
import (
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件锁定管理列表的**按账号**连接状态:多账号平台上,每行连接/断开的门控
// 必须看该账号自己的状态;没有活适配器的账号状态**未知**(字段缺省),
// 而不是被读成「未连接」——把「读不到」画成「没连上」会让主人盲点「断开」。
// TestAdminListChannelsReportsPerAccountConnection 断言每个账号各报自己的状态,
// 且没有活适配器的账号**不带** connected 字段(未知 ≠ 未连接)。
func TestAdminListChannelsReportsPerAccountConnection(t *testing.T) {
t.Parallel()
caps := capreg.New()
live := &spyChannel{name: "kook:3fc8"}
live.connected.Store(true)
down := &spyChannel{name: "kook:conf1"}
for _, ch := range []*spyChannel{live, down} {
if err := caps.RegisterChannel(ch); err != nil {
t.Fatalf("register %s: %v", ch.Name(), err)
}
}
store := &stubChannelConfigStore{
path: t.TempDir() + "/channels.json",
accounts: map[string][]config.ChannelAccount{
"kook": {
{ID: "3fc8", Config: map[string]any{"bot_token": "tk-a"}},
{ID: "conf1", Config: map[string]any{"bot_token": "tk-b"}},
{ID: "conf2", Config: map[string]any{"bot_token": "tk-c"}}, // 无活适配器 ⇒ 状态未知
},
},
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var items []ChannelSummary
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
t.Fatalf("decode list: %v (body=%s)", err, body)
}
if len(items) != 1 || items[0].Name != "kook" {
t.Fatalf("items = %+v, want a single kook row", items)
}
if len(items[0].Accounts) != 3 {
t.Fatalf("accounts = %+v, want all three configured accounts listed", items[0].Accounts)
}
state := accountConnections(items[0].Accounts)
if got := state["3fc8"]; got == nil || !*got {
t.Fatalf("account 3fc8 connected = %v, want true", got)
}
if got := state["conf1"]; got == nil || *got {
t.Fatalf("account conf1 connected = %v, want false", got)
}
// nil = 字段缺省:没有活适配器 ⇒ 状态未知,绝不能画成「未连接」。
if got := state["conf2"]; got != nil {
t.Fatalf("account conf2 connected = %v, want the field omitted(未知 ≠ 未连接)", *got)
}
// 凭据绝不进列表响应。
if bytes := string(body); containsToken(bytes) {
t.Fatalf("list response leaked a token: %s", bytes)
}
}
// accountConnections 把账号列表映射成 id → 连接状态(字段缺省时值为 nil)。
func accountConnections(accts []AdminChannelAccount) map[string]*bool {
out := make(map[string]*bool, len(accts))
for _, a := range accts {
out[a.ID] = a.Connected
}
return out
}
// containsToken 报告响应体里是否出现了测试用的原始 token 片段。
func containsToken(body string) bool {
for _, tk := range []string{"tk-a", "tk-b", "tk-c"} {
if strings.Contains(body, tk) {
return true
}
}
return false
}
@@ -0,0 +1,283 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"strings"
"sync/atomic"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件锁定「连接到底连了谁」这一根本困惑:
//
// - 带 accountId 的 open/close **只**作用于该账号;
// - 找不到该 id 时明确报错(404 account_not_found / 409 account_not_registered),
// **绝不静默回落到第一条**(静默回落正是主人点「连接」打到历史条目的根因);
// - 不带 accountId 时维持老契约,且在多账号平台上按**配置顺序**确定目标
// (注册表是 map,旧代码的「第一条」其实每次可能不同,面板无法如实标注)。
// spyChannel 记录 Connect/Disconnect 是否真被调用,使「面板点的那一条被驱动了、
// 另一条没被碰」成为可断言的证据。
type spyChannel struct {
name string
connects atomic.Int32
disconns atomic.Int32
connected atomic.Bool
connectErr error
}
func (s *spyChannel) Name() string { return s.name }
func (s *spyChannel) IsConnected() bool { return s.connected.Load() }
func (s *spyChannel) HealthCheck(context.Context) error { return nil }
func (s *spyChannel) Connect(context.Context) error {
s.connects.Add(1)
if s.connectErr != nil {
return s.connectErr
}
s.connected.Store(true)
return nil
}
func (s *spyChannel) Disconnect(context.Context) error {
s.disconns.Add(1)
s.connected.Store(false)
return nil
}
// twoAccountFixture 建一个「两条 qq-obv11」的现场:一条无 id 的历史条目
// (裸适配器名)与一条主人新建的 client 账号,并返回 spy 句柄。
func twoAccountFixture(t *testing.T, store *stubChannelConfigStore) (*spyChannel, *spyChannel, string, *auth.JWTService) {
t.Helper()
legacy := &spyChannel{name: "qq-obv11"}
fresh := &spyChannel{name: "qq-obv11:9616b27a"}
caps := capreg.New()
if err := caps.RegisterChannel(legacy); err != nil {
t.Fatalf("register legacy: %v", err)
}
if err := caps.RegisterChannel(fresh); err != nil {
t.Fatalf("register fresh: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
return legacy, fresh, ts.URL, jwt
}
// worktreeTestStore 返回一个临时 channels.json 存储,含该平台的给定账号。
func channelsStoreOf(t *testing.T, platform string, accts ...config.ChannelAccount) *stubChannelConfigStore {
t.Helper()
return &stubChannelConfigStore{
path: t.TempDir() + "/channels.json",
accounts: map[string][]config.ChannelAccount{platform: accts},
}
}
// TestAdminChannelOpenTargetsRequestedAccount 断言 accountId 给出时连的是它,
// 而不是该平台注册表里的第一条。
func TestAdminChannelOpenTargetsRequestedAccount(t *testing.T) {
t.Parallel()
legacy, fresh, base, jwt := twoAccountFixture(t, nil)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/open",
map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := fresh.connects.Load(); got != 1 {
t.Fatalf("fresh account Connect calls = %d, want 1", got)
}
if got := legacy.connects.Load(); got != 0 {
t.Fatalf("legacy account Connect calls = %d, want 0(不得打到别条账号)", got)
}
var res struct {
Name string `json:"name"`
Resolved string `json:"resolved"`
AccountID string `json:"accountId"`
Connected bool `json:"connected"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode open response: %v (body=%s)", err, body)
}
// resolved 必须如实报出**被驱动**的适配器:面板靠它显示「连的是哪一条」。
if res.Resolved != "qq-obv11:9616b27a" || res.AccountID != "9616b27a" || !res.Connected {
t.Fatalf("open response = %+v, want resolved=qq-obv11:9616b27a accountId=9616b27a connected=true", res)
}
}
// TestAdminChannelCloseTargetsRequestedAccount 同上,针对 close。
func TestAdminChannelCloseTargetsRequestedAccount(t *testing.T) {
t.Parallel()
legacy, fresh, base, jwt := twoAccountFixture(t, nil)
fresh.connected.Store(true)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/close",
map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fresh.disconns.Load() != 1 || legacy.disconns.Load() != 0 {
t.Fatalf("disconnect calls fresh=%d legacy=%d, want 1/0", fresh.disconns.Load(), legacy.disconns.Load())
}
if fresh.IsConnected() {
t.Fatal("fresh account still connected after close")
}
}
// TestAdminChannelOpenUnknownAccountIDIs404 断言「找不到该 id」是明确错误,
// 且**绝不**把请求打到该平台的第一条账号(静默回落是本 bug 的根因)。
func TestAdminChannelOpenUnknownAccountIDIs404(t *testing.T) {
t.Parallel()
store := channelsStoreOf(t, "qq-obv11",
config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
legacy, fresh, base, jwt := twoAccountFixture(t, store)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/open",
map[string]any{"name": "qq-obv11", "accountId": "does-not-exist"}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
errObj := decodeAdminError(t, body)
if errObj.Code != "account_not_found" {
t.Fatalf("error code = %q, want account_not_found", errObj.Code)
}
// 错误文案必须带上可用账号,方便面板/调用方自行纠正(账号 id 非敏感)。
if !strings.Contains(errObj.Message, "9616b27a") {
t.Fatalf("error message = %q, want it to list the known account id", errObj.Message)
}
if legacy.connects.Load() != 0 || fresh.connects.Load() != 0 {
t.Fatalf("no account may be connected on a miss: legacy=%d fresh=%d",
legacy.connects.Load(), fresh.connects.Load())
}
}
// TestAdminChannelOpenConfiguredButUnregisteredIs409 区分两种 miss:账号确实在
// channels.json 里、但当前没有它的活适配器(多半缺必要凭据),给 409 而不是 404,
// 面板据此提示「去修该账号配置」而不是「账号不存在」。
func TestAdminChannelOpenConfiguredButUnregisteredIs409(t *testing.T) {
t.Parallel()
store := channelsStoreOf(t, "qq-obv11",
config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
caps := capreg.New()
legacy := &spyChannel{name: "qq-obv11"}
if err := caps.RegisterChannel(legacy); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409 (body=%s)", resp.StatusCode, body)
}
if code := decodeAdminError(t, body).Code; code != "account_not_registered" {
t.Fatalf("error code = %q, want account_not_registered", code)
}
if legacy.connects.Load() != 0 {
t.Fatal("unregistered account must not fall back to another account")
}
}
// TestAdminChannelOpenWithoutAccountIDStaysLegacy 断言缺省 accountId 时仍是老契约:
// 单账号平台照旧连上那一条,且响应不凭空造出 accountId。
func TestAdminChannelOpenWithoutAccountIDStaysLegacy(t *testing.T) {
t.Parallel()
caps := capreg.New()
only := &spyChannel{name: "kook:3fc8"}
if err := caps.RegisterChannel(only); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "kook"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if only.connects.Load() != 1 {
t.Fatalf("connect calls = %d, want 1", only.connects.Load())
}
var out map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if _, present := out["accountId"]; present {
t.Fatalf("platform-level open must not invent an accountId: %v", out)
}
if out["name"] != "kook" || out["resolved"] != "kook:3fc8" {
t.Fatalf("open data = %v, want name=kook resolved=kook:3fc8", out)
}
}
// TestAdminChannelOpenPlatformLevelIsDeterministic 断言多账号平台上的**平台级**
// 连接目标按配置顺序确定,而不是随注册表 map 顺序抖动 —— 否则面板无法如实标注
// 「这次会连哪一条」,主人也就永远不知道平台级按钮会打到谁。
func TestAdminChannelOpenPlatformLevelIsDeterministic(t *testing.T) {
t.Parallel()
caps := capreg.New()
first := &spyChannel{name: "qq-obv11:aaaa1111"}
second := &spyChannel{name: "qq-obv11:bbbb2222"}
for _, ch := range []*spyChannel{second, first} { // 注册顺序与配置顺序故意相反
if err := caps.RegisterChannel(ch); err != nil {
t.Fatalf("register: %v", err)
}
}
store := channelsStoreOf(t, "qq-obv11",
config.ChannelAccount{ID: "aaaa1111", Config: map[string]any{"mode": "client"}},
config.ChannelAccount{ID: "bbbb2222", Config: map[string]any{"mode": "client"}},
)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for i := 0; i < 12; i++ {
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "qq-obv11"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var out map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out["resolved"] != "qq-obv11:aaaa1111" {
t.Fatalf("round %d resolved = %v, want the first configured account (aaaa1111)", i, out["resolved"])
}
}
if first.connects.Load() != 12 || second.connects.Load() != 0 {
t.Fatalf("connects first=%d second=%d, want 12/0", first.connects.Load(), second.connects.Load())
}
}
// TestAdminChannelOpenDecodeFailureStill400 保证老客户的坏请求体照旧 400。
func TestAdminChannelOpenDecodeFailureStill400(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doRaw(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", "{", tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
}
// decodeAdminError 解出 {error:{code,message}} 信封。
func decodeAdminError(t *testing.T, body []byte) struct {
Code string `json:"code"`
Message string `json:"message"`
} {
t.Helper()
var out struct {
Code string `json:"code"`
Message string `json:"message"`
}
raw := decodeBody(t, body)["error"]
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode error envelope: %v (body=%s)", err, body)
}
return out
}
@@ -0,0 +1,53 @@
package gateway
// 管理渠道列表里的**每账号**连接状态(AdminChannelAccount.Connected)。
//
// 为什么要有它:平台级 `connected` 只反映该平台第一条已注册适配器(ChannelSummary
// 由聚合行上的那个适配器决定),多账号平台上它无法回答「我点连接的那条现在活着吗」。
// 面板因此只能盲目渲染每行的连接/断开按钮 —— 主人有两条 qq-obv11 时,「断开」到底
// 断的是哪一条、断完有没有生效,看板上都无从判断。这里按账号把它补上。
import (
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// liveAccountConnections 收集每个平台各账号的实时连接状态,键与 liveBotIdentity
// 相同(裸适配器名 ⇒ 空账号 id)。注册表里**没有**该账号的适配器时它直接缺席,
// 而不是报 false:缺席 = 状态未知(面板画「状态未知」),false = 确知未连接。
func (s *Server) liveAccountConnections() map[string]map[string]bool {
out := make(map[string]map[string]bool)
if s.caps == nil {
return out
}
for _, cap := range s.caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
platform, acct := splitAdapterName(ch.Name())
if out[platform] == nil {
out[platform] = make(map[string]bool)
}
out[platform][acct] = ch.IsConnected()
}
return out
}
// enrichAccountsWithConnection 把实时连接状态合并进平台中匹配的账号(按账号 id
// 配对,与适配器注册名一致)。没有实时适配器的账号保持 Connected == nil(未知),
// 因此面板能在「没连上」与「读不到」之间如实区分。入参切片不被修改。
func enrichAccountsWithConnection(accts []AdminChannelAccount, by map[string]bool) []AdminChannelAccount {
if len(by) == 0 {
return accts
}
out := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
if connected, ok := by[a.ID]; ok {
value := connected
a.Connected = &value
}
out = append(out, a)
}
return out
}
@@ -0,0 +1,206 @@
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, "、") + ")"
}
@@ -0,0 +1,200 @@
package gateway
import (
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定「空 id 条目删得掉」以及它**必须显式**:
//
// - `?id=` 仍要求非空(老契约);
// - `?empty=1` 才表示「删无 id 的历史条目」,且只删这些条目;
// - 绝不能把「id 缺失/为空」默认读成「删掉所有无 id 条目」——
// 那是一次没有任何字面证据的批量删除。
// TestAdminDeleteChannelAccountEmptyExplicit 走 `?empty=1` 删除无 id 的历史条目:
// 文件里只剩带 id 的账号,且该平台被热重载。
func TestAdminDeleteChannelAccountEmptyExplicit(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/channels.json"
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
"qq-obv11": {
{Config: map[string]any{"mode": "server", "listen_addr": ""}}, // 无 id 的历史条目
{ID: "9616b27a", Config: map[string]any{"mode": "client", "remote_url": "ws://x/y"}},
{ID: "conf1", Config: map[string]any{"mode": "client", "remote_url": "ws://x/z"}},
},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var res struct {
Persisted bool `json:"persisted"`
Platform string `json:"platform"`
Empty bool `json:"empty"`
Removed int `json:"removed"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode delete response: %v (body=%s)", err, body)
}
if !res.Persisted || res.Platform != "qq-obv11" || !res.Empty || res.Removed != 1 {
t.Fatalf("delete response = %+v, want persisted/qq-obv11/empty/removed=1", res)
}
if len(store.reloaded) != 1 || store.reloaded[0] != "qq-obv11" {
t.Fatalf("reloaded = %v, want [qq-obv11]", store.reloaded)
}
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load channels warn = %v", warn)
}
kept := accounts["qq-obv11"]
if len(kept) != 2 {
t.Fatalf("kept accounts = %+v, want the two id'd accounts only", kept)
}
for _, acct := range kept {
if strings.TrimSpace(acct.ID) == "" {
t.Fatalf("an id-less account survived the explicit empty delete: %+v", kept)
}
}
if strings.Contains(string(body), "ws://x/y") || strings.Contains(string(body), "ws://x/z") {
t.Fatalf("delete response leaked config values: %s", body)
}
}
// TestAdminDeleteChannelAccountEmptyRequiresExplicitFlag 是核心回归:没有任何
// 字面证据时,空 id 请求一律 400,且盘上的无 id 条目**原封不动**(一次都不许写)。
func TestAdminDeleteChannelAccountEmptyRequiresExplicitFlag(t *testing.T) {
t.Parallel()
for _, query := range []string{"", "?id=", "?id=%20", "?empty=", "?empty=0", "?empty=false"} {
query := query
t.Run("query="+query, func(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/channels.json"
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
"qq-obv11": {{Config: map[string]any{"mode": "server"}}},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodDelete,
ts.URL+"/api/v1/admin/channels/qq-obv11/config"+query, nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if len(store.reloaded) != 0 {
t.Fatalf("reloaded = %v, want none", store.reloaded)
}
if store.accounts["qq-obv11"] == nil || len(store.accounts["qq-obv11"]) != 1 {
t.Fatalf("accounts = %+v, want the id-less entry untouched", store.accounts)
}
})
}
}
// TestAdminDeleteChannelAccountRejectsBothParams 断言 id 与 empty=1 同时给出时
// 一律 400:一次请求只针对一种条目,不做「两个都删」这种猜测。
func TestAdminDeleteChannelAccountRejectsBothParams(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/channels.json"
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
"qq-obv11": {
{Config: map[string]any{"mode": "server"}},
{ID: "9616b27a", Config: map[string]any{"mode": "client"}},
},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodDelete,
ts.URL+"/api/v1/admin/channels/qq-obv11/config?id=9616b27a&empty=1", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if len(store.reloaded) != 0 || len(store.accounts["qq-obv11"]) != 2 {
t.Fatalf("nothing may be deleted: reloaded=%v accounts=%+v", store.reloaded, store.accounts)
}
}
// TestAdminDeleteChannelAccountByIdStillTargeted 老契约照旧:?id=<非空> 只删那一条,
// 无 id 的历史条目**不受影响**。
func TestAdminDeleteChannelAccountByIdStillTargeted(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/channels.json"
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
"qq-obv11": {
{Config: map[string]any{"mode": "server"}},
{ID: "9616b27a", Config: map[string]any{"mode": "client"}},
},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodDelete,
ts.URL+"/api/v1/admin/channels/qq-obv11/config?id=9616b27a", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
kept := store.accounts["qq-obv11"]
if len(kept) != 1 || strings.TrimSpace(kept[0].ID) != "" {
t.Fatalf("kept = %+v, want only the id-less historical entry", kept)
}
}
// TestAdminDeleteChannelAccountsWithoutCandidatesIs404 断言显式请求了删除、但平台
// 上没有无 id 条目时诚实回 404(不写盘、不重载、不假装删过)。
func TestAdminDeleteChannelAccountsWithoutCandidatesIs404(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/channels.json"
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
"qq-obv11": {{ID: "9616b27a", Config: map[string]any{"mode": "client"}}},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
if len(store.reloaded) != 0 {
t.Fatalf("reloaded = %v, want none", store.reloaded)
}
if len(store.accounts["qq-obv11"]) != 1 {
t.Fatalf("accounts = %+v, want untouched", store.accounts)
}
}
// TestAdminDeleteChannelAccountEmptyDropsPlatformKey 断言只有无 id 条目时,
// 删除后整个平台键消失(与按 id 删除的收尾口径一致)。
func TestAdminDeleteChannelAccountEmptyDropsPlatformKey(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/channels.json"
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
"qq-obv11": {{Config: map[string]any{"mode": "server"}}},
"telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "tk"}}},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if _, ok := store.accounts["qq-obv11"]; ok {
t.Fatalf("qq-obv11 key survived an empty delete: %+v", store.accounts)
}
if len(store.accounts["telegram"]) != 1 {
t.Fatalf("other platforms must be untouched: %+v", store.accounts)
}
if bytes := string(body); strings.Contains(bytes, "tk") {
t.Fatalf("delete response leaked a token: %s", bytes)
}
}
+2 -1
View File
@@ -62,6 +62,7 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
accountsByPlatform := s.maskedChannelAccounts()
botByPlatform := s.liveBotIdentity()
connectedByPlatform := s.liveAccountConnections()
out := make([]ChannelSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) {
@@ -97,7 +98,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
}
}
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[platform])
sum.Accounts = enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform])
}
out = append(out, sum)
}
@@ -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 保持缺位,这样用的是
@@ -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
+19 -4
View File
@@ -13,11 +13,22 @@ import (
// 谁/什么/何时/结果审计链;「主代理代审」这一面向用户的提示是
// 前端的职责,不是网关的。
func (s *Server) auditClaims(r *http.Request, what, result string) {
s.auditClaimsAccount(r, what, result, "")
}
// auditClaimsAccount 在既有审计口径(what/result 不变,便于按动作聚合)之上补一个
// 账号维度,使多账号平台上的「连/断/删的是哪一条」在审计里可追溯。accountID 为空
// (平台级动作)时不带该字段。附加字段只放账号 id —— 凭据永不进审计/日志。
func (s *Server) auditClaimsAccount(r *http.Request, what, result, accountID string) {
who := "anonymous"
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
who = claims.Sub + ":" + string(claims.Role)
}
s.audit(r.Context(), who, what, result)
extra := []any{}
if accountID != "" {
extra = append(extra, "accountId", accountID)
}
s.audit(r.Context(), who, what, result, extra...)
}
// AuditEvent 记录一次带显式调用方身份的外部(非 HTTP)审计事件,
@@ -34,13 +45,17 @@ func (s *Server) AuditEvent(who, what, result string) {
// 已配置的 AuditStore(未注入时回退到内存存储)。
// 持久化失败会记日志,但绝不阻塞管理写,因此
// 坏掉的审计存储无法拖垮审计链的首要职责。
func (s *Server) audit(ctx context.Context, who, what, result string) {
s.log.Info("admin audit",
// extra 是追加的结构化字段(键值交替,如 "accountId", "abc"),
// 只用于日志行;持久化记录的口径保持 who/what/result 不变。
func (s *Server) audit(ctx context.Context, who, what, result string, extra ...any) {
fields := []any{
"who", who,
"what", what,
"when", time.Now().UTC().Format(time.RFC3339),
"result", result,
)
}
fields = append(fields, extra...)
s.log.Info("admin audit", fields...)
if s.obs != nil && s.obs.audit != nil {
if err := s.obs.audit.Append(ctx, who, what, result, time.Now()); err != nil {
s.log.Error("admin audit persist", "err", err, "what", what)