增加批量送礼
This commit is contained in:
parent
a1e038442d
commit
c47e4e6832
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||||
|
|||||||
@ -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
@ -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 表示这条逐目标事实只给统计、activity、CP 和礼物墙消费;房间展示由同命令的 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
@ -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 在公屏前同步问房间业务态。
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 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 outbox,worker 后续负责 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()))
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 份单抽金额。
|
||||||
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
|
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
|
||||||
|
|||||||
@ -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 暴露后台幸运礼物规则配置和抽奖审计入口。
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 直接桥接能力;房间命令主链路不再调用它。
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
198
services/room-service/internal/room/service/gift_batch.go
Normal file
198
services/room-service/internal/room/service/gift_batch.go
Normal 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
|
||||||
|
}
|
||||||
154
services/room-service/internal/room/service/gift_billing.go
Normal file
154
services/room-service/internal/room/service/gift_billing.go
Normal 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
|
||||||
|
}
|
||||||
76
services/room-service/internal/room/service/gift_events.go
Normal file
76
services/room-service/internal/room/service/gift_events.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
250
services/room-service/internal/room/service/gift_events_test.go
Normal file
250
services/room-service/internal/room/service/gift_events_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
448
services/room-service/internal/room/service/gift_flow.go
Normal file
448
services/room-service/internal/room/service/gift_flow.go
Normal 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 已结算的 HeatValue,room-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
|
||||||
|
}
|
||||||
274
services/room-service/internal/room/service/gift_lucky.go
Normal file
274
services/room-service/internal/room/service/gift_lucky.go
Normal 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
|
||||||
|
}
|
||||||
117
services/room-service/internal/room/service/gift_profile.go
Normal file
117
services/room-service/internal/room/service/gift_profile.go
Normal 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}
|
||||||
|
}
|
||||||
85
services/room-service/internal/room/service/gift_robot.go
Normal file
85
services/room-service/internal/room/service/gift_robot.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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{}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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 发送。
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user