package service import ( "context" "fmt" "strings" "time" "google.golang.org/protobuf/proto" luckygiftv1 "hyapp.local/api/proto/luckygift/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/command" ) func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) { if len(targetBillings) == 0 { return nil, nil } if len(targetBillings) == 1 { result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0], now) if err != nil { return nil, err } return []*roomv1.LuckyGiftDrawResult{result}, nil } return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings, now) } func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) (*roomv1.LuckyGiftDrawResult, error) { if targetBilling.Billing == nil { return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty") } // 抽奖必须按每个收礼目标独立落事实;command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。 drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{ LuckyGift: luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now), }) if err != nil { return nil, err } if drawResp == nil || drawResp.GetResult() == nil { return nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty") } // 同步返回只承载抽奖表现事实;返奖后的余额由 activity 在中奖落库后发送钱包私有 IM,避免 HTTP 响应领先账务通知链路。 return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil } func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) { req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))} for _, targetBilling := range targetBillings { if targetBilling.Billing == nil { return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty") } // 多人送礼只跨服务调用一次;activity-service 在一个事务内按这个顺序推进锁和抽奖事实。 req.LuckyGifts = append(req.LuckyGifts, luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now)) } resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req) if err != nil { return nil, err } if resp == nil || len(resp.GetResults()) != len(targetBillings) { return nil, xerr.New(xerr.Unavailable, "lucky gift batch draw response is incomplete") } results := make([]*roomv1.LuckyGiftDrawResult, 0, len(targetBillings)) for index, result := range resp.GetResults() { if result == nil { return nil, xerr.New(xerr.Unavailable, "lucky gift batch draw result is empty") } results = append(results, luckyGiftResultFromProto(result, targetBillings[index].TargetUserID)) } return results, nil } func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) *luckygiftv1.LuckyGiftMeta { return &luckygiftv1.LuckyGiftMeta{ Meta: luckyGiftMetaFromRoom(ctx, meta), CommandId: targetBilling.CommandID, UserId: cmd.ActorUserID(), TargetUserId: targetBilling.TargetUserID, // 目前没有独立设备 ID 字段,优先用 session_id;没有时退回目标子 command_id,保证每次抽奖 scope 不为空。 DeviceId: luckyGiftDeviceID(cmd, targetBilling.CommandID), RoomId: cmd.RoomID(), AnchorId: luckyGiftAnchorID(roomMeta), GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, CoinSpent: targetBilling.Billing.GetCoinSpent(), // 房间链路统一使用 UTC epoch ms;不能把本地时区时间传给幸运礼物 RTP 窗口。 PaidAtMs: now.UTC().UnixMilli(), PoolId: cmd.PoolID, VisibleRegionId: roomMeta.VisibleRegionID, CountryId: cmd.SenderCountryID, } } func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool { if s.luckyGift == nil { return false } switch strings.TrimSpace(giftTypeCode) { case "lucky", "super_lucky": return true default: return strings.TrimSpace(poolID) != "" } } func (s *Service) withLuckyGiftSendLock(ctx context.Context, enabled bool, userID int64, fn func() error) error { if !enabled || s == nil || s.luckyGiftSendLocker == nil { return fn() } release, err := s.luckyGiftSendLocker.AcquireLuckyGiftSendLock(ctx, appcode.FromContext(ctx), userID, s.luckyGiftSendLockTTL) if err != nil { return err } if release != nil { defer func() { releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() release(releaseCtx) }() } return fn() } func luckyGiftMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *luckygiftv1.RequestMeta { return &luckygiftv1.RequestMeta{ RequestId: meta.GetRequestId(), Caller: "room-service", GatewayNodeId: meta.GetGatewayNodeId(), SentAtMs: time.Now().UTC().UnixMilli(), AppCode: appcode.FromContext(ctx), } } func luckyGiftDeviceID(cmd command.SendGift, fallbackCommandID string) string { if value := strings.TrimSpace(cmd.SessionID); value != "" { return value } if value := strings.TrimSpace(fallbackCommandID); value != "" { return value } return cmd.ID() } func luckyGiftAnchorID(roomMeta RoomMeta) string { if roomMeta.OwnerUserID <= 0 { return roomMeta.RoomID } return fmt.Sprintf("%d", roomMeta.OwnerUserID) } func luckyGiftResultFromProto(result *luckygiftv1.LuckyGiftDrawResult, targetUserID int64) *roomv1.LuckyGiftDrawResult { if result == nil { return nil } return &roomv1.LuckyGiftDrawResult{ Enabled: true, DrawId: result.GetDrawId(), CommandId: result.GetCommandId(), PoolId: result.GetPoolId(), GiftId: result.GetGiftId(), RuleVersion: result.GetRuleVersion(), ExperiencePool: result.GetExperiencePool(), SelectedTierId: result.GetSelectedTierId(), MultiplierPpm: result.GetMultiplierPpm(), BaseRewardCoins: result.GetBaseRewardCoins(), EffectiveRewardCoins: result.GetEffectiveRewardCoins(), RewardStatus: result.GetRewardStatus(), StageFeedback: result.GetStageFeedback(), HighMultiplier: result.GetHighMultiplier(), CreatedAtMs: result.GetCreatedAtMs(), WalletTransactionId: result.GetWalletTransactionId(), // 这里故意不透出 activity 返回的返奖后余额。SendGift HTTP 只承诺扣费后的真实余额,中奖返奖后的余额必须等钱包落账后由 WalletBalanceChanged 私有 IM 覆盖。 CoinBalanceAfter: 0, TargetUserId: targetUserID, } } func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult { if len(results) == 0 { return nil } if len(results) == 1 { return results[0] } aggregate := proto.Clone(results[0]).(*roomv1.LuckyGiftDrawResult) aggregate.CommandId = commandID aggregate.SelectedTierId = "batch" aggregate.TargetUserId = 0 aggregate.WalletTransactionId = "" aggregate.MultiplierPpm = 0 aggregate.BaseRewardCoins = 0 aggregate.EffectiveRewardCoins = 0 aggregate.RewardStatus = "granted" aggregate.StageFeedback = false aggregate.HighMultiplier = false aggregate.CoinBalanceAfter = 0 for _, result := range results { if result == nil { continue } aggregate.RuleVersion = result.GetRuleVersion() aggregate.ExperiencePool = result.GetExperiencePool() aggregate.CreatedAtMs = result.GetCreatedAtMs() aggregate.MultiplierPpm += result.GetMultiplierPpm() aggregate.BaseRewardCoins += result.GetBaseRewardCoins() aggregate.EffectiveRewardCoins += result.GetEffectiveRewardCoins() aggregate.StageFeedback = aggregate.StageFeedback || result.GetStageFeedback() aggregate.HighMultiplier = aggregate.HighMultiplier || result.GetHighMultiplier() switch result.GetRewardStatus() { case "pending": aggregate.RewardStatus = "pending" case "failed": if aggregate.RewardStatus != "pending" { aggregate.RewardStatus = "failed" } case "granted": default: if aggregate.RewardStatus == "" { aggregate.RewardStatus = result.GetRewardStatus() } } } return aggregate } func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetBilling, now time.Time) *roomv1.LuckyGiftDrawResult { if len(targetBillings) == 0 || targetBillings[0].Billing == nil { return nil } rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, targetBillings[0].Billing.GetCoinSpent()*5) multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000) return &roomv1.LuckyGiftDrawResult{ Enabled: true, DrawId: fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli()), CommandId: cmd.ID(), PoolId: cmd.PoolID, GiftId: cmd.GiftID, MultiplierPpm: multiplier, BaseRewardCoins: rewardCoins, EffectiveRewardCoins: rewardCoins, RewardStatus: "granted", StageFeedback: true, HighMultiplier: multiplier >= 5000000, CreatedAtMs: now.UnixMilli(), TargetUserId: cmd.TargetUserID, } } func giftFinalCoinBalanceAfter(billing *walletv1.DebitGiftResponse) int64 { if billing == nil { return 0 } // SendGift 的 HTTP 余额只表示本次送礼扣费完成后的 COIN 余额;lucky/super_lucky 返奖属于后续入账事实,由钱包余额 IM 更新客户端。 return billing.GetBalanceAfter() } func firstPositiveInt64(values ...int64) int64 { for _, value := range values { if value > 0 { return value } } return 0 }