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
@@ -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
}