增加批量送礼

This commit is contained in:
zhx 2026-06-27 16:59:30 +08:00
parent a1e038442d
commit c47e4e6832
37 changed files with 8830 additions and 5381 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1886,6 +1886,14 @@ message ExecuteLuckyGiftDrawResponse {
LuckyGiftDrawResult result = 1;
}
message BatchExecuteLuckyGiftDrawRequest {
repeated LuckyGiftMeta lucky_gifts = 1;
}
message BatchExecuteLuckyGiftDrawResponse {
repeated LuckyGiftDrawResult results = 1;
}
message GetLuckyGiftConfigRequest {
RequestMeta meta = 1;
string gift_id = 2;
@ -2585,6 +2593,7 @@ service AchievementService {
service LuckyGiftService {
rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
rpc BatchExecuteLuckyGiftDraw(BatchExecuteLuckyGiftDrawRequest) returns (BatchExecuteLuckyGiftDrawResponse);
}
// WheelService owns wheel draw decisions after wallet debit succeeds.

View File

@ -1788,8 +1788,9 @@ var AchievementService_ServiceDesc = grpc.ServiceDesc{
}
const (
LuckyGiftService_CheckLuckyGift_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/CheckLuckyGift"
LuckyGiftService_ExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/ExecuteLuckyGiftDraw"
LuckyGiftService_CheckLuckyGift_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/CheckLuckyGift"
LuckyGiftService_ExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/ExecuteLuckyGiftDraw"
LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/BatchExecuteLuckyGiftDraw"
)
// LuckyGiftServiceClient is the client API for LuckyGiftService service.
@ -1800,6 +1801,7 @@ const (
type LuckyGiftServiceClient interface {
CheckLuckyGift(ctx context.Context, in *CheckLuckyGiftRequest, opts ...grpc.CallOption) (*CheckLuckyGiftResponse, error)
ExecuteLuckyGiftDraw(ctx context.Context, in *ExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*ExecuteLuckyGiftDrawResponse, error)
BatchExecuteLuckyGiftDraw(ctx context.Context, in *BatchExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*BatchExecuteLuckyGiftDrawResponse, error)
}
type luckyGiftServiceClient struct {
@ -1830,6 +1832,16 @@ func (c *luckyGiftServiceClient) ExecuteLuckyGiftDraw(ctx context.Context, in *E
return out, nil
}
func (c *luckyGiftServiceClient) BatchExecuteLuckyGiftDraw(ctx context.Context, in *BatchExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*BatchExecuteLuckyGiftDrawResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchExecuteLuckyGiftDrawResponse)
err := c.cc.Invoke(ctx, LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// LuckyGiftServiceServer is the server API for LuckyGiftService service.
// All implementations must embed UnimplementedLuckyGiftServiceServer
// for forward compatibility.
@ -1838,6 +1850,7 @@ func (c *luckyGiftServiceClient) ExecuteLuckyGiftDraw(ctx context.Context, in *E
type LuckyGiftServiceServer interface {
CheckLuckyGift(context.Context, *CheckLuckyGiftRequest) (*CheckLuckyGiftResponse, error)
ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error)
BatchExecuteLuckyGiftDraw(context.Context, *BatchExecuteLuckyGiftDrawRequest) (*BatchExecuteLuckyGiftDrawResponse, error)
mustEmbedUnimplementedLuckyGiftServiceServer()
}
@ -1854,6 +1867,9 @@ func (UnimplementedLuckyGiftServiceServer) CheckLuckyGift(context.Context, *Chec
func (UnimplementedLuckyGiftServiceServer) ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ExecuteLuckyGiftDraw not implemented")
}
func (UnimplementedLuckyGiftServiceServer) BatchExecuteLuckyGiftDraw(context.Context, *BatchExecuteLuckyGiftDrawRequest) (*BatchExecuteLuckyGiftDrawResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchExecuteLuckyGiftDraw not implemented")
}
func (UnimplementedLuckyGiftServiceServer) mustEmbedUnimplementedLuckyGiftServiceServer() {}
func (UnimplementedLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -1911,6 +1927,24 @@ func _LuckyGiftService_ExecuteLuckyGiftDraw_Handler(srv interface{}, ctx context
return interceptor(ctx, in, info, handler)
}
func _LuckyGiftService_BatchExecuteLuckyGiftDraw_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchExecuteLuckyGiftDrawRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LuckyGiftServiceServer).BatchExecuteLuckyGiftDraw(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LuckyGiftServiceServer).BatchExecuteLuckyGiftDraw(ctx, req.(*BatchExecuteLuckyGiftDrawRequest))
}
return interceptor(ctx, in, info, handler)
}
// LuckyGiftService_ServiceDesc is the grpc.ServiceDesc for LuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1926,6 +1960,10 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ExecuteLuckyGiftDraw",
Handler: _LuckyGiftService_ExecuteLuckyGiftDraw_Handler,
},
{
MethodName: "BatchExecuteLuckyGiftDraw",
Handler: _LuckyGiftService_BatchExecuteLuckyGiftDraw_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",

File diff suppressed because it is too large Load Diff

View File

@ -67,6 +67,11 @@ message RoomUserJoined {
int64 region_id = 6;
// is_robot
bool is_robot = 7;
// nickname/avatar/display_user_id IM user-service
string nickname = 8;
string avatar = 9;
string display_user_id = 10;
string pretty_display_user_id = 11;
}
// RoomUserLeft
@ -184,6 +189,74 @@ message RoomGiftSent {
string receiver_avatar = 28;
string receiver_display_user_id = 29;
string receiver_pretty_display_user_id = 30;
// display_mode=batch activityCP RoomGiftBatchSent
string display_mode = 31;
}
// RoomGiftBatchLuckyResult
// IM activity
message RoomGiftBatchLuckyResult {
bool enabled = 1;
string draw_id = 2;
string command_id = 3;
string pool_id = 4;
string gift_id = 5;
int64 rule_version = 6;
string experience_pool = 7;
string selected_tier_id = 8;
int64 multiplier_ppm = 9;
int64 base_reward_coins = 10;
int64 room_atmosphere_reward_coins = 11;
int64 activity_subsidy_coins = 12;
int64 effective_reward_coins = 13;
string reward_status = 14;
bool stage_feedback = 15;
bool high_multiplier = 16;
int64 created_at_ms = 17;
int64 target_user_id = 18;
}
// RoomGiftBatchTarget
// gateway user-service IM bridge
message RoomGiftBatchTarget {
int64 target_user_id = 1;
string receiver_nickname = 2;
string receiver_avatar = 3;
string receiver_display_user_id = 4;
string receiver_pretty_display_user_id = 5;
int64 gift_value = 6;
int64 target_gift_value = 7;
int64 coin_spent = 8;
string billing_receipt_id = 9;
string command_id = 10;
RoomGiftBatchLuckyResult lucky_gift = 11;
}
// RoomGiftBatchSent Flutter
// RoomGiftSent durable outbox CP
message RoomGiftBatchSent {
int64 sender_user_id = 1;
string sender_name = 2;
string sender_avatar = 3;
string sender_display_user_id = 4;
string sender_pretty_display_user_id = 5;
string gift_id = 6;
int32 gift_count = 7;
int64 total_gift_value = 8;
int64 total_coin_spent = 9;
string billing_receipt_id = 10;
int64 room_heat = 11;
string pool_id = 12;
string gift_type_code = 13;
string cp_relation_type = 14;
string gift_name = 15;
string gift_icon_url = 16;
string gift_animation_url = 17;
repeated string gift_effect_types = 18;
repeated int64 target_user_ids = 19;
repeated RoomGiftBatchTarget targets = 20;
string command_id = 21;
int64 visible_region_id = 22;
}
// RoomRobotLuckyGiftDrawn

File diff suppressed because it is too large Load Diff

View File

@ -635,6 +635,15 @@ message RoomEntryVehicleSnapshot {
int64 expires_at_ms = 9;
}
// RoomUserDisplayProfile gateway IM/UI
message RoomUserDisplayProfile {
int64 user_id = 1;
string nickname = 2;
string avatar = 3;
string display_user_id = 4;
string pretty_display_user_id = 5;
}
message JoinRoomRequest {
RequestMeta meta = 1;
string role = 2;
@ -645,6 +654,8 @@ message JoinRoomRequest {
int64 actor_region_id = 6;
// actor_is_robot room-service gateway false
bool actor_is_robot = 7;
// actor_display_profile room-service IM/outbox RoomState
RoomUserDisplayProfile actor_display_profile = 8;
}
// JoinRoomResponse
@ -1022,6 +1033,49 @@ message SendGiftDisplayProfile {
string pretty_display_user_id = 5;
}
// SendGiftBatchTarget
// RoomGiftSent
message SendGiftBatchTarget {
int64 target_user_id = 1;
string receiver_nickname = 2;
string receiver_avatar = 3;
string receiver_display_user_id = 4;
string receiver_pretty_display_user_id = 5;
int64 gift_value = 6;
int64 target_gift_value = 7;
int64 coin_spent = 8;
string billing_receipt_id = 9;
string command_id = 10;
LuckyGiftDrawResult lucky_gift = 11;
}
// SendGiftBatchDisplay
// 广使 RoomGiftBatchSent IM coin_balance_after
message SendGiftBatchDisplay {
string event_id = 1;
int64 sender_user_id = 2;
string sender_name = 3;
string sender_avatar = 4;
string sender_display_user_id = 5;
string sender_pretty_display_user_id = 6;
string gift_id = 7;
int32 gift_count = 8;
int64 total_gift_value = 9;
int64 total_coin_spent = 10;
string billing_receipt_id = 11;
int64 room_heat = 12;
string pool_id = 13;
string gift_type_code = 14;
string cp_relation_type = 15;
string gift_name = 16;
string gift_icon_url = 17;
string gift_animation_url = 18;
repeated string gift_effect_types = 19;
repeated int64 target_user_ids = 20;
repeated SendGiftBatchTarget targets = 21;
string command_id = 22;
}
// SendGiftRequest
message SendGiftRequest {
RequestMeta meta = 1;
@ -1053,6 +1107,8 @@ message SendGiftRequest {
SendGiftDisplayProfile sender_display_profile = 16;
// target_display_profiles IM target_user_id
repeated SendGiftDisplayProfile target_display_profiles = 17;
// display_mode=batch batch-send HTTP IM send
string display_mode = 18;
}
// SendGiftResponse
@ -1071,6 +1127,8 @@ message SendGiftResponse {
int64 coin_balance_after = 9;
// gift_income_balance_after COIN 0
int64 gift_income_balance_after = 10;
// batch_display batch-send send
SendGiftBatchDisplay batch_display = 11;
}
// CheckSpeakPermissionRequest IM gateway

View File

@ -97,11 +97,17 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
}, repository, broadcastPublisher, activityclient.NewGRPCRegionSource(clients.userConn))
broadcastSvc.SetSenderProfileSource(activityclient.NewGRPCUserProfileSource(clients.userConn))
luckyGiftSvc := luckygiftservice.New(repository,
luckyGiftOptions := []luckygiftservice.Option{
luckygiftservice.WithWallet(walletClient),
luckygiftservice.WithRoomPublisher(tencentClient),
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
)
}
if tencentClient != nil {
luckyGiftOptions = append(luckyGiftOptions,
luckygiftservice.WithRoomPublisher(tencentClient),
luckygiftservice.WithUserPublisher(tencentClient),
)
}
luckyGiftSvc := luckygiftservice.New(repository, luckyGiftOptions...)
wheelSvc := wheelservice.New(repository, wheelservice.WithWallet(walletClient))
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletClient)
cumulativeRechargeSvc := cumulativerechargeservice.New(repository, walletClient)

View File

@ -2,6 +2,8 @@ package luckygift
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
@ -28,6 +30,7 @@ type Repository interface {
ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error)
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error)
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
@ -44,7 +47,7 @@ type Repository interface {
MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
}
// WalletClient 是幸运礼物返奖唯一账务依赖;实际余额变更通知继续由 wallet outbox -> notice-service 投递
// WalletClient 是幸运礼物返奖唯一账务依赖;wallet-service 仍负责持久 outboxactivity 只做成功落库后的低延迟余额 IM
type WalletClient interface {
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
}
@ -54,6 +57,11 @@ type RoomPublisher interface {
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
}
// UserPublisher 负责把钱包余额变化私有消息发给中奖用户;失败不能回滚已经成功的钱包入账。
type UserPublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}
// RegionBroadcaster 只写 activity-service 自有的区域播报 outbox真正发腾讯 IM 仍由 broadcast worker 补偿。
type RegionBroadcaster interface {
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
@ -64,6 +72,7 @@ type Service struct {
repository Repository
wallet WalletClient
publisher RoomPublisher
userPub UserPublisher
broadcaster RegionBroadcaster
now func() time.Time
}
@ -82,6 +91,12 @@ func WithRoomPublisher(publisher RoomPublisher) Option {
}
}
func WithUserPublisher(publisher UserPublisher) Option {
return func(s *Service) {
s.userPub = publisher
}
}
func WithRegionBroadcaster(broadcaster RegionBroadcaster) Option {
return func(s *Service) {
s.broadcaster = broadcaster
@ -122,6 +137,37 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
if err := s.requireRepository(); err != nil {
return domain.DrawResult{}, err
}
normalized, err := s.normalizeDrawCommand(cmd)
if err != nil {
return domain.DrawResult{}, err
}
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, normalized, s.now().UTC().UnixMilli())
if err != nil {
return domain.DrawResult{}, err
}
return s.creditLuckyGiftRewardFastPath(ctx, normalized, result), nil
}
func (s *Service) DrawBatch(ctx context.Context, cmds []domain.DrawCommand) ([]domain.DrawResult, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
if len(cmds) == 0 {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
}
normalized := make([]domain.DrawCommand, 0, len(cmds))
for _, cmd := range cmds {
item, err := s.normalizeDrawCommand(cmd)
if err != nil {
return nil, err
}
normalized = append(normalized, item)
}
// 批量送礼的返奖不能阻塞 room-service 主链路repository 只落 draw fact 和 settlement outboxworker 后续负责 wallet/IM。
return s.repository.ExecuteLuckyGiftDrawBatch(ctx, normalized, s.now().UTC().UnixMilli())
}
func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawCommand, error) {
// Draw 只接受扣费成功后的服务端事实;客户端不能提交价格、倍率、奖励或风控绕过字段。
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
cmd.PoolID = normalizePoolID(cmd.PoolID)
@ -130,20 +176,16 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
}
if cmd.TargetUserID < 0 || cmd.GiftCount <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli())
if err != nil {
return domain.DrawResult{}, err
}
return s.creditLuckyGiftRewardFastPath(ctx, cmd, result), nil
return cmd, nil
}
type WorkerOptions struct {
@ -262,29 +304,63 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
}
func (s *Service) processRewardSettlementOutbox(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) error {
if _, _, err := s.settleLuckyGiftReward(ctx, event, payload, options); err != nil {
credited, alreadyGranted, receipt, err := s.settleLuckyGiftReward(ctx, event, payload, options)
if err != nil {
return err
}
if credited && !alreadyGranted {
// 钱包入账和 activity draw 状态都已经落库;这里的 C2C 只是降低客户端余额刷新延迟,失败时仍由 wallet outbox -> notice-service 补偿。
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, options.PublishTimeout); err != nil {
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
slog.String("draw_id", payload.DrawID),
slog.String("command_id", payload.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
}
}
if credited && payload.EffectiveRewardCoins > 0 {
// 房间中奖 IM 必须在钱包返奖落库之后发;发送方 HTTP 本地表现不能替代房间内其他用户的实时展示。
// 这里即使 draw 已经 granted 也会补发一次fast path 可能先完成账务,随后 outbox worker 才负责可靠补房间展示。
if err := s.publishLuckyGiftDrawn(ctx, payload, options); err != nil {
logx.Error(ctx, "lucky_gift_room_drawn_notice_failed", err,
slog.String("draw_id", payload.DrawID),
slog.String("command_id", payload.CommandID),
slog.String("event_id", payload.EventID),
)
}
if shouldPublishLuckyGiftRegionBroadcast(payload) {
// 区域播报和房间 IM 都是表现层副作用,失败不回滚已经成功的返奖账务。
if err := s.publishLuckyGiftRegionBroadcast(ctx, payload); err != nil {
logx.Error(ctx, "lucky_gift_region_broadcast_failed", err,
slog.String("draw_id", payload.DrawID),
slog.String("command_id", payload.CommandID),
slog.Int64("visible_region_id", payload.VisibleRegionID),
)
}
}
}
return s.repository.MarkLuckyGiftOutboxDelivered(ctx, event, s.now().UTC().UnixMilli())
}
func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, error) {
func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, luckyGiftRewardReceipt, error) {
walletTransactionID := ""
credited := false
alreadyGranted := false
receipt := luckyGiftRewardReceipt{}
if payload.EffectiveRewardCoins > 0 {
state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
if err != nil {
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
}
alreadyGranted = state.AllGranted
if alreadyGranted {
walletTransactionID = state.WalletTransactionID
credited = true
} else {
receipt, err := s.creditLuckyGiftReward(ctx, event.AppCode, payload)
var err error
receipt, err = s.creditLuckyGiftReward(ctx, event.AppCode, payload)
if err != nil {
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
}
walletTransactionID = receipt.WalletTransactionID
credited = true
@ -292,21 +368,23 @@ func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOu
}
if !credited {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
return false, false, err
return false, false, receipt, err
}
return false, false, nil
return false, false, receipt, nil
}
if !alreadyGranted {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
return credited, alreadyGranted, err
return credited, alreadyGranted, receipt, err
}
}
return credited, alreadyGranted, nil
return credited, alreadyGranted, receipt, nil
}
type luckyGiftRewardReceipt struct {
WalletTransactionID string
CoinBalanceAfter int64
CoinFrozenAfter int64
BalanceVersion int64
}
func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
@ -351,13 +429,21 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = receipt.WalletTransactionID
result.CoinBalanceAfter = receipt.CoinBalanceAfter
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID); err != nil {
logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
return result
}
// HTTP/gRPC 抽奖响应不再返回返奖后余额;成功落库后的余额变化通过钱包私有 IM 发送Flutter 用 balance_after 覆盖本地余额。
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, 3*time.Second); err != nil {
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
}
return result
}
@ -404,16 +490,90 @@ func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, pay
return luckyGiftRewardReceipt{
WalletTransactionID: resp.GetTransactionId(),
CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(),
CoinFrozenAfter: resp.GetBalance().GetFrozenAmount(),
BalanceVersion: resp.GetBalance().GetVersion(),
}, nil
}
func (s *Service) publishLuckyGiftWalletBalanceChanged(ctx context.Context, payload luckyGiftDrawnPayload, receipt luckyGiftRewardReceipt, timeout time.Duration) error {
if s.userPub == nil || payload.EffectiveRewardCoins <= 0 {
return nil
}
receipt.WalletTransactionID = strings.TrimSpace(receipt.WalletTransactionID)
if receipt.WalletTransactionID == "" {
return fmt.Errorf("lucky gift wallet transaction id is empty")
}
nowMs := s.now().UTC().UnixMilli()
commandID := "lucky_reward:" + payload.DrawID
noticePayload, err := json.Marshal(map[string]any{
"event_type": "WalletBalanceChanged",
"event_id": walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
"app_code": payload.AppCode,
"transaction_id": receipt.WalletTransactionID,
"command_id": commandID,
"user_id": fmt.Sprintf("%d", payload.UserID),
"asset_type": "COIN",
"available_delta": payload.EffectiveRewardCoins,
"frozen_delta": 0,
"available_after": receipt.CoinBalanceAfter,
"frozen_after": receipt.CoinFrozenAfter,
"balance_version": receipt.BalanceVersion,
"created_at_ms": nowMs,
"source_created_at_ms": nowMs,
"metadata": map[string]any{
"transaction_id": receipt.WalletTransactionID,
"command_id": commandID,
"user_id": payload.UserID,
"draw_id": payload.DrawID,
"room_id": payload.RoomID,
"visible_region_id": payload.VisibleRegionID,
"region_id": payload.VisibleRegionID,
"country_id": payload.CountryID,
"gift_id": payload.GiftID,
"pool_id": payload.PoolID,
"amount": payload.EffectiveRewardCoins,
"reason": "lucky_gift_reward",
"created_at_ms": nowMs,
},
})
if err != nil {
return err
}
publishTimeout := timeout
if publishTimeout <= 0 {
publishTimeout = 5 * time.Second
}
publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
defer cancel()
return s.userPub.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(payload.UserID),
EventID: walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
Desc: "WalletBalanceChanged",
Ext: "wallet_notice",
PayloadJSON: noticePayload,
})
}
func walletBalanceChangedEventID(transactionID string, userID int64, assetType string) string {
hashInput := fmt.Sprintf("%s|%s|%d|%s", strings.TrimSpace(transactionID), "WalletBalanceChanged", userID, strings.TrimSpace(assetType))
sum := sha256.Sum256([]byte(hashInput))
return "wev_" + hex.EncodeToString(sum[:])
}
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
if s.publisher == nil || payload.EffectiveRewardCoins <= 0 {
return nil
}
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
if err != nil {
return err
}
// 房间 IM 只是表现层事实同步,不参与钱包结算和 reward_status 收敛。
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
publishTimeout := options.PublishTimeout
if publishTimeout <= 0 {
publishTimeout = 5 * time.Second
}
publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
defer cancel()
return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
GroupID: payload.RoomID,
@ -425,7 +585,7 @@ func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDr
}
func (s *Service) publishLuckyGiftRegionBroadcast(ctx context.Context, payload luckyGiftDrawnPayload) error {
if payload.VisibleRegionID <= 0 {
if s.broadcaster == nil || payload.VisibleRegionID <= 0 {
return nil
}
body, err := json.Marshal(luckyGiftRegionBroadcastPayload(payload, s.now().UTC().UnixMilli()))

View File

@ -64,7 +64,8 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
CreatedAtMS: 1760000000000,
}}
wallet := &fakeLuckyGiftWallet{balanceAfter: 1300}
service := New(repository, WithWallet(wallet))
userPublisher := &fakeLuckyGiftUserPublisher{}
service := New(repository, WithWallet(wallet), WithUserPublisher(userPublisher))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{
@ -87,12 +88,51 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet fast path request mismatch: %+v", wallet.last)
}
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 1300 {
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 0 {
t.Fatalf("fast path result mismatch: %+v", result)
}
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_fast_1,lucky_draw_fast_2" {
t.Fatalf("fast path should mark all draw ids granted, got %s", got)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 1300)
}
func TestDrawBatchLeavesRewardSettlementToWorker(t *testing.T) {
repository := &fakeLuckyGiftRepository{executeBatchResults: []domain.DrawResult{{
DrawID: "lucky_draw_batch_async_1",
CommandID: "cmd-gift-batch:target:42",
PoolID: "super_lucky",
GiftID: "rose",
EffectiveRewardCoins: 300,
RewardStatus: domain.StatusPending,
}}}
wallet := &fakeLuckyGiftWallet{}
service := New(repository, WithWallet(wallet))
results, err := service.DrawBatch(appcode.WithContext(context.Background(), "lalu"), []domain.DrawCommand{{
CommandID: "cmd-gift-batch:target:42",
PoolID: "super_lucky",
UserID: 42,
TargetUserID: 99,
DeviceID: "device-1",
RoomID: "room-1",
AnchorID: "42",
GiftID: "rose",
GiftCount: 1,
CoinSpent: 100,
}})
if err != nil {
t.Fatalf("DrawBatch failed: %v", err)
}
if len(results) != 1 || results[0].DrawID != "lucky_draw_batch_async_1" {
t.Fatalf("DrawBatch result mismatch: %+v", results)
}
if wallet.last != nil || repository.grantedDrawID != "" || len(repository.grantedDrawIDs) != 0 {
t.Fatalf("batch draw must not run synchronous wallet fast path: wallet=%+v granted=%s granted_ids=%v", wallet.last, repository.grantedDrawID, repository.grantedDrawIDs)
}
if len(repository.executeBatchCmds) != 1 || repository.executeBatchCmds[0].CommandID != "cmd-gift-batch:target:42" {
t.Fatalf("batch draw must pass normalized commands to repository: %+v", repository.executeBatchCmds)
}
}
func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
@ -128,7 +168,7 @@ func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
}
}
func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_test",
@ -160,8 +200,9 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
@ -174,9 +215,11 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
}
if publisher.last.EventID != "" || broadcaster.last.EventID != "" {
t.Fatalf("settlement worker must not publish presentation: publisher=%+v broadcaster=%+v", publisher.last, broadcaster.last)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_test", "lucky_draw_test", 300)
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_test" || broadcaster.last.RegionID != 210 {
t.Fatalf("big win region broadcast mismatch: %+v", broadcaster.last)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
if repository.grantedDrawID != "lucky_draw_test" || repository.deliveredOutboxID != "lucky_lucky_draw_test" {
t.Fatalf("repository settlement mismatch: granted=%s delivered=%s", repository.grantedDrawID, repository.deliveredOutboxID)
}
@ -214,8 +257,9 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
@ -231,9 +275,14 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
t.Fatalf("all sub draws should be granted together, got %s", got)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 800, 0)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 800)
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_batch_1" || broadcaster.last.RegionID != 210 {
t.Fatalf("batch big win region broadcast mismatch: %+v", broadcaster.last)
}
}
func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_settle",
@ -261,7 +310,9 @@ func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
@ -273,9 +324,11 @@ func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" {
t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID)
}
if publisher.last.EventID != "" {
t.Fatalf("settlement outbox must not publish presentation: %+v", publisher.last)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 300)
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_settle" || broadcaster.last.RegionID != 210 {
t.Fatalf("settlement big win region broadcast mismatch: %+v", broadcaster.last)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
}
func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
@ -330,7 +383,7 @@ func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
}
}
func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
func TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_im_fail",
@ -357,7 +410,8 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{err: errors.New("im unavailable")}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
userPublisher := &fakeLuckyGiftUserPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
@ -373,6 +427,10 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
if repository.deliveredOutboxID != "lucky_lucky_draw_im_fail" || repository.retryableOutboxID != "" {
t.Fatalf("IM publisher must not affect settlement outbox: delivered=%s retryable=%s", repository.deliveredOutboxID, repository.retryableOutboxID)
}
if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_im_fail" {
t.Fatalf("room IM should still be attempted before ignoring failure: %+v", publisher.last)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
}
func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) {
@ -403,7 +461,8 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
userPublisher := &fakeLuckyGiftUserPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
@ -418,18 +477,21 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
if repository.grantedDrawID != "" {
t.Fatalf("already granted draw should not be marked again, got %q", repository.grantedDrawID)
}
if publisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
t.Fatalf("already granted draw should only deliver settlement outbox: publisher=%+v delivered=%s", publisher.last, repository.deliveredOutboxID)
if userPublisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
t.Fatalf("already granted draw should not resend wallet notice and must deliver outbox: user_publisher=%+v delivered=%s", userPublisher.last, repository.deliveredOutboxID)
}
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 300)
}
type fakeLuckyGiftRepository struct {
getConfigCalls int
upserted domain.RuleConfig
executeResult domain.DrawResult
executeCmd domain.DrawCommand
rewardState domain.DrawRewardState
outbox []domain.DrawOutbox
getConfigCalls int
upserted domain.RuleConfig
executeResult domain.DrawResult
executeCmd domain.DrawCommand
executeBatchResults []domain.DrawResult
executeBatchCmds []domain.DrawCommand
rewardState domain.DrawRewardState
outbox []domain.DrawOutbox
grantedDrawID string
grantedDrawIDs []string
@ -460,6 +522,20 @@ func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(_ context.Context, cmd do
return r.executeResult, nil
}
func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDrawBatch(_ context.Context, cmds []domain.DrawCommand, _ int64) ([]domain.DrawResult, error) {
r.executeBatchCmds = append([]domain.DrawCommand(nil), cmds...)
if len(r.executeBatchResults) > 0 {
return append([]domain.DrawResult(nil), r.executeBatchResults...), nil
}
results := make([]domain.DrawResult, 0, len(cmds))
for _, cmd := range cmds {
result := r.executeResult
result.CommandID = cmd.CommandID
results = append(results, result)
}
return results, nil
}
func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
return nil, 0, nil
}
@ -547,6 +623,16 @@ func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, me
return p.err
}
type fakeLuckyGiftUserPublisher struct {
last tencentim.CustomUserMessage
err error
}
func (p *fakeLuckyGiftUserPublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error {
p.last = message
return p.err
}
type fakeLuckyGiftRegionBroadcaster struct {
last broadcastservice.PublishInput
}
@ -555,3 +641,40 @@ func (b *fakeLuckyGiftRegionBroadcaster) PublishRegionBroadcast(_ context.Contex
b.last = input
return broadcastdomain.PublishResult{EventID: input.EventID, Status: broadcastdomain.StatusPending, Created: true}, nil
}
func assertLuckyGiftWalletNotice(t *testing.T, message tencentim.CustomUserMessage, toAccount string, transactionID string, availableDelta int64, availableAfter int64) {
t.Helper()
if message.ToAccount != toAccount || message.Desc != "WalletBalanceChanged" || message.Ext != "wallet_notice" {
t.Fatalf("wallet notice envelope mismatch: %+v", message)
}
if message.EventID != walletBalanceChangedEventID(transactionID, 42, "COIN") {
t.Fatalf("wallet notice event id mismatch: %s", message.EventID)
}
var payload map[string]any
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
t.Fatalf("wallet notice payload is invalid: %v", err)
}
if payload["event_type"] != "WalletBalanceChanged" || payload["transaction_id"] != transactionID || payload["user_id"] != "42" || payload["asset_type"] != "COIN" {
t.Fatalf("wallet notice identity fields mismatch: %+v", payload)
}
if int64(payload["available_delta"].(float64)) != availableDelta || int64(payload["available_after"].(float64)) != availableAfter {
t.Fatalf("wallet notice balance fields mismatch: %+v", payload)
}
}
func assertLuckyGiftRoomMessage(t *testing.T, message tencentim.CustomGroupMessage, groupID string, eventID string, drawID string, rewardCoins int64) {
t.Helper()
if message.GroupID != groupID || message.EventID != eventID || message.Desc != "lucky_gift_drawn" || message.Ext != "room_system_message" {
t.Fatalf("room lucky gift envelope mismatch: %+v", message)
}
var payload map[string]any
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
t.Fatalf("room lucky gift payload is invalid: %v", err)
}
if payload["event_type"] != "lucky_gift_drawn" || payload["event_id"] != eventID || payload["draw_id"] != drawID {
t.Fatalf("room lucky gift identity fields mismatch: %+v", payload)
}
if int64(payload["effective_reward_coins"].(float64)) != rewardCoins {
t.Fatalf("room lucky gift reward mismatch: %+v", payload)
}
}

