feat: 配置列表分页与钉钉机器人分钟级排队限流
统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -111,7 +111,7 @@ curl -X POST 'http://localhost:8080/api/v1/sources' \
|
||||
### 列出 Source
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://localhost:8080/api/v1/sources' \
|
||||
curl -X GET 'http://localhost:8080/api/v1/sources?page=1&page_size=20' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me'
|
||||
```
|
||||
|
||||
@@ -161,7 +161,7 @@ curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
### 列出 Template
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://localhost:8080/api/v1/templates' \
|
||||
curl -X GET 'http://localhost:8080/api/v1/templates?page=1&page_size=20' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me'
|
||||
```
|
||||
|
||||
@@ -263,7 +263,7 @@ curl -X POST 'http://localhost:8080/api/v1/channels' \
|
||||
### 列出 Channel
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://localhost:8080/api/v1/channels' \
|
||||
curl -X GET 'http://localhost:8080/api/v1/channels?page=1&page_size=20' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me'
|
||||
```
|
||||
|
||||
@@ -323,7 +323,7 @@ curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
### 列出 Rule
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://localhost:8080/api/v1/rules' \
|
||||
curl -X GET 'http://localhost:8080/api/v1/rules?page=1&page_size=20' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me'
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
# Remote E2E Notify Flow Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a Go e2e test (build tag `e2e`) that hits the deployed service and verifies source → channel×3 → template → rule → notify.
|
||||
|
||||
**Architecture:** Stdlib `net/http` client against `E2E_BASE_URL`. Admin Bearer for CRUD; source `api_key` for notify. Channel secrets from env. Unique resource names + `t.Cleanup` deletes.
|
||||
|
||||
**Tech Stack:** Go 1.22+, `testing`, `net/http`, `encoding/json`. No new dependencies.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Build tag: `e2e` — must not run under default `make test`
|
||||
- Secrets only via env vars — never hardcode webhooks/secrets in source
|
||||
- Default base URL: `http://82.157.251.93:8080`
|
||||
- Default admin key: `admin-sk-change-me`
|
||||
- Channels: dingtalk + bark + email
|
||||
- Assert notify `matched`/`accepted` and 3 channels; poll message-logs up to 15s
|
||||
- Do not commit unless the user explicitly asks
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `test/e2e/notify_flow_test.go` | Full remote flow test + tiny HTTP helpers |
|
||||
| `Makefile` | Add `test-e2e` target |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: E2E notify flow test + Makefile
|
||||
|
||||
**Files:**
|
||||
- Create: `test/e2e/notify_flow_test.go`
|
||||
- Modify: `Makefile`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `TestNotifyFlow_SourceChannelTemplateRuleSend` runnable via `go test -tags e2e ./test/e2e/`
|
||||
|
||||
- [x] **Step 1: Create `test/e2e/notify_flow_test.go`**
|
||||
|
||||
```go
|
||||
//go:build e2e
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNotifyFlow_SourceChannelTemplateRuleSend(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
srcName := "e2e-src-" + suffix
|
||||
tmplName := "e2e-tmpl-" + suffix
|
||||
chDing := "e2e-ding-" + suffix
|
||||
chBark := "e2e-bark-" + suffix
|
||||
chEmail := "e2e-email-" + suffix
|
||||
|
||||
// 1. Source
|
||||
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) })
|
||||
|
||||
// 2. Channels
|
||||
ding := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||
"name": chDing, "type": "dingtalk", "status": 1,
|
||||
"config": map[string]string{"webhook_url": dingWebhook, "secret": dingSecret},
|
||||
}, http.StatusCreated)
|
||||
dingID := intFrom(ding["id"])
|
||||
t.Cleanup(func() { _ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", dingID), nil) })
|
||||
|
||||
bark := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||
"name": chBark, "type": "bark", "status": 1,
|
||||
"config": map[string]string{"url": barkURL},
|
||||
}, http.StatusCreated)
|
||||
barkID := intFrom(bark["id"])
|
||||
t.Cleanup(func() { _ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", barkID), nil) })
|
||||
|
||||
email := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||
"name": chEmail, "type": "email", "status": 1,
|
||||
"config": map[string]any{"to": []string{emailTo}},
|
||||
}, http.StatusCreated)
|
||||
emailID := intFrom(email["id"])
|
||||
t.Cleanup(func() { _ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", emailID), nil) })
|
||||
|
||||
// 3. Template
|
||||
tmpl := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/templates", map[string]any{
|
||||
"name": tmplName,
|
||||
"content": "### {{.symbol}} 开仓\n价格: {{.price}}",
|
||||
}, http.StatusCreated)
|
||||
tmplID := intFrom(tmpl["id"])
|
||||
t.Cleanup(func() { _ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/templates/%d", tmplID), nil) })
|
||||
|
||||
// 4. Rule
|
||||
rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{
|
||||
"source_name": srcName, "event": "trade.open", "template_name": tmplName,
|
||||
"channels": []string{chDing, chBark, chEmail},
|
||||
"conditions": []map[string]string{
|
||||
{"field": "symbol", "op": "exists"},
|
||||
{"field": "price", "op": "gt", "value": "0"},
|
||||
},
|
||||
"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) })
|
||||
|
||||
// 5. Notify
|
||||
notifyBody := map[string]any{
|
||||
"event": "trade.open",
|
||||
"data": map[string]any{"symbol": "BTC", "price": 65000},
|
||||
}
|
||||
notifyResp := mustJSON(t, client, http.MethodPost, base+"/api/v1/notify", apiKey, notifyBody, http.StatusOK)
|
||||
if notifyResp["matched"] != true {
|
||||
t.Fatalf("matched: %#v", notifyResp["matched"])
|
||||
}
|
||||
if notifyResp["accepted"] != true {
|
||||
t.Fatalf("accepted: %#v", notifyResp["accepted"])
|
||||
}
|
||||
chs, _ := notifyResp["channels"].([]any)
|
||||
if len(chs) != 3 {
|
||||
t.Fatalf("want 3 channels, got %#v", notifyResp["channels"])
|
||||
}
|
||||
|
||||
// 6. Message logs (async create)
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var total float64
|
||||
for time.Now().Before(deadline) {
|
||||
logs := mustAdminJSON(t, client, base, adminKey, http.MethodGet,
|
||||
fmt.Sprintf("/api/v1/message-logs?source=%s&event=trade.open&page=1&page_size=20", srcName),
|
||||
nil, http.StatusOK)
|
||||
if n, ok := logs["total"].(float64); ok {
|
||||
total = n
|
||||
if total >= 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
if total < 1 {
|
||||
t.Fatalf("expected message logs, total=%v", total)
|
||||
}
|
||||
}
|
||||
|
||||
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 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, url, bearer string, body any, want int) map[string]any {
|
||||
t.Helper()
|
||||
code, raw, err := doJSON(c, method, url, bearer, body)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, url, err)
|
||||
}
|
||||
if code != want {
|
||||
t.Fatalf("%s %s: status %d want %d body=%s", method, url, code, want, raw)
|
||||
}
|
||||
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, url, 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, url, 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
|
||||
}
|
||||
```
|
||||
|
||||
Note: register cleanups so deletes run reverse of registration order — register source first, then channels, template, rule last so rule deletes before dependents. (Go runs cleanups LIFO.)
|
||||
|
||||
- [x] **Step 2: Update Makefile**
|
||||
|
||||
Add:
|
||||
|
||||
```make
|
||||
test-e2e:
|
||||
go test -tags e2e ./test/e2e/ -v -count=1 -timeout 2m
|
||||
```
|
||||
|
||||
- [x] **Step 3: Run without env — expect Skip**
|
||||
|
||||
```bash
|
||||
go test -tags e2e ./test/e2e/ -v -count=1
|
||||
```
|
||||
|
||||
Expected: `SKIP: missing E2E_...`
|
||||
|
||||
- [x] **Step 4: Run against remote with env — expect PASS**
|
||||
|
||||
```bash
|
||||
E2E_DINGTALK_WEBHOOK='https://oapi.dingtalk.com/robot/send?access_token=...' \
|
||||
E2E_DINGTALK_SECRET='SEC...' \
|
||||
E2E_BARK_URL='https://api.day.app/...' \
|
||||
E2E_EMAIL_TO='149516886@qq.com' \
|
||||
go test -tags e2e ./test/e2e/ -v -count=1 -timeout 2m
|
||||
```
|
||||
|
||||
Expected: `PASS`. Verify devices/inbox received messages manually if needed.
|
||||
|
||||
- [x] **Step 5: Confirm default unit tests still ignore e2e**
|
||||
|
||||
```bash
|
||||
go test ./internal/... -count=1
|
||||
```
|
||||
|
||||
Expected: no e2e package compiled.
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage
|
||||
|
||||
| Spec item | Task |
|
||||
|-----------|------|
|
||||
| Build tag e2e | Task 1 |
|
||||
| Env vars + skip | Task 1 |
|
||||
| source→channel×3→template→rule→notify | Task 1 |
|
||||
| Assert matched/accepted/3 channels | Task 1 |
|
||||
| Poll message-logs 15s | Task 1 |
|
||||
| Cleanup | Task 1 |
|
||||
| Makefile test-e2e | Task 1 |
|
||||
| No secrets in repo | Task 1 |
|
||||
@@ -219,28 +219,28 @@ Response 429:
|
||||
```
|
||||
来源:
|
||||
POST /api/v1/sources
|
||||
GET /api/v1/sources
|
||||
GET /api/v1/sources?page=1&page_size=20
|
||||
GET /api/v1/sources/:id
|
||||
PUT /api/v1/sources/:id
|
||||
DELETE /api/v1/sources/:id
|
||||
|
||||
模板:
|
||||
POST /api/v1/templates
|
||||
GET /api/v1/templates
|
||||
GET /api/v1/templates?page=1&page_size=20
|
||||
GET /api/v1/templates/:id
|
||||
PUT /api/v1/templates/:id
|
||||
DELETE /api/v1/templates/:id
|
||||
|
||||
渠道:
|
||||
POST /api/v1/channels
|
||||
GET /api/v1/channels
|
||||
GET /api/v1/channels?page=1&page_size=20
|
||||
GET /api/v1/channels/:id
|
||||
PUT /api/v1/channels/:id
|
||||
DELETE /api/v1/channels/:id
|
||||
|
||||
规则:
|
||||
POST /api/v1/rules
|
||||
GET /api/v1/rules
|
||||
GET /api/v1/rules?page=1&page_size=20
|
||||
GET /api/v1/rules/:id
|
||||
PUT /api/v1/rules/:id
|
||||
DELETE /api/v1/rules/:id
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Remote E2E Notify Flow Test Design
|
||||
|
||||
**Date:** 2026-08-01
|
||||
**Status:** Approved (pending final user review of this doc)
|
||||
|
||||
## Goal
|
||||
|
||||
Add a Go end-to-end test that hits a **deployed** notification service over HTTP and exercises the full configuration → send path:
|
||||
|
||||
`source → channel(s) → template → rule → POST /notify`
|
||||
|
||||
Channels under test: **DingTalk**, **Bark**, **Email**. Real notifications are sent.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Local `httptest` / in-process server tests
|
||||
- Mocking `ChannelSender`
|
||||
- CI by default (opt-in via build tag + env vars)
|
||||
- Committing secrets to the repo
|
||||
|
||||
## Placement
|
||||
|
||||
| Item | Path |
|
||||
|------|------|
|
||||
| Test | `test/e2e/notify_flow_test.go` |
|
||||
| Build tag | `//go:build e2e` |
|
||||
| Make target | `test-e2e` (optional helper) |
|
||||
|
||||
Default `make test` / `go test ./internal/...` must **not** run this suite.
|
||||
|
||||
## Target environment
|
||||
|
||||
| Setting | Default / source |
|
||||
|---------|------------------|
|
||||
| Base URL | `E2E_BASE_URL` → default `http://82.157.251.93:8080` |
|
||||
| Admin auth | `E2E_ADMIN_KEY` → default `admin-sk-change-me` |
|
||||
|
||||
## Required env vars (channels)
|
||||
|
||||
If any of these are missing, the test **Skips** (does not fail):
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `E2E_DINGTALK_WEBHOOK` | DingTalk robot webhook URL |
|
||||
| `E2E_DINGTALK_SECRET` | DingTalk sign secret |
|
||||
| `E2E_BARK_URL` | Bark base URL, e.g. `https://api.day.app/<device_key>` |
|
||||
| `E2E_EMAIL_TO` | Email recipient |
|
||||
|
||||
Secrets live only in the runner environment / local shell, never in source or this spec.
|
||||
|
||||
## Flow
|
||||
|
||||
Resource names use a unique suffix (unix timestamp or random) to avoid collisions, e.g. `e2e-src-<suffix>`.
|
||||
|
||||
1. **Create Source** — `POST /api/v1/sources`
|
||||
- `parse_mode: json`, `status: 1`
|
||||
- Capture `id`, `api_key`, `name`
|
||||
|
||||
2. **Create Channels** — `POST /api/v1/channels` ×3
|
||||
- DingTalk: `type=dingtalk`, config `{webhook_url, secret}`
|
||||
- Bark: `type=bark`, config `{url}`
|
||||
- Email: `type=email`, config `{to: [E2E_EMAIL_TO]}`
|
||||
- All `status: 1`
|
||||
|
||||
3. **Create Template** — `POST /api/v1/templates`
|
||||
- Content includes `{{.symbol}}` and `{{.price}}`
|
||||
|
||||
4. **Create Rule** — `POST /api/v1/rules`
|
||||
- `source_name` / `template_name` / `channels` by name
|
||||
- `event: trade.open`
|
||||
- `enabled: 1`
|
||||
- Conditions: `symbol` exists AND `price` gt `0`
|
||||
|
||||
5. **Notify** — `POST /api/v1/notify`
|
||||
- `Authorization: Bearer <source.api_key>`
|
||||
- Body: `{"event":"trade.open","data":{"symbol":"BTC","price":65000}}`
|
||||
|
||||
6. **Assert notify response**
|
||||
- `matched == true`
|
||||
- `accepted == true`
|
||||
- `channels` length == 3
|
||||
|
||||
7. **Message-log check**
|
||||
- Poll `GET /api/v1/message-logs?source=...&event=trade.open` for up to ~15s
|
||||
- Assert at least one log entry appears (fail if none within timeout)
|
||||
|
||||
8. **Cleanup** via `t.Cleanup` (reverse order)
|
||||
- Delete rule → template → channels → source
|
||||
- Cleanup errors: log only, do not fail the test after a successful assertion path
|
||||
|
||||
## Run command
|
||||
|
||||
```bash
|
||||
E2E_DINGTALK_WEBHOOK='...' \
|
||||
E2E_DINGTALK_SECRET='...' \
|
||||
E2E_BARK_URL='https://api.day.app/<device_key>' \
|
||||
E2E_EMAIL_TO='149516886@qq.com' \
|
||||
go test -tags e2e ./test/e2e/ -v -count=1 -timeout 2m
|
||||
```
|
||||
|
||||
Optional Makefile:
|
||||
|
||||
```make
|
||||
test-e2e:
|
||||
go test -tags e2e ./test/e2e/ -v -count=1 -timeout 2m
|
||||
```
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Use `net/http` + `encoding/json` (stdlib); no new test deps required.
|
||||
- Helper for admin JSON requests and source-authed notify.
|
||||
- Failures must include HTTP status + response body for debugging.
|
||||
- Do not hardcode channel secrets in the test file.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- With env vars set and remote healthy: test passes; DingTalk, Bark, and Email receive a message.
|
||||
- Without channel env vars: test skips cleanly.
|
||||
- Without `-tags e2e`: suite is not compiled/run.
|
||||
Reference in New Issue
Block a user