330 lines
11 KiB
Go
330 lines
11 KiB
Go
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 ""
|
||
}
|