View File

@ -213,6 +213,34 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
return luckyAggregateDrawResults(cmd, results), nil
}
func (r *Repository) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if len(cmds) == 0 {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
results := make([]domain.DrawResult, 0, len(cmds))
for _, cmd := range cmds {
// 多目标送礼必须在 activity 内按请求顺序推进同一用户、奖池、RTP 和风控锁;这里不做并发,避免 room-service 自己制造锁竞争。
draws, err := r.executeLuckyGiftDrawBatch(ctx, tx, cmd, nowMS)
if err != nil {
return nil, err
}
results = append(results, luckyAggregateDrawResults(cmd, draws))
}
if err := tx.Commit(); err != nil {
return nil, err
}
return results, nil
}
func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
// gift_count 是抽奖次数coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。

View File

@ -47,7 +47,35 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *activityv1.Ch
func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
meta := req.GetLuckyGift()
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode())
result, err := s.svc.Draw(ctx, domain.DrawCommand{
result, err := s.svc.Draw(ctx, luckyDrawCommandFromProto(meta))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: luckyDrawResultToProto(result)}, nil
}
func (s *LuckyGiftServer) BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
if len(req.GetLuckyGifts()) == 0 {
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required"))
}
ctx = appcode.WithContext(ctx, req.GetLuckyGifts()[0].GetMeta().GetAppCode())
cmds := make([]domain.DrawCommand, 0, len(req.GetLuckyGifts()))
for _, meta := range req.GetLuckyGifts() {
cmds = append(cmds, luckyDrawCommandFromProto(meta))
}
results, err := s.svc.DrawBatch(ctx, cmds)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*activityv1.LuckyGiftDrawResult, 0, len(results))}
for _, result := range results {
resp.Results = append(resp.Results, luckyDrawResultToProto(result))
}
return resp, nil
}
func luckyDrawCommandFromProto(meta *activityv1.LuckyGiftMeta) domain.DrawCommand {
return domain.DrawCommand{
CommandID: meta.GetCommandId(),
PoolID: meta.GetPoolId(),
UserID: meta.GetUserId(),
@ -61,11 +89,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: luckyDrawResultToProto(result)}, nil
}
// AdminLuckyGiftServer 暴露后台幸运礼物规则配置和抽奖审计入口。

