飘屏后端决定

This commit is contained in:
zhx 2026-05-26 05:57:08 +08:00
parent 113c5096e0
commit e7b6ed9fb7
12 changed files with 233 additions and 71 deletions

View File

@ -9944,21 +9944,23 @@ func (x *UpsertAchievementDefinitionResponse) GetCreated() bool {
} }
type LuckyGiftMeta struct { type LuckyGiftMeta struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
DeviceId string `protobuf:"bytes,4,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` DeviceId string `protobuf:"bytes,4,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
RoomId string `protobuf:"bytes,5,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` RoomId string `protobuf:"bytes,5,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
AnchorId string `protobuf:"bytes,6,opt,name=anchor_id,json=anchorId,proto3" json:"anchor_id,omitempty"` AnchorId string `protobuf:"bytes,6,opt,name=anchor_id,json=anchorId,proto3" json:"anchor_id,omitempty"`
GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
CoinSpent int64 `protobuf:"varint,8,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"` CoinSpent int64 `protobuf:"varint,8,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"`
PaidAtMs int64 `protobuf:"varint,9,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"` PaidAtMs int64 `protobuf:"varint,9,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"`
PoolId string `protobuf:"bytes,10,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` PoolId string `protobuf:"bytes,10,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
TargetUserId int64 `protobuf:"varint,11,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` TargetUserId int64 `protobuf:"varint,11,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
GiftCount int32 `protobuf:"varint,12,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"` GiftCount int32 `protobuf:"varint,12,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
unknownFields protoimpl.UnknownFields // visible_region_id 来自 room-service 的房间可见区域,用于中奖后的区域 IM 飘屏。
sizeCache protoimpl.SizeCache VisibleRegionId int64 `protobuf:"varint,13,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *LuckyGiftMeta) Reset() { func (x *LuckyGiftMeta) Reset() {
@ -10075,6 +10077,13 @@ func (x *LuckyGiftMeta) GetGiftCount() int32 {
return 0 return 0
} }
func (x *LuckyGiftMeta) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
type LuckyGiftTier struct { type LuckyGiftTier struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Pool string `protobuf:"bytes,1,opt,name=pool,proto3" json:"pool,omitempty"` Pool string `protobuf:"bytes,1,opt,name=pool,proto3" json:"pool,omitempty"`
@ -12615,7 +12624,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"conditions\"\x8b\x01\n" + "conditions\"\x8b\x01\n" +
"#UpsertAchievementDefinitionResponse\x12J\n" + "#UpsertAchievementDefinitionResponse\x12J\n" +
"\vachievement\x18\x01 \x01(\v2(.hyapp.activity.v1.AchievementDefinitionR\vachievement\x12\x18\n" + "\vachievement\x18\x01 \x01(\v2(.hyapp.activity.v1.AchievementDefinitionR\vachievement\x12\x18\n" +
"\acreated\x18\x02 \x01(\bR\acreated\"\x82\x03\n" + "\acreated\x18\x02 \x01(\bR\acreated\"\xae\x03\n" +
"\rLuckyGiftMeta\x122\n" + "\rLuckyGiftMeta\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1d\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1d\n" +
"\n" + "\n" +
@ -12633,7 +12642,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
" \x01(\tR\x06poolId\x12$\n" + " \x01(\tR\x06poolId\x12$\n" +
"\x0etarget_user_id\x18\v \x01(\x03R\ftargetUserId\x12\x1d\n" + "\x0etarget_user_id\x18\v \x01(\x03R\ftargetUserId\x12\x1d\n" +
"\n" + "\n" +
"gift_count\x18\f \x01(\x05R\tgiftCount\"\xe0\x01\n" + "gift_count\x18\f \x01(\x05R\tgiftCount\x12*\n" +
"\x11visible_region_id\x18\r \x01(\x03R\x0fvisibleRegionId\"\xe0\x01\n" +
"\rLuckyGiftTier\x12\x12\n" + "\rLuckyGiftTier\x12\x12\n" +
"\x04pool\x18\x01 \x01(\tR\x04pool\x12\x17\n" + "\x04pool\x18\x01 \x01(\tR\x04pool\x12\x17\n" +
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12!\n" + "\atier_id\x18\x02 \x01(\tR\x06tierId\x12!\n" +

View File

@ -1125,6 +1125,8 @@ message LuckyGiftMeta {
string pool_id = 10; string pool_id = 10;
int64 target_user_id = 11; int64 target_user_id = 11;
int32 gift_count = 12; int32 gift_count = 12;
// visible_region_id room-service IM
int64 visible_region_id = 13;
} }
message LuckyGiftTier { message LuckyGiftTier {

View File

@ -121,10 +121,6 @@ func New(cfg config.Config) (*App, error) {
} }
broadcastPublisher = tencentClient broadcastPublisher = tencentClient
} }
luckyGiftSvc := luckygiftservice.New(repository,
luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)),
luckygiftservice.WithRoomPublisher(tencentClient),
)
if cfg.Broadcast.Enabled && broadcastPublisher == nil { if cfg.Broadcast.Enabled && broadcastPublisher == nil {
_ = walletConn.Close() _ = walletConn.Close()
_ = userConn.Close() _ = userConn.Close()
@ -141,6 +137,11 @@ func New(cfg config.Config) (*App, error) {
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval, WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup, EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
}, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn)) }, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn))
luckyGiftSvc := luckygiftservice.New(repository,
luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)),
luckygiftservice.WithRoomPublisher(tencentClient),
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
)
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc)) activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc))
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc)) activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc))
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc, growthSvc, achievementSvc)) activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc, growthSvc, achievementSvc))

