2026-07-06 12:43:40 +08:00

266 lines
8.7 KiB
Go

package message
import (
"context"
"encoding/json"
"strings"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
)
const defaultNoticeProducer = "activity-service"
// NoticeCommand is the common producer-facing command for one user-visible system/activity message.
type NoticeCommand struct {
TargetUserID int64
Producer string
ProducerEventID string
ProducerEventType string
AggregateType string
AggregateID string
TemplateID string
TemplateVersion string
Title string
Summary string
Body string
IconURL string
ImageURL string
ActionType string
ActionParam string
Priority int32
SentAtMS int64
ExpireAtMS int64
Metadata map[string]any
MetadataJSON string
}
// NoticeResult keeps the inbox row and the idempotent create flag together.
type NoticeResult struct {
Message messagedomain.InboxMessage
Created bool
}
// NoticeFanoutCommand is the common command for background system/activity broadcasts.
type NoticeFanoutCommand struct {
CommandID string
MessageType string
TargetScope string
TargetUserID int64
UserIDs []int64
RegionID int64
Country string
ProducerEventType string
AggregateType string
AggregateID string
TemplateID string
TemplateVersion string
Title string
Summary string
Body string
IconURL string
ImageURL string
ActionType string
ActionParam string
Priority int32
SentAtMS int64
ExpireAtMS int64
Metadata map[string]any
MetadataJSON string
BatchSize int32
CreatedBy string
}
// CreateSystemNotice is the small public helper for account, wallet, room and application messages.
func (s *Service) CreateSystemNotice(ctx context.Context, cmd NoticeCommand) (NoticeResult, error) {
return s.CreateNotice(ctx, messagedomain.SectionSystem, cmd)
}
// CreateActivityNotice is the small public helper for activity, reward and campaign messages.
func (s *Service) CreateActivityNotice(ctx context.Context, cmd NoticeCommand) (NoticeResult, error) {
return s.CreateNotice(ctx, messagedomain.SectionActivity, cmd)
}
// CreateNotice normalizes producer defaults and metadata before delegating to the inbox owner.
func (s *Service) CreateNotice(ctx context.Context, messageType string, cmd NoticeCommand) (NoticeResult, error) {
metadataJSON, err := noticeMetadataJSON(cmd.Metadata, cmd.MetadataJSON)
if err != nil {
return NoticeResult{}, err
}
// The existing CreateInboxMessage method is still the only place that writes user_inbox_messages.
// This helper only narrows the public call shape so producers use the same idempotency and content rules.
message, created, err := s.CreateInboxMessage(ctx, messagedomain.CreateInput{
TargetUserID: cmd.TargetUserID,
Producer: firstNoticeText(cmd.Producer, defaultNoticeProducer),
ProducerEventID: cmd.ProducerEventID,
ProducerEventType: cmd.ProducerEventType,
MessageType: messageType,
AggregateType: cmd.AggregateType,
AggregateID: cmd.AggregateID,
TemplateID: cmd.TemplateID,
TemplateVersion: cmd.TemplateVersion,
Title: cmd.Title,
Summary: cmd.Summary,
Body: cmd.Body,
IconURL: cmd.IconURL,
ImageURL: cmd.ImageURL,
ActionType: cmd.ActionType,
ActionParam: cmd.ActionParam,
Priority: cmd.Priority,
SentAtMS: cmd.SentAtMS,
ExpireAtMS: cmd.ExpireAtMS,
MetadataJSON: metadataJSON,
})
if err != nil {
return NoticeResult{}, err
}
return NoticeResult{Message: message, Created: created}, nil
}
// CreateNoticeFanout records a broadcast command for the fanout worker instead of looping in the caller.
func (s *Service) CreateNoticeFanout(ctx context.Context, cmd NoticeFanoutCommand) (messagedomain.FanoutJob, bool, error) {
messageType := normalizeSection(cmd.MessageType)
if messageType == "" {
messageType = messagedomain.SectionSystem
}
targetFilterJSON, err := noticeTargetFilterJSON(cmd)
if err != nil {
return messagedomain.FanoutJob{}, false, err
}
templateSnapshotJSON, err := noticeTemplateSnapshotJSON(cmd)
if err != nil {
return messagedomain.FanoutJob{}, false, err
}
// command_id is the broadcast idempotency key. Retrying the same admin/activity command returns the
// existing job; changing payload under the same command_id is rejected by CreateFanoutJob.
return s.CreateFanoutJob(ctx, messagedomain.CreateFanoutInput{
CommandID: strings.TrimSpace(cmd.CommandID),
MessageType: messageType,
TargetScope: strings.TrimSpace(cmd.TargetScope),
TargetFilterJSON: targetFilterJSON,
TemplateSnapshotJSON: templateSnapshotJSON,
BatchSize: cmd.BatchSize,
CreatedBy: strings.TrimSpace(cmd.CreatedBy),
})
}
func noticeTargetFilterJSON(cmd NoticeFanoutCommand) (string, error) {
scope := strings.TrimSpace(cmd.TargetScope)
var payload map[string]any
switch scope {
case messagedomain.TargetScopeSingleUser:
if cmd.TargetUserID <= 0 {
return "", xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
payload = map[string]any{"target_user_id": cmd.TargetUserID}
case messagedomain.TargetScopeUserIDs:
userIDs := positiveUniqueInt64s(cmd.UserIDs)
if len(userIDs) == 0 {
return "", xerr.New(xerr.InvalidArgument, "user_ids is required")
}
payload = map[string]any{"user_ids": userIDs}
case messagedomain.TargetScopeRegion:
if cmd.RegionID <= 0 {
return "", xerr.New(xerr.InvalidArgument, "region_id is required")
}
payload = map[string]any{"region_id": cmd.RegionID}
case messagedomain.TargetScopeCountry:
country := strings.TrimSpace(cmd.Country)
if country == "" {
return "", xerr.New(xerr.InvalidArgument, "country is required")
}
payload = map[string]any{"country": country}
case messagedomain.TargetScopeAllActiveUsers, messagedomain.TargetScopeAllRegistered:
payload = map[string]any{}
default:
return "", xerr.New(xerr.InvalidArgument, "target_scope is invalid")
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(encoded), nil
}
func noticeTemplateSnapshotJSON(cmd NoticeFanoutCommand) (string, error) {
metadataJSON, err := noticeMetadataJSON(cmd.Metadata, cmd.MetadataJSON)
if err != nil {
return "", err
}
payload := map[string]any{
"producer_event_type": strings.TrimSpace(cmd.ProducerEventType),
"aggregate_type": strings.TrimSpace(cmd.AggregateType),
"aggregate_id": strings.TrimSpace(cmd.AggregateID),
"template_id": strings.TrimSpace(cmd.TemplateID),
"template_version": strings.TrimSpace(cmd.TemplateVersion),
"title": strings.TrimSpace(cmd.Title),
"summary": strings.TrimSpace(cmd.Summary),
"body": strings.TrimSpace(cmd.Body),
"icon_url": strings.TrimSpace(cmd.IconURL),
"image_url": strings.TrimSpace(cmd.ImageURL),
"action_type": strings.TrimSpace(cmd.ActionType),
"action_param": strings.TrimSpace(cmd.ActionParam),
"priority": cmd.Priority,
"sent_at_ms": cmd.SentAtMS,
"expire_at_ms": cmd.ExpireAtMS,
}
if metadataJSON != "" {
var parsed any
if err := json.Unmarshal([]byte(metadataJSON), &parsed); err != nil {
return "", xerr.New(xerr.InvalidArgument, "metadata_json is invalid")
}
payload["metadata_json"] = parsed
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(encoded), nil
}
func noticeMetadataJSON(metadata map[string]any, metadataJSON string) (string, error) {
metadataJSON = strings.TrimSpace(metadataJSON)
if len(metadata) > 0 && metadataJSON != "" {
return "", xerr.New(xerr.InvalidArgument, "metadata and metadata_json cannot both be set")
}
if metadataJSON != "" {
if !json.Valid([]byte(metadataJSON)) {
return "", xerr.New(xerr.InvalidArgument, "metadata_json is invalid")
}
return metadataJSON, nil
}
if len(metadata) == 0 {
return "", nil
}
encoded, err := json.Marshal(metadata)
if err != nil {
return "", err
}
return string(encoded), nil
}
func positiveUniqueInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func firstNoticeText(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}