View File

@ -179,6 +179,7 @@ type RoomHandlers struct {
KickUser http.HandlerFunc
UnbanUser http.HandlerFunc
SendGift http.HandlerFunc
SendGiftBatch http.HandlerFunc
}
type MessageHandlers struct {
@ -520,6 +521,7 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms/user/kick", http.MethodPost, h.KickUser)
r.profile("/rooms/user/unban", http.MethodPost, h.UnbanUser)
r.profile("/rooms/gift/send", http.MethodPost, h.SendGift)
r.profile("/rooms/gift/batch-send", http.MethodPost, h.SendGiftBatch)
}
func (r routes) registerMessageRoutes() {

View File

@ -2597,7 +2597,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
BillingReceiptId: "receipt-lucky",
RoomHeat: 100,
CoinBalanceAfter: 8800,
CoinBalanceAfter: 8200,
LuckyGift: &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: "lucky_draw_test",
@ -2610,7 +2610,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
MultiplierPpm: 5_000_000,
EffectiveRewardCoins: 500,
RewardStatus: "granted",
CoinBalanceAfter: 8800,
CoinBalanceAfter: 0,
CreatedAtMs: 1_779_258_000_000,
},
LuckyGifts: []*roomv1.LuckyGiftDrawResult{
@ -2651,13 +2651,13 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
}
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 5_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 500 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "" || envelope.Data.LuckyGift.CoinBalanceAfter != 8800 {
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 5_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 500 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "" || envelope.Data.LuckyGift.CoinBalanceAfter != 0 {
t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
}
if len(envelope.Data.LuckyGifts) != 2 || envelope.Data.LuckyGifts[0].TargetUserID != 43 || envelope.Data.LuckyGifts[0].MultiplierPPM != 2_000_000 || envelope.Data.LuckyGifts[1].TargetUserID != 44 || envelope.Data.LuckyGifts[1].MultiplierPPM != 3_000_000 {
t.Fatalf("lucky_gifts response mismatch: %+v", envelope.Data.LuckyGifts)
}
if envelope.Data.CoinBalanceAfter != 8800 {
if envelope.Data.CoinBalanceAfter != 8200 {
t.Fatalf("send gift response must expose sender coin_balance_after: %+v", envelope.Data)
}
}
@ -2729,6 +2729,67 @@ func TestSendGiftForwardsMultipleTargetUserIDs(t *testing.T) {
}
}
func TestBatchSendGiftForwardsDisplayModeAndResponse(t *testing.T) {
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 15, ServerTimeMs: 1_779_258_000_000},
BillingReceiptId: "receipt-batch",
RoomHeat: 500,
CoinBalanceAfter: 7600,
BatchDisplay: &roomv1.SendGiftBatchDisplay{
EventId: "evt-batch",
SenderUserId: 42,
GiftId: "rose",
GiftCount: 2,
TotalGiftValue: 400,
TotalCoinSpent: 400,
BillingReceiptId: "receipt-batch",
TargetUserIds: []int64{43, 44},
CommandId: "cmd-gift-batch",
GiftAnimationUrl: "https://cdn.example/rose.svga",
GiftEffectTypes: []string{"room"},
RoomHeat: 500,
Targets: []*roomv1.SendGiftBatchTarget{
{TargetUserId: 43, TargetGiftValue: 220, CommandId: "cmd-gift-batch:target:43"},
{TargetUserId: 44, TargetGiftValue: 180, CommandId: "cmd-gift-batch:target:44"},
},
},
}}
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":2,"pool_id":"super_lucky"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if roomClient.lastGift == nil || roomClient.lastGift.GetDisplayMode() != "batch" || len(roomClient.lastGift.GetTargetUserIds()) != 2 || roomClient.lastGift.GetPoolId() != "super_lucky" {
t.Fatalf("gateway must forward batch display mode and targets: %+v", roomClient.lastGift)
}
var envelope struct {
Code string `json:"code"`
Data struct {
CoinBalanceAfter int64 `json:"coin_balance_after"`
BatchDisplay struct {
EventID string `json:"event_id"`
TargetUserIDs []int64 `json:"target_user_ids"`
Targets []struct {
TargetUserID int64 `json:"target_user_id"`
TargetGiftValue int64 `json:"target_gift_value"`
CommandID string `json:"command_id"`
} `json:"targets"`
} `json:"batch_display"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
}
if envelope.Code != "OK" || envelope.Data.CoinBalanceAfter != 7600 || envelope.Data.BatchDisplay.EventID != "evt-batch" || len(envelope.Data.BatchDisplay.TargetUserIDs) != 2 || len(envelope.Data.BatchDisplay.Targets) != 2 {
t.Fatalf("batch send response mismatch: %+v", envelope.Data)
}
}
func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
roomClient := &fakeRoomClient{}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
@ -2998,6 +3059,10 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
if roomClient.lastJoin == nil || roomClient.lastJoin.GetPassword() != "1234" {
t.Fatalf("join must forward room password to room-service: %+v", roomClient.lastJoin)
}
joinProfile := roomClient.lastJoin.GetActorDisplayProfile()
if joinProfile.GetUserId() != 42 || joinProfile.GetNickname() != "hy" || joinProfile.GetAvatar() != "https://cdn.example/avatar.png" || joinProfile.GetDisplayUserId() != "100001" {
t.Fatalf("join must inject actor display profile snapshot: %+v", joinProfile)
}
seats := data["seats"].([]any)
if len(seats) != 2 || seats[0].(map[string]any)["user_id"] != "102" {
t.Fatalf("join response must include first-screen seats: %+v", seats)

View File

@ -102,6 +102,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
KickUser: h.kickUser,
UnbanUser: h.unbanUser,
SendGift: h.sendGift,
SendGiftBatch: h.sendGiftBatch,
}
}

View File

@ -1101,18 +1101,19 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
if !httpkit.Decode(writer, request, &body) {
return
}
actorCountryID, actorRegionID, err := h.resolveRoomActorCountryRegion(request)
actorSnapshot, err := h.resolveRoomActorJoinSnapshot(request)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
resp, err := h.roomClient.JoinRoom(request.Context(), &roomv1.JoinRoomRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
Role: body.Role,
Password: body.Password,
ActorCountryId: actorCountryID,
ActorRegionId: actorRegionID,
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
Role: body.Role,
Password: body.Password,
ActorCountryId: actorSnapshot.countryID,
ActorRegionId: actorSnapshot.regionID,
ActorDisplayProfile: actorSnapshot.displayProfile,
// 座驾快照在进房命令提交前生成room-service 只保存快照,不回查 wallet。
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request),
})
@ -1556,6 +1557,16 @@ func (h *Handler) unbanUser(writer http.ResponseWriter, request *http.Request) {
// sendGift 把送礼 HTTP JSON 请求转换为 room-service SendGift 命令。
// 扣费、幂等、房间表现和 outbox 仍由 room-service 的命令链路统一处理。
func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
h.handleSendGift(writer, request, "")
}
// sendGiftBatch 是新 Flutter 的多人送礼入口。
// 它不改变账务和房间事实,只通过 display_mode=batch 要求 room-service 合并房间展示 IM。
func (h *Handler) sendGiftBatch(writer http.ResponseWriter, request *http.Request) {
h.handleSendGift(writer, request, "batch")
}
func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Request, displayMode string) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
@ -1611,29 +1622,40 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
Source: strings.TrimSpace(body.Source),
SenderDisplayProfile: userSnapshots.senderDisplayProfile,
TargetDisplayProfiles: userSnapshots.targetDisplayProfiles,
DisplayMode: strings.TrimSpace(displayMode),
})
httpkit.Write(writer, request, resp, err)
}
func (h *Handler) resolveRoomActorCountryRegion(request *http.Request) (int64, int64, error) {
type roomActorJoinSnapshot struct {
countryID int64
regionID int64
displayProfile *roomv1.RoomUserDisplayProfile
}
func (h *Handler) resolveRoomActorJoinSnapshot(request *http.Request) (roomActorJoinSnapshot, error) {
if h.userProfileClient == nil {
return 0, 0, xerr.New(xerr.Unavailable, "user service is not configured")
return roomActorJoinSnapshot{}, xerr.New(xerr.Unavailable, "user service is not configured")
}
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
return 0, 0, err
return roomActorJoinSnapshot{}, err
}
if resp.GetUser() == nil {
return 0, 0, xerr.New(xerr.NotFound, "user not found")
return roomActorJoinSnapshot{}, xerr.New(xerr.NotFound, "user not found")
}
if resp.GetUser().GetCountryId() <= 0 || resp.GetUser().GetRegionId() <= 0 {
return 0, 0, xerr.New(xerr.InvalidArgument, "user country and region are required")
return roomActorJoinSnapshot{}, xerr.New(xerr.InvalidArgument, "user country and region are required")
}
// room-service 事件必须携带用户真实国家和区域;房间 visible_region_id 只表达房间列表归属。
return resp.GetUser().GetCountryId(), resp.GetUser().GetRegionId(), nil
// room-service 事件必须携带用户真实国家/区域和展示快照;房间 visible_region_id 只表达房间列表归属。
return roomActorJoinSnapshot{
countryID: resp.GetUser().GetCountryId(),
regionID: resp.GetUser().GetRegionId(),
displayProfile: roomUserDisplayProfileFromUser(resp.GetUser()),
}, nil
}
type giftUserSnapshots struct {
@ -1778,6 +1800,20 @@ func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfil
}
}
func roomUserDisplayProfileFromUser(user *userv1.User) *roomv1.RoomUserDisplayProfile {
if user == nil || user.GetUserId() <= 0 {
return nil
}
// 这里不生成 User<ID> 兜底;真实昵称/头像缺失时让客户端走 display_user_id 或最终 user_id 兜底。
return &roomv1.RoomUserDisplayProfile{
UserId: user.GetUserId(),
Nickname: strings.TrimSpace(user.GetUsername()),
Avatar: strings.TrimSpace(user.GetAvatar()),
DisplayUserId: strings.TrimSpace(user.GetDisplayUserId()),
PrettyDisplayUserId: strings.TrimSpace(user.GetPrettyDisplayUserId()),
}
}
func firstGiftTargetHostScope(scopes []*roomv1.SendGiftTargetHostScope, targetUserID int64) *roomv1.SendGiftTargetHostScope {
for _, scope := range scopes {
if scope.GetTargetUserId() == targetUserID {

View File

@ -28,6 +28,8 @@ type LuckyGiftClient interface {
CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error)
// ExecuteLuckyGiftDraw 在钱包扣费成功后按 command_id 幂等落抽奖事实。
ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error)
// BatchExecuteLuckyGiftDraw 一次提交多人送礼的全部目标抽奖事实,返回顺序必须和请求目标顺序一致。
BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error)
}
// RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。

View File

@ -61,3 +61,7 @@ func (c *grpcLuckyGiftClient) CheckLuckyGift(ctx context.Context, req *activityv
func (c *grpcLuckyGiftClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
return c.client.ExecuteLuckyGiftDraw(ctx, req)
}
func (c *grpcLuckyGiftClient) BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
return c.client.BatchExecuteLuckyGiftDraw(ctx, req)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
@ -152,7 +153,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
base.TargetUserID = body.GetUserId()
// RoomUserJoined 的座驾必须从 outbox body 取快照,不能在补偿投递时按当前佩戴重算。
base.EntryVehicle = roomEntryVehicleFromEvent(body.GetEntryVehicle())
base.Attributes = map[string]string{"role": body.GetRole()}
base.Attributes = roomUserJoinedAttributesFromEvent(&body)
return base, true, nil
case "RoomUserLeft":
// 离房事件只表达业务 presence 移除,不声明腾讯云 IM 连接是否已断开。
@ -293,6 +294,10 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
if strings.EqualFold(strings.TrimSpace(body.GetDisplayMode()), "batch") {
// batch-send 仍写逐目标 RoomGiftSent 作为 durable 业务事实,但房间展示只发 RoomGiftBatchSent否则新 Flutter 会同时收到 batch 和 N 条旧单目标 IM。
return tencentim.RoomEvent{}, false, nil
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId()
base.GiftValue = body.GetGiftValue()
@ -323,6 +328,42 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(),
}
return base, true, nil
case "RoomGiftBatchSent":
// 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。
var body roomeventsv1.RoomGiftBatchSent
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
targetUserIDsJSON, err := json.Marshal(body.GetTargetUserIds())
if err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = firstInt64(body.GetTargetUserIds())
base.GiftValue = body.GetTotalGiftValue()
base.RoomHeat = body.GetRoomHeat()
// 房间 IM 只保留客户端房内展示需要的小字段:飞座位依赖 target_user_ids礼物图标/名称用于公屏和飞行图标兜底。
// 逐目标结算、收礼累计和幸运明细已经在 durable RoomGiftSent / lucky_gift_drawn 事实中消费,不能塞进 direct IM。
base.Attributes = map[string]string{
"gift_id": body.GetGiftId(),
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"total_gift_value": fmt.Sprintf("%d", body.GetTotalGiftValue()),
"billing_receipt_id": body.GetBillingReceiptId(),
"pool_id": body.GetPoolId(),
"gift_type_code": body.GetGiftTypeCode(),
"cp_relation_type": body.GetCpRelationType(),
"gift_name": body.GetGiftName(),
"gift_icon_url": body.GetGiftIconUrl(),
"target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())),
"target_user_ids": string(targetUserIDsJSON),
"command_id": body.GetCommandId(),
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
"sender_name": body.GetSenderName(),
"sender_avatar": body.GetSenderAvatar(),
"sender_display_user_id": body.GetSenderDisplayUserId(),
"sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(),
}
return base, true, nil
case "RoomHeatChanged":
// 机器人通道也会投递热度展示事件,客户端可独立刷新房间热度,不依赖主 outbox。
var body roomeventsv1.RoomHeatChanged
@ -475,6 +516,35 @@ func roomEntryVehicleFromEvent(item *roomeventsv1.RoomEntryVehicleSnapshot) *ten
}
}
func roomUserJoinedAttributesFromEvent(body *roomeventsv1.RoomUserJoined) map[string]string {
if body == nil {
return nil
}
values := map[string]string{
"role": strings.TrimSpace(body.GetRole()),
"nickname": strings.TrimSpace(body.GetNickname()),
"avatar": strings.TrimSpace(body.GetAvatar()),
"display_user_id": strings.TrimSpace(body.GetDisplayUserId()),
"pretty_display_user_id": strings.TrimSpace(body.GetPrettyDisplayUserId()),
}
// RoomEvent.MarshalJSON 会把 attributes 平铺成客户端顶层字段;空字段不发送,避免客户端把空快照当成明确资料值。
for key, value := range values {
if value == "" {
delete(values, key)
}
}
return values
}
func firstInt64(values []int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func eventTypeForClient(eventType string) string {
// protobuf 事件名使用 PascalCase客户端 TIMCustomElem 使用 snake_case 作为稳定协议字段。
switch eventType {
@ -506,6 +576,8 @@ func eventTypeForClient(eventType string) string {
return "room_user_unbanned"
case "RoomGiftSent":
return "room_gift_sent"
case "RoomGiftBatchSent":
return "room_gift_batch_sent"
case "RoomHeatChanged":
return "room_heat_changed"
case "RoomRankChanged":

View File

@ -35,9 +35,13 @@ func TestRoomEventFromEnvelopeSkipsNonIMEvents(t *testing.T) {
func TestRoomUserJoinedCarriesEntryVehicleSnapshot(t *testing.T) {
record, err := outbox.Build("room-vehicle", "RoomUserJoined", 9, time.Now(), &roomeventsv1.RoomUserJoined{
UserId: 42,
Role: "audience",
VisibleRegionId: 86,
UserId: 42,
Role: "audience",
VisibleRegionId: 86,
Nickname: "Join Nina",
Avatar: "https://cdn.example.com/nina.png",
DisplayUserId: "100042",
PrettyDisplayUserId: "888042",
EntryVehicle: &roomeventsv1.RoomEntryVehicleSnapshot{
ResourceId: 7001,
ResourceCode: "vehicle_gold",
@ -64,6 +68,9 @@ func TestRoomUserJoinedCarriesEntryVehicleSnapshot(t *testing.T) {
if event.EntryVehicle == nil || event.EntryVehicle.ResourceID != 7001 || event.EntryVehicle.AnimationURL != "https://cdn.example.com/vehicle.svga" {
t.Fatalf("entry vehicle snapshot mismatch: %+v", event.EntryVehicle)
}
if event.Attributes["nickname"] != "Join Nina" || event.Attributes["avatar"] != "https://cdn.example.com/nina.png" || event.Attributes["display_user_id"] != "100042" || event.Attributes["pretty_display_user_id"] != "888042" {
t.Fatalf("joined display profile attributes mismatch: %+v", event.Attributes)
}
}
func TestRoomBackgroundChangedPublishesDedicatedIMEvent(t *testing.T) {
@ -125,6 +132,100 @@ func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) {
}
}
func TestRoomGiftSentBatchDisplayFactSkipsSingleTargetIM(t *testing.T) {
record, err := outbox.Build("room-gift-batch-fact", "RoomGiftSent", 9, time.Now(), &roomeventsv1.RoomGiftSent{
SenderUserId: 1001,
TargetUserId: 1002,
GiftId: "gift_clover",
GiftCount: 1,
GiftValue: 100,
TargetGiftValue: 900,
DisplayMode: "batch",
})
if err != nil {
t.Fatalf("Build batch RoomGiftSent fact failed: %v", err)
}
event, publish, err := roomEventFromEnvelope(record.Envelope)
if err != nil {
t.Fatalf("batch RoomGiftSent fact should decode without publishing: %v", err)
}
if publish {
t.Fatalf("batch RoomGiftSent fact must not publish single-target IM, event=%+v", event)
}
}
func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) {
record, err := outbox.Build("room-gift-batch", "RoomGiftBatchSent", 12, time.Now(), &roomeventsv1.RoomGiftBatchSent{
SenderUserId: 1001,
SenderName: "Batch Sender",
GiftId: "gift_clover",
GiftCount: 2,
TotalGiftValue: 600,
TotalCoinSpent: 600,
BillingReceiptId: "receipt-batch",
RoomHeat: 6600,
PoolId: "super_lucky",
GiftEffectTypes: []string{"room"},
TargetUserIds: []int64{1002, 1003},
CommandId: "cmd-batch",
Targets: []*roomeventsv1.RoomGiftBatchTarget{
{
TargetUserId: 1002,
ReceiverNickname: "Receiver One",
GiftValue: 300,
TargetGiftValue: 1300,
CommandId: "cmd-batch:target:1002",
LuckyGift: &roomeventsv1.RoomGiftBatchLuckyResult{
Enabled: true,
DrawId: "draw-1",
CommandId: "cmd-batch:target:1002",
PoolId: "super_lucky",
GiftId: "gift_clover",
MultiplierPpm: 2_000_000,
EffectiveRewardCoins: 200,
RewardStatus: "granted",
TargetUserId: 1002,
},
},
{TargetUserId: 1003, ReceiverNickname: "Receiver Two", GiftValue: 300, TargetGiftValue: 900, CommandId: "cmd-batch:target:1003"},
},
})
if err != nil {
t.Fatalf("Build RoomGiftBatchSent envelope failed: %v", err)
}
event, publish, err := roomEventFromEnvelope(record.Envelope)
if err != nil {
t.Fatalf("RoomGiftBatchSent should decode: %v", err)
}
if !publish || event.EventType != "room_gift_batch_sent" || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 600 || event.RoomHeat != 6600 {
t.Fatalf("batch gift IM event mismatch: publish=%t event=%+v", publish, event)
}
if event.Attributes["target_user_ids"] != `[1002,1003]` || event.Attributes["target_count"] != "2" {
t.Fatalf("batch gift IM target attributes mismatch: %+v", event.Attributes)
}
if event.Attributes["gift_id"] != "gift_clover" || event.Attributes["gift_count"] != "2" || event.Attributes["command_id"] != "cmd-batch" {
t.Fatalf("batch gift IM gift attributes mismatch: %+v", event.Attributes)
}
if _, exists := event.Attributes["targets"]; exists {
t.Fatalf("room batch IM must not include bulky targets: %+v", event.Attributes)
}
if _, exists := event.Attributes["lucky_gifts"]; exists {
t.Fatalf("room batch IM must not include bulky lucky_gifts: %+v", event.Attributes)
}
if _, exists := event.Attributes["coin_balance_after"]; exists {
t.Fatalf("room IM must not expose sender coin balance: %+v", event.Attributes)
}
payload, err := json.Marshal(event)
if err != nil {
t.Fatalf("marshal batch gift IM event failed: %v", err)
}
if len(payload) > 4096 {
t.Fatalf("batch gift IM payload should stay small, bytes=%d payload=%s", len(payload), payload)
}
}
func TestRoomHeatAndRankChangedPublishDisplayIMEvents(t *testing.T) {
heatRecord, err := outbox.Build("room-robot-heat", "RoomHeatChanged", 9, time.Now(), &roomeventsv1.RoomHeatChanged{
Delta: 120,

View File

@ -135,6 +135,8 @@ type JoinRoom struct {
ActorIsRobot bool `json:"actor_is_robot,omitempty"`
// EntryVehicle 是 gateway 在进房时解析出的当前有效佩戴座驾快照,用于进房 IM。
EntryVehicle EntryVehicleSnapshot `json:"entry_vehicle,omitempty"`
// ActorDisplayProfile 是 gateway 在进房入口固化的展示快照;只服务 IM/UI 兜底,不参与 presence 权限和统计。
ActorDisplayProfile UserDisplayProfile `json:"actor_display_profile,omitempty"`
}
// Type 返回命令类型。
@ -422,6 +424,8 @@ type SendGift struct {
EntitlementID string `json:"entitlement_id,omitempty"`
// Source 区分 coin/bag为空按普通金币链路兼容旧客户端。
Source string `json:"source,omitempty"`
// DisplayMode 只控制客户端展示事件形态batch 表示保留逐目标事实,但房间 IM 合并为单条多人展示消息。
DisplayMode string `json:"display_mode,omitempty"`
// SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。
SenderRegionID int64 `json:"sender_region_id,omitempty"`
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
@ -509,6 +513,21 @@ type GiftTargetHostScope struct {
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"`
}
// UserDisplayProfile 是用户展示资料的轻量快照。
// 该结构只用于异步 IM 展示,避免客户端收到事件后还要等待在线成员资料刷新。
type UserDisplayProfile struct {
// UserID 是快照归属用户;事件消费者用它确认快照没有串到其他用户。
UserID int64 `json:"user_id"`
// Nickname 是客户端优先展示的昵称;为空时客户端继续回退 display_user_id。
Nickname string `json:"nickname,omitempty"`
// Avatar 是进房瞬间头像 URL客户端可直接渲染进场横幅头像。
Avatar string `json:"avatar,omitempty"`
// DisplayUserID 是当前 active 短号,昵称缺失时给客户端兜底展示。
DisplayUserID string `json:"display_user_id,omitempty"`
// PrettyDisplayUserID 是当前靓号展示值,保留给客户端后续展示策略选择。
PrettyDisplayUserID string `json:"pretty_display_user_id,omitempty"`
}
// GiftDisplayProfile 是送礼入口固化的用户展示快照。
// 它只服务 IM 和历史事件展示兜底,不参与权限、账务、统计或 Room Cell 核心状态判定。
type GiftDisplayProfile struct {

View File

@ -12,7 +12,7 @@ import (
const roomDirectIMPublishTimeout = 2 * time.Second
func splitRoomDirectIMRecords(records []outbox.Record) ([]outbox.Record, []outbox.Record) {
func splitRoomDirectIMRecords(records []outbox.Record, suppressMirror map[string]bool) ([]outbox.Record, []outbox.Record) {
if len(records) == 0 {
return nil, nil
}
@ -24,7 +24,7 @@ func splitRoomDirectIMRecords(records []outbox.Record) ([]outbox.Record, []outbo
continue
}
durable = append(durable, record)
if roomDirectIMMirrorEventType(record.EventType) {
if roomDirectIMMirrorEventType(record.EventType) && !suppressMirror[record.EventType] {
// 这类事件同时服务 UI 和业务事实消费MySQL outbox 保证统计、任务、通知等可补偿,直接 IM 只给客户端低延迟展示。
directIM = append(directIM, record)
}
@ -36,6 +36,7 @@ func roomDirectIMOnlyEventType(eventType string) bool {
switch eventType {
case "RoomHeatChanged",
"RoomRankChanged",
"RoomGiftBatchSent",
"RoomRocketFuelChanged",
"RoomRocketLaunched":
return true

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,198 @@
package service
import (
"strings"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/services/room-service/internal/room/command"
)
func buildSendGiftBatchDisplay(cmd command.SendGift, billing *walletv1.DebitGiftResponse, targetBillings []giftTargetBilling, targetCurrentGiftValues map[int64]int64, luckyGifts []*roomv1.LuckyGiftDrawResult, roomHeat int64) *roomv1.SendGiftBatchDisplay {
displayBilling := giftBillingForDisplay(billing, targetBillings)
targets := make([]*roomv1.SendGiftBatchTarget, 0, len(targetBillings))
luckyByTarget := luckyGiftResultsByTarget(luckyGifts)
targetUserIDs := make([]int64, 0, len(targetBillings))
for _, targetBilling := range targetBillings {
if targetBilling.TargetUserID <= 0 || targetBilling.Billing == nil {
continue
}
targetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, targetBilling.TargetUserID)
targetUserIDs = append(targetUserIDs, targetBilling.TargetUserID)
targets = append(targets, &roomv1.SendGiftBatchTarget{
TargetUserId: targetBilling.TargetUserID,
ReceiverNickname: giftDisplayName(targetDisplayProfile),
ReceiverAvatar: strings.TrimSpace(targetDisplayProfile.Avatar),
ReceiverDisplayUserId: strings.TrimSpace(targetDisplayProfile.DisplayUserID),
ReceiverPrettyDisplayUserId: strings.TrimSpace(targetDisplayProfile.PrettyDisplayUserID),
GiftValue: targetBilling.Billing.GetHeatValue(),
TargetGiftValue: targetCurrentGiftValues[targetBilling.TargetUserID],
CoinSpent: targetBilling.Billing.GetCoinSpent(),
BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(),
CommandId: targetBilling.CommandID,
LuckyGift: luckyByTarget[targetBilling.TargetUserID],
})
}
if len(targetUserIDs) == 0 {
targetUserIDs = append(targetUserIDs, cmd.TargetUserIDs...)
}
return &roomv1.SendGiftBatchDisplay{
SenderUserId: cmd.ActorUserID(),
SenderName: giftDisplayName(cmd.SenderDisplayProfile),
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
TotalGiftValue: sumTargetGiftValue(targetBillings),
TotalCoinSpent: sumTargetCoinSpent(targetBillings, billing),
BillingReceiptId: firstNonEmpty(displayBilling.GetBillingReceiptId(), cmd.BillingReceiptID),
RoomHeat: roomHeat,
PoolId: cmd.PoolID,
GiftTypeCode: displayBilling.GetGiftTypeCode(),
CpRelationType: displayBilling.GetCpRelationType(),
GiftName: displayBilling.GetGiftName(),
GiftIconUrl: displayBilling.GetGiftIconUrl(),
GiftAnimationUrl: displayBilling.GetGiftAnimationUrl(),
GiftEffectTypes: displayBilling.GetGiftEffectTypes(),
TargetUserIds: targetUserIDs,
Targets: targets,
CommandId: cmd.ID(),
}
}
func roomGiftBatchSentEventFromDisplay(display *roomv1.SendGiftBatchDisplay, visibleRegionID int64) *roomeventsv1.RoomGiftBatchSent {
if display == nil {
return nil
}
targets := make([]*roomeventsv1.RoomGiftBatchTarget, 0, len(display.GetTargets()))
for _, target := range display.GetTargets() {
if target == nil {
continue
}
targets = append(targets, &roomeventsv1.RoomGiftBatchTarget{
TargetUserId: target.GetTargetUserId(),
ReceiverNickname: target.GetReceiverNickname(),
ReceiverAvatar: target.GetReceiverAvatar(),
ReceiverDisplayUserId: target.GetReceiverDisplayUserId(),
ReceiverPrettyDisplayUserId: target.GetReceiverPrettyDisplayUserId(),
GiftValue: target.GetGiftValue(),
TargetGiftValue: target.GetTargetGiftValue(),
CoinSpent: target.GetCoinSpent(),
BillingReceiptId: target.GetBillingReceiptId(),
CommandId: target.GetCommandId(),
LuckyGift: roomGiftBatchLuckyResultFromRoom(target.GetLuckyGift()),
})
}
return &roomeventsv1.RoomGiftBatchSent{
SenderUserId: display.GetSenderUserId(),
SenderName: display.GetSenderName(),
SenderAvatar: display.GetSenderAvatar(),
SenderDisplayUserId: display.GetSenderDisplayUserId(),
SenderPrettyDisplayUserId: display.GetSenderPrettyDisplayUserId(),
GiftId: display.GetGiftId(),
GiftCount: display.GetGiftCount(),
TotalGiftValue: display.GetTotalGiftValue(),
TotalCoinSpent: display.GetTotalCoinSpent(),
BillingReceiptId: display.GetBillingReceiptId(),
RoomHeat: display.GetRoomHeat(),
PoolId: display.GetPoolId(),
GiftTypeCode: display.GetGiftTypeCode(),
CpRelationType: display.GetCpRelationType(),
GiftName: display.GetGiftName(),
GiftIconUrl: display.GetGiftIconUrl(),
GiftAnimationUrl: display.GetGiftAnimationUrl(),
GiftEffectTypes: display.GetGiftEffectTypes(),
TargetUserIds: display.GetTargetUserIds(),
Targets: targets,
CommandId: display.GetCommandId(),
VisibleRegionId: visibleRegionID,
}
}
func roomGiftBatchLuckyResultFromRoom(result *roomv1.LuckyGiftDrawResult) *roomeventsv1.RoomGiftBatchLuckyResult {
if result == nil {
return nil
}
return &roomeventsv1.RoomGiftBatchLuckyResult{
Enabled: result.GetEnabled(),
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(),
TargetUserId: result.GetTargetUserId(),
}
}
func luckyGiftResultsByTarget(results []*roomv1.LuckyGiftDrawResult) map[int64]*roomv1.LuckyGiftDrawResult {
byTarget := make(map[int64]*roomv1.LuckyGiftDrawResult, len(results))
for _, result := range results {
if result == nil || result.GetTargetUserId() <= 0 {
continue
}
byTarget[result.GetTargetUserId()] = result
}
return byTarget
}
func giftBillingForDisplay(billing *walletv1.DebitGiftResponse, targetBillings []giftTargetBilling) *walletv1.DebitGiftResponse {
if billing != nil {
return billing
}
for _, targetBilling := range targetBillings {
if targetBilling.Billing != nil {
return targetBilling.Billing
}
}
return &walletv1.DebitGiftResponse{}
}
func sumTargetGiftValue(targetBillings []giftTargetBilling) int64 {
var total int64
for _, targetBilling := range targetBillings {
if targetBilling.Billing != nil {
total += targetBilling.Billing.GetHeatValue()
}
}
return total
}
func sumTargetCoinSpent(targetBillings []giftTargetBilling, billing *walletv1.DebitGiftResponse) int64 {
var total int64
for _, targetBilling := range targetBillings {
if targetBilling.Billing != nil {
total += targetBilling.Billing.GetCoinSpent()
}
}
if total > 0 {
return total
}
if billing == nil {
return 0
}
return billing.GetCoinSpent()
}
func roomGiftLeaderboardValue(billing *walletv1.DebitGiftResponse) int64 {
if billing == nil {
return 0
}
// 房间贡献榜跟房间热度、房间内贡献人保持同一口径,使用 wallet 已按全局默认比例折算后的 heat_value。
if billing.GetHeatValue() > 0 {
return billing.GetHeatValue()
}
return 0
}

View File

@ -0,0 +1,154 @@
package service
import (
"context"
"fmt"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
)
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 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
}

View File

@ -0,0 +1,76 @@
package service
import (
"strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/state"
)
func buildRoomGiftSentRecords(roomID string, roomVersion int64, now time.Time, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, targetCurrentGiftValues map[int64]int64) ([]outbox.Record, error) {
// RoomGiftSent 是统计、活动、成长任务和房间展示共同消费的送礼事实;构造时必须按目标拆分,不能用 batch 展示消息替代逐目标账务事实。
records := make([]outbox.Record, 0, len(targetBillings))
for _, targetBilling := range targetBillings {
targetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, targetBilling.TargetUserID)
record, err := outbox.Build(roomID, "RoomGiftSent", roomVersion, 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),
// display_mode 只改变房间展示投递策略;逐目标事实仍然进入 durable outbox保证统计、activity、CP 和礼物墙消费口径不变。
DisplayMode: strings.TrimSpace(cmd.DisplayMode),
// effect_types 来自 wallet-service 已锁定的礼物价格配置room-service 只透传,避免统计和全服广播各自重新判定礼物效果。
GiftEffectTypes: targetBilling.Billing.GetGiftEffectTypes(),
IsRobotGift: cmd.RobotWalletGift,
SyntheticLuckyGift: cmd.SyntheticLuckyGift,
RealRoomRobotGift: cmd.RobotWalletGift && !cmd.RobotGift,
})
if err != nil {
return nil, err
}
records = append(records, record)
}
return records, nil
}
func buildRoomHeatChangedRecord(roomID string, roomVersion int64, now time.Time, heatDelta int64, currentHeat int64) (outbox.Record, error) {
// 热度事件只服务房间 UI 展示;当前值仍以 Room Cell 快照为准,客户端丢消息后可通过 snapshot 修复。
return outbox.Build(roomID, "RoomHeatChanged", roomVersion, now, &roomeventsv1.RoomHeatChanged{
Delta: heatDelta,
CurrentHeat: currentHeat,
})
}
func buildRoomRankChangedRecord(roomID string, roomVersion int64, now time.Time, rankItem state.RankItem) (outbox.Record, error) {
// 榜单事件表达本次送礼后 sender 在本地礼物榜中的新分值;完整榜单仍由快照和榜单读模型兜底。
return outbox.Build(roomID, "RoomRankChanged", roomVersion, now, &roomeventsv1.RoomRankChanged{
UserId: rankItem.UserID,
Score: rankItem.Score,
GiftValue: rankItem.GiftValue,
})
}

View File

@ -0,0 +1,250 @@
package service
import (
"slices"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/state"
)
func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) {
now := time.Date(2026, 6, 27, 10, 0, 0, 0, time.UTC)
cmd := command.SendGift{
Base: command.Base{
CommandID: "cmd-gift-fact",
ActorID: 101,
Room: "room-gift-fact",
},
TargetType: "user",
TargetUserID: 202,
TargetUserIDs: []int64{202},
GiftID: "gift-rose",
PoolID: "pool-lucky",
GiftCount: 3,
SenderCountryID: 86,
SenderRegionID: 9001,
SenderDisplayProfile: command.GiftDisplayProfile{
UserID: 101,
Username: " sender ",
Avatar: " avatar-sender ",
DisplayUserID: "S101",
PrettyDisplayUserID: "PS101",
},
TargetDisplayProfiles: []command.GiftDisplayProfile{{
UserID: 202,
Username: " receiver ",
Avatar: " avatar-receiver ",
DisplayUserID: "R202",
PrettyDisplayUserID: "PR202",
}},
RobotWalletGift: true,
SyntheticLuckyGift: true,
}
billing := &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt-target-202",
CoinSpent: 300,
HeatValue: 450,
GiftTypeCode: "lucky",
CpRelationType: "couple",
GiftName: "Rose",
GiftIconUrl: "https://asset/icon.png",
GiftAnimationUrl: "https://asset/anim.svga",
GiftEffectTypes: []string{"room_banner", "global_broadcast"},
}
records, err := buildRoomGiftSentRecords("room-gift-fact", 7, now, cmd, RoomMeta{VisibleRegionID: 8008}, []giftTargetBilling{{
TargetUserID: 202,
CommandID: "cmd-gift-fact:202",
Billing: billing,
}}, map[int64]int64{202: 999})
if err != nil {
t.Fatalf("build RoomGiftSent record failed: %v", err)
}
if len(records) != 1 {
t.Fatalf("RoomGiftSent must be split per target, got %d records", len(records))
}
record := records[0]
if record.EventType != "RoomGiftSent" || record.RoomID != "room-gift-fact" || record.Envelope.GetRoomVersion() != 7 {
t.Fatalf("outbox envelope mismatch: %+v", record)
}
var event roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
t.Fatalf("unmarshal RoomGiftSent failed: %v", err)
}
if event.GetSenderUserId() != 101 || event.GetTargetUserId() != 202 || event.GetGiftId() != "gift-rose" || event.GetGiftCount() != 3 {
t.Fatalf("basic gift fields mismatch: %+v", &event)
}
if event.GetBillingReceiptId() != "receipt-target-202" || event.GetCoinSpent() != 300 || event.GetGiftValue() != 450 || event.GetTargetGiftValue() != 999 {
t.Fatalf("billing fields mismatch: %+v", &event)
}
if event.GetVisibleRegionId() != 8008 || event.GetCountryId() != 86 || event.GetRegionId() != 9001 || event.GetCommandId() != "cmd-gift-fact:202" {
t.Fatalf("routing/stat fields mismatch: %+v", &event)
}
if event.GetGiftTypeCode() != "lucky" || event.GetCpRelationType() != "couple" || event.GetGiftName() != "Rose" {
t.Fatalf("gift config fields mismatch: %+v", &event)
}
if event.GetDisplayMode() != "" {
t.Fatalf("single-target durable fact must keep legacy display mode empty: %+v", &event)
}
if !slices.Equal(event.GetGiftEffectTypes(), []string{"room_banner", "global_broadcast"}) {
t.Fatalf("gift effect types mismatch: %+v", event.GetGiftEffectTypes())
}
if !event.GetIsRobotGift() || !event.GetSyntheticLuckyGift() || !event.GetRealRoomRobotGift() {
t.Fatalf("robot/lucky flags mismatch: %+v", &event)
}
if event.GetSenderName() != "sender" || event.GetReceiverNickname() != "receiver" || event.GetReceiverAvatar() != "avatar-receiver" {
t.Fatalf("display profile fields mismatch: %+v", &event)
}
}
func TestBuildRoomGiftSentRecordsKeepsBatchDisplayFactsSplitPerTarget(t *testing.T) {
now := time.Date(2026, 6, 27, 10, 30, 0, 0, time.UTC)
cmd := command.SendGift{
Base: command.Base{
CommandID: "cmd-gift-batch-facts",
ActorID: 101,
Room: "room-gift-batch-facts",
},
TargetType: "user",
TargetUserID: 202,
TargetUserIDs: []int64{202, 203},
GiftID: "gift-clover",
GiftCount: 1,
DisplayMode: "batch",
}
billing := &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt-target",
CoinSpent: 100,
HeatValue: 100,
}
records, err := buildRoomGiftSentRecords("room-gift-batch-facts", 9, now, cmd, RoomMeta{}, []giftTargetBilling{
{TargetUserID: 202, CommandID: "cmd-gift-batch-facts:202", Billing: billing},
{TargetUserID: 203, CommandID: "cmd-gift-batch-facts:203", Billing: billing},
}, nil)
if err != nil {
t.Fatalf("build batch RoomGiftSent records failed: %v", err)
}
if len(records) != 2 {
t.Fatalf("batch gift facts must stay split per target, got %d records", len(records))
}
for _, record := range records {
var event roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
t.Fatalf("unmarshal batch RoomGiftSent failed: %v", err)
}
if event.GetTargetUserId() <= 0 || event.GetCommandId() == "" {
t.Fatalf("batch RoomGiftSent fact must keep per-target fields: %+v", &event)
}
if event.GetDisplayMode() != "batch" {
t.Fatalf("batch RoomGiftSent fact must be marked for display suppression: %+v", &event)
}
}
}
func TestRoomGiftBatchSentEventFromDisplayPreservesTargetsAndTotals(t *testing.T) {
cmd := command.SendGift{
Base: command.Base{
CommandID: "cmd-batch-fact",
ActorID: 301,
Room: "room-batch-fact",
},
TargetUserID: 401,
TargetUserIDs: []int64{401, 402},
GiftID: "gift-star",
PoolID: "pool-batch",
GiftCount: 2,
DisplayMode: "batch",
SenderDisplayProfile: command.GiftDisplayProfile{
UserID: 301,
Username: "batch sender",
Avatar: "sender-avatar",
DisplayUserID: "S301",
PrettyDisplayUserID: "PS301",
},
TargetDisplayProfiles: []command.GiftDisplayProfile{
{UserID: 401, Username: "target-one", Avatar: "avatar-one", DisplayUserID: "T401", PrettyDisplayUserID: "PT401"},
{UserID: 402, Username: "target-two", Avatar: "avatar-two", DisplayUserID: "T402", PrettyDisplayUserID: "PT402"},
},
}
billing := &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt-batch-root",
HeatValue: 330,
CoinSpent: 220,
GiftTypeCode: "normal",
CpRelationType: "brother",
GiftName: "Star",
GiftIconUrl: "icon-star",
GiftAnimationUrl: "anim-star",
GiftEffectTypes: []string{"room_banner"},
}
targetBillings := []giftTargetBilling{
{TargetUserID: 401, CommandID: "cmd-batch-fact:401", Billing: &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-401", HeatValue: 110, CoinSpent: 70}},
{TargetUserID: 402, CommandID: "cmd-batch-fact:402", Billing: &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-402", HeatValue: 220, CoinSpent: 150}},
}
luckyGifts := []*roomv1.LuckyGiftDrawResult{{
Enabled: true,
DrawId: "draw-402",
CommandId: "cmd-batch-fact:402",
PoolId: "pool-batch",
GiftId: "gift-star",
MultiplierPpm: 2000000,
EffectiveRewardCoins: 50,
TargetUserId: 402,
}}
display := buildSendGiftBatchDisplay(cmd, billing, targetBillings, map[int64]int64{401: 1001, 402: 1002}, luckyGifts, 8888)
event := roomGiftBatchSentEventFromDisplay(display, 6006)
if event.GetSenderUserId() != 301 || event.GetGiftId() != "gift-star" || event.GetGiftCount() != 2 || event.GetCommandId() != "cmd-batch-fact" {
t.Fatalf("batch root fields mismatch: %+v", event)
}
if event.GetTotalGiftValue() != 330 || event.GetTotalCoinSpent() != 220 || event.GetBillingReceiptId() != "receipt-batch-root" || event.GetRoomHeat() != 8888 {
t.Fatalf("batch total fields mismatch: %+v", event)
}
if event.GetVisibleRegionId() != 6006 || !slices.Equal(event.GetTargetUserIds(), []int64{401, 402}) || !slices.Equal(event.GetGiftEffectTypes(), []string{"room_banner"}) {
t.Fatalf("batch routing/effect fields mismatch: %+v", event)
}
if len(event.GetTargets()) != 2 {
t.Fatalf("batch targets mismatch: %+v", event.GetTargets())
}
if target := event.GetTargets()[0]; target.GetTargetUserId() != 401 || target.GetGiftValue() != 110 || target.GetTargetGiftValue() != 1001 || target.GetBillingReceiptId() != "receipt-401" {
t.Fatalf("first batch target mismatch: %+v", target)
}
if target := event.GetTargets()[1]; target.GetTargetUserId() != 402 || target.GetGiftValue() != 220 || target.GetTargetGiftValue() != 1002 || target.GetLuckyGift().GetDrawId() != "draw-402" {
t.Fatalf("second batch target mismatch: %+v", target)
}
}
func TestRoomHeatAndRankChangedRecordsPreserveSnapshotFields(t *testing.T) {
now := time.Date(2026, 6, 27, 11, 0, 0, 0, time.UTC)
heatRecord, err := buildRoomHeatChangedRecord("room-heat-rank", 12, now, 77, 1007)
if err != nil {
t.Fatalf("build RoomHeatChanged failed: %v", err)
}
var heat roomeventsv1.RoomHeatChanged
if err := proto.Unmarshal(heatRecord.Envelope.GetBody(), &heat); err != nil {
t.Fatalf("unmarshal RoomHeatChanged failed: %v", err)
}
if heatRecord.EventType != "RoomHeatChanged" || heat.GetDelta() != 77 || heat.GetCurrentHeat() != 1007 {
t.Fatalf("heat event mismatch: record=%+v event=%+v", heatRecord, &heat)
}
rankRecord, err := buildRoomRankChangedRecord("room-heat-rank", 13, now, state.RankItem{UserID: 501, Score: 700, GiftValue: 700})
if err != nil {
t.Fatalf("build RoomRankChanged failed: %v", err)
}
var rank roomeventsv1.RoomRankChanged
if err := proto.Unmarshal(rankRecord.Envelope.GetBody(), &rank); err != nil {
t.Fatalf("unmarshal RoomRankChanged failed: %v", err)
}
if rankRecord.EventType != "RoomRankChanged" || rank.GetUserId() != 501 || rank.GetScore() != 700 || rank.GetGiftValue() != 700 {
t.Fatalf("rank event mismatch: record=%+v event=%+v", rankRecord, &rank)
}
}

View File

@ -0,0 +1,448 @@
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"
)
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
}
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,
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"
}
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 _, 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.batchDisplayEnabled && len(f.cmd.TargetUserIDs) < 2 {
// batch-send 是多人展示入口,必须明确至少两个收礼目标;单目标继续走普通 SendGift避免展示协议和账务事实混用。
return xerr.New(xerr.InvalidArgument, "batch gift requires multiple targets")
}
for _, targetUserID := range f.cmd.TargetUserIDs {
if _, exists := current.OnlineUsers[targetUserID]; !exists {
// 每个接收方都必须在房间 presence 内;批量扣费前先全部校验,避免钱包出现无房间表现的入账。
return xerr.New(xerr.NotFound, "target not in room")
}
}
if f.robot.Enabled {
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 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) 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 {
// 客户端显式传 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, &activityv1.CheckLuckyGiftRequest{
Meta: activityMetaFromRoom(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 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)
}
}
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(),
RoomAtmosphereRewardCoins: f.luckyGift.GetRoomAtmosphereRewardCoins(),
ActivitySubsidyCoins: f.luckyGift.GetActivitySubsidyCoins(),
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(),
},
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),
},
},
}
if f.cmd.RobotGift {
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
result.roomGiftLeaderboard = nil
result.roomUserGiftStats = nil
result.roomWeeklyContribution = nil
}
return result
}

