From a057a0f61f7904f557d0d33211b2ecaba2f0f965 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 13:50:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=B5=81=E6=8E=A7=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=20=E2=80=94=E2=80=94=20=E9=99=8C=E7=94=9F=E4=BA=BA?= =?UTF-8?q?=E7=A7=81=E8=81=8A=E9=A2=9D=E5=BA=A6=E8=B5=B0=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20directMsgPerMin=EF=BC=88=E8=A3=81=E5=86=B3?= =?UTF-8?q?=2013=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN) - 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6 - 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要 - 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行 - 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原 - ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0 --- cmd/cyrene/channels.go | 11 +- cmd/cyrene/channels_test.go | 7 +- .../modules/channelConfigForm.render.test.ts | 11 ++ .../src/admin/modules/channelConfigForm.tsx | 20 ++- .../modules/channels/AdapterConfigSummary.tsx | 15 +- .../channels/adminVirtualScroll.test.ts | 16 ++ .../modules/channels/channelUtils.test.ts | 62 +++++--- .../admin/modules/channels/channelUtils.ts | 18 ++- frontend/web/src/lib/api/adminChannels.ts | 3 + frontend/web/src/lib/types/admin.ts | 4 + internal/channel/adapter/flowgate.go | 9 ++ .../channel/adapter/flowgate_direct_test.go | 14 ++ internal/config/channels_test.go | 14 +- internal/config/config.go | 6 + .../services/channelbridge/channelbridge.go | 5 + internal/services/channelbridge/flowgate.go | 43 +++++- .../services/channelbridge/flowgate_test.go | 140 ++++++++++++++++-- internal/services/channelbridge/light.go | 11 +- 18 files changed, 339 insertions(+), 70 deletions(-) diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index 282f12e..aeb68fe 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -176,16 +176,17 @@ type namedAdapter struct { denyUsers []string // 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号 // 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。 - groupMsgPerMin int - msgPerMin int - maxMessages int + groupMsgPerMin int + directMsgPerMin int + msgPerMin int + maxMessages int } func (n *namedAdapter) Name() string { return n.name } func (n *namedAdapter) Config() map[string]any { cfg := n.PlatformAdapter.Config() - out := make(map[string]any, len(cfg)+7) + out := make(map[string]any, len(cfg)+8) for k, v := range cfg { out[k] = v } @@ -194,6 +195,7 @@ func (n *namedAdapter) Config() map[string]any { out["allowUsers"] = n.allowUsers out["denyUsers"] = n.denyUsers out["groupMsgPerMin"] = n.groupMsgPerMin + out["directMsgPerMin"] = n.directMsgPerMin out["msgPerMin"] = n.msgPerMin out["maxMessages"] = n.maxMessages return out @@ -282,6 +284,7 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog. allowUsers: acct.AllowUsers, denyUsers: acct.DenyUsers, groupMsgPerMin: acct.GroupMsgPerMin, + directMsgPerMin: acct.DirectMsgPerMin, msgPerMin: acct.MsgPerMin, maxMessages: acct.MaxMessages, }, nil diff --git a/cmd/cyrene/channels_test.go b/cmd/cyrene/channels_test.go index d9b6601..24f65c5 100644 --- a/cmd/cyrene/channels_test.go +++ b/cmd/cyrene/channels_test.go @@ -118,7 +118,7 @@ func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool { // save → Reload path uses exactly this builder. func TestChannelFactoryBuildFromFile(t *testing.T) { path := filepath.Join(t.TempDir(), "channels.json") - if err := os.WriteFile(path, []byte(`{"kook":[{"id":"conf0","config":{"bot_token":"bt"},"denyGroups":["g-1"],"allowUsers":["u-1"],"groupMsgPerMin":3}]}`), 0o600); err != nil { + if err := os.WriteFile(path, []byte(`{"kook":[{"id":"conf0","config":{"bot_token":"bt"},"denyGroups":["g-1"],"allowUsers":["u-1"],"groupMsgPerMin":3,"directMsgPerMin":-1}]}`), 0o600); err != nil { t.Fatalf("write channels.json: %v", err) } f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()} @@ -146,6 +146,11 @@ func TestChannelFactoryBuildFromFile(t *testing.T) { if got, _ := cfg["groupMsgPerMin"].(int); got != 3 { t.Errorf("groupMsgPerMin = %v, want 3", cfg["groupMsgPerMin"]) } + // directMsgPerMin 同一条链:channels.json 的账号顶层字段 → namedAdapter.Config() → + // 桥的 gateOptions。断桥这一环就等于「面板/文件配了却没人读」(负值必须原样透出)。 + if got, _ := cfg["directMsgPerMin"].(int); got != -1 { + t.Errorf("directMsgPerMin = %v, want -1", cfg["directMsgPerMin"]) + } } } diff --git a/frontend/web/src/admin/modules/channelConfigForm.render.test.ts b/frontend/web/src/admin/modules/channelConfigForm.render.test.ts index d78965c..c23beac 100644 --- a/frontend/web/src/admin/modules/channelConfigForm.render.test.ts +++ b/frontend/web/src/admin/modules/channelConfigForm.render.test.ts @@ -193,4 +193,15 @@ const qqRecords = (config: Record, id = "conf0"): AdminChannelR ok(has(html, "留空 = 不校验来源"), "webhook secret 的说明写清空值的后果"); } +// ── ⑥ 陌生人私聊限流:字段确实在表单里(裁决 13「流控时间要可配置」) ────────────── +// ⚠️ 限流四个输入框在「业务策略」折叠区内(`polOpen` 默认收起),静态 markup 看不到 +// 输入框本身 —— 这里能实证的是该折叠区的标题已把私聊限流算进去(旧文案只写「群聊限流」, +// 主人读到的等于「这里没有私聊流控可配」)。字段的读数/写回链路由 +// channelUtils.test.ts(formLimits / limitRequestBody 的同一份实现)逐条覆盖。 +{ + const html = renderForm("kook", [{ name: "kook", connected: false, accounts: [{ id: "conf0", config: {} }] }]); + ok(has(html, "群聊与私聊限流"), "业务策略区标题把私聊限流算进去(不再只写「群聊限流」)"); + ok(!has(html, "directMsgPerMin(陌生人私聊每分钟上限)"), "折叠状态下输入框不渲染(与其它限流字段一致)"); +} + console.log(`channel config form render test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/channelConfigForm.tsx b/frontend/web/src/admin/modules/channelConfigForm.tsx index 9825a93..16c716a 100644 --- a/frontend/web/src/admin/modules/channelConfigForm.tsx +++ b/frontend/web/src/admin/modules/channelConfigForm.tsx @@ -38,6 +38,8 @@ export interface ChannelConfigInitial { groupMsgPerMin: string; msgPerMin: string; maxMessages: string; + /** 非主人(陌生人)私聊每分钟每发送者上限,`''` = 未设置(裁决 13)。 */ + directMsgPerMin: string; /** The account's bound role, `''` = 未绑定(跟随默认角色)(design/22 §4.4). */ personaId: string; } @@ -84,6 +86,7 @@ export function ChannelConfigForm({ const [groupMsgPerMin, setGroupMsgPerMin] = useState(initial.groupMsgPerMin); const [msgPerMin, setMsgPerMin] = useState(initial.msgPerMin); const [maxMessages, setMaxMessages] = useState(initial.maxMessages); + const [directMsgPerMin, setDirectMsgPerMin] = useState(initial.directMsgPerMin); const [extraConfig, setExtraConfig] = useState(""); const [advOpen, setAdvOpen] = useState(false); const [polOpen, setPolOpen] = useState(false); @@ -147,9 +150,9 @@ export function ChannelConfigForm({ } else if (Object.keys(specificMap).length) { configMap = specificMap; } - // 限流三键:留空的**不进请求体**(后端 int 契约下省略 = 0 = 未设置),非法输入整单 + // 限流四键:留空的**不进请求体**(后端 int 契约下省略 = 0 = 未设置),非法输入整单 // 拒绝 —— 已设限额只可能被「没回显出来」或「NaN → null」清掉,两条路都在这里封死。 - const limits = limitRequestBody({ groupMsgPerMin, msgPerMin, maxMessages }); + const limits = limitRequestBody({ groupMsgPerMin, msgPerMin, maxMessages, directMsgPerMin }); if (!limits.ok) { onError(limits.error); setBusy(null); @@ -191,6 +194,7 @@ export function ChannelConfigForm({ setGroupMsgPerMin(""); setMsgPerMin(""); setMaxMessages(""); + setDirectMsgPerMin(""); setExtraConfig(""); setPersonaId(""); setPersonaDirty(false); @@ -341,15 +345,18 @@ export function ChannelConfigForm({ - 业务策略(黑白名单 / 群聊限流——不参与连接配置) + 业务策略(黑白名单 / 群聊与私聊限流——不参与连接配置) {polOpen ? (
{/* 数值语义按 internal/services/channelbridge/flowgate.go 的 flowOrDefault: - 0 = 保护性默认(群 6 条/分、待处理上限 50),负值 = 关闭限制;msgPerMin 只认正值。 */} + 0 = 保护性默认(群 6 条/分、陌生人私聊 6 条/分、待处理上限 50),负值 = 关闭限制; + msgPerMin 只认正值。 */}

