2026-07-23 21:38:43 +08:00

330 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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