View File

@ -0,0 +1,274 @@
package service
import (
"context"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/proto"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
)
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) {
if len(targetBillings) == 0 {
return nil, nil
}
if len(targetBillings) == 1 {
result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0], now)
if err != nil {
return nil, err
}
return []*roomv1.LuckyGiftDrawResult{result}, nil
}
return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings, now)
}
func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) (*roomv1.LuckyGiftDrawResult, error) {
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: luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now),
})
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 在中奖落库后发送钱包私有 IM避免 HTTP 响应领先账务通知链路。
return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil
}
func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) {
req := &activityv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*activityv1.LuckyGiftMeta, 0, len(targetBillings))}
for _, targetBilling := range targetBillings {
if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
}
// 多人送礼只跨服务调用一次activity-service 在一个事务内按这个顺序推进锁和抽奖事实。
req.LuckyGifts = append(req.LuckyGifts, luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now))
}
resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req)
if err != nil {
return nil, err
}
if resp == nil || len(resp.GetResults()) != len(targetBillings) {
return nil, xerr.New(xerr.Unavailable, "lucky gift batch draw response is incomplete")
}
results := make([]*roomv1.LuckyGiftDrawResult, 0, len(targetBillings))
for index, result := range resp.GetResults() {
if result == nil {
return nil, xerr.New(xerr.Unavailable, "lucky gift batch draw result is empty")
}
results = append(results, luckyGiftResultFromProto(result, targetBillings[index].TargetUserID))
}
return results, nil
}
func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) *activityv1.LuckyGiftMeta {
return &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,
}
}
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(),
// 这里故意不透出 activity 返回的返奖后余额。SendGift HTTP 只承诺扣费后的真实余额,中奖返奖后的余额必须等钱包落账后由 WalletBalanceChanged 私有 IM 覆盖。
CoinBalanceAfter: 0,
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()
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 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 giftFinalCoinBalanceAfter(billing *walletv1.DebitGiftResponse) int64 {
if billing == nil {
return 0
}
// SendGift 的 HTTP 余额只表示本次送礼扣费完成后的 COIN 余额lucky/super_lucky 返奖属于后续入账事实,由钱包余额 IM 更新客户端。
return billing.GetBalanceAfter()
}
func firstPositiveInt64(values ...int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}

