增加批量送礼

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

View File

@ -1788,8 +1788,9 @@ var AchievementService_ServiceDesc = grpc.ServiceDesc{
} }
const ( const (
LuckyGiftService_CheckLuckyGift_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/CheckLuckyGift" LuckyGiftService_CheckLuckyGift_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/CheckLuckyGift"
LuckyGiftService_ExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/ExecuteLuckyGiftDraw" LuckyGiftService_ExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/ExecuteLuckyGiftDraw"
LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/BatchExecuteLuckyGiftDraw"
) )
// LuckyGiftServiceClient is the client API for LuckyGiftService service. // LuckyGiftServiceClient is the client API for LuckyGiftService service.
@ -1800,6 +1801,7 @@ const (
type LuckyGiftServiceClient interface { type LuckyGiftServiceClient interface {
CheckLuckyGift(ctx context.Context, in *CheckLuckyGiftRequest, opts ...grpc.CallOption) (*CheckLuckyGiftResponse, error) CheckLuckyGift(ctx context.Context, in *CheckLuckyGiftRequest, opts ...grpc.CallOption) (*CheckLuckyGiftResponse, error)
ExecuteLuckyGiftDraw(ctx context.Context, in *ExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*ExecuteLuckyGiftDrawResponse, 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 { type luckyGiftServiceClient struct {
@ -1830,6 +1832,16 @@ func (c *luckyGiftServiceClient) ExecuteLuckyGiftDraw(ctx context.Context, in *E
return out, nil 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. // LuckyGiftServiceServer is the server API for LuckyGiftService service.
// All implementations must embed UnimplementedLuckyGiftServiceServer // All implementations must embed UnimplementedLuckyGiftServiceServer
// for forward compatibility. // for forward compatibility.
@ -1838,6 +1850,7 @@ func (c *luckyGiftServiceClient) ExecuteLuckyGiftDraw(ctx context.Context, in *E
type LuckyGiftServiceServer interface { type LuckyGiftServiceServer interface {
CheckLuckyGift(context.Context, *CheckLuckyGiftRequest) (*CheckLuckyGiftResponse, error) CheckLuckyGift(context.Context, *CheckLuckyGiftRequest) (*CheckLuckyGiftResponse, error)
ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error) ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error)
BatchExecuteLuckyGiftDraw(context.Context, *BatchExecuteLuckyGiftDrawRequest) (*BatchExecuteLuckyGiftDrawResponse, error)
mustEmbedUnimplementedLuckyGiftServiceServer() mustEmbedUnimplementedLuckyGiftServiceServer()
} }
@ -1854,6 +1867,9 @@ func (UnimplementedLuckyGiftServiceServer) CheckLuckyGift(context.Context, *Chec
func (UnimplementedLuckyGiftServiceServer) ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error) { func (UnimplementedLuckyGiftServiceServer) ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ExecuteLuckyGiftDraw not implemented") 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) mustEmbedUnimplementedLuckyGiftServiceServer() {}
func (UnimplementedLuckyGiftServiceServer) testEmbeddedByValue() {} func (UnimplementedLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -1911,6 +1927,24 @@ func _LuckyGiftService_ExecuteLuckyGiftDraw_Handler(srv interface{}, ctx context
return interceptor(ctx, in, info, handler) 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. // LuckyGiftService_ServiceDesc is the grpc.ServiceDesc for LuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -1926,6 +1960,10 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ExecuteLuckyGiftDraw", MethodName: "ExecuteLuckyGiftDraw",
Handler: _LuckyGiftService_ExecuteLuckyGiftDraw_Handler, Handler: _LuckyGiftService_ExecuteLuckyGiftDraw_Handler,
}, },
{
MethodName: "BatchExecuteLuckyGiftDraw",
Handler: _LuckyGiftService_BatchExecuteLuckyGiftDraw_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto", 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; int64 region_id = 6;
// is_robot // is_robot
bool is_robot = 7; 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 // RoomUserLeft
@ -184,6 +189,74 @@ message RoomGiftSent {
string receiver_avatar = 28; string receiver_avatar = 28;
string receiver_display_user_id = 29; string receiver_display_user_id = 29;
string receiver_pretty_display_user_id = 30; 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 // RoomRobotLuckyGiftDrawn

File diff suppressed because it is too large Load Diff

View File

@ -635,6 +635,15 @@ message RoomEntryVehicleSnapshot {
int64 expires_at_ms = 9; 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 { message JoinRoomRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string role = 2; string role = 2;
@ -645,6 +654,8 @@ message JoinRoomRequest {
int64 actor_region_id = 6; int64 actor_region_id = 6;
// actor_is_robot room-service gateway false // actor_is_robot room-service gateway false
bool actor_is_robot = 7; bool actor_is_robot = 7;
// actor_display_profile room-service IM/outbox RoomState
RoomUserDisplayProfile actor_display_profile = 8;
} }
// JoinRoomResponse // JoinRoomResponse
@ -1022,6 +1033,49 @@ message SendGiftDisplayProfile {
string pretty_display_user_id = 5; 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 // SendGiftRequest
message SendGiftRequest { message SendGiftRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
@ -1053,6 +1107,8 @@ message SendGiftRequest {
SendGiftDisplayProfile sender_display_profile = 16; SendGiftDisplayProfile sender_display_profile = 16;
// target_display_profiles IM target_user_id // target_display_profiles IM target_user_id
repeated SendGiftDisplayProfile target_display_profiles = 17; repeated SendGiftDisplayProfile target_display_profiles = 17;
// display_mode=batch batch-send HTTP IM send
string display_mode = 18;
} }
// SendGiftResponse // SendGiftResponse
@ -1071,6 +1127,8 @@ message SendGiftResponse {
int64 coin_balance_after = 9; int64 coin_balance_after = 9;
// gift_income_balance_after COIN 0 // gift_income_balance_after COIN 0
int64 gift_income_balance_after = 10; int64 gift_income_balance_after = 10;
// batch_display batch-send send
SendGiftBatchDisplay batch_display = 11;
} }
// CheckSpeakPermissionRequest IM gateway // CheckSpeakPermissionRequest IM gateway

View File

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

View File

@ -2,6 +2,8 @@ package luckygift
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
@ -28,6 +30,7 @@ type Repository interface {
ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error) ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error)
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, 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) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, 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 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 { type WalletClient interface {
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error) 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 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 补偿。 // RegionBroadcaster 只写 activity-service 自有的区域播报 outbox真正发腾讯 IM 仍由 broadcast worker 补偿。
type RegionBroadcaster interface { type RegionBroadcaster interface {
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error) PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
@ -64,6 +72,7 @@ type Service struct {
repository Repository repository Repository
wallet WalletClient wallet WalletClient
publisher RoomPublisher publisher RoomPublisher
userPub UserPublisher
broadcaster RegionBroadcaster broadcaster RegionBroadcaster
now func() time.Time 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 { func WithRegionBroadcaster(broadcaster RegionBroadcaster) Option {
return func(s *Service) { return func(s *Service) {
s.broadcaster = broadcaster 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 { if err := s.requireRepository(); err != nil {
return domain.DrawResult{}, err 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 只接受扣费成功后的服务端事实;客户端不能提交价格、倍率、奖励或风控绕过字段。 // Draw 只接受扣费成功后的服务端事实;客户端不能提交价格、倍率、奖励或风控绕过字段。
cmd.CommandID = strings.TrimSpace(cmd.CommandID) cmd.CommandID = strings.TrimSpace(cmd.CommandID)
cmd.PoolID = normalizePoolID(cmd.PoolID) 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.RoomID = strings.TrimSpace(cmd.RoomID)
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID) cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 { 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 { 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 { if cmd.PaidAtMS <= 0 {
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。 // paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。
cmd.PaidAtMS = s.now().UTC().UnixMilli() cmd.PaidAtMS = s.now().UTC().UnixMilli()
} }
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli()) return cmd, nil
if err != nil {
return domain.DrawResult{}, err
}
return s.creditLuckyGiftRewardFastPath(ctx, cmd, result), nil
} }
type WorkerOptions struct { 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 { 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 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()) 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 := "" walletTransactionID := ""
credited := false credited := false
alreadyGranted := false alreadyGranted := false
receipt := luckyGiftRewardReceipt{}
if payload.EffectiveRewardCoins > 0 { if payload.EffectiveRewardCoins > 0 {
state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs) state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
if err != nil { 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 alreadyGranted = state.AllGranted
if alreadyGranted { if alreadyGranted {
walletTransactionID = state.WalletTransactionID walletTransactionID = state.WalletTransactionID
credited = true credited = true
} else { } else {
receipt, err := s.creditLuckyGiftReward(ctx, event.AppCode, payload) var err error
receipt, err = s.creditLuckyGiftReward(ctx, event.AppCode, payload)
if err != nil { 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 walletTransactionID = receipt.WalletTransactionID
credited = true credited = true
@ -292,21 +368,23 @@ func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOu
} }
if !credited { if !credited {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil { 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 !alreadyGranted {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil { 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 { type luckyGiftRewardReceipt struct {
WalletTransactionID string WalletTransactionID string
CoinBalanceAfter int64 CoinBalanceAfter int64
CoinFrozenAfter int64
BalanceVersion int64
} }
func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult { 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.RewardStatus = domain.StatusGranted
result.WalletTransactionID = receipt.WalletTransactionID result.WalletTransactionID = receipt.WalletTransactionID
result.CoinBalanceAfter = receipt.CoinBalanceAfter
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID); err != nil { 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, logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
slog.String("draw_id", result.DrawID), slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID), slog.String("command_id", result.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID), 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 return result
} }
@ -404,16 +490,90 @@ func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, pay
return luckyGiftRewardReceipt{ return luckyGiftRewardReceipt{
WalletTransactionID: resp.GetTransactionId(), WalletTransactionID: resp.GetTransactionId(),
CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(), CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(),
CoinFrozenAfter: resp.GetBalance().GetFrozenAmount(),
BalanceVersion: resp.GetBalance().GetVersion(),
}, nil }, 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 { 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)) body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
if err != nil { if err != nil {
return err return err
} }
// 房间 IM 只是表现层事实同步,不参与钱包结算和 reward_status 收敛。 // 房间 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() defer cancel()
return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{ return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
GroupID: payload.RoomID, 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 { func (s *Service) publishLuckyGiftRegionBroadcast(ctx context.Context, payload luckyGiftDrawnPayload) error {
if payload.VisibleRegionID <= 0 { if s.broadcaster == nil || payload.VisibleRegionID <= 0 {
return nil return nil
} }
body, err := json.Marshal(luckyGiftRegionBroadcastPayload(payload, s.now().UTC().UnixMilli())) body, err := json.Marshal(luckyGiftRegionBroadcastPayload(payload, s.now().UTC().UnixMilli()))

View File

@ -64,7 +64,8 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
CreatedAtMS: 1760000000000, CreatedAtMS: 1760000000000,
}} }}
wallet := &fakeLuckyGiftWallet{balanceAfter: 1300} 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() }) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{ 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 { 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) 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) t.Fatalf("fast path result mismatch: %+v", result)
} }
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_fast_1,lucky_draw_fast_2" { 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) 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) { 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{ payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn", "event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_test", "event_id": "lucky_gift_drawn:lucky_draw_test",
@ -160,8 +200,9 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
} }
wallet := &fakeLuckyGiftWallet{} wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{} publisher := &fakeLuckyGiftPublisher{}
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{} 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() }) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) 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 { 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) t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
} }
if publisher.last.EventID != "" || broadcaster.last.EventID != "" { assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_test", "lucky_draw_test", 300)
t.Fatalf("settlement worker must not publish presentation: publisher=%+v broadcaster=%+v", publisher.last, broadcaster.last) 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" { 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) t.Fatalf("repository settlement mismatch: granted=%s delivered=%s", repository.grantedDrawID, repository.deliveredOutboxID)
} }
@ -214,8 +257,9 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
} }
wallet := &fakeLuckyGiftWallet{} wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{} publisher := &fakeLuckyGiftPublisher{}
userPublisher := &fakeLuckyGiftUserPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{} 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() }) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) 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" { 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) 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{ payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn", "event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_settle", "event_id": "lucky_gift_drawn:lucky_draw_settle",
@ -261,7 +310,9 @@ func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
} }
wallet := &fakeLuckyGiftWallet{} wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{} 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"}) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil { if err != nil {
@ -273,9 +324,11 @@ func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" { 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) t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID)
} }
if publisher.last.EventID != "" { assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 300)
t.Fatalf("settlement outbox must not publish presentation: %+v", publisher.last) 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) { 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{ payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn", "event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_im_fail", "event_id": "lucky_gift_drawn:lucky_draw_im_fail",
@ -357,7 +410,8 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
} }
wallet := &fakeLuckyGiftWallet{} wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{err: errors.New("im unavailable")} 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() }) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) 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 != "" { 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) 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) { func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) {
@ -403,7 +461,8 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
} }
wallet := &fakeLuckyGiftWallet{} wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{} 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"}) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil { if err != nil {
@ -418,18 +477,21 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
if repository.grantedDrawID != "" { if repository.grantedDrawID != "" {
t.Fatalf("already granted draw should not be marked again, got %q", 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" { if userPublisher.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) 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 { type fakeLuckyGiftRepository struct {
getConfigCalls int getConfigCalls int
upserted domain.RuleConfig upserted domain.RuleConfig
executeResult domain.DrawResult executeResult domain.DrawResult
executeCmd domain.DrawCommand executeCmd domain.DrawCommand
rewardState domain.DrawRewardState executeBatchResults []domain.DrawResult
outbox []domain.DrawOutbox executeBatchCmds []domain.DrawCommand
rewardState domain.DrawRewardState
outbox []domain.DrawOutbox
grantedDrawID string grantedDrawID string
grantedDrawIDs []string grantedDrawIDs []string
@ -460,6 +522,20 @@ func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(_ context.Context, cmd do
return r.executeResult, nil 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) { func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
return nil, 0, nil return nil, 0, nil
} }
@ -547,6 +623,16 @@ func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, me
return p.err 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 { type fakeLuckyGiftRegionBroadcaster struct {
last broadcastservice.PublishInput last broadcastservice.PublishInput
} }
@ -555,3 +641,40 @@ func (b *fakeLuckyGiftRegionBroadcaster) PublishRegionBroadcast(_ context.Contex
b.last = input b.last = input
return broadcastdomain.PublishResult{EventID: input.EventID, Status: broadcastdomain.StatusPending, Created: true}, nil 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 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) { func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
// gift_count 是抽奖次数coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。 // 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) { func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
meta := req.GetLuckyGift() meta := req.GetLuckyGift()
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode()) 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(), CommandID: meta.GetCommandId(),
PoolID: meta.GetPoolId(), PoolID: meta.GetPoolId(),
UserID: meta.GetUserId(), UserID: meta.GetUserId(),
@ -61,11 +89,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
PaidAtMS: meta.GetPaidAtMs(), PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(), VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(), CountryID: meta.GetCountryId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
} }
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: luckyDrawResultToProto(result)}, nil
} }
// AdminLuckyGiftServer 暴露后台幸运礼物规则配置和抽奖审计入口。 // AdminLuckyGiftServer 暴露后台幸运礼物规则配置和抽奖审计入口。

