feat(channels): 流控可配 —— 陌生人私聊额度走账号配置 directMsgPerMin(裁决 13)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
This commit is contained in:
AskaEth committed 2026-10-05 13:50:29 +08:00
1 parent ba17820040
commit a057a0f61f
18 files changed
+339 -70

No files matched your search

+7 -4
View File
@@ -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
+6 -1
View File
@@ -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"])
}
}
}
@@ -193,4 +193,15 @@ const qqRecords = (config: Record<string, unknown>, 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`);
@@ -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({
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${polOpen ? "rotate-90" : ""}`}>
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
</span>
业务策略(黑白名单 / 群聊限流——不参与连接配置)
业务策略(黑白名单 / 群聊与私聊限流——不参与连接配置)
</button>
{polOpen ? (
<div className="space-y-2 px-3 pb-3 pt-1">
{/* 数值语义按 internal/services/channelbridge/flowgate.go 的 flowOrDefault:
0 = 保护性默认(群 6 条/分、待处理上限 50),负值 = 关闭限制;msgPerMin 只认正值。 */}
0 = 保护性默认(群 6 条/分、陌生人私聊 6 条/分、待处理上限 50),负值 = 关闭限制;
msgPerMin 只认正值。 */}
<p className="text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
留空 = 未设置(不动盘上的值);<code>groupMsgPerMin</code>/<code>maxMessages</code> 填
<strong className="font-medium"> 0 = 该渠道的保护性默认</strong>(群 6 条/分、待处理 50 条),
留空 = 未设置(不动盘上的值);<code>groupMsgPerMin</code>/<code>directMsgPerMin</code>/
<code>maxMessages</code> 填
<strong className="font-medium"> 0 = 该渠道的保护性默认</strong>
(群 6 条/分、陌生人私聊 6 条/分、待处理 50 条),
<strong className="font-medium">负值 = 关闭该限制</strong>;<code>msgPerMin</code> 只认正值(留空/0 = 不限)。
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
@@ -358,6 +365,7 @@ export function ChannelConfigForm({
<Field label="allowUsers(白名单)" value={allowUsers} onChange={setAllowUsers} />
<Field label="denyUsers(黑名单)" value={denyUsers} onChange={setDenyUsers} />
<Field label="groupMsgPerMin(每群每分钟上限)" value={groupMsgPerMin} onChange={setGroupMsgPerMin} inputMode="numeric" />
<Field label="directMsgPerMin(陌生人私聊每分钟上限)" value={directMsgPerMin} onChange={setDirectMsgPerMin} inputMode="numeric" />
<Field label="msgPerMin(每分钟总上限)" value={msgPerMin} onChange={setMsgPerMin} inputMode="numeric" />
<Field label="maxMessages(待处理消息上限)" value={maxMessages} onChange={setMaxMessages} inputMode="numeric" />
</div>
@@ -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) },
@@ -195,6 +195,22 @@ const card = renderToStaticMarkup(
);
ok(card.includes(">30</dd>"), "msgPerMin is read from the account top-level field");
ok(card.includes(">5</dd>"), "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");
@@ -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<string, string> {
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<string, unknown> = { 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 位后下发,
@@ -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<LimitKey, string>
@@ -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 — 群聊/陌生人始终用默认角色. */
+4
View File
@@ -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. */
+9
View File
@@ -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
@@ -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)
}
}
}
+8 -6
View File
@@ -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)
}
}
+6
View File
@@ -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"`
@@ -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,
+37 -6
View File
@@ -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
+126 -14
View File
@@ -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
+6 -5
View File
@@ -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"