feat: 配置列表分页与钉钉机器人分钟级排队限流
统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
//go:build e2e
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
client *http.Client
|
||||
base string
|
||||
adminKey string
|
||||
apiKey string
|
||||
srcName string
|
||||
srcID int
|
||||
tmplID int
|
||||
ruleID int
|
||||
dingID int
|
||||
barkID int
|
||||
emailID int
|
||||
chDing string
|
||||
chBark string
|
||||
chEmail string
|
||||
}
|
||||
|
||||
func requireE2EEnv(t *testing.T) (base, adminKey, dingWebhook, dingSecret, barkURL, emailTo string) {
|
||||
t.Helper()
|
||||
base = envOr("E2E_BASE_URL", "http://82.157.251.93:8080")
|
||||
adminKey = envOr("E2E_ADMIN_KEY", "admin-sk-change-me")
|
||||
dingWebhook = os.Getenv("E2E_DINGTALK_WEBHOOK")
|
||||
dingSecret = os.Getenv("E2E_DINGTALK_SECRET")
|
||||
barkURL = os.Getenv("E2E_BARK_URL")
|
||||
emailTo = os.Getenv("E2E_EMAIL_TO")
|
||||
if dingWebhook == "" || dingSecret == "" || barkURL == "" || emailTo == "" {
|
||||
t.Skip("missing E2E_DINGTALK_WEBHOOK / E2E_DINGTALK_SECRET / E2E_BARK_URL / E2E_EMAIL_TO")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func setupFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
base, adminKey, dingWebhook, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
f := &fixture{
|
||||
t: t,
|
||||
client: client,
|
||||
base: base,
|
||||
adminKey: adminKey,
|
||||
srcName: "e2e-src-" + suffix,
|
||||
chDing: "e2e-ding-" + suffix,
|
||||
chBark: "e2e-bark-" + suffix,
|
||||
chEmail: "e2e-email-" + suffix,
|
||||
}
|
||||
tmplName := "e2e-tmpl-" + suffix
|
||||
|
||||
// Cleanups run LIFO: register source first so rule (registered last) is deleted first.
|
||||
src := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/sources", map[string]any{
|
||||
"name": f.srcName, "parse_mode": "json", "status": 1,
|
||||
}, http.StatusCreated)
|
||||
f.srcID = intFrom(src["id"])
|
||||
apiKey, _ := src["api_key"].(string)
|
||||
if apiKey == "" {
|
||||
t.Fatal("source api_key empty")
|
||||
}
|
||||
f.apiKey = apiKey
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/sources/%d", f.srcID), nil)
|
||||
})
|
||||
|
||||
ding := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||
"name": f.chDing, "type": "dingtalk", "status": 1,
|
||||
"config": map[string]string{"webhook_url": dingWebhook, "secret": dingSecret},
|
||||
}, http.StatusCreated)
|
||||
f.dingID = intFrom(ding["id"])
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", f.dingID), nil)
|
||||
})
|
||||
|
||||
bark := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||
"name": f.chBark, "type": "bark", "status": 1,
|
||||
"config": map[string]string{"url": barkURL},
|
||||
}, http.StatusCreated)
|
||||
f.barkID = intFrom(bark["id"])
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", f.barkID), nil)
|
||||
})
|
||||
|
||||
email := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||
"name": f.chEmail, "type": "email", "status": 1,
|
||||
"config": map[string]any{"to": []string{emailTo}},
|
||||
}, http.StatusCreated)
|
||||
f.emailID = intFrom(email["id"])
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", f.emailID), nil)
|
||||
})
|
||||
|
||||
tmpl := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/templates", map[string]any{
|
||||
"name": tmplName,
|
||||
"content": "### {{.symbol}} 开仓\n价格: {{.price}}",
|
||||
}, http.StatusCreated)
|
||||
f.tmplID = intFrom(tmpl["id"])
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/templates/%d", f.tmplID), nil)
|
||||
})
|
||||
|
||||
rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{
|
||||
"source_name": f.srcName,
|
||||
"event": "trade.open",
|
||||
"template_name": tmplName,
|
||||
"channels": []string{f.chDing, f.chBark, f.chEmail},
|
||||
"conditions": []map[string]string{
|
||||
{"field": "symbol", "op": "exists"},
|
||||
{"field": "price", "op": "gt", "value": "0"},
|
||||
},
|
||||
"enabled": 1,
|
||||
}, http.StatusCreated)
|
||||
f.ruleID = intFrom(rule["id"])
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/rules/%d", f.ruleID), nil)
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) notify(body any) map[string]any {
|
||||
f.t.Helper()
|
||||
return mustJSON(f.t, f.client, http.MethodPost, f.base+"/api/v1/notify", f.apiKey, body, http.StatusOK)
|
||||
}
|
||||
|
||||
func (f *fixture) messageLogTotal() float64 {
|
||||
f.t.Helper()
|
||||
q := url.Values{}
|
||||
q.Set("source", f.srcName)
|
||||
q.Set("event", "trade.open")
|
||||
q.Set("page", "1")
|
||||
q.Set("page_size", "20")
|
||||
logs := mustAdminJSON(f.t, f.client, f.base, f.adminKey, http.MethodGet,
|
||||
"/api/v1/message-logs?"+q.Encode(), nil, http.StatusOK)
|
||||
n, _ := logs["total"].(float64)
|
||||
return n
|
||||
}
|
||||
|
||||
func (f *fixture) waitMessageLogs(min float64, timeout time.Duration) float64 {
|
||||
f.t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
var total float64
|
||||
for time.Now().Before(deadline) {
|
||||
total = f.messageLogTotal()
|
||||
if total >= min {
|
||||
return total
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
f.t.Fatalf("expected message logs >= %v within %s, total=%v", min, timeout, total)
|
||||
return total
|
||||
}
|
||||
|
||||
func TestNotifyFlow_SourceChannelTemplateRuleSend(t *testing.T) {
|
||||
f := setupFixture(t)
|
||||
|
||||
resp := f.notify(map[string]any{
|
||||
"event": "trade.open",
|
||||
"data": map[string]any{"symbol": "BTC", "price": 65000},
|
||||
})
|
||||
if resp["matched"] != true {
|
||||
t.Fatalf("matched: %#v body=%v", resp["matched"], resp)
|
||||
}
|
||||
if resp["accepted"] != true {
|
||||
t.Fatalf("accepted: %#v body=%v", resp["accepted"], resp)
|
||||
}
|
||||
chs, _ := resp["channels"].([]any)
|
||||
if len(chs) != 3 {
|
||||
t.Fatalf("want 3 channels, got %#v", resp["channels"])
|
||||
}
|
||||
f.waitMessageLogs(1, 15*time.Second)
|
||||
}
|
||||
|
||||
func TestNotifyFlow_ConditionFiltered(t *testing.T) {
|
||||
f := setupFixture(t)
|
||||
before := f.messageLogTotal()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{"price_not_gt_zero", map[string]any{"symbol": "BTC", "price": 0}},
|
||||
{"symbol_missing", map[string]any{"price": 65000}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := f.notify(map[string]any{"event": "trade.open", "data": tc.data})
|
||||
if resp["matched"] != true {
|
||||
t.Fatalf("matched: %#v body=%v", resp["matched"], resp)
|
||||
}
|
||||
if resp["filtered"] != true {
|
||||
t.Fatalf("filtered: %#v body=%v", resp["filtered"], resp)
|
||||
}
|
||||
if resp["reason"] != "condition not met" {
|
||||
t.Fatalf("reason: %#v body=%v", resp["reason"], resp)
|
||||
}
|
||||
if _, ok := resp["accepted"]; ok {
|
||||
t.Fatalf("filtered notify should not be accepted: %v", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
after := f.messageLogTotal()
|
||||
if after != before {
|
||||
t.Fatalf("filtered notify should not create logs: before=%v after=%v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyFlow_NoMatchingRule(t *testing.T) {
|
||||
f := setupFixture(t)
|
||||
|
||||
resp := f.notify(map[string]any{
|
||||
"event": "unknown.event",
|
||||
"data": map[string]any{"symbol": "BTC", "price": 65000},
|
||||
})
|
||||
if resp["matched"] != false {
|
||||
t.Fatalf("matched: %#v body=%v", resp["matched"], resp)
|
||||
}
|
||||
if _, ok := resp["channels"]; ok {
|
||||
t.Fatalf("unmatched should not include channels: %v", resp)
|
||||
}
|
||||
if _, ok := resp["accepted"]; ok {
|
||||
t.Fatalf("unmatched should not be accepted: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyFlow_DisableChannel(t *testing.T) {
|
||||
f := setupFixture(t)
|
||||
|
||||
// Disable before any notify so channel cache is cold (safe even on older deploys).
|
||||
mustAdminJSON(t, f.client, f.base, f.adminKey, http.MethodPatch,
|
||||
fmt.Sprintf("/api/v1/rules/%d/channels/%d/disable", f.ruleID, f.dingID), nil, http.StatusOK)
|
||||
|
||||
resp := f.notify(map[string]any{
|
||||
"event": "trade.open",
|
||||
"data": map[string]any{"symbol": "BTC", "price": 65000},
|
||||
})
|
||||
if resp["matched"] != true || resp["accepted"] != true {
|
||||
t.Fatalf("unexpected notify resp: %v", resp)
|
||||
}
|
||||
chs, _ := resp["channels"].([]any)
|
||||
if len(chs) != 2 {
|
||||
t.Fatalf("want 2 channels after disable, got %#v", resp["channels"])
|
||||
}
|
||||
for _, ch := range chs {
|
||||
s, _ := ch.(string)
|
||||
if s == fmt.Sprintf("dingtalk:%d", f.dingID) {
|
||||
t.Fatalf("disabled dingtalk channel still routed: %#v", chs)
|
||||
}
|
||||
}
|
||||
f.waitMessageLogs(1, 15*time.Second)
|
||||
}
|
||||
|
||||
func TestNotifyFlow_DisableRule(t *testing.T) {
|
||||
f := setupFixture(t)
|
||||
|
||||
// Disable before any notify so rule cache is cold (safe even on older deploys).
|
||||
mustAdminJSON(t, f.client, f.base, f.adminKey, http.MethodPatch,
|
||||
fmt.Sprintf("/api/v1/rules/%d/disable", f.ruleID), nil, http.StatusOK)
|
||||
|
||||
resp := f.notify(map[string]any{
|
||||
"event": "trade.open",
|
||||
"data": map[string]any{"symbol": "BTC", "price": 65000},
|
||||
})
|
||||
if resp["matched"] != false {
|
||||
t.Fatalf("disabled rule should not match: %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyFlow_DifferentTemplatesPerRule verifies rule→template binding:
|
||||
// same source, three events, each with a distinct template and a single channel.
|
||||
func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) {
|
||||
base, adminKey, dingWebhook, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
srcName := "e2e-mt-" + suffix
|
||||
|
||||
src := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/sources", map[string]any{
|
||||
"name": srcName, "parse_mode": "json", "status": 1,
|
||||
}, http.StatusCreated)
|
||||
srcID := intFrom(src["id"])
|
||||
apiKey, _ := src["api_key"].(string)
|
||||
if apiKey == "" {
|
||||
t.Fatal("source api_key empty")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/sources/%d", srcID), nil)
|
||||
})
|
||||
|
||||
type route struct {
|
||||
event string
|
||||
marker string
|
||||
chName string
|
||||
chType string
|
||||
chCfg map[string]any
|
||||
tmpl string
|
||||
channel string // set after create
|
||||
}
|
||||
routes := []route{
|
||||
{
|
||||
event: "e2e.ding",
|
||||
marker: "DING-TMPL",
|
||||
chName: "e2e-mtd-" + suffix,
|
||||
chType: "dingtalk",
|
||||
chCfg: map[string]any{"webhook_url": dingWebhook, "secret": dingSecret},
|
||||
tmpl: "DING-TMPL {{.symbol}} ding price={{.price}}",
|
||||
},
|
||||
{
|
||||
event: "e2e.bark",
|
||||
marker: "BARK-TMPL",
|
||||
chName: "e2e-mtb-" + suffix,
|
||||
chType: "bark",
|
||||
chCfg: map[string]any{"url": barkURL},
|
||||
tmpl: "BARK-TMPL {{.symbol}} bark price={{.price}}",
|
||||
},
|
||||
{
|
||||
event: "e2e.email",
|
||||
marker: "EMAIL-TMPL",
|
||||
chName: "e2e-mte-" + suffix,
|
||||
chType: "email",
|
||||
chCfg: map[string]any{"to": []string{emailTo}},
|
||||
tmpl: "EMAIL-TMPL {{.symbol}} email price={{.price}}",
|
||||
},
|
||||
}
|
||||
|
||||
for i := range routes {
|
||||
r := &routes[i]
|
||||
tmplName := "e2e-tmpl-" + r.marker + "-" + suffix
|
||||
|
||||
ch := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||
"name": r.chName, "type": r.chType, "status": 1, "config": r.chCfg,
|
||||
}, http.StatusCreated)
|
||||
chID := intFrom(ch["id"])
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", chID), nil)
|
||||
})
|
||||
|
||||
tmpl := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/templates", map[string]any{
|
||||
"name": tmplName, "content": r.tmpl,
|
||||
}, http.StatusCreated)
|
||||
tmplID := intFrom(tmpl["id"])
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/templates/%d", tmplID), nil)
|
||||
})
|
||||
|
||||
rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{
|
||||
"source_name": srcName,
|
||||
"event": r.event,
|
||||
"template_name": tmplName,
|
||||
"channels": []string{r.chName},
|
||||
"enabled": 1,
|
||||
}, http.StatusCreated)
|
||||
ruleID := intFrom(rule["id"])
|
||||
t.Cleanup(func() {
|
||||
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/rules/%d", ruleID), nil)
|
||||
})
|
||||
r.channel = fmt.Sprintf("%s:%d", r.chType, chID)
|
||||
}
|
||||
|
||||
payload := map[string]any{"symbol": "ETH", "price": 3200}
|
||||
for _, r := range routes {
|
||||
resp := mustJSON(t, client, http.MethodPost, base+"/api/v1/notify", apiKey, map[string]any{
|
||||
"event": r.event,
|
||||
"data": payload,
|
||||
}, http.StatusOK)
|
||||
if resp["matched"] != true || resp["accepted"] != true {
|
||||
t.Fatalf("event %s: unexpected resp %v", r.event, resp)
|
||||
}
|
||||
chs, _ := resp["channels"].([]any)
|
||||
if len(chs) != 1 || chs[0] != r.channel {
|
||||
t.Fatalf("event %s: want channels [%s], got %#v", r.event, r.channel, resp["channels"])
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
want := strings.ReplaceAll(r.tmpl, "{{.symbol}}", "ETH")
|
||||
want = strings.ReplaceAll(want, "{{.price}}", "3200")
|
||||
content := waitLogContent(t, client, base, adminKey, srcName, r.event, r.marker, 15*time.Second)
|
||||
if content != want {
|
||||
t.Fatalf("event %s: content=%q want=%q", r.event, content, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitLogContent(t *testing.T, c *http.Client, base, adminKey, source, event, marker string, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
q := url.Values{}
|
||||
q.Set("source", source)
|
||||
q.Set("event", event)
|
||||
q.Set("page", "1")
|
||||
q.Set("page_size", "20")
|
||||
path := "/api/v1/message-logs?" + q.Encode()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
var last string
|
||||
for time.Now().Before(deadline) {
|
||||
logs := mustAdminJSON(t, c, base, adminKey, http.MethodGet, path, nil, http.StatusOK)
|
||||
data, _ := logs["data"].([]any)
|
||||
for _, item := range data {
|
||||
m, _ := item.(map[string]any)
|
||||
content, _ := m["content"].(string)
|
||||
if strings.Contains(content, marker) {
|
||||
return content
|
||||
}
|
||||
last = content
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("no message log with marker %q for event %s (last=%q)", marker, event, last)
|
||||
return ""
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func intFrom(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mustAdminJSON(t *testing.T, c *http.Client, base, key, method, path string, body any, want int) map[string]any {
|
||||
t.Helper()
|
||||
return mustJSON(t, c, method, base+path, key, body, want)
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, c *http.Client, method, urlStr, bearer string, body any, want int) map[string]any {
|
||||
t.Helper()
|
||||
code, raw, err := doJSON(c, method, urlStr, bearer, body)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, urlStr, err)
|
||||
}
|
||||
if code != want {
|
||||
t.Fatalf("%s %s: status %d want %d body=%s", method, urlStr, code, want, raw)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func adminDo(c *http.Client, base, key, method, path string, body any) error {
|
||||
_, _, err := doJSON(c, method, base+path, key, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func doJSON(c *http.Client, method, urlStr, bearer string, body any) (int, []byte, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, urlStr, rdr)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, raw, err
|
||||
}
|
||||
Reference in New Issue
Block a user