package service import ( "context" "fmt" "math" "math/bits" "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" ) const ( luckyGiftStrategyFixedV2 = "fixed_v2" luckyGiftStrategyDynamicV3 = "dynamic_v3" ) func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) { if len(targetBillings) == 0 { return nil, nil } if len(targetBillings) == 1 { result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0]) if err != nil { return nil, err } return []*roomv1.LuckyGiftDrawResult{result}, nil } return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings) } func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*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 子交易。 luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling) if err != nil { return nil, err } drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{ LuckyGift: luckyMeta, }) 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) ([]*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") } luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling) if err != nil { return nil, err } // 多人送礼只跨服务调用一次;lucky-gift-service 在一个事务内按这个顺序推进锁和抽奖事实。 req.LuckyGifts = append(req.LuckyGifts, luckyMeta) } 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) (*luckygiftv1.LuckyGiftMeta, error) { paidAtMS := targetBilling.Billing.GetPaidAtMs() if paidAtMS <= 0 && normalizeLuckyGiftStrategyVersion(cmd.LuckyGiftStrategyVersion) == luckyGiftStrategyDynamicV3 { // dynamic_v3 所有时间窗口必须归属 wallet owner 的实际交易;缺字段通常表示 wallet 尚未滚动升级, // 已扣费 saga 保持可恢复并等待 owner 回执补齐,绝不能拿 room mutate/recovery 时钟继续开奖。 return nil, xerr.New(xerr.Unavailable, "wallet paid_at_ms is required for dynamic lucky gift") } return &luckygiftv1.LuckyGiftMeta{ Meta: luckyGiftMetaFromRoom(ctx, meta), CommandId: targetBilling.CommandID, UserId: cmd.ActorUserID(), TargetUserId: targetBilling.TargetUserID, // DeviceID 已在首次请求进入 gift operation 时随 command.Base 持久化,恢复必须继续使用该值。 // 空值原样传给 owner:dynamic_v3 会跳过设备日额度但保留其他风控;绝不用 session_id/command_id 伪造设备。 DeviceId: cmd.DeviceID, RoomId: cmd.RoomID(), AnchorId: luckyGiftAnchorID(roomMeta), GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, CoinSpent: targetBilling.Billing.GetCoinSpent(), // PaidAtMs 只来自当前目标的 wallet transaction;batch 每个 target 也保留自己的 owner 回执, // 不使用聚合回执、Room Cell mutate 时钟或 gift_operation.created_at_ms。 PaidAtMs: paidAtMS, PoolId: cmd.PoolID, VisibleRegionId: roomMeta.VisibleRegionID, CountryId: cmd.SenderCountryID, // 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化;owner 只用它判断 // 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。 UserRegisteredAtMs: cmd.SenderRegisteredAtMS, // 展示资料来自 gateway 本次送礼入口的用户快照,随抽奖事实持久化后,下游不需要在异步消费时反查当前资料。 SenderName: giftDisplayName(cmd.SenderDisplayProfile), SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), // 充值分层和短时加权只消费 wallet 扣费回执里固化的 owner 快照;room 不查询充值流水,也不按当前时间重新计算。 Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(), Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(), LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(), // LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 已按后台 // “主播钻石比例”实际入账的钻石,不能误传独立的“返还金币比例”结果,否则 0% 返币会阻断开奖。 GiftIncomeCoins: targetBilling.Billing.GetHostPeriodDiamondAdded(), }, nil } func normalizeLuckyGiftStrategyVersion(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { // 旧 lucky-gift 版本没有返回 strategy_version;它发布时只有 fixed_v2,故仅该空值兼容成 fixed_v2。 return luckyGiftStrategyFixedV2 } return value } 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 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 } mapped := &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, } for _, hit := range result.GetHits() { if hit == nil || hit.GetRewardCoins() <= 0 { continue } mapped.Hits = append(mapped.Hits, &roomv1.SendGiftLuckyHit{ GiftIndex: hit.GetGiftIndex(), DrawId: hit.GetDrawId(), TargetUserId: targetUserID, SelectedTierId: hit.GetSelectedTierId(), MultiplierPpm: hit.GetMultiplierPpm(), RewardCoins: hit.GetRewardCoins(), }) } return mapped } 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 aggregate.Hits = nil 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() aggregate.Hits = append(aggregate.Hits, result.GetHits()...) 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 } coinSpent := targetBillings[0].Billing.GetCoinSpent() multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000) // 未显式指定中奖额时必须按同一 multiplier 推导,避免 20x 文案却展示 5x 金额;计算饱和防止异常配置溢出。 defaultRewardCoins := scaledRobotRewardCoins(coinSpent, multiplier) rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, defaultRewardCoins) drawID := fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli()) return &roomv1.LuckyGiftDrawResult{ Enabled: true, DrawId: drawID, 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, Hits: []*roomv1.SendGiftLuckyHit{{ GiftIndex: 1, DrawId: drawID, TargetUserId: cmd.TargetUserID, MultiplierPpm: multiplier, RewardCoins: rewardCoins, }}, } } func scaledRobotRewardCoins(coinSpent int64, multiplierPPM int64) int64 { if coinSpent <= 0 || multiplierPPM <= 0 { return 0 } // bits.Mul64 + Div64 完成无符号 128 位乘除;高 64 位已不小于除数时,商超出 uint64,直接饱和。 high, low := bits.Mul64(uint64(coinSpent), uint64(multiplierPPM)) if high >= 1_000_000 { return math.MaxInt64 } reward, _ := bits.Div64(high, low, 1_000_000) if reward > math.MaxInt64 { return math.MaxInt64 } return int64(reward) } 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 }