ci(arch): forbid pkg packages from depending on project core packages

This commit is contained in:
ryan
2026-09-02 22:22:04 +08:00
parent 9632604958
commit 90f3efdd50
19 changed files with 985 additions and 40 deletions
+1 -1
View File
@@ -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`。
+23 -10
View File
@@ -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,
+8 -8
View File
@@ -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,
}
@@ -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
@@ -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: "邮件推送通道",
@@ -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
}
@@ -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")
}
}
@@ -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
}
@@ -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
}
@@ -15,6 +15,7 @@ const (
defaultTitle = "系统通知"
levelInfo = "INFO"
defaultHTTPClientTimeout = 10 * time.Second
maxResponseBodyBytes = 4096
)
// Config 基础通知渠道配置
@@ -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
}
@@ -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
}
@@ -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))
+41
View File
@@ -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)
}
+1 -2
View File
@@ -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)
@@ -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)
}
+1 -2
View File
@@ -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
}
@@ -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) {
+12 -1
View File
@@ -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)