View File

@ -0,0 +1,117 @@
package service
import (
"strconv"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/state"
)
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 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}
}

View File

@ -0,0 +1,85 @@
package service
import (
"context"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/state"
)
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, giftSendOptions{
Robot: robotGiftOptions{
Enabled: true,
RobotUserIDs: input.RobotUserIDs,
SyntheticRewardCoins: input.SyntheticRewardCoins,
SyntheticMultiplierPPM: input.SyntheticMultiplierPPM,
RealRoomHeat: input.RealRoomHeat,
},
})
}
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 normalizeGiftTargetType(raw string) string {
raw = strings.TrimSpace(raw)
switch raw {
case "", "user", "all_mic", "all_room", "couple":
return raw
default:
return raw
}
}

View File

@ -19,6 +19,7 @@ import (
type luckyGiftTestClient struct {
checks []*activityv1.CheckLuckyGiftRequest
draws []*activityv1.ExecuteLuckyGiftDrawRequest
batchDraws []*activityv1.BatchExecuteLuckyGiftDrawRequest
drawResults []*activityv1.LuckyGiftDrawResult
}
@ -72,6 +73,46 @@ func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activ
}}, nil
}
func (c *luckyGiftTestClient) BatchExecuteLuckyGiftDraw(_ context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
c.batchDraws = append(c.batchDraws, req)
resp := &activityv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*activityv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts()))}
for index, meta := range req.GetLuckyGifts() {
if len(c.drawResults) > 0 {
resultIndex := index
if resultIndex >= len(c.drawResults) {
resultIndex = len(c.drawResults) - 1
}
result := proto.Clone(c.drawResults[resultIndex]).(*activityv1.LuckyGiftDrawResult)
if result.CommandId == "" {
result.CommandId = meta.GetCommandId()
}
if result.PoolId == "" {
result.PoolId = meta.GetPoolId()
}
if result.GiftId == "" {
result.GiftId = meta.GetGiftId()
}
resp.Results = append(resp.Results, result)
continue
}
resp.Results = append(resp.Results, &activityv1.LuckyGiftDrawResult{
DrawId: "lucky_draw_test",
CommandId: meta.GetCommandId(),
PoolId: meta.GetPoolId(),
GiftId: meta.GetGiftId(),
RuleVersion: 12,
ExperiencePool: "novice",
SelectedTierId: "novice_2x",
MultiplierPpm: 2_000_000,
BaseRewardCoins: 200,
EffectiveRewardCoins: 200,
RewardStatus: "pending",
CreatedAtMs: 1_779_258_000_000,
})
}
return resp, nil
}
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -82,32 +123,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
HeatValue: 100,
GiftTypeCode: "super_lucky",
}}}
luckyGift := &luckyGiftTestClient{drawResults: []*activityv1.LuckyGiftDrawResult{
{
DrawId: "lucky_draw_target_202",
SelectedTierId: "novice_2x",
MultiplierPpm: 2_000_000,
BaseRewardCoins: 200,
EffectiveRewardCoins: 200,
RewardStatus: "granted",
WalletTransactionId: "wallet_tx_lucky_202",
CoinBalanceAfter: 8800,
CreatedAtMs: 1_779_258_000_000,
},
{
DrawId: "lucky_draw_target_303",
SelectedTierId: "normal_3x",
MultiplierPpm: 3_000_000,
BaseRewardCoins: 300,
EffectiveRewardCoins: 300,
RewardStatus: "granted",
WalletTransactionId: "wallet_tx_lucky_303",
CoinBalanceAfter: 9100,
CreatedAtMs: 1_779_258_000_001,
StageFeedback: true,
HighMultiplier: true,
},
}}
luckyGift := &luckyGiftTestClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-test",
LeaseTTL: 10 * time.Second,
@ -140,7 +156,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
if err != nil {
t.Fatalf("send lucky gift failed: %v", err)
}
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 || resp.GetLuckyGift().GetRewardStatus() != "granted" || resp.GetLuckyGift().GetWalletTransactionId() != "wallet_tx_lucky" || resp.GetLuckyGift().GetCoinBalanceAfter() != 8800 {
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 || resp.GetLuckyGift().GetRewardStatus() != "granted" || resp.GetLuckyGift().GetWalletTransactionId() != "wallet_tx_lucky" || resp.GetLuckyGift().GetCoinBalanceAfter() != 0 {
t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
}
if len(resp.GetLuckyGifts()) != 1 || resp.GetLuckyGifts()[0].GetTargetUserId() != viewerID {
@ -169,7 +185,10 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
{BillingReceiptId: "receipt-lucky-202", CoinSpent: 100, ChargeAmount: 100, GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "super_lucky"},
{BillingReceiptId: "receipt-lucky-303", CoinSpent: 200, ChargeAmount: 200, GiftPointAdded: 200, HeatValue: 200, GiftTypeCode: "super_lucky"},
}}
luckyGift := &luckyGiftTestClient{}
luckyGift := &luckyGiftTestClient{drawResults: []*activityv1.LuckyGiftDrawResult{
{DrawId: "draw-first", MultiplierPpm: 2_000_000, BaseRewardCoins: 200, EffectiveRewardCoins: 200, RewardStatus: "pending", StageFeedback: true, CreatedAtMs: 1_779_258_000_001},
{DrawId: "draw-second", MultiplierPpm: 3_000_000, BaseRewardCoins: 300, EffectiveRewardCoins: 300, RewardStatus: "pending", HighMultiplier: true, CreatedAtMs: 1_779_258_000_002},
}}
svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-multi-test",
LeaseTTL: 10 * time.Second,
@ -207,11 +226,14 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" {
t.Fatalf("multi-target lucky gift should check pool once: %+v", luckyGift.checks)
}
if len(luckyGift.draws) != 2 {
t.Fatalf("multi-target lucky gift should draw once per target, got %d", len(luckyGift.draws))
if len(luckyGift.draws) != 0 || len(luckyGift.batchDraws) != 1 {
t.Fatalf("multi-target lucky gift should call one batch draw: draws=%d batch=%d", len(luckyGift.draws), len(luckyGift.batchDraws))
}
firstDraw := luckyGift.draws[0].GetLuckyGift()
secondDraw := luckyGift.draws[1].GetLuckyGift()
if len(luckyGift.batchDraws[0].GetLuckyGifts()) != 2 {
t.Fatalf("multi-target lucky batch should include two targets: %+v", luckyGift.batchDraws[0])
}
firstDraw := luckyGift.batchDraws[0].GetLuckyGifts()[0]
secondDraw := luckyGift.batchDraws[0].GetLuckyGifts()[1]
if firstDraw.GetCommandId() != "cmd-lucky-multi:target:202" || firstDraw.GetTargetUserId() != firstTargetID || firstDraw.GetCoinSpent() != 100 {
t.Fatalf("first target lucky draw mismatch: %+v", firstDraw)
}
@ -224,7 +246,7 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
resp.GetLuckyGift().GetMultiplierPpm() != 5_000_000 ||
resp.GetLuckyGift().GetEffectiveRewardCoins() != 500 ||
resp.GetLuckyGift().GetWalletTransactionId() != "" ||
resp.GetLuckyGift().GetCoinBalanceAfter() != 9100 ||
resp.GetLuckyGift().GetCoinBalanceAfter() != 0 ||
!resp.GetLuckyGift().GetStageFeedback() ||
!resp.GetLuckyGift().GetHighMultiplier() ||
len(resp.GetLuckyGifts()) != 2 {

View File

@ -79,7 +79,10 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}
// 机器人展示只是 App 视觉补偿,提交前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
robotDisplayRecords := s.sampleRobotDisplayRecords(result.robotDisplayRecords)
durableOutboxRecords, directIMRecords := splitRoomDirectIMRecords(outboxRecords)
durableOutboxRecords, directIMRecords := splitRoomDirectIMRecords(outboxRecords, result.suppressDirectIMEventTypes)
// mutate 可额外返回只服务客户端展示的直发 IM典型场景是重复 JoinRoom 的入房飘窗;
// 这类事件不能进入 durable outbox否则会污染活跃统计和补偿消费。
directIMRecords = append(directIMRecords, result.directIMRecords...)
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{

View File

@ -25,11 +25,12 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
password := strings.TrimSpace(req.GetPassword())
// JoinRoom 只维护 room-service presence不创建任何本地长连接订阅。
cmd := command.JoinRoom{
Base: baseFromMeta(req.GetMeta()),
Role: req.GetRole(),
ActorCountryID: req.GetActorCountryId(),
ActorRegionID: req.GetActorRegionId(),
ActorIsRobot: req.GetActorIsRobot(),
Base: baseFromMeta(req.GetMeta()),
Role: req.GetRole(),
ActorCountryID: req.GetActorCountryId(),
ActorRegionID: req.GetActorRegionId(),
ActorIsRobot: req.GetActorIsRobot(),
ActorDisplayProfile: userDisplayProfileFromProto(req.GetActorDisplayProfile()),
// entry_vehicle 来自 gateway 入房瞬间的 wallet 快照,写入 command log 后可稳定重放。
EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()),
}
@ -58,14 +59,21 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
}
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
// 重复 JoinRoom 表示重连或刷新业务 presence只更新 last_seen不重复发进房系统消息。
// 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords不进入 durable outbox
// 避免统计服务把同一个在线用户重复计为进房活跃。
existing.LastSeenAtMS = now.UnixMilli()
current.Version++
joinedDisplayEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, firstNonEmpty(existing.Role, cmd.Role), current.VisibleRegionID))
if err != nil {
return mutationResult{}, nil, err
}
snapshot := current.ToProto()
return mutationResult{
snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()),
snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()),
directIMRecords: []outbox.Record{joinedDisplayEvent},
}, nil, nil
}
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
@ -77,17 +85,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
current.Version++
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, &roomeventsv1.RoomUserJoined{
UserId: cmd.ActorUserID(),
Role: cmd.Role,
VisibleRegionId: current.VisibleRegionID,
CountryId: cmd.ActorCountryID,
RegionId: firstNonZeroInt64(cmd.ActorRegionID, current.VisibleRegionID),
// 机器人进房仍然需要 outbox 驱动 IM 展示和恢复,但不能进入真人活跃统计。
IsRobot: cmd.ActorIsRobot,
// outbox 事件必须带座驾快照;否则异步补偿投递 IM 时会丢失入场座驾。
EntryVehicle: eventEntryVehicleFromCommand(cmd.EntryVehicle),
})
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, cmd.Role, current.VisibleRegionID))
if err != nil {
return mutationResult{}, nil, err
}
@ -106,6 +104,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
RoomVersion: current.Version,
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
Attributes: roomUserJoinedIMAttributes(cmd.Role, cmd.ActorDisplayProfile),
},
}, []outbox.Record{joinedEvent}, nil
})
@ -121,6 +120,55 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
}, nil
}
func userDisplayProfileFromProto(item *roomv1.RoomUserDisplayProfile) command.UserDisplayProfile {
if item == nil || item.GetUserId() <= 0 {
return command.UserDisplayProfile{}
}
// 只信任 gateway 固化的展示快照room-service 不在进房高频链路反查用户服务。
return command.UserDisplayProfile{
UserID: item.GetUserId(),
Nickname: strings.TrimSpace(item.GetNickname()),
Avatar: strings.TrimSpace(item.GetAvatar()),
DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()),
PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()),
}
}
func roomUserJoinedEventFromCommand(cmd command.JoinRoom, role string, visibleRegionID int64) *roomeventsv1.RoomUserJoined {
profile := cmd.ActorDisplayProfile
// 事件 body 是 direct IM 和 outbox 补偿的共同来源,展示字段必须和统计字段一起固定在命令提交时刻。
return &roomeventsv1.RoomUserJoined{
UserId: cmd.ActorUserID(),
Role: role,
VisibleRegionId: visibleRegionID,
CountryId: cmd.ActorCountryID,
RegionId: firstNonZeroInt64(cmd.ActorRegionID, visibleRegionID),
IsRobot: cmd.ActorIsRobot,
EntryVehicle: eventEntryVehicleFromCommand(cmd.EntryVehicle),
Nickname: strings.TrimSpace(profile.Nickname),
Avatar: strings.TrimSpace(profile.Avatar),
DisplayUserId: strings.TrimSpace(profile.DisplayUserID),
PrettyDisplayUserId: strings.TrimSpace(profile.PrettyDisplayUserID),
}
}
func roomUserJoinedIMAttributes(role string, profile command.UserDisplayProfile) map[string]string {
values := map[string]string{
"role": strings.TrimSpace(role),
"nickname": strings.TrimSpace(profile.Nickname),
"avatar": strings.TrimSpace(profile.Avatar),
"display_user_id": strings.TrimSpace(profile.DisplayUserID),
"pretty_display_user_id": strings.TrimSpace(profile.PrettyDisplayUserID),
}
// 空字段不进 IM payload避免客户端把空字符串误判为后端明确清空资料。
for key, value := range values {
if value == "" {
delete(values, key)
}
}
return values
}
func entryVehicleSnapshotFromProto(item *roomv1.RoomEntryVehicleSnapshot) command.EntryVehicleSnapshot {
if item == nil || item.GetResourceId() <= 0 {
return command.EntryVehicleSnapshot{}

View File

@ -9,6 +9,7 @@ import (
"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"
@ -30,11 +31,13 @@ func (c *fixedRoomRocketClock) Now() time.Time {
}
type rocketTestWallet struct {
debits []*walletv1.DebitGiftResponse
grants []*walletv1.GrantResourceGroupRequest
lastDebit *walletv1.DebitGiftRequest
debitRequests []*walletv1.DebitGiftRequest
lastBatch *walletv1.BatchDebitGiftRequest
debits []*walletv1.DebitGiftResponse
grants []*walletv1.GrantResourceGroupRequest
lastDebit *walletv1.DebitGiftRequest
debitRequests []*walletv1.DebitGiftRequest
lastBatch *walletv1.BatchDebitGiftRequest
balanceResponses []*walletv1.GetBalancesResponse
balanceRequests []*walletv1.GetBalancesRequest
}
type recordingRoomDirectIMPublisher struct {
@ -68,6 +71,86 @@ func (p *recordingRoomDirectIMPublisher) counts() map[string]int {
return counts
}
func (p *recordingRoomDirectIMPublisher) recordsByType(eventType string) []*roomeventsv1.EventEnvelope {
p.mu.Lock()
defer p.mu.Unlock()
records := make([]*roomeventsv1.EventEnvelope, 0)
for _, record := range p.records {
if record.GetEventType() == eventType {
records = append(records, proto.Clone(record).(*roomeventsv1.EventEnvelope))
}
}
return records
}
func TestJoinRoomDirectIMCarriesDisplayProfileSnapshot(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)
roomID := "room-join-display-profile"
userID := int64(1002)
createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, userID),
Role: "audience",
ActorDisplayProfile: &roomv1.RoomUserDisplayProfile{
UserId: userID,
Nickname: "Join Snapshot",
Avatar: "https://cdn.example/join.png",
DisplayUserId: "200002",
PrettyDisplayUserId: "888002",
},
}); err != nil {
t.Fatalf("join room with display profile failed: %v", err)
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})
records := directIM.recordsByType("RoomUserJoined")
if len(records) != 1 {
t.Fatalf("direct IM joined records mismatch: %+v", records)
}
var joined roomeventsv1.RoomUserJoined
if err := proto.Unmarshal(records[0].GetBody(), &joined); err != nil {
t.Fatalf("decode direct IM joined body failed: %v", err)
}
if joined.GetNickname() != "Join Snapshot" || joined.GetAvatar() != "https://cdn.example/join.png" || joined.GetDisplayUserId() != "200002" || joined.GetPrettyDisplayUserId() != "888002" {
t.Fatalf("direct IM joined profile mismatch: %+v", &joined)
}
}
func TestRepeatJoinRoomPublishesEntryBroadcastDirectIMOnly(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)
roomID := "room-repeat-join-broadcast"
userID := int64(1002)
createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
joinRocketRoom(t, ctx, svc, roomID, userID)
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})
if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != 1 {
t.Fatalf("first join must write one durable RoomUserJoined outbox, got %d", got)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, userID),
Role: "audience",
}); err != nil {
t.Fatalf("repeat join failed: %v", err)
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 2})
if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != 1 {
t.Fatalf("repeat join must not write durable RoomUserJoined outbox, got %d", got)
}
}
func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
w.lastDebit = req
w.debitRequests = append(w.debitRequests, req)
@ -273,6 +356,247 @@ func TestSendGiftMirrorsGiftDisplayToDirectIMAndKeepsDurableFact(t *testing.T) {
})
}
func TestBatchSendGiftPublishesSingleBatchDisplayAndKeepsDurableFacts(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-batch-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 20, GiftTypeCode: "normal", BalanceAfter: 980, GiftName: "Clover", GiftAnimationUrl: "https://cdn.example/clover.svga"},
{BillingReceiptId: "receipt-batch-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 30, GiftTypeCode: "normal", BalanceAfter: 950, GiftName: "Clover", GiftAnimationUrl: "https://cdn.example/clover.svga"},
}}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
roomID := "room-gift-batch-direct-im"
ownerID := int64(801)
firstTargetID := int64(802)
secondTargetID := int64(803)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
before := outboxEventCounts(t, ctx, repository)
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "gift-batch-direct-im"),
TargetType: "user",
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "clover",
GiftCount: 1,
})
if err != nil {
t.Fatalf("batch send gift failed: %v", err)
}
if resp.GetCoinBalanceAfter() != 950 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 {
t.Fatalf("batch response must include final balance and batch display: %+v", resp)
}
if resp.GetBatchDisplay().GetEventId() == "" || resp.GetBatchDisplay().GetTotalGiftValue() != 50 || resp.GetBatchDisplay().GetTargetUserIds()[0] != firstTargetID {
t.Fatalf("batch display snapshot mismatch: %+v", resp.GetBatchDisplay())
}
after := outboxEventCounts(t, ctx, repository)
if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 2 {
t.Fatalf("batch send must keep one durable RoomGiftSent per target, delta=%d counts=%+v", delta, after)
}
if delta := after["RoomGiftBatchSent"] - before["RoomGiftBatchSent"]; delta != 0 {
t.Fatalf("RoomGiftBatchSent is direct display only and must not enter durable outbox, delta=%d counts=%+v", delta, after)
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{
"RoomGiftBatchSent": 1,
"RoomHeatChanged": 1,
"RoomRankChanged": 1,
})
counts := directIM.counts()
if counts["RoomGiftSent"] != 0 {
t.Fatalf("batch display must suppress per-target GiftSent direct IM: %+v", counts)
}
}
func TestSendGiftDisplayModeDoesNotTriggerBatchDisplay(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 10, 30, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-normal-multi-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 20, GiftTypeCode: "normal", BalanceAfter: 980},
{BillingReceiptId: "receipt-normal-multi-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 30, GiftTypeCode: "normal", BalanceAfter: 950},
}}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
roomID := "room-gift-normal-multi-display-mode"
ownerID := int64(811)
firstTargetID := int64(812)
secondTargetID := int64(813)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "gift-normal-multi-display-mode"),
TargetType: "user",
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "clover",
GiftCount: 1,
DisplayMode: "batch",
})
if err != nil {
t.Fatalf("normal multi-target send gift failed: %v", err)
}
if resp.GetBatchDisplay() != nil {
t.Fatalf("plain SendGift must not create batch display: %+v", resp.GetBatchDisplay())
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftSent": 2})
}
func TestBatchLuckyGiftUsesSingleActivityBatchDrawAndDebitCoinBalance(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 11, 0, 0, 0, time.UTC)}
debits := make([]*walletv1.DebitGiftResponse, 0, 10)
for index := 0; index < 10; index++ {
debits = append(debits, &walletv1.DebitGiftResponse{
BillingReceiptId: fmt.Sprintf("receipt-lucky-batch-%d", index),
CoinSpent: 10,
ChargeAmount: 10,
HeatValue: 10,
GiftTypeCode: "super_lucky",
BalanceAfter: int64(990 - index*10),
})
}
wallet := &rocketTestWallet{debits: debits}
luckyGift := &batchLuckyGiftTestClient{drawDelay: 20 * time.Millisecond, createdAtBase: now.Now().UnixMilli()}
directIM := newRecordingRoomDirectIMPublisher()
svc := roomservice.New(roomservice.Config{
NodeID: "node-batch-lucky-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
RoomDisplayPublisher: directIM,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
roomID := "room-batch-lucky"
ownerID := int64(901)
targetUserIDs := make([]int64, 0, 10)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
for offset := int64(0); offset < 10; offset++ {
userID := int64(902) + offset
targetUserIDs = append(targetUserIDs, userID)
joinRocketRoom(t, ctx, svc, roomID, userID)
}
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "batch-lucky"),
TargetType: "user",
TargetUserIds: targetUserIDs,
GiftId: "super-lucky-clover",
GiftCount: 1,
PoolId: "super_lucky",
})
if err != nil {
t.Fatalf("batch lucky gift failed: %v", err)
}
checks, singleDraws, batchDraws, batchCalls := luckyGift.stats()
if checks != 1 || singleDraws != 0 || batchDraws != 10 || batchCalls != 1 {
t.Fatalf("batch lucky must check once and call one activity batch draw: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls)
}
if len(wallet.balanceRequests) != 0 {
t.Fatalf("batch lucky must not query final sender coin balance: %+v", wallet.balanceRequests)
}
if resp.GetCoinBalanceAfter() != 900 || resp.GetLuckyGift().GetCoinBalanceAfter() != 0 {
t.Fatalf("batch lucky HTTP balance must stay at debit result and lucky balance must stay empty: %+v", resp)
}
if len(resp.GetLuckyGifts()) != 10 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 10 {
t.Fatalf("batch lucky response must keep per-target lucky details and batch display: %+v", resp)
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
}
func TestBatchLuckyGiftUpdatesEachMicGiftValueAndBatchDisplay(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 12, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-batch-lucky-seat-1", CoinSpent: 60, ChargeAmount: 60, HeatValue: 60, GiftTypeCode: "super_lucky", BalanceAfter: 940},
{BillingReceiptId: "receipt-batch-lucky-seat-2", CoinSpent: 40, ChargeAmount: 40, HeatValue: 40, GiftTypeCode: "super_lucky", BalanceAfter: 900},
}}
luckyGift := &batchLuckyGiftTestClient{createdAtBase: now.Now().UnixMilli()}
directIM := newRecordingRoomDirectIMPublisher()
svc := roomservice.New(roomservice.Config{
NodeID: "node-batch-lucky-mic-value-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: now,
RoomDisplayPublisher: directIM,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
roomID := "room-batch-lucky-mic-value"
ownerID := int64(931)
firstTargetID := int64(932)
secondTargetID := int64(933)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, firstTargetID, "batch-lucky-first-mic-up"),
SeatNo: 2,
}); err != nil {
t.Fatalf("first target mic up failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, secondTargetID, "batch-lucky-second-mic-up"),
SeatNo: 3,
}); err != nil {
t.Fatalf("second target mic up failed: %v", err)
}
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "batch-lucky-mic-value"),
TargetType: "user",
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "super-lucky-seat-gift",
GiftCount: 1,
PoolId: "super_lucky",
})
if err != nil {
t.Fatalf("batch lucky gift with mic targets failed: %v", err)
}
if checks, singleDraws, batchDraws, batchCalls := luckyGift.stats(); checks != 1 || singleDraws != 0 || batchDraws != 2 || batchCalls != 1 {
t.Fatalf("batch lucky mic gift must check once and call one activity batch draw: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls)
}
if user := onlineUserByID(resp.GetRoom(), firstTargetID); user == nil || user.GetGiftValue() != 60 {
t.Fatalf("first target online gift_value mismatch: %+v", user)
}
if user := onlineUserByID(resp.GetRoom(), secondTargetID); user == nil || user.GetGiftValue() != 40 {
t.Fatalf("second target online gift_value mismatch: %+v", user)
}
if seat := seatByNo(resp.GetRoom(), 2); seat == nil || seat.GetUserId() != firstTargetID || seat.GetGiftValue() != 60 {
t.Fatalf("first target mic seat gift_value mismatch: %+v", seat)
}
if seat := seatByNo(resp.GetRoom(), 3); seat == nil || seat.GetUserId() != secondTargetID || seat.GetGiftValue() != 40 {
t.Fatalf("second target mic seat gift_value mismatch: %+v", seat)
}
if resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 || len(resp.GetLuckyGifts()) != 2 {
t.Fatalf("batch lucky mic response must keep batch display and per-target lucky results: %+v", resp)
}
if first := resp.GetBatchDisplay().GetTargets()[0]; first.GetTargetUserId() != firstTargetID || first.GetTargetGiftValue() != 60 || first.GetLuckyGift() == nil {
t.Fatalf("first batch display target mismatch: %+v", first)
}
if second := resp.GetBatchDisplay().GetTargets()[1]; second.GetTargetUserId() != secondTargetID || second.GetTargetGiftValue() != 40 || second.GetLuckyGift() == nil {
t.Fatalf("second batch display target mismatch: %+v", second)
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
batchEvents := roomDirectIMGiftBatchSentEvents(t, directIM)
if len(batchEvents) != 1 || len(batchEvents[0].GetTargets()) != 2 {
t.Fatalf("batch lucky mic gift must publish one batch IM with two targets: %+v", batchEvents)
}
if first := batchEvents[0].GetTargets()[0]; first.GetTargetUserId() != firstTargetID || first.GetTargetGiftValue() != 60 || first.GetLuckyGift() == nil {
t.Fatalf("first batch IM target mismatch: %+v", first)
}
if second := batchEvents[0].GetTargets()[1]; second.GetTargetUserId() != secondTargetID || second.GetTargetGiftValue() != 40 || second.GetLuckyGift() == nil {
t.Fatalf("second batch IM target mismatch: %+v", second)
}
}
func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
w.grants = append(w.grants, req)
return &walletv1.ResourceGrantResponse{
@ -284,6 +608,141 @@ func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.G
}, nil
}
func (w *rocketTestWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
w.balanceRequests = append(w.balanceRequests, req)
if len(w.balanceResponses) == 0 {
return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{
{AssetType: "COIN"},
}}, nil
}
next := w.balanceResponses[0]
w.balanceResponses = w.balanceResponses[1:]
return proto.Clone(next).(*walletv1.GetBalancesResponse), nil
}
type batchLuckyGiftTestClient struct {
mu sync.Mutex
checks []*activityv1.CheckLuckyGiftRequest
draws []*activityv1.ExecuteLuckyGiftDrawRequest
batchDraws []*activityv1.BatchExecuteLuckyGiftDrawRequest
inFlight int
maxInFlight int
drawDelay time.Duration
createdAtBase int64
}
func (c *batchLuckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
c.mu.Lock()
c.checks = append(c.checks, req)
c.mu.Unlock()
return &activityv1.CheckLuckyGiftResponse{
Enabled: true,
Reason: "enabled",
GiftId: req.GetGiftId(),
PoolId: req.GetPoolId(),
RuleVersion: 12,
ExperiencePool: "novice",
}, nil
}
func (c *batchLuckyGiftTestClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
c.mu.Lock()
c.draws = append(c.draws, req)
drawIndex := len(c.draws)
c.inFlight++
if c.inFlight > c.maxInFlight {
c.maxInFlight = c.inFlight
}
c.mu.Unlock()
defer func() {
c.mu.Lock()
c.inFlight--
c.mu.Unlock()
}()
if c.drawDelay > 0 {
timer := time.NewTimer(c.drawDelay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
meta := req.GetLuckyGift()
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: &activityv1.LuckyGiftDrawResult{
DrawId: fmt.Sprintf("draw-%d", meta.GetTargetUserId()),
CommandId: meta.GetCommandId(),
PoolId: meta.GetPoolId(),
GiftId: meta.GetGiftId(),
RuleVersion: 12,
ExperiencePool: "novice",
SelectedTierId: "tier",
MultiplierPpm: 2_000_000,
BaseRewardCoins: 100,
EffectiveRewardCoins: 100,
RewardStatus: "granted",
WalletTransactionId: fmt.Sprintf("wallet-tx-%d", drawIndex),
CoinBalanceAfter: int64(9000 + drawIndex),
CreatedAtMs: c.createdAtBase + int64(drawIndex),
}}, nil
}
func (c *batchLuckyGiftTestClient) BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
c.mu.Lock()
c.batchDraws = append(c.batchDraws, req)
drawOffset := len(c.draws)
c.inFlight++
if c.inFlight > c.maxInFlight {
c.maxInFlight = c.inFlight
}
c.mu.Unlock()
defer func() {
c.mu.Lock()
c.inFlight--
c.mu.Unlock()
}()
if c.drawDelay > 0 {
timer := time.NewTimer(c.drawDelay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
resp := &activityv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*activityv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts()))}
for index, meta := range req.GetLuckyGifts() {
drawIndex := drawOffset + index + 1
resp.Results = append(resp.Results, &activityv1.LuckyGiftDrawResult{
DrawId: fmt.Sprintf("draw-%d", meta.GetTargetUserId()),
CommandId: meta.GetCommandId(),
PoolId: meta.GetPoolId(),
GiftId: meta.GetGiftId(),
RuleVersion: 12,
ExperiencePool: "novice",
SelectedTierId: "tier",
MultiplierPpm: 2_000_000,
BaseRewardCoins: 100,
EffectiveRewardCoins: 100,
RewardStatus: "pending",
CreatedAtMs: c.createdAtBase + int64(drawIndex),
})
}
return resp, nil
}
func (c *batchLuckyGiftTestClient) stats() (checks int, singleDraws int, batchDraws int, batchCalls int) {
c.mu.Lock()
defer c.mu.Unlock()
totalBatchDraws := 0
for _, req := range c.batchDraws {
totalBatchDraws += len(req.GetLuckyGifts())
}
return len(c.checks), len(c.draws), totalBatchDraws, len(c.batchDraws)
}
type rocketLaunchScheduleRecorder struct {
schedules []integration.RoomRocketLaunchSchedule
}
@ -1062,6 +1521,25 @@ func roomDirectIMRocketProgressEvents(t *testing.T, publisher *recordingRoomDire
return events
}
func roomDirectIMGiftBatchSentEvents(t *testing.T, publisher *recordingRoomDirectIMPublisher) []*roomeventsv1.RoomGiftBatchSent {
t.Helper()
publisher.mu.Lock()
records := append([]*roomeventsv1.EventEnvelope(nil), publisher.records...)
publisher.mu.Unlock()
events := make([]*roomeventsv1.RoomGiftBatchSent, 0)
for _, record := range records {
if record.GetEventType() != "RoomGiftBatchSent" {
continue
}
var event roomeventsv1.RoomGiftBatchSent
if err := proto.Unmarshal(record.GetBody(), &event); err != nil {
t.Fatalf("decode directIM batch gift failed: %v", err)
}
events = append(events, &event)
}
return events
}
func roomGiftSentEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomGiftSent {
t.Helper()

View File

@ -166,6 +166,10 @@ type mutationResult struct {
luckyGift *roomv1.LuckyGiftDrawResult
// luckyGifts 是多目标幸运礼物的逐接收方抽奖结果;单目标时只包含 luckyGift。
luckyGifts []*roomv1.LuckyGiftDrawResult
// batchDisplay 是 batch-send 的发送方本地展示快照;它不替代逐目标 RoomGiftSent 事实。
batchDisplay *roomv1.SendGiftBatchDisplay
// suppressDirectIMEventTypes 只影响提交后的低延迟展示 lane不能改变 durable outbox 事实。
suppressDirectIMEventTypes map[string]bool
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log用于恢复。
commandPayload []byte
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态保留旧字段兼容命令结果room UI 现在由提交后 direct IM 发送。

View File

@ -3,6 +3,7 @@ package grpc
import (
"context"
"strings"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
@ -391,6 +392,10 @@ func (s *Server) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*ro
}); forwarded {
return resp, err
}
if strings.EqualFold(strings.TrimSpace(req.GetDisplayMode()), "batch") {
// protobuf 仍保持一个 RPC领域层显式分派 batch 入口,避免普通 SendGift 被 display_mode 偷偷改变展示/IM 策略。
return mapServiceResult(s.svc.SendGiftBatch(ctx, req))
}
return mapServiceResult(s.svc.SendGift(ctx, req))
}