2026-07-12 00:47:20 +08:00

563 lines
26 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"
"strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"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"
)
type giftFlow struct {
s *Service
ctx context.Context
req *roomv1.SendGiftRequest
robot robotGiftOptions
cmd command.SendGift
roomMeta RoomMeta
rocketConfig RoomRocketConfig
billing *walletv1.DebitGiftResponse
// targetBillings 保存 wallet-service 按目标结算后的账务事实;后续事件、麦位热度和 batch 展示都必须从这里读取,不能重新按客户端输入推导价格。
targetBillings []giftTargetBilling
walletDebitMS int64
settledCommand command.SendGift
luckyEnabled bool
luckyGift *roomv1.LuckyGiftDrawResult
luckyGifts []*roomv1.LuckyGiftDrawResult
heatValue int64
targetGiftValueDeltas map[int64]int64
targetCurrentGiftValues map[int64]int64
rocketApply rocketGiftApplyResult
commandPayload []byte
giftEvents []outbox.Record
records []outbox.Record
robotDisplayRecords []outbox.Record
rocketProgress *roomRocketProgressPending
batchDisplay *roomv1.SendGiftBatchDisplay
batchDisplayEnabled bool
suppressDirectIMEventTypes map[string]bool
// operationStarted 后任何失败都必须由服务端更新 saga客户端断线不能成为扣款后完成结算的必要条件。
operationStarted bool
debitCompleted bool
drawCompleted bool
recovering bool
operation GiftOperation
}
func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions) *giftFlow {
displayMode := ""
if options.BatchDisplay {
displayMode = "batch"
}
// 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()),
DisplayMode: displayMode,
ComboSessionID: strings.TrimSpace(req.GetComboSessionId()),
BatchSeq: req.GetBatchSeq(),
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: options.Robot.Enabled && !options.Robot.RealRoomHeat,
RobotWalletGift: options.Robot.Enabled,
SyntheticLuckyGift: options.Robot.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
SyntheticLuckyRewardCoins: firstPositiveInt64(options.Robot.SyntheticRewardCoins, 0),
SyntheticLuckyMultiplierPPM: firstPositiveInt64(options.Robot.SyntheticMultiplierPPM, 1000000),
}
if cmd.TargetType == "" {
cmd.TargetType = "user"
}
cmd.RequestedTargetUserIDs = append([]int64(nil), cmd.TargetUserIDs...)
if cmd.TargetType == "user" && len(cmd.TargetUserIDs) > 0 {
cmd.TargetUserID = cmd.TargetUserIDs[0]
}
return &giftFlow{s: s, ctx: ctx, req: req, robot: options.Robot, cmd: cmd, settledCommand: cmd, batchDisplayEnabled: options.BatchDisplay}
}
func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
if !f.recovering {
if _, exists := current.OnlineUsers[f.cmd.ActorUserID()]; !exists {
// 送礼者必须在房间业务 presence 内;没有进入 Room Cell 的用户不能触发钱包扣费。
return xerr.New(xerr.NotFound, "sender not in room")
}
}
if f.cmd.TargetType != "user" {
// v1 HTTP 契约已预留 all_mic/all_room/couple账务拆单和房间表现策略补齐前先 fail-close。
return xerr.New(xerr.InvalidArgument, "target_type is unsupported")
}
if len(f.cmd.TargetUserIDs) == 0 || f.cmd.TargetUserID <= 0 {
return xerr.New(xerr.InvalidArgument, "target_user_ids is required")
}
if f.recovering {
// operation 只会在首次 presence/目标校验通过后创建;扣款未知或已完成时必须使用当时固化的实际目标继续结算,
// 不能因用户随后离房而永久卡住已扣款 saga。
} else if f.batchDisplayEnabled {
if len(f.cmd.RequestedTargetUserIDs) < 2 {
// batch-send 只承载“全部人/多人”语义;过滤后可以只剩一个真实送达目标,但原始请求不能退化成单人入口。
return xerr.New(xerr.InvalidArgument, "batch target_user_ids requires at least two users")
}
if !f.retainOnlineBatchTargets(current) {
// 全部目标都已离房时不能做成功空操作,也不能扣费。
return xerr.New(xerr.NotFound, "target not in room")
}
} else {
for _, targetUserID := range f.cmd.TargetUserIDs {
if _, exists := current.OnlineUsers[targetUserID]; !exists {
// 普通 SendGift 继续 fail-close避免旧单目标入口把“目标不存在”静默吞掉。
return xerr.New(xerr.NotFound, "target not in room")
}
}
}
if f.robot.Enabled && !f.recovering {
if err := requireRobotGiftParticipants(current, f.cmd.ActorUserID(), f.cmd.TargetUserIDs, f.robot.RobotUserIDs, !f.robot.RealRoomHeat); err != nil {
return err
}
}
if f.cmd.GiftID == "" || f.cmd.GiftCount <= 0 {
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
return xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
}
if len(f.cmd.TargetUserIDs) > maxGiftTargetsPerCommand {
// 限制必须由 Room owner 再执行一次,不能只相信 gateway内部调用、旧网关或回放入口
// 都可能绕过 HTTP 层。gift_count 和目标数分别限制,避免分配异常大的账务请求。
return xerr.New(xerr.InvalidArgument, "gift command exceeds owner limits")
}
if _, ok := giftlimits.WorkUnits(int64(f.cmd.GiftCount), len(f.cmd.TargetUserIDs)); !ok {
// 幸运礼物按“数量 x 目标数”逐份推进奖池状态,真实成本不是 HTTP 请求数;总单位上限
// 防止一个合法外形的大批次独占 Room Cell 和 lucky-gift 数据库事务。
return xerr.New(xerr.InvalidArgument, "gift command units exceed owner limit")
}
if len(f.cmd.ComboSessionID) > maxGiftComboSessionIDLength || f.cmd.BatchSeq < 0 || (f.cmd.ComboSessionID != "" && f.cmd.BatchSeq <= 0) {
return xerr.New(xerr.InvalidArgument, "gift combo metadata is invalid")
}
if strings.EqualFold(f.cmd.Source, "bag") && strings.TrimSpace(f.cmd.EntitlementID) == "" {
// 背包送礼只改变扣费来源,不改变房间事件;因此必须在扣费前明确指定要扣的权益,不能让 wallet 猜测某个库存。
return xerr.New(xerr.InvalidArgument, "entitlement_id is required for bag gift")
}
return nil
}
func (f *giftFlow) retainOnlineBatchTargets(current *state.RoomState) bool {
if f == nil || current == nil || len(f.cmd.TargetUserIDs) == 0 {
return false
}
kept := make([]int64, 0, len(f.cmd.TargetUserIDs))
for _, targetUserID := range f.cmd.TargetUserIDs {
if _, exists := current.OnlineUsers[targetUserID]; exists {
kept = append(kept, targetUserID)
}
}
if len(kept) == 0 {
return false
}
if len(kept) == len(f.cmd.TargetUserIDs) {
return true
}
originalPrimaryTargetUserID := f.cmd.TargetUserID
primaryHostScope, hasPrimaryHostScope := giftTargetHostScopeForExact(f.cmd.TargetHostScopes, kept[0])
// 后续 wallet、幸运礼物、RoomGiftSent、RoomGiftBatchSent 和 command log 都只能看到实际送达目标。
f.cmd.TargetUserIDs = kept
f.cmd.TargetUserID = kept[0]
f.cmd.TargetHostScopes = filterGiftTargetHostScopes(f.cmd.TargetHostScopes, kept)
f.cmd.TargetDisplayProfiles = filterGiftDisplayProfiles(f.cmd.TargetDisplayProfiles, kept)
if hasPrimaryHostScope {
f.cmd.TargetIsHost = primaryHostScope.TargetIsHost
f.cmd.TargetHostRegionID = primaryHostScope.TargetHostRegionID
f.cmd.TargetAgencyOwnerUserID = primaryHostScope.TargetAgencyOwnerUserID
} else if originalPrimaryTargetUserID != f.cmd.TargetUserID {
// 旧单目标 host 字段只描述原主目标;主目标被跳过时必须清空,避免工资入账串到剩余用户。
f.cmd.TargetIsHost = false
f.cmd.TargetHostRegionID = 0
f.cmd.TargetAgencyOwnerUserID = 0
}
f.settledCommand.TargetUserIDs = append([]int64(nil), f.cmd.TargetUserIDs...)
f.settledCommand.TargetUserID = f.cmd.TargetUserID
f.settledCommand.TargetIsHost = f.cmd.TargetIsHost
f.settledCommand.TargetHostRegionID = f.cmd.TargetHostRegionID
f.settledCommand.TargetAgencyOwnerUserID = f.cmd.TargetAgencyOwnerUserID
f.settledCommand.TargetHostScopes = append([]command.GiftTargetHostScope(nil), f.cmd.TargetHostScopes...)
f.settledCommand.TargetDisplayProfiles = append([]command.GiftDisplayProfile(nil), f.cmd.TargetDisplayProfiles...)
return true
}
func (f *giftFlow) prepareRoomFacts() error {
roomMeta, exists, err := f.s.repository.GetRoomMeta(f.ctx, f.cmd.RoomID())
if err != nil {
return err
}
if !exists {
return xerr.New(xerr.NotFound, "room not found")
}
f.roomMeta = roomMeta
if f.cmd.PoolID != "" && !f.cmd.SyntheticLuckyGift {
if f.recovering {
// saga 只在首次 CheckLuckyGift 已通过后创建;恢复阶段直接复用稳定 command_id 开奖,
// 避免规则临时下线把已经扣款的操作卡在预检查上。
f.luckyEnabled = true
} else {
// 客户端显式传 pool_id 时必须先检查活动规则;规则未启用就拒绝,避免先扣费再发现不能抽奖。
if f.s.luckyGift == nil {
return xerr.New(xerr.Unavailable, "lucky gift service is not configured")
}
checkResp, err := f.s.luckyGift.CheckLuckyGift(f.ctx, &luckygiftv1.CheckLuckyGiftRequest{
Meta: luckyGiftMetaFromRoom(f.ctx, f.req.GetMeta()),
UserId: f.cmd.ActorUserID(),
RoomId: f.cmd.RoomID(),
GiftId: f.cmd.GiftID,
PoolId: f.cmd.PoolID,
})
if err != nil {
return err
}
if checkResp == nil || !checkResp.GetEnabled() {
return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
}
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
f.luckyEnabled = true
}
}
rocketConfig, err := f.s.roomRocketConfig(f.ctx)
if err != nil {
return err
}
f.rocketConfig = rocketConfig
return nil
}
func (f *giftFlow) debitAndSettle(now time.Time) error {
return f.s.withLuckyGiftSendLock(f.ctx, f.luckyEnabled, f.cmd.ActorUserID(), func() error {
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
walletStartedAt := time.Now()
var err error
f.billing, f.targetBillings, err = f.s.debitGiftTargets(f.ctx, f.cmd, f.roomMeta)
f.walletDebitMS = elapsedMS(walletStartedAt)
if err != nil {
return err
}
if f.billing == nil {
return xerr.New(xerr.Unavailable, "wallet debit response is empty")
}
f.settledCommand.BillingReceiptID = f.billing.GetBillingReceiptId()
f.settledCommand.CoinSpent = f.billing.GetCoinSpent()
f.settledCommand.HeatValue = f.billing.GetHeatValue()
f.settledCommand.PriceVersion = f.billing.GetPriceVersion()
f.settledCommand.GiftTypeCode = f.billing.GetGiftTypeCode()
f.settledCommand.HostPeriodDiamondAdded = f.billing.GetHostPeriodDiamondAdded()
f.settledCommand.HostPeriodCycleKey = f.billing.GetHostPeriodCycleKey()
if err := f.markGiftOperationDebited(now); err != nil {
// wallet 已返回稳定回执但 saga 落盘失败时不能继续抽奖;后台会用相同 command_id 重取钱包回执后推进。
return err
}
if f.cmd.SyntheticLuckyGift {
f.luckyGift = syntheticLuckyGiftResult(f.cmd, f.targetBillings, now)
if f.luckyGift != nil {
f.luckyGifts = []*roomv1.LuckyGiftDrawResult{f.luckyGift}
}
} else if !f.luckyEnabled {
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode())
}
if !f.cmd.SyntheticLuckyGift && f.luckyEnabled {
f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings, now)
if err != nil {
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
return err
}
if len(f.luckyGifts) > 0 {
// lucky_gifts 保留每个收礼目标的独立抽奖事实lucky_gift 返回整次送礼的聚合表现,复用已有批量送礼的倍率累加语义。
f.luckyGift = aggregateLuckyGiftResults(f.cmd.ID(), f.luckyGifts)
}
}
if err := f.markGiftOperationDrawn(now); err != nil {
// 抽奖 owner 已按 command_id 幂等;即使进度写入失败,恢复也只会重放同一结果,不会再次产生奖金。
return err
}
return nil
})
}
func (f *giftFlow) applyRoomState(now time.Time, current *state.RoomState, localRank *rank.LocalRank) error {
f.heatValue = f.billing.GetHeatValue()
// 扣费成功后Room Cell 同步更新热度和本地礼物榜;这些状态才是 IM 和 snapshot 的权威来源。
current.Heat += f.heatValue
current.GiftRank = localRank.ApplyGift(f.cmd.ActorUserID(), f.heatValue, now)
f.targetGiftValueDeltas = make(map[int64]int64, len(f.targetBillings))
f.targetCurrentGiftValues = make(map[int64]int64, len(f.targetBillings))
for _, targetBilling := range f.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
f.targetGiftValueDeltas[targetBilling.TargetUserID] += targetGiftValue
}
f.targetCurrentGiftValues[targetBilling.TargetUserID] = userState.GiftValue
}
}
if len(f.targetGiftValueDeltas) > 0 {
// 命令日志只保存每个目标本次增量;累计值由在线用户态按 replay 顺序重建。
f.settledCommand.TargetGiftValues = f.targetGiftValueDeltas
}
current.Version++
rocketApply, err := f.s.applyRoomRocketGift(now, current, f.rocketConfig, f.cmd, &f.settledCommand, f.billing, f.roomMeta)
if err != nil {
return err
}
f.rocketApply = rocketApply
commandPayload, err := command.Serialize(f.settledCommand)
if err != nil {
return err
}
f.commandPayload = commandPayload
return nil
}
func (f *giftFlow) buildMutationResult(now time.Time, current *state.RoomState) (mutationResult, []outbox.Record, error) {
giftEvents, err := buildRoomGiftSentRecords(current.RoomID, current.Version, now, f.cmd, f.roomMeta, f.targetBillings, f.targetCurrentGiftValues)
if err != nil {
return mutationResult{}, nil, err
}
f.giftEvents = giftEvents
f.records = make([]outbox.Record, 0, len(f.giftEvents)+4)
f.robotDisplayRecords = make([]outbox.Record, 0, len(f.giftEvents)+5)
// RobotWalletGift 只表示这笔礼物由机器人钱包结算;真实房机器人仍会同步写房间贡献、火箭和麦位热度,但展示事件改为提交后直发 IM避免机器人流量抢占真人主 outbox。
robotDisplayGift := f.cmd.RobotWalletGift
if robotDisplayGift {
f.robotDisplayRecords = append(f.robotDisplayRecords, f.giftEvents...)
} else {
f.records = append(f.records, f.giftEvents...)
}
heatEvent, err := buildRoomHeatChangedRecord(current.RoomID, current.Version, now, f.heatValue, current.Heat)
if err != nil {
return mutationResult{}, nil, err
}
rankItem := findRankItem(current.GiftRank, f.cmd.ActorUserID())
rankEvent, err := buildRoomRankChangedRecord(current.RoomID, current.Version, now, rankItem)
if err != nil {
return mutationResult{}, nil, err
}
if robotDisplayGift {
// 房间热度和麦位收礼热度仍要给客户端展示;是否真实计入统计由 RobotGift 语义单独控制,不能再拿投递通道反推业务真实性。
f.robotDisplayRecords = append(f.robotDisplayRecords, heatEvent, rankEvent)
} else {
f.records = append(f.records, heatEvent, rankEvent)
}
if !f.cmd.RobotGift {
// 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人展示直发通道投递。
if f.rocketApply.progressEvent != nil {
progressRecord, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, f.rocketApply.progressEvent)
if err != nil {
return mutationResult{}, nil, err
}
progressRecord.AppCode = appcode.FromContext(f.ctx)
if progressRecord.Envelope != nil {
progressRecord.Envelope.AppCode = progressRecord.AppCode
}
// 燃料变化是高频展示事件,不逐笔写 outbox命令提交成功后由内存合并器按 1 秒窗口只落最大进度。
f.rocketProgress = &roomRocketProgressPending{
record: progressRecord,
robotLane: robotDisplayGift,
rocketID: f.rocketApply.progressEvent.GetRocketId(),
level: f.rocketApply.progressEvent.GetLevel(),
currentFuel: f.rocketApply.progressEvent.GetCurrentFuel(),
roomVersion: current.Version,
}
}
if f.rocketApply.ignited != nil {
ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, f.rocketApply.ignited)
if err != nil {
return mutationResult{}, nil, err
}
if robotDisplayGift {
f.robotDisplayRecords = append(f.robotDisplayRecords, ignitedEvent)
} else {
f.records = append(f.records, ignitedEvent)
}
}
}
if f.cmd.SyntheticLuckyGift && f.luckyGift != nil {
drawEvent, err := outbox.Build(current.RoomID, "RoomRobotLuckyGiftDrawn", current.Version, now, &roomeventsv1.RoomRobotLuckyGiftDrawn{
DrawId: f.luckyGift.GetDrawId(),
CommandId: f.cmd.ID(),
SenderUserId: f.cmd.ActorUserID(),
TargetUserId: f.cmd.TargetUserID,
GiftId: f.cmd.GiftID,
GiftCount: f.cmd.GiftCount,
PoolId: f.cmd.PoolID,
MultiplierPpm: f.luckyGift.GetMultiplierPpm(),
BaseRewardCoins: f.luckyGift.GetBaseRewardCoins(),
EffectiveRewardCoins: f.luckyGift.GetEffectiveRewardCoins(),
CreatedAtMs: f.luckyGift.GetCreatedAtMs(),
VisibleRegionId: f.roomMeta.VisibleRegionID,
IsRobot: true,
Synthetic: true,
})
if err != nil {
return mutationResult{}, nil, err
}
if robotDisplayGift {
f.robotDisplayRecords = append(f.robotDisplayRecords, drawEvent)
} else {
f.records = append(f.records, drawEvent)
}
}
if f.batchDisplayEnabled {
// 新 batch-send 只合并房间展示消息:逐目标 RoomGiftSent 已经在 giftEvents 中保留为 durable 业务事实。
f.batchDisplay = buildSendGiftBatchDisplay(f.cmd, f.billing, f.targetBillings, f.targetCurrentGiftValues, f.luckyGifts, current.Heat)
batchEvent := roomGiftBatchSentEventFromDisplay(f.batchDisplay, f.roomMeta.VisibleRegionID)
batchRecord, err := outbox.Build(current.RoomID, "RoomGiftBatchSent", current.Version, now, batchEvent)
if err != nil {
return mutationResult{}, nil, err
}
f.batchDisplay.EventId = batchRecord.EventID
f.records = append(f.records, batchRecord)
f.suppressDirectIMEventTypes = map[string]bool{"RoomGiftSent": true}
}
return f.toMutationResult(now, current), f.records, nil
}
func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mutationResult {
finalCoinBalanceAfter := giftFinalCoinBalanceAfter(f.billing)
firstTargetDisplayProfile := giftDisplayProfileForUser(f.cmd.TargetDisplayProfiles, f.cmd.TargetUserID)
result := mutationResult{
snapshot: current.ToProto(),
billingReceiptID: f.billing.GetBillingReceiptId(),
coinBalanceAfter: finalCoinBalanceAfter,
giftIncomeBalanceAfter: f.billing.GetGiftIncomeBalanceAfter(),
roomHeat: current.Heat,
giftRank: cloneProtoRank(current.GiftRank),
luckyGift: f.luckyGift,
luckyGifts: f.luckyGifts,
batchDisplay: f.batchDisplay,
suppressDirectIMEventTypes: f.suppressDirectIMEventTypes,
commandPayload: f.commandPayload,
walletDebitMS: f.walletDebitMS,
robotDisplayRecords: f.robotDisplayRecords,
roomRocketProgress: f.rocketProgress,
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
AppCode: appcode.FromContext(f.ctx),
RoomID: current.RoomID,
CoinSpent: roomGiftLeaderboardValue(f.billing),
OccurredAtMS: now.UnixMilli(),
},
roomUserContributionPeriodStats: roomContributionPeriodStatIncrements(appcode.FromContext(f.ctx), current.RoomID, f.cmd.ActorUserID(), f.heatValue, now.UnixMilli()),
roomUserGiftStats: []RoomUserGiftStatIncrement{
{
AppCode: appcode.FromContext(f.ctx),
RoomID: current.RoomID,
UserID: f.cmd.ActorUserID(),
GiftValue: f.heatValue,
LastGiftAtMS: now.UnixMilli(),
},
},
roomWeeklyContribution: &RoomWeeklyContributionIncrement{
AppCode: appcode.FromContext(f.ctx),
RoomID: current.RoomID,
GiftValue: f.heatValue,
OccurredMS: now.UnixMilli(),
},
syncEvent: &tencentim.RoomEvent{
// 同步广播只选 GiftSent 主事件作为客户端房间系统消息Heat/Rank 通过字段携带。
EventID: f.giftEvents[0].EventID,
RoomID: current.RoomID,
EventType: "room_gift_sent",
ActorUserID: f.cmd.ActorUserID(),
TargetUserID: f.cmd.TargetUserID,
GiftValue: f.heatValue,
RoomHeat: current.Heat,
RoomVersion: current.Version,
Attributes: map[string]string{
"gift_id": f.cmd.GiftID,
"pool_id": f.cmd.PoolID,
"gift_count": fmt.Sprintf("%d", f.cmd.GiftCount),
"billing_receipt_id": f.billing.GetBillingReceiptId(),
"coin_spent": fmt.Sprintf("%d", f.settledCommand.CoinSpent),
"target_count": fmt.Sprintf("%d", len(f.cmd.TargetUserIDs)),
"target_user_ids": giftTargetUserIDsAttribute(f.cmd.TargetUserIDs),
"price_version": f.settledCommand.PriceVersion,
"host_period_diamond_added": fmt.Sprintf("%d", f.settledCommand.HostPeriodDiamondAdded),
"host_period_cycle_key": f.settledCommand.HostPeriodCycleKey,
"gift_type_code": f.billing.GetGiftTypeCode(),
"cp_relation_type": f.billing.GetCpRelationType(),
"gift_name": f.billing.GetGiftName(),
"gift_icon_url": f.billing.GetGiftIconUrl(),
"gift_animation_url": f.billing.GetGiftAnimationUrl(),
"target_gift_value": fmt.Sprintf("%d", f.targetCurrentGiftValues[f.cmd.TargetUserID]),
"is_robot_gift": fmt.Sprintf("%t", f.cmd.RobotWalletGift),
"synthetic_lucky_gift": fmt.Sprintf("%t", f.cmd.SyntheticLuckyGift),
"sender_name": giftDisplayName(f.cmd.SenderDisplayProfile),
"sender_avatar": strings.TrimSpace(f.cmd.SenderDisplayProfile.Avatar),
"sender_display_user_id": strings.TrimSpace(f.cmd.SenderDisplayProfile.DisplayUserID),
"sender_pretty_display_user_id": strings.TrimSpace(f.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),
},
},
}
result.giftV2 = &roomv1.SendGiftResultV2{
ApiVersion: 2,
CommandId: f.cmd.ID(),
ComboSessionId: f.cmd.ComboSessionID,
BatchSeq: f.cmd.BatchSeq,
IdempotentReplay: false,
CommittedRoomVersion: current.Version,
BillingReceiptId: f.billing.GetBillingReceiptId(),
CoinBalanceAfter: finalCoinBalanceAfter,
GiftIncomeBalanceAfter: f.billing.GetGiftIncomeBalanceAfter(),
SettledGiftCount: f.cmd.GiftCount,
SettledTargetUserIds: append([]int64(nil), f.cmd.TargetUserIDs...),
BalanceVersion: f.billing.GetBalanceVersion(),
LuckyGift: f.luckyGift,
LuckyGifts: append([]*roomv1.LuckyGiftDrawResult(nil), f.luckyGifts...),
LuckyHits: luckyHitsFromResults(f.luckyGifts),
BatchDisplay: f.batchDisplay,
RoomHeat: current.Heat,
GiftRank: cloneProtoRank(current.GiftRank),
OperationStatus: GiftOperationStatusCommitted,
}
if f.cmd.RobotGift {
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
result.roomGiftLeaderboard = nil
result.roomUserContributionPeriodStats = nil
result.roomUserGiftStats = nil
result.roomWeeklyContribution = nil
}
return result
}