mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
ci(arch): forbid pkg packages from depending on project core packages
This commit is contained in:
@@ -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`。
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user