Files
Cyrene_v2/internal/services/channelbridge/flowgate_test.go
T
AskaEth a057a0f61f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 流控可配 —— 陌生人私聊额度走账号配置 directMsgPerMin(裁决 13)
- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
2026-10-05 13:50:29 +08:00

332 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func groupSender(i int) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "kook",
ChannelID: "-100",
ChannelType: adapter.ChannelGroup,
GroupID: "-100",
SenderID: "u" + itoa(i),
Content: "spam " + itoa(i),
ContentType: adapter.ContentText,
}
}
func groupOwnerMsg(i int) adapter.UnifiedMessage {
m := groupSender(i)
m.Sender = adapter.Actor{Role: adapter.RoleOwner}
return m
}
// TestFlowGate_RateLimitDropsNonOwnerAndAudits 验证闸门已接入
// 入口(onMessage),且被限流的非主人消息会被丢弃并经
// OnRejected 审计钩子暴露原因。丢弃路径是同步的
// (不会派生工作 goroutine),因此断言是确定性的。
func TestFlowGate_RateLimitDropsNonOwnerAndAudits(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "hi"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
var rejected []string
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "msgPerMin": 1}}
b.Add(ad)
// 第一条消息被放行(异步工作项),只对第二条入审计,
// 它被每分钟总量上限拒绝。
b.onMessage(context.Background(), groupSender(0), ad)
b.onMessage(context.Background(), groupSender(1), ad)
if len(rejected) != 1 || rejected[0] != "rate_limited" {
t.Fatalf("rejected = %v, want [rate_limited]", rejected)
}
}
// TestFlowGate_OwnerPrivateBypassesGate 验证主人消息完全绕过流控
// 闸门:即使限流很激进,主人消息也绝不会以
// rate_limited / review_pruned / capped 被丢弃。
func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "hi"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
var rejected []string
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: testOwnerProfile("kook", "u0", "u1", "u2"),
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "groupMsgPerMin": 1, "maxMessages": 1}}
b.Add(ad)
// 三条主人消息:若闸门生效,第 2、3 条会被限流
// / 触及上限并到达 OnRejected。主人绕过则审计为空。
for i := 0; i < 3; i++ {
b.onMessage(context.Background(), groupOwnerMsg(i), ad)
}
if len(rejected) != 0 {
t.Fatalf("owner messages must bypass the gate, got rejected %v", rejected)
}
}
// TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则,
// 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性
// 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流同一口径,
// 但缺省侧相反**:键缺失/非整数/0 一律取保护性默认 6(裁决③「陌生人必须有流控」
// +裁决 13「流控时间要可配置」)——绝不因为「没配」而静默关掉。
func TestFlowGate_GateOptionsDefaults(t *testing.T) {
t.Parallel()
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
if err != nil {
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, 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) {
opts := b.gateOptions(&fakeAdapter{name: "kook", cfg: c.cfg})
if opts.GroupMsgPerMin != c.group {
t.Fatalf("GroupMsgPerMin = %d, want %d", opts.GroupMsgPerMin, c.group)
}
if opts.MaxMessages != c.max {
t.Fatalf("MaxMessages = %d, want %d", opts.MaxMessages, c.max)
}
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
err error
}
func (m *mockLLM) Name() string { return "mock-llm" }
func (m *mockLLM) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
if m.err != nil {
return model.Response{}, m.err
}
return model.Response{Content: m.content, Message: model.TextMessage(model.RoleAssistant, m.content)}, nil
}
func (m *mockLLM) ChatStream(_ context.Context, _ model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return model.Response{}, nil
}
// TestNewLLMReviewer 验证审查模型被包装成 Reviewer:向
// LLM 询问哪些队列消息该保留,并过滤返回的索引。
func TestNewLLMReviewer(t *testing.T) {
t.Parallel()
llm := &mockLLM{content: `[0,1,99,0]`}
review := NewLLMReviewer(llm, nil)
msgs := []adapter.UnifiedMessage{
{SenderID: "a", Content: "x", ContentType: adapter.ContentText},
{SenderID: "b", Content: "y", ContentType: adapter.ContentText},
{SenderID: "c", Content: "z", ContentType: adapter.ContentText},
}
keep, err := review(context.Background(), msgs)
if err != nil {
t.Fatalf("review: %v", err)
}
// 越界索引 99 与重复的 0 被丢弃;保留 = [0,1]。
if len(keep) != 2 || keep[0] != 0 || keep[1] != 1 {
t.Fatalf("keep = %v, want [0 1]", keep)
}
if llm.err != nil {
t.Fatal("unexpected mock error")
}
}
// TestNewLLMReviewerError 验证审查模型失败会暴露给闸门,
// 闸门随后回退为 FIFO 淘汰,而不是伪造一个成功。
func TestNewLLMReviewerError(t *testing.T) {
t.Parallel()
review := NewLLMReviewer(&mockLLM{err: context.DeadlineExceeded}, nil)
if _, err := review(context.Background(), []adapter.UnifiedMessage{
{SenderID: "a", Content: "x", ContentType: adapter.ContentText},
}); err == nil {
t.Fatal("expected error from failed review model")
}
}
func TestParseKeepIndices(t *testing.T) {
t.Parallel()
got, err := parseKeepIndices(`[2,0,0,5]`, 3)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
t.Fatalf("keep = %v, want [0 2] (out-of-range/dup dropped)", got)
}
if _, err := parseKeepIndices(`not json`, 3); err == nil {
t.Fatal("expected error for non-JSON")
}
}