package service import ( "context" "errors" "fmt" "strings" "time" "google.golang.org/protobuf/proto" luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/luckygiftmq" "hyapp/services/room-service/internal/room/outbox" ) const roomLuckyGiftDrawnEventType = "RoomLuckyGiftDrawn" // HandleLuckyGiftOutboxEvent 把 lucky-gift-service 已完成返奖的 owner 事实持久化为房间展示 outbox。 // 中奖展示不改变房间状态,因此不能伪造 Room Cell 命令、推进 room_version 或写 command log; // room_outbox 的稳定 event_id 同时承担本服务消费幂等和腾讯云 IM 失败补偿。 func (s *Service) HandleLuckyGiftOutboxEvent(ctx context.Context, envelope *luckygifteventsv1.EventEnvelope) error { if s == nil || s.repository == nil { return errors.New("room repository is not configured") } if envelope == nil || envelope.GetEventType() != luckygiftmq.EventTypeLuckyGiftDrawn { return errors.New("unexpected lucky gift outbox event") } var drawn luckygifteventsv1.LuckyGiftDrawn if err := proto.Unmarshal(envelope.GetBody(), &drawn); err != nil { return fmt.Errorf("decode lucky gift drawn body: %w", err) } if err := validateLuckyGiftDrawnEnvelope(envelope, &drawn); err != nil { return err } // owner 发布全部正向返奖事实;房间金色飘屏只消费达到统一 10x 门槛的 granted 中奖。 // 低倍事实没有房间副作用,重复投递仍保持同样的 no-op 结果。 if !strings.EqualFold(strings.TrimSpace(drawn.GetRewardStatus()), "granted") || drawn.GetEffectiveRewardCoins() <= 0 || drawn.GetMultiplierPpm() < luckygiftmq.LuckyGiftDisplayMinMultiplierPPM { return nil } body := &roomeventsv1.RoomLuckyGiftDrawn{ DrawId: drawn.GetDrawId(), CommandId: drawn.GetCommandId(), PoolId: drawn.GetPoolId(), GiftId: drawn.GetGiftId(), GiftCount: drawn.GetGiftCount(), SenderUserId: drawn.GetSenderUserId(), TargetUserId: drawn.GetTargetUserId(), SenderName: drawn.GetSenderName(), SenderAvatar: drawn.GetSenderAvatar(), SenderDisplayUserId: drawn.GetSenderDisplayUserId(), SenderPrettyDisplayUserId: drawn.GetSenderPrettyDisplayUserId(), VisibleRegionId: drawn.GetVisibleRegionId(), CountryId: drawn.GetCountryId(), CoinSpent: drawn.GetCoinSpent(), RuleVersion: drawn.GetRuleVersion(), ExperiencePool: drawn.GetExperiencePool(), SelectedTierId: drawn.GetSelectedTierId(), MultiplierPpm: drawn.GetMultiplierPpm(), BaseRewardCoins: drawn.GetBaseRewardCoins(), EffectiveRewardCoins: drawn.GetEffectiveRewardCoins(), StageFeedback: drawn.GetStageFeedback(), HighMultiplier: drawn.GetHighMultiplier(), RewardStatus: "granted", WalletTransactionId: drawn.GetWalletTransactionId(), DrawCreatedAtMs: drawn.GetDrawCreatedAtMs(), RewardGrantedAtMs: drawn.GetRewardGrantedAtMs(), } record, err := outbox.Build(drawn.GetRoomId(), roomLuckyGiftDrawnEventType, 0, time.UnixMilli(envelope.GetOccurredAtMs()).UTC(), body) if err != nil { return err } // owner event_id 是跨服务唯一事实 ID;覆盖 room 本地随机 ID 后,MQ 重投只能命中同一条 room_outbox。 record.AppCode = strings.TrimSpace(envelope.GetAppCode()) record.EventID = strings.TrimSpace(envelope.GetEventId()) record.Envelope.AppCode = record.AppCode record.Envelope.EventId = record.EventID ctx = appcode.WithContext(ctx, record.AppCode) if err := s.repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil { return err } // 唤醒必须发生在 SaveOutbox 提交之后;重复事实最多产生一个多余唤醒,不会产生第二条 IM outbox。 s.notifyOutboxCommitted([]outbox.Record{record}) return nil } func validateLuckyGiftDrawnEnvelope(envelope *luckygifteventsv1.EventEnvelope, drawn *luckygifteventsv1.LuckyGiftDrawn) error { if envelope == nil || drawn == nil { return errors.New("lucky gift drawn fact is required") } appCode := strings.TrimSpace(envelope.GetAppCode()) drawID := strings.TrimSpace(drawn.GetDrawId()) eventID := strings.TrimSpace(envelope.GetEventId()) if appCode == "" || drawID == "" || drawID != strings.TrimSpace(envelope.GetDrawId()) || strings.TrimSpace(drawn.GetRoomId()) == "" || strings.TrimSpace(drawn.GetCommandId()) == "" || strings.TrimSpace(drawn.GetGiftId()) == "" || drawn.GetGiftCount() <= 0 || drawn.GetSenderUserId() <= 0 || drawn.GetTargetUserId() <= 0 || envelope.GetOccurredAtMs() <= 0 || drawn.GetDrawCreatedAtMs() <= 0 || drawn.GetRewardGrantedAtMs() <= 0 { return errors.New("lucky gift drawn fact is incomplete") } if eventID != "lucky_gift_drawn:"+drawID { // 同一 draw 只能映射到一个房间 IM 幂等键;不能接受 producer 临时生成的随机 event_id。 return errors.New("lucky gift drawn event_id does not match draw_id") } return nil }