package message import ( "context" "encoding/json" "fmt" "net/url" "strings" "hyapp/pkg/xerr" messagedomain "hyapp/services/activity-service/internal/domain/message" ) const ( defaultNoticeProducer = "activity-service" VIPDailyCoinRebateNoticeTemplateID = "vip_daily_coin_rebate_available" VIPDailyCoinRebateNoticeTemplateVersion = "v2" VIPDailyCoinRebateNoticeTitle = "VIP Coin Cashback" ) // 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 ImageURLs []string RewardItems []messagedomain.RewardItem Actions []messagedomain.InboxAction 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 } // VIPDailyCoinRebateNoticeCopy 使用钱包金额事实生成统一英文快照;金额缺失时仍返回英文兜底, // 供读取历史 v1 中文消息时关闭旧文案泄漏,而不伪造未知的返现金额。 func VIPDailyCoinRebateNoticeCopy(coinAmount int64) (string, string) { if coinAmount <= 0 { return VIPDailyCoinRebateNoticeTitle, "Your daily VIP cashback is ready. Claim it before it expires." } return VIPDailyCoinRebateNoticeTitle, fmt.Sprintf("Your daily VIP cashback of %d coins is ready. Claim it before it expires.", coinAmount) } // normalizeVIPDailyCoinRebateNoticeCopy keeps already-persisted v1 inbox snapshots compatible with // the English-only client contract. The rebate ID and amount still come from the immutable wallet event. func normalizeVIPDailyCoinRebateNoticeCopy(message messagedomain.InboxMessage) messagedomain.InboxMessage { if message.TemplateID != VIPDailyCoinRebateNoticeTemplateID || message.ActionType != "vip_coin_rebate_claim" { return message } var actionParam struct { CoinAmount int64 `json:"coin_amount"` RebateID string `json:"rebate_id"` } _ = json.Unmarshal([]byte(message.ActionParam), &actionParam) message.Title, message.Summary = VIPDailyCoinRebateNoticeCopy(actionParam.CoinAmount) message.Body = message.Summary if strings.TrimSpace(actionParam.RebateID) != "" { message.Actions = []messagedomain.InboxAction{VIPDailyCoinRebateNoticeAction(actionParam.RebateID)} message.ActionType = "" message.ActionParam = "" } return message } // VIPDailyCoinRebateNoticeAction 把 VIP 返现映射成普通 inbox 操作;Flutter 不识别 VIP, // 只展示后端 label 并执行同源 API。稳定 command_id 让超时重试仍命中钱包幂等结果。 func VIPDailyCoinRebateNoticeAction(rebateID string) messagedomain.InboxAction { rebateID = strings.TrimSpace(rebateID) return messagedomain.InboxAction{ Action: "claim", Label: "Claim", Style: "primary", API: messagedomain.InboxActionAPI{ Method: "POST", Path: "/api/v1/vip/coin-rebates/" + url.PathEscape(rebateID) + "/claim", Body: map[string]any{ "command_id": "vip_rebate_claim_" + rebateID, }, }, } } // 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 Actions []messagedomain.InboxAction 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, ImageURLs: cmd.ImageURLs, RewardItems: cmd.RewardItems, Actions: cmd.Actions, 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), "actions": cmd.Actions, "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 "" }