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 "" }