feat: 配置列表分页与钉钉机器人分钟级排队限流

统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 00:34:26 +08:00
parent 009694fee7
commit 39f3774940
27 changed files with 1447 additions and 60 deletions
+141
View File
@@ -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)
}