package service import ( "context" "fmt" "strconv" "strings" "time" "google.golang.org/protobuf/proto" activityv1 "hyapp.local/api/proto/activity/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/state" ) // SendGift 先同步调用 wallet,再在 Room Cell 内完成热度、榜单和房间事件落地。 func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) { return s.sendGift(ctx, req, robotGiftOptions{}) } type RobotSendGiftInput struct { Meta *roomv1.RequestMeta TargetUserID int64 GiftID string GiftCount int32 PoolID string RobotUserIDs []int64 SyntheticRewardCoins int64 SyntheticMultiplierPPM int64 RealRoomHeat bool } type robotGiftOptions struct { Enabled bool RobotUserIDs []int64 SyntheticRewardCoins int64 SyntheticMultiplierPPM int64 RealRoomHeat bool } func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (*roomv1.SendGiftResponse, error) { req := &roomv1.SendGiftRequest{ Meta: input.Meta, TargetUserId: input.TargetUserID, TargetUserIds: []int64{input.TargetUserID}, GiftId: input.GiftID, GiftCount: input.GiftCount, TargetType: "user", PoolId: input.PoolID, } return s.sendGift(ctx, req, robotGiftOptions{ Enabled: true, RobotUserIDs: input.RobotUserIDs, SyntheticRewardCoins: input.SyntheticRewardCoins, SyntheticMultiplierPPM: input.SyntheticMultiplierPPM, RealRoomHeat: input.RealRoomHeat, }) } func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, robotOptions robotGiftOptions) (*roomv1.SendGiftResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) // SendGift 的账务不在 room-service 内结算,但房间表现、热度和榜单由 Room Cell 同步更新。 cmd := command.SendGift{ Base: baseFromMeta(req.GetMeta()), TargetType: normalizeGiftTargetType(req.GetTargetType()), TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()), GiftID: req.GetGiftId(), PoolID: strings.TrimSpace(req.GetPoolId()), GiftCount: req.GetGiftCount(), EntitlementID: strings.TrimSpace(req.GetEntitlementId()), Source: strings.TrimSpace(req.GetSource()), SenderCountryID: req.GetSenderCountryId(), SenderRegionID: req.GetSenderRegionId(), TargetIsHost: req.GetTargetIsHost(), // 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。 TargetHostRegionID: req.GetTargetHostRegionId(), TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), TargetHostScopes: giftTargetHostScopesFromProto(req.GetTargetHostScopes()), SenderDisplayProfile: giftDisplayProfileFromProto(req.GetSenderDisplayProfile()), TargetDisplayProfiles: giftDisplayProfilesFromProto(req.GetTargetDisplayProfiles()), RobotGift: robotOptions.Enabled && !robotOptions.RealRoomHeat, RobotWalletGift: robotOptions.Enabled, SyntheticLuckyGift: robotOptions.Enabled && strings.TrimSpace(req.GetPoolId()) != "", SyntheticLuckyRewardCoins: firstPositiveInt64(robotOptions.SyntheticRewardCoins, 0), SyntheticLuckyMultiplierPPM: firstPositiveInt64(robotOptions.SyntheticMultiplierPPM, 1000000), } if cmd.TargetType == "" { cmd.TargetType = "user" } if cmd.TargetType == "user" && len(cmd.TargetUserIDs) > 0 { cmd.TargetUserID = cmd.TargetUserIDs[0] } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error) { if _, exists := current.OnlineUsers[cmd.ActorUserID()]; !exists { // 送礼者必须在房间业务 presence 内。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "sender not in room") } if cmd.TargetType != "user" { // v1 HTTP 契约已预留 all_mic/all_room/couple;账务拆单和房间表现策略补齐前先 fail-close。 return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_type is unsupported") } if len(cmd.TargetUserIDs) == 0 || cmd.TargetUserID <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_ids is required") } for _, targetUserID := range cmd.TargetUserIDs { if _, exists := current.OnlineUsers[targetUserID]; !exists { // 每个接收方都必须在房间 presence 内;批量扣费前先全部校验,避免钱包出现无房间表现的入账。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not in room") } } if robotOptions.Enabled { if err := requireRobotGiftParticipants(current, cmd.ActorUserID(), cmd.TargetUserIDs, robotOptions.RobotUserIDs, !robotOptions.RealRoomHeat); err != nil { return mutationResult{}, nil, err } } if cmd.GiftID == "" || cmd.GiftCount <= 0 { // 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。 return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid") } if strings.EqualFold(cmd.Source, "bag") && strings.TrimSpace(cmd.EntitlementID) == "" { // 背包送礼只改变扣费来源,不改变房间事件;因此必须在扣费前明确指定要扣的权益,不能让 wallet 猜测某个库存。 return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "entitlement_id is required for bag gift") } roomMeta, exists, err := s.repository.GetRoomMeta(ctx, cmd.RoomID()) if err != nil { return mutationResult{}, nil, err } if !exists { return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found") } luckyEnabled := false if cmd.PoolID != "" && !cmd.SyntheticLuckyGift { // 客户端显式传 pool_id 时必须先检查活动规则;规则未启用就拒绝,避免先扣费再发现不能抽奖。 if s.luckyGift == nil { return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift service is not configured") } checkResp, err := s.luckyGift.CheckLuckyGift(ctx, &activityv1.CheckLuckyGiftRequest{ Meta: activityMetaFromRoom(ctx, req.GetMeta()), UserId: cmd.ActorUserID(), RoomId: cmd.RoomID(), GiftId: cmd.GiftID, PoolId: cmd.PoolID, }) if err != nil { return mutationResult{}, nil, err } if checkResp == nil || !checkResp.GetEnabled() { return mutationResult{}, nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") } // Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。 luckyEnabled = true } rocketConfig, err := s.roomRocketConfig(ctx) if err != nil { return mutationResult{}, nil, err } var billing *walletv1.DebitGiftResponse var targetBillings []giftTargetBilling var walletDebitMS int64 settledCommand := cmd var luckyGift *roomv1.LuckyGiftDrawResult var luckyGifts []*roomv1.LuckyGiftDrawResult if err := s.withLuckyGiftSendLock(ctx, luckyEnabled, cmd.ActorUserID(), func() error { // 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。 walletStartedAt := time.Now() var err error billing, targetBillings, err = s.debitGiftTargets(ctx, cmd, roomMeta) walletDebitMS = elapsedMS(walletStartedAt) if err != nil { return err } if billing == nil { return xerr.New(xerr.Unavailable, "wallet debit response is empty") } settledCommand.BillingReceiptID = billing.GetBillingReceiptId() settledCommand.CoinSpent = billing.GetCoinSpent() settledCommand.HeatValue = billing.GetHeatValue() settledCommand.PriceVersion = billing.GetPriceVersion() settledCommand.GiftTypeCode = billing.GetGiftTypeCode() settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded() settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey() if cmd.SyntheticLuckyGift { luckyGift = syntheticLuckyGiftResult(cmd, targetBillings, now) if luckyGift != nil { luckyGifts = []*roomv1.LuckyGiftDrawResult{luckyGift} } } else if !luckyEnabled { // 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。 luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode()) } if !cmd.SyntheticLuckyGift && luckyEnabled { luckyGifts, err = s.executeLuckyGiftDraws(ctx, req.GetMeta(), cmd, roomMeta, targetBillings, now) if err != nil { // 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。 return err } if len(luckyGifts) > 0 { // lucky_gifts 保留每个收礼目标的独立抽奖事实;lucky_gift 返回整次送礼的聚合表现,复用已有批量送礼的倍率累加语义。 luckyGift = aggregateLuckyGiftResults(cmd.ID(), luckyGifts) } } return nil }); err != nil { return mutationResult{}, nil, err } heatValue := billing.GetHeatValue() // 扣费成功后,Room Cell 同步更新热度和本地礼物榜。 current.Heat += heatValue current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now) targetGiftValueDeltas := make(map[int64]int64, len(targetBillings)) targetCurrentGiftValues := make(map[int64]int64, len(targetBillings)) for _, targetBilling := range targetBillings { if targetBilling.Billing == nil || targetBilling.TargetUserID <= 0 { continue } targetGiftValue := targetBilling.Billing.GetHeatValue() if userState := current.OnlineUsers[targetBilling.TargetUserID]; userState != nil { // 收礼用户热度完全使用 wallet-service 已结算的 HeatValue,room-service 不重新判断普通/幸运/超级幸运比例。 if targetGiftValue > 0 { userState.GiftValue += targetGiftValue targetGiftValueDeltas[targetBilling.TargetUserID] += targetGiftValue } targetCurrentGiftValues[targetBilling.TargetUserID] = userState.GiftValue } } if len(targetGiftValueDeltas) > 0 { // 命令日志只保存每个目标本次增量;累计值由在线用户态按 replay 顺序重建。 settledCommand.TargetGiftValues = targetGiftValueDeltas } current.Version++ rocketApply, err := s.applyRoomRocketGift(now, current, rocketConfig, cmd, &settledCommand, billing, roomMeta) if err != nil { return mutationResult{}, nil, err } commandPayload, err := command.Serialize(settledCommand) if err != nil { return mutationResult{}, nil, err } // 送礼事件按 target 拆分,保证 IM、统计和礼物墙消费者拿到准确的接收方和账务回执。 giftEvents := make([]outbox.Record, 0, len(targetBillings)) for _, targetBilling := range targetBillings { targetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, targetBilling.TargetUserID) giftEvent, err := outbox.Build(current.RoomID, "RoomGiftSent", current.Version, now, &roomeventsv1.RoomGiftSent{ SenderUserId: cmd.ActorUserID(), TargetUserId: targetBilling.TargetUserID, GiftId: cmd.GiftID, PoolId: cmd.PoolID, GiftCount: cmd.GiftCount, GiftValue: targetBilling.Billing.GetHeatValue(), CoinSpent: targetBilling.Billing.GetCoinSpent(), BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(), VisibleRegionId: roomMeta.VisibleRegionID, CountryId: cmd.SenderCountryID, RegionId: firstNonZeroInt64(cmd.SenderRegionID, roomMeta.VisibleRegionID), CommandId: targetBilling.CommandID, GiftTypeCode: targetBilling.Billing.GetGiftTypeCode(), CpRelationType: targetBilling.Billing.GetCpRelationType(), GiftName: targetBilling.Billing.GetGiftName(), GiftIconUrl: targetBilling.Billing.GetGiftIconUrl(), GiftAnimationUrl: targetBilling.Billing.GetGiftAnimationUrl(), TargetGiftValue: targetCurrentGiftValues[targetBilling.TargetUserID], SenderName: giftDisplayName(cmd.SenderDisplayProfile), SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), ReceiverNickname: giftDisplayName(targetDisplayProfile), ReceiverAvatar: strings.TrimSpace(targetDisplayProfile.Avatar), ReceiverDisplayUserId: strings.TrimSpace(targetDisplayProfile.DisplayUserID), ReceiverPrettyDisplayUserId: strings.TrimSpace(targetDisplayProfile.PrettyDisplayUserID), // effect_types 来自后台礼物配置,房间事件必须原样带出,后续 activity-service 才能识别全服广播标识。 GiftEffectTypes: targetBilling.Billing.GetGiftEffectTypes(), IsRobotGift: cmd.RobotWalletGift, SyntheticLuckyGift: cmd.SyntheticLuckyGift, RealRoomRobotGift: cmd.RobotWalletGift && !cmd.RobotGift, }) if err != nil { return mutationResult{}, nil, err } giftEvents = append(giftEvents, giftEvent) } records := make([]outbox.Record, 0, len(giftEvents)+4) robotRecords := make([]outbox.Record, 0, len(giftEvents)+5) // RobotWalletGift 只表示这笔礼物由机器人钱包结算;真实房机器人仍会同步写房间贡献、火箭和麦位热度,但展示事件必须走独立 outbox,避免机器人流量抢占真人主 outbox。 robotOutboxGift := cmd.RobotWalletGift if robotOutboxGift { robotRecords = append(robotRecords, giftEvents...) } else { records = append(records, giftEvents...) } heatEvent, err := outbox.Build(current.RoomID, "RoomHeatChanged", current.Version, now, &roomeventsv1.RoomHeatChanged{ Delta: heatValue, CurrentHeat: current.Heat, }) if err != nil { return mutationResult{}, nil, err } rankItem := findRankItem(current.GiftRank, cmd.ActorUserID()) rankEvent, err := outbox.Build(current.RoomID, "RoomRankChanged", current.Version, now, &roomeventsv1.RoomRankChanged{ UserId: rankItem.UserID, Score: rankItem.Score, GiftValue: rankItem.GiftValue, }) if err != nil { return mutationResult{}, nil, err } if robotOutboxGift { // 房间热度和麦位收礼热度仍要给客户端展示;是否真实计入统计由 RobotGift 语义单独控制,不能再拿投递通道反推业务真实性。 robotRecords = append(robotRecords, heatEvent, rankEvent) } else { records = append(records, heatEvent, rankEvent) } var rocketProgress *roomRocketProgressPending if !cmd.RobotGift { // 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人通道投递。 if rocketApply.progressEvent != nil { progressRecord, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent) if err != nil { return mutationResult{}, nil, err } progressRecord.AppCode = appcode.FromContext(ctx) if progressRecord.Envelope != nil { progressRecord.Envelope.AppCode = progressRecord.AppCode } // 燃料变化是高频展示事件,不逐笔写 outbox;命令提交成功后由内存合并器按 1 秒窗口只落最大进度。 rocketProgress = &roomRocketProgressPending{ record: progressRecord, robotLane: robotOutboxGift, rocketID: rocketApply.progressEvent.GetRocketId(), level: rocketApply.progressEvent.GetLevel(), currentFuel: rocketApply.progressEvent.GetCurrentFuel(), roomVersion: current.Version, } } if rocketApply.ignited != nil { ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, rocketApply.ignited) if err != nil { return mutationResult{}, nil, err } if robotOutboxGift { robotRecords = append(robotRecords, ignitedEvent) } else { records = append(records, ignitedEvent) } } } if cmd.SyntheticLuckyGift && luckyGift != nil { drawEvent, err := outbox.Build(current.RoomID, "RoomRobotLuckyGiftDrawn", current.Version, now, &roomeventsv1.RoomRobotLuckyGiftDrawn{ DrawId: luckyGift.GetDrawId(), CommandId: cmd.ID(), SenderUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, PoolId: cmd.PoolID, MultiplierPpm: luckyGift.GetMultiplierPpm(), BaseRewardCoins: luckyGift.GetBaseRewardCoins(), RoomAtmosphereRewardCoins: luckyGift.GetRoomAtmosphereRewardCoins(), ActivitySubsidyCoins: luckyGift.GetActivitySubsidyCoins(), EffectiveRewardCoins: luckyGift.GetEffectiveRewardCoins(), CreatedAtMs: luckyGift.GetCreatedAtMs(), VisibleRegionId: roomMeta.VisibleRegionID, IsRobot: true, Synthetic: true, }) if err != nil { return mutationResult{}, nil, err } if robotOutboxGift { robotRecords = append(robotRecords, drawEvent) } else { records = append(records, drawEvent) } } firstTargetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, cmd.TargetUserID) result := mutationResult{ snapshot: current.ToProto(), billingReceiptID: billing.GetBillingReceiptId(), roomHeat: current.Heat, giftRank: cloneProtoRank(current.GiftRank), luckyGift: luckyGift, luckyGifts: luckyGifts, commandPayload: commandPayload, walletDebitMS: walletDebitMS, robotOutboxRecords: robotRecords, roomRocketProgress: rocketProgress, roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{ AppCode: appcode.FromContext(ctx), RoomID: current.RoomID, CoinSpent: roomGiftLeaderboardValue(billing), OccurredAtMS: now.UnixMilli(), }, roomUserGiftStats: []RoomUserGiftStatIncrement{ { AppCode: appcode.FromContext(ctx), RoomID: current.RoomID, UserID: cmd.ActorUserID(), GiftValue: heatValue, LastGiftAtMS: now.UnixMilli(), }, }, roomWeeklyContribution: &RoomWeeklyContributionIncrement{ AppCode: appcode.FromContext(ctx), RoomID: current.RoomID, GiftValue: heatValue, OccurredMS: now.UnixMilli(), }, syncEvent: &tencentim.RoomEvent{ // 同步广播只选 GiftSent 主事件作为客户端房间系统消息,Heat/Rank 通过字段携带。 EventID: giftEvents[0].EventID, RoomID: current.RoomID, EventType: "room_gift_sent", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, GiftValue: heatValue, RoomHeat: current.Heat, RoomVersion: current.Version, Attributes: map[string]string{ "gift_id": cmd.GiftID, "pool_id": cmd.PoolID, "gift_count": fmt.Sprintf("%d", cmd.GiftCount), "billing_receipt_id": billing.GetBillingReceiptId(), "coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent), "target_count": fmt.Sprintf("%d", len(cmd.TargetUserIDs)), "target_user_ids": giftTargetUserIDsAttribute(cmd.TargetUserIDs), "price_version": settledCommand.PriceVersion, "host_period_diamond_added": fmt.Sprintf("%d", settledCommand.HostPeriodDiamondAdded), "host_period_cycle_key": settledCommand.HostPeriodCycleKey, "gift_type_code": billing.GetGiftTypeCode(), "cp_relation_type": billing.GetCpRelationType(), "gift_name": billing.GetGiftName(), "gift_icon_url": billing.GetGiftIconUrl(), "gift_animation_url": billing.GetGiftAnimationUrl(), "target_gift_value": fmt.Sprintf("%d", targetCurrentGiftValues[cmd.TargetUserID]), "is_robot_gift": fmt.Sprintf("%t", cmd.RobotWalletGift), "synthetic_lucky_gift": fmt.Sprintf("%t", cmd.SyntheticLuckyGift), "sender_name": giftDisplayName(cmd.SenderDisplayProfile), "sender_avatar": strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), "sender_display_user_id": strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), "sender_pretty_display_user_id": strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), "receiver_nickname": giftDisplayName(firstTargetDisplayProfile), "receiver_avatar": strings.TrimSpace(firstTargetDisplayProfile.Avatar), "receiver_display_user_id": strings.TrimSpace(firstTargetDisplayProfile.DisplayUserID), "receiver_pretty_display_user_id": strings.TrimSpace(firstTargetDisplayProfile.PrettyDisplayUserID), }, }, } if cmd.RobotGift { // 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。 result.roomGiftLeaderboard = nil result.roomUserGiftStats = nil result.roomWeeklyContribution = nil } return result, records, nil }) if err != nil { return nil, err } return &roomv1.SendGiftResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), BillingReceiptId: result.billingReceiptID, RoomHeat: result.roomHeat, GiftRank: result.giftRank, Room: result.snapshot, Rocket: result.snapshot.GetRocket(), LuckyGift: result.luckyGift, LuckyGifts: result.luckyGifts, }, nil } type giftTargetBilling struct { TargetUserID int64 CommandID string Billing *walletv1.DebitGiftResponse } func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, roomMeta RoomMeta) (*walletv1.DebitGiftResponse, []giftTargetBilling, error) { if cmd.RobotWalletGift { if len(cmd.TargetUserIDs) != 1 { return nil, nil, xerr.New(xerr.InvalidArgument, "robot gift only supports one target") } billing, err := s.wallet.DebitRobotGift(ctx, &walletv1.DebitRobotGiftRequest{ CommandId: cmd.ID(), RoomId: cmd.RoomID(), SenderUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, AppCode: appcode.FromContext(ctx), RegionId: roomMeta.VisibleRegionID, SenderRegionId: cmd.SenderRegionID, }) if err != nil { return nil, nil, err } return billing, []giftTargetBilling{{TargetUserID: cmd.TargetUserID, CommandID: cmd.ID(), Billing: billing}}, nil } if len(cmd.TargetUserIDs) == 1 { targetScope := giftTargetHostScopeFor(cmd, cmd.TargetUserID) // 单目标保留原 wallet command_id,避免改变已有幂等键、账务回执和排障路径。 billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{ CommandId: cmd.ID(), RoomId: cmd.RoomID(), SenderUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, AppCode: appcode.FromContext(ctx), RegionId: roomMeta.VisibleRegionID, SenderRegionId: cmd.SenderRegionID, TargetIsHost: targetScope.TargetIsHost, TargetHostRegionId: targetScope.TargetHostRegionID, TargetAgencyOwnerUserId: targetScope.TargetAgencyOwnerUserID, EntitlementId: cmd.EntitlementID, ChargeSource: cmd.Source, }) if err != nil { return nil, nil, err } return billing, []giftTargetBilling{{TargetUserID: cmd.TargetUserID, CommandID: cmd.ID(), Billing: billing}}, nil } targets := make([]*walletv1.DebitGiftTarget, 0, len(cmd.TargetUserIDs)) for _, targetUserID := range cmd.TargetUserIDs { targetScope := giftTargetHostScopeFor(cmd, targetUserID) target := &walletv1.DebitGiftTarget{ CommandId: giftTargetCommandID(cmd.ID(), targetUserID, len(cmd.TargetUserIDs)), TargetUserId: targetUserID, } if targetScope.TargetIsHost { // 每个目标只使用 gateway 为该 target 固化的主播快照,避免批量送礼时第一个接收方以外的主播丢工资钻石。 target.TargetIsHost = true target.TargetHostRegionId = targetScope.TargetHostRegionID target.TargetAgencyOwnerUserId = targetScope.TargetAgencyOwnerUserID } targets = append(targets, target) } resp, err := s.wallet.BatchDebitGift(ctx, &walletv1.BatchDebitGiftRequest{ CommandId: cmd.ID(), RoomId: cmd.RoomID(), SenderUserId: cmd.ActorUserID(), GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, AppCode: appcode.FromContext(ctx), RegionId: roomMeta.VisibleRegionID, SenderRegionId: cmd.SenderRegionID, Targets: targets, EntitlementId: cmd.EntitlementID, ChargeSource: cmd.Source, }) if err != nil { return nil, nil, err } targetBillings, err := giftTargetBillingsFromBatch(resp, cmd.TargetUserIDs) if err != nil { return nil, nil, err } return resp.GetAggregate(), targetBillings, nil } func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) { results := make([]*roomv1.LuckyGiftDrawResult, 0, len(targetBillings)) for _, targetBilling := range targetBillings { 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, &activityv1.ExecuteLuckyGiftDrawRequest{ LuckyGift: &activityv1.LuckyGiftMeta{ Meta: activityMetaFromRoom(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;不能把本地时区时间传给活动风控窗口。 PaidAtMs: now.UTC().UnixMilli(), PoolId: cmd.PoolID, VisibleRegionId: roomMeta.VisibleRegionID, CountryId: cmd.SenderCountryID, }, }) 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/wallet 快路径回执为准,房间开奖结果仍由 activity outbox 补偿。 results = append(results, luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID)) } return results, nil } func roomGiftLeaderboardValue(billing *walletv1.DebitGiftResponse) int64 { if billing == nil { return 0 } // 房间贡献榜跟房间热度、房间内贡献人保持同一口径,使用 wallet 已按全局默认比例折算后的 heat_value。 if billing.GetHeatValue() > 0 { return billing.GetHeatValue() } return 0 } 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 activityMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *activityv1.RequestMeta { return &activityv1.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 *activityv1.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(), RoomAtmosphereRewardCoins: result.GetRoomAtmosphereRewardCoins(), ActivitySubsidyCoins: result.GetActivitySubsidyCoins(), EffectiveRewardCoins: result.GetEffectiveRewardCoins(), RewardStatus: result.GetRewardStatus(), StageFeedback: result.GetStageFeedback(), HighMultiplier: result.GetHighMultiplier(), CreatedAtMs: result.GetCreatedAtMs(), WalletTransactionId: result.GetWalletTransactionId(), CoinBalanceAfter: result.GetCoinBalanceAfter(), 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.RoomAtmosphereRewardCoins = 0 aggregate.ActivitySubsidyCoins = 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.RoomAtmosphereRewardCoins += result.GetRoomAtmosphereRewardCoins() aggregate.ActivitySubsidyCoins += result.GetActivitySubsidyCoins() aggregate.EffectiveRewardCoins += result.GetEffectiveRewardCoins() aggregate.StageFeedback = aggregate.StageFeedback || result.GetStageFeedback() aggregate.HighMultiplier = aggregate.HighMultiplier || result.GetHighMultiplier() if result.GetCoinBalanceAfter() > 0 { // 多目标会产生多笔返奖交易;聚合字段不能表达多个交易号,只保留最后一次快路径回执后的余额给客户端刷新资产。 aggregate.CoinBalanceAfter = result.GetCoinBalanceAfter() } 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 normalizeGiftTargetType(raw string) string { raw = strings.TrimSpace(raw) switch raw { case "", "user", "all_mic", "all_room", "couple": return raw default: return raw } } func normalizeGiftTargetUserIDs(targetUserID int64, targetUserIDs []int64) []int64 { seen := make(map[int64]bool, len(targetUserIDs)+1) ids := make([]int64, 0, len(targetUserIDs)+1) for _, userID := range targetUserIDs { if userID <= 0 || seen[userID] { continue } seen[userID] = true ids = append(ids, userID) } if len(ids) == 0 && targetUserID > 0 { ids = append(ids, targetUserID) } return ids } func giftTargetHostScopesFromProto(scopes []*roomv1.SendGiftTargetHostScope) []command.GiftTargetHostScope { result := make([]command.GiftTargetHostScope, 0, len(scopes)) seen := make(map[int64]bool, len(scopes)) for _, scope := range scopes { targetUserID := scope.GetTargetUserId() if targetUserID <= 0 || seen[targetUserID] { continue } seen[targetUserID] = true result = append(result, command.GiftTargetHostScope{ TargetUserID: targetUserID, TargetIsHost: scope.GetTargetIsHost(), TargetHostRegionID: scope.GetTargetHostRegionId(), TargetAgencyOwnerUserID: scope.GetTargetAgencyOwnerUserId(), }) } return result } func giftDisplayProfileFromProto(item *roomv1.SendGiftDisplayProfile) command.GiftDisplayProfile { if item == nil || item.GetUserId() <= 0 { return command.GiftDisplayProfile{} } // 展示快照只裁剪空白字符,不做业务兜底;兜底顺序统一放在 giftDisplayName,避免事件字段互相不一致。 return command.GiftDisplayProfile{ UserID: item.GetUserId(), Username: strings.TrimSpace(item.GetUsername()), Avatar: strings.TrimSpace(item.GetAvatar()), DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()), PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()), } } func giftDisplayProfilesFromProto(items []*roomv1.SendGiftDisplayProfile) []command.GiftDisplayProfile { result := make([]command.GiftDisplayProfile, 0, len(items)) seen := make(map[int64]bool, len(items)) for _, item := range items { profile := giftDisplayProfileFromProto(item) if profile.UserID <= 0 || seen[profile.UserID] { continue } seen[profile.UserID] = true result = append(result, profile) } return result } func giftDisplayProfileForUser(profiles []command.GiftDisplayProfile, userID int64) command.GiftDisplayProfile { if userID <= 0 { return command.GiftDisplayProfile{} } for _, profile := range profiles { if profile.UserID == userID { return profile } } return command.GiftDisplayProfile{UserID: userID} } func giftDisplayName(profile command.GiftDisplayProfile) string { // Flutter 的 IM 解析已支持 sender_name/receiver_nickname;这里按用户昵称、靓号、短号依次兜底,避免跨房飘窗直接落到 User 。 for _, value := range []string{profile.Username, profile.PrettyDisplayUserID, profile.DisplayUserID} { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func giftTargetHostScopeFor(cmd command.SendGift, targetUserID int64) command.GiftTargetHostScope { for _, scope := range cmd.TargetHostScopes { if scope.TargetUserID == targetUserID { return scope } } if targetUserID == cmd.TargetUserID && cmd.TargetIsHost { // 老 gateway 只会传单目标字段;保留这个兜底,避免滚动发布期间新 room-service 丢旧请求的主播入账。 return command.GiftTargetHostScope{ TargetUserID: targetUserID, TargetIsHost: true, TargetHostRegionID: cmd.TargetHostRegionID, TargetAgencyOwnerUserID: cmd.TargetAgencyOwnerUserID, } } return command.GiftTargetHostScope{TargetUserID: targetUserID} } func giftTargetCommandID(commandID string, targetUserID int64, targetCount int) string { if targetCount <= 1 { return commandID } // 钱包以 command_id 做幂等主键;多目标必须按接收方派生稳定子命令,才能在同一批内保存独立交易。 return fmt.Sprintf("%s:target:%d", commandID, targetUserID) } func giftTargetBillingsFromBatch(resp *walletv1.BatchDebitGiftResponse, targetUserIDs []int64) ([]giftTargetBilling, error) { if resp == nil || resp.GetAggregate() == nil { return nil, xerr.New(xerr.Unavailable, "wallet batch debit response is empty") } receipts := make(map[int64]*walletv1.BatchDebitGiftReceipt, len(resp.GetReceipts())) for _, receipt := range resp.GetReceipts() { if receipt.GetTargetUserId() <= 0 || receipt.GetBilling() == nil { return nil, xerr.New(xerr.Unavailable, "wallet batch debit receipt is incomplete") } receipts[receipt.GetTargetUserId()] = receipt } targetBillings := make([]giftTargetBilling, 0, len(targetUserIDs)) for _, targetUserID := range targetUserIDs { receipt := receipts[targetUserID] if receipt == nil { // 批量钱包响应必须覆盖每个房间目标;少一条就不能提交房间表现和 outbox。 return nil, xerr.New(xerr.Unavailable, "wallet batch debit receipt is missing") } targetBillings = append(targetBillings, giftTargetBilling{ TargetUserID: targetUserID, CommandID: receipt.GetCommandId(), Billing: receipt.GetBilling(), }) } return targetBillings, nil } func giftTargetUserIDsAttribute(targetUserIDs []int64) string { if len(targetUserIDs) == 0 { return "" } parts := make([]string, 0, len(targetUserIDs)) for _, targetUserID := range targetUserIDs { parts = append(parts, strconv.FormatInt(targetUserID, 10)) } return strings.Join(parts, ",") } func findRankItem(items []state.RankItem, userID int64) state.RankItem { // 送礼后通常能找到发送方榜单项;找不到时返回 user_id 占位避免空事件。 for _, item := range items { if item.UserID == userID { return item } } return state.RankItem{UserID: userID} } func requireRobotGiftParticipants(current *state.RoomState, senderUserID int64, targetUserIDs []int64, runtimeRobotUserIDs []int64, requireRobotRoom bool) error { if current == nil { return xerr.New(xerr.PermissionDenied, "robot gift requires room state") } if requireRobotRoom && (current == nil || current.RoomExt[roomExtRobotRoomKey] != "true") { return xerr.New(xerr.PermissionDenied, "robot gift requires robot room") } allowed := parseInt64CSV(current.RoomExt[roomExtRobotUserIDsKey]) for _, userID := range runtimeRobotUserIDs { if userID > 0 { allowed[userID] = true } } if !allowed[senderUserID] { return xerr.New(xerr.PermissionDenied, "robot sender is not allowed") } for _, targetUserID := range targetUserIDs { if !allowed[targetUserID] { return xerr.New(xerr.PermissionDenied, "robot gift target is not allowed") } } return nil } 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 firstPositiveInt64(values ...int64) int64 { for _, value := range values { if value > 0 { return value } } return 0 }