飘屏后端决定

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 {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
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"`
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"`
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"`
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"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
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"`
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"`
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"`
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"`
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"`
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
}
func (x *LuckyGiftMeta) Reset() {
@ -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

@ -84,17 +84,18 @@ type CheckCommand struct {
}
type DrawCommand struct {
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
GiftCount int32
CoinSpent int64
PaidAtMS int64
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
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,12 +46,18 @@ 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
now func() time.Time
repository Repository
wallet WalletClient
publisher RoomPublisher
broadcaster RegionBroadcaster
now func() time.Time
}
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 {
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

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

View File

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