feat: 配置列表分页与钉钉机器人分钟级排队限流
统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,10 +13,10 @@ type ChannelSender interface {
|
||||
Send(title, content string, config json.RawMessage) error
|
||||
}
|
||||
|
||||
func NewSender(channelType string, smtpCfg *config.SMTPConfig) (ChannelSender, error) {
|
||||
func NewSender(channelType string, smtpCfg *config.SMTPConfig, dingtalkLimiter *DingTalkLimiter) (ChannelSender, error) {
|
||||
switch channelType {
|
||||
case "dingtalk":
|
||||
return &DingTalkSender{}, nil
|
||||
return &DingTalkSender{limiter: dingtalkLimiter}, nil
|
||||
case "wecom":
|
||||
return &WeComSender{}, nil
|
||||
case "bark":
|
||||
|
||||
@@ -2,6 +2,7 @@ package adapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -27,7 +28,9 @@ type dingtalkMD struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type DingTalkSender struct{}
|
||||
type DingTalkSender struct {
|
||||
limiter *DingTalkLimiter
|
||||
}
|
||||
|
||||
func (s *DingTalkSender) Type() string { return "dingtalk" }
|
||||
|
||||
@@ -37,6 +40,12 @@ func (s *DingTalkSender) Send(title, content string, config json.RawMessage) err
|
||||
return fmt.Errorf("parse dingtalk config: %w", err)
|
||||
}
|
||||
|
||||
if s.limiter != nil {
|
||||
if err := s.limiter.Acquire(context.Background(), DingTalkLimitKey(cfg.WebhookURL)); err != nil {
|
||||
return fmt.Errorf("dingtalk rate limit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
reqURL := cfg.WebhookURL
|
||||
if cfg.Secret != "" {
|
||||
timestamp := time.Now().UnixMilli()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultDingTalkPerMin = 18
|
||||
|
||||
// MinuteWindowStore tracks per-key counters in a fixed UTC-minute window.
|
||||
type MinuteWindowStore interface {
|
||||
// TryIncr increments if under limit; returns (count, true) when acquired,
|
||||
// or (currentOrLimit, false) when the window is full.
|
||||
TryIncr(ctx context.Context, key string, minute int64, limit int) (count int64, ok bool, err error)
|
||||
}
|
||||
|
||||
// DingTalkLimiter enforces per-robot (access_token) send quota and waits for the next minute when full.
|
||||
type DingTalkLimiter struct {
|
||||
store MinuteWindowStore
|
||||
limit int
|
||||
now func() time.Time
|
||||
sleep func(time.Duration)
|
||||
}
|
||||
|
||||
func NewDingTalkLimiter(store MinuteWindowStore, limitPerMin int) *DingTalkLimiter {
|
||||
if limitPerMin <= 0 {
|
||||
limitPerMin = DefaultDingTalkPerMin
|
||||
}
|
||||
return &DingTalkLimiter{
|
||||
store: store,
|
||||
limit: limitPerMin,
|
||||
now: time.Now,
|
||||
sleep: time.Sleep,
|
||||
}
|
||||
}
|
||||
|
||||
// DingTalkLimitKey returns the rate-limit key for a webhook URL (access_token).
|
||||
func DingTalkLimitKey(webhookURL string) string {
|
||||
if webhookURL == "" {
|
||||
return "unknown"
|
||||
}
|
||||
u, err := url.Parse(webhookURL)
|
||||
if err != nil {
|
||||
return webhookURL
|
||||
}
|
||||
if tok := u.Query().Get("access_token"); tok != "" {
|
||||
return tok
|
||||
}
|
||||
return webhookURL
|
||||
}
|
||||
|
||||
// Acquire blocks until a send slot is available for key in the current minute window.
|
||||
func (l *DingTalkLimiter) Acquire(ctx context.Context, key string) error {
|
||||
if l == nil || l.store == nil {
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := l.now()
|
||||
minute := now.Unix() / 60
|
||||
count, ok, err := l.store.TryIncr(ctx, key, minute, l.limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dingtalk rate limit: %w", err)
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
next := time.Unix((minute+1)*60, 0)
|
||||
wait := next.Sub(now)
|
||||
if wait < time.Millisecond {
|
||||
wait = time.Millisecond
|
||||
}
|
||||
slog.Info("dingtalk rate limited, waiting for next minute",
|
||||
"key_suffix", maskKey(key),
|
||||
"count", count,
|
||||
"limit", l.limit,
|
||||
"wait", wait,
|
||||
)
|
||||
if err := l.sleepCtx(ctx, wait); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *DingTalkLimiter) sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
// Prefer injectable sleep for tests; also honor context cancel via polling when possible.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
l.sleep(d)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func maskKey(key string) string {
|
||||
if len(key) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return key[:4] + "…" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
// --- in-memory store (fallback / tests) ---
|
||||
|
||||
type memoryMinuteStore struct {
|
||||
mu sync.Mutex
|
||||
data map[string]int64 // key:minute -> count
|
||||
}
|
||||
|
||||
func newMemoryMinuteStore() *memoryMinuteStore {
|
||||
return &memoryMinuteStore{data: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func (m *memoryMinuteStore) TryIncr(_ context.Context, key string, minute int64, limit int) (int64, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
k := fmt.Sprintf("%s:%d", key, minute)
|
||||
cur := m.data[k]
|
||||
if cur >= int64(limit) {
|
||||
return cur, false, nil
|
||||
}
|
||||
cur++
|
||||
m.data[k] = cur
|
||||
return cur, true, nil
|
||||
}
|
||||
|
||||
// NewMemoryDingTalkLimiter builds a process-local limiter (single-instance only).
|
||||
func NewMemoryDingTalkLimiter(limitPerMin int) *DingTalkLimiter {
|
||||
return NewDingTalkLimiter(newMemoryMinuteStore(), limitPerMin)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDingTalkLimitKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "token only",
|
||||
url: "https://oapi.dingtalk.com/robot/send?access_token=abc123",
|
||||
want: "abc123",
|
||||
},
|
||||
{
|
||||
name: "token with other params",
|
||||
url: "https://oapi.dingtalk.com/robot/send?access_token=tok&foo=1",
|
||||
want: "tok",
|
||||
},
|
||||
{
|
||||
name: "missing token falls back to url",
|
||||
url: "https://oapi.dingtalk.com/robot/send?foo=1",
|
||||
want: "https://oapi.dingtalk.com/robot/send?foo=1",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
url: "",
|
||||
want: "unknown",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := DingTalkLimitKey(tt.url); got != tt.want {
|
||||
t.Fatalf("DingTalkLimitKey(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkLimiterAcquireWaitsForNextWindow(t *testing.T) {
|
||||
now := time.Unix(1000, 0) // fixed
|
||||
var slept time.Duration
|
||||
store := newMemoryMinuteStore()
|
||||
lim := &DingTalkLimiter{
|
||||
store: store,
|
||||
limit: 2,
|
||||
now: func() time.Time { return now },
|
||||
sleep: func(d time.Duration) {
|
||||
slept += d
|
||||
now = now.Add(d)
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := "tok-1"
|
||||
if err := lim.Acquire(ctx, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lim.Acquire(ctx, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slept != 0 {
|
||||
t.Fatalf("unexpected sleep before limit: %v", slept)
|
||||
}
|
||||
|
||||
// third should wait until next minute boundary (60 - 1000%60 = 20s? 1000/60=16 rem 40, next at 17*60=1020, wait 20s)
|
||||
if err := lim.Acquire(ctx, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slept != 20*time.Second {
|
||||
t.Fatalf("slept = %v, want 20s", slept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkLimiterSameKeyShared(t *testing.T) {
|
||||
now := time.Unix(0, 0)
|
||||
store := newMemoryMinuteStore()
|
||||
lim := &DingTalkLimiter{
|
||||
store: store,
|
||||
limit: 1,
|
||||
now: func() time.Time { return now },
|
||||
sleep: func(d time.Duration) { now = now.Add(d) },
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := lim.Acquire(ctx, "shared"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lim.Acquire(ctx, "shared"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if now.Unix() != 60 {
|
||||
t.Fatalf("expected wait to next minute boundary, now=%v", now)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user