fix(gateway): directMsgPerMin 的写入路径 5 处透传(关掉「面板保存把 -1 静默重置为 0」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 缺口:后端解析与面板都已就绪,但 gateway 没让该字段通过 ⇒ 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」重置为 0=保护性默认 6 条/分
- 5 处:AdminChannelAccount DTO / channelConfigRequest / config.ChannelAccount 拷贝 / adminChannelAccountOf 映射 / 微信扫码重建账号
- 语义零改动(只透传);变异实测:删拷贝 ⇒ channels.json 变 0(want -1)、删微信重建拷贝 ⇒ 同红、删 GET 映射 ⇒ 回显缺失
- 顺带登记一条负载诱发 flake(capability/plugin 的 TestManagerInstall_ConnectFailureDoesNotPersist,靠「端口被拒」断言,满载时端口被抢)⇒ 非本切片引入,已进 00-索引 §四
This commit is contained in:
AskaEth committed 2026-10-05 14:01:52 +08:00
1 parent 321cc3b763
commit 7aaf7731dc
3 files changed
+179 -1

No files matched your search

@@ -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"])
}
}
@@ -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 无需手工编辑就认出它是主人。
@@ -180,6 +181,7 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID
GroupMsgPerMin: base.GroupMsgPerMin,
MsgPerMin: base.MsgPerMin,
MaxMessages: base.MaxMessages,
DirectMsgPerMin: base.DirectMsgPerMin,
}
if acct.ID == "" {
acct.ID = runtimeconfig.NewID()
@@ -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,6 +173,8 @@ 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,
@@ -170,6 +185,7 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
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 {
@@ -422,6 +438,7 @@ func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
GroupMsgPerMin: acct.GroupMsgPerMin,
MsgPerMin: acct.MsgPerMin,
MaxMessages: acct.MaxMessages,
DirectMsgPerMin: acct.DirectMsgPerMin,
PersonaID: acct.PersonaID,
}
}