391 lines
14 KiB
Go
391 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
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(),
|
||
}
|
||
if cmd.TargetType == "" {
|
||
cmd.TargetType = "user"
|
||
}
|
||
if cmd.TargetType == "user" && len(cmd.TargetUserIDs) == 1 {
|
||
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) != 1 || cmd.TargetUserID <= 0 {
|
||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_ids must contain one user")
|
||
}
|
||
if _, exists := current.OnlineUsers[cmd.TargetUserID]; !exists {
|
||
// v1 要求礼物目标在房间内,避免给不存在的房间表现目标计榜。
|
||
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
|
||
}
|
||
treasureConfig, err := s.roomTreasureConfig(ctx)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
|
||
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
|
||
walletStartedAt := time.Now()
|
||
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,
|
||
})
|
||
walletDebitMS := elapsedMS(walletStartedAt)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
if billing == nil {
|
||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "wallet debit response is empty")
|
||
}
|
||
heatValue := billing.GetHeatValue()
|
||
settledCommand := cmd
|
||
settledCommand.BillingReceiptID = billing.GetBillingReceiptId()
|
||
settledCommand.CoinSpent = billing.GetCoinSpent()
|
||
settledCommand.GiftPointAdded = billing.GetGiftPointAdded()
|
||
settledCommand.HeatValue = heatValue
|
||
settledCommand.PriceVersion = billing.GetPriceVersion()
|
||
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
|
||
if !luckyEnabled {
|
||
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
|
||
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
|
||
}
|
||
var luckyGift *roomv1.LuckyGiftDrawResult
|
||
if luckyEnabled {
|
||
// 抽奖必须发生在扣费成功之后;activity-service 只接收钱包结算后的 coin_spent,不信任客户端价格。
|
||
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
|
||
LuckyGift: &activityv1.LuckyGiftMeta{
|
||
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
|
||
CommandId: cmd.ID(),
|
||
UserId: cmd.ActorUserID(),
|
||
TargetUserId: cmd.TargetUserID,
|
||
// 目前没有独立设备 ID 字段,优先用 session_id;没有时退回 command_id,保证风控 scope 不为空。
|
||
DeviceId: luckyGiftDeviceID(cmd),
|
||
RoomId: cmd.RoomID(),
|
||
AnchorId: luckyGiftAnchorID(roomMeta),
|
||
GiftId: cmd.GiftID,
|
||
GiftCount: cmd.GiftCount,
|
||
CoinSpent: billing.GetCoinSpent(),
|
||
// 房间链路统一使用 UTC epoch ms;不能把本地时区时间传给活动风控窗口。
|
||
PaidAtMs: now.UTC().UnixMilli(),
|
||
PoolId: cmd.PoolID,
|
||
VisibleRegionId: roomMeta.VisibleRegionID,
|
||
},
|
||
})
|
||
if err != nil {
|
||
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
|
||
return mutationResult{}, nil, err
|
||
}
|
||
if drawResp == nil {
|
||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
|
||
}
|
||
// 同步返回只给当前送礼用户做即时表现;返奖到账和房间开奖结果仍由 activity outbox 补偿。
|
||
luckyGift = luckyGiftResultFromProto(drawResp.GetResult())
|
||
}
|
||
|
||
// 扣费成功后,Room Cell 同步更新热度和本地礼物榜。
|
||
current.Heat += heatValue
|
||
current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now)
|
||
current.Version++
|
||
treasureApply, err := s.applyRoomTreasureGift(now, current, treasureConfig, cmd, &settledCommand, billing, roomMeta)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
commandPayload, err := command.Serialize(settledCommand)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
|
||
// 送礼事件、热度事件和榜单事件使用同一个房间版本,方便消费者关联同一次命令。
|
||
giftEvent, err := outbox.Build(current.RoomID, "RoomGiftSent", current.Version, now, &roomeventsv1.RoomGiftSent{
|
||
SenderUserId: cmd.ActorUserID(),
|
||
TargetUserId: cmd.TargetUserID,
|
||
GiftId: cmd.GiftID,
|
||
PoolId: cmd.PoolID,
|
||
GiftCount: cmd.GiftCount,
|
||
GiftValue: heatValue,
|
||
BillingReceiptId: billing.GetBillingReceiptId(),
|
||
VisibleRegionId: roomMeta.VisibleRegionID,
|
||
CommandId: cmd.ID(),
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
|
||
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 := []outbox.Record{giftEvent, heatEvent, rankEvent}
|
||
if treasureApply.progressEvent != nil {
|
||
progressEvent, err := outbox.Build(current.RoomID, "RoomTreasureProgressChanged", current.Version, now, treasureApply.progressEvent)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, progressEvent)
|
||
}
|
||
if treasureApply.countdown != nil {
|
||
countdownEvent, err := outbox.Build(current.RoomID, "RoomTreasureCountdownStarted", current.Version, now, treasureApply.countdown)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, countdownEvent)
|
||
}
|
||
|
||
return mutationResult{
|
||
snapshot: current.ToProto(),
|
||
billingReceiptID: billing.GetBillingReceiptId(),
|
||
roomHeat: current.Heat,
|
||
giftRank: cloneProtoRank(current.GiftRank),
|
||
luckyGift: luckyGift,
|
||
commandPayload: commandPayload,
|
||
walletDebitMS: walletDebitMS,
|
||
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
|
||
AppCode: appcode.FromContext(ctx),
|
||
RoomID: current.RoomID,
|
||
CoinSpent: roomGiftLeaderboardCoinSpent(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: giftEvent.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),
|
||
"gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded),
|
||
"price_version": settledCommand.PriceVersion,
|
||
},
|
||
},
|
||
}, 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,
|
||
Treasure: result.snapshot.GetTreasure(),
|
||
LuckyGift: result.luckyGift,
|
||
}, nil
|
||
}
|
||
|
||
func roomGiftLeaderboardCoinSpent(billing *walletv1.DebitGiftResponse) int64 {
|
||
if billing == nil {
|
||
return 0
|
||
}
|
||
if billing.GetCoinSpent() > 0 {
|
||
return billing.GetCoinSpent()
|
||
}
|
||
return billing.GetChargeAmount()
|
||
}
|
||
|
||
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 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) string {
|
||
if value := strings.TrimSpace(cmd.SessionID); 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) *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(),
|
||
}
|
||
}
|
||
|
||
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 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}
|
||
}
|