mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
1e19d8114a
- 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
399 lines
12 KiB
Go
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 ""
|
|
}
|