View File

@ -14,6 +14,8 @@ const (
TypeRedPacket = "red_packet" TypeRedPacket = "red_packet"
// TypeRoomTreasure 是语音房宝箱满能量后的开箱倒计时播报。 // TypeRoomTreasure 是语音房宝箱满能量后的开箱倒计时播报。
TypeRoomTreasure = "room_treasure" TypeRoomTreasure = "room_treasure"
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前默认 1 倍及以上即进入播报 outbox。
TypeLuckyGiftBigWin = "lucky_gift_big_win"
// StatusPending 表示消息已持久化,尚未被 worker claim。 // StatusPending 表示消息已持久化,尚未被 worker claim。
StatusPending = "pending" StatusPending = "pending"

View File

@ -84,17 +84,18 @@ type CheckCommand struct {
} }
type DrawCommand struct { type DrawCommand struct {
CommandID string CommandID string
PoolID string PoolID string
UserID int64 UserID int64
TargetUserID int64 TargetUserID int64
DeviceID string DeviceID string
RoomID string RoomID string
AnchorID string AnchorID string
GiftID string GiftID string
GiftCount int32 GiftCount int32
CoinSpent int64 CoinSpent int64
PaidAtMS int64 PaidAtMS int64
VisibleRegionID int64
} }
type CheckResult struct { type CheckResult struct {

View File

@ -122,8 +122,8 @@ func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64
if len(values) == 0 { if len(values) == 0 {
values = defaultMultiplierPPMs() values = defaultMultiplierPPMs()
} }
seen := map[int64]bool{0: true} seen := map[int64]bool{}
out := []int64{0} out := make([]int64, 0, len(values))
for _, value := range values { for _, value := range values {
if value < 0 || seen[value] { if value < 0 || seen[value] {
continue continue
@ -131,6 +131,9 @@ func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64
seen[value] = true seen[value] = true
out = append(out, value) out = append(out, value)
} }
if len(out) == 0 {
out = defaultMultiplierPPMs()
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out return out
} }

View File

@ -41,6 +41,19 @@ func TestNormalizeTiersBuildsMultiplierDrivenTiers(t *testing.T) {
} }
} }
func TestNormalizeTiersDoesNotForceZeroMultiplier(t *testing.T) {
tiers, multipliers := normalizeTiers(nil, []int64{1_000_000, 2_000_000}, 500, 100)
if len(multipliers) != 2 || multipliers[0] != 1_000_000 || multipliers[1] != 2_000_000 {
t.Fatalf("expected positive-only multiplier list to stay positive-only, got %#v", multipliers)
}
for _, tier := range tiers {
if tier.MultiplierPPM == 0 || tier.RewardCoins == 0 {
t.Fatalf("positive-only test config must not generate none tier: %#v", tier)
}
}
}
func TestValidateConfigRejectsMultiplierListBelowTargetRTP(t *testing.T) { func TestValidateConfigRejectsMultiplierListBelowTargetRTP(t *testing.T) {
config := DefaultConfig("hyapp", "pool_low") config := DefaultConfig("hyapp", "pool_low")
config.MultiplierPPMs = []int64{0, 500_000} config.MultiplierPPMs = []int64{0, 500_000}

View File

@ -14,7 +14,9 @@ import (
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
domain "hyapp/services/activity-service/internal/domain/luckygift" domain "hyapp/services/activity-service/internal/domain/luckygift"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
) )
// Repository 是幸运礼物唯一持久化边界RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。 // Repository 是幸运礼物唯一持久化边界RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
@ -44,12 +46,18 @@ type RoomPublisher interface {
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
} }
// RegionBroadcaster 只写 activity-service 自有的区域播报 outbox真正发腾讯 IM 仍由 broadcast worker 补偿。
type RegionBroadcaster interface {
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
}
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。 // Service 承载幸运礼物查询、抽奖命令和后台规则配置。
type Service struct { type Service struct {
repository Repository repository Repository
wallet WalletClient wallet WalletClient
publisher RoomPublisher publisher RoomPublisher
now func() time.Time broadcaster RegionBroadcaster
now func() time.Time
} }
type Option func(*Service) type Option func(*Service)
@ -66,6 +74,12 @@ func WithRoomPublisher(publisher RoomPublisher) Option {
} }
} }
func WithRegionBroadcaster(broadcaster RegionBroadcaster) Option {
return func(s *Service) {
s.broadcaster = broadcaster
}
}
func New(repository Repository, options ...Option) *Service { func New(repository Repository, options ...Option) *Service {
service := &Service{repository: repository, now: time.Now} service := &Service{repository: repository, now: time.Now}
for _, option := range options { for _, option := range options {
@ -194,6 +208,14 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
if s.publisher == nil { if s.publisher == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited) return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited)
} }
if shouldPublishLuckyGiftRegionBroadcast(payload) {
if s.broadcaster == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift region broadcaster is not configured"), credited)
}
if err := s.publishLuckyGiftRegionBroadcast(eventCtx, payload); err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, credited)
}
}
if err := s.publishLuckyGiftDrawn(eventCtx, payload, options); err != nil { if err := s.publishLuckyGiftDrawn(eventCtx, payload, options); err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, credited) return s.markOutboxFailed(eventCtx, event, options, err, credited)
} }
@ -220,6 +242,23 @@ func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDr
}) })
} }
func (s *Service) publishLuckyGiftRegionBroadcast(ctx context.Context, payload luckyGiftDrawnPayload) error {
if payload.VisibleRegionID <= 0 {
return nil
}
body, err := json.Marshal(luckyGiftRegionBroadcastPayload(payload, s.now().UTC().UnixMilli()))
if err != nil {
return err
}
_, err = s.broadcaster.PublishRegionBroadcast(ctx, broadcastservice.PublishInput{
EventID: "lucky_gift_big_win:" + payload.DrawID,
BroadcastType: broadcastdomain.TypeLuckyGiftBigWin,
RegionID: payload.VisibleRegionID,
PayloadJSON: string(body),
})
return err
}
func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox, options WorkerOptions, cause error, alreadyCredited bool) error { func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox, options WorkerOptions, cause error, alreadyCredited bool) error {
nowMS := s.now().UTC().UnixMilli() nowMS := s.now().UTC().UnixMilli()
nextRetryCount := event.RetryCount + 1 nextRetryCount := event.RetryCount + 1
@ -321,6 +360,7 @@ type luckyGiftDrawnPayload struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
SenderUserID int64 `json:"sender_user_id"` SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"` TargetUserID int64 `json:"target_user_id"`
VisibleRegionID int64 `json:"visible_region_id"`
CoinSpent int64 `json:"coin_spent"` CoinSpent int64 `json:"coin_spent"`
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"` ExperiencePool string `json:"experience_pool"`
@ -335,6 +375,49 @@ type luckyGiftDrawnPayload struct {
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
} }
func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool {
// 当前产品策略先把 1 倍作为默认区域飘屏阈值;后续接后台配置时只替换这里的阈值来源。
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= 1_000_000
}
func luckyGiftRegionBroadcastPayload(payload luckyGiftDrawnPayload, sentAtMS int64) map[string]any {
return map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_big_win:" + payload.DrawID,
"broadcast_type": broadcastdomain.TypeLuckyGiftBigWin,
"scope": broadcastdomain.ScopeRegion,
"app_code": payload.AppCode,
"region_id": payload.VisibleRegionID,
"visible_region_id": payload.VisibleRegionID,
"draw_id": payload.DrawID,
"command_id": payload.CommandID,
"pool_id": payload.PoolID,
"room_id": payload.RoomID,
"gift_id": payload.GiftID,
"gift_count": payload.GiftCount,
"user_id": payload.UserID,
"sender_user_id": payload.SenderUserID,
"target_user_id": payload.TargetUserID,
"coin_spent": payload.CoinSpent,
"rule_version": payload.RuleVersion,
"experience_pool": payload.ExperiencePool,
"selected_tier_id": payload.SelectedTierID,
"multiplier_ppm": payload.MultiplierPPM,
"base_reward_coins": payload.BaseRewardCoins,
"room_atmosphere_reward_coins": payload.RoomAtmosphereRewardCoins,
"activity_subsidy_coins": payload.ActivitySubsidyCoins,
"effective_reward_coins": payload.EffectiveRewardCoins,
"stage_feedback": payload.StageFeedback,
"high_multiplier": payload.HighMultiplier,
"created_at_ms": payload.CreatedAtMS,
"sent_at_ms": sentAtMS,
"action": map[string]any{
"type": "enter_room",
"room_id": payload.RoomID,
},
}
}
func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error) { func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error) {
var payload luckyGiftDrawnPayload var payload luckyGiftDrawnPayload
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {

View File

@ -583,6 +583,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
"user_id": input.Command.UserID, "user_id": input.Command.UserID,
"sender_user_id": input.Command.UserID, "sender_user_id": input.Command.UserID,
"target_user_id": input.Command.TargetUserID, "target_user_id": input.Command.TargetUserID,
"visible_region_id": input.Command.VisibleRegionID,
"room_id": input.Command.RoomID, "room_id": input.Command.RoomID,
"gift_id": input.Command.GiftID, "gift_id": input.Command.GiftID,
"gift_count": input.Command.GiftCount, "gift_count": input.Command.GiftCount,
@ -1332,15 +1333,11 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
limited := map[string]bool{} limited := map[string]bool{}
baseCandidates := make([]luckyCandidate, 0, len(config.Tiers)) baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
hasZeroCandidate := false
var totalWeight int64 var totalWeight int64
for _, tier := range config.Tiers { for _, tier := range config.Tiers {
if tier.Pool != pool || !tier.Enabled { if tier.Pool != pool || !tier.Enabled {
continue continue
} }
if tier.RewardCoins == 0 {
hasZeroCandidate = true
}
highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier
if highMultiplier && !config.LargeTierEnabled { if highMultiplier && !config.LargeTierEnabled {
limited["large_tier_disabled"] = true limited["large_tier_disabled"] = true
@ -1371,9 +1368,6 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
HighMultiplier: highMultiplier, HighMultiplier: highMultiplier,
}) })
} }
if minRequired == 0 && !hasZeroCandidate {
baseCandidates = append(baseCandidates, luckyCandidate{TierID: pool + "_none", Source: domain.SourceBaseRTP, Pool: pool})
}
candidates := luckyAutoWeightedBaseCandidates(baseCandidates, minRequired, globalWindow, giftWindow) candidates := luckyAutoWeightedBaseCandidates(baseCandidates, minRequired, globalWindow, giftWindow)
for _, candidate := range candidates { for _, candidate := range candidates {
totalWeight += candidate.Weight totalWeight += candidate.Weight
@ -1673,9 +1667,6 @@ func luckyMultiplierPPMsFromTiers(tiers []domain.Tier, giftPrice int64) []int64
seen[multiplier] = true seen[multiplier] = true
out = append(out, multiplier) out = append(out, multiplier)
} }
if !seen[0] {
out = append(out, 0)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out return out
} }

