622 lines
24 KiB
Go
622 lines
24 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) {
|
||
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(),
|
||
SenderRegionID: req.GetSenderRegionId(),
|
||
TargetIsHost: req.GetTargetIsHost(),
|
||
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
|
||
TargetHostRegionID: req.GetTargetHostRegionId(),
|
||
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
||
}
|
||
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 cmd.GiftID == "" || cmd.GiftCount <= 0 {
|
||
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
|
||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
|
||
}
|
||
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 != "" {
|
||
// 客户端显式传 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 !luckyEnabled {
|
||
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
|
||
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
|
||
}
|
||
if 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)
|
||
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 {
|
||
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(),
|
||
BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(),
|
||
VisibleRegionId: roomMeta.VisibleRegionID,
|
||
CommandId: targetBilling.CommandID,
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
giftEvents = append(giftEvents, giftEvent)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
records := make([]outbox.Record, 0, len(giftEvents)+4)
|
||
records = append(records, giftEvents...)
|
||
records = append(records, heatEvent, rankEvent)
|
||
if rocketApply.progressEvent != nil {
|
||
progressEvent, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, progressEvent)
|
||
}
|
||
if rocketApply.ignited != nil {
|
||
ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, rocketApply.ignited)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, ignitedEvent)
|
||
}
|
||
|
||
return mutationResult{
|
||
snapshot: current.ToProto(),
|
||
billingReceiptID: billing.GetBillingReceiptId(),
|
||
roomHeat: current.Heat,
|
||
giftRank: cloneProtoRank(current.GiftRank),
|
||
luckyGift: luckyGift,
|
||
luckyGifts: luckyGifts,
|
||
commandPayload: commandPayload,
|
||
walletDebitMS: walletDebitMS,
|
||
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(),
|
||
},
|
||
},
|
||
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,
|
||
},
|
||
},
|
||
}, 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 len(cmd.TargetUserIDs) == 1 {
|
||
// 单目标保留原 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: cmd.TargetIsHost,
|
||
TargetHostRegionId: cmd.TargetHostRegionID,
|
||
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
|
||
})
|
||
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 {
|
||
target := &walletv1.DebitGiftTarget{
|
||
CommandId: giftTargetCommandID(cmd.ID(), targetUserID, len(cmd.TargetUserIDs)),
|
||
TargetUserId: targetUserID,
|
||
}
|
||
if targetUserID == cmd.TargetUserID && cmd.TargetIsHost {
|
||
// 现有 room proto 只有单个 host scope;多目标时只允许把该快照用于对应的第一个目标,不能错误扩散到其他接收方。
|
||
target.TargetIsHost = true
|
||
target.TargetHostRegionId = cmd.TargetHostRegionID
|
||
target.TargetAgencyOwnerUserId = cmd.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,
|
||
})
|
||
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,
|
||
},
|
||
})
|
||
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 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}
|
||
}
|