- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
119 lines
4.3 KiB
Go
119 lines
4.3 KiB
Go
package adapter
|
||
|
||
// 本文件覆盖主人 2026-10-05 裁决③的**陌生人私聊流控**两件套:
|
||
// - AllowDirect:私聊按发送者(会话键 platform+channel+sender)的每分钟滑窗;
|
||
// - AcquireTurn/ReleaseTurn:同一发送者同时最多 1 个在跑的回合。
|
||
// 两者都复用同一张 flowSession 表与同一套 ConversationKey 口径,不另造第二套限流。
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestFlowGate_AllowDirectIsPerSender 钉住私聊滑窗按发送者独立计数:
|
||
// A 用尽额度不挤占 B;窗口滚过后 A 恢复;群聊消息不经这条通道。
|
||
func TestFlowGate_AllowDirectIsPerSender(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
now, clock := fixedClock(time.Unix(2000, 0))
|
||
var audited []string
|
||
g := NewFlowGate(FlowGateOptions{
|
||
DirectMsgPerMin: 2,
|
||
Window: time.Minute,
|
||
Now: now,
|
||
OnPruned: func(_ UnifiedMessage, reason string) { audited = append(audited, reason) },
|
||
})
|
||
|
||
for i := 0; i < 2; i++ {
|
||
if !g.AllowDirect(directMsgTo("peer-1", i)) {
|
||
t.Fatalf("peer-1 第 %d 条被拒,want allowed", i+1)
|
||
}
|
||
}
|
||
if g.AllowDirect(directMsgTo("peer-1", 2)) {
|
||
t.Fatal("peer-1 超出自身额度仍被放行")
|
||
}
|
||
if len(audited) != 1 || audited[0] != "rate_limited" {
|
||
t.Fatalf("拒绝未入审计:%v", audited)
|
||
}
|
||
if !g.AllowDirect(directMsgTo("peer-2", 0)) {
|
||
t.Fatal("peer-2 被 peer-1 的额度挤占(私聊限流未按发送者独立)")
|
||
}
|
||
// 群聊走 Admit 的群聊滑窗,AllowDirect 对它恒放行。
|
||
if !g.AllowDirect(groupMsgIn("-100", 0)) {
|
||
t.Fatal("群聊消息不该被私聊限流拦下")
|
||
}
|
||
|
||
*clock = clock.Add(61 * time.Second)
|
||
if !g.AllowDirect(directMsgTo("peer-1", 3)) {
|
||
t.Fatal("窗口滚过后 peer-1 应恢复放行")
|
||
}
|
||
}
|
||
|
||
// TestFlowGate_AllowDirectDisabledByZero 钉住零值 = 关闭:没有配置私聊限流时
|
||
// (DirectMsgPerMin 为 0)AllowDirect 恒放行,既有调用行为不变。
|
||
func TestFlowGate_AllowDirectDisabledByZero(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
g := NewFlowGate(FlowGateOptions{})
|
||
for i := 0; i < 50; i++ {
|
||
if !g.AllowDirect(directMsgTo("peer-1", i)) {
|
||
t.Fatalf("零值额度下第 %d 条被拒,want allowed", i+1)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestFlowDirectDefaultIsPinned 用**字面量 6** 钉住保护性默认值(与 FlowLimitDefault
|
||
// 的既有钉桩同口径)。不这么钉的后果:`stranger_test.go` 的额度循环以该常量为上界,
|
||
// 一旦有人把它改成近似无限,测试不会断言失败,而是**跑一百万轮把整包测试拖到超时**
|
||
// ——看起来像「偶发卡死」,实际是流控被静默关掉。主人 2026-10-05 裁决③要求陌生人
|
||
// 私聊**必须有**流控,因此这条默认值属于契约,不允许随手放大。
|
||
func TestFlowDirectDefaultIsPinned(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
if FlowDirectDefault != 6 {
|
||
t.Fatalf("FlowDirectDefault = %d, want 6(每发送者每分钟 6 条的保护性默认;"+
|
||
"放大它等于静默关闭陌生人私聊流控)", FlowDirectDefault)
|
||
}
|
||
}
|
||
|
||
// TestFlowGate_AcquireTurnSinglePerConversation 钉住「同一会话同时最多 1 个在跑的回合」:
|
||
// 第一个占位成功、第二个被拒;换一个发送者互不影响;释放后又能占。
|
||
func TestFlowGate_AcquireTurnSinglePerConversation(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
g := NewFlowGate(FlowGateOptions{})
|
||
a := directMsgTo("peer-1", 0)
|
||
b := directMsgTo("peer-2", 0)
|
||
|
||
if !g.AcquireTurn(a) {
|
||
t.Fatal("首个回合应能占到槽位")
|
||
}
|
||
if g.AcquireTurn(a) {
|
||
t.Fatal("同一发送者的第二个回合不该同时占到槽位")
|
||
}
|
||
if !g.AcquireTurn(b) {
|
||
t.Fatal("另一个发送者的槽位被挤占")
|
||
}
|
||
|
||
g.ReleaseTurn(a)
|
||
if !g.AcquireTurn(a) {
|
||
t.Fatal("释放后应能重新占到槽位")
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|