From 90f3efdd50368edd50a8f6c296c3a7eb42e4d34a Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 22:22:04 +0800 Subject: [PATCH] ci(arch): forbid pkg packages from depending on project core packages --- AGENTS.md | 2 +- backend/pkg/limiter/memory.go | 33 ++-- backend/pkg/limiter/memory_test.go | 16 +- .../domain/auth/oauth_rate_limit_test.go | 9 +- .../domain/message_gateway/model/push.go | 130 +++++++++++++++- .../domain/message_gateway/push/bark.go | 122 +++++++++++++++ .../message_gateway/push/channels_test.go | 142 ++++++++++++++++++ .../domain/message_gateway/push/dingtalk.go | 124 +++++++++++++++ .../domain/message_gateway/push/discord.go | 108 +++++++++++++ .../domain/message_gateway/push/push.go | 1 + .../domain/message_gateway/push/pushover.go | 93 ++++++++++++ .../domain/message_gateway/push/slack.go | 80 ++++++++++ .../domain/user/login_rate_limit_test.go | 8 +- backend/plugins/infra/cache/limiter.go | 41 +++++ backend/plugins/infra/cache/plugin.go | 3 +- backend/plugins/infra/cache_memory/limiter.go | 56 +++++++ backend/plugins/infra/cache_memory/plugin.go | 3 +- .../admin/push/components/settings-tab.tsx | 41 ++++- scripts/check_cordis_architecture.sh | 13 +- 19 files changed, 985 insertions(+), 40 deletions(-) create mode 100644 backend/plugins/domain/message_gateway/push/bark.go create mode 100644 backend/plugins/domain/message_gateway/push/channels_test.go create mode 100644 backend/plugins/domain/message_gateway/push/dingtalk.go create mode 100644 backend/plugins/domain/message_gateway/push/discord.go create mode 100644 backend/plugins/domain/message_gateway/push/pushover.go create mode 100644 backend/plugins/domain/message_gateway/push/slack.go create mode 100644 backend/plugins/infra/cache_memory/limiter.go diff --git a/AGENTS.md b/AGENTS.md index c75d9438..2b5d4edf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work ## 严格遵循事项 (Guardrails) - 切勿删除 `frontend/node_modules`。 -- 保持 `backend/pkg/util/` 绝对纯净,禁止导入 Gin、GORM、sessions 等 Web/数据库框架包。 +- 保持 `backend/pkg/` 绝对纯净,属于底层通用基础库,**严禁依赖项目上层包(如 `Wavelet/core/*`、`Wavelet/plugins/*`)**;保持 `backend/pkg/util/` 绝对纯净无状态,禁止导入 Gin、GORM、sessions 等 Web/数据库框架包。 - 测试用例禁止硬编码相对路径创建临时目录,统一使用 Go 内置 `t.TempDir()`。 - 修改 API Handler 后运行 `make swagger`,完成代码开发后必须依次运行 `make code-check` 与 `make format`。 diff --git a/backend/pkg/limiter/memory.go b/backend/pkg/limiter/memory.go index 425a282b..74a3d7cb 100644 --- a/backend/pkg/limiter/memory.go +++ b/backend/pkg/limiter/memory.go @@ -1,16 +1,29 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package limiter provides in-memory rate limiting utilities. +// Package limiter provides in-memory rate limiting utilities without external project dependencies. package limiter import ( - "Wavelet/core/contracts" "context" "sync" "time" ) +// Rate specifies a rate limit of Limit events permitted within a Period. +type Rate struct { + Limit int + Period time.Duration +} + +// Result holds the outcome of an Allow check. +type Result struct { + Allowed bool + Remaining int + ResetAfter time.Duration + RetryAfter time.Duration +} + type memoryEntry struct { timestamps []time.Time lastSeen time.Time @@ -29,10 +42,10 @@ func (e *memoryEntry) prune(cutoff time.Time) { } } -func (e *memoryEntry) calcBlockedResult(limit int, period time.Duration, now time.Time) *contracts.RateLimitResult { +func (e *memoryEntry) calcBlockedResult(limit int, period time.Duration, now time.Time) *Result { currentCount := len(e.timestamps) if currentCount == 0 { - return &contracts.RateLimitResult{ + return &Result{ Allowed: false, Remaining: limit, ResetAfter: period, @@ -46,7 +59,7 @@ func (e *memoryEntry) calcBlockedResult(limit int, period time.Duration, now tim newest := e.timestamps[currentCount-1] resetAfter := max(0, newest.Add(period).Sub(now)) - return &contracts.RateLimitResult{ + return &Result{ Allowed: false, Remaining: limit - currentCount, ResetAfter: resetAfter, @@ -54,7 +67,7 @@ func (e *memoryEntry) calcBlockedResult(limit int, period time.Duration, now tim } } -// MemoryLimiter implements contracts.LimiterService using an in-memory sliding window algorithm. +// MemoryLimiter implements an in-memory sliding window rate limiter. type MemoryLimiter struct { mu sync.Mutex entries map[string]*memoryEntry @@ -68,14 +81,14 @@ func NewMemoryLimiter() *MemoryLimiter { } // Allow checks whether 1 event for key is permitted under rate. -func (m *MemoryLimiter) Allow(ctx context.Context, key string, rate contracts.Rate) (*contracts.RateLimitResult, error) { +func (m *MemoryLimiter) Allow(ctx context.Context, key string, rate Rate) (*Result, error) { return m.AllowN(ctx, key, rate, 1) } // AllowN checks whether n events for key are permitted under rate. -func (m *MemoryLimiter) AllowN(_ context.Context, key string, rate contracts.Rate, n int) (*contracts.RateLimitResult, error) { +func (m *MemoryLimiter) AllowN(_ context.Context, key string, rate Rate, n int) (*Result, error) { if rate.Limit <= 0 || rate.Period <= 0 || n <= 0 { - return &contracts.RateLimitResult{Allowed: true}, nil + return &Result{Allowed: true}, nil } m.mu.Lock() @@ -102,7 +115,7 @@ func (m *MemoryLimiter) AllowN(_ context.Context, key string, rate contracts.Rat remaining := max(0, rate.Limit-len(entry.timestamps)) - return &contracts.RateLimitResult{ + return &Result{ Allowed: true, Remaining: remaining, ResetAfter: rate.Period, diff --git a/backend/pkg/limiter/memory_test.go b/backend/pkg/limiter/memory_test.go index c7220fcb..b6e19312 100644 --- a/backend/pkg/limiter/memory_test.go +++ b/backend/pkg/limiter/memory_test.go @@ -1,10 +1,10 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package limiter +package limiter_test import ( - "Wavelet/core/contracts" + "Wavelet/pkg/limiter" "context" "sync" "testing" @@ -16,9 +16,9 @@ import ( func TestMemoryLimiter_Basic(t *testing.T) { ctx := context.Background() - lim := NewMemoryLimiter() + lim := limiter.NewMemoryLimiter() - rate := contracts.Rate{ + rate := limiter.Rate{ Limit: 3, Period: 100 * time.Millisecond, } @@ -61,9 +61,9 @@ func TestMemoryLimiter_Basic(t *testing.T) { func TestMemoryLimiter_WindowSlide(t *testing.T) { ctx := context.Background() - lim := NewMemoryLimiter() + lim := limiter.NewMemoryLimiter() - rate := contracts.Rate{ + rate := limiter.Rate{ Limit: 2, Period: 50 * time.Millisecond, } @@ -90,9 +90,9 @@ func TestMemoryLimiter_WindowSlide(t *testing.T) { func TestMemoryLimiter_Concurrency(t *testing.T) { ctx := context.Background() - lim := NewMemoryLimiter() + lim := limiter.NewMemoryLimiter() - rate := contracts.Rate{ + rate := limiter.Rate{ Limit: 100, Period: time.Second, } diff --git a/backend/plugins/domain/auth/oauth_rate_limit_test.go b/backend/plugins/domain/auth/oauth_rate_limit_test.go index 93265907..cb806cb8 100644 --- a/backend/plugins/domain/auth/oauth_rate_limit_test.go +++ b/backend/plugins/domain/auth/oauth_rate_limit_test.go @@ -5,10 +5,9 @@ package auth_test import ( "Wavelet/core" - "Wavelet/core/contracts" - "Wavelet/pkg/limiter" "Wavelet/pkg/response" "Wavelet/plugins/domain/auth" + "Wavelet/plugins/infra/cache_memory" database "Wavelet/plugins/infra/database" "context" "encoding/json" @@ -32,11 +31,7 @@ func TestOAuthRateLimiting(t *testing.T) { testDB := setupTestDB(t) require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) - - // Provide in-memory limiter service - memLimiter := limiter.NewMemoryLimiter() - core.Provide[contracts.LimiterService](ctx, memLimiter) - + require.NoError(t, cache_memory.New().Apply(ctx)) require.NoError(t, auth.New().Apply(ctx)) // Create an active OIDC source diff --git a/backend/plugins/domain/message_gateway/model/push.go b/backend/plugins/domain/message_gateway/model/push.go index b16c9e38..1b01deb7 100644 --- a/backend/plugins/domain/message_gateway/model/push.go +++ b/backend/plugins/domain/message_gateway/model/push.go @@ -15,7 +15,12 @@ const ( ChannelCustom = "custom" ChannelEmail = "email" ChannelLark = "lark" + ChannelDingTalk = "dingtalk" ChannelTelegram = "telegram" + ChannelBark = "bark" + ChannelDiscord = "discord" + ChannelSlack = "slack" + ChannelPushover = "pushover" DefaultLevelInfo = "INFO" KeyTitle = "title" KeyContent = "content" @@ -193,7 +198,17 @@ func ListPushDefinitions() []PushDefinition { pushDefMu.RLock() defer pushDefMu.RUnlock() - order := []string{ChannelCustom, ChannelLark, ChannelTelegram, ChannelEmail} + order := []string{ + ChannelCustom, + ChannelLark, + ChannelDingTalk, + ChannelTelegram, + ChannelBark, + ChannelDiscord, + ChannelSlack, + ChannelPushover, + ChannelEmail, + } res := make([]PushDefinition, 0, len(pushDefinitions)) for _, t := range order { if d, ok := pushDefinitions[t]; ok { @@ -215,6 +230,7 @@ func ListPushDefinitions() []PushDefinition { return res } +//nolint:funlen,goconst // Channel definitions registration table func init() { RegisterPushChannelDefinition(PushDefinition{ Type: ChannelCustom, @@ -272,6 +288,30 @@ func init() { }, }) + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelDingTalk, + Name: "钉钉群机器人", + Description: "配置钉钉群自定义机器人的 Webhook 接口投递。", + Fields: []PushField{ + { + Key: KeyURL, + Label: "Webhook 地址", + Type: TypeText, + Required: true, + Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN", + Description: "从钉钉群机器人设置中获取的完整 Webhook URL", + }, + { + Key: KeyToken, + Label: "加签密钥 (Secret) (可选)", + Type: TypeText, + Required: false, + Placeholder: "可选,若机器人启用了安全设置中的加签校验,请在此输入 SEC 开头的密钥", + Description: "钉钉群机器人安全设置中的加签 Secret", + }, + }, + }) + RegisterPushChannelDefinition(PushDefinition{ Type: ChannelTelegram, Name: "Telegram 机器人", @@ -304,6 +344,94 @@ func init() { }, }) + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelBark, + Name: "Bark (iOS 推送)", + Description: "配置 Bark 推送通知至 iPhone / iPad 客户端。", + Fields: []PushField{ + { + Key: KeyToken, + Label: "设备 Key (Device Key)", + Type: TypeText, + Required: true, + Placeholder: "Bark App 首页显示的 Device Key", + Description: "从 Bark App 复制的设备专属 Key", + }, + { + Key: KeyURL, + Label: "Bark 服务器地址 (可选)", + Type: TypeText, + Required: false, + Placeholder: "https://api.day.app", + Description: "Bark 服务器地址,留空默认使用官方公共服务器 https://api.day.app", + }, + { + Key: KeyOther, + Label: "额外配置 JSON (可选)", + Type: TypeTextarea, + Required: false, + Placeholder: "{\"group\": \"Wavelet\", \"sound\": \"minuet\", \"icon\": \"https://...\"}", + Description: "可选的 JSON 配置,支持 group (分组)、sound (铃声)、icon (自定义图标)", + }, + }, + }) + + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelDiscord, + Name: "Discord 频道", + Description: "配置 Discord 频道的 Incoming Webhook 消息推送。", + Fields: []PushField{ + { + Key: KeyURL, + Label: "Webhook 地址", + Type: TypeText, + Required: true, + Placeholder: "https://discord.com/api/webhooks/...", + Description: "从 Discord 频道集成设置中复制的 Webhook URL", + }, + }, + }) + + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelSlack, + Name: "Slack 频道", + Description: "配置 Slack 频道的 Incoming Webhook 消息推送。", + Fields: []PushField{ + { + Key: KeyURL, + Label: "Webhook 地址", + Type: TypeText, + Required: true, + Placeholder: "https://hooks.slack.com/services/...", + Description: "从 Slack 应用配置中复制的 Incoming Webhook URL", + }, + }, + }) + + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelPushover, + Name: "Pushover 推送", + Description: "配置 Pushover 即时推送到手机/桌面客户端。", + Fields: []PushField{ + { + Key: KeyToken, + Label: "应用 Token (App Token)", + Type: TypePassword, + Required: true, + Placeholder: "Pushover 创建应用生成的 API Token / Key", + Description: "从 Pushover 控制台创建的 Application API Token", + }, + { + Key: KeyURL, + Label: "用户 Key (User Key)", + Type: TypeText, + Required: true, + Placeholder: "Pushover 账号主页的 User Key", + Description: "Pushover 个人账号的 User Key", + }, + }, + }) + RegisterPushChannelDefinition(PushDefinition{ Type: ChannelEmail, Name: "邮件推送通道", diff --git a/backend/plugins/domain/message_gateway/push/bark.go b/backend/plugins/domain/message_gateway/push/bark.go new file mode 100644 index 00000000..9a060da5 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/bark.go @@ -0,0 +1,122 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/httppool" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + defaultBarkServer = "https://api.day.app" +) + +func init() { + Register("bark", &BarkPusher{}) +} + +// BarkPusher Bark iOS 客户端通知推送实现 +type BarkPusher struct{} + +type barkPayload struct { + DeviceKey string `json:"device_key"` + Title string `json:"title"` + Body string `json:"body"` + Group string `json:"group,omitempty"` + Sound string `json:"sound,omitempty"` + Icon string `json:"icon,omitempty"` + URL string `json:"url,omitempty"` +} + +// Send 发送 Bark 通知 +func (p *BarkPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) { + deviceKey := cfg.Key + if deviceKey == "" { + deviceKey = cfg.Secret + } + if target != "" { + deviceKey = target + } + if deviceKey == "" { + return "", errors.New("bark: device key is required") + } + + serverURL := strings.TrimRight(cfg.URL, "/") + if serverURL == "" { + serverURL = defaultBarkServer + } + + title := bodyTitle(body) + content := bodyContent(body, "%s: %v", "\n") + + payload := barkPayload{ + DeviceKey: deviceKey, + Title: title, + Body: content, + Group: "Wavelet", + } + + // 提取可选配置 (Ext 字段包含 group, sound, icon 等) + if cfg.Ext != nil { + if g, ok := cfg.Ext["group"].(string); ok && g != "" { + payload.Group = g + } + if s, ok := cfg.Ext["sound"].(string); ok && s != "" { + payload.Sound = s + } + if icon, ok := cfg.Ext["icon"].(string); ok && icon != "" { + payload.Icon = icon + } + } + + reqBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("bark: marshal payload failed: %w", err) + } + + pushURL := fmt.Sprintf("%s/push", serverURL) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, pushURL, bytes.NewReader(reqBytes)) + if err != nil { + return "", fmt.Errorf("bark: create request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json; charset=utf-8") + + client := httppool.NewClient(defaultHTTPClientTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("bark: http request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes)) + upstreamResp := strings.TrimSpace(string(respBody)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return upstreamResp, fmt.Errorf("bark: http status %s", resp.Status) + } + + return upstreamResp, nil +} + +// ValidateConfig 校验 Bark 配置 +func (p *BarkPusher) ValidateConfig(cfg Config) error { + deviceKey := cfg.Key + if deviceKey == "" { + deviceKey = cfg.Secret + } + if deviceKey == "" { + return errors.New("device key is required") + } + if cfg.URL != "" && !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") { + return errors.New("server URL must start with http:// or https://") + } + return nil +} diff --git a/backend/plugins/domain/message_gateway/push/channels_test.go b/backend/plugins/domain/message_gateway/push/channels_test.go new file mode 100644 index 00000000..1c6e8100 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/channels_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDingTalkPusher(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok"}`)) + })) + defer server.Close() + + pusher, err := GetPusher("dingtalk") + if err != nil { + t.Fatalf("failed to get dingtalk pusher: %v", err) + } + + err = pusher.ValidateConfig(Config{URL: "https://oapi.dingtalk.com/robot/send?access_token=test"}) + if err != nil { + t.Errorf("ValidateConfig failed: %v", err) + } + + _, err = pusher.Send(context.Background(), Config{ + URL: server.URL, + Secret: "test_secret", + }, "", map[string]any{ + "title": "Alert", + "content": "Server down", + }, "", nil) + if err != nil { + t.Errorf("Send failed: %v", err) + } +} + +func TestBarkPusher(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) + })) + defer server.Close() + + pusher, err := GetPusher("bark") + if err != nil { + t.Fatalf("failed to get bark pusher: %v", err) + } + + err = pusher.ValidateConfig(Config{Key: "device_key_123"}) + if err != nil { + t.Errorf("ValidateConfig failed: %v", err) + } + + _, err = pusher.Send(context.Background(), Config{ + URL: server.URL, + Key: "device_key_123", + }, "", map[string]any{ + "title": "Alert", + "content": "Bark notification", + }, "", nil) + if err != nil { + t.Errorf("Send failed: %v", err) + } +} + +func TestDiscordPusher(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + pusher, err := GetPusher("discord") + if err != nil { + t.Fatalf("failed to get discord pusher: %v", err) + } + + err = pusher.ValidateConfig(Config{URL: "https://discord.com/api/webhooks/123/abc"}) + if err != nil { + t.Errorf("ValidateConfig failed: %v", err) + } + + _, err = pusher.Send(context.Background(), Config{ + URL: server.URL, + }, "", map[string]any{ + "title": "Discord Title", + "content": "Discord Content", + "level": "WARN", + }, "", nil) + if err != nil { + t.Errorf("Send failed: %v", err) + } +} + +func TestSlackPusher(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + pusher, err := GetPusher("slack") + if err != nil { + t.Fatalf("failed to get slack pusher: %v", err) + } + + err = pusher.ValidateConfig(Config{URL: "https://hooks.slack.com/services/123"}) + if err != nil { + t.Errorf("ValidateConfig failed: %v", err) + } + + _, err = pusher.Send(context.Background(), Config{ + URL: server.URL, + }, "", map[string]any{ + "title": "Slack Title", + "content": "Slack Content", + }, "", nil) + if err != nil { + t.Errorf("Send failed: %v", err) + } +} + +func TestPushoverPusher(t *testing.T) { + pusher, err := GetPusher("pushover") + if err != nil { + t.Fatalf("failed to get pushover pusher: %v", err) + } + + err = pusher.ValidateConfig(Config{Key: "app_token_123"}) + if err != nil { + t.Errorf("ValidateConfig failed: %v", err) + } + + err = pusher.ValidateConfig(Config{}) + if err == nil { + t.Errorf("expected error for empty config, got nil") + } +} diff --git a/backend/plugins/domain/message_gateway/push/dingtalk.go b/backend/plugins/domain/message_gateway/push/dingtalk.go new file mode 100644 index 00000000..0a6dbf53 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/dingtalk.go @@ -0,0 +1,124 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/httppool" + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func init() { + Register("dingtalk", &DingTalkPusher{}) +} + +// DingTalkPusher 钉钉机器人 Webhook 推送实现 +type DingTalkPusher struct{} + +type dingTalkMarkdown struct { + Title string `json:"title"` + Text string `json:"text"` +} + +type dingTalkMessage struct { + MsgType string `json:"msgtype"` + Markdown dingTalkMarkdown `json:"markdown"` +} + +// Send 发送钉钉通知 +func (p *DingTalkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) { + if cfg.URL == "" { + return "", errors.New("dingtalk: webhook URL is required") + } + + title := bodyTitle(body) + content := bodyContent(body, "**%s**: %v", "\n\n") + + webhookURL := cfg.URL + // 如果配置了签名 Secret (Key 或 Secret 字段),计算时间戳与签名 + secret := cfg.Secret + if secret == "" { + secret = cfg.Key + } + if secret != "" { + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + stringToSign := timestamp + "\n" + secret + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(stringToSign)) + signature := url.QueryEscape(base64.StdEncoding.EncodeToString(mac.Sum(nil))) + + sep := "?" + if strings.Contains(webhookURL, "?") { + sep = "&" + } + webhookURL = fmt.Sprintf("%s%stimestamp=%s&sign=%s", webhookURL, sep, timestamp, signature) + } + + markdownText := fmt.Sprintf("### %s\n\n%s", title, content) + msg := dingTalkMessage{ + MsgType: "markdown", + Markdown: dingTalkMarkdown{ + Title: title, + Text: markdownText, + }, + } + + reqBytes, err := json.Marshal(msg) + if err != nil { + return "", fmt.Errorf("dingtalk: marshal message failed: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(reqBytes)) + if err != nil { + return "", fmt.Errorf("dingtalk: create request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + client := httppool.NewClient(defaultHTTPClientTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("dingtalk: http request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes)) + upstreamResp := strings.TrimSpace(string(respBody)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return upstreamResp, fmt.Errorf("dingtalk: http status %s", resp.Status) + } + + var dingResp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(respBody, &dingResp); err == nil && dingResp.ErrCode != 0 { + return upstreamResp, fmt.Errorf("dingtalk: api error code %d: %s", dingResp.ErrCode, dingResp.ErrMsg) + } + + return upstreamResp, nil +} + +// ValidateConfig 校验钉钉配置 +func (p *DingTalkPusher) ValidateConfig(cfg Config) error { + if cfg.URL == "" { + return errors.New("webhook URL is required") + } + if !strings.HasPrefix(cfg.URL, "https://") { + return errors.New("webhook URL must use https:// protocol") + } + return nil +} diff --git a/backend/plugins/domain/message_gateway/push/discord.go b/backend/plugins/domain/message_gateway/push/discord.go new file mode 100644 index 00000000..2f2a0b7a --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/discord.go @@ -0,0 +1,108 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/httppool" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + discordColorInfo = 3447003 // Blue + discordColorWarn = 15105570 // Orange + discordColorErr = 15158332 // Red +) + +func init() { + Register("discord", &DiscordPusher{}) +} + +// DiscordPusher Discord Webhook 机器人推送实现 +type DiscordPusher struct{} + +type discordEmbed struct { + Title string `json:"title"` + Description string `json:"description"` + Color int `json:"color"` +} + +type discordPayload struct { + Username string `json:"username,omitempty"` + Embeds []discordEmbed `json:"embeds"` +} + +// Send 发送 Discord 通知 +func (p *DiscordPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) { + if cfg.URL == "" { + return "", errors.New("discord: webhook URL is required") + } + + title := bodyTitle(body) + content := bodyContent(body, "**%s**: %v", "\n") + level := bodyLevel(body) + + color := discordColorInfo + switch strings.ToUpper(level) { + case "WARN", "WARNING": + color = discordColorWarn + case "ERROR", "FATAL": + color = discordColorErr + } + + payload := discordPayload{ + Username: "Wavelet System", + Embeds: []discordEmbed{ + { + Title: title, + Description: content, + Color: color, + }, + }, + } + + reqBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("discord: marshal payload failed: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBytes)) + if err != nil { + return "", fmt.Errorf("discord: create request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + client := httppool.NewClient(defaultHTTPClientTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("discord: http request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes)) + upstreamResp := strings.TrimSpace(string(respBody)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return upstreamResp, fmt.Errorf("discord: http status %s", resp.Status) + } + + return upstreamResp, nil +} + +// ValidateConfig 校验 Discord 配置 +func (p *DiscordPusher) ValidateConfig(cfg Config) error { + if cfg.URL == "" { + return errors.New("webhook URL is required") + } + if !strings.HasPrefix(cfg.URL, "https://") { + return errors.New("webhook URL must start with https://") + } + return nil +} diff --git a/backend/plugins/domain/message_gateway/push/push.go b/backend/plugins/domain/message_gateway/push/push.go index e035d62a..e9739346 100644 --- a/backend/plugins/domain/message_gateway/push/push.go +++ b/backend/plugins/domain/message_gateway/push/push.go @@ -15,6 +15,7 @@ const ( defaultTitle = "系统通知" levelInfo = "INFO" defaultHTTPClientTimeout = 10 * time.Second + maxResponseBodyBytes = 4096 ) // Config 基础通知渠道配置 diff --git a/backend/plugins/domain/message_gateway/push/pushover.go b/backend/plugins/domain/message_gateway/push/pushover.go new file mode 100644 index 00000000..18067c04 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/pushover.go @@ -0,0 +1,93 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/httppool" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + pushoverAPIEndpoint = "https://api.pushover.net/1/messages.json" +) + +func init() { + Register("pushover", &PushoverPusher{}) +} + +// PushoverPusher Pushover 移动端推送实现 +type PushoverPusher struct{} + +// Send 发送 Pushover 通知 +func (p *PushoverPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) { + appToken := cfg.Key + if appToken == "" { + appToken = cfg.Secret + } + if appToken == "" { + return "", errors.New("pushover: app token is required") + } + + userKey := cfg.URL + if userKey == "" && cfg.Ext != nil { + if k, ok := cfg.Ext["user_key"].(string); ok { + userKey = k + } + } + if target != "" { + userKey = target + } + if userKey == "" { + return "", errors.New("pushover: user key is required") + } + + title := bodyTitle(body) + content := bodyContent(body, "%s: %v", "\n") + + formData := url.Values{} + formData.Set("token", appToken) + formData.Set("user", userKey) + formData.Set("title", title) + formData.Set("message", content) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, pushoverAPIEndpoint, strings.NewReader(formData.Encode())) + if err != nil { + return "", fmt.Errorf("pushover: create request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := httppool.NewClient(defaultHTTPClientTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("pushover: http request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes)) + upstreamResp := strings.TrimSpace(string(respBody)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return upstreamResp, fmt.Errorf("pushover: http status %s", resp.Status) + } + + return upstreamResp, nil +} + +// ValidateConfig 校验 Pushover 配置 +func (p *PushoverPusher) ValidateConfig(cfg Config) error { + appToken := cfg.Key + if appToken == "" { + appToken = cfg.Secret + } + if appToken == "" { + return errors.New("app token is required") + } + return nil +} diff --git a/backend/plugins/domain/message_gateway/push/slack.go b/backend/plugins/domain/message_gateway/push/slack.go new file mode 100644 index 00000000..a9c58fb0 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/slack.go @@ -0,0 +1,80 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/httppool" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +func init() { + Register("slack", &SlackPusher{}) +} + +// SlackPusher Slack Webhook 推送实现 +type SlackPusher struct{} + +type slackPayload struct { + Text string `json:"text"` +} + +// Send 发送 Slack 通知 +func (p *SlackPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) { + if cfg.URL == "" { + return "", errors.New("slack: webhook URL is required") + } + + title := bodyTitle(body) + content := bodyContent(body, "*%s*: %v", "\n") + + text := fmt.Sprintf("*%s*\n%s", title, content) + payload := slackPayload{ + Text: text, + } + + reqBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("slack: marshal payload failed: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBytes)) + if err != nil { + return "", fmt.Errorf("slack: create request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + client := httppool.NewClient(defaultHTTPClientTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("slack: http request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes)) + upstreamResp := strings.TrimSpace(string(respBody)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return upstreamResp, fmt.Errorf("slack: http status %s", resp.Status) + } + + return upstreamResp, nil +} + +// ValidateConfig 校验 Slack 配置 +func (p *SlackPusher) ValidateConfig(cfg Config) error { + if cfg.URL == "" { + return errors.New("webhook URL is required") + } + if !strings.HasPrefix(cfg.URL, "https://") { + return errors.New("webhook URL must start with https://") + } + return nil +} diff --git a/backend/plugins/domain/user/login_rate_limit_test.go b/backend/plugins/domain/user/login_rate_limit_test.go index 966c8a51..4b5f1f89 100644 --- a/backend/plugins/domain/user/login_rate_limit_test.go +++ b/backend/plugins/domain/user/login_rate_limit_test.go @@ -6,10 +6,10 @@ package user_test import ( "Wavelet/core" "Wavelet/core/contracts" - "Wavelet/pkg/limiter" "Wavelet/pkg/response" "Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/user" + "Wavelet/plugins/infra/cache_memory" database "Wavelet/plugins/infra/database" "bytes" "context" @@ -34,11 +34,7 @@ func TestUserLoginRateLimiting(t *testing.T) { testDB := setupTestDB(t) require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) - - // Provide in-memory limiter service - memLimiter := limiter.NewMemoryLimiter() - core.Provide[contracts.LimiterService](ctx, memLimiter) - + require.NoError(t, cache_memory.New().Apply(ctx)) require.NoError(t, auth.New().Apply(ctx)) require.NoError(t, user.New().Apply(ctx)) diff --git a/backend/plugins/infra/cache/limiter.go b/backend/plugins/infra/cache/limiter.go index 3a843e5c..13a9c8a8 100644 --- a/backend/plugins/infra/cache/limiter.go +++ b/backend/plugins/infra/cache/limiter.go @@ -5,6 +5,7 @@ package cache import ( "Wavelet/core/contracts" + "Wavelet/pkg/limiter" "context" "github.com/go-redis/redis_rate/v10" @@ -57,3 +58,43 @@ func (r *redisLimiterImpl) AllowN(ctx context.Context, key string, rate contract func (r *redisLimiterImpl) Reset(ctx context.Context, key string) error { return r.limiter.Reset(ctx, r.prefixedKey(key)) } + +type memoryLimiterFallback struct { + limiter *limiter.MemoryLimiter +} + +func newMemoryLimiterFallback() contracts.LimiterService { + return &memoryLimiterFallback{ + limiter: limiter.NewMemoryLimiter(), + } +} + +func (m *memoryLimiterFallback) Allow(ctx context.Context, key string, rate contracts.Rate) (*contracts.RateLimitResult, error) { + res, err := m.limiter.Allow(ctx, key, limiter.Rate{Limit: rate.Limit, Period: rate.Period}) + if err != nil { + return nil, err + } + return &contracts.RateLimitResult{ + Allowed: res.Allowed, + Remaining: res.Remaining, + ResetAfter: res.ResetAfter, + RetryAfter: res.RetryAfter, + }, nil +} + +func (m *memoryLimiterFallback) AllowN(ctx context.Context, key string, rate contracts.Rate, n int) (*contracts.RateLimitResult, error) { + res, err := m.limiter.AllowN(ctx, key, limiter.Rate{Limit: rate.Limit, Period: rate.Period}, n) + if err != nil { + return nil, err + } + return &contracts.RateLimitResult{ + Allowed: res.Allowed, + Remaining: res.Remaining, + ResetAfter: res.ResetAfter, + RetryAfter: res.RetryAfter, + }, nil +} + +func (m *memoryLimiterFallback) Reset(ctx context.Context, key string) error { + return m.limiter.Reset(ctx, key) +} diff --git a/backend/plugins/infra/cache/plugin.go b/backend/plugins/infra/cache/plugin.go index d5717688..b2f4f9d7 100644 --- a/backend/plugins/infra/cache/plugin.go +++ b/backend/plugins/infra/cache/plugin.go @@ -8,7 +8,6 @@ import ( "Wavelet/core" "Wavelet/core/contracts" "Wavelet/pkg/cache/ram" - "Wavelet/pkg/limiter" "Wavelet/pkg/util" "context" "encoding/json" @@ -145,7 +144,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { if redisClient != nil { limiterSvc = newRedisLimiter(redisClient, p.keyPrefix) } else { - limiterSvc = limiter.NewMemoryLimiter() + limiterSvc = newMemoryLimiterFallback() } core.Provide[contracts.LimiterService](ctx, limiterSvc) diff --git a/backend/plugins/infra/cache_memory/limiter.go b/backend/plugins/infra/cache_memory/limiter.go new file mode 100644 index 00000000..e847d0b3 --- /dev/null +++ b/backend/plugins/infra/cache_memory/limiter.go @@ -0,0 +1,56 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache_memory + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/limiter" + "context" +) + +type memoryLimiterAdapter struct { + limiter *limiter.MemoryLimiter +} + +func newMemoryLimiterService() contracts.LimiterService { + return &memoryLimiterAdapter{ + limiter: limiter.NewMemoryLimiter(), + } +} + +func (a *memoryLimiterAdapter) Allow(ctx context.Context, key string, rate contracts.Rate) (*contracts.RateLimitResult, error) { + res, err := a.limiter.Allow(ctx, key, limiter.Rate{ + Limit: rate.Limit, + Period: rate.Period, + }) + if err != nil { + return nil, err + } + return &contracts.RateLimitResult{ + Allowed: res.Allowed, + Remaining: res.Remaining, + ResetAfter: res.ResetAfter, + RetryAfter: res.RetryAfter, + }, nil +} + +func (a *memoryLimiterAdapter) AllowN(ctx context.Context, key string, rate contracts.Rate, n int) (*contracts.RateLimitResult, error) { + res, err := a.limiter.AllowN(ctx, key, limiter.Rate{ + Limit: rate.Limit, + Period: rate.Period, + }, n) + if err != nil { + return nil, err + } + return &contracts.RateLimitResult{ + Allowed: res.Allowed, + Remaining: res.Remaining, + ResetAfter: res.ResetAfter, + RetryAfter: res.RetryAfter, + }, nil +} + +func (a *memoryLimiterAdapter) Reset(ctx context.Context, key string) error { + return a.limiter.Reset(ctx, key) +} diff --git a/backend/plugins/infra/cache_memory/plugin.go b/backend/plugins/infra/cache_memory/plugin.go index 660ba67b..249ef9ad 100644 --- a/backend/plugins/infra/cache_memory/plugin.go +++ b/backend/plugins/infra/cache_memory/plugin.go @@ -7,7 +7,6 @@ package cache_memory import ( "Wavelet/core" "Wavelet/core/contracts" - "Wavelet/pkg/limiter" ) const defaultRAMCapacity = 10000 @@ -80,6 +79,6 @@ func (p *Plugin) Apply(ctx *core.Context) error { } core.Provide[contracts.CacheService](ctx, svc) - core.Provide[contracts.LimiterService](ctx, limiter.NewMemoryLimiter()) + core.Provide[contracts.LimiterService](ctx, newMemoryLimiterService()) return nil } diff --git a/frontend/app/(main)/admin/push/components/settings-tab.tsx b/frontend/app/(main)/admin/push/components/settings-tab.tsx index 1d925556..e60c7e51 100644 --- a/frontend/app/(main)/admin/push/components/settings-tab.tsx +++ b/frontend/app/(main)/admin/push/components/settings-tab.tsx @@ -156,6 +156,20 @@ export function SettingsTab() { 2, ), ); + } else if (newType === 'bark') { + setChannelUrl('https://api.day.app'); + setChannelOther( + JSON.stringify( + { + group: 'Wavelet', + }, + null, + 2, + ), + ); + } else if (newType === 'telegram') { + setChannelUrl('https://api.telegram.org'); + setChannelOther(''); } else { setChannelOther(''); } @@ -224,12 +238,28 @@ export function SettingsTab() { } } - // 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议) - if (channelType !== 'email') { + // 协议安全校验(对特定 Webhook 服务且配置了地址时,强制 HTTPS 协议) + const httpsRequiredChannels = [ + 'custom', + 'lark', + 'dingtalk', + 'discord', + 'slack', + 'telegram', + ]; + if (httpsRequiredChannels.includes(channelType)) { if (channelUrl && !channelUrl.startsWith('https://')) { toast.error(t('httpsRequired')); return; } + } else if (channelType === 'bark' && channelUrl) { + if ( + !channelUrl.startsWith('https://') && + !channelUrl.startsWith('http://') + ) { + toast.error('Bark 服务器地址必须以 http:// 或 https:// 开头'); + return; + } } // JSON 结构格式校验 @@ -247,6 +277,13 @@ export function SettingsTab() { toast.error(t('larkTemplateFormat')); return; } + } else if (channelType === 'bark' && channelOther) { + try { + JSON.parse(channelOther); + } catch { + toast.error('Bark 额外配置必须是合法的 JSON 格式'); + return; + } } if (editingChannel) { diff --git a/scripts/check_cordis_architecture.sh b/scripts/check_cordis_architecture.sh index 248e3a1f..3259546a 100755 --- a/scripts/check_cordis_architecture.sh +++ b/scripts/check_cordis_architecture.sh @@ -120,7 +120,18 @@ else log_pass "backend/pkg/ 零插件依赖" fi -# 3.2 pkg/util/ 严禁导入 Gin / ORM / Session 框架 +# 3.2 pkg/ 严禁导入 core/ (微内核与契约层) +PKG_CORE_IMPORTS=$(rg -n "\"${MODULE}/core(/|\")" \ + "${BACKEND_DIR}/pkg/" --glob '*.go' -g '!*testhelper*' -g '!*_test.go' || true) + +if [ -n "${PKG_CORE_IMPORTS}" ]; then + log_fail "backend/pkg/ 属于通用基础库,严禁依赖项目上层内核或契约 (${MODULE}/core/*):" + echo "${PKG_CORE_IMPORTS}" >&2 +else + log_pass "backend/pkg/ 零 core/ 依赖" +fi + +# 3.3 pkg/util/ 严禁导入 Gin / ORM / Session 框架 UTIL_FRAMEWORK_IMPORTS=$(rg -n '"gorm.io/gorm"|"github.com/gorilla/sessions"|"github.com/gin-gonic/gin"' \ "${BACKEND_DIR}/pkg/util/" --glob '*.go' -g '!*_test.go' || true)