diff --git a/internal/services/gateway/admin_channel_direct_limit_test.go b/internal/services/gateway/admin_channel_direct_limit_test.go new file mode 100644 index 0000000..cfd84d4 --- /dev/null +++ b/internal/services/gateway/admin_channel_direct_limit_test.go @@ -0,0 +1,159 @@ +package gateway + +// 本文件覆盖渠道账号**顶层限流字段 `directMsgPerMin`** 的写/读往返 —— +// 「已关闭的流控被静默重置」回归(写入见 admin_write_channel_config.go 的 +// DTO / 请求体 / config.ChannelAccount 拷贝 / adminChannelAccountOf, +// 以及 admin_channels_weixin.go 的扫码重建账号)。 +// +// 语义(与 internal/services/channelbridge/flowgate.go 的 directLimit 逐字同口径): +// +// 键缺失 / 0 ⇒ 保护性默认 6 条/分(陌生人必须有流控,缺省绝不解释成「关闭」) +// 正值 ⇒ 该值 +// 负值 ⇒ 关闭该限制(主人的显式选择,桥会留一条带 source 的 WARN) +// +// 它在线上是**账号顶层**字段(不在 config 里),面板回显后把该值原样写回, +// 因此两端任一处漏掉它,「已关闭」都会被静默变回「保护性默认 6 条/分」—— +// 面板保存一次账号卡 = 主人明确关掉的限制被系统悄悄打开。 + +import ( + "context" + "io" + "log/slog" + "net/http" + "path/filepath" + "strconv" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// TestAdminSaveChannelConfigRoundTripsDirectMsgPerMin 是主回归:PUT 里给出的 +// directMsgPerMin 必须**逐字**落到 channels.json(负值尤其不许被吞成 0), +// 且 GET /admin/channels 把它如实回显给面板 —— 面板正是靠这次回显才知道 +// 该字段「已关闭」,进而在下一次保存时把 -1 原样送回。 +func TestAdminSaveChannelConfigRoundTripsDirectMsgPerMin(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + baseline int + body string + want int + }{ + { + name: "已关闭(-1)再保存一次仍是 -1", + baseline: -1, + body: `{"id":"conf0","token":"bt","directMsgPerMin":-1}`, + want: -1, + }, + { + name: "原本是正值 5,改成 -1 关掉", + baseline: 5, + body: `{"id":"conf0","token":"bt","directMsgPerMin":-1}`, + want: -1, + }, + { + name: "原本已关闭,改成正值 4 重新打开", + baseline: -1, + body: `{"id":"conf0","token":"bt","directMsgPerMin":4}`, + want: 4, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "kook": {{ID: "conf0", Config: map[string]any{"bot_token": "bt"}, DirectMsgPerMin: tc.baseline}}, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + if resp, body := putChannelConfigRaw(t, ts, "kook", tc.body, tok); resp.StatusCode != http.StatusCreated { + t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body) + } + + // 盘上:channels.json 的账号顶层字段,就是请求体给的值。 + acct, ok := storedChannelAccountOrNil(t, path, "kook", "conf0") + if !ok { + t.Fatal("kook/conf0 vanished from channels.json") + } + if acct.DirectMsgPerMin != tc.want { + t.Fatalf("channels.json directMsgPerMin = %d, want %d —— 被吞成 0 就等于把关掉的限制"+ + "静默重置回保护性默认 6 条/分", acct.DirectMsgPerMin, tc.want) + } + // 内存里的账号(热重载后适配器读的就是它)。 + for _, live := range store.accounts["kook"] { + if live.ID == "conf0" && live.DirectMsgPerMin != tc.want { + t.Fatalf("in-memory directMsgPerMin = %d, want %d", live.DirectMsgPerMin, tc.want) + } + } + // GET 如实回显(否则面板把它显示成「未设置」,下一次保存就把它抹成 0)。 + _, listBody := adminChannelsList(t, ts, tok) + if !strings.Contains(string(listBody), `"directMsgPerMin":`+strconv.Itoa(tc.want)) { + t.Fatalf("GET /admin/channels did not echo directMsgPerMin=%d: %s", tc.want, listBody) + } + }) + } +} + +// TestAdminChannelsListOmitsUnsetDirectMsgPerMin 钉住 omitempty 约定:账号未设 +// 该字段时键**缺席**(0 与「未设置」在线上同形是既有约定,不改)。对照组断言 +// 该账号元素确实出现在响应里,使「不含该键」不是一句空话。 +func TestAdminChannelsListOmitsUnsetDirectMsgPerMin(t *testing.T) { + t.Parallel() + store := &stubChannelConfigStore{ + path: filepath.Join(t.TempDir(), "channels.json"), + accounts: map[string][]config.ChannelAccount{ + "kook": {{ID: "conf0", Config: map[string]any{"bot_token": "bt"}, GroupMsgPerMin: 5}}, + }, + } + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + items, listBody := adminChannelsList(t, ts, tok) + if len(items) != 1 || len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "conf0" { + t.Fatalf("items = %+v, want a single kook/conf0 account", items) + } + if !strings.Contains(string(listBody), `"groupMsgPerMin":5`) { + t.Fatalf("control field missing, the absence assertion would be vacuous: %s", listBody) + } + if strings.Contains(string(listBody), "directMsgPerMin") { + t.Fatalf("未设置时该键必须缺席(omitempty 约定;0 与未设置线上同形): %s", listBody) + } +} + +// TestPersistWeixinConfirmedKeepsDirectMsgPerMin 覆盖第二条重建路径:微信扫码 +// 确认后 handler 会把账号整体重建为 config.ChannelAccount 再 UpsertChannel, +// 因此它必须把账号顶层的 directMsgPerMin(含 -1 = 关闭)一并拷过去 —— +// 否则一次扫码保存会把主人关掉的陌生人私聊限制重新打开。 +func TestPersistWeixinConfirmedKeepsDirectMsgPerMin(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + chs := &fakeWeixinChannels{ + path: path, + data: map[string][]config.ChannelAccount{ + "weixin-ilink": {{ + ID: "conf0", + Config: map[string]any{"base_url": "https://base"}, + DirectMsgPerMin: -1, + }}, + }, + } + s := &Server{channels: chs, log: slog.New(slog.NewTextHandler(io.Discard, nil))} + + if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-x", "bot-x", "o-1"); err != nil { + t.Fatalf("persistWeixinConfirmed: %v", err) + } + + acct, ok := storedChannelAccountOrNil(t, path, "weixin-ilink", "conf0") + if !ok { + t.Fatal("weixin-ilink/conf0 vanished from channels.json after the scan save") + } + if acct.DirectMsgPerMin != -1 { + t.Fatalf("扫码重建账号把 directMsgPerMin 写成 %d, want -1(关掉的限制不许被重置)", acct.DirectMsgPerMin) + } + if acct.Config["bot_token"] != "tok-x" { + t.Fatalf("config[bot_token] = %#v, want the scanned token persisted", acct.Config["bot_token"]) + } +} diff --git a/internal/services/gateway/admin_channels_weixin.go b/internal/services/gateway/admin_channels_weixin.go index bccdda4..a6339b0 100644 --- a/internal/services/gateway/admin_channels_weixin.go +++ b/internal/services/gateway/admin_channels_weixin.go @@ -121,7 +121,8 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) // persistWeixinConfirmed 把登录获得的凭据写入该账号的 // channels.json 配置(合并到用户精心维护的 base_url/cdn 字段之上, -// 并保留其 allow/deny/limit 列表),再热重载该平台, +// 并保留其 allow/deny/limit 列表 —— 含账号顶层的 directMsgPerMin, +// 主人配的「关闭流控(-1)」因而不会被一次扫码保存重置),再热重载该平台, // 使 bridge 连接到这个刚配置好的适配器。ownerID 非空时它还会 // 把扫码者绑定的 ilink_user_id 登记进主人白名单 // (owner.yaml),使 bridge 无需手工编辑就认出它是主人。 @@ -171,15 +172,16 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID } } acct := config.ChannelAccount{ - ID: base.ID, - Config: cfg, - AllowGroups: base.AllowGroups, - DenyGroups: base.DenyGroups, - AllowUsers: allowUsers, - DenyUsers: base.DenyUsers, - GroupMsgPerMin: base.GroupMsgPerMin, - MsgPerMin: base.MsgPerMin, - MaxMessages: base.MaxMessages, + ID: base.ID, + Config: cfg, + AllowGroups: base.AllowGroups, + DenyGroups: base.DenyGroups, + AllowUsers: allowUsers, + DenyUsers: base.DenyUsers, + GroupMsgPerMin: base.GroupMsgPerMin, + MsgPerMin: base.MsgPerMin, + MaxMessages: base.MaxMessages, + DirectMsgPerMin: base.DirectMsgPerMin, } if acct.ID == "" { acct.ID = runtimeconfig.NewID() diff --git a/internal/services/gateway/admin_write_channel_config.go b/internal/services/gateway/admin_write_channel_config.go index 84df6f6..029359a 100644 --- a/internal/services/gateway/admin_write_channel_config.go +++ b/internal/services/gateway/admin_write_channel_config.go @@ -41,6 +41,13 @@ type AdminChannelAccount struct { GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"` MsgPerMin int `json:"msgPerMin,omitempty"` MaxMessages int `json:"maxMessages,omitempty"` + // DirectMsgPerMin 是**非主人私聊(陌生人)每分钟每发送者上限** + // (design/12 §6 策略三;主人 2026-10-05 裁决③与裁决 13)。它是账号 + // **顶层**字段(与 GroupMsgPerMin 同一条写路径,**不在 config 里**), + // 语义与 `channelbridge.directLimit` 逐字同口径:0 或未设置为保护性 + // 默认 6 条/分、正值固定为该值、**负值 = 关闭该限制**。面板必须能把它 + // 原样回显,否则「已关闭」会被静默显示成「未设置」。 + DirectMsgPerMin int `json:"directMsgPerMin,omitempty"` // PersonaID 是该账号应答所用的角色(design/22 §2 方案 b),账号 // 未绑定任何角色时省略。它是人格 id,不是秘密。 PersonaID string `json:"personaId,omitempty"` @@ -101,6 +108,12 @@ type channelConfigRequest struct { GroupMsgPerMin int `json:"groupMsgPerMin"` MsgPerMin int `json:"msgPerMin"` MaxMessages int `json:"maxMessages"` + // DirectMsgPerMin 是账号顶层的陌生人私聊每分钟每发送者上限,与 + // GroupMsgPerMin 同一口径的**值语义**(对非 config 字段是整值替换): + // 缺失或 0 → 保护性默认 6 条/分,正值 → 该值,**负值 = 关闭该限制**。 + // 它必须原样落到 config.ChannelAccount,绝不能被吞成 0 —— 否则主人显式 + // 关掉的限制会被一次面板保存静默重置回默认 6(见 adminSaveChannelConfig)。 + DirectMsgPerMin int `json:"directMsgPerMin"` // PersonaID 是该账号应答所用的角色(design/22 §2 方案 b)。它是 // 指针,因为线上契约是三态的,而普通 string 无法 // 区分键缺失与显式 "":缺失保留已存绑定, @@ -160,17 +173,20 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) accounts := s.channels.Load() // patch 语义:未在 cfg 里出现的 config 键保留盘上已存值;cfg 里显式为 // null/"" 的键被清除。非 config 字段(黑白名单 / 限流 / personaId)仍是整值替换。 + // DirectMsgPerMin 必须**逐字**落到账号上:它就是面板「关闭流控(-1)」的 + // 保存路径,吞成 0 等于把主人显式关掉的限制静默重置回保护性默认 6 条/分。 updated := runtimeconfig.PatchChannel(accounts, platform, config.ChannelAccount{ - ID: strings.TrimSpace(req.ID), - Config: cfg, - AllowGroups: req.AllowGroups, - DenyGroups: req.DenyGroups, - AllowUsers: req.AllowUsers, - DenyUsers: req.DenyUsers, - GroupMsgPerMin: req.GroupMsgPerMin, - MsgPerMin: req.MsgPerMin, - MaxMessages: req.MaxMessages, - PersonaID: channelPersonaBinding(req.PersonaID, existingChannelAccount(accounts[platform], req.ID)), + ID: strings.TrimSpace(req.ID), + Config: cfg, + AllowGroups: req.AllowGroups, + DenyGroups: req.DenyGroups, + AllowUsers: req.AllowUsers, + DenyUsers: req.DenyUsers, + GroupMsgPerMin: req.GroupMsgPerMin, + MsgPerMin: req.MsgPerMin, + MaxMessages: req.MaxMessages, + DirectMsgPerMin: req.DirectMsgPerMin, + PersonaID: channelPersonaBinding(req.PersonaID, existingChannelAccount(accounts[platform], req.ID)), }) if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil { s.auditClaims(r, "channel.save", "error") @@ -413,16 +429,17 @@ func savedAccountID(list []config.ChannelAccount, supplied string) string { // adminChannelAccountOf 把一个(已脱敏的)账号映射为其管理侧线上结构。 func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount { return AdminChannelAccount{ - ID: acct.ID, - Config: acct.Config, - AllowGroups: acct.AllowGroups, - DenyGroups: acct.DenyGroups, - AllowUsers: acct.AllowUsers, - DenyUsers: acct.DenyUsers, - GroupMsgPerMin: acct.GroupMsgPerMin, - MsgPerMin: acct.MsgPerMin, - MaxMessages: acct.MaxMessages, - PersonaID: acct.PersonaID, + ID: acct.ID, + Config: acct.Config, + AllowGroups: acct.AllowGroups, + DenyGroups: acct.DenyGroups, + AllowUsers: acct.AllowUsers, + DenyUsers: acct.DenyUsers, + GroupMsgPerMin: acct.GroupMsgPerMin, + MsgPerMin: acct.MsgPerMin, + MaxMessages: acct.MaxMessages, + DirectMsgPerMin: acct.DirectMsgPerMin, + PersonaID: acct.PersonaID, } }