package service import ( "context" "fmt" "strings" "time" 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(), 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") } // 钱包扣费在房间状态变更前完成;失败时不写 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() commandPayload, err := command.Serialize(settledCommand) if err != nil { return mutationResult{}, nil, err } // 扣费成功后,Room Cell 同步更新热度和本地礼物榜。 current.Heat += heatValue current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now) current.Version++ // 送礼事件、热度事件和榜单事件使用同一个房间版本,方便消费者关联同一次命令。 giftEvent, err := outbox.Build(current.RoomID, "RoomGiftSent", current.Version, now, &roomeventsv1.RoomGiftSent{ SenderUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, GiftId: cmd.GiftID, 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 } return mutationResult{ snapshot: current.ToProto(), billingReceiptID: billing.GetBillingReceiptId(), roomHeat: current.Heat, giftRank: cloneProtoRank(current.GiftRank), commandPayload: commandPayload, walletDebitMS: walletDebitMS, 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, "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, }, }, }, []outbox.Record{giftEvent, heatEvent, rankEvent}, 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, }, nil } 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} }