- 留空 = 未设置(不动盘上的值);groupMsgPerMin/maxMessages 填 - 0 = 该渠道的保护性默认(群 6 条/分、待处理 50 条), + 留空 = 未设置(不动盘上的值);groupMsgPerMin/directMsgPerMin/ + maxMessages 填 + 0 = 该渠道的保护性默认 + (群 6 条/分、陌生人私聊 6 条/分、待处理 50 条), 负值 = 关闭该限制;msgPerMin 只认正值(留空/0 = 不限)。

@@ -358,6 +365,7 @@ export function ChannelConfigForm({ +
diff --git a/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx b/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx index 7536864..62dfc9e 100644 --- a/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx +++ b/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx @@ -17,13 +17,21 @@ function commonConnectionKeys(platform: string): string[] { } /** 限流 / 会话额度**不在 config 里**:后端把它们作为账号顶层字段下发 - * (`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/MaxMessages`,见 + * (`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/MaxMessages/DirectMsgPerMin`,见 * `internal/services/gateway/admin_write_channel_config.go`),且 `omitempty` * ⇒ 缺省 / 0 就是「不限」。读错来源会让面板把已设的限额显示成未设置。 */ function fmtLimit(value?: number): string { return value === undefined || value === null || value === 0 ? "未设置(不限)" : String(value); } +/** `directMsgPerMin` 的展示:它的 0 **不是**「不限」而是「保护性默认 6 条/分」 + * (缺省绝不等于关掉陌生人私聊流控),负值才是主人显式关闭 —— 与其它三项的文案不同, + * 所以单独一个格式化器,免得把「未设置」误读成「敞口」。 */ +function fmtDirectLimit(value?: number): string { + if (value === undefined || value === null || value === 0) return "未设置(保护性默认 6 条/分)"; + return value < 0 ? `${value}(已关闭流控)` : String(value); +} + /** 只读展示用的值格式化:空值明确写成「未设置」,对象/数组走 JSON 串,其余原样。 * 绝不在这里造值——后端给什么显示什么(后端已对 token 类键做掩码)。 */ function fmtValue(value: unknown): string { @@ -68,8 +76,11 @@ function connectionFields(platform: string, account: AdminChannelAccount): Field function businessFields(account: AdminChannelAccount): Field[] { return [ { label: "personaId(绑定角色)", value: account.personaId?.trim() ? account.personaId : "跟随默认角色" }, - { label: "msgPerMin(私聊每分钟上限)", value: fmtLimit(account.msgPerMin) }, + // msgPerMin 是**账号级非主人消息总量上限**,不是私聊上限 —— 旧文案写成「私聊每分钟 + // 上限」会让主人以为它管的是陌生人私聊(真正管私聊的是下面那行 directMsgPerMin)。 + { label: "msgPerMin(非主人消息每分钟总上限)", value: fmtLimit(account.msgPerMin) }, { label: "groupMsgPerMin(群每分钟上限)", value: fmtLimit(account.groupMsgPerMin) }, + { label: "directMsgPerMin(陌生人对私聊每分钟上限)", value: fmtDirectLimit(account.directMsgPerMin) }, { label: "maxMessages(会话消息窗口)", value: fmtLimit(account.maxMessages) }, { label: "allowGroups(群白名单)", value: fmtList(account.allowGroups) }, { label: "denyGroups(群黑名单)", value: fmtList(account.denyGroups) }, diff --git a/frontend/web/src/admin/modules/channels/adminVirtualScroll.test.ts b/frontend/web/src/admin/modules/channels/adminVirtualScroll.test.ts index 2d8dd2a..14b015f 100644 --- a/frontend/web/src/admin/modules/channels/adminVirtualScroll.test.ts +++ b/frontend/web/src/admin/modules/channels/adminVirtualScroll.test.ts @@ -195,6 +195,22 @@ const card = renderToStaticMarkup( ); ok(card.includes(">30"), "msgPerMin is read from the account top-level field"); ok(card.includes(">5"), "groupMsgPerMin is read from the account top-level field"); +// 私聊限流两行(裁决 13):`msgPerMin` 是**账号级非主人消息总量上限**,旧文案把它写成 +// 「私聊每分钟上限」是误导;真正管陌生人私聊的是 `directMsgPerMin`,而它的缺省不是 +// 「不限」而是保护性默认 6 条/分 —— 展示层若说成「不限」,主人会以为陌生人敞口。 +ok(!card.includes("msgPerMin(私聊每分钟上限)"), "msgPerMin 不再被说成「私聊每分钟上限」"); +ok(card.includes("msgPerMin(非主人消息每分钟总上限)"), "msgPerMin 说清是账号级总量上限"); +ok(card.includes("directMsgPerMin(陌生人对私聊每分钟上限)"), "并列补上 directMsgPerMin 行"); +ok(card.includes("未设置(保护性默认 6 条/分)"), "directMsgPerMin 的缺省说成保护性默认,不是「不限」"); +const directOff = renderToStaticMarkup( + createElement(AdapterConfigSummary, { + platform: "kook", + account: { ...account, directMsgPerMin: -1 }, + unknown: false, + onEdit: () => {}, + }), +); +ok(directOff.includes("-1(已关闭流控)"), "负值(主人显式关闭)显示成「已关闭流控」"); ok(card.includes("未设置(不限)"), "an absent limit is shown as 未设置, not invented"); ok(card.includes("****1234"), "the masked token the backend sent is shown verbatim"); ok(card.includes("receive_mode"), "every other config key is still listed"); diff --git a/frontend/web/src/admin/modules/channels/channelUtils.test.ts b/frontend/web/src/admin/modules/channels/channelUtils.test.ts index 3f90ea3..a13bd4a 100644 --- a/frontend/web/src/admin/modules/channels/channelUtils.test.ts +++ b/frontend/web/src/admin/modules/channels/channelUtils.test.ts @@ -1,8 +1,8 @@ -// 渠道配置表单的限流读数 / 写回逻辑测试(design/12 §6 策略三/四;裁决 16.4.1 无关,纯数据安全)。 +// 渠道配置表单的限流读数 / 写回逻辑测试(design/12 §6 策略三/四 + 裁决 13 流控可配;裁决 16.4.1 无关,纯数据安全)。 // // 锁两件事: -// ① **读数来源** = 账号**顶层**字段 `groupMsgPerMin` / `msgPerMin` / `maxMessages` -// (`AdminChannelAccount`,见 `internal/services/gateway/admin_write_channel_config.go` +// ① **读数来源** = 账号**顶层**字段 `groupMsgPerMin` / `msgPerMin` / `maxMessages` / +// `directMsgPerMin`(`AdminChannelAccount`,见 `internal/services/gateway/admin_write_channel_config.go` // 的 `adminChannelAccountOf`)——**不是** `config`。读错来源 ⇒ 表单永远回显空; // ② 由 ① 造成的连带数据丢失:回显空 ⇒ 保存时按「未设置」写 0(`omitempty`) // ⇒ **静默清掉主人已设的限流**。所以「没动过的字段」必须原样回传。 @@ -47,44 +47,46 @@ function records(account: AdminChannelAccount): AdminChannelRecord[] { return [{ name: "kook:conf0", connected: true, accounts: [account] }]; } -/** 表单被打开时的三个限流输入值(= save 时交给 limitRequestBody 的那三个字段)。 */ +/** 表单被打开时的四个限流输入值(= save 时交给 limitRequestBody 的那四个字段)。 */ function formLimits(record: AdminChannelRecord[]): Record { const { initial } = buildCfgTarget("kook:conf0", record); return { groupMsgPerMin: initial.groupMsgPerMin, msgPerMin: initial.msgPerMin, maxMessages: initial.maxMessages, + directMsgPerMin: initial.directMsgPerMin, }; } // ── ① 读数来源:账号顶层字段回显 ───────────────────────────────────────────── -const stored = records(acct({ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40 })); +const stored = records(acct({ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40, directMsgPerMin: 8 })); const target = buildCfgTarget("kook:conf0", stored); same(target.initial.groupMsgPerMin, "20", "groupMsgPerMin(顶层)回显到表单"); same(target.initial.msgPerMin, "30", "msgPerMin(顶层)回显到表单"); same(target.initial.maxMessages, "40", "maxMessages(顶层)回显到表单"); +same(target.initial.directMsgPerMin, "8", "directMsgPerMin(顶层)回显到表单"); same(target.platform, "kook", "平台名仍按 adapter 名解析"); // `config` 里的同名键**不是**来源(后端从不把它们放进 config;读它等于永远读空)。 // 这条断言就是这次回归的锁:把读数改回 `acct.config.*` 立刻变红。 const nestedOnly = records( - acct({ config: { groupMsgPerMin: 99, msgPerMin: 98, maxMessages: 97 } }) + acct({ config: { groupMsgPerMin: 99, msgPerMin: 98, maxMessages: 97, directMsgPerMin: 96 } }) ); same( formLimits(nestedOnly), - { groupMsgPerMin: "", msgPerMin: "", maxMessages: "" }, + { groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "" }, "限流不从 config 读(后端把它们放在账号顶层)" ); // 缺省与 0 都渲染成空(后端 `omitempty` ⇒ 两者在盘上等价;0 的真实语义是「用保护性默认」)。 same( - formLimits(records(acct({ groupMsgPerMin: 0, msgPerMin: 0, maxMessages: 0 }))), - { groupMsgPerMin: "", msgPerMin: "", maxMessages: "" }, + formLimits(records(acct({ groupMsgPerMin: 0, msgPerMin: 0, maxMessages: 0, directMsgPerMin: 0 }))), + { groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "" }, "0 与未设置都渲染成空" ); same( formLimits(records(acct({}))), - { groupMsgPerMin: "", msgPerMin: "", maxMessages: "" }, + { groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "" }, "未设置渲染成空" ); // 负值(= 关闭限制)必须原样回显,否则一次保存就把「关闭」写回默认。 @@ -93,6 +95,13 @@ same( "-1", "负值(关闭限制)原样回显" ); +// directMsgPerMin 的负值同理:它被吞成 0 就等于把主人的「已关闭」改写回「保护性默认 6」 +// (反向静默变更),因此这一条与请求体断言(②)一起把负值两端都钉住。 +same( + formLimits(records(acct({ directMsgPerMin: -1 }))).directMsgPerMin, + "-1", + "directMsgPerMin 的负值(关闭限制)原样回显,不被吞" +); // ── ② 不改动就保存:已设限额原样回传,绝不被清空 ────────────────────────────── const untouched = limitRequestBody(formLimits(stored)); @@ -100,12 +109,12 @@ ok(untouched.ok, "没动过的表单必须能生成请求体"); if (untouched.ok) { same( untouched.body, - { groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40 }, + { groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40, directMsgPerMin: 8 }, "没动过的字段原样回传(不清空已设限额)" ); } -// 只改 token / 白名单(表单挂载后**没碰**限流三个框)→ 限流三键仍在请求体里。 +// 只改 token / 白名单(表单挂载后**没碰**限流四个框)→ 限流四键仍在请求体里。 { const body: Record = { id: "conf0", token: "bt", allowUsers: ["u-1"] }; const limits = limitRequestBody(formLimits(stored)); @@ -113,24 +122,36 @@ if (untouched.ok) { if (limits.ok) Object.assign(body, limits.body); same( JSON.stringify(body), - '{"id":"conf0","token":"bt","allowUsers":["u-1"],"groupMsgPerMin":20,"msgPerMin":30,"maxMessages":40}', + '{"id":"conf0","token":"bt","allowUsers":["u-1"],"groupMsgPerMin":20,"msgPerMin":30,"maxMessages":40,"directMsgPerMin":8}', "改 token/白名单的一单保存不会把已设限额清空" ); } // ── ③ 未设置:该键**不进请求体**(不用 0 代替未设置) ──────────────────────── -const blank = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" }); -ok(blank.ok, "留空的三个字段是合法输入(= 未设置)"); +const blank = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "" }); +ok(blank.ok, "留空的四个字段是合法输入(= 未设置)"); if (blank.ok) { same(JSON.stringify(blank.body), "{}", "未设置的字段不出现在请求体里"); } -// 只填其中一个:另外两个不出现(显式清空与未设置都不写键 —— 后端 int 契约下 +// 只填其中一个:其余不出现(显式清空与未设置都不写键 —— 后端 int 契约下 // 「键缺席」就是 0,与留空同义;要区分「保留」与「清空」,靠的是没动过的字段原样回传)。 -const partial = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "12", maxMessages: "" }); +const partial = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "12", maxMessages: "", directMsgPerMin: "" }); ok(partial.ok, "部分填写是合法输入"); if (partial.ok) { same(JSON.stringify(partial.body), '{"msgPerMin":12}', "只发用户真的填了的那个键"); } +// 只填 directMsgPerMin:请求体只带这一个键(它就是表单新加的那个字段的写回路径)。 +const directOnly = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "3" }); +ok(directOnly.ok, "只填 directMsgPerMin 是合法输入"); +if (directOnly.ok) { + same(JSON.stringify(directOnly.body), '{"directMsgPerMin":3}', "只填 directMsgPerMin 时请求体只带该键"); +} +// 负值直达请求体(不被吞成 0;0 的语义是「保护性默认 6」,与「关闭」是两件事)。 +const directOff = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "-1" }); +ok(directOff.ok, "directMsgPerMin = -1 是合法输入(关闭流控)"); +if (directOff.ok) { + same(JSON.stringify(directOff.body), '{"directMsgPerMin":-1}', "负值原样进请求体,不被当成 0"); +} // ── ④ 非法输入:报错中止,绝不发 NaN(NaN → JSON null → 后端 int 静默落 0 清空限额) ── same(limitFieldValue("12"), 12, "整数原样解析"); @@ -143,11 +164,16 @@ same(limitFieldValue("6/min"), null, "带单位 = 非法"); same(limitFieldValue("6.5"), null, "小数 = 非法(后端字段是 int)"); same(limitFieldValue("1e3"), null, "科学计数法 = 非法(不给后端猜)"); -const bad = limitRequestBody({ groupMsgPerMin: "abc", msgPerMin: "", maxMessages: "" }); +const bad = limitRequestBody({ groupMsgPerMin: "abc", msgPerMin: "", maxMessages: "", directMsgPerMin: "" }); ok(!bad.ok, "非法值必须让整单失败,而不是发出去清掉已设限额"); if (!bad.ok) { ok(bad.error.includes("groupMsgPerMin"), `错误文案点名出错的字段 — got ${bad.error}`); } +const badDirect = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "abc" }); +ok(!badDirect.ok, "directMsgPerMin 非法值同样让整单失败(不发 NaN → null 清掉已设限额)"); +if (!badDirect.ok) { + ok(badDirect.error.includes("directMsgPerMin"), `错误文案点名出错的字段 — got ${badDirect.error}`); +} // ── ⑤ 平台字段预填:掩码秘密绝不回填(回填 = 一次「打开就保存」把 ****abcd 写成真 token) ── // 后端 `runtimeconfig.Masked` 把 token/secret/key 类键脱敏成「****」+ 末 4 位后下发, diff --git a/frontend/web/src/admin/modules/channels/channelUtils.ts b/frontend/web/src/admin/modules/channels/channelUtils.ts index 8f2ffd3..3fb48a4 100644 --- a/frontend/web/src/admin/modules/channels/channelUtils.ts +++ b/frontend/web/src/admin/modules/channels/channelUtils.ts @@ -86,23 +86,24 @@ export function buildCfgTarget(name: string, records: AdminChannelRecord[], acco denyGroups: (acct?.denyGroups ?? []).join(","), allowUsers: (acct?.allowUsers ?? []).join(","), denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "", - // 限流三个字段读**账号顶层**,不是 `config`(见 LIMIT_KEYS 的说明)。 + // 限流四个字段读**账号顶层**,不是 `config`(见 LIMIT_KEYS 的说明)。 groupMsgPerMin: limitInitialOf(acct?.groupMsgPerMin), msgPerMin: limitInitialOf(acct?.msgPerMin), maxMessages: limitInitialOf(acct?.maxMessages), + directMsgPerMin: limitInitialOf(acct?.directMsgPerMin), personaId: channelPersonaOf(acct), }, }; } -/** 限流 / 会话额度的三个字段名(design/12 §6 策略三/四)。回显、写回与表单共用这一份, - * 避免两处清单漂移。 +/** 限流 / 会话额度的四个字段名(design/12 §6 策略三/四 + 裁决 13)。回显、写回与表单共用 + * 这一份,避免两处清单漂移。 * * ⚠️ 线上它们是**账号顶层**字段(`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/ - * MaxMessages`,见 `internal/services/gateway/admin_write_channel_config.go` 的 - * `adminChannelAccountOf`),**不在 `config` 里** —— 从 `config` 读永远读不到:回显空 + * MaxMessages/DirectMsgPerMin`,见 `internal/services/gateway/admin_write_channel_config.go` + * 的 `adminChannelAccountOf`),**不在 `config` 里** —— 从 `config` 读永远读不到:回显空 * ⇒ 保存时按「未设置」写回 0 ⇒ **静默清掉主人已设的限流**。 */ -export const LIMIT_KEYS = ["groupMsgPerMin", "msgPerMin", "maxMessages"] as const; +export const LIMIT_KEYS = ["groupMsgPerMin", "msgPerMin", "maxMessages", "directMsgPerMin"] as const; /** 限流字段名。 */ export type LimitKey = (typeof LIMIT_KEYS)[number]; @@ -120,14 +121,15 @@ function limitInitialOf(v: number | undefined): string { * 序列化成 `null`,后端 int 收到 `null` 静默落 0,同样会清空已设限额。 * * 数值语义以 `internal/services/channelbridge/flowgate.go` 的 `flowOrDefault` 为准: - * **0 = 保护性默认**(群 6 条/分、待处理上限 50)、**负值 = 关闭限制**;`msgPerMin` 只认正值。 */ + * **0 = 保护性默认**(群 6 条/分、非主人私聊 6 条/分、待处理上限 50)、**负值 = 关闭限制**; + * `msgPerMin` 只认正值。 */ export function limitFieldValue(raw: string): number | undefined | null { const t = raw.trim(); if (!t) return undefined; return /^-?\d+$/.test(t) ? Number(t) : null; } -/** 表单三个限流字段 → PUT 请求体片段。任一字段非法即整单拒绝(绝不发半份请求体, +/** 表单四个限流字段 → PUT 请求体片段。任一字段非法即整单拒绝(绝不发半份请求体, * 也绝不用 0 代替「未设置」)。 */ export function limitRequestBody( fields: Record diff --git a/frontend/web/src/lib/api/adminChannels.ts b/frontend/web/src/lib/api/adminChannels.ts index 80666bb..e7ab5a1 100644 --- a/frontend/web/src/lib/api/adminChannels.ts +++ b/frontend/web/src/lib/api/adminChannels.ts @@ -94,6 +94,9 @@ export function adminSaveChannelConfig( groupMsgPerMin?: number; msgPerMin?: number; maxMessages?: number; + /** 非主人(陌生人)私聊每分钟每发送者上限(裁决 13)。缺省 / 0 = 保护性默认 + * 6 条/分、负值 = 关闭该限制、正值 = 该值(口径同 groupMsgPerMin)。 */ + directMsgPerMin?: number; /** Role bound to this channel account (design/22 §4.4). Omit to leave the * stored binding untouched; `''` clears it (「跟随默认角色」). It only affects * the account's non-isolated sessions — 群聊/陌生人始终用默认角色. */ diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts index 5f57d3c..0945846 100644 --- a/frontend/web/src/lib/types/admin.ts +++ b/frontend/web/src/lib/types/admin.ts @@ -55,6 +55,10 @@ export interface AdminChannelAccount { groupMsgPerMin?: number; msgPerMin?: number; maxMessages?: number; + /** 非主人(陌生人)**私聊**每分钟每发送者上限(裁决 13「流控时间要可配置」)。 + * 与 `groupMsgPerMin` 同口径:缺省 / 0 = 保护性默认 6 条/分,负值 = 关闭该限制, + * 正值 = 该值。它是**账号顶层**字段,不在 `config` 里。 */ + directMsgPerMin?: number; /** The role this channel account talks as (design/22 §4.4, 批次 2). Absent/`''` * means "跟随默认角色" — it only ever applies to the account's non-isolated * sessions (群聊/陌生人强制默认角色). Written through the config PUT. */ diff --git a/internal/channel/adapter/flowgate.go b/internal/channel/adapter/flowgate.go index d1e8abd..7ba0914 100644 --- a/internal/channel/adapter/flowgate.go +++ b/internal/channel/adapter/flowgate.go @@ -306,6 +306,15 @@ func (g *FlowGate) AllowDirect(msg UnifiedMessage) bool { return true } +// DirectPerMin 返回当前生效的私聊(非主人)每分钟每发送者上限,0 = 该限制已关闭。 +// 调用方据此**如实报出实际额度**(例如 channelbridge 被限流时的告警日志),而不是 +// 写死保护性默认 6 —— 主人 2026-10-05 裁决 13 把该额度变成按账号可配 +// (`directMsgPerMin`),硬编码会让日志报出一个与真实生效值不符的数字。 +// 该值在构造后不再变化,因此这里直接读,无需加锁。 +func (g *FlowGate) DirectPerMin() int { + return g.directPerMin +} + // AcquireTurn reserves the single running-turn slot of a conversation, returning // false when a turn is already in flight for it. Callers must pair every true // result with ReleaseTurn. It is the second half of the mandatory stranger flow diff --git a/internal/channel/adapter/flowgate_direct_test.go b/internal/channel/adapter/flowgate_direct_test.go index a1deb39..a1d40c1 100644 --- a/internal/channel/adapter/flowgate_direct_test.go +++ b/internal/channel/adapter/flowgate_direct_test.go @@ -102,3 +102,17 @@ func TestFlowGate_AcquireTurnSinglePerConversation(t *testing.T) { g.ReleaseTurn(a) g.ReleaseTurn(b) } + +// TestFlowGate_DirectPerMinMirrorsOption 钉住访问器报出的是**实际生效**的额度 +// (0 = 已关闭):调用方(channelbridge 被限流时的告警日志)据此如实报数,而不是 +// 写死保护性默认 6 —— 裁决 13 之后这个额度是按账号可配的。 +func TestFlowGate_DirectPerMinMirrorsOption(t *testing.T) { + t.Parallel() + + for _, want := range []int{0, 1, FlowDirectDefault, 20} { + g := NewFlowGate(FlowGateOptions{DirectMsgPerMin: want}) + if got := g.DirectPerMin(); got != want { + t.Fatalf("DirectPerMin() = %d, want %d", got, want) + } + } +} diff --git a/internal/config/channels_test.go b/internal/config/channels_test.go index 142cba4..8f3d44e 100644 --- a/internal/config/channels_test.go +++ b/internal/config/channels_test.go @@ -57,7 +57,7 @@ func TestParseChannelAccounts_FlowLimitFields(t *testing.T) { t.Parallel() raw := `{ "telegram": [ - {"id":"conf0","config":{"bot_token":"t0"},"groupMsgPerMin":10,"maxMessages":80,"msgPerMin":30} + {"id":"conf0","config":{"bot_token":"t0"},"groupMsgPerMin":10,"directMsgPerMin":-1,"maxMessages":80,"msgPerMin":30} ] }` got, err := ParseChannelAccounts(raw) @@ -65,9 +65,11 @@ func TestParseChannelAccounts_FlowLimitFields(t *testing.T) { t.Fatalf("ParseChannelAccounts: %v", err) } a := got["telegram"][0] - if a.GroupMsgPerMin != 10 || a.MsgPerMin != 30 || a.MaxMessages != 80 { - t.Fatalf("flow fields = group:%d msg:%d max:%d, want 10/30/80", - a.GroupMsgPerMin, a.MsgPerMin, a.MaxMessages) + // directMsgPerMin 是账号顶层字段(与 groupMsgPerMin 同一口径):负值 = 关闭该限制, + // 必须是 -1,绝不能被解析层吞成 0(0 的语义是「用保护性默认 6」)。 + if a.GroupMsgPerMin != 10 || a.MsgPerMin != 30 || a.MaxMessages != 80 || a.DirectMsgPerMin != -1 { + t.Fatalf("flow fields = group:%d direct:%d msg:%d max:%d, want 10/-1/30/80", + a.GroupMsgPerMin, a.DirectMsgPerMin, a.MsgPerMin, a.MaxMessages) } } @@ -80,8 +82,8 @@ func TestParseChannelAccounts_NoFlowFieldsDefaultsToZero(t *testing.T) { } a := got["telegram"][0] // Absent fields stay zero; the bridge maps zero to the protective defaults - // (6 group/min, 50 max), so old accounts are backward compatible. - if a.GroupMsgPerMin != 0 || a.MsgPerMin != 0 || a.MaxMessages != 0 { + // (6 group/min, 6 direct/min, 50 max), so old accounts are backward compatible. + if a.GroupMsgPerMin != 0 || a.DirectMsgPerMin != 0 || a.MsgPerMin != 0 || a.MaxMessages != 0 { t.Fatalf("absent flow fields should be zero, got %+v", a) } } diff --git a/internal/config/config.go b/internal/config/config.go index bb700de..66ea0a9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -392,6 +392,12 @@ type ChannelAccount struct { // (design/12 §6 策略三). Zero follows the protective default (6); a negative // value disables the group rate limit; a positive value pins it. GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"` + // DirectMsgPerMin 是**非主人私聊(陌生人)每分钟每发送者上限**(主人 2026-10-05 + // 裁决③与裁决 13「流控时间要可配置」;design/12 §6 策略三)。零值或未配置一律走 + // 保护性默认 6 条/分(陌生人必须有流控,绝不允许因漏配静默敞口);负值关闭该限制 + // (主人的显式选择,桥会留一条带 source 的日志);正值固定为该值。它随 channels.json + // 的**账号顶层**字段读写,与 GroupMsgPerMin 同一口径。 + DirectMsgPerMin int `json:"directMsgPerMin,omitempty"` // MsgPerMin caps total inbound non-owner messages per minute for this account. // Zero or negative disables it. MsgPerMin int `json:"msgPerMin,omitempty"` diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index be85841..5fb3539 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -183,6 +183,10 @@ type Bridge struct { // noticeLog 是系统通知传入前审查(design/12 §6 策略一补充)的子功能日志器, // 与 voiceLog 同款:`source=services/channelbridge/notice` 便于跨模块定位。 noticeLog *slog.Logger + // flowLog 是流控解析(design/12 §6 策略三,含账号级 directMsgPerMin)的子功能 + // 日志器:`source=services/channelbridge/flowgate`,使「主人显式关掉某项流控」 + // 在日志里可追溯(见 flowgate.go 的 directLimit)。 + flowLog *slog.Logger // 参与度引擎状态(design/18):按会话的内存状态机, // 加上约束它的角色提醒词 / 参与度参数,以及概率触发 @@ -281,6 +285,7 @@ func New(opts Options) (*Bridge, error) { quietEndHour: qEnd, now: now, noticeLog: logger.WithSource(log, "services/channelbridge/notice"), + flowLog: logger.WithSource(log, flowgateLogSource), triggerWords: opts.TriggerWords, personaPrompt: opts.PersonaPrompt, engage: opts.Engage, diff --git a/internal/services/channelbridge/flowgate.go b/internal/services/channelbridge/flowgate.go index c9fa285..bb6135b 100644 --- a/internal/services/channelbridge/flowgate.go +++ b/internal/services/channelbridge/flowgate.go @@ -41,18 +41,19 @@ func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate { // (每群每分钟 6 条、上限 50),负值则关闭限制—— // 这是对 CHANNELS_JSON 账号的向后兼容约定(design/12 §6 实现要点)。 // -// **私聊(陌生人)限流例外:它不依赖任何配置键**(主人 2026-10-05 裁决③), -// 恒定取保护性默认 FlowDirectDefault —— 原本没有任何配置的账号(如 qq-obv11) -// 对陌生人私聊是完全敞口,而裁决要求「陌生人必须有流控」,所以这里不能 -// 因为「没配」而静默关掉。主人私聊在 onMessage 就被绕过本闸门,不受影响。 +// **私聊(陌生人)限流走 directMsgPerMin**(主人 2026-10-05 裁决③与裁决 13 +// 「流控时间要可配置」):它与群聊同一条读法,但缺省/0 一律取保护性默认 +// FlowDirectDefault —— 原本没有任何配置的账号(如 qq-obv11)对陌生人私聊是完全 +// 敞口,而裁决要求「陌生人必须有流控」,所以这里绝不能因为「没配」而静默关掉。 +// 主人私聊在 onMessage 就被绕过本闸门,不受影响。 func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions { + cfg := a.Config() opts := adapter.FlowGateOptions{ Review: b.review, OnPruned: b.onRejected, Now: b.now, - DirectMsgPerMin: adapter.FlowDirectDefault, + DirectMsgPerMin: directLimit(cfg, b.flowLog), } - cfg := a.Config() if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok { opts.GroupMsgPerMin = flowOrDefault(grp, adapter.FlowLimitDefault) } @@ -65,6 +66,29 @@ func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions return opts } +// directLimit 解析账号级 directMsgPerMin(非主人私聊 = 陌生人的每分钟每发送者上限), +// 语义与 flowOrDefault 逐字同口径:**键缺失、非整数或 0 ⇒ 保护性默认 +// adapter.FlowDirectDefault;正值 ⇒ 该值;负值 ⇒ 0(关闭该限制)**。 +// +// 与群聊的唯一区别是缺省侧:缺省绝不解释成「关闭」,否则一个漏配的键就会 +// 把陌生人私聊重新变成全敞口(主人 2026-10-05 裁决③的既定裁决)。负值关闭 +// 只有手写负数才能到达,属主人的**显式**选择,因此这里留一条带 source 的 WARN +// 日志 —— 流控被关必须在日志里可追溯,不许静默发生。 +func directLimit(cfg map[string]any, log *slog.Logger) int { + v, ok := cfgInt(cfg, directMsgPerMinKey) + if !ok { + return adapter.FlowDirectDefault + } + if v < 0 { + if log != nil { + log.Warn("non-owner private rate limit turned off by account config", + "key", directMsgPerMinKey, "value", v) + } + return 0 + } + return flowOrDefault(v, adapter.FlowDirectDefault) +} + // flowOrDefault 把配置的限制值映射为实际生效的限制:负值 // 关闭限制(0),零采用保护性默认值,正值则 // 固定为该值。这就是「没有该字段的旧账号保持原有行为或 @@ -99,6 +123,13 @@ func cfgInt(m map[string]any, key string) (int, bool) { } const ( + // directMsgPerMinKey 是账号级「非主人私聊(陌生人)每分钟每发送者上限」的配置键 + // (design/12 §6 策略三;主人 2026-10-05 裁决 13)。它与 groupMsgPerMin 同一条读法: + // 由 cmd/cyrene 的 namedAdapter 从账号字段透出到 Config(),桥在这里解析。 + directMsgPerMinKey = "directMsgPerMin" + // flowgateLogSource 是本文件日志的来源标记,使「流控解析结果」跨模块可定位 + // (项目规范 §五:禁止无来源裸日志)。 + flowgateLogSource = "services/channelbridge/flowgate" // reviewMaxTokens 限定审查模型的输出,使冗长的队列清单绝不会 // 产出超长补全(design/12 §6 策略四 成本护栏)。 reviewMaxTokens = 256 diff --git a/internal/services/channelbridge/flowgate_test.go b/internal/services/channelbridge/flowgate_test.go index 93145d0..bc5f94d 100644 --- a/internal/services/channelbridge/flowgate_test.go +++ b/internal/services/channelbridge/flowgate_test.go @@ -1,8 +1,12 @@ package channelbridge import ( + "bytes" "context" + "log/slog" + "strings" "testing" + "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" @@ -104,8 +108,9 @@ func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) { // TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则, // 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性 -// 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流是例外**: -// 它不读配置键,恒取保护性默认(裁决③:陌生人必须有流控)。 +// 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流同一口径, +// 但缺省侧相反**:键缺失/非整数/0 一律取保护性默认 6(裁决③「陌生人必须有流控」 +// +裁决 13「流控时间要可配置」)——绝不因为「没配」而静默关掉。 func TestFlowGate_GateOptionsDefaults(t *testing.T) { t.Parallel() @@ -114,18 +119,25 @@ func TestFlowGate_GateOptionsDefaults(t *testing.T) { t.Fatalf("New: %v", err) } + // direct 列用**字面量 6**(该常量由 adapter/flowgate_direct_test.go 的 + // TestFlowDirectDefaultIsPinned 钉住):缺键时若解析成 0(= 关掉流控)本用例必须红。 cases := []struct { name string cfg map[string]any group, max int + direct int }{ - {"absent keys off", map[string]any{"bot_token": "x"}, 0, 0}, - {"group 0 defaults 6", map[string]any{"bot_token": "x", "groupMsgPerMin": 0}, 6, 0}, - {"group -1 off", map[string]any{"bot_token": "x", "groupMsgPerMin": -1}, 0, 0}, - {"group 10 pinned", map[string]any{"bot_token": "x", "groupMsgPerMin": 10}, 10, 0}, - {"max 0 defaults 50", map[string]any{"bot_token": "x", "maxMessages": 0}, 0, 50}, - {"max -1 off", map[string]any{"bot_token": "x", "maxMessages": -1}, 0, 0}, - {"max 80 pinned", map[string]any{"bot_token": "x", "maxMessages": 80}, 0, 80}, + {"absent keys off", map[string]any{"bot_token": "x"}, 0, 0, 6}, + {"group 0 defaults 6", map[string]any{"bot_token": "x", "groupMsgPerMin": 0}, 6, 0, 6}, + {"group -1 off", map[string]any{"bot_token": "x", "groupMsgPerMin": -1}, 0, 0, 6}, + {"group 10 pinned", map[string]any{"bot_token": "x", "groupMsgPerMin": 10}, 10, 0, 6}, + {"max 0 defaults 50", map[string]any{"bot_token": "x", "maxMessages": 0}, 0, 50, 6}, + {"max -1 off", map[string]any{"bot_token": "x", "maxMessages": -1}, 0, 0, 6}, + {"max 80 pinned", map[string]any{"bot_token": "x", "maxMessages": 80}, 0, 80, 6}, + {"direct 0 defaults 6", map[string]any{"bot_token": "x", "directMsgPerMin": 0}, 0, 0, 6}, + {"direct -1 off", map[string]any{"bot_token": "x", "directMsgPerMin": -1}, 0, 0, 0}, + {"direct 12 pinned", map[string]any{"bot_token": "x", "directMsgPerMin": 12}, 0, 0, 12}, + {"direct non-numeric defaults 6", map[string]any{"bot_token": "x", "directMsgPerMin": "abc"}, 0, 0, 6}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -136,16 +148,116 @@ func TestFlowGate_GateOptionsDefaults(t *testing.T) { if opts.MaxMessages != c.max { t.Fatalf("MaxMessages = %d, want %d", opts.MaxMessages, c.max) } - // 陌生人私聊的强制流控不随账号配置开关:无论群聊/上限怎么配, - // 私聊限流恒为保护性默认(主人 2026-10-05 裁决③)。 - if opts.DirectMsgPerMin != adapter.FlowDirectDefault { - t.Fatalf("DirectMsgPerMin = %d, want the protective default %d", - opts.DirectMsgPerMin, adapter.FlowDirectDefault) + if opts.DirectMsgPerMin != c.direct { + t.Fatalf("DirectMsgPerMin = %d, want %d(缺失/0 走保护性默认 6,负值=关闭,正值=该值)", + opts.DirectMsgPerMin, c.direct) } }) } } +// TestFlowGate_DirectLimitFromAccountConfig 验证 directMsgPerMin 从账号配置到闸门的 +// **整条链**:键缺失 ⇒ 保护性默认 6 条/分真的拦下第 7 条;正值 ⇒ 该值;负值 ⇒ 关闭 +// (全部放行)。计数按发送者独立(AllowDirect 的会话键含 sender)。 +func TestFlowGate_DirectLimitFromAccountConfig(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + cfg map[string]any + // allowed 是限制内会被放行的条数;off = 限制被显式关闭(第 allowed+1 条不会被拒)。 + allowed int + off bool + }{ + {"absent key keeps the protective default 6", map[string]any{"bot_token": "x"}, 6, false}, + {"pinned 2", map[string]any{"bot_token": "x", "directMsgPerMin": 2}, 2, false}, + {"negative disables the limit", map[string]any{"bot_token": "x", "directMsgPerMin": -1}, 12, true}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + b, ad := newGateBridge(t, c.cfg) + gate := b.gateFor(ad) + if gate == nil { + t.Fatal("gateFor = nil, want a gate for the configured adapter") + } + for i := 0; i < c.allowed; i++ { + if !gate.AllowDirect(strangerDirectMsg("777", "第 "+itoa(i)+" 句")) { + t.Fatalf("额度内第 %d 条被拒,want allowed", i+1) + } + } + // 限制生效时第 allowed+1 条必须被拒(off=false);关闭时(off=true)必须放行。 + if over := gate.AllowDirect(strangerDirectMsg("777", "第 N+1 句")); over != c.off { + t.Fatalf("第 %d 条的放行结果 = %v(off=%v),want %v", + c.allowed+1, over, c.off, c.off) + } + // 另一个发送者有独立额度:不被前一个发送者挤占。 + if !gate.AllowDirect(strangerDirectMsg("888", "我是另一个人")) { + t.Fatal("另一个发送者的额度被挤占") + } + }) + } +} + +// newGateBridge 构建一条只用于流控解析的桥接器(telegram 适配器 + 给定账号配置), +// 使 directMsgPerMin 走与生产完全相同的「Config() → gateOptions」链。 +func newGateBridge(t *testing.T, cfg map[string]any) (*Bridge, *fakeAdapter) { + t.Helper() + b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}}) + if err != nil { + t.Fatalf("New: %v", err) + } + ad := &fakeAdapter{name: "telegram", cfg: cfg} + b.Add(ad) + return b, ad +} + +// TestFlowGate_DirectLimitOffIsLogged 钉住「负值关闭必须留痕」:主人显式关掉熟人/ +// 陌生人私聊流控时,日志里必须出现一条带 source 的 WARN(不许静默关闭);而正值或 +// 缺省(保护性默认)绝不产生这条告警(否则主人会以为流控被关了)。 +func TestFlowGate_DirectLimitOffIsLogged(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + b, _, ad := newStrangerBridge(t, &stubChatter{}, &captureHistory{}, at, log) + + ad.cfg = map[string]any{"bot_token": "x", "directMsgPerMin": -1} + if opts := b.gateOptions(ad); opts.DirectMsgPerMin != 0 { + t.Fatalf("DirectMsgPerMin = %d, want 0(负值 = 关闭)", opts.DirectMsgPerMin) + } + logged := buf.String() + if !strings.Contains(logged, "non-owner private rate limit turned off") || + !strings.Contains(logged, "source=services/channelbridge/flowgate") || + !strings.Contains(logged, "directMsgPerMin") { + t.Fatalf("负值关闭流控没有留下带 source 的可见告警:%q", logged) + } + + // 正值:不告警,且额度就是该值。 + buf.Reset() + ad.cfg = map[string]any{"bot_token": "x", "directMsgPerMin": 12} + if opts := b.gateOptions(ad); opts.DirectMsgPerMin != 12 { + t.Fatalf("DirectMsgPerMin = %d, want 12", opts.DirectMsgPerMin) + } + if got := buf.String(); got != "" { + t.Fatalf("正值不该产生「流控被关闭」告警:%q", got) + } + + // 缺省(保护性默认):同样不告警 —— 默认不是「主人关掉了它」。 + buf.Reset() + ad.cfg = map[string]any{"bot_token": "x"} + if opts := b.gateOptions(ad); opts.DirectMsgPerMin != adapter.FlowDirectDefault { + t.Fatalf("DirectMsgPerMin = %d, want the protective default %d", + opts.DirectMsgPerMin, adapter.FlowDirectDefault) + } + if got := buf.String(); got != "" { + t.Fatalf("缺省不该产生「流控被关闭」告警:%q", got) + } +} + // mockLLM 是返回固定补全的 model.Adapter 桩。 type mockLLM struct { content string diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index 3b329fc..575da37 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -177,16 +177,17 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, } // 裁决①(主人 2026-10-05):**陌生人私聊 = 正常聊天** —— 不设安静期、 - // 不走「值不值得回」的概率门,直接进入完整回合。唯一的闸门是强制 - // 流控:同一发送者每分钟 ≤ FlowDirectDefault 条(AllowDirect 记账) - // 与同时最多 1 个在跑的回合(processFrom 用 AcquireTurn 施加)。 + // 不走「值不值得回」的概率门,直接进入完整回合。唯一的闸门是流控: + // 同一发送者每分钟 ≤ 该账号的 directMsgPerMin(未配 = 保护性默认 6,裁决 13 + // 让它可配;AllowDirect 记账)与同时最多 1 个在跑的回合 + // (processFrom 用 AcquireTurn 施加)。 // 被限流时**不回**:调用方会把它登记进隔离会话,这里留一条带 - // source 的可见告警(绝不静默)。 + // source 的可见告警(绝不静默),并报出**实际生效**的额度而不是硬编码默认值。 if isStrangerPrivate(sess) { if g := b.gateFor(a); g != nil && !g.AllowDirect(msg) { b.log.Warn("stranger private chat rate limited, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID, - "limit_per_min", adapter.FlowDirectDefault) + "limit_per_min", g.DirectPerMin()) return false, "stranger private: over the per-sender flow limit" } return true, "stranger private chat: normal reply"