Files
OpenFlare/backend/plugins/domain/msg_gateway/service/push_trigger.go
T
ryan 1e19d8114a refactor(msg_gateway): decouple bot gateway and push notification architecture
- Split shared monolithic consts into bot, push, and errs with typed sentinel errors
- Restructure model layer into distinct bot and push subdomains
- Refactor DAO layer to enforce single-owner principle and remove cross-table raw SQL queries
- Decompose 1150+ line service/push.go into push_channel, push_event, push_trigger, push_worker, and push_template
- Clean up controller layer with generic request handlers and parameter validation in controller/base.go
- Streamline plugin.go to core Cordis lifecycle orchestration and remove re-export bloat
- Verify all unit tests, race tests, Cordis architecture rules, and Swagger generation pass cleanly
2026-09-02 23:21:36 +08:00

399 lines
12 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/msg_gateway/consts"
"Wavelet/plugins/domain/msg_gateway/dao"
"Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/msg_gateway/model/entity"
pkgpush "Wavelet/plugins/domain/msg_gateway/push"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
// 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 do.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 := dao.GetActivePushEventByKey(asyncCtx, meta.Key)
if err != nil {
if errors.Is(err, consts.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 *entity.PushEvent, meta do.EventMetadata, flatBody, body map[string]any) (do.NotificationMessage, string) {
var msg do.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 = consts.DefaultLevelInfo
}
} else {
msg = t.parseDefaultTemplate(meta, flatBody)
}
if msg.Ext == nil {
msg.Ext = make(map[string]any)
}
for k, v := range body {
if k == consts.KeyTitle || k == consts.KeyContent || k == consts.KeyLevel {
continue
}
if _, exists := msg.Ext[k]; !exists {
msg.Ext[k] = v
}
}
return msg, renderedTemplate
}
func (t *EventTrigger) parseCustomTemplate(event *entity.PushEvent, templateSource string, flatBody map[string]any) (do.NotificationMessage, string, error) {
var msg do.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[consts.KeyTitle].(string); ok && title != "" {
msg.Title = title
} else {
msg.Title = event.Name
}
delete(tMap, consts.KeyTitle)
if content, ok := tMap[consts.KeyContent].(string); ok && content != "" {
msg.Content = content
} else {
msg.Content = renderedTemplate
}
delete(tMap, consts.KeyContent)
if level, ok := tMap[consts.KeyLevel].(string); ok && level != "" {
msg.Level = level
} else {
msg.Level = consts.DefaultLevelInfo
}
delete(tMap, consts.KeyLevel)
msg.Ext = tMap
return msg, renderedTemplate, nil
}
func (t *EventTrigger) parseDefaultTemplate(meta do.EventMetadata, flatBody map[string]any) do.NotificationMessage {
var msg do.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 do.EventMetadata, event *entity.PushEvent, msg do.NotificationMessage, flatBody map[string]any) {
for _, channelName := range event.Channels {
customChannel, err := dao.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 do.EventMetadata, event *entity.PushEvent, channel *entity.PushChannel, msg do.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 do.EventMetadata, channel *entity.PushChannel, target string, msg do.NotificationMessage) {
var config pkgpush.Config
var renderedTemplate string
switch channel.Type {
case consts.ChannelLark:
config = pkgpush.Config{Channel: consts.ChannelLark, URL: channel.URL, Secret: channel.Token}
renderedTemplate = channel.Other
case consts.ChannelEmail:
config = pkgpush.Config{Channel: consts.ChannelEmail, URL: channel.URL, Key: channel.Token, Secret: channel.Other}
case consts.ChannelTelegram:
config = pkgpush.Config{Channel: consts.ChannelTelegram, URL: channel.URL, Secret: channel.Token, Key: channel.Other}
default:
config = pkgpush.Config{Channel: consts.ChannelCustom, URL: channel.URL}
customPushReq := do.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 := do.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)
}
}
// 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 == consts.ChannelEmail && user.Email != "" {
return user.Email
}
if channel != consts.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", consts.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 via UserService contract.
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
}
// FindUserByID resolves a user by primary key through UserService.
func FindUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
if userSvc := GetUserService(ctx); userSvc != nil {
return userSvc.GetUserByID(ctx, id)
}
return nil, consts.ErrUserNotFound
}
// FindUserByUsername resolves a user by login name through UserService.
func FindUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) {
if userSvc := GetUserService(ctx); userSvc != nil {
return userSvc.GetUserByUsername(ctx, username)
}
return nil, consts.ErrUserNotFound
}
// GetFirstAdminUser resolves the first administrator through the UserService contract.
func GetFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) {
if userSvc := GetUserService(ctx); userSvc != nil {
return userSvc.GetFirstAdminUser(ctx)
}
return nil, consts.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 == consts.ChannelEmail && adminUser.Email != "" {
return adminUser.Email, true
}
if channel != consts.ChannelEmail && adminUser.Username != "" {
return adminUser.Username, true
}
return resolved, true
}
// 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: "系统管理员",
}
}
// 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
}
// 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 ""
}