2026-06-26 23:21:22 +08:00

994 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 已结算的 HeatValueroom-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)
robotDisplayRecords := make([]outbox.Record, 0, len(giftEvents)+5)
// RobotWalletGift 只表示这笔礼物由机器人钱包结算;真实房机器人仍会同步写房间贡献、火箭和麦位热度,但展示事件改为提交后直发 IM避免机器人流量抢占真人主 outbox。
robotDisplayGift := cmd.RobotWalletGift
if robotDisplayGift {
robotDisplayRecords = append(robotDisplayRecords, 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 robotDisplayGift {
// 房间热度和麦位收礼热度仍要给客户端展示;是否真实计入统计由 RobotGift 语义单独控制,不能再拿投递通道反推业务真实性。
robotDisplayRecords = append(robotDisplayRecords, 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: robotDisplayGift,
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 robotDisplayGift {
robotDisplayRecords = append(robotDisplayRecords, 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 robotDisplayGift {
robotDisplayRecords = append(robotDisplayRecords, 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,
robotDisplayRecords: robotDisplayRecords,
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 <id>。
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
}