View File

@ -100,6 +100,60 @@ func TestLuckyAutoWeightedBaseCandidatesDoesNotDiluteDeficitAcrossLargeWindow(t
} }
} }
func TestSelectLuckyCandidateDoesNotInjectNoneTier(t *testing.T) {
config := domain.Config{
GiftID: domain.DefaultPoolID,
PoolID: domain.DefaultPoolID,
GiftPrice: 10,
TargetRTPPPM: 950_000,
HighMultiplier: 100,
LargeTierEnabled: true,
PlatformPoolWeightPPM: 200_000,
RoomPoolWeightPPM: 300_000,
GiftPoolWeightPPM: 500_000,
MaxSinglePayout: 10_000,
UserHourlyPayoutCap: 10_000,
UserDailyPayoutCap: 10_000,
DeviceDailyPayoutCap: 10_000,
RoomHourlyPayoutCap: 10_000,
AnchorDailyPayoutCap: 10_000,
Tiers: []domain.Tier{
{Pool: domain.PoolAdvanced, TierID: "advanced_1x", RewardCoins: 10, MultiplierPPM: 1_000_000, Enabled: true},
{Pool: domain.PoolAdvanced, TierID: "advanced_2x", RewardCoins: 20, MultiplierPPM: 2_000_000, Enabled: true},
},
}
window := luckyRTPWindow{ControlDraws: 100, TargetRTPPPM: 950_000}
pool := luckyPool{Balance: 1_000_000}
counters := map[string]luckyCounter{
luckyCounterKey("user", "hour"): {},
luckyCounterKey("user", "day"): {},
luckyCounterKey("device", "day"): {},
luckyCounterKey("room", "hour"): {},
luckyCounterKey("anchor", "day"): {},
}
candidate, _, err := (&Repository{}).selectLuckyCandidate(
config,
domain.PoolAdvanced,
false,
window,
window,
pool,
pool,
pool,
luckyPool{},
0,
counters,
20,
)
if err != nil {
t.Fatalf("select positive-only candidate failed: %v", err)
}
if candidate.BaseReward <= 0 || candidate.MultiplierPPM <= 0 {
t.Fatalf("positive-only config should not draw implicit none tier: %#v", candidate)
}
}
func TestLuckyInitialRewardStatusGrantsZeroRewardWithoutOutbox(t *testing.T) { func TestLuckyInitialRewardStatusGrantsZeroRewardWithoutOutbox(t *testing.T) {
if status := luckyInitialRewardStatus(luckyCandidate{TierID: "none"}, false); status != domain.StatusGranted { if status := luckyInitialRewardStatus(luckyCandidate{TierID: "none"}, false); status != domain.StatusGranted {
t.Fatalf("zero reward without presentation should be granted, got %s", status) t.Fatalf("zero reward without presentation should be granted, got %s", status)

View File

@ -48,17 +48,18 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
meta := req.GetLuckyGift() meta := req.GetLuckyGift()
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode())
result, err := s.svc.Draw(ctx, domain.DrawCommand{ result, err := s.svc.Draw(ctx, domain.DrawCommand{
CommandID: meta.GetCommandId(), CommandID: meta.GetCommandId(),
PoolID: meta.GetPoolId(), PoolID: meta.GetPoolId(),
UserID: meta.GetUserId(), UserID: meta.GetUserId(),
TargetUserID: meta.GetTargetUserId(), TargetUserID: meta.GetTargetUserId(),
DeviceID: meta.GetDeviceId(), DeviceID: meta.GetDeviceId(),
RoomID: meta.GetRoomId(), RoomID: meta.GetRoomId(),
AnchorID: meta.GetAnchorId(), AnchorID: meta.GetAnchorId(),
GiftID: meta.GetGiftId(), GiftID: meta.GetGiftId(),
GiftCount: meta.GetGiftCount(), GiftCount: meta.GetGiftCount(),
CoinSpent: meta.GetCoinSpent(), CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(), PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
}) })
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)

View File

@ -126,18 +126,19 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
if luckyEnabled { if luckyEnabled {
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{ drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: &activityv1.LuckyGiftMeta{ LuckyGift: &activityv1.LuckyGiftMeta{
Meta: activityMetaFromRoom(ctx, req.GetMeta()), Meta: activityMetaFromRoom(ctx, req.GetMeta()),
CommandId: cmd.ID(), CommandId: cmd.ID(),
UserId: cmd.ActorUserID(), UserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID, TargetUserId: cmd.TargetUserID,
DeviceId: luckyGiftDeviceID(cmd), DeviceId: luckyGiftDeviceID(cmd),
RoomId: cmd.RoomID(), RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta), AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID, GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount, GiftCount: cmd.GiftCount,
CoinSpent: billing.GetCoinSpent(), CoinSpent: billing.GetCoinSpent(),
PaidAtMs: now.UTC().UnixMilli(), PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID, PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
}, },
}) })
if err != nil { if err != nil {