// Package rewardnotice turns committed wallet reward facts into one idempotent activity inbox message. package rewardnotice import ( "context" "encoding/json" "fmt" "math" "strings" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" messagedomain "hyapp/services/activity-service/internal/domain/message" messageservice "hyapp/services/activity-service/internal/service/message" ) const ( rewardTypeResource = "resource" rewardTypeCoin = "coin" millisecondsPerDay = int64(24 * 60 * 60 * 1000) ) // Activity 固定活动英文名和稳定代码,避免各发奖链路自行拼出不同文案或幂等键。 type Activity struct { Code string Name string } var ( CumulativeRecharge = Activity{Code: "cumulative_recharge", Name: "Cumulative Recharge Reward"} WeeklyStar = Activity{Code: "weekly_star", Name: "Weekly Star Reward"} RoomRocket = Activity{Code: "room_rocket", Name: "Room Rocket Reward"} SevenDayCheckIn = Activity{Code: "seven_day_checkin", Name: "Seven-Day Check-In Reward"} FirstRecharge = Activity{Code: "first_recharge", Name: "First Recharge Gift"} Registration = Activity{Code: "registration", Name: "Registration Reward"} DailyTask = Activity{Code: "daily_task", Name: "Daily Task Reward"} CPRanking = Activity{Code: "cp_ranking", Name: "CP Ranking Reward"} RoomTurnover = Activity{Code: "room_turnover", Name: "Room Turnover Reward"} ) // Command 只接受钱包已成功返回的 grant/coin 事实;调用方不能用活动配置伪造实际到账内容。 type Command struct { Activity Activity TargetUserID int64 ProducerEventID string AggregateType string AggregateID string Grants []*walletv1.ResourceGrant GrantIDs []string CoinAmount int64 SentAtMS int64 Metadata map[string]any } // Notifier 是九类活动共享的最小依赖面,便于业务服务和 MQ 消费者统一接入。 type Notifier interface { NotifyReward(ctx context.Context, command Command) error } // ActivityNoticeWriter keeps reward formatting separate from the inbox owner. type ActivityNoticeWriter interface { CreateActivityNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) } // ResourceGrantReader 供异步房间火箭事件按精确 grant_id 补取钱包发放快照。 type ResourceGrantReader interface { GetResourceGrant(ctx context.Context, req *walletv1.GetResourceGrantRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) } // Service owns reward snapshot conversion and activity-message idempotency. type Service struct { notices ActivityNoticeWriter wallet ResourceGrantReader } func New(notices ActivityNoticeWriter, wallet ResourceGrantReader) *Service { return &Service{notices: notices, wallet: wallet} } // NotifyReward creates exactly one activity message for one business reward event. // 钱包成功、消息失败时调用方保留 pending/failed 状态并重试;稳定 producer_event_id 会让重试只补齐一次消息。 func (s *Service) NotifyReward(ctx context.Context, command Command) error { if s == nil || s.notices == nil { return xerr.New(xerr.Unavailable, "reward notice service is not configured") } command = normalizeCommand(command) if err := validateCommand(command); err != nil { return err } grants := append([]*walletv1.ResourceGrant(nil), command.Grants...) for _, grantID := range command.GrantIDs { if s.wallet == nil { return xerr.New(xerr.Unavailable, "wallet resource grant reader is not configured") } // grant_id 来自已提交的 room outbox;精确读取避免分页扫描误拿同用户其它活动奖励。 resp, err := s.wallet.GetResourceGrant(ctx, &walletv1.GetResourceGrantRequest{ RequestId: "reward-notice:" + command.Activity.Code + ":" + command.ProducerEventID + ":" + grantID, AppCode: appcode.FromContext(ctx), GrantId: grantID, }) if err != nil { return err } if resp.GetGrant() == nil { return xerr.New(xerr.Unavailable, "wallet resource grant response is empty") } grants = append(grants, resp.GetGrant()) } for _, grant := range grants { if grant == nil || grant.GetTargetUserId() != command.TargetUserID { // 站内信收件人必须与钱包 grant 的真实归属完全一致,不能让事件或调用方传错用户后展示他人奖励。 return xerr.New(xerr.Conflict, "reward notice target does not match wallet grant") } } rewardItems, grantIDs, err := rewardItemsFromFacts(grants, command.CoinAmount) if err != nil { return err } imageURLs := rewardImageURLs(rewardItems) metadata := cloneMetadata(command.Metadata) metadata["activity_code"] = command.Activity.Code metadata["activity_name"] = command.Activity.Name if len(grantIDs) > 0 { metadata["wallet_grant_ids"] = grantIDs } if command.CoinAmount > 0 { metadata["coin_amount"] = command.CoinAmount } body := "You received the " + command.Activity.Name + ":" _, err = s.notices.CreateActivityNotice(ctx, messageservice.NoticeCommand{ TargetUserID: command.TargetUserID, Producer: "activity-reward", ProducerEventID: "reward:" + command.Activity.Code + ":" + command.ProducerEventID, ProducerEventType: "activity_reward_granted", AggregateType: command.AggregateType, AggregateID: command.AggregateID, Title: command.Activity.Name, Summary: body, Body: body, ImageURLs: imageURLs, RewardItems: rewardItems, SentAtMS: command.SentAtMS, Metadata: metadata, }) return err } func normalizeCommand(command Command) Command { command.Activity.Code = strings.ToLower(strings.TrimSpace(command.Activity.Code)) command.Activity.Name = strings.TrimSpace(command.Activity.Name) command.ProducerEventID = strings.TrimSpace(command.ProducerEventID) command.AggregateType = strings.TrimSpace(command.AggregateType) command.AggregateID = strings.TrimSpace(command.AggregateID) if command.AggregateType == "" { command.AggregateType = "activity_reward" } if command.AggregateID == "" { command.AggregateID = command.ProducerEventID } grantIDs := make([]string, 0, len(command.GrantIDs)) seen := make(map[string]struct{}, len(command.GrantIDs)) for _, grantID := range command.GrantIDs { grantID = strings.TrimSpace(grantID) if grantID == "" { continue } if _, exists := seen[grantID]; exists { continue } seen[grantID] = struct{}{} grantIDs = append(grantIDs, grantID) } command.GrantIDs = grantIDs return command } func validateCommand(command Command) error { if command.Activity.Code == "" || command.Activity.Name == "" || command.TargetUserID <= 0 || command.ProducerEventID == "" { return xerr.New(xerr.InvalidArgument, "reward notice command is incomplete") } if command.CoinAmount < 0 { return xerr.New(xerr.InvalidArgument, "reward notice coin amount cannot be negative") } if command.CoinAmount == 0 && len(command.Grants) == 0 && len(command.GrantIDs) == 0 { return xerr.New(xerr.InvalidArgument, "reward notice facts are required") } return nil } func rewardItemsFromFacts(grants []*walletv1.ResourceGrant, coinAmount int64) ([]messagedomain.RewardItem, []string, error) { items := make([]messagedomain.RewardItem, 0) grantIDs := make([]string, 0, len(grants)) totalCoin := coinAmount for _, grant := range grants { if grant == nil { continue } if grantID := strings.TrimSpace(grant.GetGrantId()); grantID != "" { grantIDs = append(grantIDs, grantID) } for _, grantItem := range grant.GetItems() { if grantItem == nil { continue } snapshot := parseSnapshot(grantItem.GetResourceSnapshotJson()) if strings.EqualFold(grantItem.GetResultType(), "wallet_credit") { amount := grantItem.GetQuantity() // 普通 wallet-credit 资源的 quantity 是资源份数,实际金币为快照单份金额乘份数; // 资源组原生 wallet_asset 项的 quantity 已经是最终入账额,不能再次相乘。 if grantItem.GetResourceId() > 0 { perItem := snapshotInt64(snapshot, "WalletAssetAmount", "wallet_asset_amount") if perItem > 0 { if grantItem.GetQuantity() > math.MaxInt64/perItem { return nil, nil, xerr.New(xerr.InvalidArgument, "reward coin amount overflows") } amount = perItem * grantItem.GetQuantity() } } if amount > math.MaxInt64-totalCoin { return nil, nil, xerr.New(xerr.InvalidArgument, "reward coin amount overflows") } totalCoin += amount continue } items = append(items, messagedomain.RewardItem{ RewardType: rewardTypeResource, Name: firstText(snapshotString(snapshot, "Name", "name"), "Reward"), ImageURL: firstText(snapshotString(snapshot, "PreviewURL", "preview_url"), snapshotString(snapshot, "AssetURL", "asset_url"), snapshotString(snapshot, "AnimationURL", "animation_url")), Quantity: grantItem.GetQuantity(), DurationDays: durationDays(grantItem.GetDurationMs()), }) } } if totalCoin > 0 { items = append(items, messagedomain.RewardItem{RewardType: rewardTypeCoin, Name: "Coins", Quantity: totalCoin}) } if len(items) == 0 { return nil, nil, xerr.New(xerr.InvalidArgument, "reward notice has no displayable items") } return items, grantIDs, nil } func rewardImageURLs(items []messagedomain.RewardItem) []string { result := make([]string, 0, len(items)) for _, item := range items { if item.RewardType == rewardTypeResource && strings.TrimSpace(item.ImageURL) != "" { result = append(result, item.ImageURL) } } return result } func durationDays(durationMS int64) int64 { if durationMS <= 0 { return 0 } // 非整日历史配置向上取整,避免实际仍有效的奖励在消息里显示 0 天。 return (durationMS + millisecondsPerDay - 1) / millisecondsPerDay } func parseSnapshot(raw string) map[string]any { var snapshot map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &snapshot); err != nil || snapshot == nil { return map[string]any{} } return snapshot } func snapshotString(snapshot map[string]any, keys ...string) string { for _, key := range keys { if value, exists := snapshot[key]; exists { if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "" { return text } } } return "" } func snapshotInt64(snapshot map[string]any, keys ...string) int64 { for _, key := range keys { switch value := snapshot[key].(type) { case float64: return int64(value) case int64: return value case json.Number: parsed, _ := value.Int64() return parsed } } return 0 } func cloneMetadata(source map[string]any) map[string]any { result := make(map[string]any, len(source)+3) for key, value := range source { result[key] = value } return result } func firstText(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" }