增加批量送礼
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;
|
||||
}
|
||||
|
||||
message BatchExecuteLuckyGiftDrawRequest {
|
||||
repeated LuckyGiftMeta lucky_gifts = 1;
|
||||
}
|
||||
|
||||
message BatchExecuteLuckyGiftDrawResponse {
|
||||
repeated LuckyGiftDrawResult results = 1;
|
||||
}
|
||||
|
||||
message GetLuckyGiftConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
string gift_id = 2;
|
||||
@ -2585,6 +2593,7 @@ service AchievementService {
|
||||
service LuckyGiftService {
|
||||
rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
|
||||
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
|
||||
rpc BatchExecuteLuckyGiftDraw(BatchExecuteLuckyGiftDrawRequest) returns (BatchExecuteLuckyGiftDrawResponse);
|
||||
}
|
||||
|
||||
// WheelService owns wheel draw decisions after wallet debit succeeds.
|
||||
|
||||
@ -1788,8 +1788,9 @@ var AchievementService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
LuckyGiftService_CheckLuckyGift_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/CheckLuckyGift"
|
||||
LuckyGiftService_ExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/ExecuteLuckyGiftDraw"
|
||||
LuckyGiftService_CheckLuckyGift_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/CheckLuckyGift"
|
||||
LuckyGiftService_ExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/ExecuteLuckyGiftDraw"
|
||||
LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName = "/hyapp.activity.v1.LuckyGiftService/BatchExecuteLuckyGiftDraw"
|
||||
)
|
||||
|
||||
// LuckyGiftServiceClient is the client API for LuckyGiftService service.
|
||||
@ -1800,6 +1801,7 @@ const (
|
||||
type LuckyGiftServiceClient interface {
|
||||
CheckLuckyGift(ctx context.Context, in *CheckLuckyGiftRequest, opts ...grpc.CallOption) (*CheckLuckyGiftResponse, error)
|
||||
ExecuteLuckyGiftDraw(ctx context.Context, in *ExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*ExecuteLuckyGiftDrawResponse, error)
|
||||
BatchExecuteLuckyGiftDraw(ctx context.Context, in *BatchExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*BatchExecuteLuckyGiftDrawResponse, error)
|
||||
}
|
||||
|
||||
type luckyGiftServiceClient struct {
|
||||
@ -1830,6 +1832,16 @@ func (c *luckyGiftServiceClient) ExecuteLuckyGiftDraw(ctx context.Context, in *E
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *luckyGiftServiceClient) BatchExecuteLuckyGiftDraw(ctx context.Context, in *BatchExecuteLuckyGiftDrawRequest, opts ...grpc.CallOption) (*BatchExecuteLuckyGiftDrawResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchExecuteLuckyGiftDrawResponse)
|
||||
err := c.cc.Invoke(ctx, LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LuckyGiftServiceServer is the server API for LuckyGiftService service.
|
||||
// All implementations must embed UnimplementedLuckyGiftServiceServer
|
||||
// for forward compatibility.
|
||||
@ -1838,6 +1850,7 @@ func (c *luckyGiftServiceClient) ExecuteLuckyGiftDraw(ctx context.Context, in *E
|
||||
type LuckyGiftServiceServer interface {
|
||||
CheckLuckyGift(context.Context, *CheckLuckyGiftRequest) (*CheckLuckyGiftResponse, error)
|
||||
ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error)
|
||||
BatchExecuteLuckyGiftDraw(context.Context, *BatchExecuteLuckyGiftDrawRequest) (*BatchExecuteLuckyGiftDrawResponse, error)
|
||||
mustEmbedUnimplementedLuckyGiftServiceServer()
|
||||
}
|
||||
|
||||
@ -1854,6 +1867,9 @@ func (UnimplementedLuckyGiftServiceServer) CheckLuckyGift(context.Context, *Chec
|
||||
func (UnimplementedLuckyGiftServiceServer) ExecuteLuckyGiftDraw(context.Context, *ExecuteLuckyGiftDrawRequest) (*ExecuteLuckyGiftDrawResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ExecuteLuckyGiftDraw not implemented")
|
||||
}
|
||||
func (UnimplementedLuckyGiftServiceServer) BatchExecuteLuckyGiftDraw(context.Context, *BatchExecuteLuckyGiftDrawRequest) (*BatchExecuteLuckyGiftDrawResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchExecuteLuckyGiftDraw not implemented")
|
||||
}
|
||||
func (UnimplementedLuckyGiftServiceServer) mustEmbedUnimplementedLuckyGiftServiceServer() {}
|
||||
func (UnimplementedLuckyGiftServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -1911,6 +1927,24 @@ func _LuckyGiftService_ExecuteLuckyGiftDraw_Handler(srv interface{}, ctx context
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _LuckyGiftService_BatchExecuteLuckyGiftDraw_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchExecuteLuckyGiftDrawRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(LuckyGiftServiceServer).BatchExecuteLuckyGiftDraw(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: LuckyGiftService_BatchExecuteLuckyGiftDraw_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(LuckyGiftServiceServer).BatchExecuteLuckyGiftDraw(ctx, req.(*BatchExecuteLuckyGiftDrawRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// LuckyGiftService_ServiceDesc is the grpc.ServiceDesc for LuckyGiftService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -1926,6 +1960,10 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ExecuteLuckyGiftDraw",
|
||||
Handler: _LuckyGiftService_ExecuteLuckyGiftDraw_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchExecuteLuckyGiftDraw",
|
||||
Handler: _LuckyGiftService_BatchExecuteLuckyGiftDraw_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -67,6 +67,11 @@ message RoomUserJoined {
|
||||
int64 region_id = 6;
|
||||
// is_robot 标记本次进房来自房间机器人调度,统计服务必须跳过活跃用户口径。
|
||||
bool is_robot = 7;
|
||||
// nickname/avatar/display_user_id 是进房瞬间的展示资料快照,IM 补偿投递不能再临时反查 user-service。
|
||||
string nickname = 8;
|
||||
string avatar = 9;
|
||||
string display_user_id = 10;
|
||||
string pretty_display_user_id = 11;
|
||||
}
|
||||
|
||||
// RoomUserLeft 表达用户离房成功。
|
||||
@ -184,6 +189,74 @@ message RoomGiftSent {
|
||||
string receiver_avatar = 28;
|
||||
string receiver_display_user_id = 29;
|
||||
string receiver_pretty_display_user_id = 30;
|
||||
// display_mode=batch 表示这条逐目标事实只给统计、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 是机器人房间专用幸运礼物展示事件。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -635,6 +635,15 @@ message RoomEntryVehicleSnapshot {
|
||||
int64 expires_at_ms = 9;
|
||||
}
|
||||
|
||||
// RoomUserDisplayProfile 是 gateway 在入口固化的用户展示快照,只服务 IM/UI 兜底。
|
||||
message RoomUserDisplayProfile {
|
||||
int64 user_id = 1;
|
||||
string nickname = 2;
|
||||
string avatar = 3;
|
||||
string display_user_id = 4;
|
||||
string pretty_display_user_id = 5;
|
||||
}
|
||||
|
||||
message JoinRoomRequest {
|
||||
RequestMeta meta = 1;
|
||||
string role = 2;
|
||||
@ -645,6 +654,8 @@ message JoinRoomRequest {
|
||||
int64 actor_region_id = 6;
|
||||
// actor_is_robot 只由 room-service 内部机器人调度链路设置,外部 gateway 进房保持 false。
|
||||
bool actor_is_robot = 7;
|
||||
// actor_display_profile 是进房用户展示资料快照,room-service 只透传到进房 IM/outbox,不写入 RoomState。
|
||||
RoomUserDisplayProfile actor_display_profile = 8;
|
||||
}
|
||||
|
||||
// JoinRoomResponse 返回加入后的房间快照。
|
||||
@ -1022,6 +1033,49 @@ message SendGiftDisplayProfile {
|
||||
string pretty_display_user_id = 5;
|
||||
}
|
||||
|
||||
// SendGiftBatchTarget 是批量送礼展示响应中单个目标的结算和展示快照。
|
||||
// 它只服务发送方本地即时展示,不替代逐目标 RoomGiftSent 业务事实。
|
||||
message SendGiftBatchTarget {
|
||||
int64 target_user_id = 1;
|
||||
string receiver_nickname = 2;
|
||||
string receiver_avatar = 3;
|
||||
string receiver_display_user_id = 4;
|
||||
string receiver_pretty_display_user_id = 5;
|
||||
int64 gift_value = 6;
|
||||
int64 target_gift_value = 7;
|
||||
int64 coin_spent = 8;
|
||||
string billing_receipt_id = 9;
|
||||
string command_id = 10;
|
||||
LuckyGiftDrawResult lucky_gift = 11;
|
||||
}
|
||||
|
||||
// SendGiftBatchDisplay 是新批量送礼接口返回给发送方的本地展示协议。
|
||||
// 房间广播使用同构的 RoomGiftBatchSent IM 事件;coin_balance_after 不进入该展示对象。
|
||||
message SendGiftBatchDisplay {
|
||||
string event_id = 1;
|
||||
int64 sender_user_id = 2;
|
||||
string sender_name = 3;
|
||||
string sender_avatar = 4;
|
||||
string sender_display_user_id = 5;
|
||||
string sender_pretty_display_user_id = 6;
|
||||
string gift_id = 7;
|
||||
int32 gift_count = 8;
|
||||
int64 total_gift_value = 9;
|
||||
int64 total_coin_spent = 10;
|
||||
string billing_receipt_id = 11;
|
||||
int64 room_heat = 12;
|
||||
string pool_id = 13;
|
||||
string gift_type_code = 14;
|
||||
string cp_relation_type = 15;
|
||||
string gift_name = 16;
|
||||
string gift_icon_url = 17;
|
||||
string gift_animation_url = 18;
|
||||
repeated string gift_effect_types = 19;
|
||||
repeated int64 target_user_ids = 20;
|
||||
repeated SendGiftBatchTarget targets = 21;
|
||||
string command_id = 22;
|
||||
}
|
||||
|
||||
// SendGiftRequest 是首版房间内最重要的变现命令。
|
||||
message SendGiftRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -1053,6 +1107,8 @@ message SendGiftRequest {
|
||||
SendGiftDisplayProfile sender_display_profile = 16;
|
||||
// target_display_profiles 是每个收礼目标的展示快照;多目标 IM 按 target_user_id 取对应项。
|
||||
repeated SendGiftDisplayProfile target_display_profiles = 17;
|
||||
// display_mode=batch 只由新 batch-send HTTP 入口设置,用来生成单条多人展示 IM;旧 send 入口保持空值。
|
||||
string display_mode = 18;
|
||||
}
|
||||
|
||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||
@ -1071,6 +1127,8 @@ message SendGiftResponse {
|
||||
int64 coin_balance_after = 9;
|
||||
// gift_income_balance_after 是收礼人返币后的 COIN 余额;多目标或无返币时为 0。
|
||||
int64 gift_income_balance_after = 10;
|
||||
// batch_display 是新 batch-send 接口的多人展示快照;旧 send 入口保持空值。
|
||||
SendGiftBatchDisplay batch_display = 11;
|
||||
}
|
||||
|
||||
// CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。
|
||||
|
||||
@ -97,11 +97,17 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
|
||||
}, repository, broadcastPublisher, activityclient.NewGRPCRegionSource(clients.userConn))
|
||||
broadcastSvc.SetSenderProfileSource(activityclient.NewGRPCUserProfileSource(clients.userConn))
|
||||
|
||||
luckyGiftSvc := luckygiftservice.New(repository,
|
||||
luckyGiftOptions := []luckygiftservice.Option{
|
||||
luckygiftservice.WithWallet(walletClient),
|
||||
luckygiftservice.WithRoomPublisher(tencentClient),
|
||||
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
|
||||
)
|
||||
}
|
||||
if tencentClient != nil {
|
||||
luckyGiftOptions = append(luckyGiftOptions,
|
||||
luckygiftservice.WithRoomPublisher(tencentClient),
|
||||
luckygiftservice.WithUserPublisher(tencentClient),
|
||||
)
|
||||
}
|
||||
luckyGiftSvc := luckygiftservice.New(repository, luckyGiftOptions...)
|
||||
wheelSvc := wheelservice.New(repository, wheelservice.WithWallet(walletClient))
|
||||
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletClient)
|
||||
cumulativeRechargeSvc := cumulativerechargeservice.New(repository, walletClient)
|
||||
|
||||
@ -2,6 +2,8 @@ package luckygift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@ -28,6 +30,7 @@ type Repository interface {
|
||||
ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error)
|
||||
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
|
||||
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
|
||||
ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error)
|
||||
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
|
||||
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
|
||||
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
|
||||
@ -44,7 +47,7 @@ type Repository interface {
|
||||
MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
|
||||
}
|
||||
|
||||
// WalletClient 是幸运礼物返奖唯一账务依赖;实际余额变更通知继续由 wallet outbox -> notice-service 投递。
|
||||
// WalletClient 是幸运礼物返奖唯一账务依赖;wallet-service 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 IM。
|
||||
type WalletClient interface {
|
||||
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
|
||||
}
|
||||
@ -54,6 +57,11 @@ type RoomPublisher interface {
|
||||
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
|
||||
}
|
||||
|
||||
// UserPublisher 负责把钱包余额变化私有消息发给中奖用户;失败不能回滚已经成功的钱包入账。
|
||||
type UserPublisher interface {
|
||||
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
|
||||
}
|
||||
|
||||
// RegionBroadcaster 只写 activity-service 自有的区域播报 outbox;真正发腾讯 IM 仍由 broadcast worker 补偿。
|
||||
type RegionBroadcaster interface {
|
||||
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
|
||||
@ -64,6 +72,7 @@ type Service struct {
|
||||
repository Repository
|
||||
wallet WalletClient
|
||||
publisher RoomPublisher
|
||||
userPub UserPublisher
|
||||
broadcaster RegionBroadcaster
|
||||
now func() time.Time
|
||||
}
|
||||
@ -82,6 +91,12 @@ func WithRoomPublisher(publisher RoomPublisher) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithUserPublisher(publisher UserPublisher) Option {
|
||||
return func(s *Service) {
|
||||
s.userPub = publisher
|
||||
}
|
||||
}
|
||||
|
||||
func WithRegionBroadcaster(broadcaster RegionBroadcaster) Option {
|
||||
return func(s *Service) {
|
||||
s.broadcaster = broadcaster
|
||||
@ -122,6 +137,37 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
normalized, err := s.normalizeDrawCommand(cmd)
|
||||
if err != nil {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, normalized, s.now().UTC().UnixMilli())
|
||||
if err != nil {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
return s.creditLuckyGiftRewardFastPath(ctx, normalized, result), nil
|
||||
}
|
||||
|
||||
func (s *Service) DrawBatch(ctx context.Context, cmds []domain.DrawCommand) ([]domain.DrawResult, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
|
||||
}
|
||||
normalized := make([]domain.DrawCommand, 0, len(cmds))
|
||||
for _, cmd := range cmds {
|
||||
item, err := s.normalizeDrawCommand(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
// 批量送礼的返奖不能阻塞 room-service 主链路;repository 只落 draw fact 和 settlement 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 只接受扣费成功后的服务端事实;客户端不能提交价格、倍率、奖励或风控绕过字段。
|
||||
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
|
||||
cmd.PoolID = normalizePoolID(cmd.PoolID)
|
||||
@ -130,20 +176,16 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
|
||||
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
|
||||
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
|
||||
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
|
||||
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
|
||||
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
|
||||
}
|
||||
if cmd.TargetUserID < 0 || cmd.GiftCount <= 0 {
|
||||
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
|
||||
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
|
||||
}
|
||||
if cmd.PaidAtMS <= 0 {
|
||||
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。
|
||||
cmd.PaidAtMS = s.now().UTC().UnixMilli()
|
||||
}
|
||||
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli())
|
||||
if err != nil {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
return s.creditLuckyGiftRewardFastPath(ctx, cmd, result), nil
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
type WorkerOptions struct {
|
||||
@ -262,29 +304,63 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
|
||||
}
|
||||
|
||||
func (s *Service) processRewardSettlementOutbox(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) error {
|
||||
if _, _, err := s.settleLuckyGiftReward(ctx, event, payload, options); err != nil {
|
||||
credited, alreadyGranted, receipt, err := s.settleLuckyGiftReward(ctx, event, payload, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if credited && !alreadyGranted {
|
||||
// 钱包入账和 activity draw 状态都已经落库;这里的 C2C 只是降低客户端余额刷新延迟,失败时仍由 wallet outbox -> notice-service 补偿。
|
||||
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, options.PublishTimeout); err != nil {
|
||||
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
|
||||
slog.String("draw_id", payload.DrawID),
|
||||
slog.String("command_id", payload.CommandID),
|
||||
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
|
||||
)
|
||||
}
|
||||
}
|
||||
if credited && payload.EffectiveRewardCoins > 0 {
|
||||
// 房间中奖 IM 必须在钱包返奖落库之后发;发送方 HTTP 本地表现不能替代房间内其他用户的实时展示。
|
||||
// 这里即使 draw 已经 granted 也会补发一次:fast path 可能先完成账务,随后 outbox worker 才负责可靠补房间展示。
|
||||
if err := s.publishLuckyGiftDrawn(ctx, payload, options); err != nil {
|
||||
logx.Error(ctx, "lucky_gift_room_drawn_notice_failed", err,
|
||||
slog.String("draw_id", payload.DrawID),
|
||||
slog.String("command_id", payload.CommandID),
|
||||
slog.String("event_id", payload.EventID),
|
||||
)
|
||||
}
|
||||
if shouldPublishLuckyGiftRegionBroadcast(payload) {
|
||||
// 区域播报和房间 IM 都是表现层副作用,失败不回滚已经成功的返奖账务。
|
||||
if err := s.publishLuckyGiftRegionBroadcast(ctx, payload); err != nil {
|
||||
logx.Error(ctx, "lucky_gift_region_broadcast_failed", err,
|
||||
slog.String("draw_id", payload.DrawID),
|
||||
slog.String("command_id", payload.CommandID),
|
||||
slog.Int64("visible_region_id", payload.VisibleRegionID),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.repository.MarkLuckyGiftOutboxDelivered(ctx, event, s.now().UTC().UnixMilli())
|
||||
}
|
||||
|
||||
func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, error) {
|
||||
func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, luckyGiftRewardReceipt, error) {
|
||||
walletTransactionID := ""
|
||||
credited := false
|
||||
alreadyGranted := false
|
||||
receipt := luckyGiftRewardReceipt{}
|
||||
if payload.EffectiveRewardCoins > 0 {
|
||||
state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
|
||||
if err != nil {
|
||||
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
|
||||
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
|
||||
}
|
||||
alreadyGranted = state.AllGranted
|
||||
if alreadyGranted {
|
||||
walletTransactionID = state.WalletTransactionID
|
||||
credited = true
|
||||
} else {
|
||||
receipt, err := s.creditLuckyGiftReward(ctx, event.AppCode, payload)
|
||||
var err error
|
||||
receipt, err = s.creditLuckyGiftReward(ctx, event.AppCode, payload)
|
||||
if err != nil {
|
||||
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
|
||||
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
|
||||
}
|
||||
walletTransactionID = receipt.WalletTransactionID
|
||||
credited = true
|
||||
@ -292,21 +368,23 @@ func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOu
|
||||
}
|
||||
if !credited {
|
||||
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
|
||||
return false, false, err
|
||||
return false, false, receipt, err
|
||||
}
|
||||
return false, false, nil
|
||||
return false, false, receipt, nil
|
||||
}
|
||||
if !alreadyGranted {
|
||||
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
|
||||
return credited, alreadyGranted, err
|
||||
return credited, alreadyGranted, receipt, err
|
||||
}
|
||||
}
|
||||
return credited, alreadyGranted, nil
|
||||
return credited, alreadyGranted, receipt, nil
|
||||
}
|
||||
|
||||
type luckyGiftRewardReceipt struct {
|
||||
WalletTransactionID string
|
||||
CoinBalanceAfter int64
|
||||
CoinFrozenAfter int64
|
||||
BalanceVersion int64
|
||||
}
|
||||
|
||||
func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
|
||||
@ -351,13 +429,21 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
|
||||
}
|
||||
result.RewardStatus = domain.StatusGranted
|
||||
result.WalletTransactionID = receipt.WalletTransactionID
|
||||
result.CoinBalanceAfter = receipt.CoinBalanceAfter
|
||||
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID); err != nil {
|
||||
logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
|
||||
slog.String("draw_id", result.DrawID),
|
||||
slog.String("command_id", result.CommandID),
|
||||
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
|
||||
)
|
||||
return result
|
||||
}
|
||||
// HTTP/gRPC 抽奖响应不再返回返奖后余额;成功落库后的余额变化通过钱包私有 IM 发送,Flutter 用 balance_after 覆盖本地余额。
|
||||
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, 3*time.Second); err != nil {
|
||||
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
|
||||
slog.String("draw_id", result.DrawID),
|
||||
slog.String("command_id", result.CommandID),
|
||||
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -404,16 +490,90 @@ func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, pay
|
||||
return luckyGiftRewardReceipt{
|
||||
WalletTransactionID: resp.GetTransactionId(),
|
||||
CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(),
|
||||
CoinFrozenAfter: resp.GetBalance().GetFrozenAmount(),
|
||||
BalanceVersion: resp.GetBalance().GetVersion(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) publishLuckyGiftWalletBalanceChanged(ctx context.Context, payload luckyGiftDrawnPayload, receipt luckyGiftRewardReceipt, timeout time.Duration) error {
|
||||
if s.userPub == nil || payload.EffectiveRewardCoins <= 0 {
|
||||
return nil
|
||||
}
|
||||
receipt.WalletTransactionID = strings.TrimSpace(receipt.WalletTransactionID)
|
||||
if receipt.WalletTransactionID == "" {
|
||||
return fmt.Errorf("lucky gift wallet transaction id is empty")
|
||||
}
|
||||
nowMs := s.now().UTC().UnixMilli()
|
||||
commandID := "lucky_reward:" + payload.DrawID
|
||||
noticePayload, err := json.Marshal(map[string]any{
|
||||
"event_type": "WalletBalanceChanged",
|
||||
"event_id": walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
|
||||
"app_code": payload.AppCode,
|
||||
"transaction_id": receipt.WalletTransactionID,
|
||||
"command_id": commandID,
|
||||
"user_id": fmt.Sprintf("%d", payload.UserID),
|
||||
"asset_type": "COIN",
|
||||
"available_delta": payload.EffectiveRewardCoins,
|
||||
"frozen_delta": 0,
|
||||
"available_after": receipt.CoinBalanceAfter,
|
||||
"frozen_after": receipt.CoinFrozenAfter,
|
||||
"balance_version": receipt.BalanceVersion,
|
||||
"created_at_ms": nowMs,
|
||||
"source_created_at_ms": nowMs,
|
||||
"metadata": map[string]any{
|
||||
"transaction_id": receipt.WalletTransactionID,
|
||||
"command_id": commandID,
|
||||
"user_id": payload.UserID,
|
||||
"draw_id": payload.DrawID,
|
||||
"room_id": payload.RoomID,
|
||||
"visible_region_id": payload.VisibleRegionID,
|
||||
"region_id": payload.VisibleRegionID,
|
||||
"country_id": payload.CountryID,
|
||||
"gift_id": payload.GiftID,
|
||||
"pool_id": payload.PoolID,
|
||||
"amount": payload.EffectiveRewardCoins,
|
||||
"reason": "lucky_gift_reward",
|
||||
"created_at_ms": nowMs,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publishTimeout := timeout
|
||||
if publishTimeout <= 0 {
|
||||
publishTimeout = 5 * time.Second
|
||||
}
|
||||
publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
|
||||
defer cancel()
|
||||
return s.userPub.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
|
||||
ToAccount: tencentim.FormatUserID(payload.UserID),
|
||||
EventID: walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
|
||||
Desc: "WalletBalanceChanged",
|
||||
Ext: "wallet_notice",
|
||||
PayloadJSON: noticePayload,
|
||||
})
|
||||
}
|
||||
|
||||
func walletBalanceChangedEventID(transactionID string, userID int64, assetType string) string {
|
||||
hashInput := fmt.Sprintf("%s|%s|%d|%s", strings.TrimSpace(transactionID), "WalletBalanceChanged", userID, strings.TrimSpace(assetType))
|
||||
sum := sha256.Sum256([]byte(hashInput))
|
||||
return "wev_" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
|
||||
if s.publisher == nil || payload.EffectiveRewardCoins <= 0 {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 房间 IM 只是表现层事实同步,不参与钱包结算和 reward_status 收敛。
|
||||
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
|
||||
publishTimeout := options.PublishTimeout
|
||||
if publishTimeout <= 0 {
|
||||
publishTimeout = 5 * time.Second
|
||||
}
|
||||
publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
|
||||
defer cancel()
|
||||
return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
|
||||
GroupID: payload.RoomID,
|
||||
@ -425,7 +585,7 @@ func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDr
|
||||
}
|
||||
|
||||
func (s *Service) publishLuckyGiftRegionBroadcast(ctx context.Context, payload luckyGiftDrawnPayload) error {
|
||||
if payload.VisibleRegionID <= 0 {
|
||||
if s.broadcaster == nil || payload.VisibleRegionID <= 0 {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(luckyGiftRegionBroadcastPayload(payload, s.now().UTC().UnixMilli()))
|
||||
|
||||
@ -64,7 +64,8 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
|
||||
CreatedAtMS: 1760000000000,
|
||||
}}
|
||||
wallet := &fakeLuckyGiftWallet{balanceAfter: 1300}
|
||||
service := New(repository, WithWallet(wallet))
|
||||
userPublisher := &fakeLuckyGiftUserPublisher{}
|
||||
service := New(repository, WithWallet(wallet), WithUserPublisher(userPublisher))
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
|
||||
|
||||
result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{
|
||||
@ -87,12 +88,51 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
|
||||
if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 || wallet.last.GetCountryId() != 15 {
|
||||
t.Fatalf("wallet fast path request mismatch: %+v", wallet.last)
|
||||
}
|
||||
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 1300 {
|
||||
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 0 {
|
||||
t.Fatalf("fast path result mismatch: %+v", result)
|
||||
}
|
||||
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_fast_1,lucky_draw_fast_2" {
|
||||
t.Fatalf("fast path should mark all draw ids granted, got %s", got)
|
||||
}
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 1300)
|
||||
}
|
||||
|
||||
func TestDrawBatchLeavesRewardSettlementToWorker(t *testing.T) {
|
||||
repository := &fakeLuckyGiftRepository{executeBatchResults: []domain.DrawResult{{
|
||||
DrawID: "lucky_draw_batch_async_1",
|
||||
CommandID: "cmd-gift-batch:target:42",
|
||||
PoolID: "super_lucky",
|
||||
GiftID: "rose",
|
||||
EffectiveRewardCoins: 300,
|
||||
RewardStatus: domain.StatusPending,
|
||||
}}}
|
||||
wallet := &fakeLuckyGiftWallet{}
|
||||
service := New(repository, WithWallet(wallet))
|
||||
|
||||
results, err := service.DrawBatch(appcode.WithContext(context.Background(), "lalu"), []domain.DrawCommand{{
|
||||
CommandID: "cmd-gift-batch:target:42",
|
||||
PoolID: "super_lucky",
|
||||
UserID: 42,
|
||||
TargetUserID: 99,
|
||||
DeviceID: "device-1",
|
||||
RoomID: "room-1",
|
||||
AnchorID: "42",
|
||||
GiftID: "rose",
|
||||
GiftCount: 1,
|
||||
CoinSpent: 100,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("DrawBatch failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].DrawID != "lucky_draw_batch_async_1" {
|
||||
t.Fatalf("DrawBatch result mismatch: %+v", results)
|
||||
}
|
||||
if wallet.last != nil || repository.grantedDrawID != "" || len(repository.grantedDrawIDs) != 0 {
|
||||
t.Fatalf("batch draw must not run synchronous wallet fast path: wallet=%+v granted=%s granted_ids=%v", wallet.last, repository.grantedDrawID, repository.grantedDrawIDs)
|
||||
}
|
||||
if len(repository.executeBatchCmds) != 1 || repository.executeBatchCmds[0].CommandID != "cmd-gift-batch:target:42" {
|
||||
t.Fatalf("batch draw must pass normalized commands to repository: %+v", repository.executeBatchCmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
|
||||
@ -128,7 +168,7 @@ func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
|
||||
func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing.T) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"event_id": "lucky_gift_drawn:lucky_draw_test",
|
||||
@ -160,8 +200,9 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
|
||||
}
|
||||
wallet := &fakeLuckyGiftWallet{}
|
||||
publisher := &fakeLuckyGiftPublisher{}
|
||||
userPublisher := &fakeLuckyGiftUserPublisher{}
|
||||
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
|
||||
|
||||
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
|
||||
@ -174,9 +215,11 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
|
||||
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 || wallet.last.GetCountryId() != 15 {
|
||||
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
|
||||
}
|
||||
if publisher.last.EventID != "" || broadcaster.last.EventID != "" {
|
||||
t.Fatalf("settlement worker must not publish presentation: publisher=%+v broadcaster=%+v", publisher.last, broadcaster.last)
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_test", "lucky_draw_test", 300)
|
||||
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_test" || broadcaster.last.RegionID != 210 {
|
||||
t.Fatalf("big win region broadcast mismatch: %+v", broadcaster.last)
|
||||
}
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
|
||||
if repository.grantedDrawID != "lucky_draw_test" || repository.deliveredOutboxID != "lucky_lucky_draw_test" {
|
||||
t.Fatalf("repository settlement mismatch: granted=%s delivered=%s", repository.grantedDrawID, repository.deliveredOutboxID)
|
||||
}
|
||||
@ -214,8 +257,9 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
|
||||
}
|
||||
wallet := &fakeLuckyGiftWallet{}
|
||||
publisher := &fakeLuckyGiftPublisher{}
|
||||
userPublisher := &fakeLuckyGiftUserPublisher{}
|
||||
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
|
||||
|
||||
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
|
||||
@ -231,9 +275,14 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
|
||||
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
|
||||
t.Fatalf("all sub draws should be granted together, got %s", got)
|
||||
}
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 800, 0)
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 800)
|
||||
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_batch_1" || broadcaster.last.RegionID != 210 {
|
||||
t.Fatalf("batch big win region broadcast mismatch: %+v", broadcaster.last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
|
||||
func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(t *testing.T) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"event_id": "lucky_gift_drawn:lucky_draw_settle",
|
||||
@ -261,7 +310,9 @@ func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
|
||||
}
|
||||
wallet := &fakeLuckyGiftWallet{}
|
||||
publisher := &fakeLuckyGiftPublisher{}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
|
||||
userPublisher := &fakeLuckyGiftUserPublisher{}
|
||||
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
|
||||
|
||||
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
|
||||
if err != nil {
|
||||
@ -273,9 +324,11 @@ func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
|
||||
if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" {
|
||||
t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID)
|
||||
}
|
||||
if publisher.last.EventID != "" {
|
||||
t.Fatalf("settlement outbox must not publish presentation: %+v", publisher.last)
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 300)
|
||||
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_settle" || broadcaster.last.RegionID != 210 {
|
||||
t.Fatalf("settlement big win region broadcast mismatch: %+v", broadcaster.last)
|
||||
}
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
|
||||
}
|
||||
|
||||
func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
|
||||
@ -330,7 +383,7 @@ func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
|
||||
func TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(t *testing.T) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"event_id": "lucky_gift_drawn:lucky_draw_im_fail",
|
||||
@ -357,7 +410,8 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
|
||||
}
|
||||
wallet := &fakeLuckyGiftWallet{}
|
||||
publisher := &fakeLuckyGiftPublisher{err: errors.New("im unavailable")}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
|
||||
userPublisher := &fakeLuckyGiftUserPublisher{}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
|
||||
|
||||
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
|
||||
@ -373,6 +427,10 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
|
||||
if repository.deliveredOutboxID != "lucky_lucky_draw_im_fail" || repository.retryableOutboxID != "" {
|
||||
t.Fatalf("IM publisher must not affect settlement outbox: delivered=%s retryable=%s", repository.deliveredOutboxID, repository.retryableOutboxID)
|
||||
}
|
||||
if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_im_fail" {
|
||||
t.Fatalf("room IM should still be attempted before ignoring failure: %+v", publisher.last)
|
||||
}
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
|
||||
}
|
||||
|
||||
func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) {
|
||||
@ -403,7 +461,8 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
|
||||
}
|
||||
wallet := &fakeLuckyGiftWallet{}
|
||||
publisher := &fakeLuckyGiftPublisher{}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
|
||||
userPublisher := &fakeLuckyGiftUserPublisher{}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
|
||||
|
||||
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
|
||||
if err != nil {
|
||||
@ -418,18 +477,21 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
|
||||
if repository.grantedDrawID != "" {
|
||||
t.Fatalf("already granted draw should not be marked again, got %q", repository.grantedDrawID)
|
||||
}
|
||||
if publisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
|
||||
t.Fatalf("already granted draw should only deliver settlement outbox: publisher=%+v delivered=%s", publisher.last, repository.deliveredOutboxID)
|
||||
if userPublisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
|
||||
t.Fatalf("already granted draw should not resend wallet notice and must deliver outbox: user_publisher=%+v delivered=%s", userPublisher.last, repository.deliveredOutboxID)
|
||||
}
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 300)
|
||||
}
|
||||
|
||||
type fakeLuckyGiftRepository struct {
|
||||
getConfigCalls int
|
||||
upserted domain.RuleConfig
|
||||
executeResult domain.DrawResult
|
||||
executeCmd domain.DrawCommand
|
||||
rewardState domain.DrawRewardState
|
||||
outbox []domain.DrawOutbox
|
||||
getConfigCalls int
|
||||
upserted domain.RuleConfig
|
||||
executeResult domain.DrawResult
|
||||
executeCmd domain.DrawCommand
|
||||
executeBatchResults []domain.DrawResult
|
||||
executeBatchCmds []domain.DrawCommand
|
||||
rewardState domain.DrawRewardState
|
||||
outbox []domain.DrawOutbox
|
||||
|
||||
grantedDrawID string
|
||||
grantedDrawIDs []string
|
||||
@ -460,6 +522,20 @@ func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(_ context.Context, cmd do
|
||||
return r.executeResult, nil
|
||||
}
|
||||
|
||||
func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDrawBatch(_ context.Context, cmds []domain.DrawCommand, _ int64) ([]domain.DrawResult, error) {
|
||||
r.executeBatchCmds = append([]domain.DrawCommand(nil), cmds...)
|
||||
if len(r.executeBatchResults) > 0 {
|
||||
return append([]domain.DrawResult(nil), r.executeBatchResults...), nil
|
||||
}
|
||||
results := make([]domain.DrawResult, 0, len(cmds))
|
||||
for _, cmd := range cmds {
|
||||
result := r.executeResult
|
||||
result.CommandID = cmd.CommandID
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
@ -547,6 +623,16 @@ func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, me
|
||||
return p.err
|
||||
}
|
||||
|
||||
type fakeLuckyGiftUserPublisher struct {
|
||||
last tencentim.CustomUserMessage
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *fakeLuckyGiftUserPublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error {
|
||||
p.last = message
|
||||
return p.err
|
||||
}
|
||||
|
||||
type fakeLuckyGiftRegionBroadcaster struct {
|
||||
last broadcastservice.PublishInput
|
||||
}
|
||||
@ -555,3 +641,40 @@ func (b *fakeLuckyGiftRegionBroadcaster) PublishRegionBroadcast(_ context.Contex
|
||||
b.last = input
|
||||
return broadcastdomain.PublishResult{EventID: input.EventID, Status: broadcastdomain.StatusPending, Created: true}, nil
|
||||
}
|
||||
|
||||
func assertLuckyGiftWalletNotice(t *testing.T, message tencentim.CustomUserMessage, toAccount string, transactionID string, availableDelta int64, availableAfter int64) {
|
||||
t.Helper()
|
||||
if message.ToAccount != toAccount || message.Desc != "WalletBalanceChanged" || message.Ext != "wallet_notice" {
|
||||
t.Fatalf("wallet notice envelope mismatch: %+v", message)
|
||||
}
|
||||
if message.EventID != walletBalanceChangedEventID(transactionID, 42, "COIN") {
|
||||
t.Fatalf("wallet notice event id mismatch: %s", message.EventID)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
|
||||
t.Fatalf("wallet notice payload is invalid: %v", err)
|
||||
}
|
||||
if payload["event_type"] != "WalletBalanceChanged" || payload["transaction_id"] != transactionID || payload["user_id"] != "42" || payload["asset_type"] != "COIN" {
|
||||
t.Fatalf("wallet notice identity fields mismatch: %+v", payload)
|
||||
}
|
||||
if int64(payload["available_delta"].(float64)) != availableDelta || int64(payload["available_after"].(float64)) != availableAfter {
|
||||
t.Fatalf("wallet notice balance fields mismatch: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func assertLuckyGiftRoomMessage(t *testing.T, message tencentim.CustomGroupMessage, groupID string, eventID string, drawID string, rewardCoins int64) {
|
||||
t.Helper()
|
||||
if message.GroupID != groupID || message.EventID != eventID || message.Desc != "lucky_gift_drawn" || message.Ext != "room_system_message" {
|
||||
t.Fatalf("room lucky gift envelope mismatch: %+v", message)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
|
||||
t.Fatalf("room lucky gift payload is invalid: %v", err)
|
||||
}
|
||||
if payload["event_type"] != "lucky_gift_drawn" || payload["event_id"] != eventID || payload["draw_id"] != drawID {
|
||||
t.Fatalf("room lucky gift identity fields mismatch: %+v", payload)
|
||||
}
|
||||
if int64(payload["effective_reward_coins"].(float64)) != rewardCoins {
|
||||
t.Fatalf("room lucky gift reward mismatch: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
@ -213,6 +213,34 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
||||
return luckyAggregateDrawResults(cmd, results), nil
|
||||
}
|
||||
|
||||
func (r *Repository) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
results := make([]domain.DrawResult, 0, len(cmds))
|
||||
for _, cmd := range cmds {
|
||||
// 多目标送礼必须在 activity 内按请求顺序推进同一用户、奖池、RTP 和风控锁;这里不做并发,避免 room-service 自己制造锁竞争。
|
||||
draws, err := r.executeLuckyGiftDrawBatch(ctx, tx, cmd, nowMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, luckyAggregateDrawResults(cmd, draws))
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
|
||||
// gift_count 是抽奖次数,coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。
|
||||
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
|
||||
|
||||
@ -47,7 +47,35 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *activityv1.Ch
|
||||
func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
|
||||
meta := req.GetLuckyGift()
|
||||
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode())
|
||||
result, err := s.svc.Draw(ctx, domain.DrawCommand{
|
||||
result, err := s.svc.Draw(ctx, luckyDrawCommandFromProto(meta))
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: luckyDrawResultToProto(result)}, nil
|
||||
}
|
||||
|
||||
func (s *LuckyGiftServer) BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
|
||||
if len(req.GetLuckyGifts()) == 0 {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetLuckyGifts()[0].GetMeta().GetAppCode())
|
||||
cmds := make([]domain.DrawCommand, 0, len(req.GetLuckyGifts()))
|
||||
for _, meta := range req.GetLuckyGifts() {
|
||||
cmds = append(cmds, luckyDrawCommandFromProto(meta))
|
||||
}
|
||||
results, err := s.svc.DrawBatch(ctx, cmds)
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &activityv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*activityv1.LuckyGiftDrawResult, 0, len(results))}
|
||||
for _, result := range results {
|
||||
resp.Results = append(resp.Results, luckyDrawResultToProto(result))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func luckyDrawCommandFromProto(meta *activityv1.LuckyGiftMeta) domain.DrawCommand {
|
||||
return domain.DrawCommand{
|
||||
CommandID: meta.GetCommandId(),
|
||||
PoolID: meta.GetPoolId(),
|
||||
UserID: meta.GetUserId(),
|
||||
@ -61,11 +89,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
|
||||
PaidAtMS: meta.GetPaidAtMs(),
|
||||
VisibleRegionID: meta.GetVisibleRegionId(),
|
||||
CountryID: meta.GetCountryId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: luckyDrawResultToProto(result)}, nil
|
||||
}
|
||||
|
||||
// AdminLuckyGiftServer 暴露后台幸运礼物规则配置和抽奖审计入口。
|
||||
|
||||
@ -179,6 +179,7 @@ type RoomHandlers struct {
|
||||
KickUser http.HandlerFunc
|
||||
UnbanUser http.HandlerFunc
|
||||
SendGift http.HandlerFunc
|
||||
SendGiftBatch http.HandlerFunc
|
||||
}
|
||||
|
||||
type MessageHandlers struct {
|
||||
@ -520,6 +521,7 @@ func (r routes) registerRoomRoutes() {
|
||||
r.profile("/rooms/user/kick", http.MethodPost, h.KickUser)
|
||||
r.profile("/rooms/user/unban", http.MethodPost, h.UnbanUser)
|
||||
r.profile("/rooms/gift/send", http.MethodPost, h.SendGift)
|
||||
r.profile("/rooms/gift/batch-send", http.MethodPost, h.SendGiftBatch)
|
||||
}
|
||||
|
||||
func (r routes) registerMessageRoutes() {
|
||||
|
||||
@ -2597,7 +2597,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
|
||||
BillingReceiptId: "receipt-lucky",
|
||||
RoomHeat: 100,
|
||||
CoinBalanceAfter: 8800,
|
||||
CoinBalanceAfter: 8200,
|
||||
LuckyGift: &roomv1.LuckyGiftDrawResult{
|
||||
Enabled: true,
|
||||
DrawId: "lucky_draw_test",
|
||||
@ -2610,7 +2610,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
MultiplierPpm: 5_000_000,
|
||||
EffectiveRewardCoins: 500,
|
||||
RewardStatus: "granted",
|
||||
CoinBalanceAfter: 8800,
|
||||
CoinBalanceAfter: 0,
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
},
|
||||
LuckyGifts: []*roomv1.LuckyGiftDrawResult{
|
||||
@ -2651,13 +2651,13 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 5_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 500 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "" || envelope.Data.LuckyGift.CoinBalanceAfter != 8800 {
|
||||
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 5_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 500 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "" || envelope.Data.LuckyGift.CoinBalanceAfter != 0 {
|
||||
t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
|
||||
}
|
||||
if len(envelope.Data.LuckyGifts) != 2 || envelope.Data.LuckyGifts[0].TargetUserID != 43 || envelope.Data.LuckyGifts[0].MultiplierPPM != 2_000_000 || envelope.Data.LuckyGifts[1].TargetUserID != 44 || envelope.Data.LuckyGifts[1].MultiplierPPM != 3_000_000 {
|
||||
t.Fatalf("lucky_gifts response mismatch: %+v", envelope.Data.LuckyGifts)
|
||||
}
|
||||
if envelope.Data.CoinBalanceAfter != 8800 {
|
||||
if envelope.Data.CoinBalanceAfter != 8200 {
|
||||
t.Fatalf("send gift response must expose sender coin_balance_after: %+v", envelope.Data)
|
||||
}
|
||||
}
|
||||
@ -2729,6 +2729,67 @@ func TestSendGiftForwardsMultipleTargetUserIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSendGiftForwardsDisplayModeAndResponse(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 15, ServerTimeMs: 1_779_258_000_000},
|
||||
BillingReceiptId: "receipt-batch",
|
||||
RoomHeat: 500,
|
||||
CoinBalanceAfter: 7600,
|
||||
BatchDisplay: &roomv1.SendGiftBatchDisplay{
|
||||
EventId: "evt-batch",
|
||||
SenderUserId: 42,
|
||||
GiftId: "rose",
|
||||
GiftCount: 2,
|
||||
TotalGiftValue: 400,
|
||||
TotalCoinSpent: 400,
|
||||
BillingReceiptId: "receipt-batch",
|
||||
TargetUserIds: []int64{43, 44},
|
||||
CommandId: "cmd-gift-batch",
|
||||
GiftAnimationUrl: "https://cdn.example/rose.svga",
|
||||
GiftEffectTypes: []string{"room"},
|
||||
RoomHeat: 500,
|
||||
Targets: []*roomv1.SendGiftBatchTarget{
|
||||
{TargetUserId: 43, TargetGiftValue: 220, CommandId: "cmd-gift-batch:target:43"},
|
||||
{TargetUserId: 44, TargetGiftValue: 180, CommandId: "cmd-gift-batch:target:44"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":2,"pool_id":"super_lucky"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if roomClient.lastGift == nil || roomClient.lastGift.GetDisplayMode() != "batch" || len(roomClient.lastGift.GetTargetUserIds()) != 2 || roomClient.lastGift.GetPoolId() != "super_lucky" {
|
||||
t.Fatalf("gateway must forward batch display mode and targets: %+v", roomClient.lastGift)
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
CoinBalanceAfter int64 `json:"coin_balance_after"`
|
||||
BatchDisplay struct {
|
||||
EventID string `json:"event_id"`
|
||||
TargetUserIDs []int64 `json:"target_user_ids"`
|
||||
Targets []struct {
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
TargetGiftValue int64 `json:"target_gift_value"`
|
||||
CommandID string `json:"command_id"`
|
||||
} `json:"targets"`
|
||||
} `json:"batch_display"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if envelope.Code != "OK" || envelope.Data.CoinBalanceAfter != 7600 || envelope.Data.BatchDisplay.EventID != "evt-batch" || len(envelope.Data.BatchDisplay.TargetUserIDs) != 2 || len(envelope.Data.BatchDisplay.Targets) != 2 {
|
||||
t.Fatalf("batch send response mismatch: %+v", envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||
@ -2998,6 +3059,10 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
|
||||
if roomClient.lastJoin == nil || roomClient.lastJoin.GetPassword() != "1234" {
|
||||
t.Fatalf("join must forward room password to room-service: %+v", roomClient.lastJoin)
|
||||
}
|
||||
joinProfile := roomClient.lastJoin.GetActorDisplayProfile()
|
||||
if joinProfile.GetUserId() != 42 || joinProfile.GetNickname() != "hy" || joinProfile.GetAvatar() != "https://cdn.example/avatar.png" || joinProfile.GetDisplayUserId() != "100001" {
|
||||
t.Fatalf("join must inject actor display profile snapshot: %+v", joinProfile)
|
||||
}
|
||||
seats := data["seats"].([]any)
|
||||
if len(seats) != 2 || seats[0].(map[string]any)["user_id"] != "102" {
|
||||
t.Fatalf("join response must include first-screen seats: %+v", seats)
|
||||
|
||||
@ -102,6 +102,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
|
||||
KickUser: h.kickUser,
|
||||
UnbanUser: h.unbanUser,
|
||||
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) {
|
||||
return
|
||||
}
|
||||
actorCountryID, actorRegionID, err := h.resolveRoomActorCountryRegion(request)
|
||||
actorSnapshot, err := h.resolveRoomActorJoinSnapshot(request)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.JoinRoom(request.Context(), &roomv1.JoinRoomRequest{
|
||||
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
|
||||
Role: body.Role,
|
||||
Password: body.Password,
|
||||
ActorCountryId: actorCountryID,
|
||||
ActorRegionId: actorRegionID,
|
||||
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
|
||||
Role: body.Role,
|
||||
Password: body.Password,
|
||||
ActorCountryId: actorSnapshot.countryID,
|
||||
ActorRegionId: actorSnapshot.regionID,
|
||||
ActorDisplayProfile: actorSnapshot.displayProfile,
|
||||
// 座驾快照在进房命令提交前生成;room-service 只保存快照,不回查 wallet。
|
||||
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request),
|
||||
})
|
||||
@ -1556,6 +1557,16 @@ func (h *Handler) unbanUser(writer http.ResponseWriter, request *http.Request) {
|
||||
// sendGift 把送礼 HTTP JSON 请求转换为 room-service SendGift 命令。
|
||||
// 扣费、幂等、房间表现和 outbox 仍由 room-service 的命令链路统一处理。
|
||||
func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
h.handleSendGift(writer, request, "")
|
||||
}
|
||||
|
||||
// sendGiftBatch 是新 Flutter 的多人送礼入口。
|
||||
// 它不改变账务和房间事实,只通过 display_mode=batch 要求 room-service 合并房间展示 IM。
|
||||
func (h *Handler) sendGiftBatch(writer http.ResponseWriter, request *http.Request) {
|
||||
h.handleSendGift(writer, request, "batch")
|
||||
}
|
||||
|
||||
func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Request, displayMode string) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
@ -1611,29 +1622,40 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
Source: strings.TrimSpace(body.Source),
|
||||
SenderDisplayProfile: userSnapshots.senderDisplayProfile,
|
||||
TargetDisplayProfiles: userSnapshots.targetDisplayProfiles,
|
||||
DisplayMode: strings.TrimSpace(displayMode),
|
||||
})
|
||||
httpkit.Write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
func (h *Handler) resolveRoomActorCountryRegion(request *http.Request) (int64, int64, error) {
|
||||
type roomActorJoinSnapshot struct {
|
||||
countryID int64
|
||||
regionID int64
|
||||
displayProfile *roomv1.RoomUserDisplayProfile
|
||||
}
|
||||
|
||||
func (h *Handler) resolveRoomActorJoinSnapshot(request *http.Request) (roomActorJoinSnapshot, error) {
|
||||
if h.userProfileClient == nil {
|
||||
return 0, 0, xerr.New(xerr.Unavailable, "user service is not configured")
|
||||
return roomActorJoinSnapshot{}, xerr.New(xerr.Unavailable, "user service is not configured")
|
||||
}
|
||||
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return roomActorJoinSnapshot{}, err
|
||||
}
|
||||
if resp.GetUser() == nil {
|
||||
return 0, 0, xerr.New(xerr.NotFound, "user not found")
|
||||
return roomActorJoinSnapshot{}, xerr.New(xerr.NotFound, "user not found")
|
||||
}
|
||||
if resp.GetUser().GetCountryId() <= 0 || resp.GetUser().GetRegionId() <= 0 {
|
||||
return 0, 0, xerr.New(xerr.InvalidArgument, "user country and region are required")
|
||||
return roomActorJoinSnapshot{}, xerr.New(xerr.InvalidArgument, "user country and region are required")
|
||||
}
|
||||
// room-service 事件必须携带用户真实国家和区域;房间 visible_region_id 只表达房间列表归属。
|
||||
return resp.GetUser().GetCountryId(), resp.GetUser().GetRegionId(), nil
|
||||
// room-service 事件必须携带用户真实国家/区域和展示快照;房间 visible_region_id 只表达房间列表归属。
|
||||
return roomActorJoinSnapshot{
|
||||
countryID: resp.GetUser().GetCountryId(),
|
||||
regionID: resp.GetUser().GetRegionId(),
|
||||
displayProfile: roomUserDisplayProfileFromUser(resp.GetUser()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type giftUserSnapshots struct {
|
||||
@ -1778,6 +1800,20 @@ func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfil
|
||||
}
|
||||
}
|
||||
|
||||
func roomUserDisplayProfileFromUser(user *userv1.User) *roomv1.RoomUserDisplayProfile {
|
||||
if user == nil || user.GetUserId() <= 0 {
|
||||
return nil
|
||||
}
|
||||
// 这里不生成 User<ID> 兜底;真实昵称/头像缺失时让客户端走 display_user_id 或最终 user_id 兜底。
|
||||
return &roomv1.RoomUserDisplayProfile{
|
||||
UserId: user.GetUserId(),
|
||||
Nickname: strings.TrimSpace(user.GetUsername()),
|
||||
Avatar: strings.TrimSpace(user.GetAvatar()),
|
||||
DisplayUserId: strings.TrimSpace(user.GetDisplayUserId()),
|
||||
PrettyDisplayUserId: strings.TrimSpace(user.GetPrettyDisplayUserId()),
|
||||
}
|
||||
}
|
||||
|
||||
func firstGiftTargetHostScope(scopes []*roomv1.SendGiftTargetHostScope, targetUserID int64) *roomv1.SendGiftTargetHostScope {
|
||||
for _, scope := range scopes {
|
||||
if scope.GetTargetUserId() == targetUserID {
|
||||
|
||||
@ -28,6 +28,8 @@ type LuckyGiftClient interface {
|
||||
CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error)
|
||||
// ExecuteLuckyGiftDraw 在钱包扣费成功后按 command_id 幂等落抽奖事实。
|
||||
ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error)
|
||||
// BatchExecuteLuckyGiftDraw 一次提交多人送礼的全部目标抽奖事实,返回顺序必须和请求目标顺序一致。
|
||||
BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error)
|
||||
}
|
||||
|
||||
// RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。
|
||||
|
||||
@ -61,3 +61,7 @@ func (c *grpcLuckyGiftClient) CheckLuckyGift(ctx context.Context, req *activityv
|
||||
func (c *grpcLuckyGiftClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
|
||||
return c.client.ExecuteLuckyGiftDraw(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcLuckyGiftClient) BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
|
||||
return c.client.BatchExecuteLuckyGiftDraw(ctx, req)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
@ -152,7 +153,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
base.TargetUserID = body.GetUserId()
|
||||
// RoomUserJoined 的座驾必须从 outbox body 取快照,不能在补偿投递时按当前佩戴重算。
|
||||
base.EntryVehicle = roomEntryVehicleFromEvent(body.GetEntryVehicle())
|
||||
base.Attributes = map[string]string{"role": body.GetRole()}
|
||||
base.Attributes = roomUserJoinedAttributesFromEvent(&body)
|
||||
return base, true, nil
|
||||
case "RoomUserLeft":
|
||||
// 离房事件只表达业务 presence 移除,不声明腾讯云 IM 连接是否已断开。
|
||||
@ -293,6 +294,10 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(body.GetDisplayMode()), "batch") {
|
||||
// batch-send 仍写逐目标 RoomGiftSent 作为 durable 业务事实,但房间展示只发 RoomGiftBatchSent;否则新 Flutter 会同时收到 batch 和 N 条旧单目标 IM。
|
||||
return tencentim.RoomEvent{}, false, nil
|
||||
}
|
||||
base.ActorUserID = body.GetSenderUserId()
|
||||
base.TargetUserID = body.GetTargetUserId()
|
||||
base.GiftValue = body.GetGiftValue()
|
||||
@ -323,6 +328,42 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
"receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(),
|
||||
}
|
||||
return base, true, nil
|
||||
case "RoomGiftBatchSent":
|
||||
// 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。
|
||||
var body roomeventsv1.RoomGiftBatchSent
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
targetUserIDsJSON, err := json.Marshal(body.GetTargetUserIds())
|
||||
if err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetSenderUserId()
|
||||
base.TargetUserID = firstInt64(body.GetTargetUserIds())
|
||||
base.GiftValue = body.GetTotalGiftValue()
|
||||
base.RoomHeat = body.GetRoomHeat()
|
||||
// 房间 IM 只保留客户端房内展示需要的小字段:飞座位依赖 target_user_ids,礼物图标/名称用于公屏和飞行图标兜底。
|
||||
// 逐目标结算、收礼累计和幸运明细已经在 durable RoomGiftSent / lucky_gift_drawn 事实中消费,不能塞进 direct IM。
|
||||
base.Attributes = map[string]string{
|
||||
"gift_id": body.GetGiftId(),
|
||||
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
|
||||
"total_gift_value": fmt.Sprintf("%d", body.GetTotalGiftValue()),
|
||||
"billing_receipt_id": body.GetBillingReceiptId(),
|
||||
"pool_id": body.GetPoolId(),
|
||||
"gift_type_code": body.GetGiftTypeCode(),
|
||||
"cp_relation_type": body.GetCpRelationType(),
|
||||
"gift_name": body.GetGiftName(),
|
||||
"gift_icon_url": body.GetGiftIconUrl(),
|
||||
"target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())),
|
||||
"target_user_ids": string(targetUserIDsJSON),
|
||||
"command_id": body.GetCommandId(),
|
||||
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
|
||||
"sender_name": body.GetSenderName(),
|
||||
"sender_avatar": body.GetSenderAvatar(),
|
||||
"sender_display_user_id": body.GetSenderDisplayUserId(),
|
||||
"sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(),
|
||||
}
|
||||
return base, true, nil
|
||||
case "RoomHeatChanged":
|
||||
// 机器人通道也会投递热度展示事件,客户端可独立刷新房间热度,不依赖主 outbox。
|
||||
var body roomeventsv1.RoomHeatChanged
|
||||
@ -475,6 +516,35 @@ func roomEntryVehicleFromEvent(item *roomeventsv1.RoomEntryVehicleSnapshot) *ten
|
||||
}
|
||||
}
|
||||
|
||||
func roomUserJoinedAttributesFromEvent(body *roomeventsv1.RoomUserJoined) map[string]string {
|
||||
if body == nil {
|
||||
return nil
|
||||
}
|
||||
values := map[string]string{
|
||||
"role": strings.TrimSpace(body.GetRole()),
|
||||
"nickname": strings.TrimSpace(body.GetNickname()),
|
||||
"avatar": strings.TrimSpace(body.GetAvatar()),
|
||||
"display_user_id": strings.TrimSpace(body.GetDisplayUserId()),
|
||||
"pretty_display_user_id": strings.TrimSpace(body.GetPrettyDisplayUserId()),
|
||||
}
|
||||
// RoomEvent.MarshalJSON 会把 attributes 平铺成客户端顶层字段;空字段不发送,避免客户端把空快照当成明确资料值。
|
||||
for key, value := range values {
|
||||
if value == "" {
|
||||
delete(values, key)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func firstInt64(values []int64) int64 {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func eventTypeForClient(eventType string) string {
|
||||
// protobuf 事件名使用 PascalCase,客户端 TIMCustomElem 使用 snake_case 作为稳定协议字段。
|
||||
switch eventType {
|
||||
@ -506,6 +576,8 @@ func eventTypeForClient(eventType string) string {
|
||||
return "room_user_unbanned"
|
||||
case "RoomGiftSent":
|
||||
return "room_gift_sent"
|
||||
case "RoomGiftBatchSent":
|
||||
return "room_gift_batch_sent"
|
||||
case "RoomHeatChanged":
|
||||
return "room_heat_changed"
|
||||
case "RoomRankChanged":
|
||||
|
||||
@ -35,9 +35,13 @@ func TestRoomEventFromEnvelopeSkipsNonIMEvents(t *testing.T) {
|
||||
|
||||
func TestRoomUserJoinedCarriesEntryVehicleSnapshot(t *testing.T) {
|
||||
record, err := outbox.Build("room-vehicle", "RoomUserJoined", 9, time.Now(), &roomeventsv1.RoomUserJoined{
|
||||
UserId: 42,
|
||||
Role: "audience",
|
||||
VisibleRegionId: 86,
|
||||
UserId: 42,
|
||||
Role: "audience",
|
||||
VisibleRegionId: 86,
|
||||
Nickname: "Join Nina",
|
||||
Avatar: "https://cdn.example.com/nina.png",
|
||||
DisplayUserId: "100042",
|
||||
PrettyDisplayUserId: "888042",
|
||||
EntryVehicle: &roomeventsv1.RoomEntryVehicleSnapshot{
|
||||
ResourceId: 7001,
|
||||
ResourceCode: "vehicle_gold",
|
||||
@ -64,6 +68,9 @@ func TestRoomUserJoinedCarriesEntryVehicleSnapshot(t *testing.T) {
|
||||
if event.EntryVehicle == nil || event.EntryVehicle.ResourceID != 7001 || event.EntryVehicle.AnimationURL != "https://cdn.example.com/vehicle.svga" {
|
||||
t.Fatalf("entry vehicle snapshot mismatch: %+v", event.EntryVehicle)
|
||||
}
|
||||
if event.Attributes["nickname"] != "Join Nina" || event.Attributes["avatar"] != "https://cdn.example.com/nina.png" || event.Attributes["display_user_id"] != "100042" || event.Attributes["pretty_display_user_id"] != "888042" {
|
||||
t.Fatalf("joined display profile attributes mismatch: %+v", event.Attributes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomBackgroundChangedPublishesDedicatedIMEvent(t *testing.T) {
|
||||
@ -125,6 +132,100 @@ func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomGiftSentBatchDisplayFactSkipsSingleTargetIM(t *testing.T) {
|
||||
record, err := outbox.Build("room-gift-batch-fact", "RoomGiftSent", 9, time.Now(), &roomeventsv1.RoomGiftSent{
|
||||
SenderUserId: 1001,
|
||||
TargetUserId: 1002,
|
||||
GiftId: "gift_clover",
|
||||
GiftCount: 1,
|
||||
GiftValue: 100,
|
||||
TargetGiftValue: 900,
|
||||
DisplayMode: "batch",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build batch RoomGiftSent fact failed: %v", err)
|
||||
}
|
||||
|
||||
event, publish, err := roomEventFromEnvelope(record.Envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("batch RoomGiftSent fact should decode without publishing: %v", err)
|
||||
}
|
||||
if publish {
|
||||
t.Fatalf("batch RoomGiftSent fact must not publish single-target IM, event=%+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) {
|
||||
record, err := outbox.Build("room-gift-batch", "RoomGiftBatchSent", 12, time.Now(), &roomeventsv1.RoomGiftBatchSent{
|
||||
SenderUserId: 1001,
|
||||
SenderName: "Batch Sender",
|
||||
GiftId: "gift_clover",
|
||||
GiftCount: 2,
|
||||
TotalGiftValue: 600,
|
||||
TotalCoinSpent: 600,
|
||||
BillingReceiptId: "receipt-batch",
|
||||
RoomHeat: 6600,
|
||||
PoolId: "super_lucky",
|
||||
GiftEffectTypes: []string{"room"},
|
||||
TargetUserIds: []int64{1002, 1003},
|
||||
CommandId: "cmd-batch",
|
||||
Targets: []*roomeventsv1.RoomGiftBatchTarget{
|
||||
{
|
||||
TargetUserId: 1002,
|
||||
ReceiverNickname: "Receiver One",
|
||||
GiftValue: 300,
|
||||
TargetGiftValue: 1300,
|
||||
CommandId: "cmd-batch:target:1002",
|
||||
LuckyGift: &roomeventsv1.RoomGiftBatchLuckyResult{
|
||||
Enabled: true,
|
||||
DrawId: "draw-1",
|
||||
CommandId: "cmd-batch:target:1002",
|
||||
PoolId: "super_lucky",
|
||||
GiftId: "gift_clover",
|
||||
MultiplierPpm: 2_000_000,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "granted",
|
||||
TargetUserId: 1002,
|
||||
},
|
||||
},
|
||||
{TargetUserId: 1003, ReceiverNickname: "Receiver Two", GiftValue: 300, TargetGiftValue: 900, CommandId: "cmd-batch:target:1003"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build RoomGiftBatchSent envelope failed: %v", err)
|
||||
}
|
||||
|
||||
event, publish, err := roomEventFromEnvelope(record.Envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("RoomGiftBatchSent should decode: %v", err)
|
||||
}
|
||||
if !publish || event.EventType != "room_gift_batch_sent" || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 600 || event.RoomHeat != 6600 {
|
||||
t.Fatalf("batch gift IM event mismatch: publish=%t event=%+v", publish, event)
|
||||
}
|
||||
if event.Attributes["target_user_ids"] != `[1002,1003]` || event.Attributes["target_count"] != "2" {
|
||||
t.Fatalf("batch gift IM target attributes mismatch: %+v", event.Attributes)
|
||||
}
|
||||
if event.Attributes["gift_id"] != "gift_clover" || event.Attributes["gift_count"] != "2" || event.Attributes["command_id"] != "cmd-batch" {
|
||||
t.Fatalf("batch gift IM gift attributes mismatch: %+v", event.Attributes)
|
||||
}
|
||||
if _, exists := event.Attributes["targets"]; exists {
|
||||
t.Fatalf("room batch IM must not include bulky targets: %+v", event.Attributes)
|
||||
}
|
||||
if _, exists := event.Attributes["lucky_gifts"]; exists {
|
||||
t.Fatalf("room batch IM must not include bulky lucky_gifts: %+v", event.Attributes)
|
||||
}
|
||||
if _, exists := event.Attributes["coin_balance_after"]; exists {
|
||||
t.Fatalf("room IM must not expose sender coin balance: %+v", event.Attributes)
|
||||
}
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal batch gift IM event failed: %v", err)
|
||||
}
|
||||
if len(payload) > 4096 {
|
||||
t.Fatalf("batch gift IM payload should stay small, bytes=%d payload=%s", len(payload), payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomHeatAndRankChangedPublishDisplayIMEvents(t *testing.T) {
|
||||
heatRecord, err := outbox.Build("room-robot-heat", "RoomHeatChanged", 9, time.Now(), &roomeventsv1.RoomHeatChanged{
|
||||
Delta: 120,
|
||||
|
||||
@ -135,6 +135,8 @@ type JoinRoom struct {
|
||||
ActorIsRobot bool `json:"actor_is_robot,omitempty"`
|
||||
// EntryVehicle 是 gateway 在进房时解析出的当前有效佩戴座驾快照,用于进房 IM。
|
||||
EntryVehicle EntryVehicleSnapshot `json:"entry_vehicle,omitempty"`
|
||||
// ActorDisplayProfile 是 gateway 在进房入口固化的展示快照;只服务 IM/UI 兜底,不参与 presence 权限和统计。
|
||||
ActorDisplayProfile UserDisplayProfile `json:"actor_display_profile,omitempty"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
@ -422,6 +424,8 @@ type SendGift struct {
|
||||
EntitlementID string `json:"entitlement_id,omitempty"`
|
||||
// Source 区分 coin/bag;为空按普通金币链路兼容旧客户端。
|
||||
Source string `json:"source,omitempty"`
|
||||
// DisplayMode 只控制客户端展示事件形态;batch 表示保留逐目标事实,但房间 IM 合并为单条多人展示消息。
|
||||
DisplayMode string `json:"display_mode,omitempty"`
|
||||
// SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。
|
||||
SenderRegionID int64 `json:"sender_region_id,omitempty"`
|
||||
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
|
||||
@ -509,6 +513,21 @@ type GiftTargetHostScope struct {
|
||||
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"`
|
||||
}
|
||||
|
||||
// UserDisplayProfile 是用户展示资料的轻量快照。
|
||||
// 该结构只用于异步 IM 展示,避免客户端收到事件后还要等待在线成员资料刷新。
|
||||
type UserDisplayProfile struct {
|
||||
// UserID 是快照归属用户;事件消费者用它确认快照没有串到其他用户。
|
||||
UserID int64 `json:"user_id"`
|
||||
// Nickname 是客户端优先展示的昵称;为空时客户端继续回退 display_user_id。
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
// Avatar 是进房瞬间头像 URL,客户端可直接渲染进场横幅头像。
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
// DisplayUserID 是当前 active 短号,昵称缺失时给客户端兜底展示。
|
||||
DisplayUserID string `json:"display_user_id,omitempty"`
|
||||
// PrettyDisplayUserID 是当前靓号展示值,保留给客户端后续展示策略选择。
|
||||
PrettyDisplayUserID string `json:"pretty_display_user_id,omitempty"`
|
||||
}
|
||||
|
||||
// GiftDisplayProfile 是送礼入口固化的用户展示快照。
|
||||
// 它只服务 IM 和历史事件展示兜底,不参与权限、账务、统计或 Room Cell 核心状态判定。
|
||||
type GiftDisplayProfile struct {
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
|
||||
const roomDirectIMPublishTimeout = 2 * time.Second
|
||||
|
||||
func splitRoomDirectIMRecords(records []outbox.Record) ([]outbox.Record, []outbox.Record) {
|
||||
func splitRoomDirectIMRecords(records []outbox.Record, suppressMirror map[string]bool) ([]outbox.Record, []outbox.Record) {
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@ -24,7 +24,7 @@ func splitRoomDirectIMRecords(records []outbox.Record) ([]outbox.Record, []outbo
|
||||
continue
|
||||
}
|
||||
durable = append(durable, record)
|
||||
if roomDirectIMMirrorEventType(record.EventType) {
|
||||
if roomDirectIMMirrorEventType(record.EventType) && !suppressMirror[record.EventType] {
|
||||
// 这类事件同时服务 UI 和业务事实消费:MySQL outbox 保证统计、任务、通知等可补偿,直接 IM 只给客户端低延迟展示。
|
||||
directIM = append(directIM, record)
|
||||
}
|
||||
@ -36,6 +36,7 @@ func roomDirectIMOnlyEventType(eventType string) bool {
|
||||
switch eventType {
|
||||
case "RoomHeatChanged",
|
||||
"RoomRankChanged",
|
||||
"RoomGiftBatchSent",
|
||||
"RoomRocketFuelChanged",
|
||||
"RoomRocketLaunched":
|
||||
return true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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 {
|
||||
checks []*activityv1.CheckLuckyGiftRequest
|
||||
draws []*activityv1.ExecuteLuckyGiftDrawRequest
|
||||
batchDraws []*activityv1.BatchExecuteLuckyGiftDrawRequest
|
||||
drawResults []*activityv1.LuckyGiftDrawResult
|
||||
}
|
||||
|
||||
@ -72,6 +73,46 @@ func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activ
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c *luckyGiftTestClient) BatchExecuteLuckyGiftDraw(_ context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
|
||||
c.batchDraws = append(c.batchDraws, req)
|
||||
resp := &activityv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*activityv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts()))}
|
||||
for index, meta := range req.GetLuckyGifts() {
|
||||
if len(c.drawResults) > 0 {
|
||||
resultIndex := index
|
||||
if resultIndex >= len(c.drawResults) {
|
||||
resultIndex = len(c.drawResults) - 1
|
||||
}
|
||||
result := proto.Clone(c.drawResults[resultIndex]).(*activityv1.LuckyGiftDrawResult)
|
||||
if result.CommandId == "" {
|
||||
result.CommandId = meta.GetCommandId()
|
||||
}
|
||||
if result.PoolId == "" {
|
||||
result.PoolId = meta.GetPoolId()
|
||||
}
|
||||
if result.GiftId == "" {
|
||||
result.GiftId = meta.GetGiftId()
|
||||
}
|
||||
resp.Results = append(resp.Results, result)
|
||||
continue
|
||||
}
|
||||
resp.Results = append(resp.Results, &activityv1.LuckyGiftDrawResult{
|
||||
DrawId: "lucky_draw_test",
|
||||
CommandId: meta.GetCommandId(),
|
||||
PoolId: meta.GetPoolId(),
|
||||
GiftId: meta.GetGiftId(),
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
SelectedTierId: "novice_2x",
|
||||
MultiplierPpm: 2_000_000,
|
||||
BaseRewardCoins: 200,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "pending",
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -82,32 +123,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
||||
HeatValue: 100,
|
||||
GiftTypeCode: "super_lucky",
|
||||
}}}
|
||||
luckyGift := &luckyGiftTestClient{drawResults: []*activityv1.LuckyGiftDrawResult{
|
||||
{
|
||||
DrawId: "lucky_draw_target_202",
|
||||
SelectedTierId: "novice_2x",
|
||||
MultiplierPpm: 2_000_000,
|
||||
BaseRewardCoins: 200,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "granted",
|
||||
WalletTransactionId: "wallet_tx_lucky_202",
|
||||
CoinBalanceAfter: 8800,
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
},
|
||||
{
|
||||
DrawId: "lucky_draw_target_303",
|
||||
SelectedTierId: "normal_3x",
|
||||
MultiplierPpm: 3_000_000,
|
||||
BaseRewardCoins: 300,
|
||||
EffectiveRewardCoins: 300,
|
||||
RewardStatus: "granted",
|
||||
WalletTransactionId: "wallet_tx_lucky_303",
|
||||
CoinBalanceAfter: 9100,
|
||||
CreatedAtMs: 1_779_258_000_001,
|
||||
StageFeedback: true,
|
||||
HighMultiplier: true,
|
||||
},
|
||||
}}
|
||||
luckyGift := &luckyGiftTestClient{}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-lucky-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
@ -140,7 +156,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("send lucky gift failed: %v", err)
|
||||
}
|
||||
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 || resp.GetLuckyGift().GetRewardStatus() != "granted" || resp.GetLuckyGift().GetWalletTransactionId() != "wallet_tx_lucky" || resp.GetLuckyGift().GetCoinBalanceAfter() != 8800 {
|
||||
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 || resp.GetLuckyGift().GetRewardStatus() != "granted" || resp.GetLuckyGift().GetWalletTransactionId() != "wallet_tx_lucky" || resp.GetLuckyGift().GetCoinBalanceAfter() != 0 {
|
||||
t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
|
||||
}
|
||||
if len(resp.GetLuckyGifts()) != 1 || resp.GetLuckyGifts()[0].GetTargetUserId() != viewerID {
|
||||
@ -169,7 +185,10 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
|
||||
{BillingReceiptId: "receipt-lucky-202", CoinSpent: 100, ChargeAmount: 100, GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "super_lucky"},
|
||||
{BillingReceiptId: "receipt-lucky-303", CoinSpent: 200, ChargeAmount: 200, GiftPointAdded: 200, HeatValue: 200, GiftTypeCode: "super_lucky"},
|
||||
}}
|
||||
luckyGift := &luckyGiftTestClient{}
|
||||
luckyGift := &luckyGiftTestClient{drawResults: []*activityv1.LuckyGiftDrawResult{
|
||||
{DrawId: "draw-first", MultiplierPpm: 2_000_000, BaseRewardCoins: 200, EffectiveRewardCoins: 200, RewardStatus: "pending", StageFeedback: true, CreatedAtMs: 1_779_258_000_001},
|
||||
{DrawId: "draw-second", MultiplierPpm: 3_000_000, BaseRewardCoins: 300, EffectiveRewardCoins: 300, RewardStatus: "pending", HighMultiplier: true, CreatedAtMs: 1_779_258_000_002},
|
||||
}}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-lucky-multi-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
@ -207,11 +226,14 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
|
||||
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" {
|
||||
t.Fatalf("multi-target lucky gift should check pool once: %+v", luckyGift.checks)
|
||||
}
|
||||
if len(luckyGift.draws) != 2 {
|
||||
t.Fatalf("multi-target lucky gift should draw once per target, got %d", len(luckyGift.draws))
|
||||
if len(luckyGift.draws) != 0 || len(luckyGift.batchDraws) != 1 {
|
||||
t.Fatalf("multi-target lucky gift should call one batch draw: draws=%d batch=%d", len(luckyGift.draws), len(luckyGift.batchDraws))
|
||||
}
|
||||
firstDraw := luckyGift.draws[0].GetLuckyGift()
|
||||
secondDraw := luckyGift.draws[1].GetLuckyGift()
|
||||
if len(luckyGift.batchDraws[0].GetLuckyGifts()) != 2 {
|
||||
t.Fatalf("multi-target lucky batch should include two targets: %+v", luckyGift.batchDraws[0])
|
||||
}
|
||||
firstDraw := luckyGift.batchDraws[0].GetLuckyGifts()[0]
|
||||
secondDraw := luckyGift.batchDraws[0].GetLuckyGifts()[1]
|
||||
if firstDraw.GetCommandId() != "cmd-lucky-multi:target:202" || firstDraw.GetTargetUserId() != firstTargetID || firstDraw.GetCoinSpent() != 100 {
|
||||
t.Fatalf("first target lucky draw mismatch: %+v", firstDraw)
|
||||
}
|
||||
@ -224,7 +246,7 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
|
||||
resp.GetLuckyGift().GetMultiplierPpm() != 5_000_000 ||
|
||||
resp.GetLuckyGift().GetEffectiveRewardCoins() != 500 ||
|
||||
resp.GetLuckyGift().GetWalletTransactionId() != "" ||
|
||||
resp.GetLuckyGift().GetCoinBalanceAfter() != 9100 ||
|
||||
resp.GetLuckyGift().GetCoinBalanceAfter() != 0 ||
|
||||
!resp.GetLuckyGift().GetStageFeedback() ||
|
||||
!resp.GetLuckyGift().GetHighMultiplier() ||
|
||||
len(resp.GetLuckyGifts()) != 2 {
|
||||
|
||||
@ -79,7 +79,10 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
}
|
||||
// 机器人展示只是 App 视觉补偿,提交前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
|
||||
robotDisplayRecords := s.sampleRobotDisplayRecords(result.robotDisplayRecords)
|
||||
durableOutboxRecords, directIMRecords := splitRoomDirectIMRecords(outboxRecords)
|
||||
durableOutboxRecords, directIMRecords := splitRoomDirectIMRecords(outboxRecords, result.suppressDirectIMEventTypes)
|
||||
// mutate 可额外返回只服务客户端展示的直发 IM,典型场景是重复 JoinRoom 的入房飘窗;
|
||||
// 这类事件不能进入 durable outbox,否则会污染活跃统计和补偿消费。
|
||||
directIMRecords = append(directIMRecords, result.directIMRecords...)
|
||||
saveStartedAt := time.Now()
|
||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
||||
Command: CommandRecord{
|
||||
|
||||
@ -25,11 +25,12 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
password := strings.TrimSpace(req.GetPassword())
|
||||
// JoinRoom 只维护 room-service presence,不创建任何本地长连接订阅。
|
||||
cmd := command.JoinRoom{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
Role: req.GetRole(),
|
||||
ActorCountryID: req.GetActorCountryId(),
|
||||
ActorRegionID: req.GetActorRegionId(),
|
||||
ActorIsRobot: req.GetActorIsRobot(),
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
Role: req.GetRole(),
|
||||
ActorCountryID: req.GetActorCountryId(),
|
||||
ActorRegionID: req.GetActorRegionId(),
|
||||
ActorIsRobot: req.GetActorIsRobot(),
|
||||
ActorDisplayProfile: userDisplayProfileFromProto(req.GetActorDisplayProfile()),
|
||||
// entry_vehicle 来自 gateway 入房瞬间的 wallet 快照,写入 command log 后可稳定重放。
|
||||
EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()),
|
||||
}
|
||||
@ -58,14 +59,21 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
}
|
||||
|
||||
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
|
||||
// 重复 JoinRoom 表示重连或刷新业务 presence,只更新 last_seen,不重复发进房系统消息。
|
||||
// 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen,
|
||||
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords,不进入 durable outbox,
|
||||
// 避免统计服务把同一个在线用户重复计为进房活跃。
|
||||
existing.LastSeenAtMS = now.UnixMilli()
|
||||
current.Version++
|
||||
|
||||
joinedDisplayEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, firstNonEmpty(existing.Role, cmd.Role), current.VisibleRegionID))
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
snapshot := current.ToProto()
|
||||
return mutationResult{
|
||||
snapshot: snapshot,
|
||||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
||||
snapshot: snapshot,
|
||||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
||||
directIMRecords: []outbox.Record{joinedDisplayEvent},
|
||||
}, nil, nil
|
||||
}
|
||||
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
|
||||
@ -77,17 +85,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
current.Version++
|
||||
|
||||
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
|
||||
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, &roomeventsv1.RoomUserJoined{
|
||||
UserId: cmd.ActorUserID(),
|
||||
Role: cmd.Role,
|
||||
VisibleRegionId: current.VisibleRegionID,
|
||||
CountryId: cmd.ActorCountryID,
|
||||
RegionId: firstNonZeroInt64(cmd.ActorRegionID, current.VisibleRegionID),
|
||||
// 机器人进房仍然需要 outbox 驱动 IM 展示和恢复,但不能进入真人活跃统计。
|
||||
IsRobot: cmd.ActorIsRobot,
|
||||
// outbox 事件必须带座驾快照;否则异步补偿投递 IM 时会丢失入场座驾。
|
||||
EntryVehicle: eventEntryVehicleFromCommand(cmd.EntryVehicle),
|
||||
})
|
||||
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, cmd.Role, current.VisibleRegionID))
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
@ -106,6 +104,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
RoomVersion: current.Version,
|
||||
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
|
||||
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
|
||||
Attributes: roomUserJoinedIMAttributes(cmd.Role, cmd.ActorDisplayProfile),
|
||||
},
|
||||
}, []outbox.Record{joinedEvent}, nil
|
||||
})
|
||||
@ -121,6 +120,55 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userDisplayProfileFromProto(item *roomv1.RoomUserDisplayProfile) command.UserDisplayProfile {
|
||||
if item == nil || item.GetUserId() <= 0 {
|
||||
return command.UserDisplayProfile{}
|
||||
}
|
||||
// 只信任 gateway 固化的展示快照;room-service 不在进房高频链路反查用户服务。
|
||||
return command.UserDisplayProfile{
|
||||
UserID: item.GetUserId(),
|
||||
Nickname: strings.TrimSpace(item.GetNickname()),
|
||||
Avatar: strings.TrimSpace(item.GetAvatar()),
|
||||
DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()),
|
||||
PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()),
|
||||
}
|
||||
}
|
||||
|
||||
func roomUserJoinedEventFromCommand(cmd command.JoinRoom, role string, visibleRegionID int64) *roomeventsv1.RoomUserJoined {
|
||||
profile := cmd.ActorDisplayProfile
|
||||
// 事件 body 是 direct IM 和 outbox 补偿的共同来源,展示字段必须和统计字段一起固定在命令提交时刻。
|
||||
return &roomeventsv1.RoomUserJoined{
|
||||
UserId: cmd.ActorUserID(),
|
||||
Role: role,
|
||||
VisibleRegionId: visibleRegionID,
|
||||
CountryId: cmd.ActorCountryID,
|
||||
RegionId: firstNonZeroInt64(cmd.ActorRegionID, visibleRegionID),
|
||||
IsRobot: cmd.ActorIsRobot,
|
||||
EntryVehicle: eventEntryVehicleFromCommand(cmd.EntryVehicle),
|
||||
Nickname: strings.TrimSpace(profile.Nickname),
|
||||
Avatar: strings.TrimSpace(profile.Avatar),
|
||||
DisplayUserId: strings.TrimSpace(profile.DisplayUserID),
|
||||
PrettyDisplayUserId: strings.TrimSpace(profile.PrettyDisplayUserID),
|
||||
}
|
||||
}
|
||||
|
||||
func roomUserJoinedIMAttributes(role string, profile command.UserDisplayProfile) map[string]string {
|
||||
values := map[string]string{
|
||||
"role": strings.TrimSpace(role),
|
||||
"nickname": strings.TrimSpace(profile.Nickname),
|
||||
"avatar": strings.TrimSpace(profile.Avatar),
|
||||
"display_user_id": strings.TrimSpace(profile.DisplayUserID),
|
||||
"pretty_display_user_id": strings.TrimSpace(profile.PrettyDisplayUserID),
|
||||
}
|
||||
// 空字段不进 IM payload,避免客户端把空字符串误判为后端明确清空资料。
|
||||
for key, value := range values {
|
||||
if value == "" {
|
||||
delete(values, key)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func entryVehicleSnapshotFromProto(item *roomv1.RoomEntryVehicleSnapshot) command.EntryVehicleSnapshot {
|
||||
if item == nil || item.GetResourceId() <= 0 {
|
||||
return command.EntryVehicleSnapshot{}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
@ -30,11 +31,13 @@ func (c *fixedRoomRocketClock) Now() time.Time {
|
||||
}
|
||||
|
||||
type rocketTestWallet struct {
|
||||
debits []*walletv1.DebitGiftResponse
|
||||
grants []*walletv1.GrantResourceGroupRequest
|
||||
lastDebit *walletv1.DebitGiftRequest
|
||||
debitRequests []*walletv1.DebitGiftRequest
|
||||
lastBatch *walletv1.BatchDebitGiftRequest
|
||||
debits []*walletv1.DebitGiftResponse
|
||||
grants []*walletv1.GrantResourceGroupRequest
|
||||
lastDebit *walletv1.DebitGiftRequest
|
||||
debitRequests []*walletv1.DebitGiftRequest
|
||||
lastBatch *walletv1.BatchDebitGiftRequest
|
||||
balanceResponses []*walletv1.GetBalancesResponse
|
||||
balanceRequests []*walletv1.GetBalancesRequest
|
||||
}
|
||||
|
||||
type recordingRoomDirectIMPublisher struct {
|
||||
@ -68,6 +71,86 @@ func (p *recordingRoomDirectIMPublisher) counts() map[string]int {
|
||||
return counts
|
||||
}
|
||||
|
||||
func (p *recordingRoomDirectIMPublisher) recordsByType(eventType string) []*roomeventsv1.EventEnvelope {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
records := make([]*roomeventsv1.EventEnvelope, 0)
|
||||
for _, record := range p.records {
|
||||
if record.GetEventType() == eventType {
|
||||
records = append(records, proto.Clone(record).(*roomeventsv1.EventEnvelope))
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func TestJoinRoomDirectIMCarriesDisplayProfileSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)}
|
||||
directIM := newRecordingRoomDirectIMPublisher()
|
||||
svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)
|
||||
|
||||
roomID := "room-join-display-profile"
|
||||
userID := int64(1002)
|
||||
createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, userID),
|
||||
Role: "audience",
|
||||
ActorDisplayProfile: &roomv1.RoomUserDisplayProfile{
|
||||
UserId: userID,
|
||||
Nickname: "Join Snapshot",
|
||||
Avatar: "https://cdn.example/join.png",
|
||||
DisplayUserId: "200002",
|
||||
PrettyDisplayUserId: "888002",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("join room with display profile failed: %v", err)
|
||||
}
|
||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})
|
||||
|
||||
records := directIM.recordsByType("RoomUserJoined")
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("direct IM joined records mismatch: %+v", records)
|
||||
}
|
||||
var joined roomeventsv1.RoomUserJoined
|
||||
if err := proto.Unmarshal(records[0].GetBody(), &joined); err != nil {
|
||||
t.Fatalf("decode direct IM joined body failed: %v", err)
|
||||
}
|
||||
if joined.GetNickname() != "Join Snapshot" || joined.GetAvatar() != "https://cdn.example/join.png" || joined.GetDisplayUserId() != "200002" || joined.GetPrettyDisplayUserId() != "888002" {
|
||||
t.Fatalf("direct IM joined profile mismatch: %+v", &joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatJoinRoomPublishesEntryBroadcastDirectIMOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)}
|
||||
directIM := newRecordingRoomDirectIMPublisher()
|
||||
svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)
|
||||
|
||||
roomID := "room-repeat-join-broadcast"
|
||||
userID := int64(1002)
|
||||
createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, userID)
|
||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})
|
||||
|
||||
if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != 1 {
|
||||
t.Fatalf("first join must write one durable RoomUserJoined outbox, got %d", got)
|
||||
}
|
||||
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, userID),
|
||||
Role: "audience",
|
||||
}); err != nil {
|
||||
t.Fatalf("repeat join failed: %v", err)
|
||||
}
|
||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 2})
|
||||
|
||||
if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != 1 {
|
||||
t.Fatalf("repeat join must not write durable RoomUserJoined outbox, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||
w.lastDebit = req
|
||||
w.debitRequests = append(w.debitRequests, req)
|
||||
@ -273,6 +356,247 @@ func TestSendGiftMirrorsGiftDisplayToDirectIMAndKeepsDurableFact(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchSendGiftPublishesSingleBatchDisplayAndKeepsDurableFacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)}
|
||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-batch-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 20, GiftTypeCode: "normal", BalanceAfter: 980, GiftName: "Clover", GiftAnimationUrl: "https://cdn.example/clover.svga"},
|
||||
{BillingReceiptId: "receipt-batch-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 30, GiftTypeCode: "normal", BalanceAfter: 950, GiftName: "Clover", GiftAnimationUrl: "https://cdn.example/clover.svga"},
|
||||
}}
|
||||
directIM := newRecordingRoomDirectIMPublisher()
|
||||
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
|
||||
|
||||
roomID := "room-gift-batch-direct-im"
|
||||
ownerID := int64(801)
|
||||
firstTargetID := int64(802)
|
||||
secondTargetID := int64(803)
|
||||
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
|
||||
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
|
||||
before := outboxEventCounts(t, ctx, repository)
|
||||
|
||||
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, "gift-batch-direct-im"),
|
||||
TargetType: "user",
|
||||
TargetUserIds: []int64{firstTargetID, secondTargetID},
|
||||
GiftId: "clover",
|
||||
GiftCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch send gift failed: %v", err)
|
||||
}
|
||||
if resp.GetCoinBalanceAfter() != 950 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 {
|
||||
t.Fatalf("batch response must include final balance and batch display: %+v", resp)
|
||||
}
|
||||
if resp.GetBatchDisplay().GetEventId() == "" || resp.GetBatchDisplay().GetTotalGiftValue() != 50 || resp.GetBatchDisplay().GetTargetUserIds()[0] != firstTargetID {
|
||||
t.Fatalf("batch display snapshot mismatch: %+v", resp.GetBatchDisplay())
|
||||
}
|
||||
|
||||
after := outboxEventCounts(t, ctx, repository)
|
||||
if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 2 {
|
||||
t.Fatalf("batch send must keep one durable RoomGiftSent per target, delta=%d counts=%+v", delta, after)
|
||||
}
|
||||
if delta := after["RoomGiftBatchSent"] - before["RoomGiftBatchSent"]; delta != 0 {
|
||||
t.Fatalf("RoomGiftBatchSent is direct display only and must not enter durable outbox, delta=%d counts=%+v", delta, after)
|
||||
}
|
||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{
|
||||
"RoomGiftBatchSent": 1,
|
||||
"RoomHeatChanged": 1,
|
||||
"RoomRankChanged": 1,
|
||||
})
|
||||
counts := directIM.counts()
|
||||
if counts["RoomGiftSent"] != 0 {
|
||||
t.Fatalf("batch display must suppress per-target GiftSent direct IM: %+v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftDisplayModeDoesNotTriggerBatchDisplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 10, 30, 0, 0, time.UTC)}
|
||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-normal-multi-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 20, GiftTypeCode: "normal", BalanceAfter: 980},
|
||||
{BillingReceiptId: "receipt-normal-multi-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 30, GiftTypeCode: "normal", BalanceAfter: 950},
|
||||
}}
|
||||
directIM := newRecordingRoomDirectIMPublisher()
|
||||
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
|
||||
|
||||
roomID := "room-gift-normal-multi-display-mode"
|
||||
ownerID := int64(811)
|
||||
firstTargetID := int64(812)
|
||||
secondTargetID := int64(813)
|
||||
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
|
||||
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
|
||||
|
||||
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, "gift-normal-multi-display-mode"),
|
||||
TargetType: "user",
|
||||
TargetUserIds: []int64{firstTargetID, secondTargetID},
|
||||
GiftId: "clover",
|
||||
GiftCount: 1,
|
||||
DisplayMode: "batch",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normal multi-target send gift failed: %v", err)
|
||||
}
|
||||
if resp.GetBatchDisplay() != nil {
|
||||
t.Fatalf("plain SendGift must not create batch display: %+v", resp.GetBatchDisplay())
|
||||
}
|
||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftSent": 2})
|
||||
}
|
||||
|
||||
func TestBatchLuckyGiftUsesSingleActivityBatchDrawAndDebitCoinBalance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 11, 0, 0, 0, time.UTC)}
|
||||
debits := make([]*walletv1.DebitGiftResponse, 0, 10)
|
||||
for index := 0; index < 10; index++ {
|
||||
debits = append(debits, &walletv1.DebitGiftResponse{
|
||||
BillingReceiptId: fmt.Sprintf("receipt-lucky-batch-%d", index),
|
||||
CoinSpent: 10,
|
||||
ChargeAmount: 10,
|
||||
HeatValue: 10,
|
||||
GiftTypeCode: "super_lucky",
|
||||
BalanceAfter: int64(990 - index*10),
|
||||
})
|
||||
}
|
||||
wallet := &rocketTestWallet{debits: debits}
|
||||
luckyGift := &batchLuckyGiftTestClient{drawDelay: 20 * time.Millisecond, createdAtBase: now.Now().UnixMilli()}
|
||||
directIM := newRecordingRoomDirectIMPublisher()
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-batch-lucky-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
RoomDisplayPublisher: directIM,
|
||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
|
||||
|
||||
roomID := "room-batch-lucky"
|
||||
ownerID := int64(901)
|
||||
targetUserIDs := make([]int64, 0, 10)
|
||||
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||
for offset := int64(0); offset < 10; offset++ {
|
||||
userID := int64(902) + offset
|
||||
targetUserIDs = append(targetUserIDs, userID)
|
||||
joinRocketRoom(t, ctx, svc, roomID, userID)
|
||||
}
|
||||
|
||||
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, "batch-lucky"),
|
||||
TargetType: "user",
|
||||
TargetUserIds: targetUserIDs,
|
||||
GiftId: "super-lucky-clover",
|
||||
GiftCount: 1,
|
||||
PoolId: "super_lucky",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch lucky gift failed: %v", err)
|
||||
}
|
||||
checks, singleDraws, batchDraws, batchCalls := luckyGift.stats()
|
||||
if checks != 1 || singleDraws != 0 || batchDraws != 10 || batchCalls != 1 {
|
||||
t.Fatalf("batch lucky must check once and call one activity batch draw: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls)
|
||||
}
|
||||
if len(wallet.balanceRequests) != 0 {
|
||||
t.Fatalf("batch lucky must not query final sender coin balance: %+v", wallet.balanceRequests)
|
||||
}
|
||||
if resp.GetCoinBalanceAfter() != 900 || resp.GetLuckyGift().GetCoinBalanceAfter() != 0 {
|
||||
t.Fatalf("batch lucky HTTP balance must stay at debit result and lucky balance must stay empty: %+v", resp)
|
||||
}
|
||||
if len(resp.GetLuckyGifts()) != 10 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 10 {
|
||||
t.Fatalf("batch lucky response must keep per-target lucky details and batch display: %+v", resp)
|
||||
}
|
||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
|
||||
}
|
||||
|
||||
func TestBatchLuckyGiftUpdatesEachMicGiftValueAndBatchDisplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 12, 0, 0, 0, time.UTC)}
|
||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-batch-lucky-seat-1", CoinSpent: 60, ChargeAmount: 60, HeatValue: 60, GiftTypeCode: "super_lucky", BalanceAfter: 940},
|
||||
{BillingReceiptId: "receipt-batch-lucky-seat-2", CoinSpent: 40, ChargeAmount: 40, HeatValue: 40, GiftTypeCode: "super_lucky", BalanceAfter: 900},
|
||||
}}
|
||||
luckyGift := &batchLuckyGiftTestClient{createdAtBase: now.Now().UnixMilli()}
|
||||
directIM := newRecordingRoomDirectIMPublisher()
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-batch-lucky-mic-value-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
Clock: now,
|
||||
RoomDisplayPublisher: directIM,
|
||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
|
||||
|
||||
roomID := "room-batch-lucky-mic-value"
|
||||
ownerID := int64(931)
|
||||
firstTargetID := int64(932)
|
||||
secondTargetID := int64(933)
|
||||
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
|
||||
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||||
Meta: rocketMeta(roomID, firstTargetID, "batch-lucky-first-mic-up"),
|
||||
SeatNo: 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("first target mic up failed: %v", err)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||||
Meta: rocketMeta(roomID, secondTargetID, "batch-lucky-second-mic-up"),
|
||||
SeatNo: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("second target mic up failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, "batch-lucky-mic-value"),
|
||||
TargetType: "user",
|
||||
TargetUserIds: []int64{firstTargetID, secondTargetID},
|
||||
GiftId: "super-lucky-seat-gift",
|
||||
GiftCount: 1,
|
||||
PoolId: "super_lucky",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch lucky gift with mic targets failed: %v", err)
|
||||
}
|
||||
if checks, singleDraws, batchDraws, batchCalls := luckyGift.stats(); checks != 1 || singleDraws != 0 || batchDraws != 2 || batchCalls != 1 {
|
||||
t.Fatalf("batch lucky mic gift must check once and call one activity batch draw: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls)
|
||||
}
|
||||
if user := onlineUserByID(resp.GetRoom(), firstTargetID); user == nil || user.GetGiftValue() != 60 {
|
||||
t.Fatalf("first target online gift_value mismatch: %+v", user)
|
||||
}
|
||||
if user := onlineUserByID(resp.GetRoom(), secondTargetID); user == nil || user.GetGiftValue() != 40 {
|
||||
t.Fatalf("second target online gift_value mismatch: %+v", user)
|
||||
}
|
||||
if seat := seatByNo(resp.GetRoom(), 2); seat == nil || seat.GetUserId() != firstTargetID || seat.GetGiftValue() != 60 {
|
||||
t.Fatalf("first target mic seat gift_value mismatch: %+v", seat)
|
||||
}
|
||||
if seat := seatByNo(resp.GetRoom(), 3); seat == nil || seat.GetUserId() != secondTargetID || seat.GetGiftValue() != 40 {
|
||||
t.Fatalf("second target mic seat gift_value mismatch: %+v", seat)
|
||||
}
|
||||
if resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 || len(resp.GetLuckyGifts()) != 2 {
|
||||
t.Fatalf("batch lucky mic response must keep batch display and per-target lucky results: %+v", resp)
|
||||
}
|
||||
if first := resp.GetBatchDisplay().GetTargets()[0]; first.GetTargetUserId() != firstTargetID || first.GetTargetGiftValue() != 60 || first.GetLuckyGift() == nil {
|
||||
t.Fatalf("first batch display target mismatch: %+v", first)
|
||||
}
|
||||
if second := resp.GetBatchDisplay().GetTargets()[1]; second.GetTargetUserId() != secondTargetID || second.GetTargetGiftValue() != 40 || second.GetLuckyGift() == nil {
|
||||
t.Fatalf("second batch display target mismatch: %+v", second)
|
||||
}
|
||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
|
||||
batchEvents := roomDirectIMGiftBatchSentEvents(t, directIM)
|
||||
if len(batchEvents) != 1 || len(batchEvents[0].GetTargets()) != 2 {
|
||||
t.Fatalf("batch lucky mic gift must publish one batch IM with two targets: %+v", batchEvents)
|
||||
}
|
||||
if first := batchEvents[0].GetTargets()[0]; first.GetTargetUserId() != firstTargetID || first.GetTargetGiftValue() != 60 || first.GetLuckyGift() == nil {
|
||||
t.Fatalf("first batch IM target mismatch: %+v", first)
|
||||
}
|
||||
if second := batchEvents[0].GetTargets()[1]; second.GetTargetUserId() != secondTargetID || second.GetTargetGiftValue() != 40 || second.GetLuckyGift() == nil {
|
||||
t.Fatalf("second batch IM target mismatch: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
|
||||
w.grants = append(w.grants, req)
|
||||
return &walletv1.ResourceGrantResponse{
|
||||
@ -284,6 +608,141 @@ func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.G
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *rocketTestWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||
w.balanceRequests = append(w.balanceRequests, req)
|
||||
if len(w.balanceResponses) == 0 {
|
||||
return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{
|
||||
{AssetType: "COIN"},
|
||||
}}, nil
|
||||
}
|
||||
next := w.balanceResponses[0]
|
||||
w.balanceResponses = w.balanceResponses[1:]
|
||||
return proto.Clone(next).(*walletv1.GetBalancesResponse), nil
|
||||
}
|
||||
|
||||
type batchLuckyGiftTestClient struct {
|
||||
mu sync.Mutex
|
||||
checks []*activityv1.CheckLuckyGiftRequest
|
||||
draws []*activityv1.ExecuteLuckyGiftDrawRequest
|
||||
batchDraws []*activityv1.BatchExecuteLuckyGiftDrawRequest
|
||||
inFlight int
|
||||
maxInFlight int
|
||||
drawDelay time.Duration
|
||||
createdAtBase int64
|
||||
}
|
||||
|
||||
func (c *batchLuckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
|
||||
c.mu.Lock()
|
||||
c.checks = append(c.checks, req)
|
||||
c.mu.Unlock()
|
||||
return &activityv1.CheckLuckyGiftResponse{
|
||||
Enabled: true,
|
||||
Reason: "enabled",
|
||||
GiftId: req.GetGiftId(),
|
||||
PoolId: req.GetPoolId(),
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *batchLuckyGiftTestClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
|
||||
c.mu.Lock()
|
||||
c.draws = append(c.draws, req)
|
||||
drawIndex := len(c.draws)
|
||||
c.inFlight++
|
||||
if c.inFlight > c.maxInFlight {
|
||||
c.maxInFlight = c.inFlight
|
||||
}
|
||||
c.mu.Unlock()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.inFlight--
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
if c.drawDelay > 0 {
|
||||
timer := time.NewTimer(c.drawDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
meta := req.GetLuckyGift()
|
||||
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: &activityv1.LuckyGiftDrawResult{
|
||||
DrawId: fmt.Sprintf("draw-%d", meta.GetTargetUserId()),
|
||||
CommandId: meta.GetCommandId(),
|
||||
PoolId: meta.GetPoolId(),
|
||||
GiftId: meta.GetGiftId(),
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
SelectedTierId: "tier",
|
||||
MultiplierPpm: 2_000_000,
|
||||
BaseRewardCoins: 100,
|
||||
EffectiveRewardCoins: 100,
|
||||
RewardStatus: "granted",
|
||||
WalletTransactionId: fmt.Sprintf("wallet-tx-%d", drawIndex),
|
||||
CoinBalanceAfter: int64(9000 + drawIndex),
|
||||
CreatedAtMs: c.createdAtBase + int64(drawIndex),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c *batchLuckyGiftTestClient) BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) {
|
||||
c.mu.Lock()
|
||||
c.batchDraws = append(c.batchDraws, req)
|
||||
drawOffset := len(c.draws)
|
||||
c.inFlight++
|
||||
if c.inFlight > c.maxInFlight {
|
||||
c.maxInFlight = c.inFlight
|
||||
}
|
||||
c.mu.Unlock()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.inFlight--
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
if c.drawDelay > 0 {
|
||||
timer := time.NewTimer(c.drawDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
resp := &activityv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*activityv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts()))}
|
||||
for index, meta := range req.GetLuckyGifts() {
|
||||
drawIndex := drawOffset + index + 1
|
||||
resp.Results = append(resp.Results, &activityv1.LuckyGiftDrawResult{
|
||||
DrawId: fmt.Sprintf("draw-%d", meta.GetTargetUserId()),
|
||||
CommandId: meta.GetCommandId(),
|
||||
PoolId: meta.GetPoolId(),
|
||||
GiftId: meta.GetGiftId(),
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
SelectedTierId: "tier",
|
||||
MultiplierPpm: 2_000_000,
|
||||
BaseRewardCoins: 100,
|
||||
EffectiveRewardCoins: 100,
|
||||
RewardStatus: "pending",
|
||||
CreatedAtMs: c.createdAtBase + int64(drawIndex),
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *batchLuckyGiftTestClient) stats() (checks int, singleDraws int, batchDraws int, batchCalls int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
totalBatchDraws := 0
|
||||
for _, req := range c.batchDraws {
|
||||
totalBatchDraws += len(req.GetLuckyGifts())
|
||||
}
|
||||
return len(c.checks), len(c.draws), totalBatchDraws, len(c.batchDraws)
|
||||
}
|
||||
|
||||
type rocketLaunchScheduleRecorder struct {
|
||||
schedules []integration.RoomRocketLaunchSchedule
|
||||
}
|
||||
@ -1062,6 +1521,25 @@ func roomDirectIMRocketProgressEvents(t *testing.T, publisher *recordingRoomDire
|
||||
return events
|
||||
}
|
||||
|
||||
func roomDirectIMGiftBatchSentEvents(t *testing.T, publisher *recordingRoomDirectIMPublisher) []*roomeventsv1.RoomGiftBatchSent {
|
||||
t.Helper()
|
||||
publisher.mu.Lock()
|
||||
records := append([]*roomeventsv1.EventEnvelope(nil), publisher.records...)
|
||||
publisher.mu.Unlock()
|
||||
events := make([]*roomeventsv1.RoomGiftBatchSent, 0)
|
||||
for _, record := range records {
|
||||
if record.GetEventType() != "RoomGiftBatchSent" {
|
||||
continue
|
||||
}
|
||||
var event roomeventsv1.RoomGiftBatchSent
|
||||
if err := proto.Unmarshal(record.GetBody(), &event); err != nil {
|
||||
t.Fatalf("decode directIM batch gift failed: %v", err)
|
||||
}
|
||||
events = append(events, &event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func roomGiftSentEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomGiftSent {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@ -166,6 +166,10 @@ type mutationResult struct {
|
||||
luckyGift *roomv1.LuckyGiftDrawResult
|
||||
// luckyGifts 是多目标幸运礼物的逐接收方抽奖结果;单目标时只包含 luckyGift。
|
||||
luckyGifts []*roomv1.LuckyGiftDrawResult
|
||||
// batchDisplay 是 batch-send 的发送方本地展示快照;它不替代逐目标 RoomGiftSent 事实。
|
||||
batchDisplay *roomv1.SendGiftBatchDisplay
|
||||
// suppressDirectIMEventTypes 只影响提交后的低延迟展示 lane,不能改变 durable outbox 事实。
|
||||
suppressDirectIMEventTypes map[string]bool
|
||||
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log,用于恢复。
|
||||
commandPayload []byte
|
||||
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;保留旧字段兼容命令结果,room UI 现在由提交后 direct IM 发送。
|
||||
|
||||
@ -3,6 +3,7 @@ package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
@ -391,6 +392,10 @@ func (s *Server) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*ro
|
||||
}); forwarded {
|
||||
return resp, err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(req.GetDisplayMode()), "batch") {
|
||||
// protobuf 仍保持一个 RPC,领域层显式分派 batch 入口,避免普通 SendGift 被 display_mode 偷偷改变展示/IM 策略。
|
||||
return mapServiceResult(s.svc.SendGiftBatch(ctx, req))
|
||||
}
|
||||
return mapServiceResult(s.svc.SendGift(ctx, req))
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user