mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
9fa38dcfdc
- Implement cached queries in GetActivePushEventByKey and GetActivePushChannelByName. - Set TTL for cached items to 24 hours via activePushEventCacheTTL and activePushChannelCacheTTL constants. - Implement GORM hooks (AfterSave and AfterDelete) on PushEvent and PushChannel to auto-evict Redis caches, guaranteeing cache consistency. - Evict Redis caches manually inside API handlers for Create/Update/Delete/Toggle event/channel endpoints. - Update events.go to query models through the new caching methods.
503 lines
15 KiB
Go
503 lines
15 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// Package push defines push notification HTTP routes.
|
|
package push
|
|
|
|
import ("context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/db"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/task"
|
|
"github.com/Rain-kl/Wavelet/pkg/push"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/common/response")
|
|
|
|
// UpdateEventRequest 更新事件请求参数
|
|
type UpdateEventRequest struct {
|
|
Channels []string `json:"channels"`
|
|
Targets []string `json:"targets"`
|
|
Template string `json:"template" binding:"required"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// TestPushRequest 测试推送通道请求参数
|
|
type TestPushRequest struct {
|
|
Config push.Config `json:"config" binding:"required"`
|
|
Target string `json:"target"`
|
|
}
|
|
|
|
// SyncEvents automatically registers/updates built-in events in the database.
|
|
func SyncEvents(ctx context.Context) error {
|
|
for _, meta := range BuiltInEvents {
|
|
var event model.PushEvent
|
|
err := db.DB(ctx).Where("event_key = ?", meta.Key).First(&event).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
var defaultTemplateStr string
|
|
if defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate); err == nil {
|
|
defaultTemplateStr = string(defaultTemplateBytes)
|
|
}
|
|
event = model.PushEvent{
|
|
EventKey: meta.Key,
|
|
Name: meta.Name,
|
|
Channels: []string{},
|
|
Targets: []string{},
|
|
Template: defaultTemplateStr,
|
|
Enabled: false,
|
|
}
|
|
if err := db.DB(ctx).Create(&event).Error; err != nil {
|
|
return err
|
|
}
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListEvents 获取通知事件列表
|
|
// @Summary 获取所有通知事件
|
|
// @Description 返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限
|
|
// @Tags admin-push
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Success 200 {object} response.Any{data=[]model.PushEvent} "通知事件列表"
|
|
// @Router /api/v1/admin/push/events [get]
|
|
func ListEvents(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
var events []model.PushEvent
|
|
if err := db.DB(ctx).Order("created_at DESC").Find(&events).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.OK(events))
|
|
}
|
|
|
|
// CreateEventRequest 创建事件请求参数
|
|
type CreateEventRequest struct {
|
|
EventKey string `json:"event_key"`
|
|
TaskType string `json:"task_type"` // 关联的异步任务类型
|
|
Channels []string `json:"channels"`
|
|
Targets []string `json:"targets"`
|
|
Template string `json:"template"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
func findBuiltInEvent(key string) (EventMetadata, bool) {
|
|
for _, meta := range BuiltInEvents {
|
|
if meta.Key == key {
|
|
return meta, true
|
|
}
|
|
}
|
|
return EventMetadata{}, false
|
|
}
|
|
|
|
// ListBuiltInEvents 获取内置通知事件列表
|
|
// @Summary 获取所有内置通知事件
|
|
// @Description 返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限
|
|
// @Tags admin-push
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Success 200 {object} response.Any{data=[]EventMetadata} "内置通知事件列表"
|
|
// @Router /api/v1/admin/push/events/builtin [get]
|
|
func ListBuiltInEvents(c *gin.Context) {
|
|
c.JSON(http.StatusOK, response.OK(BuiltInEvents))
|
|
}
|
|
|
|
func getEventInfo(req CreateEventRequest) (string, string, []byte, error) {
|
|
if req.TaskType != "" {
|
|
// 1. 检查关联任务是否存在
|
|
meta := task.GetTaskMetaByAsynqTask(req.TaskType)
|
|
if meta == nil {
|
|
return "", "", nil, errors.New("unsupported task type")
|
|
}
|
|
eventKey := "task_completed:" + req.TaskType
|
|
eventName := "任务完成: " + meta.Name
|
|
|
|
defaultTemplate := NotificationMessage{
|
|
Title: "任务完成: " + meta.Name,
|
|
Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。",
|
|
Level: defaultLevelInfo,
|
|
}
|
|
defaultTemplateBytes, err := json.Marshal(defaultTemplate)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
return eventKey, eventName, defaultTemplateBytes, nil
|
|
}
|
|
|
|
if req.EventKey == "" {
|
|
return "", "", nil, errors.New("either event_key or task_type must be provided")
|
|
}
|
|
|
|
// 1. 检查内置事件是否存在
|
|
meta, found := findBuiltInEvent(req.EventKey)
|
|
if !found {
|
|
return "", "", nil, errors.New("unsupported built-in event key")
|
|
}
|
|
|
|
defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
return req.EventKey, meta.Name, defaultTemplateBytes, nil
|
|
}
|
|
|
|
// CreateEvent 创建通知事件
|
|
// @Summary 创建通知事件
|
|
// @Description 绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限
|
|
// @Tags admin-push
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Param request body CreateEventRequest true "创建参数"
|
|
// @Success 200 {object} response.Any{data=model.PushEvent} "创建成功"
|
|
// @Router /api/v1/admin/push/events [post]
|
|
func CreateEvent(c *gin.Context) {
|
|
var req CreateEventRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
eventKey, eventName, defaultTemplateBytes, err := getEventInfo(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
// 2. 检查是否已经创建过该事件的配置
|
|
var count int64
|
|
if err := db.DB(ctx).Model(&model.PushEvent{}).Where("event_key = ?", eventKey).Count(&count).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
return
|
|
}
|
|
if count > 0 {
|
|
c.JSON(http.StatusBadRequest, response.Err("this notification event is already configured"))
|
|
return
|
|
}
|
|
|
|
// 3. 模板处理
|
|
templateStr := strings.TrimSpace(req.Template)
|
|
if templateStr == "" {
|
|
templateStr = string(defaultTemplateBytes)
|
|
} else {
|
|
var tempMap map[string]any
|
|
if err := json.Unmarshal([]byte(templateStr), &tempMap); err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err("custom template is not a valid JSON format"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// 4. 创建事件记录
|
|
channels := req.Channels
|
|
if channels == nil {
|
|
channels = []string{}
|
|
}
|
|
targets := req.Targets
|
|
if targets == nil {
|
|
targets = []string{}
|
|
}
|
|
|
|
event := model.PushEvent{
|
|
EventKey: eventKey,
|
|
Name: eventName,
|
|
TaskType: req.TaskType,
|
|
Channels: channels,
|
|
Targets: targets,
|
|
Template: templateStr,
|
|
Enabled: req.Enabled,
|
|
}
|
|
|
|
if err := event.Validate(); err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
if err := db.DB(ctx).Create(&event).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
// 缓存一致性:清除旧事件缓存
|
|
model.DeleteActivePushEventCache(ctx, event.EventKey)
|
|
|
|
c.JSON(http.StatusOK, response.OK(event))
|
|
}
|
|
|
|
// DeleteEvent 删除通知事件配置
|
|
// @Summary 删除通知事件配置
|
|
// @Description 删除数据库中的特定通知事件配置,需要管理员权限
|
|
// @Tags admin-push
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Param id path int true "事件 ID"
|
|
// @Success 200 {object} response.Any{data=string} "删除成功"
|
|
// @Router /api/v1/admin/push/events/{id} [delete]
|
|
func DeleteEvent(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err("invalid event id"))
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
var event model.PushEvent
|
|
if err := db.DB(ctx).First(&event, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusNotFound, response.Err("notification event not found"))
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := db.DB(ctx).Delete(&event).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
// 缓存一致性:清除事件缓存
|
|
model.DeleteActivePushEventCache(ctx, event.EventKey)
|
|
|
|
c.JSON(http.StatusOK, response.OKNil())
|
|
}
|
|
|
|
// UpdateEvent 更新通知事件
|
|
// @Summary 更新通知事件
|
|
// @Description 更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限
|
|
// @Tags admin-push
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Param id path int true "事件 ID"
|
|
// @Param request body push.UpdateEventRequest true "更新参数"
|
|
// @Success 200 {object} response.Any{data=string} "修改成功"
|
|
// @Router /api/v1/admin/push/events/{id} [put]
|
|
func UpdateEvent(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err("invalid event id"))
|
|
return
|
|
}
|
|
|
|
var req UpdateEventRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
var event model.PushEvent
|
|
if err := db.DB(c.Request.Context()).First(&event, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusNotFound, response.Err("notification event not found"))
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
}
|
|
return
|
|
}
|
|
|
|
event.Channels = req.Channels
|
|
event.Targets = req.Targets
|
|
event.Template = req.Template
|
|
event.Enabled = req.Enabled
|
|
|
|
if err := event.Validate(); err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
if err := db.DB(c.Request.Context()).Save(&event).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
// 缓存一致性:清除事件缓存
|
|
model.DeleteActivePushEventCache(c.Request.Context(), event.EventKey)
|
|
|
|
c.JSON(http.StatusOK, response.OKNil())
|
|
}
|
|
|
|
// ToggleEvent 快捷切换通知事件启用状态
|
|
// @Summary 快捷切换通知事件启用状态
|
|
// @Description 启用或禁用指定的通知事件
|
|
// @Tags admin-push
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Param id path int true "事件 ID"
|
|
// @Success 200 {object} response.Any{data=string} "切换成功"
|
|
// @Router /api/v1/admin/push/events/{id}/toggle [post]
|
|
func ToggleEvent(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err("invalid event id"))
|
|
return
|
|
}
|
|
|
|
var event model.PushEvent
|
|
if err := db.DB(c.Request.Context()).First(&event, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusNotFound, response.Err("notification event not found"))
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
}
|
|
return
|
|
}
|
|
|
|
event.Enabled = !event.Enabled
|
|
if event.Enabled && len(event.Channels) == 0 {
|
|
c.JSON(http.StatusBadRequest, response.Err("cannot enable event without any push channels configured"))
|
|
return
|
|
}
|
|
if err := db.DB(c.Request.Context()).Model(&event).Update("enabled", event.Enabled).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
// 缓存一致性:清除事件缓存
|
|
model.DeleteActivePushEventCache(c.Request.Context(), event.EventKey)
|
|
|
|
c.JSON(http.StatusOK, response.OK(event.Enabled))
|
|
}
|
|
|
|
// pushHistoriesResponse 推送历史分页响应
|
|
//
|
|
//nolint:unused
|
|
type pushHistoriesResponse struct {
|
|
Total int64 `json:"total"`
|
|
Results []model.PushHistory `json:"results"`
|
|
}
|
|
|
|
// ListHistories 分页获取通知推送历史
|
|
// @Summary 分页获取通知推送历史
|
|
// @Description 返回分页的通知历史日志数据,需要管理员权限
|
|
// @Tags admin-push
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Param page query int false "当前页码"
|
|
// @Param page_size query int false "分页大小"
|
|
// @Param event_key query string false "过滤事件名称"
|
|
// @Param status query string false "过滤发送状态"
|
|
// @Success 200 {object} response.Any{data=pushHistoriesResponse} "推送历史列表"
|
|
// @Router /api/v1/admin/push/histories [get]
|
|
func ListHistories(c *gin.Context) {
|
|
pageStr := c.DefaultQuery("page", "1")
|
|
pageSizeStr := c.DefaultQuery("page_size", "20")
|
|
eventKey := c.Query("event_key")
|
|
status := c.Query("status")
|
|
|
|
page, err := strconv.Atoi(pageStr)
|
|
if err != nil || page < 1 {
|
|
page = 1
|
|
}
|
|
pageSize, err := strconv.Atoi(pageSizeStr)
|
|
if err != nil || pageSize < 1 {
|
|
pageSize = 20
|
|
}
|
|
|
|
query := db.DB(c.Request.Context()).Model(&model.PushHistory{}).Order("created_at DESC")
|
|
if eventKey != "" {
|
|
query = query.Where("event_key = ?", eventKey)
|
|
}
|
|
if status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
var results []model.PushHistory
|
|
offset := (page - 1) * pageSize
|
|
if err := query.Offset(offset).Limit(pageSize).Find(&results).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response.OK(map[string]any{
|
|
"total": total,
|
|
"results": results,
|
|
}))
|
|
}
|
|
|
|
// TestPush 测试推送通道发送
|
|
// @Summary 测试推送通道发送
|
|
// @Description 接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息
|
|
// @Tags admin-push
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Param request body push.TestPushRequest true "测试请求体"
|
|
// @Success 200 {object} response.Any{data=string} "测试成功"
|
|
// @Router /api/v1/admin/push/test [post]
|
|
func TestPush(c *gin.Context) {
|
|
var req TestPushRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
pusher, err := push.GetPusher(req.Config.Channel)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
// 校验配置
|
|
if err := pusher.ValidateConfig(req.Config); err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(fmt.Sprintf("validation failed: %v", err)))
|
|
return
|
|
}
|
|
|
|
// 邮件渠道需要从系统设置中拉取发件人 SMTP 信息做测试 (除非配了独立的)
|
|
if req.Config.Channel == channelEmail && (req.Config.URL == "" || req.Config.Key == "") {
|
|
var smtpHost, smtpPort, smtpUser, smtpPass model.SystemConfig
|
|
ctx := c.Request.Context()
|
|
_ = smtpHost.GetByKey(ctx, model.ConfigKeySMTPHost)
|
|
_ = smtpPort.GetByKey(ctx, model.ConfigKeySMTPPort)
|
|
_ = smtpUser.GetByKey(ctx, model.ConfigKeySMTPUsername)
|
|
_ = smtpPass.GetByKey(ctx, model.ConfigKeySMTPPassword)
|
|
|
|
if smtpHost.Value != "" && smtpUser.Value != "" {
|
|
port := smtpPort.Value
|
|
if port == "" {
|
|
port = "587"
|
|
}
|
|
req.Config.URL = smtpHost.Value + ":" + port
|
|
req.Config.Key = smtpUser.Value
|
|
req.Config.Secret = smtpPass.Value
|
|
}
|
|
}
|
|
|
|
testBody := map[string]any{
|
|
keyTitle: "测试通道推送",
|
|
keyContent: "当您收到这条消息,说明当前渠道连通性测试通过。",
|
|
keyLevel: defaultLevelInfo,
|
|
}
|
|
|
|
err = pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response.OKNil())
|
|
}
|