feat(push): implement system notification and push framework

This commit is contained in:
ryan
2026-06-14 16:52:06 +08:00
parent aee457093d
commit cb018b3b60
60 changed files with 8756 additions and 160 deletions
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
)
const (
// TypeCustom 自定义消息通道类型
TypeCustom = "custom"
// TypeEmail 邮件推送消息通道类型
TypeEmail = "email"
)
// PushChannel 消息通道模型
type PushChannel struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` // 通道名称,仅英文字母和下划线,唯一
Description string `json:"description" gorm:"size:255"` // 备注
Type string `json:"type" gorm:"size:50;not null;default:'custom'"` // 通道类型:custom, lark, email
Token string `json:"token" gorm:"size:100"` // 鉴权令牌或发信用户名等
URL string `json:"url" gorm:"type:text;not null"` // 请求地址,HTTPS 协议或 SMTP 地址
Other string `json:"other" gorm:"type:text;not null"` // 请求体/SMTP 密码等
Enabled bool `json:"enabled" gorm:"index;not null;default:true"` // 通道是否启用
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 指定 GORM 表名
func (PushChannel) TableName() string {
return "w_push_channels"
}
var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
// Validate 参数合法性与 JSON 格式校验
func (pc *PushChannel) Validate() error {
pc.Name = strings.TrimSpace(pc.Name)
pc.URL = strings.TrimSpace(pc.URL)
pc.Other = strings.TrimSpace(pc.Other)
pc.Type = strings.TrimSpace(pc.Type)
if pc.Type == "" {
pc.Type = TypeCustom
}
if pc.Name == "" {
return errors.New("channel name is required")
}
if !nameRegex.MatchString(pc.Name) {
return errors.New("channel name can only contain letters, numbers, and underscores")
}
if pc.Type != TypeEmail && pc.URL == "" {
return errors.New("request URL/address is required")
}
// For custom and lark, we must enforce https:// URL prefix for security.
// For email, it is an SMTP host:port, so no need for https:// prefix.
if pc.Type != TypeEmail && !strings.HasPrefix(pc.URL, "https://") {
return errors.New("request URL must use HTTPS protocol for security reasons")
}
switch pc.Type {
case TypeCustom:
if pc.Other == "" {
return errors.New("payload schema (request body) is required")
}
return validateJSON(pc.Other)
case TypeEmail:
// Email channel SMTP configs fall back to global settings, so they are not required to be filled.
}
return nil
}
func validateJSON(s string) error {
var jsonTest map[string]any
if err := json.Unmarshal([]byte(s), &jsonTest); err == nil {
return nil
}
var jsonArr []any
if err := json.Unmarshal([]byte(s), &jsonArr); err == nil {
return nil
}
return errors.New("payload schema must be a valid JSON format")
}
// GetPushChannelByName 根据名称获取消息通道
func GetPushChannelByName(ctx context.Context, name string) (*PushChannel, error) {
var channel PushChannel
err := db.DB(ctx).Where("name = ?", name).First(&channel).Error
if err != nil {
return nil, err
}
return &channel, nil
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
)
// PushEvent 系统通知事件模型
type PushEvent struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"` // 如 admin_login
Name string `json:"name" gorm:"size:100;not null"` // 如 管理员登录
Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"` // 推送渠道列表,如 ["lark"]
Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"` // 推送目标用户/邮箱列表
Template string `json:"template" gorm:"type:text;not null"` // 消息模板 JSON
Enabled bool `json:"enabled" gorm:"index;not null;default:false"` // 是否启用
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 指定 GORM 表名
func (PushEvent) TableName() string {
return "w_push_events"
}
// Validate 基础校验
func (pe *PushEvent) Validate() error {
pe.EventKey = strings.TrimSpace(pe.EventKey)
pe.Name = strings.TrimSpace(pe.Name)
pe.Template = strings.TrimSpace(pe.Template)
if pe.EventKey == "" {
return errors.New("event key is required")
}
if pe.Name == "" {
return errors.New("event name is required")
}
if pe.Template == "" {
return errors.New("event template is required")
}
return nil
}
// GetActivePushEventByKey 获取启用的通知事件
func GetActivePushEventByKey(ctx context.Context, key string) (*PushEvent, error) {
var event PushEvent
err := db.DB(ctx).Where("event_key = ? AND enabled = ?", key, true).First(&event).Error
if err != nil {
return nil, err
}
return &event, nil
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"time"
)
// PushHistory 推送日志/历史实体
type PushHistory struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"size:80;not null;index"`
Channel string `json:"channel" gorm:"size:50;not null"`
Target string `json:"target" gorm:"size:255;not null"`
Title string `json:"title" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text;not null"`
Level string `json:"level" gorm:"size:20;not null"`
Status string `json:"status" gorm:"size:20;not null"` // success / failed
ErrorMsg string `json:"error_msg" gorm:"type:text"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
}
// TableName 指定表名
func (PushHistory) TableName() string {
return "w_push_histories"
}
+1
View File
@@ -49,6 +49,7 @@ const (
ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时,0表示浏览器关闭后自动退出登录,-1表示永不过期)
ConfigKeyUpdateUpstreamRepository = "update_upstream_repository" // GitHub Actions Release 上游仓库
ConfigKeyStorageConfig = "storage_config" // 文件存储配置 (JSON)
ConfigKeyPushConfig = "push_config" // 通知推送渠道配置 (JSON)
)
const (
+12 -12
View File
@@ -56,7 +56,7 @@ func TestCreateTaskExecution(t *testing.T) {
execution := &TaskExecution{
TaskID: "manual_cleanup_123",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
Retryable: true,
@@ -81,7 +81,7 @@ func TestGetTaskExecutionByTaskID(t *testing.T) {
// 创建记录
execution := &TaskExecution{
TaskID: "test_task_id_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
Retryable: true,
@@ -112,7 +112,7 @@ func TestGetTaskExecutionByID(t *testing.T) {
execution := &TaskExecution{
TaskID: "test_by_id_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
TriggeredBy: "system",
@@ -134,7 +134,7 @@ func TestUpdateTaskExecution(t *testing.T) {
// 创建记录
execution := &TaskExecution{
TaskID: "test_update_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
TriggeredBy: "manual",
@@ -178,7 +178,7 @@ func TestUpdateTaskExecutionFailed(t *testing.T) {
execution := &TaskExecution{
TaskID: "test_fail_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
Retryable: true,
@@ -212,7 +212,7 @@ func TestUpdateTaskExecutionDoesNotPersistBufferedLog(t *testing.T) {
execution := &TaskExecution{
TaskID: "test_omit_log_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
TriggeredBy: "manual",
@@ -249,7 +249,7 @@ func TestAppendTaskExecutionLog(t *testing.T) {
execution := &TaskExecution{
TaskID: "test_log_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
TriggeredBy: "manual",
@@ -334,7 +334,7 @@ func TestGetTaskExecutionLogPrefersRedis(t *testing.T) {
execution := &TaskExecution{
TaskID: "redis_priority_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusRunning,
Log: "数据库旧日志",
@@ -358,10 +358,10 @@ func TestListTaskExecutions(t *testing.T) {
// 创建多条记录,包含不同状态和类型
records := []*TaskExecution{
{TaskID: "list_001", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusSucceeded, TriggeredBy: "manual"},
{TaskID: "list_002", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusFailed, TriggeredBy: "system"},
{TaskID: "list_001", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: TaskExecutionStatusSucceeded, TriggeredBy: "manual"},
{TaskID: "list_002", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: TaskExecutionStatusFailed, TriggeredBy: "system"},
{TaskID: "list_003", TaskType: "other:task", TaskName: "其他任务", Status: TaskExecutionStatusPending, TriggeredBy: "manual"},
{TaskID: "list_004", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusRunning, TriggeredBy: "manual"},
{TaskID: "list_004", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: TaskExecutionStatusRunning, TriggeredBy: "manual"},
{TaskID: "list_005", TaskType: "other:task", TaskName: "其他任务", Status: TaskExecutionStatusSucceeded, TriggeredBy: "system"},
}
for _, r := range records {
@@ -409,7 +409,7 @@ func TestListTaskExecutions(t *testing.T) {
assert.NotEqual(t, items[0].ID, items2[0].ID)
// 状态 + 类型组合筛选
items, total, err = ListTaskExecutions(ctx, ListTaskExecutionsRequest{Status: "succeeded", TaskType: "upload:cleanup_unused", Page: 1, PageSize: 10})
items, total, err = ListTaskExecutions(ctx, ListTaskExecutionsRequest{Status: "succeeded", TaskType: "system:cleanup", Page: 1, PageSize: 10})
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, "list_001", items[0].TaskID)