package broadcast import ( "context" "encoding/json" "fmt" "strings" "google.golang.org/protobuf/proto" luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1" "hyapp/pkg/appcode" "hyapp/pkg/luckygiftmq" "hyapp/pkg/xerr" broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast" ) // HandleLuckyGiftEvent 把 lucky-gift-service 在钱包返奖成功后发布的 owner fact 投影成区域 IM outbox。 // MQ 事实只负责描述已发生的中奖;activity-service 独立拥有区域展示策略和腾讯 IM 投递状态,不能反写 owner outbox。 func (s *Service) HandleLuckyGiftEvent(ctx context.Context, envelope *luckygifteventsv1.EventEnvelope) (broadcastdomain.ConsumeLuckyGiftEventResult, error) { if envelope == nil { return broadcastdomain.ConsumeLuckyGiftEventResult{}, xerr.New(xerr.InvalidArgument, "lucky gift event envelope is required") } result := broadcastdomain.ConsumeLuckyGiftEventResult{ EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped, } if envelope.GetEventType() != luckygiftmq.EventTypeLuckyGiftDrawn { // 独立 topic 未来可能增加审计类事实;未知类型必须确认跳过,不能误生成客户端无法识别的播报。 return result, nil } var event luckygifteventsv1.LuckyGiftDrawn if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil { return broadcastdomain.ConsumeLuckyGiftEventResult{}, fmt.Errorf("decode lucky gift drawn body: %w", err) } if err := validateLuckyGiftDrawnEnvelope(envelope, &event); err != nil { return broadcastdomain.ConsumeLuckyGiftEventResult{}, err } // 区域顶部飘屏只接受已经实际到账的 10 倍及以上奖励。pending/failed 事实、0 奖励和无区域事实 // 都属于合法跳过,避免展示先于钱包到账或把区域未知事件扩大成全局消息。 if !shouldPublishLuckyGiftBigWin(&event) { return result, nil } eventCtx := appcode.WithContext(ctx, envelope.GetAppCode()) sender, err := s.luckyGiftSenderProfile(eventCtx, &event) if err != nil { // 资料查询是完整 IM 快照的一部分;暂时失败必须交还 MQ 重试,不能先确认后永久退化成错误昵称。 return broadcastdomain.ConsumeLuckyGiftEventResult{}, fmt.Errorf("resolve lucky gift sender profile: %w", err) } broadcastEventID := "lucky_gift_big_win:" + event.GetDrawId() payloadJSON, err := luckyGiftBigWinPayload(envelope, &event, broadcastEventID, sender, s.now().UTC().UnixMilli()) if err != nil { return broadcastdomain.ConsumeLuckyGiftEventResult{}, err } published, err := s.PublishRegionBroadcast(eventCtx, PublishInput{ EventID: broadcastEventID, BroadcastType: broadcastdomain.TypeLuckyGiftBigWin, RegionID: event.GetVisibleRegionId(), PayloadJSON: payloadJSON, }) if err != nil { return broadcastdomain.ConsumeLuckyGiftEventResult{}, err } // im_broadcast_outbox 的 (app_code,event_id) 是本投影的幂等事实;MQ 重投只会得到 Created=false, // 不会生成第二条区域消息,也不需要读取 lucky-gift-service 的 owner outbox 状态。 return broadcastdomain.ConsumeLuckyGiftEventResult{ EventID: envelope.GetEventId(), Status: published.Status, BroadcastEventID: published.EventID, BroadcastCreated: published.Created, }, nil } func validateLuckyGiftDrawnEnvelope(envelope *luckygifteventsv1.EventEnvelope, event *luckygifteventsv1.LuckyGiftDrawn) error { if strings.TrimSpace(envelope.GetAppCode()) == "" || strings.TrimSpace(envelope.GetEventId()) == "" { return xerr.New(xerr.InvalidArgument, "lucky gift event identity is incomplete") } drawID := strings.TrimSpace(event.GetDrawId()) if drawID == "" || strings.TrimSpace(event.GetRoomId()) == "" || event.GetSenderUserId() <= 0 { return xerr.New(xerr.InvalidArgument, "lucky gift drawn fact is incomplete") } if envelopeDrawID := strings.TrimSpace(envelope.GetDrawId()); envelopeDrawID != "" && envelopeDrawID != drawID { return xerr.New(xerr.InvalidArgument, "lucky gift envelope draw_id does not match body") } return nil } func shouldPublishLuckyGiftBigWin(event *luckygifteventsv1.LuckyGiftDrawn) bool { if event == nil || !strings.EqualFold(strings.TrimSpace(event.GetRewardStatus()), "granted") { return false } return event.GetEffectiveRewardCoins() > 0 && event.GetVisibleRegionId() > 0 && event.GetMultiplierPpm() >= luckygiftmq.LuckyGiftDisplayMinMultiplierPPM } func (s *Service) luckyGiftSenderProfile(ctx context.Context, event *luckygifteventsv1.LuckyGiftDrawn) (SenderProfile, error) { profile := SenderProfile{ UserID: event.GetSenderUserId(), Account: strings.TrimSpace(event.GetSenderDisplayUserId()), PrettyDisplayUserID: strings.TrimSpace(event.GetSenderPrettyDisplayUserId()), Nickname: strings.TrimSpace(event.GetSenderName()), Avatar: strings.TrimSpace(event.GetSenderAvatar()), } // 送礼主链路已经把展示快照写进 owner fact;字段齐全时禁止回查当前资料,避免用户改名后历史事实漂移。 if profile.Nickname != "" && profile.Account != "" { return profile, nil } resolved, err := s.broadcastUserProfile(ctx, profile.UserID) if err != nil { return SenderProfile{}, err } if profile.Account == "" { profile.Account = strings.TrimSpace(resolved.Account) } if profile.PrettyDisplayUserID == "" { profile.PrettyDisplayUserID = strings.TrimSpace(resolved.PrettyDisplayUserID) } if profile.Nickname == "" { profile.Nickname = firstLuckyGiftDisplayValue(resolved.Nickname, profile.PrettyDisplayUserID, profile.Account) } if profile.Avatar == "" { profile.Avatar = strings.TrimSpace(resolved.Avatar) } return profile, nil } func firstLuckyGiftDisplayValue(values ...string) string { for _, value := range values { if normalized := strings.TrimSpace(value); normalized != "" { return normalized } } return "" } func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *luckygifteventsv1.LuckyGiftDrawn, eventID string, sender SenderProfile, sentAtMS int64) (string, error) { createdAtMS := event.GetDrawCreatedAtMs() if createdAtMS <= 0 { createdAtMS = envelope.GetOccurredAtMs() } // 字段保持拆服前 Flutter 已消费的扁平协议,并补充 granted/pretty ID 快照;不携带 gift_name, // 客户端继续用 gift_id 命中本地多语言礼物配置,避免服务端文案覆盖客户端翻译。 payload := map[string]any{ "event_type": "lucky_gift_drawn", "event_id": eventID, "broadcast_type": broadcastdomain.TypeLuckyGiftBigWin, "scope": broadcastdomain.ScopeRegion, "app_code": envelope.GetAppCode(), "region_id": event.GetVisibleRegionId(), "visible_region_id": event.GetVisibleRegionId(), "draw_id": event.GetDrawId(), "command_id": event.GetCommandId(), "pool_id": event.GetPoolId(), "room_id": event.GetRoomId(), "gift_id": event.GetGiftId(), "gift_count": event.GetGiftCount(), "user_id": event.GetSenderUserId(), "sender_user_id": event.GetSenderUserId(), "target_user_id": event.GetTargetUserId(), "sender_name": sender.Nickname, "sender_avatar": sender.Avatar, "sender_display_user_id": sender.Account, "sender_pretty_display_user_id": sender.PrettyDisplayUserID, "coin_spent": event.GetCoinSpent(), "rule_version": event.GetRuleVersion(), "experience_pool": event.GetExperiencePool(), "selected_tier_id": event.GetSelectedTierId(), "multiplier_ppm": event.GetMultiplierPpm(), "base_reward_coins": event.GetBaseRewardCoins(), "effective_reward_coins": event.GetEffectiveRewardCoins(), "reward_status": "granted", "wallet_transaction_id": event.GetWalletTransactionId(), "stage_feedback": event.GetStageFeedback(), "high_multiplier": event.GetHighMultiplier(), "created_at_ms": createdAtMS, "draw_created_at_ms": createdAtMS, "reward_granted_at_ms": event.GetRewardGrantedAtMs(), "sent_at_ms": sentAtMS, "action": map[string]any{ "type": "enter_room", "room_id": event.GetRoomId(), }, "sender": map[string]any{ "user_id": sender.UserID, "display_user_id": sender.Account, "pretty_display_user_id": sender.PrettyDisplayUserID, "nickname": sender.Nickname, "avatar": sender.Avatar, }, } encoded, err := json.Marshal(payload) return string(encoded), err }