View File

@ -179,6 +179,7 @@ type RoomHandlers struct {
KickUser http.HandlerFunc KickUser http.HandlerFunc
UnbanUser http.HandlerFunc UnbanUser http.HandlerFunc
SendGift http.HandlerFunc SendGift http.HandlerFunc
SendGiftBatch http.HandlerFunc
} }
type MessageHandlers struct { type MessageHandlers struct {
@ -520,6 +521,7 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms/user/kick", http.MethodPost, h.KickUser) r.profile("/rooms/user/kick", http.MethodPost, h.KickUser)
r.profile("/rooms/user/unban", http.MethodPost, h.UnbanUser) r.profile("/rooms/user/unban", http.MethodPost, h.UnbanUser)
r.profile("/rooms/gift/send", http.MethodPost, h.SendGift) r.profile("/rooms/gift/send", http.MethodPost, h.SendGift)
r.profile("/rooms/gift/batch-send", http.MethodPost, h.SendGiftBatch)
} }
func (r routes) registerMessageRoutes() { 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}, Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
BillingReceiptId: "receipt-lucky", BillingReceiptId: "receipt-lucky",
RoomHeat: 100, RoomHeat: 100,
CoinBalanceAfter: 8800, CoinBalanceAfter: 8200,
LuckyGift: &roomv1.LuckyGiftDrawResult{ LuckyGift: &roomv1.LuckyGiftDrawResult{
Enabled: true, Enabled: true,
DrawId: "lucky_draw_test", DrawId: "lucky_draw_test",
@ -2610,7 +2610,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
MultiplierPpm: 5_000_000, MultiplierPpm: 5_000_000,
EffectiveRewardCoins: 500, EffectiveRewardCoins: 500,
RewardStatus: "granted", RewardStatus: "granted",
CoinBalanceAfter: 8800, CoinBalanceAfter: 0,
CreatedAtMs: 1_779_258_000_000, CreatedAtMs: 1_779_258_000_000,
}, },
LuckyGifts: []*roomv1.LuckyGiftDrawResult{ LuckyGifts: []*roomv1.LuckyGiftDrawResult{
@ -2651,13 +2651,13 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String()) 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) 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 { 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) 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) 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) { func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
roomClient := &fakeRoomClient{} roomClient := &fakeRoomClient{}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
@ -2998,6 +3059,10 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
if roomClient.lastJoin == nil || roomClient.lastJoin.GetPassword() != "1234" { if roomClient.lastJoin == nil || roomClient.lastJoin.GetPassword() != "1234" {
t.Fatalf("join must forward room password to room-service: %+v", roomClient.lastJoin) 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) seats := data["seats"].([]any)
if len(seats) != 2 || seats[0].(map[string]any)["user_id"] != "102" { if len(seats) != 2 || seats[0].(map[string]any)["user_id"] != "102" {
t.Fatalf("join response must include first-screen seats: %+v", seats) 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, KickUser: h.kickUser,
UnbanUser: h.unbanUser, UnbanUser: h.unbanUser,
SendGift: h.sendGift, 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) { if !httpkit.Decode(writer, request, &body) {
return return
} }
actorCountryID, actorRegionID, err := h.resolveRoomActorCountryRegion(request) actorSnapshot, err := h.resolveRoomActorJoinSnapshot(request)
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
return return
} }
resp, err := h.roomClient.JoinRoom(request.Context(), &roomv1.JoinRoomRequest{ resp, err := h.roomClient.JoinRoom(request.Context(), &roomv1.JoinRoomRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
Role: body.Role, Role: body.Role,
Password: body.Password, Password: body.Password,
ActorCountryId: actorCountryID, ActorCountryId: actorSnapshot.countryID,
ActorRegionId: actorRegionID, ActorRegionId: actorSnapshot.regionID,
ActorDisplayProfile: actorSnapshot.displayProfile,
// 座驾快照在进房命令提交前生成room-service 只保存快照,不回查 wallet。 // 座驾快照在进房命令提交前生成room-service 只保存快照,不回查 wallet。
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request), EntryVehicle: h.joinRoomEntryVehicleSnapshot(request),
}) })
@ -1556,6 +1557,16 @@ func (h *Handler) unbanUser(writer http.ResponseWriter, request *http.Request) {
// sendGift 把送礼 HTTP JSON 请求转换为 room-service SendGift 命令。 // sendGift 把送礼 HTTP JSON 请求转换为 room-service SendGift 命令。
// 扣费、幂等、房间表现和 outbox 仍由 room-service 的命令链路统一处理。 // 扣费、幂等、房间表现和 outbox 仍由 room-service 的命令链路统一处理。
func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { 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 { var body struct {
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
CommandID string `json:"command_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), Source: strings.TrimSpace(body.Source),
SenderDisplayProfile: userSnapshots.senderDisplayProfile, SenderDisplayProfile: userSnapshots.senderDisplayProfile,
TargetDisplayProfiles: userSnapshots.targetDisplayProfiles, TargetDisplayProfiles: userSnapshots.targetDisplayProfiles,
DisplayMode: strings.TrimSpace(displayMode),
}) })
httpkit.Write(writer, request, resp, err) 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 { 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{ resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""), Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()),
}) })
if err != nil { if err != nil {
return 0, 0, err return roomActorJoinSnapshot{}, err
} }
if resp.GetUser() == nil { 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 { 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 只表达房间列表归属。 // room-service 事件必须携带用户真实国家/区域和展示快照;房间 visible_region_id 只表达房间列表归属。
return resp.GetUser().GetCountryId(), resp.GetUser().GetRegionId(), nil return roomActorJoinSnapshot{
countryID: resp.GetUser().GetCountryId(),
regionID: resp.GetUser().GetRegionId(),
displayProfile: roomUserDisplayProfileFromUser(resp.GetUser()),
}, nil
} }
type giftUserSnapshots struct { 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 { func firstGiftTargetHostScope(scopes []*roomv1.SendGiftTargetHostScope, targetUserID int64) *roomv1.SendGiftTargetHostScope {
for _, scope := range scopes { for _, scope := range scopes {
if scope.GetTargetUserId() == targetUserID { if scope.GetTargetUserId() == targetUserID {

View File

@ -28,6 +28,8 @@ type LuckyGiftClient interface {
CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error)
// ExecuteLuckyGiftDraw 在钱包扣费成功后按 command_id 幂等落抽奖事实。 // ExecuteLuckyGiftDraw 在钱包扣费成功后按 command_id 幂等落抽奖事实。
ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error)
// BatchExecuteLuckyGiftDraw 一次提交多人送礼的全部目标抽奖事实,返回顺序必须和请求目标顺序一致。
BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error)
} }
// RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。 // 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) { func (c *grpcLuckyGiftClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
return c.client.ExecuteLuckyGiftDraw(ctx, req) 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" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
@ -152,7 +153,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
base.TargetUserID = body.GetUserId() base.TargetUserID = body.GetUserId()
// RoomUserJoined 的座驾必须从 outbox body 取快照,不能在补偿投递时按当前佩戴重算。 // RoomUserJoined 的座驾必须从 outbox body 取快照,不能在补偿投递时按当前佩戴重算。
base.EntryVehicle = roomEntryVehicleFromEvent(body.GetEntryVehicle()) base.EntryVehicle = roomEntryVehicleFromEvent(body.GetEntryVehicle())
base.Attributes = map[string]string{"role": body.GetRole()} base.Attributes = roomUserJoinedAttributesFromEvent(&body)
return base, true, nil return base, true, nil
case "RoomUserLeft": case "RoomUserLeft":
// 离房事件只表达业务 presence 移除,不声明腾讯云 IM 连接是否已断开。 // 离房事件只表达业务 presence 移除,不声明腾讯云 IM 连接是否已断开。
@ -293,6 +294,10 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err 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.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId() base.TargetUserID = body.GetTargetUserId()
base.GiftValue = body.GetGiftValue() base.GiftValue = body.GetGiftValue()
@ -323,6 +328,42 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(), "receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(),
} }
return base, true, nil 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": case "RoomHeatChanged":
// 机器人通道也会投递热度展示事件,客户端可独立刷新房间热度,不依赖主 outbox。 // 机器人通道也会投递热度展示事件,客户端可独立刷新房间热度,不依赖主 outbox。
var body roomeventsv1.RoomHeatChanged 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 { func eventTypeForClient(eventType string) string {
// protobuf 事件名使用 PascalCase客户端 TIMCustomElem 使用 snake_case 作为稳定协议字段。 // protobuf 事件名使用 PascalCase客户端 TIMCustomElem 使用 snake_case 作为稳定协议字段。
switch eventType { switch eventType {
@ -506,6 +576,8 @@ func eventTypeForClient(eventType string) string {
return "room_user_unbanned" return "room_user_unbanned"
case "RoomGiftSent": case "RoomGiftSent":
return "room_gift_sent" return "room_gift_sent"
case "RoomGiftBatchSent":
return "room_gift_batch_sent"
case "RoomHeatChanged": case "RoomHeatChanged":
return "room_heat_changed" return "room_heat_changed"
case "RoomRankChanged": case "RoomRankChanged":

View File

@ -35,9 +35,13 @@ func TestRoomEventFromEnvelopeSkipsNonIMEvents(t *testing.T) {
func TestRoomUserJoinedCarriesEntryVehicleSnapshot(t *testing.T) { func TestRoomUserJoinedCarriesEntryVehicleSnapshot(t *testing.T) {
record, err := outbox.Build("room-vehicle", "RoomUserJoined", 9, time.Now(), &roomeventsv1.RoomUserJoined{ record, err := outbox.Build("room-vehicle", "RoomUserJoined", 9, time.Now(), &roomeventsv1.RoomUserJoined{
UserId: 42, UserId: 42,
Role: "audience", Role: "audience",
VisibleRegionId: 86, VisibleRegionId: 86,
Nickname: "Join Nina",
Avatar: "https://cdn.example.com/nina.png",
DisplayUserId: "100042",
PrettyDisplayUserId: "888042",
EntryVehicle: &roomeventsv1.RoomEntryVehicleSnapshot{ EntryVehicle: &roomeventsv1.RoomEntryVehicleSnapshot{
ResourceId: 7001, ResourceId: 7001,
ResourceCode: "vehicle_gold", 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" { 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) 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) { 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) { func TestRoomHeatAndRankChangedPublishDisplayIMEvents(t *testing.T) {
heatRecord, err := outbox.Build("room-robot-heat", "RoomHeatChanged", 9, time.Now(), &roomeventsv1.RoomHeatChanged{ heatRecord, err := outbox.Build("room-robot-heat", "RoomHeatChanged", 9, time.Now(), &roomeventsv1.RoomHeatChanged{
Delta: 120, Delta: 120,

View File

@ -135,6 +135,8 @@ type JoinRoom struct {
ActorIsRobot bool `json:"actor_is_robot,omitempty"` ActorIsRobot bool `json:"actor_is_robot,omitempty"`
// EntryVehicle 是 gateway 在进房时解析出的当前有效佩戴座驾快照,用于进房 IM。 // EntryVehicle 是 gateway 在进房时解析出的当前有效佩戴座驾快照,用于进房 IM。
EntryVehicle EntryVehicleSnapshot `json:"entry_vehicle,omitempty"` EntryVehicle EntryVehicleSnapshot `json:"entry_vehicle,omitempty"`
// ActorDisplayProfile 是 gateway 在进房入口固化的展示快照;只服务 IM/UI 兜底,不参与 presence 权限和统计。
ActorDisplayProfile UserDisplayProfile `json:"actor_display_profile,omitempty"`
} }
// Type 返回命令类型。 // Type 返回命令类型。
@ -422,6 +424,8 @@ type SendGift struct {
EntitlementID string `json:"entitlement_id,omitempty"` EntitlementID string `json:"entitlement_id,omitempty"`
// Source 区分 coin/bag为空按普通金币链路兼容旧客户端。 // Source 区分 coin/bag为空按普通金币链路兼容旧客户端。
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
// DisplayMode 只控制客户端展示事件形态batch 表示保留逐目标事实,但房间 IM 合并为单条多人展示消息。
DisplayMode string `json:"display_mode,omitempty"`
// SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。 // SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。
SenderRegionID int64 `json:"sender_region_id,omitempty"` SenderRegionID int64 `json:"sender_region_id,omitempty"`
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。 // SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
@ -509,6 +513,21 @@ type GiftTargetHostScope struct {
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"` 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 是送礼入口固化的用户展示快照。 // GiftDisplayProfile 是送礼入口固化的用户展示快照。
// 它只服务 IM 和历史事件展示兜底,不参与权限、账务、统计或 Room Cell 核心状态判定。 // 它只服务 IM 和历史事件展示兜底,不参与权限、账务、统计或 Room Cell 核心状态判定。
type GiftDisplayProfile struct { type GiftDisplayProfile struct {

View File

@ -12,7 +12,7 @@ import (
const roomDirectIMPublishTimeout = 2 * time.Second 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 { if len(records) == 0 {
return nil, nil return nil, nil
} }
@ -24,7 +24,7 @@ func splitRoomDirectIMRecords(records []outbox.Record) ([]outbox.Record, []outbo
continue continue
} }
durable = append(durable, record) durable = append(durable, record)
if roomDirectIMMirrorEventType(record.EventType) { if roomDirectIMMirrorEventType(record.EventType) && !suppressMirror[record.EventType] {
// 这类事件同时服务 UI 和业务事实消费MySQL outbox 保证统计、任务、通知等可补偿,直接 IM 只给客户端低延迟展示。 // 这类事件同时服务 UI 和业务事实消费MySQL outbox 保证统计、任务、通知等可补偿,直接 IM 只给客户端低延迟展示。
directIM = append(directIM, record) directIM = append(directIM, record)
} }
@ -36,6 +36,7 @@ func roomDirectIMOnlyEventType(eventType string) bool {
switch eventType { switch eventType {
case "RoomHeatChanged", case "RoomHeatChanged",
"RoomRankChanged", "RoomRankChanged",
"RoomGiftBatchSent",
"RoomRocketFuelChanged", "RoomRocketFuelChanged",
"RoomRocketLaunched": "RoomRocketLaunched":
return true 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 { type luckyGiftTestClient struct {
checks []*activityv1.CheckLuckyGiftRequest checks []*activityv1.CheckLuckyGiftRequest
draws []*activityv1.ExecuteLuckyGiftDrawRequest draws []*activityv1.ExecuteLuckyGiftDrawRequest
batchDraws []*activityv1.BatchExecuteLuckyGiftDrawRequest
drawResults []*activityv1.LuckyGiftDrawResult drawResults []*activityv1.LuckyGiftDrawResult
} }
@ -72,6 +73,46 @@ func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activ
}}, nil }}, 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) { func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
@ -82,32 +123,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
HeatValue: 100, HeatValue: 100,
GiftTypeCode: "super_lucky", GiftTypeCode: "super_lucky",
}}} }}}
luckyGift := &luckyGiftTestClient{drawResults: []*activityv1.LuckyGiftDrawResult{ luckyGift := &luckyGiftTestClient{}
{
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,
},
}}
svc := roomservice.New(roomservice.Config{ svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-test", NodeID: "node-lucky-test",
LeaseTTL: 10 * time.Second, LeaseTTL: 10 * time.Second,
@ -140,7 +156,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("send lucky gift failed: %v", err) 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()) t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
} }
if len(resp.GetLuckyGifts()) != 1 || resp.GetLuckyGifts()[0].GetTargetUserId() != viewerID { 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-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"}, {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{ svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-multi-test", NodeID: "node-lucky-multi-test",
LeaseTTL: 10 * time.Second, LeaseTTL: 10 * time.Second,
@ -207,11 +226,14 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" { if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" {
t.Fatalf("multi-target lucky gift should check pool once: %+v", luckyGift.checks) t.Fatalf("multi-target lucky gift should check pool once: %+v", luckyGift.checks)
} }
if len(luckyGift.draws) != 2 { if len(luckyGift.draws) != 0 || len(luckyGift.batchDraws) != 1 {
t.Fatalf("multi-target lucky gift should draw once per target, got %d", len(luckyGift.draws)) 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() if len(luckyGift.batchDraws[0].GetLuckyGifts()) != 2 {
secondDraw := luckyGift.draws[1].GetLuckyGift() 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 { if firstDraw.GetCommandId() != "cmd-lucky-multi:target:202" || firstDraw.GetTargetUserId() != firstTargetID || firstDraw.GetCoinSpent() != 100 {
t.Fatalf("first target lucky draw mismatch: %+v", firstDraw) 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().GetMultiplierPpm() != 5_000_000 ||
resp.GetLuckyGift().GetEffectiveRewardCoins() != 500 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 500 ||
resp.GetLuckyGift().GetWalletTransactionId() != "" || resp.GetLuckyGift().GetWalletTransactionId() != "" ||
resp.GetLuckyGift().GetCoinBalanceAfter() != 9100 || resp.GetLuckyGift().GetCoinBalanceAfter() != 0 ||
!resp.GetLuckyGift().GetStageFeedback() || !resp.GetLuckyGift().GetStageFeedback() ||
!resp.GetLuckyGift().GetHighMultiplier() || !resp.GetLuckyGift().GetHighMultiplier() ||
len(resp.GetLuckyGifts()) != 2 { len(resp.GetLuckyGifts()) != 2 {

View File

@ -79,7 +79,10 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
} }
// 机器人展示只是 App 视觉补偿,提交前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。 // 机器人展示只是 App 视觉补偿,提交前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
robotDisplayRecords := s.sampleRobotDisplayRecords(result.robotDisplayRecords) 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() saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{ if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{ Command: CommandRecord{

View File

@ -25,11 +25,12 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
password := strings.TrimSpace(req.GetPassword()) password := strings.TrimSpace(req.GetPassword())
// JoinRoom 只维护 room-service presence不创建任何本地长连接订阅。 // JoinRoom 只维护 room-service presence不创建任何本地长连接订阅。
cmd := command.JoinRoom{ cmd := command.JoinRoom{
Base: baseFromMeta(req.GetMeta()), Base: baseFromMeta(req.GetMeta()),
Role: req.GetRole(), Role: req.GetRole(),
ActorCountryID: req.GetActorCountryId(), ActorCountryID: req.GetActorCountryId(),
ActorRegionID: req.GetActorRegionId(), ActorRegionID: req.GetActorRegionId(),
ActorIsRobot: req.GetActorIsRobot(), ActorIsRobot: req.GetActorIsRobot(),
ActorDisplayProfile: userDisplayProfileFromProto(req.GetActorDisplayProfile()),
// entry_vehicle 来自 gateway 入房瞬间的 wallet 快照,写入 command log 后可稳定重放。 // entry_vehicle 来自 gateway 入房瞬间的 wallet 快照,写入 command log 后可稳定重放。
EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()), 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 { if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
// 重复 JoinRoom 表示重连或刷新业务 presence只更新 last_seen不重复发进房系统消息。 // 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords不进入 durable outbox
// 避免统计服务把同一个在线用户重复计为进房活跃。
existing.LastSeenAtMS = now.UnixMilli() existing.LastSeenAtMS = now.UnixMilli()
current.Version++ 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() snapshot := current.ToProto()
return mutationResult{ return mutationResult{
snapshot: snapshot, snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()), user: findProtoUser(snapshot, cmd.ActorUserID()),
directIMRecords: []outbox.Record{joinedDisplayEvent},
}, nil, nil }, nil, nil
} }
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{ current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
@ -77,17 +85,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
current.Version++ current.Version++
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。 // JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, &roomeventsv1.RoomUserJoined{ joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, cmd.Role, current.VisibleRegionID))
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),
})
if err != nil { if err != nil {
return mutationResult{}, nil, err return mutationResult{}, nil, err
} }
@ -106,6 +104,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
RoomVersion: current.Version, RoomVersion: current.Version,
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。 // syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle), EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
Attributes: roomUserJoinedIMAttributes(cmd.Role, cmd.ActorDisplayProfile),
}, },
}, []outbox.Record{joinedEvent}, nil }, []outbox.Record{joinedEvent}, nil
}) })
@ -121,6 +120,55 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
}, nil }, 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 { func entryVehicleSnapshotFromProto(item *roomv1.RoomEntryVehicleSnapshot) command.EntryVehicleSnapshot {
if item == nil || item.GetResourceId() <= 0 { if item == nil || item.GetResourceId() <= 0 {
return command.EntryVehicleSnapshot{} return command.EntryVehicleSnapshot{}

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
activityv1 "hyapp.local/api/proto/activity/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
@ -30,11 +31,13 @@ func (c *fixedRoomRocketClock) Now() time.Time {
} }
type rocketTestWallet struct { type rocketTestWallet struct {
debits []*walletv1.DebitGiftResponse debits []*walletv1.DebitGiftResponse
grants []*walletv1.GrantResourceGroupRequest grants []*walletv1.GrantResourceGroupRequest
lastDebit *walletv1.DebitGiftRequest lastDebit *walletv1.DebitGiftRequest
debitRequests []*walletv1.DebitGiftRequest debitRequests []*walletv1.DebitGiftRequest
lastBatch *walletv1.BatchDebitGiftRequest lastBatch *walletv1.BatchDebitGiftRequest
balanceResponses []*walletv1.GetBalancesResponse
balanceRequests []*walletv1.GetBalancesRequest
} }
type recordingRoomDirectIMPublisher struct { type recordingRoomDirectIMPublisher struct {
@ -68,6 +71,86 @@ func (p *recordingRoomDirectIMPublisher) counts() map[string]int {
return counts 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) { func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
w.lastDebit = req w.lastDebit = req
w.debitRequests = append(w.debitRequests, 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) { func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
w.grants = append(w.grants, req) w.grants = append(w.grants, req)
return &walletv1.ResourceGrantResponse{ return &walletv1.ResourceGrantResponse{
@ -284,6 +608,141 @@ func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.G
}, nil }, 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 { type rocketLaunchScheduleRecorder struct {
schedules []integration.RoomRocketLaunchSchedule schedules []integration.RoomRocketLaunchSchedule
} }
@ -1062,6 +1521,25 @@ func roomDirectIMRocketProgressEvents(t *testing.T, publisher *recordingRoomDire
return events 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 { func roomGiftSentEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomGiftSent {
t.Helper() t.Helper()

View File

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

View File

@ -3,6 +3,7 @@ package grpc
import ( import (
"context" "context"
"strings"
"time" "time"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
@ -391,6 +392,10 @@ func (s *Server) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*ro
}); forwarded { }); forwarded {
return resp, err 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)) return mapServiceResult(s.svc.SendGift(ctx, req))
} }