飘屏后端决定

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

@ -9957,6 +9957,8 @@ type LuckyGiftMeta struct {
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"`
GiftCount int32 `protobuf:"varint,12,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
// visible_region_id 来自 room-service 的房间可见区域,用于中奖后的区域 IM 飘屏。
VisibleRegionId int64 `protobuf:"varint,13,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -10075,6 +10077,13 @@ func (x *LuckyGiftMeta) GetGiftCount() int32 {
return 0
}
func (x *LuckyGiftMeta) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
type LuckyGiftTier struct {
state protoimpl.MessageState `protogen:"open.v1"`
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" +
"#UpsertAchievementDefinitionResponse\x12J\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" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1d\n" +
"\n" +
@ -12633,7 +12642,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
" \x01(\tR\x06poolId\x12$\n" +
"\x0etarget_user_id\x18\v \x01(\x03R\ftargetUserId\x12\x1d\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" +
"\x04pool\x18\x01 \x01(\tR\x04pool\x12\x17\n" +
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12!\n" +

View File

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

View File

@ -121,10 +121,6 @@ func New(cfg config.Config) (*App, error) {
}
broadcastPublisher = tencentClient
}
luckyGiftSvc := luckygiftservice.New(repository,
luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)),
luckygiftservice.WithRoomPublisher(tencentClient),
)
if cfg.Broadcast.Enabled && broadcastPublisher == nil {
_ = walletConn.Close()
_ = userConn.Close()
@ -141,6 +137,11 @@ func New(cfg config.Config) (*App, error) {
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
}, 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.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc))
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc, growthSvc, achievementSvc))

View File

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

View File

@ -95,6 +95,7 @@ type DrawCommand struct {
GiftCount int32
CoinSpent int64
PaidAtMS int64
VisibleRegionID int64
}
type CheckResult struct {

View File

@ -122,8 +122,8 @@ func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64
if len(values) == 0 {
values = defaultMultiplierPPMs()
}
seen := map[int64]bool{0: true}
out := []int64{0}
seen := map[int64]bool{}
out := make([]int64, 0, len(values))
for _, value := range values {
if value < 0 || seen[value] {
continue
@ -131,6 +131,9 @@ func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64
seen[value] = true
out = append(out, value)
}
if len(out) == 0 {
out = defaultMultiplierPPMs()
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
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) {
config := DefaultConfig("hyapp", "pool_low")
config.MultiplierPPMs = []int64{0, 500_000}

View File

@ -14,7 +14,9 @@ import (
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
domain "hyapp/services/activity-service/internal/domain/luckygift"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
)
// Repository 是幸运礼物唯一持久化边界RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
@ -44,11 +46,17 @@ type RoomPublisher interface {
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 承载幸运礼物查询、抽奖命令和后台规则配置。
type Service struct {
repository Repository
wallet WalletClient
publisher RoomPublisher
broadcaster RegionBroadcaster
now func() time.Time
}
@ -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 {
service := &Service{repository: repository, now: time.Now}
for _, option := range options {
@ -194,6 +208,14 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
if s.publisher == nil {
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 {
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 {
nowMS := s.now().UTC().UnixMilli()
nextRetryCount := event.RetryCount + 1
@ -321,6 +360,7 @@ type luckyGiftDrawnPayload struct {
UserID int64 `json:"user_id"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
VisibleRegionID int64 `json:"visible_region_id"`
CoinSpent int64 `json:"coin_spent"`
RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"`
@ -335,6 +375,49 @@ type luckyGiftDrawnPayload struct {
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) {
var payload luckyGiftDrawnPayload
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,
"sender_user_id": input.Command.UserID,
"target_user_id": input.Command.TargetUserID,
"visible_region_id": input.Command.VisibleRegionID,
"room_id": input.Command.RoomID,
"gift_id": input.Command.GiftID,
"gift_count": input.Command.GiftCount,
@ -1332,15 +1333,11 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
limited := map[string]bool{}
baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
hasZeroCandidate := false
var totalWeight int64
for _, tier := range config.Tiers {
if tier.Pool != pool || !tier.Enabled {
continue
}
if tier.RewardCoins == 0 {
hasZeroCandidate = true
}
highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier
if highMultiplier && !config.LargeTierEnabled {
limited["large_tier_disabled"] = true
@ -1371,9 +1368,6 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
HighMultiplier: highMultiplier,
})
}
if minRequired == 0 && !hasZeroCandidate {
baseCandidates = append(baseCandidates, luckyCandidate{TierID: pool + "_none", Source: domain.SourceBaseRTP, Pool: pool})
}
candidates := luckyAutoWeightedBaseCandidates(baseCandidates, minRequired, globalWindow, giftWindow)
for _, candidate := range candidates {
totalWeight += candidate.Weight
@ -1673,9 +1667,6 @@ func luckyMultiplierPPMsFromTiers(tiers []domain.Tier, giftPrice int64) []int64
seen[multiplier] = true
out = append(out, multiplier)
}
if !seen[0] {
out = append(out, 0)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
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) {
if status := luckyInitialRewardStatus(luckyCandidate{TierID: "none"}, false); status != domain.StatusGranted {
t.Fatalf("zero reward without presentation should be granted, got %s", status)

View File

@ -59,6 +59,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
GiftCount: meta.GetGiftCount(),
CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)

View File

@ -138,6 +138,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
CoinSpent: billing.GetCoinSpent(),
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
},
})
if err != nil {