Files
aiaa-notification-server/docs/superpowers/plans/2026-08-01-remote-e2e-notify-flow.md
ryan 39f3774940 feat: 配置列表分页与钉钉机器人分钟级排队限流
统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 00:34:26 +08:00

295 lines
9.0 KiB
Markdown
Raw Permalink 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.
# 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 |