Files
OpenFlare/backend/plugins/domain/message_gateway/service/push.go
T
ryan df7ad453cc fix(tasks): canonicalize triggered_by so execution labels resolve
Unknown values such as http and inproc_cron made the admin UI call
t(undefined). Dispatch sites now write system/manual/retry/schedule,
the list API maps legacy rows, and the table skips missing i18n keys.
2026-09-02 18:04:32 +08:00

1156 lines
34 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/model"
pkgpush "Wavelet/plugins/domain/message_gateway/push"
"Wavelet/plugins/domain/message_gateway/repository"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
var (
builtInEventsMu sync.RWMutex
// BuiltInEvents lists all built-in events defined in custom_events.
BuiltInEvents []model.EventMetadata
)
// RegisterBuiltInEvent registers a built-in event definition.
func RegisterBuiltInEvent(meta model.EventMetadata) {
builtInEventsMu.Lock()
defer builtInEventsMu.Unlock()
for i, e := range BuiltInEvents {
if e.Key == meta.Key {
BuiltInEvents[i] = meta
return
}
}
BuiltInEvents = append(BuiltInEvents, meta)
}
// GetBuiltInEvents returns a copy of registered built-in events.
func GetBuiltInEvents() []model.EventMetadata {
builtInEventsMu.RLock()
defer builtInEventsMu.RUnlock()
out := make([]model.EventMetadata, len(BuiltInEvents))
copy(out, BuiltInEvents)
return out
}
// PushRegistryAdapter adapts contracts.PushRegistry onto the built-in event store.
type PushRegistryAdapter struct{}
// RegisterBuiltInEvent records a built-in push event definition.
func (PushRegistryAdapter) RegisterBuiltInEvent(meta contracts.PushEventMeta) {
RegisterBuiltInEvent(eventMetadataFromContract(meta))
}
// SyncEvents persists registered built-in events into the database.
func (PushRegistryAdapter) SyncEvents(ctx context.Context) error {
return SyncEvents(ctx)
}
func eventMetadataFromContract(meta contracts.PushEventMeta) model.EventMetadata {
return model.EventMetadata{
Key: meta.Key,
Name: meta.Name,
Description: meta.Description,
DefaultTemplate: model.NotificationMessage{
Title: meta.DefaultTemplate.Title,
Content: meta.DefaultTemplate.Content,
Level: meta.DefaultTemplate.Level,
Ext: meta.DefaultTemplate.Ext,
},
}
}
// SyncBuiltInEvents seeds a database row for every registered built-in event.
func SyncBuiltInEvents(ctx context.Context) error {
for _, meta := range GetBuiltInEvents() {
_, err := repository.GetPushEventByKeyRecord(ctx, meta.Key)
if errors.Is(err, errs.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 := repository.CreatePushEventRecord(ctx, &event); err != nil {
return err
}
} else if err != nil {
return err
}
}
return nil
}
// ListPushEvents lists all configured push events.
func ListPushEvents(ctx context.Context) ([]model.PushEvent, error) {
return repository.ListPushEventsRecord(ctx)
}
// CreatePushEvent stores a push event configuration for a built-in event or task type.
func CreatePushEvent(ctx context.Context, req model.CreatePushEventRequest) (model.PushEvent, error) {
eventKey, eventName, defaultTemplateBytes, err := GetEventInfo(ctx, req)
if err != nil {
return model.PushEvent{}, err
}
count, err := repository.CountPushEventsByKeyRecord(ctx, eventKey)
if err != nil {
return model.PushEvent{}, err
}
if count > 0 {
return model.PushEvent{}, errors.New(errs.ErrEventAlreadyConfigured)
}
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 {
return model.PushEvent{}, errors.New(errs.ErrTemplateInvalidJSON)
}
}
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 {
return model.PushEvent{}, err
}
if err := repository.CreatePushEventRecord(ctx, &event); err != nil {
return model.PushEvent{}, err
}
return event, nil
}
// DeletePushEvent deletes a push event configuration by id.
func DeletePushEvent(ctx context.Context, id uint64) error {
event, err := repository.GetPushEventByIDRecord(ctx, id)
if err != nil {
return err
}
return repository.DeletePushEventRecord(ctx, &event)
}
// UpdatePushEvent replaces mutable push event fields.
func UpdatePushEvent(ctx context.Context, id uint64, req model.UpdatePushEventRequest) error {
event, err := repository.GetPushEventByIDRecord(ctx, id)
if err != nil {
return err
}
event.Channels = req.Channels
event.Targets = req.Targets
event.Template = req.Template
event.Enabled = req.Enabled
if err := event.Validate(); err != nil {
return err
}
return repository.SavePushEventRecord(ctx, &event)
}
// TogglePushEvent flips the enabled flag of a push event.
func TogglePushEvent(ctx context.Context, id uint64) (bool, error) {
event, err := repository.GetPushEventByIDRecord(ctx, id)
if err != nil {
return false, err
}
enabled := !event.Enabled
if enabled && len(event.Channels) == 0 {
return false, errors.New(errs.ErrEnableWithoutChannels)
}
if err := repository.UpdatePushEventEnabledRecord(ctx, &event, enabled); err != nil {
return false, err
}
return enabled, nil
}
// ListPushHistories returns a paginated push delivery audit page.
func ListPushHistories(ctx context.Context, filter model.PushHistoryListFilter) (int64, []model.PushHistory, error) {
return repository.ListPushHistoriesRecord(ctx, filter)
}
// ApplySMTPFallbackToPushConfig fills an email config from the system SMTP settings.
func ApplySMTPFallbackToPushConfig(ctx context.Context, cfg *pkgpush.Config) {
if cfg.Channel != model.ChannelEmail || (cfg.URL != "" && cfg.Key != "") {
return
}
smtp, err := repository.LoadSMTPConfigRecord(ctx)
if err != nil {
logger.ErrorF(ctx, "[Push] 读取 SMTP 系统配置失败: %v", err)
return
}
if smtp.Host == "" || smtp.Username == "" {
return
}
port := smtp.Port
if port == "" {
port = "587"
}
cfg.URL = smtp.Host + ":" + port
cfg.Key = smtp.Username
cfg.Secret = smtp.Password
}
// RunPushTest validates an ad-hoc channel config and sends a connectivity probe.
func RunPushTest(ctx context.Context, cfg pkgpush.Config, target string) error {
pusher, err := pkgpush.GetPusher(cfg.Channel)
if err != nil {
return err
}
if err := pusher.ValidateConfig(cfg); err != nil {
return fmt.Errorf("%s: %w", errs.ErrValidationFailed, err)
}
ApplySMTPFallbackToPushConfig(ctx, &cfg)
testBody := map[string]any{
model.KeyTitle: "测试通道推送",
model.KeyContent: "当您收到这条消息,说明当前渠道连通性测试通过。",
model.KeyLevel: model.DefaultLevelInfo,
}
if _, err := pusher.Send(ctx, cfg, target, testBody, "", nil); err != nil {
return err
}
return nil
}
// ListPushChannels returns every configured push channel.
func ListPushChannels(ctx context.Context) ([]model.PushChannel, error) {
return repository.ListPushChannelsRecord(ctx)
}
// CreatePushChannel validates uniqueness and persists a new push channel.
func CreatePushChannel(ctx context.Context, req model.CreatePushChannelRequest) (model.PushChannel, error) {
count, err := repository.CountPushChannelsByNameRecord(ctx, req.Name)
if err != nil {
return model.PushChannel{}, err
}
if count > 0 {
return model.PushChannel{}, errors.New(errs.ErrChannelNameExists)
}
channel := model.PushChannel{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Token: req.Token,
URL: req.URL,
Other: req.Other,
Enabled: req.Enabled,
}
if err := channel.Validate(); err != nil {
return model.PushChannel{}, err
}
if err := repository.CreatePushChannelRecord(ctx, &channel); err != nil {
return model.PushChannel{}, err
}
return channel, nil
}
// UpdatePushChannel replaces the mutable fields of an existing push channel.
func UpdatePushChannel(ctx context.Context, id uint64, req model.UpdatePushChannelRequest) (model.PushChannel, error) {
channel, err := repository.GetPushChannelByIDRecord(ctx, id)
if err != nil {
return model.PushChannel{}, err
}
channel.Description = req.Description
channel.Type = req.Type
channel.Token = req.Token
channel.URL = req.URL
channel.Other = req.Other
channel.Enabled = req.Enabled
if err := channel.Validate(); err != nil {
return model.PushChannel{}, err
}
if err := repository.SavePushChannelRecord(ctx, &channel); err != nil {
return model.PushChannel{}, err
}
return channel, nil
}
// DeletePushChannel removes a push channel by id.
func DeletePushChannel(ctx context.Context, id uint64) error {
channel, err := repository.GetPushChannelByIDRecord(ctx, id)
if err != nil {
return err
}
return repository.DeletePushChannelRecord(ctx, &channel)
}
// LoadChannelForTest resolves the credentials under test, either from a stored
// channel name or from the ad-hoc values sent by the caller.
func LoadChannelForTest(ctx context.Context, req model.TestPushChannelRequest) (string, string, string, string, error) {
if req.Name != "" {
channel, err := repository.GetPushChannelByNameRecord(ctx, req.Name)
if err != nil {
return "", "", "", "", errors.New(errs.ErrChannelNotFound)
}
return channel.URL, channel.Token, channel.Other, channel.Type, nil
}
return req.URL, req.Token, req.Other, req.Type, nil
}
// PreparePushChannelTest builds the connectivity probe payload for a channel.
func PreparePushChannelTest(ctx context.Context, req model.TestPushChannelRequest) (model.SendPayload, error) {
url, token, other, channelType, err := LoadChannelForTest(ctx, req)
if err != nil {
return model.SendPayload{}, err
}
if channelType == model.ChannelEmail {
url, token, other = ResolveSMTPConfig(ctx, url, token, other)
}
tempChannel := model.PushChannel{
Name: "test_temp",
URL: url,
Token: token,
Other: other,
Type: channelType,
Enabled: true,
}
if err := tempChannel.Validate(); err != nil {
return model.SendPayload{}, err
}
url = tempChannel.URL
var config pkgpush.Config
var renderedJSON string
switch channelType {
case model.ChannelLark:
config = pkgpush.Config{Channel: model.ChannelLark, URL: url, Secret: token}
renderedJSON = other
case model.ChannelEmail:
config = pkgpush.Config{Channel: model.ChannelEmail, URL: url, Key: token, Secret: other}
case model.ChannelTelegram:
config = pkgpush.Config{Channel: model.ChannelTelegram, URL: url, Secret: token, Key: other}
default:
config = pkgpush.Config{Channel: model.ChannelCustom, URL: url}
customPushReq := model.CustomPushRequest{
Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。",
Description: "系统通道测试",
URL: "https://example.com",
To: req.Target,
}
renderedJSON = RenderCustomPayload(other, customPushReq)
}
return model.SendPayload{
EventKey: "test_channel",
Config: config,
Target: req.Target,
Body: model.NotificationMessage{
Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。",
Level: model.DefaultLevelInfo,
},
Template: renderedJSON,
}, nil
}
// RenderCustomPayload substitutes the supported template variables of a custom
// webhook body, JSON-escaping every injected value.
func RenderCustomPayload(template string, req model.CustomPushRequest) string {
result := template
result = strings.ReplaceAll(result, "$title", EscapeJSONString(req.Title))
result = strings.ReplaceAll(result, "$description", EscapeJSONString(req.Description))
result = strings.ReplaceAll(result, "$content", EscapeJSONString(req.Content))
result = strings.ReplaceAll(result, "$url", EscapeJSONString(req.URL))
result = strings.ReplaceAll(result, "$to", EscapeJSONString(req.To))
return result
}
// EscapeJSONString renders s as a JSON string body without the surrounding quotes.
func EscapeJSONString(s string) string {
b, _ := json.Marshal(s)
const minJSONLen = 2
if len(b) >= minJSONLen {
return string(b[1 : len(b)-1])
}
return s
}
// ListActivePushEventsByTaskType returns enabled push events for a given task type.
func ListActivePushEventsByTaskType(ctx context.Context, taskType string) ([]model.PushEvent, error) {
return repository.ListActivePushEventsByTaskTypeRecord(ctx, taskType)
}
// QueryUser resolves a user through the UserService contract, falling back to the
// repository read path while the contract is not wired yet.
func QueryUser(ctx context.Context, fromService func(contracts.UserService) (*contracts.UserDTO, error), dbField string, dbVal any) (*contracts.UserDTO, error) {
if userSvc := GetUserService(ctx); userSvc != nil {
return fromService(userSvc)
}
if user, err := repository.FindUserByFieldRecord(ctx, dbField, dbVal); err == nil && user != nil {
return user, nil
}
return nil, errors.New(errs.ErrUserNotFound)
}
// FindUserByID resolves a user by primary key.
func FindUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
return QueryUser(ctx, func(s contracts.UserService) (*contracts.UserDTO, error) {
return s.GetUserByID(ctx, id)
}, "id", id)
}
// FindUserByUsername resolves a user by login name.
func FindUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) {
return QueryUser(ctx, func(s contracts.UserService) (*contracts.UserDTO, error) {
return s.GetUserByUsername(ctx, username)
}, "username", username)
}
// LoadUserFromPayload extracts user info from data.
func LoadUserFromPayload(ctx context.Context, data map[string]any) any {
if u, exists := data["user"]; exists && u != nil {
return u
}
if userID, ok := ExtractUserID(data); ok && userID > 0 {
if user, err := FindUserByID(ctx, userID); err == nil && user != nil {
return user
}
}
if username := ExtractUsername(data); username != "" {
if user, err := FindUserByUsername(ctx, username); err == nil && user != nil {
return user
}
}
return nil
}
// RecordPushHistory creates a push history audit record.
func RecordPushHistory(ctx context.Context, req model.SendPayload, status, errMsg string) error {
title := req.Body.Title
content := req.Body.Content
level := req.Body.Level
if title == "" {
title = "系统通知"
}
if level == "" {
level = model.DefaultLevelInfo
}
target := req.Target
if target == "" {
if req.Config.URL != "" {
target = req.Config.URL
const maxTargetLen = 50
const truncatedLen = 47
if len(target) > maxTargetLen {
target = target[:truncatedLen] + "..."
}
} else {
target = "default"
}
}
history := model.PushHistory{
EventKey: req.EventKey,
Channel: req.Config.Channel,
Target: target,
Title: title,
Content: content,
Level: level,
Status: status,
ErrorMsg: errMsg,
}
return repository.CreatePushHistoryRecord(ctx, &history)
}
// ResolveTarget parses dynamic placeholders into concrete receiver targets.
func ResolveTarget(ctx context.Context, target string, flatBody map[string]any, channel string) string {
target = strings.TrimSpace(target)
if target == "" {
return ""
}
resolved := ResolveDynamicKeyword(target, flatBody)
if strings.Contains(resolved, "@") {
return resolved
}
if val, matched := ResolveSystemTarget(ctx, resolved, channel); matched {
return val
}
user, found := ResolveTargetUser(ctx, resolved, channel)
if !found {
return resolved
}
if channel == model.ChannelEmail && user.Email != "" {
return user.Email
}
if channel != model.ChannelEmail && user.Username != "" {
return user.Username
}
return resolved
}
// ResolveDynamicKeyword resolves user.id, username, email keywords.
func ResolveDynamicKeyword(target string, flatBody map[string]any) string {
switch target {
case "user.id", "id":
if val, ok := flatBody["user.id"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["id"]; ok {
return fmt.Sprintf("%v", val)
}
case "user.username", "username":
if val, ok := flatBody["user.username"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["username"]; ok {
return fmt.Sprintf("%v", val)
}
case "user.email", model.ChannelEmail:
if val, ok := flatBody["user.email"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["email"]; ok {
return fmt.Sprintf("%v", val)
}
}
return target
}
// ResolveTargetUser resolves user by numeric ID or username string.
func ResolveTargetUser(ctx context.Context, resolved, _ string) (contracts.UserDTO, bool) {
if id, err := strconv.ParseUint(resolved, 10, 64); err == nil {
if u, err := FindUserByID(ctx, id); err == nil && u != nil {
return *u, true
}
}
if u, err := FindUserByUsername(ctx, resolved); err == nil && u != nil {
return *u, true
}
return contracts.UserDTO{}, false
}
// GetFirstAdminUser resolves the first administrator through the UserService
// contract, falling back to the repository read path when it is unavailable.
func GetFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) {
if userSvc := GetUserService(ctx); userSvc != nil {
return userSvc.GetFirstAdminUser(ctx)
}
if adminUser, err := repository.FindFirstAdminUserRecord(ctx); err == nil && adminUser != nil {
return adminUser, nil
}
return nil, errors.New(errs.ErrNoAdminUser)
}
// ResolveSystemTarget maps system receiver aliases to administrator contact info.
func ResolveSystemTarget(ctx context.Context, resolved, channel string) (string, bool) {
if resolved != "系统" && resolved != "system" && resolved != "0" {
return "", false
}
adminUser, err := GetFirstAdminUser(ctx)
if err != nil || adminUser == nil {
return resolved, true
}
if channel == model.ChannelEmail && adminUser.Email != "" {
return adminUser.Email, true
}
if channel != model.ChannelEmail && adminUser.Username != "" {
return adminUser.Username, true
}
return resolved, true
}
// ResolveSMTPConfig fills missing email endpoint fields from the system SMTP settings.
func ResolveSMTPConfig(ctx context.Context, url, token, other string) (string, string, string) {
if url != "" && token != "" {
return url, token, other
}
smtp, err := repository.LoadSMTPConfigRecord(ctx)
if err != nil {
logger.ErrorF(ctx, "[Push] 读取 SMTP 系统配置失败: %v", err)
return url, token, other
}
if smtp.Host == "" || smtp.Username == "" {
return url, token, other
}
port := smtp.Port
if port == "" {
port = "587"
}
if url == "" {
url = smtp.Host + ":" + port
}
if token == "" {
token = smtp.Username
}
if other == "" {
other = smtp.Password
}
return url, token, other
}
// GetSystemUser gets a system user DTO.
func GetSystemUser(ctx context.Context) *contracts.UserDTO {
if adminUser, err := GetFirstAdminUser(ctx); err == nil && adminUser != nil {
return adminUser
}
return &contracts.UserDTO{
Username: "system",
Nickname: "系统管理员",
}
}
// FindBuiltInEvent finds a registered built-in event by key.
func FindBuiltInEvent(key string) (model.EventMetadata, bool) {
for _, meta := range GetBuiltInEvents() {
if meta.Key == key {
return meta, true
}
}
return model.EventMetadata{}, false
}
// GetEventInfo derives the event key, display name and default template for a
// task-completion based event or a registered built-in event key.
func GetEventInfo(ctx context.Context, req model.CreatePushEventRequest) (string, string, []byte, error) {
if req.TaskType != "" {
taskName := req.TaskType
if taskSvc := GetTaskService(ctx); taskSvc != nil {
if meta, ok := taskSvc.GetTaskMeta(req.TaskType); ok {
taskName = meta.DisplayName
}
}
eventKey := "task_completed:" + req.TaskType
eventName := "任务完成: " + taskName
defaultTemplate := model.NotificationMessage{
Title: "任务完成: " + taskName,
Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。",
Level: model.DefaultLevelInfo,
}
defaultTemplateBytes, err := json.Marshal(defaultTemplate)
if err != nil {
return "", "", nil, err
}
return eventKey, eventName, defaultTemplateBytes, nil
}
if req.EventKey == "" {
return "", "", nil, errors.New(errs.ErrEventKeyOrTaskType)
}
meta, found := FindBuiltInEvent(req.EventKey)
if !found {
return "", "", nil, errors.New(errs.ErrUnsupportedEventKey)
}
defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate)
if err != nil {
return "", "", nil, err
}
return req.EventKey, meta.Name, defaultTemplateBytes, nil
}
// EnqueuePushTask dispatches a notification payload to the async push worker.
func EnqueuePushTask(ctx context.Context, payload model.SendPayload) error {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
if taskSvc := GetTaskService(ctx); taskSvc != nil {
_, err = taskSvc.Dispatch(ctx, "send_notification", payloadBytes, contracts.TaskTriggerSystem)
return err
}
return errors.New(errs.ErrTaskServiceUnavailable)
}
// GetFlatBody flattens nested body map.
func GetFlatBody(body map[string]any) map[string]any {
jsonBytes, err := json.Marshal(body)
if err != nil {
return body
}
var jsonMap map[string]any
if err := json.Unmarshal(jsonBytes, &jsonMap); err != nil {
return body
}
flatResult := make(map[string]any)
FlattenMap("", jsonMap, flatResult)
return flatResult
}
// FlattenMap recursively flattens map key-values.
func FlattenMap(prefix string, m, result map[string]any) {
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
if nestedMap, ok := v.(map[string]any); ok {
FlattenMap(key, nestedMap, result)
} else {
result[key] = v
}
}
}
const (
// SendNotificationTask is the asynq task name for push notification.
SendNotificationTask = "push:send"
// TaskTypeSendNotification is the admin task manager type identifier.
TaskTypeSendNotification = "send_notification"
)
// SendNotificationMeta represents the task metadata.
var SendNotificationMeta = contracts.TaskMetaDTO{
Type: TaskTypeSendNotification,
AsynqTask: SendNotificationTask,
Name: "推送通知",
DisplayName: "推送通知",
Description: "异步执行系统通知的多渠道派发与推送",
Category: "push",
SupportsTime: false,
MaxRetry: 3,
Queue: taskQueueDefault,
Retryable: true,
Params: []contracts.TaskParamDTO{
{
Name: "event_key",
Label: "事件标识",
Type: taskParamTypeString,
Required: true,
Placeholder: "admin_login",
Description: "事件标识 (如 admin_login)",
},
{
Name: "target",
Label: "目标接收者",
Type: taskParamTypeString,
Required: false,
Description: "目标接收者",
},
},
}
// PushHandler handles asynchronous notification sending.
type PushHandler struct{}
// ValidatePayload validates and normalizes push parameters.
func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New(errs.ErrPayloadRequired)
}
var req model.SendPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("%s: %w", errs.ErrInvalidJSONFormat, err)
}
if req.Config.Channel == "" {
return nil, errors.New(errs.ErrChannelTypeRequired)
}
return json.Marshal(req)
}
// Execute performs the push send and logs delivery history audit.
func (h *PushHandler) Execute(ctx context.Context, payload []byte) error {
var req model.SendPayload
if err := json.Unmarshal(payload, &req); err != nil {
logger.ErrorF(ctx, "[Push] 解析推送参数失败: %v", err)
return fmt.Errorf("%s: %w", errs.ErrParsePayloadFailed, err)
}
logger.InfoF(ctx, "[Push] 开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target)
pusher, err := pkgpush.GetPusher(req.Config.Channel)
if err != nil {
errWrap := fmt.Errorf("%s: %w", errs.ErrGetPusherFailed, err)
logger.ErrorF(ctx, "[Push] 推送失败: %v", errWrap)
h.recordHistory(ctx, req, "failed", errWrap.Error())
return errWrap
}
flatBody := req.Body.Flatten()
upstreamResp, err := pusher.Send(ctx, req.Config, req.Target, flatBody, req.Template, nil)
title := req.Body.Title
content := req.Body.Content
if err != nil {
logger.ErrorF(ctx, "[Push] 消息推送失败 (标题: %s): %v, 上游返回: %s", title, err, upstreamResp)
h.recordHistory(ctx, req, "failed", err.Error())
return fmt.Errorf("pusher.Send failed: %w", err)
}
logger.InfoF(ctx, "[Push] 消息推送成功 (标题: %s, 内容摘要: %s), 上游返回: %s", title, content, upstreamResp)
h.recordHistory(ctx, req, "success", "")
return nil
}
func (h *PushHandler) recordHistory(ctx context.Context, req model.SendPayload, status, errMsg string) {
if dbErr := RecordPushHistory(ctx, req, status, errMsg); dbErr != nil {
logger.ErrorF(ctx, "[Push] 写入推送历史审计记录失败: %v", dbErr)
}
}
// HandleTaskCompleted handles task completion notifications.
func HandleTaskCompleted(ctx context.Context, e contracts.TaskCompletedEvent) {
events, err := ListActivePushEventsByTaskType(ctx, e.TaskType)
if err != nil {
logger.ErrorF(ctx, "push_task_completed_listener: failed to query push events for task type %s: %v", e.TaskType, err)
return
}
if len(events) == 0 {
return
}
body := map[string]any{
"task_id": e.TaskID,
"task_name": e.TaskName,
"task_type": e.TaskType,
"task_status": e.Status,
"task_duration": e.Duration,
"time": time.Now().Format("2006-01-02 15:04:05"),
"task_error": e.ErrorMsg,
"task_result": e.ResultMsg,
}
var payloadMap map[string]any
if e.Payload != "" {
if err := json.Unmarshal([]byte(e.Payload), &payloadMap); err == nil {
body["payload"] = payloadMap
ExtractUserFromMap(ctx, payloadMap, body)
}
}
if e.Detail != "" {
var detailMap map[string]any
if err := json.Unmarshal([]byte(e.Detail), &detailMap); err == nil {
body["detail"] = detailMap
ExtractUserFromMap(ctx, detailMap, body)
}
}
for _, event := range events {
meta := model.EventMetadata{
Key: event.EventKey,
Name: event.Name,
Description: "异步任务执行完毕触发的自动通知",
}
DefaultTrigger.Trigger(ctx, meta, body)
}
}
// ExtractUserFromMap extracts user info from payload/detail into body map.
func ExtractUserFromMap(ctx context.Context, data, body map[string]any) {
if u, exists := body["user"]; exists && u != nil {
return
}
if user := LoadUserFromPayload(ctx, data); user != nil {
body["user"] = user
}
}
// ExtractUserID extracts a user ID from map keys.
func ExtractUserID(data map[string]any) (uint64, bool) {
for _, k := range []string{"user_id", "userId", "uid"} {
val, ok := data[k]
if !ok || val == nil {
continue
}
switch v := val.(type) {
case float64:
if v >= 0 {
return uint64(v), true
}
case int:
if v >= 0 {
return uint64(v), true
}
case int64:
if v >= 0 {
return uint64(v), true
}
case uint64:
return v, true
case string:
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
return id, true
}
}
}
return 0, false
}
// ExtractUsername extracts a username string from map keys.
func ExtractUsername(data map[string]any) string {
for _, k := range []string{"username", "user_name"} {
if val, ok := data[k]; ok && val != nil {
if s, ok := val.(string); ok && s != "" {
return s
}
}
}
return ""
}
// EventTrigger represents the unified event trigger class.
type EventTrigger struct{}
// DefaultTrigger is the singleton instance of EventTrigger.
var DefaultTrigger = &EventTrigger{}
// Trigger receives event metadata and processes the event notification dispatch asynchronously.
func (t *EventTrigger) Trigger(ctx context.Context, meta model.EventMetadata, body map[string]any) {
asyncCtx := context.WithoutCancel(ctx)
util.Go(func() {
if body == nil {
body = make(map[string]any)
}
if _, hasUser := body["user"]; !hasUser || body["user"] == nil {
body["user"] = GetSystemUser(asyncCtx)
}
eventPtr, err := repository.GetActivePushEventByKey(asyncCtx, meta.Key)
if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) {
return
}
logger.ErrorF(asyncCtx, "push_event_trigger: failed to get active event %s: %v", meta.Key, err)
return
}
event := *eventPtr
if len(event.Channels) == 0 {
return
}
flatBody := GetFlatBody(body)
msg, _ := t.buildMessage(&event, meta, flatBody, body)
t.enqueuePushTasks(asyncCtx, meta, &event, msg, flatBody)
})
}
func (t *EventTrigger) buildMessage(event *model.PushEvent, meta model.EventMetadata, flatBody, body map[string]any) (model.NotificationMessage, string) {
var msg model.NotificationMessage
renderedTemplate := ""
templateSource := event.Template
if templateSource != "" {
var err error
msg, renderedTemplate, err = t.parseCustomTemplate(event, templateSource, flatBody)
if err != nil {
msg.Title = event.Name
msg.Content = renderedTemplate
msg.Level = model.DefaultLevelInfo
}
} else {
msg = t.parseDefaultTemplate(meta, flatBody)
}
if msg.Ext == nil {
msg.Ext = make(map[string]any)
}
for k, v := range body {
if k == model.KeyTitle || k == model.KeyContent || k == model.KeyLevel {
continue
}
if _, exists := msg.Ext[k]; !exists {
msg.Ext[k] = v
}
}
return msg, renderedTemplate
}
func (t *EventTrigger) parseCustomTemplate(event *model.PushEvent, templateSource string, flatBody map[string]any) (model.NotificationMessage, string, error) {
var msg model.NotificationMessage
renderedTemplate := pkgpush.ParseTemplate(templateSource, flatBody)
var tMap map[string]any
if err := json.Unmarshal([]byte(renderedTemplate), &tMap); err != nil {
return msg, renderedTemplate, err
}
if title, ok := tMap[model.KeyTitle].(string); ok && title != "" {
msg.Title = title
} else {
msg.Title = event.Name
}
delete(tMap, model.KeyTitle)
if content, ok := tMap[model.KeyContent].(string); ok && content != "" {
msg.Content = content
} else {
msg.Content = renderedTemplate
}
delete(tMap, model.KeyContent)
if level, ok := tMap[model.KeyLevel].(string); ok && level != "" {
msg.Level = level
} else {
msg.Level = model.DefaultLevelInfo
}
delete(tMap, model.KeyLevel)
msg.Ext = tMap
return msg, renderedTemplate, nil
}
func (t *EventTrigger) parseDefaultTemplate(meta model.EventMetadata, flatBody map[string]any) model.NotificationMessage {
var msg model.NotificationMessage
msg.Title = pkgpush.ParseTemplate(meta.DefaultTemplate.Title, flatBody)
msg.Content = pkgpush.ParseTemplate(meta.DefaultTemplate.Content, flatBody)
msg.Level = pkgpush.ParseTemplate(meta.DefaultTemplate.Level, flatBody)
if meta.DefaultTemplate.Ext != nil {
msg.Ext = make(map[string]any)
for k, v := range meta.DefaultTemplate.Ext {
if strVal, ok := v.(string); ok {
msg.Ext[k] = pkgpush.ParseTemplate(strVal, flatBody)
} else {
msg.Ext[k] = v
}
}
}
return msg
}
func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta model.EventMetadata, event *model.PushEvent, msg model.NotificationMessage, flatBody map[string]any) {
for _, channelName := range event.Channels {
customChannel, err := repository.GetActivePushChannelByName(ctx, channelName)
if err == nil {
t.enqueueCustomPushChannelTasks(ctx, meta, event, customChannel, msg, flatBody)
continue
}
logger.WarnF(ctx, "push_event_trigger: channel %q not found in DB or disabled: %v", channelName, err)
}
}
func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta model.EventMetadata, event *model.PushEvent, channel *model.PushChannel, msg model.NotificationMessage, flatBody map[string]any) {
if len(event.Targets) == 0 {
t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, "", msg)
return
}
for _, target := range event.Targets {
resolvedTarget := ResolveTarget(ctx, target, flatBody, channel.Name)
t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, resolvedTarget, msg)
}
}
func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta model.EventMetadata, channel *model.PushChannel, target string, msg model.NotificationMessage) {
var config pkgpush.Config
var renderedTemplate string
switch channel.Type {
case model.ChannelLark:
config = pkgpush.Config{Channel: model.ChannelLark, URL: channel.URL, Secret: channel.Token}
renderedTemplate = channel.Other
case model.ChannelEmail:
url, token, other := ResolveSMTPConfig(ctx, channel.URL, channel.Token, channel.Other)
config = pkgpush.Config{Channel: model.ChannelEmail, URL: url, Key: token, Secret: other}
case model.ChannelTelegram:
config = pkgpush.Config{Channel: model.ChannelTelegram, URL: channel.URL, Secret: channel.Token, Key: channel.Other}
default:
config = pkgpush.Config{Channel: model.ChannelCustom, URL: channel.URL}
customPushReq := model.CustomPushRequest{
Title: msg.Title,
Content: msg.Content,
Description: meta.Description,
To: target,
}
if urlVal, ok := msg.Ext["url"].(string); ok {
customPushReq.URL = urlVal
}
renderedTemplate = RenderCustomPayload(channel.Other, customPushReq)
}
payload := model.SendPayload{
EventKey: meta.Key,
Config: config,
Target: target,
Body: msg,
Template: renderedTemplate,
}
if err := EnqueuePushTask(ctx, payload); err != nil {
logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for %s channel %s -> %s: %v", channel.Type, channel.Name, target, err)
}
}
// SyncEvents automatically registers/updates built-in events in the database.
func SyncEvents(ctx context.Context) error {
return SyncBuiltInEvents(ctx)
}
// AdminLogin is the metadata definition for the admin login event.
var AdminLogin = model.EventMetadata{
Key: "admin_login",
Name: "管理员登录",
DefaultTemplate: model.NotificationMessage{
Title: "管理员登录提醒",
Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。",
Level: "INFO",
},
Description: "当管理员成功登录系统时触发此通知",
}
// HandleAdminLoggedIn 处理管理员登录事件并触发通知
func HandleAdminLoggedIn(ctx context.Context, event contracts.AdminLoggedIn) {
if event.User == nil {
return
}
body := map[string]any{
"user": event.User,
"ip": event.IP,
"time": time.Now().Format("2006-01-02 15:04:05"),
}
DefaultTrigger.Trigger(ctx, AdminLogin, body)
}
// RegisterCustomEvents registers default domain push notification events.
func RegisterCustomEvents() {
RegisterBuiltInEvent(AdminLogin)
}