994 lines
41 KiB
Go
994 lines
41 KiB
Go
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)
|
||
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
|
||
}
|