diff --git a/api/proto/activity/v1/activity.pb.go b/api/proto/activity/v1/activity.pb.go index 0eeff0d6..b204fdc8 100644 --- a/api/proto/activity/v1/activity.pb.go +++ b/api/proto/activity/v1/activity.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/activity/v1/activity.proto package activityv1 diff --git a/api/proto/activity/v1/activity_grpc.pb.go b/api/proto/activity/v1/activity_grpc.pb.go index a84580ea..c4af8c40 100644 --- a/api/proto/activity/v1/activity_grpc.pb.go +++ b/api/proto/activity/v1/activity_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/activity/v1/activity.proto package activityv1 diff --git a/api/proto/events/luckygift/v1/events.pb.go b/api/proto/events/luckygift/v1/events.pb.go index 5e46e37c..194c68f0 100644 --- a/api/proto/events/luckygift/v1/events.pb.go +++ b/api/proto/events/luckygift/v1/events.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/events/luckygift/v1/events.proto package luckygifteventsv1 diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index 23a8a872..adb88b4a 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/events/room/v1/events.proto package roomeventsv1 diff --git a/api/proto/game/v1/game.pb.go b/api/proto/game/v1/game.pb.go index 358b1668..6ed173eb 100644 --- a/api/proto/game/v1/game.pb.go +++ b/api/proto/game/v1/game.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/game/v1/game.proto package gamev1 diff --git a/api/proto/game/v1/game_grpc.pb.go b/api/proto/game/v1/game_grpc.pb.go index 27c5cf40..cb1b15c4 100644 --- a/api/proto/game/v1/game_grpc.pb.go +++ b/api/proto/game/v1/game_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/game/v1/game.proto package gamev1 diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index 734b60cc..85953cec 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -128,8 +128,11 @@ type LuckyGiftMeta struct { LastRechargedAtMs int64 `protobuf:"varint,21,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"` // gift_income_coins 是 wallet 已实际入账给收礼人的收益金币;dynamic_v3 用它校验规则主播拆账,缺失或比例不一致均拒绝开奖。 GiftIncomeCoins int64 `protobuf:"varint,22,opt,name=gift_income_coins,json=giftIncomeCoins,proto3" json:"gift_income_coins,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。 + // dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。 + UserRegisteredAtMs int64 `protobuf:"varint,23,opt,name=user_registered_at_ms,json=userRegisteredAtMs,proto3" json:"user_registered_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LuckyGiftMeta) Reset() { @@ -316,6 +319,13 @@ func (x *LuckyGiftMeta) GetGiftIncomeCoins() int64 { return 0 } +func (x *LuckyGiftMeta) GetUserRegisteredAtMs() int64 { + if x != nil { + return x.UserRegisteredAtMs + } + return 0 +} + type LuckyGiftRuleTier struct { state protoimpl.MessageState `protogen:"open.v1"` Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"` @@ -487,31 +497,36 @@ type LuckyGiftRuleConfig struct { CreatedAtMs int64 `protobuf:"varint,14,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` Stages []*LuckyGiftRuleStage `protobuf:"bytes,15,rep,name=stages,proto3" json:"stages,omitempty"` // strategy_version 隔离历史 fixed_v2 与动态算法 dynamic_v3,避免新增约束破坏已经发布的旧规则。 - StrategyVersion string `protobuf:"bytes,16,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"` - ProfitRatePpm int64 `protobuf:"varint,17,opt,name=profit_rate_ppm,json=profitRatePpm,proto3" json:"profit_rate_ppm,omitempty"` - AnchorRatePpm int64 `protobuf:"varint,18,opt,name=anchor_rate_ppm,json=anchorRatePpm,proto3" json:"anchor_rate_ppm,omitempty"` - InitialPoolCoins int64 `protobuf:"varint,19,opt,name=initial_pool_coins,json=initialPoolCoins,proto3" json:"initial_pool_coins,omitempty"` - LossStreakGuarantee int64 `protobuf:"varint,20,opt,name=loss_streak_guarantee,json=lossStreakGuarantee,proto3" json:"loss_streak_guarantee,omitempty"` - LowWatermarkCoins int64 `protobuf:"varint,21,opt,name=low_watermark_coins,json=lowWatermarkCoins,proto3" json:"low_watermark_coins,omitempty"` - LowWaterNonzeroFactorPpm int64 `protobuf:"varint,22,opt,name=low_water_nonzero_factor_ppm,json=lowWaterNonzeroFactorPpm,proto3" json:"low_water_nonzero_factor_ppm,omitempty"` - HighWatermarkCoins int64 `protobuf:"varint,23,opt,name=high_watermark_coins,json=highWatermarkCoins,proto3" json:"high_watermark_coins,omitempty"` - HighWaterNonzeroFactorPpm int64 `protobuf:"varint,24,opt,name=high_water_nonzero_factor_ppm,json=highWaterNonzeroFactorPpm,proto3" json:"high_water_nonzero_factor_ppm,omitempty"` - RechargeBoostWindowMs int64 `protobuf:"varint,25,opt,name=recharge_boost_window_ms,json=rechargeBoostWindowMs,proto3" json:"recharge_boost_window_ms,omitempty"` - RechargeBoostFactorPpm int64 `protobuf:"varint,26,opt,name=recharge_boost_factor_ppm,json=rechargeBoostFactorPpm,proto3" json:"recharge_boost_factor_ppm,omitempty"` - JackpotMultiplierPpms []int64 `protobuf:"varint,27,rep,packed,name=jackpot_multiplier_ppms,json=jackpotMultiplierPpms,proto3" json:"jackpot_multiplier_ppms,omitempty"` - JackpotGlobalRtpMaxPpm int64 `protobuf:"varint,28,opt,name=jackpot_global_rtp_max_ppm,json=jackpotGlobalRtpMaxPpm,proto3" json:"jackpot_global_rtp_max_ppm,omitempty"` - JackpotUserDayRtpMaxPpm int64 `protobuf:"varint,29,opt,name=jackpot_user_day_rtp_max_ppm,json=jackpotUserDayRtpMaxPpm,proto3" json:"jackpot_user_day_rtp_max_ppm,omitempty"` - JackpotUser_72HRtpMaxPpm int64 `protobuf:"varint,30,opt,name=jackpot_user_72h_rtp_max_ppm,json=jackpotUser72hRtpMaxPpm,proto3" json:"jackpot_user_72h_rtp_max_ppm,omitempty"` - JackpotSpendThresholdCoins int64 `protobuf:"varint,31,opt,name=jackpot_spend_threshold_coins,json=jackpotSpendThresholdCoins,proto3" json:"jackpot_spend_threshold_coins,omitempty"` - MaxJackpotHitsPerUserDay int64 `protobuf:"varint,32,opt,name=max_jackpot_hits_per_user_day,json=maxJackpotHitsPerUserDay,proto3" json:"max_jackpot_hits_per_user_day,omitempty"` - MaxSinglePayout int64 `protobuf:"varint,33,opt,name=max_single_payout,json=maxSinglePayout,proto3" json:"max_single_payout,omitempty"` - UserHourlyPayoutCap int64 `protobuf:"varint,34,opt,name=user_hourly_payout_cap,json=userHourlyPayoutCap,proto3" json:"user_hourly_payout_cap,omitempty"` - UserDailyPayoutCap int64 `protobuf:"varint,35,opt,name=user_daily_payout_cap,json=userDailyPayoutCap,proto3" json:"user_daily_payout_cap,omitempty"` - DeviceDailyPayoutCap int64 `protobuf:"varint,36,opt,name=device_daily_payout_cap,json=deviceDailyPayoutCap,proto3" json:"device_daily_payout_cap,omitempty"` - RoomHourlyPayoutCap int64 `protobuf:"varint,37,opt,name=room_hourly_payout_cap,json=roomHourlyPayoutCap,proto3" json:"room_hourly_payout_cap,omitempty"` - AnchorDailyPayoutCap int64 `protobuf:"varint,38,opt,name=anchor_daily_payout_cap,json=anchorDailyPayoutCap,proto3" json:"anchor_daily_payout_cap,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + StrategyVersion string `protobuf:"bytes,16,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"` + ProfitRatePpm int64 `protobuf:"varint,17,opt,name=profit_rate_ppm,json=profitRatePpm,proto3" json:"profit_rate_ppm,omitempty"` + AnchorRatePpm int64 `protobuf:"varint,18,opt,name=anchor_rate_ppm,json=anchorRatePpm,proto3" json:"anchor_rate_ppm,omitempty"` + InitialPoolCoins int64 `protobuf:"varint,19,opt,name=initial_pool_coins,json=initialPoolCoins,proto3" json:"initial_pool_coins,omitempty"` + LossStreakGuarantee int64 `protobuf:"varint,20,opt,name=loss_streak_guarantee,json=lossStreakGuarantee,proto3" json:"loss_streak_guarantee,omitempty"` + LowWatermarkCoins int64 `protobuf:"varint,21,opt,name=low_watermark_coins,json=lowWatermarkCoins,proto3" json:"low_watermark_coins,omitempty"` + LowWaterNonzeroFactorPpm int64 `protobuf:"varint,22,opt,name=low_water_nonzero_factor_ppm,json=lowWaterNonzeroFactorPpm,proto3" json:"low_water_nonzero_factor_ppm,omitempty"` + HighWatermarkCoins int64 `protobuf:"varint,23,opt,name=high_watermark_coins,json=highWatermarkCoins,proto3" json:"high_watermark_coins,omitempty"` + HighWaterNonzeroFactorPpm int64 `protobuf:"varint,24,opt,name=high_water_nonzero_factor_ppm,json=highWaterNonzeroFactorPpm,proto3" json:"high_water_nonzero_factor_ppm,omitempty"` + RechargeBoostWindowMs int64 `protobuf:"varint,25,opt,name=recharge_boost_window_ms,json=rechargeBoostWindowMs,proto3" json:"recharge_boost_window_ms,omitempty"` + RechargeBoostFactorPpm int64 `protobuf:"varint,26,opt,name=recharge_boost_factor_ppm,json=rechargeBoostFactorPpm,proto3" json:"recharge_boost_factor_ppm,omitempty"` + JackpotMultiplierPpms []int64 `protobuf:"varint,27,rep,packed,name=jackpot_multiplier_ppms,json=jackpotMultiplierPpms,proto3" json:"jackpot_multiplier_ppms,omitempty"` + JackpotGlobalRtpMaxPpm int64 `protobuf:"varint,28,opt,name=jackpot_global_rtp_max_ppm,json=jackpotGlobalRtpMaxPpm,proto3" json:"jackpot_global_rtp_max_ppm,omitempty"` + JackpotUserRoundRtpMaxPpm int64 `protobuf:"varint,29,opt,name=jackpot_user_round_rtp_max_ppm,json=jackpotUserRoundRtpMaxPpm,proto3" json:"jackpot_user_round_rtp_max_ppm,omitempty"` + JackpotUser_48HRtpMaxPpm int64 `protobuf:"varint,30,opt,name=jackpot_user_48h_rtp_max_ppm,json=jackpotUser48hRtpMaxPpm,proto3" json:"jackpot_user_48h_rtp_max_ppm,omitempty"` + // 用户在同一应用、奖池和 UTC 自然日每累计消费满该金币数,生成一次可跨日保留的消费大奖资格。 + JackpotSpendThresholdCoins int64 `protobuf:"varint,31,opt,name=jackpot_spend_threshold_coins,json=jackpotSpendThresholdCoins,proto3" json:"jackpot_spend_threshold_coins,omitempty"` + MaxJackpotHitsPerUserDay int64 `protobuf:"varint,32,opt,name=max_jackpot_hits_per_user_day,json=maxJackpotHitsPerUserDay,proto3" json:"max_jackpot_hits_per_user_day,omitempty"` + MaxSinglePayout int64 `protobuf:"varint,33,opt,name=max_single_payout,json=maxSinglePayout,proto3" json:"max_single_payout,omitempty"` + UserHourlyPayoutCap int64 `protobuf:"varint,34,opt,name=user_hourly_payout_cap,json=userHourlyPayoutCap,proto3" json:"user_hourly_payout_cap,omitempty"` + UserDailyPayoutCap int64 `protobuf:"varint,35,opt,name=user_daily_payout_cap,json=userDailyPayoutCap,proto3" json:"user_daily_payout_cap,omitempty"` + DeviceDailyPayoutCap int64 `protobuf:"varint,36,opt,name=device_daily_payout_cap,json=deviceDailyPayoutCap,proto3" json:"device_daily_payout_cap,omitempty"` + RoomHourlyPayoutCap int64 `protobuf:"varint,37,opt,name=room_hourly_payout_cap,json=roomHourlyPayoutCap,proto3" json:"room_hourly_payout_cap,omitempty"` + AnchorDailyPayoutCap int64 `protobuf:"varint,38,opt,name=anchor_daily_payout_cap,json=anchorDailyPayoutCap,proto3" json:"anchor_daily_payout_cap,omitempty"` + // 用户严格滚动 24 小时 RTP 超过此值时,普通非零奖档按下方因子降权;0 表示关闭该策略。 + User_24HRtpThresholdPpm int64 `protobuf:"varint,39,opt,name=user_24h_rtp_threshold_ppm,json=user24hRtpThresholdPpm,proto3" json:"user_24h_rtp_threshold_ppm,omitempty"` + // 触发用户 24 小时 RTP 降权后保留的普通中奖权重;1000000 表示保持原概率。 + User_24HOrdinaryWinFactorPpm int64 `protobuf:"varint,40,opt,name=user_24h_ordinary_win_factor_ppm,json=user24hOrdinaryWinFactorPpm,proto3" json:"user_24h_ordinary_win_factor_ppm,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LuckyGiftRuleConfig) Reset() { @@ -740,16 +755,16 @@ func (x *LuckyGiftRuleConfig) GetJackpotGlobalRtpMaxPpm() int64 { return 0 } -func (x *LuckyGiftRuleConfig) GetJackpotUserDayRtpMaxPpm() int64 { +func (x *LuckyGiftRuleConfig) GetJackpotUserRoundRtpMaxPpm() int64 { if x != nil { - return x.JackpotUserDayRtpMaxPpm + return x.JackpotUserRoundRtpMaxPpm } return 0 } -func (x *LuckyGiftRuleConfig) GetJackpotUser_72HRtpMaxPpm() int64 { +func (x *LuckyGiftRuleConfig) GetJackpotUser_48HRtpMaxPpm() int64 { if x != nil { - return x.JackpotUser_72HRtpMaxPpm + return x.JackpotUser_48HRtpMaxPpm } return 0 } @@ -810,6 +825,20 @@ func (x *LuckyGiftRuleConfig) GetAnchorDailyPayoutCap() int64 { return 0 } +func (x *LuckyGiftRuleConfig) GetUser_24HRtpThresholdPpm() int64 { + if x != nil { + return x.User_24HRtpThresholdPpm + } + return 0 +} + +func (x *LuckyGiftRuleConfig) GetUser_24HOrdinaryWinFactorPpm() int64 { + if x != nil { + return x.User_24HOrdinaryWinFactorPpm + } + return 0 +} + type CheckLuckyGiftRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` @@ -1415,9 +1444,11 @@ type ExternalGiftDrawRequest struct { MetadataJson string `protobuf:"bytes,11,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"` PoolId string `protobuf:"bytes,12,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` // dynamic_v3 必填的稳定设备作用域。luck-gateway 不独立证明其真实性,调用方必须在自己的认证/签名边界内绑定该值;fixed_v2 允许缺失。 - DeviceId string `protobuf:"bytes,13,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + DeviceId string `protobuf:"bytes,13,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + // user_registered_at_ms 由外部 App 按自己的用户主数据提供;dynamic_v3 缺失或晚于支付时间时补偿大奖资格 fail-close。 + UserRegisteredAtMs int64 `protobuf:"varint,14,opt,name=user_registered_at_ms,json=userRegisteredAtMs,proto3" json:"user_registered_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExternalGiftDrawRequest) Reset() { @@ -1534,6 +1565,13 @@ func (x *ExternalGiftDrawRequest) GetDeviceId() string { return "" } +func (x *ExternalGiftDrawRequest) GetUserRegisteredAtMs() int64 { + if x != nil { + return x.UserRegisteredAtMs + } + return 0 +} + type ExternalGiftDrawResponse struct { state protoimpl.MessageState `protogen:"open.v1"` DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"` @@ -3946,6 +3984,1159 @@ func (x *AdjustLuckyGiftPoolBalanceResponse) GetIdempotentReplay() bool { return false } +// 用户画像协议追加在文件尾,避免改变既有 message index。外部策略哈希只存在于 owner 数据库, +// 本协议永远只下发 external_user_id,防止运营误把不可逆内部键当成外部用户身份。 +type LuckyGiftUserProfileWindow struct { + state protoimpl.MessageState `protogen:"open.v1"` + Draws int64 `protobuf:"varint,1,opt,name=draws,proto3" json:"draws,omitempty"` + WagerCoins int64 `protobuf:"varint,2,opt,name=wager_coins,json=wagerCoins,proto3" json:"wager_coins,omitempty"` + PayoutCoins int64 `protobuf:"varint,3,opt,name=payout_coins,json=payoutCoins,proto3" json:"payout_coins,omitempty"` + NetCoins int64 `protobuf:"varint,4,opt,name=net_coins,json=netCoins,proto3" json:"net_coins,omitempty"` + RtpPpm int64 `protobuf:"varint,5,opt,name=rtp_ppm,json=rtpPpm,proto3" json:"rtp_ppm,omitempty"` + HasRtp bool `protobuf:"varint,6,opt,name=has_rtp,json=hasRtp,proto3" json:"has_rtp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LuckyGiftUserProfileWindow) Reset() { + *x = LuckyGiftUserProfileWindow{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftUserProfileWindow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftUserProfileWindow) ProtoMessage() {} + +func (x *LuckyGiftUserProfileWindow) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LuckyGiftUserProfileWindow.ProtoReflect.Descriptor instead. +func (*LuckyGiftUserProfileWindow) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{45} +} + +func (x *LuckyGiftUserProfileWindow) GetDraws() int64 { + if x != nil { + return x.Draws + } + return 0 +} + +func (x *LuckyGiftUserProfileWindow) GetWagerCoins() int64 { + if x != nil { + return x.WagerCoins + } + return 0 +} + +func (x *LuckyGiftUserProfileWindow) GetPayoutCoins() int64 { + if x != nil { + return x.PayoutCoins + } + return 0 +} + +func (x *LuckyGiftUserProfileWindow) GetNetCoins() int64 { + if x != nil { + return x.NetCoins + } + return 0 +} + +func (x *LuckyGiftUserProfileWindow) GetRtpPpm() int64 { + if x != nil { + return x.RtpPpm + } + return 0 +} + +func (x *LuckyGiftUserProfileWindow) GetHasRtp() bool { + if x != nil { + return x.HasRtp + } + return false +} + +type LuckyGiftUserProfile struct { + state protoimpl.MessageState `protogen:"open.v1"` + AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + IdentityType string `protobuf:"bytes,3,opt,name=identity_type,json=identityType,proto3" json:"identity_type,omitempty"` + InternalUserId int64 `protobuf:"varint,4,opt,name=internal_user_id,json=internalUserId,proto3" json:"internal_user_id,omitempty"` + ExternalUserId string `protobuf:"bytes,5,opt,name=external_user_id,json=externalUserId,proto3" json:"external_user_id,omitempty"` + RuleVersion int64 `protobuf:"varint,6,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"` + StrategyVersion string `protobuf:"bytes,7,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"` + Stage string `protobuf:"bytes,8,opt,name=stage,proto3" json:"stage,omitempty"` + PaidDraws int64 `protobuf:"varint,9,opt,name=paid_draws,json=paidDraws,proto3" json:"paid_draws,omitempty"` + EquivalentDraws int64 `protobuf:"varint,10,opt,name=equivalent_draws,json=equivalentDraws,proto3" json:"equivalent_draws,omitempty"` + LossStreak int64 `protobuf:"varint,11,opt,name=loss_streak,json=lossStreak,proto3" json:"loss_streak,omitempty"` + PendingSpendJackpotTokens int64 `protobuf:"varint,12,opt,name=pending_spend_jackpot_tokens,json=pendingSpendJackpotTokens,proto3" json:"pending_spend_jackpot_tokens,omitempty"` + GuaranteeDrawsRemaining int64 `protobuf:"varint,13,opt,name=guarantee_draws_remaining,json=guaranteeDrawsRemaining,proto3" json:"guarantee_draws_remaining,omitempty"` + DownweightActive bool `protobuf:"varint,14,opt,name=downweight_active,json=downweightActive,proto3" json:"downweight_active,omitempty"` + DownweightFactorPpm int64 `protobuf:"varint,15,opt,name=downweight_factor_ppm,json=downweightFactorPpm,proto3" json:"downweight_factor_ppm,omitempty"` + OneHour *LuckyGiftUserProfileWindow `protobuf:"bytes,16,opt,name=one_hour,json=oneHour,proto3" json:"one_hour,omitempty"` + TwentyFourHours *LuckyGiftUserProfileWindow `protobuf:"bytes,17,opt,name=twenty_four_hours,json=twentyFourHours,proto3" json:"twenty_four_hours,omitempty"` + FortyEightHours *LuckyGiftUserProfileWindow `protobuf:"bytes,18,opt,name=forty_eight_hours,json=fortyEightHours,proto3" json:"forty_eight_hours,omitempty"` + Lifetime *LuckyGiftUserProfileWindow `protobuf:"bytes,19,opt,name=lifetime,proto3" json:"lifetime,omitempty"` + BaseRewardCoins int64 `protobuf:"varint,20,opt,name=base_reward_coins,json=baseRewardCoins,proto3" json:"base_reward_coins,omitempty"` + OrdinaryWinCount int64 `protobuf:"varint,21,opt,name=ordinary_win_count,json=ordinaryWinCount,proto3" json:"ordinary_win_count,omitempty"` + HighMultiplierOrdinaryWinCount int64 `protobuf:"varint,22,opt,name=high_multiplier_ordinary_win_count,json=highMultiplierOrdinaryWinCount,proto3" json:"high_multiplier_ordinary_win_count,omitempty"` + RtpCompensationJackpotCount int64 `protobuf:"varint,23,opt,name=rtp_compensation_jackpot_count,json=rtpCompensationJackpotCount,proto3" json:"rtp_compensation_jackpot_count,omitempty"` + CumulativeSpendJackpotCount int64 `protobuf:"varint,24,opt,name=cumulative_spend_jackpot_count,json=cumulativeSpendJackpotCount,proto3" json:"cumulative_spend_jackpot_count,omitempty"` + GuaranteeHitCount int64 `protobuf:"varint,25,opt,name=guarantee_hit_count,json=guaranteeHitCount,proto3" json:"guarantee_hit_count,omitempty"` + ZeroDrawCount int64 `protobuf:"varint,26,opt,name=zero_draw_count,json=zeroDrawCount,proto3" json:"zero_draw_count,omitempty"` + GrantedDrawCount int64 `protobuf:"varint,27,opt,name=granted_draw_count,json=grantedDrawCount,proto3" json:"granted_draw_count,omitempty"` + PendingDrawCount int64 `protobuf:"varint,28,opt,name=pending_draw_count,json=pendingDrawCount,proto3" json:"pending_draw_count,omitempty"` + FailedDrawCount int64 `protobuf:"varint,29,opt,name=failed_draw_count,json=failedDrawCount,proto3" json:"failed_draw_count,omitempty"` + MaxMultiplierPpm int64 `protobuf:"varint,30,opt,name=max_multiplier_ppm,json=maxMultiplierPpm,proto3" json:"max_multiplier_ppm,omitempty"` + MaxRewardCoins int64 `protobuf:"varint,31,opt,name=max_reward_coins,json=maxRewardCoins,proto3" json:"max_reward_coins,omitempty"` + FirstDrawAtMs int64 `protobuf:"varint,32,opt,name=first_draw_at_ms,json=firstDrawAtMs,proto3" json:"first_draw_at_ms,omitempty"` + LastDrawAtMs int64 `protobuf:"varint,33,opt,name=last_draw_at_ms,json=lastDrawAtMs,proto3" json:"last_draw_at_ms,omitempty"` + AggregatedAtMs int64 `protobuf:"varint,34,opt,name=aggregated_at_ms,json=aggregatedAtMs,proto3" json:"aggregated_at_ms,omitempty"` + DataLagMs int64 `protobuf:"varint,35,opt,name=data_lag_ms,json=dataLagMs,proto3" json:"data_lag_ms,omitempty"` + User_24HRtpThresholdPpm int64 `protobuf:"varint,36,opt,name=user_24h_rtp_threshold_ppm,json=user24hRtpThresholdPpm,proto3" json:"user_24h_rtp_threshold_ppm,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LuckyGiftUserProfile) Reset() { + *x = LuckyGiftUserProfile{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftUserProfile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftUserProfile) ProtoMessage() {} + +func (x *LuckyGiftUserProfile) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[46] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LuckyGiftUserProfile.ProtoReflect.Descriptor instead. +func (*LuckyGiftUserProfile) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{46} +} + +func (x *LuckyGiftUserProfile) GetAppCode() string { + if x != nil { + return x.AppCode + } + return "" +} + +func (x *LuckyGiftUserProfile) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + +func (x *LuckyGiftUserProfile) GetIdentityType() string { + if x != nil { + return x.IdentityType + } + return "" +} + +func (x *LuckyGiftUserProfile) GetInternalUserId() int64 { + if x != nil { + return x.InternalUserId + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetExternalUserId() string { + if x != nil { + return x.ExternalUserId + } + return "" +} + +func (x *LuckyGiftUserProfile) GetRuleVersion() int64 { + if x != nil { + return x.RuleVersion + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetStrategyVersion() string { + if x != nil { + return x.StrategyVersion + } + return "" +} + +func (x *LuckyGiftUserProfile) GetStage() string { + if x != nil { + return x.Stage + } + return "" +} + +func (x *LuckyGiftUserProfile) GetPaidDraws() int64 { + if x != nil { + return x.PaidDraws + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetEquivalentDraws() int64 { + if x != nil { + return x.EquivalentDraws + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetLossStreak() int64 { + if x != nil { + return x.LossStreak + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetPendingSpendJackpotTokens() int64 { + if x != nil { + return x.PendingSpendJackpotTokens + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetGuaranteeDrawsRemaining() int64 { + if x != nil { + return x.GuaranteeDrawsRemaining + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetDownweightActive() bool { + if x != nil { + return x.DownweightActive + } + return false +} + +func (x *LuckyGiftUserProfile) GetDownweightFactorPpm() int64 { + if x != nil { + return x.DownweightFactorPpm + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetOneHour() *LuckyGiftUserProfileWindow { + if x != nil { + return x.OneHour + } + return nil +} + +func (x *LuckyGiftUserProfile) GetTwentyFourHours() *LuckyGiftUserProfileWindow { + if x != nil { + return x.TwentyFourHours + } + return nil +} + +func (x *LuckyGiftUserProfile) GetFortyEightHours() *LuckyGiftUserProfileWindow { + if x != nil { + return x.FortyEightHours + } + return nil +} + +func (x *LuckyGiftUserProfile) GetLifetime() *LuckyGiftUserProfileWindow { + if x != nil { + return x.Lifetime + } + return nil +} + +func (x *LuckyGiftUserProfile) GetBaseRewardCoins() int64 { + if x != nil { + return x.BaseRewardCoins + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetOrdinaryWinCount() int64 { + if x != nil { + return x.OrdinaryWinCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetHighMultiplierOrdinaryWinCount() int64 { + if x != nil { + return x.HighMultiplierOrdinaryWinCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetRtpCompensationJackpotCount() int64 { + if x != nil { + return x.RtpCompensationJackpotCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetCumulativeSpendJackpotCount() int64 { + if x != nil { + return x.CumulativeSpendJackpotCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetGuaranteeHitCount() int64 { + if x != nil { + return x.GuaranteeHitCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetZeroDrawCount() int64 { + if x != nil { + return x.ZeroDrawCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetGrantedDrawCount() int64 { + if x != nil { + return x.GrantedDrawCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetPendingDrawCount() int64 { + if x != nil { + return x.PendingDrawCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetFailedDrawCount() int64 { + if x != nil { + return x.FailedDrawCount + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetMaxMultiplierPpm() int64 { + if x != nil { + return x.MaxMultiplierPpm + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetMaxRewardCoins() int64 { + if x != nil { + return x.MaxRewardCoins + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetFirstDrawAtMs() int64 { + if x != nil { + return x.FirstDrawAtMs + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetLastDrawAtMs() int64 { + if x != nil { + return x.LastDrawAtMs + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetAggregatedAtMs() int64 { + if x != nil { + return x.AggregatedAtMs + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetDataLagMs() int64 { + if x != nil { + return x.DataLagMs + } + return 0 +} + +func (x *LuckyGiftUserProfile) GetUser_24HRtpThresholdPpm() int64 { + if x != nil { + return x.User_24HRtpThresholdPpm + } + return 0 +} + +type ListLuckyGiftUserProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + IdentityType string `protobuf:"bytes,3,opt,name=identity_type,json=identityType,proto3" json:"identity_type,omitempty"` + InternalUserId int64 `protobuf:"varint,4,opt,name=internal_user_id,json=internalUserId,proto3" json:"internal_user_id,omitempty"` + ExternalUserId string `protobuf:"bytes,5,opt,name=external_user_id,json=externalUserId,proto3" json:"external_user_id,omitempty"` + Stage string `protobuf:"bytes,6,opt,name=stage,proto3" json:"stage,omitempty"` + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` + SortBy string `protobuf:"bytes,8,opt,name=sort_by,json=sortBy,proto3" json:"sort_by,omitempty"` + SortDirection string `protobuf:"bytes,9,opt,name=sort_direction,json=sortDirection,proto3" json:"sort_direction,omitempty"` + Page int32 `protobuf:"varint,10,opt,name=page,proto3" json:"page,omitempty"` + PageSize int32 `protobuf:"varint,11,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // cursor 是 owner service 生成的不透明 keyset 游标;page 仅保留旧客户端协议兼容,服务端不再执行 OFFSET。 + Cursor string `protobuf:"bytes,12,opt,name=cursor,proto3" json:"cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListLuckyGiftUserProfilesRequest) Reset() { + *x = ListLuckyGiftUserProfilesRequest{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListLuckyGiftUserProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListLuckyGiftUserProfilesRequest) ProtoMessage() {} + +func (x *ListLuckyGiftUserProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[47] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListLuckyGiftUserProfilesRequest.ProtoReflect.Descriptor instead. +func (*ListLuckyGiftUserProfilesRequest) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{47} +} + +func (x *ListLuckyGiftUserProfilesRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ListLuckyGiftUserProfilesRequest) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + +func (x *ListLuckyGiftUserProfilesRequest) GetIdentityType() string { + if x != nil { + return x.IdentityType + } + return "" +} + +func (x *ListLuckyGiftUserProfilesRequest) GetInternalUserId() int64 { + if x != nil { + return x.InternalUserId + } + return 0 +} + +func (x *ListLuckyGiftUserProfilesRequest) GetExternalUserId() string { + if x != nil { + return x.ExternalUserId + } + return "" +} + +func (x *ListLuckyGiftUserProfilesRequest) GetStage() string { + if x != nil { + return x.Stage + } + return "" +} + +func (x *ListLuckyGiftUserProfilesRequest) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ListLuckyGiftUserProfilesRequest) GetSortBy() string { + if x != nil { + return x.SortBy + } + return "" +} + +func (x *ListLuckyGiftUserProfilesRequest) GetSortDirection() string { + if x != nil { + return x.SortDirection + } + return "" +} + +func (x *ListLuckyGiftUserProfilesRequest) GetPage() int32 { + if x != nil { + return x.Page + } + return 0 +} + +func (x *ListLuckyGiftUserProfilesRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListLuckyGiftUserProfilesRequest) GetCursor() string { + if x != nil { + return x.Cursor + } + return "" +} + +type ListLuckyGiftUserProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []*LuckyGiftUserProfile `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + Total int64 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"` + SnapshotAtMs int64 `protobuf:"varint,3,opt,name=snapshot_at_ms,json=snapshotAtMs,proto3" json:"snapshot_at_ms,omitempty"` + NextCursor string `protobuf:"bytes,4,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + HasMore bool `protobuf:"varint,5,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListLuckyGiftUserProfilesResponse) Reset() { + *x = ListLuckyGiftUserProfilesResponse{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListLuckyGiftUserProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListLuckyGiftUserProfilesResponse) ProtoMessage() {} + +func (x *ListLuckyGiftUserProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListLuckyGiftUserProfilesResponse.ProtoReflect.Descriptor instead. +func (*ListLuckyGiftUserProfilesResponse) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{48} +} + +func (x *ListLuckyGiftUserProfilesResponse) GetProfiles() []*LuckyGiftUserProfile { + if x != nil { + return x.Profiles + } + return nil +} + +func (x *ListLuckyGiftUserProfilesResponse) GetTotal() int64 { + if x != nil { + return x.Total + } + return 0 +} + +func (x *ListLuckyGiftUserProfilesResponse) GetSnapshotAtMs() int64 { + if x != nil { + return x.SnapshotAtMs + } + return 0 +} + +func (x *ListLuckyGiftUserProfilesResponse) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +func (x *ListLuckyGiftUserProfilesResponse) GetHasMore() bool { + if x != nil { + return x.HasMore + } + return false +} + +type GetLuckyGiftUserProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + IdentityType string `protobuf:"bytes,3,opt,name=identity_type,json=identityType,proto3" json:"identity_type,omitempty"` + InternalUserId int64 `protobuf:"varint,4,opt,name=internal_user_id,json=internalUserId,proto3" json:"internal_user_id,omitempty"` + ExternalUserId string `protobuf:"bytes,5,opt,name=external_user_id,json=externalUserId,proto3" json:"external_user_id,omitempty"` + JackpotPage int32 `protobuf:"varint,6,opt,name=jackpot_page,json=jackpotPage,proto3" json:"jackpot_page,omitempty"` + JackpotPageSize int32 `protobuf:"varint,7,opt,name=jackpot_page_size,json=jackpotPageSize,proto3" json:"jackpot_page_size,omitempty"` + // jackpot_cursor 由上一页 next_jackpot_cursor 原样返回;jackpot_page 仅保留协议兼容。 + JackpotCursor string `protobuf:"bytes,8,opt,name=jackpot_cursor,json=jackpotCursor,proto3" json:"jackpot_cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetLuckyGiftUserProfileRequest) Reset() { + *x = GetLuckyGiftUserProfileRequest{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetLuckyGiftUserProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetLuckyGiftUserProfileRequest) ProtoMessage() {} + +func (x *GetLuckyGiftUserProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetLuckyGiftUserProfileRequest.ProtoReflect.Descriptor instead. +func (*GetLuckyGiftUserProfileRequest) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{49} +} + +func (x *GetLuckyGiftUserProfileRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *GetLuckyGiftUserProfileRequest) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + +func (x *GetLuckyGiftUserProfileRequest) GetIdentityType() string { + if x != nil { + return x.IdentityType + } + return "" +} + +func (x *GetLuckyGiftUserProfileRequest) GetInternalUserId() int64 { + if x != nil { + return x.InternalUserId + } + return 0 +} + +func (x *GetLuckyGiftUserProfileRequest) GetExternalUserId() string { + if x != nil { + return x.ExternalUserId + } + return "" +} + +func (x *GetLuckyGiftUserProfileRequest) GetJackpotPage() int32 { + if x != nil { + return x.JackpotPage + } + return 0 +} + +func (x *GetLuckyGiftUserProfileRequest) GetJackpotPageSize() int32 { + if x != nil { + return x.JackpotPageSize + } + return 0 +} + +func (x *GetLuckyGiftUserProfileRequest) GetJackpotCursor() string { + if x != nil { + return x.JackpotCursor + } + return "" +} + +type LuckyGiftUserMultiplierStat struct { + state protoimpl.MessageState `protogen:"open.v1"` + HitType string `protobuf:"bytes,1,opt,name=hit_type,json=hitType,proto3" json:"hit_type,omitempty"` + MultiplierPpm int64 `protobuf:"varint,2,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"` + DrawCount int64 `protobuf:"varint,3,opt,name=draw_count,json=drawCount,proto3" json:"draw_count,omitempty"` + WagerCoins int64 `protobuf:"varint,4,opt,name=wager_coins,json=wagerCoins,proto3" json:"wager_coins,omitempty"` + PayoutCoins int64 `protobuf:"varint,5,opt,name=payout_coins,json=payoutCoins,proto3" json:"payout_coins,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LuckyGiftUserMultiplierStat) Reset() { + *x = LuckyGiftUserMultiplierStat{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftUserMultiplierStat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftUserMultiplierStat) ProtoMessage() {} + +func (x *LuckyGiftUserMultiplierStat) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LuckyGiftUserMultiplierStat.ProtoReflect.Descriptor instead. +func (*LuckyGiftUserMultiplierStat) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{50} +} + +func (x *LuckyGiftUserMultiplierStat) GetHitType() string { + if x != nil { + return x.HitType + } + return "" +} + +func (x *LuckyGiftUserMultiplierStat) GetMultiplierPpm() int64 { + if x != nil { + return x.MultiplierPpm + } + return 0 +} + +func (x *LuckyGiftUserMultiplierStat) GetDrawCount() int64 { + if x != nil { + return x.DrawCount + } + return 0 +} + +func (x *LuckyGiftUserMultiplierStat) GetWagerCoins() int64 { + if x != nil { + return x.WagerCoins + } + return 0 +} + +func (x *LuckyGiftUserMultiplierStat) GetPayoutCoins() int64 { + if x != nil { + return x.PayoutCoins + } + return 0 +} + +type LuckyGiftUserJackpotCondition struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Numerator int64 `protobuf:"varint,2,opt,name=numerator,proto3" json:"numerator,omitempty"` + Denominator int64 `protobuf:"varint,3,opt,name=denominator,proto3" json:"denominator,omitempty"` + RatioPpm int64 `protobuf:"varint,4,opt,name=ratio_ppm,json=ratioPpm,proto3" json:"ratio_ppm,omitempty"` + LimitPpm int64 `protobuf:"varint,5,opt,name=limit_ppm,json=limitPpm,proto3" json:"limit_ppm,omitempty"` + FactorPpm int64 `protobuf:"varint,6,opt,name=factor_ppm,json=factorPpm,proto3" json:"factor_ppm,omitempty"` + Passed bool `protobuf:"varint,7,opt,name=passed,proto3" json:"passed,omitempty"` + Reason string `protobuf:"bytes,8,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LuckyGiftUserJackpotCondition) Reset() { + *x = LuckyGiftUserJackpotCondition{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftUserJackpotCondition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftUserJackpotCondition) ProtoMessage() {} + +func (x *LuckyGiftUserJackpotCondition) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LuckyGiftUserJackpotCondition.ProtoReflect.Descriptor instead. +func (*LuckyGiftUserJackpotCondition) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{51} +} + +func (x *LuckyGiftUserJackpotCondition) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *LuckyGiftUserJackpotCondition) GetNumerator() int64 { + if x != nil { + return x.Numerator + } + return 0 +} + +func (x *LuckyGiftUserJackpotCondition) GetDenominator() int64 { + if x != nil { + return x.Denominator + } + return 0 +} + +func (x *LuckyGiftUserJackpotCondition) GetRatioPpm() int64 { + if x != nil { + return x.RatioPpm + } + return 0 +} + +func (x *LuckyGiftUserJackpotCondition) GetLimitPpm() int64 { + if x != nil { + return x.LimitPpm + } + return 0 +} + +func (x *LuckyGiftUserJackpotCondition) GetFactorPpm() int64 { + if x != nil { + return x.FactorPpm + } + return 0 +} + +func (x *LuckyGiftUserJackpotCondition) GetPassed() bool { + if x != nil { + return x.Passed + } + return false +} + +func (x *LuckyGiftUserJackpotCondition) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type LuckyGiftUserJackpotHit struct { + state protoimpl.MessageState `protogen:"open.v1"` + DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"` + RequestId string `protobuf:"bytes,2,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + RuleVersion int64 `protobuf:"varint,3,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"` + Stage string `protobuf:"bytes,4,opt,name=stage,proto3" json:"stage,omitempty"` + Mechanism string `protobuf:"bytes,5,opt,name=mechanism,proto3" json:"mechanism,omitempty"` + ReasonCode string `protobuf:"bytes,6,opt,name=reason_code,json=reasonCode,proto3" json:"reason_code,omitempty"` + ReasonSummary string `protobuf:"bytes,7,opt,name=reason_summary,json=reasonSummary,proto3" json:"reason_summary,omitempty"` + TierId string `protobuf:"bytes,8,opt,name=tier_id,json=tierId,proto3" json:"tier_id,omitempty"` + MultiplierPpm int64 `protobuf:"varint,9,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"` + WagerCoins int64 `protobuf:"varint,10,opt,name=wager_coins,json=wagerCoins,proto3" json:"wager_coins,omitempty"` + PayoutCoins int64 `protobuf:"varint,11,opt,name=payout_coins,json=payoutCoins,proto3" json:"payout_coins,omitempty"` + OccurredAtMs int64 `protobuf:"varint,12,opt,name=occurred_at_ms,json=occurredAtMs,proto3" json:"occurred_at_ms,omitempty"` + Conditions []*LuckyGiftUserJackpotCondition `protobuf:"bytes,13,rep,name=conditions,proto3" json:"conditions,omitempty"` + // event_key 只标识这条画像大奖事实,供前端稳定追加去重;不包含用户策略键或用户哈希。 + EventKey string `protobuf:"bytes,14,opt,name=event_key,json=eventKey,proto3" json:"event_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LuckyGiftUserJackpotHit) Reset() { + *x = LuckyGiftUserJackpotHit{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftUserJackpotHit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftUserJackpotHit) ProtoMessage() {} + +func (x *LuckyGiftUserJackpotHit) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[52] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LuckyGiftUserJackpotHit.ProtoReflect.Descriptor instead. +func (*LuckyGiftUserJackpotHit) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{52} +} + +func (x *LuckyGiftUserJackpotHit) GetDrawId() string { + if x != nil { + return x.DrawId + } + return "" +} + +func (x *LuckyGiftUserJackpotHit) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *LuckyGiftUserJackpotHit) GetRuleVersion() int64 { + if x != nil { + return x.RuleVersion + } + return 0 +} + +func (x *LuckyGiftUserJackpotHit) GetStage() string { + if x != nil { + return x.Stage + } + return "" +} + +func (x *LuckyGiftUserJackpotHit) GetMechanism() string { + if x != nil { + return x.Mechanism + } + return "" +} + +func (x *LuckyGiftUserJackpotHit) GetReasonCode() string { + if x != nil { + return x.ReasonCode + } + return "" +} + +func (x *LuckyGiftUserJackpotHit) GetReasonSummary() string { + if x != nil { + return x.ReasonSummary + } + return "" +} + +func (x *LuckyGiftUserJackpotHit) GetTierId() string { + if x != nil { + return x.TierId + } + return "" +} + +func (x *LuckyGiftUserJackpotHit) GetMultiplierPpm() int64 { + if x != nil { + return x.MultiplierPpm + } + return 0 +} + +func (x *LuckyGiftUserJackpotHit) GetWagerCoins() int64 { + if x != nil { + return x.WagerCoins + } + return 0 +} + +func (x *LuckyGiftUserJackpotHit) GetPayoutCoins() int64 { + if x != nil { + return x.PayoutCoins + } + return 0 +} + +func (x *LuckyGiftUserJackpotHit) GetOccurredAtMs() int64 { + if x != nil { + return x.OccurredAtMs + } + return 0 +} + +func (x *LuckyGiftUserJackpotHit) GetConditions() []*LuckyGiftUserJackpotCondition { + if x != nil { + return x.Conditions + } + return nil +} + +func (x *LuckyGiftUserJackpotHit) GetEventKey() string { + if x != nil { + return x.EventKey + } + return "" +} + +type GetLuckyGiftUserProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profile *LuckyGiftUserProfile `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + MultiplierDistribution []*LuckyGiftUserMultiplierStat `protobuf:"bytes,2,rep,name=multiplier_distribution,json=multiplierDistribution,proto3" json:"multiplier_distribution,omitempty"` + JackpotHits []*LuckyGiftUserJackpotHit `protobuf:"bytes,3,rep,name=jackpot_hits,json=jackpotHits,proto3" json:"jackpot_hits,omitempty"` + JackpotHitTotal int64 `protobuf:"varint,4,opt,name=jackpot_hit_total,json=jackpotHitTotal,proto3" json:"jackpot_hit_total,omitempty"` + SnapshotAtMs int64 `protobuf:"varint,5,opt,name=snapshot_at_ms,json=snapshotAtMs,proto3" json:"snapshot_at_ms,omitempty"` + JackpotPage int32 `protobuf:"varint,6,opt,name=jackpot_page,json=jackpotPage,proto3" json:"jackpot_page,omitempty"` + JackpotPageSize int32 `protobuf:"varint,7,opt,name=jackpot_page_size,json=jackpotPageSize,proto3" json:"jackpot_page_size,omitempty"` + NextJackpotCursor string `protobuf:"bytes,8,opt,name=next_jackpot_cursor,json=nextJackpotCursor,proto3" json:"next_jackpot_cursor,omitempty"` + JackpotHasMore bool `protobuf:"varint,9,opt,name=jackpot_has_more,json=jackpotHasMore,proto3" json:"jackpot_has_more,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetLuckyGiftUserProfileResponse) Reset() { + *x = GetLuckyGiftUserProfileResponse{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetLuckyGiftUserProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetLuckyGiftUserProfileResponse) ProtoMessage() {} + +func (x *GetLuckyGiftUserProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetLuckyGiftUserProfileResponse.ProtoReflect.Descriptor instead. +func (*GetLuckyGiftUserProfileResponse) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{53} +} + +func (x *GetLuckyGiftUserProfileResponse) GetProfile() *LuckyGiftUserProfile { + if x != nil { + return x.Profile + } + return nil +} + +func (x *GetLuckyGiftUserProfileResponse) GetMultiplierDistribution() []*LuckyGiftUserMultiplierStat { + if x != nil { + return x.MultiplierDistribution + } + return nil +} + +func (x *GetLuckyGiftUserProfileResponse) GetJackpotHits() []*LuckyGiftUserJackpotHit { + if x != nil { + return x.JackpotHits + } + return nil +} + +func (x *GetLuckyGiftUserProfileResponse) GetJackpotHitTotal() int64 { + if x != nil { + return x.JackpotHitTotal + } + return 0 +} + +func (x *GetLuckyGiftUserProfileResponse) GetSnapshotAtMs() int64 { + if x != nil { + return x.SnapshotAtMs + } + return 0 +} + +func (x *GetLuckyGiftUserProfileResponse) GetJackpotPage() int32 { + if x != nil { + return x.JackpotPage + } + return 0 +} + +func (x *GetLuckyGiftUserProfileResponse) GetJackpotPageSize() int32 { + if x != nil { + return x.JackpotPageSize + } + return 0 +} + +func (x *GetLuckyGiftUserProfileResponse) GetNextJackpotCursor() string { + if x != nil { + return x.NextJackpotCursor + } + return "" +} + +func (x *GetLuckyGiftUserProfileResponse) GetJackpotHasMore() bool { + if x != nil { + return x.JackpotHasMore + } + return false +} + var File_proto_luckygift_v1_luckygift_proto protoreflect.FileDescriptor const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + @@ -3958,7 +5149,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x0fgateway_node_id\x18\x03 \x01(\tR\rgatewayNodeId\x12\x1c\n" + "\n" + "sent_at_ms\x18\x04 \x01(\x03R\bsentAtMs\x12\x19\n" + - "\bapp_code\x18\x05 \x01(\tR\aappCode\"\xc2\x06\n" + + "\bapp_code\x18\x05 \x01(\tR\aappCode\"\xf5\x06\n" + "\rLuckyGiftMeta\x123\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" + "\n" + @@ -3988,7 +5179,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x11recharge_7d_coins\x18\x13 \x01(\x03R\x0frecharge7dCoins\x12,\n" + "\x12recharge_30d_coins\x18\x14 \x01(\x03R\x10recharge30dCoins\x12/\n" + "\x14last_recharged_at_ms\x18\x15 \x01(\x03R\x11lastRechargedAtMs\x12*\n" + - "\x11gift_income_coins\x18\x16 \x01(\x03R\x0fgiftIncomeCoins\"\xd3\x01\n" + + "\x11gift_income_coins\x18\x16 \x01(\x03R\x0fgiftIncomeCoins\x121\n" + + "\x15user_registered_at_ms\x18\x17 \x01(\x03R\x12userRegisteredAtMs\"\xd3\x01\n" + "\x11LuckyGiftRuleTier\x12\x14\n" + "\x05stage\x18\x01 \x01(\tR\x05stage\x12\x17\n" + "\atier_id\x18\x02 \x01(\tR\x06tierId\x12%\n" + @@ -4000,7 +5192,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x05stage\x18\x01 \x01(\tR\x05stage\x12;\n" + "\x05tiers\x18\x02 \x03(\v2%.hyapp.luckygift.v1.LuckyGiftRuleTierR\x05tiers\x121\n" + "\x15min_recharge_7d_coins\x18\x03 \x01(\x03R\x12minRecharge7dCoins\x123\n" + - "\x16min_recharge_30d_coins\x18\x04 \x01(\x03R\x13minRecharge30dCoins\"\x83\x0f\n" + + "\x16min_recharge_30d_coins\x18\x04 \x01(\x03R\x13minRecharge30dCoins\"\x8a\x10\n" + "\x13LuckyGiftRuleConfig\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12!\n" + @@ -4030,9 +5222,9 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x18recharge_boost_window_ms\x18\x19 \x01(\x03R\x15rechargeBoostWindowMs\x129\n" + "\x19recharge_boost_factor_ppm\x18\x1a \x01(\x03R\x16rechargeBoostFactorPpm\x126\n" + "\x17jackpot_multiplier_ppms\x18\x1b \x03(\x03R\x15jackpotMultiplierPpms\x12:\n" + - "\x1ajackpot_global_rtp_max_ppm\x18\x1c \x01(\x03R\x16jackpotGlobalRtpMaxPpm\x12=\n" + - "\x1cjackpot_user_day_rtp_max_ppm\x18\x1d \x01(\x03R\x17jackpotUserDayRtpMaxPpm\x12=\n" + - "\x1cjackpot_user_72h_rtp_max_ppm\x18\x1e \x01(\x03R\x17jackpotUser72hRtpMaxPpm\x12A\n" + + "\x1ajackpot_global_rtp_max_ppm\x18\x1c \x01(\x03R\x16jackpotGlobalRtpMaxPpm\x12A\n" + + "\x1ejackpot_user_round_rtp_max_ppm\x18\x1d \x01(\x03R\x19jackpotUserRoundRtpMaxPpm\x12=\n" + + "\x1cjackpot_user_48h_rtp_max_ppm\x18\x1e \x01(\x03R\x17jackpotUser48hRtpMaxPpm\x12A\n" + "\x1djackpot_spend_threshold_coins\x18\x1f \x01(\x03R\x1ajackpotSpendThresholdCoins\x12?\n" + "\x1dmax_jackpot_hits_per_user_day\x18 \x01(\x03R\x18maxJackpotHitsPerUserDay\x12*\n" + "\x11max_single_payout\x18! \x01(\x03R\x0fmaxSinglePayout\x123\n" + @@ -4040,7 +5232,9 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x15user_daily_payout_cap\x18# \x01(\x03R\x12userDailyPayoutCap\x125\n" + "\x17device_daily_payout_cap\x18$ \x01(\x03R\x14deviceDailyPayoutCap\x123\n" + "\x16room_hourly_payout_cap\x18% \x01(\x03R\x13roomHourlyPayoutCap\x125\n" + - "\x17anchor_daily_payout_cap\x18& \x01(\x03R\x14anchorDailyPayoutCap\"\xb0\x01\n" + + "\x17anchor_daily_payout_cap\x18& \x01(\x03R\x14anchorDailyPayoutCap\x12:\n" + + "\x1auser_24h_rtp_threshold_ppm\x18' \x01(\x03R\x16user24hRtpThresholdPpm\x12E\n" + + " user_24h_ordinary_win_factor_ppm\x18( \x01(\x03R\x1buser24hOrdinaryWinFactorPpm\"\xb0\x01\n" + "\x15CheckLuckyGiftRequest\x123\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" + @@ -4094,7 +5288,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\vlucky_gifts\x18\x01 \x03(\v2!.hyapp.luckygift.v1.LuckyGiftMetaR\n" + "luckyGifts\"f\n" + "!BatchExecuteLuckyGiftDrawResponse\x12A\n" + - "\aresults\x18\x01 \x03(\v2'.hyapp.luckygift.v1.LuckyGiftDrawResultR\aresults\"\xaa\x03\n" + + "\aresults\x18\x01 \x03(\v2'.hyapp.luckygift.v1.LuckyGiftDrawResultR\aresults\"\xdd\x03\n" + "\x17ExternalGiftDrawRequest\x123\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x19\n" + "\bapp_code\x18\x03 \x01(\tR\aappCode\x12(\n" + @@ -4112,7 +5306,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + " \x01(\x03R\bpaidAtMs\x12#\n" + "\rmetadata_json\x18\v \x01(\tR\fmetadataJson\x12\x17\n" + "\apool_id\x18\f \x01(\tR\x06poolId\x12\x1b\n" + - "\tdevice_id\x18\r \x01(\tR\bdeviceId\"\xb2\x03\n" + + "\tdevice_id\x18\r \x01(\tR\bdeviceId\x121\n" + + "\x15user_registered_at_ms\x18\x0e \x01(\x03R\x12userRegisteredAtMs\"\xb2\x03\n" + "\x18ExternalGiftDrawResponse\x12\x17\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\n" + @@ -4326,12 +5521,139 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "adjustment\x18\x01 \x01(\v2+.hyapp.luckygift.v1.LuckyGiftPoolAdjustmentR\n" + "adjustment\x12<\n" + "\x04pool\x18\x02 \x01(\v2(.hyapp.luckygift.v1.LuckyGiftPoolBalanceR\x04pool\x12+\n" + - "\x11idempotent_replay\x18\x03 \x01(\bR\x10idempotentReplay2\xf7\x03\n" + + "\x11idempotent_replay\x18\x03 \x01(\bR\x10idempotentReplay\"\xc5\x01\n" + + "\x1aLuckyGiftUserProfileWindow\x12\x14\n" + + "\x05draws\x18\x01 \x01(\x03R\x05draws\x12\x1f\n" + + "\vwager_coins\x18\x02 \x01(\x03R\n" + + "wagerCoins\x12!\n" + + "\fpayout_coins\x18\x03 \x01(\x03R\vpayoutCoins\x12\x1b\n" + + "\tnet_coins\x18\x04 \x01(\x03R\bnetCoins\x12\x17\n" + + "\artp_ppm\x18\x05 \x01(\x03R\x06rtpPpm\x12\x17\n" + + "\ahas_rtp\x18\x06 \x01(\bR\x06hasRtp\"\xfd\r\n" + + "\x14LuckyGiftUserProfile\x12\x19\n" + + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12#\n" + + "\ridentity_type\x18\x03 \x01(\tR\fidentityType\x12(\n" + + "\x10internal_user_id\x18\x04 \x01(\x03R\x0einternalUserId\x12(\n" + + "\x10external_user_id\x18\x05 \x01(\tR\x0eexternalUserId\x12!\n" + + "\frule_version\x18\x06 \x01(\x03R\vruleVersion\x12)\n" + + "\x10strategy_version\x18\a \x01(\tR\x0fstrategyVersion\x12\x14\n" + + "\x05stage\x18\b \x01(\tR\x05stage\x12\x1d\n" + + "\n" + + "paid_draws\x18\t \x01(\x03R\tpaidDraws\x12)\n" + + "\x10equivalent_draws\x18\n" + + " \x01(\x03R\x0fequivalentDraws\x12\x1f\n" + + "\vloss_streak\x18\v \x01(\x03R\n" + + "lossStreak\x12?\n" + + "\x1cpending_spend_jackpot_tokens\x18\f \x01(\x03R\x19pendingSpendJackpotTokens\x12:\n" + + "\x19guarantee_draws_remaining\x18\r \x01(\x03R\x17guaranteeDrawsRemaining\x12+\n" + + "\x11downweight_active\x18\x0e \x01(\bR\x10downweightActive\x122\n" + + "\x15downweight_factor_ppm\x18\x0f \x01(\x03R\x13downweightFactorPpm\x12I\n" + + "\bone_hour\x18\x10 \x01(\v2..hyapp.luckygift.v1.LuckyGiftUserProfileWindowR\aoneHour\x12Z\n" + + "\x11twenty_four_hours\x18\x11 \x01(\v2..hyapp.luckygift.v1.LuckyGiftUserProfileWindowR\x0ftwentyFourHours\x12Z\n" + + "\x11forty_eight_hours\x18\x12 \x01(\v2..hyapp.luckygift.v1.LuckyGiftUserProfileWindowR\x0ffortyEightHours\x12J\n" + + "\blifetime\x18\x13 \x01(\v2..hyapp.luckygift.v1.LuckyGiftUserProfileWindowR\blifetime\x12*\n" + + "\x11base_reward_coins\x18\x14 \x01(\x03R\x0fbaseRewardCoins\x12,\n" + + "\x12ordinary_win_count\x18\x15 \x01(\x03R\x10ordinaryWinCount\x12J\n" + + "\"high_multiplier_ordinary_win_count\x18\x16 \x01(\x03R\x1ehighMultiplierOrdinaryWinCount\x12C\n" + + "\x1ertp_compensation_jackpot_count\x18\x17 \x01(\x03R\x1brtpCompensationJackpotCount\x12C\n" + + "\x1ecumulative_spend_jackpot_count\x18\x18 \x01(\x03R\x1bcumulativeSpendJackpotCount\x12.\n" + + "\x13guarantee_hit_count\x18\x19 \x01(\x03R\x11guaranteeHitCount\x12&\n" + + "\x0fzero_draw_count\x18\x1a \x01(\x03R\rzeroDrawCount\x12,\n" + + "\x12granted_draw_count\x18\x1b \x01(\x03R\x10grantedDrawCount\x12,\n" + + "\x12pending_draw_count\x18\x1c \x01(\x03R\x10pendingDrawCount\x12*\n" + + "\x11failed_draw_count\x18\x1d \x01(\x03R\x0ffailedDrawCount\x12,\n" + + "\x12max_multiplier_ppm\x18\x1e \x01(\x03R\x10maxMultiplierPpm\x12(\n" + + "\x10max_reward_coins\x18\x1f \x01(\x03R\x0emaxRewardCoins\x12'\n" + + "\x10first_draw_at_ms\x18 \x01(\x03R\rfirstDrawAtMs\x12%\n" + + "\x0flast_draw_at_ms\x18! \x01(\x03R\flastDrawAtMs\x12(\n" + + "\x10aggregated_at_ms\x18\" \x01(\x03R\x0eaggregatedAtMs\x12\x1e\n" + + "\vdata_lag_ms\x18# \x01(\x03R\tdataLagMs\x12:\n" + + "\x1auser_24h_rtp_threshold_ppm\x18$ \x01(\x03R\x16user24hRtpThresholdPpm\"\xa0\x03\n" + + " ListLuckyGiftUserProfilesRequest\x123\n" + + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12#\n" + + "\ridentity_type\x18\x03 \x01(\tR\fidentityType\x12(\n" + + "\x10internal_user_id\x18\x04 \x01(\x03R\x0einternalUserId\x12(\n" + + "\x10external_user_id\x18\x05 \x01(\tR\x0eexternalUserId\x12\x14\n" + + "\x05stage\x18\x06 \x01(\tR\x05stage\x12\x16\n" + + "\x06status\x18\a \x01(\tR\x06status\x12\x17\n" + + "\asort_by\x18\b \x01(\tR\x06sortBy\x12%\n" + + "\x0esort_direction\x18\t \x01(\tR\rsortDirection\x12\x12\n" + + "\x04page\x18\n" + + " \x01(\x05R\x04page\x12\x1b\n" + + "\tpage_size\x18\v \x01(\x05R\bpageSize\x12\x16\n" + + "\x06cursor\x18\f \x01(\tR\x06cursor\"\xe1\x01\n" + + "!ListLuckyGiftUserProfilesResponse\x12D\n" + + "\bprofiles\x18\x01 \x03(\v2(.hyapp.luckygift.v1.LuckyGiftUserProfileR\bprofiles\x12\x14\n" + + "\x05total\x18\x02 \x01(\x03R\x05total\x12$\n" + + "\x0esnapshot_at_ms\x18\x03 \x01(\x03R\fsnapshotAtMs\x12\x1f\n" + + "\vnext_cursor\x18\x04 \x01(\tR\n" + + "nextCursor\x12\x19\n" + + "\bhas_more\x18\x05 \x01(\bR\ahasMore\"\xdd\x02\n" + + "\x1eGetLuckyGiftUserProfileRequest\x123\n" + + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12#\n" + + "\ridentity_type\x18\x03 \x01(\tR\fidentityType\x12(\n" + + "\x10internal_user_id\x18\x04 \x01(\x03R\x0einternalUserId\x12(\n" + + "\x10external_user_id\x18\x05 \x01(\tR\x0eexternalUserId\x12!\n" + + "\fjackpot_page\x18\x06 \x01(\x05R\vjackpotPage\x12*\n" + + "\x11jackpot_page_size\x18\a \x01(\x05R\x0fjackpotPageSize\x12%\n" + + "\x0ejackpot_cursor\x18\b \x01(\tR\rjackpotCursor\"\xc2\x01\n" + + "\x1bLuckyGiftUserMultiplierStat\x12\x19\n" + + "\bhit_type\x18\x01 \x01(\tR\ahitType\x12%\n" + + "\x0emultiplier_ppm\x18\x02 \x01(\x03R\rmultiplierPpm\x12\x1d\n" + + "\n" + + "draw_count\x18\x03 \x01(\x03R\tdrawCount\x12\x1f\n" + + "\vwager_coins\x18\x04 \x01(\x03R\n" + + "wagerCoins\x12!\n" + + "\fpayout_coins\x18\x05 \x01(\x03R\vpayoutCoins\"\xfc\x01\n" + + "\x1dLuckyGiftUserJackpotCondition\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tnumerator\x18\x02 \x01(\x03R\tnumerator\x12 \n" + + "\vdenominator\x18\x03 \x01(\x03R\vdenominator\x12\x1b\n" + + "\tratio_ppm\x18\x04 \x01(\x03R\bratioPpm\x12\x1b\n" + + "\tlimit_ppm\x18\x05 \x01(\x03R\blimitPpm\x12\x1d\n" + + "\n" + + "factor_ppm\x18\x06 \x01(\x03R\tfactorPpm\x12\x16\n" + + "\x06passed\x18\a \x01(\bR\x06passed\x12\x16\n" + + "\x06reason\x18\b \x01(\tR\x06reason\"\x8a\x04\n" + + "\x17LuckyGiftUserJackpotHit\x12\x17\n" + + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + + "\n" + + "request_id\x18\x02 \x01(\tR\trequestId\x12!\n" + + "\frule_version\x18\x03 \x01(\x03R\vruleVersion\x12\x14\n" + + "\x05stage\x18\x04 \x01(\tR\x05stage\x12\x1c\n" + + "\tmechanism\x18\x05 \x01(\tR\tmechanism\x12\x1f\n" + + "\vreason_code\x18\x06 \x01(\tR\n" + + "reasonCode\x12%\n" + + "\x0ereason_summary\x18\a \x01(\tR\rreasonSummary\x12\x17\n" + + "\atier_id\x18\b \x01(\tR\x06tierId\x12%\n" + + "\x0emultiplier_ppm\x18\t \x01(\x03R\rmultiplierPpm\x12\x1f\n" + + "\vwager_coins\x18\n" + + " \x01(\x03R\n" + + "wagerCoins\x12!\n" + + "\fpayout_coins\x18\v \x01(\x03R\vpayoutCoins\x12$\n" + + "\x0eoccurred_at_ms\x18\f \x01(\x03R\foccurredAtMs\x12Q\n" + + "\n" + + "conditions\x18\r \x03(\v21.hyapp.luckygift.v1.LuckyGiftUserJackpotConditionR\n" + + "conditions\x12\x1b\n" + + "\tevent_key\x18\x0e \x01(\tR\beventKey\"\x9a\x04\n" + + "\x1fGetLuckyGiftUserProfileResponse\x12B\n" + + "\aprofile\x18\x01 \x01(\v2(.hyapp.luckygift.v1.LuckyGiftUserProfileR\aprofile\x12h\n" + + "\x17multiplier_distribution\x18\x02 \x03(\v2/.hyapp.luckygift.v1.LuckyGiftUserMultiplierStatR\x16multiplierDistribution\x12N\n" + + "\fjackpot_hits\x18\x03 \x03(\v2+.hyapp.luckygift.v1.LuckyGiftUserJackpotHitR\vjackpotHits\x12*\n" + + "\x11jackpot_hit_total\x18\x04 \x01(\x03R\x0fjackpotHitTotal\x12$\n" + + "\x0esnapshot_at_ms\x18\x05 \x01(\x03R\fsnapshotAtMs\x12!\n" + + "\fjackpot_page\x18\x06 \x01(\x05R\vjackpotPage\x12*\n" + + "\x11jackpot_page_size\x18\a \x01(\x05R\x0fjackpotPageSize\x12.\n" + + "\x13next_jackpot_cursor\x18\b \x01(\tR\x11nextJackpotCursor\x12(\n" + + "\x10jackpot_has_more\x18\t \x01(\bR\x0ejackpotHasMore2\xf7\x03\n" + "\x10LuckyGiftService\x12g\n" + "\x0eCheckLuckyGift\x12).hyapp.luckygift.v1.CheckLuckyGiftRequest\x1a*.hyapp.luckygift.v1.CheckLuckyGiftResponse\x12y\n" + "\x14ExecuteLuckyGiftDraw\x12/.hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest\x1a0.hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse\x12\x88\x01\n" + "\x19BatchExecuteLuckyGiftDraw\x124.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest\x1a5.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse\x12t\n" + - "\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\xd4\f\n" + + "\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\xe4\x0e\n" + "\x15AdminLuckyGiftService\x12s\n" + "\x12GetLuckyGiftConfig\x12-.hyapp.luckygift.v1.GetLuckyGiftConfigRequest\x1a..hyapp.luckygift.v1.GetLuckyGiftConfigResponse\x12|\n" + "\x15UpsertLuckyGiftConfig\x120.hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest\x1a1.hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse\x12y\n" + @@ -4344,7 +5666,9 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x18ListLuckyGiftExperiments\x123.hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest\x1a4.hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse\x12\x88\x01\n" + "\x19UpdateLuckyGiftExperiment\x124.hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest\x1a5.hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse\x12\x9a\x01\n" + "\x1fSetLuckyGiftExperimentOverrides\x12:.hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest\x1a;.hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse\x12\x7f\n" + - "\x16EndLuckyGiftExperiment\x121.hyapp.luckygift.v1.EndLuckyGiftExperimentRequest\x1a2.hyapp.luckygift.v1.EndLuckyGiftExperimentResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3" + "\x16EndLuckyGiftExperiment\x121.hyapp.luckygift.v1.EndLuckyGiftExperimentRequest\x1a2.hyapp.luckygift.v1.EndLuckyGiftExperimentResponse\x12\x88\x01\n" + + "\x19ListLuckyGiftUserProfiles\x124.hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest\x1a5.hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse\x12\x82\x01\n" + + "\x17GetLuckyGiftUserProfile\x122.hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest\x1a3.hyapp.luckygift.v1.GetLuckyGiftUserProfileResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3" var ( file_proto_luckygift_v1_luckygift_proto_rawDescOnce sync.Once @@ -4358,7 +5682,7 @@ func file_proto_luckygift_v1_luckygift_proto_rawDescGZIP() []byte { return file_proto_luckygift_v1_luckygift_proto_rawDescData } -var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 45) +var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 54) var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{ (*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta (*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta @@ -4405,6 +5729,15 @@ var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{ (*AdjustLuckyGiftPoolBalanceRequest)(nil), // 42: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest (*LuckyGiftPoolAdjustment)(nil), // 43: hyapp.luckygift.v1.LuckyGiftPoolAdjustment (*AdjustLuckyGiftPoolBalanceResponse)(nil), // 44: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse + (*LuckyGiftUserProfileWindow)(nil), // 45: hyapp.luckygift.v1.LuckyGiftUserProfileWindow + (*LuckyGiftUserProfile)(nil), // 46: hyapp.luckygift.v1.LuckyGiftUserProfile + (*ListLuckyGiftUserProfilesRequest)(nil), // 47: hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest + (*ListLuckyGiftUserProfilesResponse)(nil), // 48: hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse + (*GetLuckyGiftUserProfileRequest)(nil), // 49: hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest + (*LuckyGiftUserMultiplierStat)(nil), // 50: hyapp.luckygift.v1.LuckyGiftUserMultiplierStat + (*LuckyGiftUserJackpotCondition)(nil), // 51: hyapp.luckygift.v1.LuckyGiftUserJackpotCondition + (*LuckyGiftUserJackpotHit)(nil), // 52: hyapp.luckygift.v1.LuckyGiftUserJackpotHit + (*GetLuckyGiftUserProfileResponse)(nil), // 53: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse } var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{ 0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta @@ -4450,43 +5783,58 @@ var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{ 0, // 40: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta 43, // 41: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.adjustment:type_name -> hyapp.luckygift.v1.LuckyGiftPoolAdjustment 23, // 42: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.pool:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance - 5, // 43: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest - 8, // 44: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest - 10, // 45: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest - 12, // 46: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest - 14, // 47: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest - 16, // 48: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest - 18, // 49: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest - 20, // 50: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest - 24, // 51: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest - 26, // 52: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest - 42, // 53: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest - 31, // 54: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest - 33, // 55: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:input_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest - 35, // 56: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest - 37, // 57: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:input_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest - 39, // 58: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentRequest - 6, // 59: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse - 9, // 60: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse - 11, // 61: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse - 13, // 62: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse - 15, // 63: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse - 17, // 64: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse - 19, // 65: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse - 21, // 66: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse - 25, // 67: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse - 27, // 68: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse - 44, // 69: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse - 32, // 70: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse - 34, // 71: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:output_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse - 36, // 72: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse - 38, // 73: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:output_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse - 40, // 74: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentResponse - 59, // [59:75] is the sub-list for method output_type - 43, // [43:59] is the sub-list for method input_type - 43, // [43:43] is the sub-list for extension type_name - 43, // [43:43] is the sub-list for extension extendee - 0, // [0:43] is the sub-list for field type_name + 45, // 43: hyapp.luckygift.v1.LuckyGiftUserProfile.one_hour:type_name -> hyapp.luckygift.v1.LuckyGiftUserProfileWindow + 45, // 44: hyapp.luckygift.v1.LuckyGiftUserProfile.twenty_four_hours:type_name -> hyapp.luckygift.v1.LuckyGiftUserProfileWindow + 45, // 45: hyapp.luckygift.v1.LuckyGiftUserProfile.forty_eight_hours:type_name -> hyapp.luckygift.v1.LuckyGiftUserProfileWindow + 45, // 46: hyapp.luckygift.v1.LuckyGiftUserProfile.lifetime:type_name -> hyapp.luckygift.v1.LuckyGiftUserProfileWindow + 0, // 47: hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 46, // 48: hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse.profiles:type_name -> hyapp.luckygift.v1.LuckyGiftUserProfile + 0, // 49: hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 51, // 50: hyapp.luckygift.v1.LuckyGiftUserJackpotHit.conditions:type_name -> hyapp.luckygift.v1.LuckyGiftUserJackpotCondition + 46, // 51: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.profile:type_name -> hyapp.luckygift.v1.LuckyGiftUserProfile + 50, // 52: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.multiplier_distribution:type_name -> hyapp.luckygift.v1.LuckyGiftUserMultiplierStat + 52, // 53: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.jackpot_hits:type_name -> hyapp.luckygift.v1.LuckyGiftUserJackpotHit + 5, // 54: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest + 8, // 55: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest + 10, // 56: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest + 12, // 57: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest + 14, // 58: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest + 16, // 59: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest + 18, // 60: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest + 20, // 61: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest + 24, // 62: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest + 26, // 63: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest + 42, // 64: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest + 31, // 65: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest + 33, // 66: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:input_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest + 35, // 67: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest + 37, // 68: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:input_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest + 39, // 69: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentRequest + 47, // 70: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:input_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest + 49, // 71: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:input_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest + 6, // 72: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse + 9, // 73: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse + 11, // 74: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse + 13, // 75: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse + 15, // 76: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse + 17, // 77: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse + 19, // 78: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse + 21, // 79: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse + 25, // 80: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse + 27, // 81: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse + 44, // 82: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse + 32, // 83: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse + 34, // 84: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:output_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse + 36, // 85: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse + 38, // 86: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:output_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse + 40, // 87: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentResponse + 48, // 88: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:output_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse + 53, // 89: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:output_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse + 72, // [72:90] is the sub-list for method output_type + 54, // [54:72] is the sub-list for method input_type + 54, // [54:54] is the sub-list for extension type_name + 54, // [54:54] is the sub-list for extension extendee + 0, // [0:54] is the sub-list for field type_name } func init() { file_proto_luckygift_v1_luckygift_proto_init() } @@ -4501,7 +5849,7 @@ func file_proto_luckygift_v1_luckygift_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_luckygift_v1_luckygift_proto_rawDesc), len(file_proto_luckygift_v1_luckygift_proto_rawDesc)), NumEnums: 0, - NumMessages: 45, + NumMessages: 54, NumExtensions: 0, NumServices: 2, }, diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index 76a835b6..2f36e2e4 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -42,6 +42,9 @@ message LuckyGiftMeta { int64 last_recharged_at_ms = 21; // gift_income_coins 是 wallet 已实际入账给收礼人的收益金币;dynamic_v3 用它校验规则主播拆账,缺失或比例不一致均拒绝开奖。 int64 gift_income_coins = 22; + // user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。 + // dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。 + int64 user_registered_at_ms = 23; } message LuckyGiftRuleTier { @@ -91,8 +94,9 @@ message LuckyGiftRuleConfig { int64 recharge_boost_factor_ppm = 26; repeated int64 jackpot_multiplier_ppms = 27; int64 jackpot_global_rtp_max_ppm = 28; - int64 jackpot_user_day_rtp_max_ppm = 29; - int64 jackpot_user_72h_rtp_max_ppm = 30; + int64 jackpot_user_round_rtp_max_ppm = 29; + int64 jackpot_user_48h_rtp_max_ppm = 30; + // 用户在同一应用、奖池和 UTC 自然日每累计消费满该金币数,生成一次可跨日保留的消费大奖资格。 int64 jackpot_spend_threshold_coins = 31; int64 max_jackpot_hits_per_user_day = 32; int64 max_single_payout = 33; @@ -101,6 +105,10 @@ message LuckyGiftRuleConfig { int64 device_daily_payout_cap = 36; int64 room_hourly_payout_cap = 37; int64 anchor_daily_payout_cap = 38; + // 用户严格滚动 24 小时 RTP 超过此值时,普通非零奖档按下方因子降权;0 表示关闭该策略。 + int64 user_24h_rtp_threshold_ppm = 39; + // 触发用户 24 小时 RTP 降权后保留的普通中奖权重;1000000 表示保持原概率。 + int64 user_24h_ordinary_win_factor_ppm = 40; } message CheckLuckyGiftRequest { @@ -183,6 +191,8 @@ message ExternalGiftDrawRequest { string pool_id = 12; // dynamic_v3 必填的稳定设备作用域。luck-gateway 不独立证明其真实性,调用方必须在自己的认证/签名边界内绑定该值;fixed_v2 允许缺失。 string device_id = 13; + // user_registered_at_ms 由外部 App 按自己的用户主数据提供;dynamic_v3 缺失或晚于支付时间时补偿大奖资格 fail-close。 + int64 user_registered_at_ms = 14; } message ExternalGiftDrawResponse { @@ -433,6 +443,8 @@ service AdminLuckyGiftService { rpc UpdateLuckyGiftExperiment(UpdateLuckyGiftExperimentRequest) returns (UpdateLuckyGiftExperimentResponse); rpc SetLuckyGiftExperimentOverrides(SetLuckyGiftExperimentOverridesRequest) returns (SetLuckyGiftExperimentOverridesResponse); rpc EndLuckyGiftExperiment(EndLuckyGiftExperimentRequest) returns (EndLuckyGiftExperimentResponse); + rpc ListLuckyGiftUserProfiles(ListLuckyGiftUserProfilesRequest) returns (ListLuckyGiftUserProfilesResponse); + rpc GetLuckyGiftUserProfile(GetLuckyGiftUserProfileRequest) returns (GetLuckyGiftUserProfileResponse); } // LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。 @@ -477,3 +489,138 @@ message AdjustLuckyGiftPoolBalanceResponse { LuckyGiftPoolBalance pool = 2; bool idempotent_replay = 3; } + +// 用户画像协议追加在文件尾,避免改变既有 message index。外部策略哈希只存在于 owner 数据库, +// 本协议永远只下发 external_user_id,防止运营误把不可逆内部键当成外部用户身份。 +message LuckyGiftUserProfileWindow { + int64 draws = 1; + int64 wager_coins = 2; + int64 payout_coins = 3; + int64 net_coins = 4; + int64 rtp_ppm = 5; + bool has_rtp = 6; +} + +message LuckyGiftUserProfile { + string app_code = 1; + string pool_id = 2; + string identity_type = 3; + int64 internal_user_id = 4; + string external_user_id = 5; + int64 rule_version = 6; + string strategy_version = 7; + string stage = 8; + int64 paid_draws = 9; + int64 equivalent_draws = 10; + int64 loss_streak = 11; + int64 pending_spend_jackpot_tokens = 12; + int64 guarantee_draws_remaining = 13; + bool downweight_active = 14; + int64 downweight_factor_ppm = 15; + LuckyGiftUserProfileWindow one_hour = 16; + LuckyGiftUserProfileWindow twenty_four_hours = 17; + LuckyGiftUserProfileWindow forty_eight_hours = 18; + LuckyGiftUserProfileWindow lifetime = 19; + int64 base_reward_coins = 20; + int64 ordinary_win_count = 21; + int64 high_multiplier_ordinary_win_count = 22; + int64 rtp_compensation_jackpot_count = 23; + int64 cumulative_spend_jackpot_count = 24; + int64 guarantee_hit_count = 25; + int64 zero_draw_count = 26; + int64 granted_draw_count = 27; + int64 pending_draw_count = 28; + int64 failed_draw_count = 29; + int64 max_multiplier_ppm = 30; + int64 max_reward_coins = 31; + int64 first_draw_at_ms = 32; + int64 last_draw_at_ms = 33; + int64 aggregated_at_ms = 34; + int64 data_lag_ms = 35; + int64 user_24h_rtp_threshold_ppm = 36; +} + +message ListLuckyGiftUserProfilesRequest { + RequestMeta meta = 1; + string pool_id = 2; + string identity_type = 3; + int64 internal_user_id = 4; + string external_user_id = 5; + string stage = 6; + string status = 7; + string sort_by = 8; + string sort_direction = 9; + int32 page = 10; + int32 page_size = 11; + // cursor 是 owner service 生成的不透明 keyset 游标;page 仅保留旧客户端协议兼容,服务端不再执行 OFFSET。 + string cursor = 12; +} + +message ListLuckyGiftUserProfilesResponse { + repeated LuckyGiftUserProfile profiles = 1; + int64 total = 2; + int64 snapshot_at_ms = 3; + string next_cursor = 4; + bool has_more = 5; +} + +message GetLuckyGiftUserProfileRequest { + RequestMeta meta = 1; + string pool_id = 2; + string identity_type = 3; + int64 internal_user_id = 4; + string external_user_id = 5; + int32 jackpot_page = 6; + int32 jackpot_page_size = 7; + // jackpot_cursor 由上一页 next_jackpot_cursor 原样返回;jackpot_page 仅保留协议兼容。 + string jackpot_cursor = 8; +} + +message LuckyGiftUserMultiplierStat { + string hit_type = 1; + int64 multiplier_ppm = 2; + int64 draw_count = 3; + int64 wager_coins = 4; + int64 payout_coins = 5; +} + +message LuckyGiftUserJackpotCondition { + string name = 1; + int64 numerator = 2; + int64 denominator = 3; + int64 ratio_ppm = 4; + int64 limit_ppm = 5; + int64 factor_ppm = 6; + bool passed = 7; + string reason = 8; +} + +message LuckyGiftUserJackpotHit { + string draw_id = 1; + string request_id = 2; + int64 rule_version = 3; + string stage = 4; + string mechanism = 5; + string reason_code = 6; + string reason_summary = 7; + string tier_id = 8; + int64 multiplier_ppm = 9; + int64 wager_coins = 10; + int64 payout_coins = 11; + int64 occurred_at_ms = 12; + repeated LuckyGiftUserJackpotCondition conditions = 13; + // event_key 只标识这条画像大奖事实,供前端稳定追加去重;不包含用户策略键或用户哈希。 + string event_key = 14; +} + +message GetLuckyGiftUserProfileResponse { + LuckyGiftUserProfile profile = 1; + repeated LuckyGiftUserMultiplierStat multiplier_distribution = 2; + repeated LuckyGiftUserJackpotHit jackpot_hits = 3; + int64 jackpot_hit_total = 4; + int64 snapshot_at_ms = 5; + int32 jackpot_page = 6; + int32 jackpot_page_size = 7; + string next_jackpot_cursor = 8; + bool jackpot_has_more = 9; +} diff --git a/api/proto/luckygift/v1/luckygift_grpc.pb.go b/api/proto/luckygift/v1/luckygift_grpc.pb.go index 2f207f08..4dc66011 100644 --- a/api/proto/luckygift/v1/luckygift_grpc.pb.go +++ b/api/proto/luckygift/v1/luckygift_grpc.pb.go @@ -247,6 +247,8 @@ const ( AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpdateLuckyGiftExperiment" AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/SetLuckyGiftExperimentOverrides" AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/EndLuckyGiftExperiment" + AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftUserProfiles" + AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftUserProfile" ) // AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service. @@ -265,6 +267,8 @@ type AdminLuckyGiftServiceClient interface { UpdateLuckyGiftExperiment(ctx context.Context, in *UpdateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*UpdateLuckyGiftExperimentResponse, error) SetLuckyGiftExperimentOverrides(ctx context.Context, in *SetLuckyGiftExperimentOverridesRequest, opts ...grpc.CallOption) (*SetLuckyGiftExperimentOverridesResponse, error) EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error) + ListLuckyGiftUserProfiles(ctx context.Context, in *ListLuckyGiftUserProfilesRequest, opts ...grpc.CallOption) (*ListLuckyGiftUserProfilesResponse, error) + GetLuckyGiftUserProfile(ctx context.Context, in *GetLuckyGiftUserProfileRequest, opts ...grpc.CallOption) (*GetLuckyGiftUserProfileResponse, error) } type adminLuckyGiftServiceClient struct { @@ -395,6 +399,26 @@ func (c *adminLuckyGiftServiceClient) EndLuckyGiftExperiment(ctx context.Context return out, nil } +func (c *adminLuckyGiftServiceClient) ListLuckyGiftUserProfiles(ctx context.Context, in *ListLuckyGiftUserProfilesRequest, opts ...grpc.CallOption) (*ListLuckyGiftUserProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListLuckyGiftUserProfilesResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *adminLuckyGiftServiceClient) GetLuckyGiftUserProfile(ctx context.Context, in *GetLuckyGiftUserProfileRequest, opts ...grpc.CallOption) (*GetLuckyGiftUserProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetLuckyGiftUserProfileResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service. // All implementations must embed UnimplementedAdminLuckyGiftServiceServer // for forward compatibility. @@ -411,6 +435,8 @@ type AdminLuckyGiftServiceServer interface { UpdateLuckyGiftExperiment(context.Context, *UpdateLuckyGiftExperimentRequest) (*UpdateLuckyGiftExperimentResponse, error) SetLuckyGiftExperimentOverrides(context.Context, *SetLuckyGiftExperimentOverridesRequest) (*SetLuckyGiftExperimentOverridesResponse, error) EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error) + ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error) + GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error) mustEmbedUnimplementedAdminLuckyGiftServiceServer() } @@ -457,6 +483,12 @@ func (UnimplementedAdminLuckyGiftServiceServer) SetLuckyGiftExperimentOverrides( func (UnimplementedAdminLuckyGiftServiceServer) EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error) { return nil, status.Error(codes.Unimplemented, "method EndLuckyGiftExperiment not implemented") } +func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftUserProfiles not implemented") +} +func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetLuckyGiftUserProfile not implemented") +} func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {} func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {} @@ -694,6 +726,42 @@ func _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler(srv interface{}, ctx return interceptor(ctx, in, info, handler) } +func _AdminLuckyGiftService_ListLuckyGiftUserProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListLuckyGiftUserProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftUserProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftUserProfiles(ctx, req.(*ListLuckyGiftUserProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetLuckyGiftUserProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftUserProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftUserProfile(ctx, req.(*GetLuckyGiftUserProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -749,6 +817,14 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{ MethodName: "EndLuckyGiftExperiment", Handler: _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler, }, + { + MethodName: "ListLuckyGiftUserProfiles", + Handler: _AdminLuckyGiftService_ListLuckyGiftUserProfiles_Handler, + }, + { + MethodName: "GetLuckyGiftUserProfile", + Handler: _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "proto/luckygift/v1/luckygift.proto", diff --git a/api/proto/robot/v1/robot.pb.go b/api/proto/robot/v1/robot.pb.go index becc325c..a5f2f5de 100644 --- a/api/proto/robot/v1/robot.pb.go +++ b/api/proto/robot/v1/robot.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/robot/v1/robot.proto package robotv1 diff --git a/api/proto/robot/v1/robot_grpc.pb.go b/api/proto/robot/v1/robot_grpc.pb.go index c1bb2b2e..3cf9ceb8 100644 --- a/api/proto/robot/v1/robot_grpc.pb.go +++ b/api/proto/robot/v1/robot_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/robot/v1/robot.proto package robotv1 diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index f05ddd78..628a8aa9 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/room/v1/room.proto package roomv1 @@ -9488,8 +9488,11 @@ type SendGiftRequest struct { // combo_session_id + batch_seq 只描述 Flutter 微批会话,command_id 仍是跨服务唯一幂等键。 ComboSessionId string `protobuf:"bytes,19,opt,name=combo_session_id,json=comboSessionId,proto3" json:"combo_session_id,omitempty"` BatchSeq int64 `protobuf:"varint,20,opt,name=batch_seq,json=batchSeq,proto3" json:"batch_seq,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sender_registered_at_ms 是 gateway 从 user-service 读取的账号创建事实;客户端不能提交或覆盖。 + // room-service 只随首次送礼命令持久化并透传给 lucky-gift-service,不据此拥有用户主数据。 + SenderRegisteredAtMs int64 `protobuf:"varint,21,opt,name=sender_registered_at_ms,json=senderRegisteredAtMs,proto3" json:"sender_registered_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendGiftRequest) Reset() { @@ -9662,6 +9665,13 @@ func (x *SendGiftRequest) GetBatchSeq() int64 { return 0 } +func (x *SendGiftRequest) GetSenderRegisteredAtMs() int64 { + if x != nil { + return x.SenderRegisteredAtMs + } + return 0 +} + // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 type SendGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -13432,7 +13442,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x0ftarget_user_ids\x18\x14 \x03(\x03R\rtargetUserIds\x12<\n" + "\atargets\x18\x15 \x03(\v2\".hyapp.room.v1.SendGiftBatchTargetR\atargets\x12\x1d\n" + "\n" + - "command_id\x18\x16 \x01(\tR\tcommandId\"\xa9\a\n" + + "command_id\x18\x16 \x01(\tR\tcommandId\"\xe0\a\n" + "\x0fSendGiftRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + @@ -13456,7 +13466,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x17target_display_profiles\x18\x11 \x03(\v2%.hyapp.room.v1.SendGiftDisplayProfileR\x15targetDisplayProfiles\x12!\n" + "\fdisplay_mode\x18\x12 \x01(\tR\vdisplayMode\x12(\n" + "\x10combo_session_id\x18\x13 \x01(\tR\x0ecomboSessionId\x12\x1b\n" + - "\tbatch_seq\x18\x14 \x01(\x03R\bbatchSeq\"\x9e\x05\n" + + "\tbatch_seq\x18\x14 \x01(\x03R\bbatchSeq\x125\n" + + "\x17sender_registered_at_ms\x18\x15 \x01(\x03R\x14senderRegisteredAtMs\"\x9e\x05\n" + "\x10SendGiftResponse\x124\n" + "\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" + "\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index a887bc2c..f530360d 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -1163,6 +1163,9 @@ message SendGiftRequest { // combo_session_id + batch_seq 只描述 Flutter 微批会话,command_id 仍是跨服务唯一幂等键。 string combo_session_id = 19; int64 batch_seq = 20; + // sender_registered_at_ms 是 gateway 从 user-service 读取的账号创建事实;客户端不能提交或覆盖。 + // room-service 只随首次送礼命令持久化并透传给 lucky-gift-service,不据此拥有用户主数据。 + int64 sender_registered_at_ms = 21; } // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 diff --git a/api/proto/room/v1/room_grpc.pb.go b/api/proto/room/v1/room_grpc.pb.go index b647f921..fcc21dab 100644 --- a/api/proto/room/v1/room_grpc.pb.go +++ b/api/proto/room/v1/room_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/room/v1/room.proto package roomv1 diff --git a/api/proto/user/v1/auth.pb.go b/api/proto/user/v1/auth.pb.go index ebf7b843..0dd2dc3e 100644 --- a/api/proto/user/v1/auth.pb.go +++ b/api/proto/user/v1/auth.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/user/v1/auth.proto package userv1 diff --git a/api/proto/user/v1/auth_grpc.pb.go b/api/proto/user/v1/auth_grpc.pb.go index 97e929a5..c7a713cf 100644 --- a/api/proto/user/v1/auth_grpc.pb.go +++ b/api/proto/user/v1/auth_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/user/v1/auth.proto package userv1 diff --git a/api/proto/user/v1/host.pb.go b/api/proto/user/v1/host.pb.go index 0d0855a5..6c41d455 100644 --- a/api/proto/user/v1/host.pb.go +++ b/api/proto/user/v1/host.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/user/v1/host.proto package userv1 diff --git a/api/proto/user/v1/host_grpc.pb.go b/api/proto/user/v1/host_grpc.pb.go index 999c6526..d9959a03 100644 --- a/api/proto/user/v1/host_grpc.pb.go +++ b/api/proto/user/v1/host_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/user/v1/host.proto package userv1 diff --git a/api/proto/user/v1/user.pb.go b/api/proto/user/v1/user.pb.go index e3bb1eed..90594456 100644 --- a/api/proto/user/v1/user.pb.go +++ b/api/proto/user/v1/user.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/user/v1/user.proto package userv1 @@ -13348,6 +13348,105 @@ func (x *ListAppsResponse) GetApps() []*App { return nil } +// ResolveAdminUserIdentifierRequest 是后台精确查人的内部契约。与客户端展示号解析不同, +// user_identifier 可以是永久默认短号、当前有效展示号或系统长 user_id。 +type ResolveAdminUserIdentifierRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + UserIdentifier string `protobuf:"bytes,2,opt,name=user_identifier,json=userIdentifier,proto3" json:"user_identifier,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolveAdminUserIdentifierRequest) Reset() { + *x = ResolveAdminUserIdentifierRequest{} + mi := &file_proto_user_v1_user_proto_msgTypes[182] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolveAdminUserIdentifierRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolveAdminUserIdentifierRequest) ProtoMessage() {} + +func (x *ResolveAdminUserIdentifierRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[182] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolveAdminUserIdentifierRequest.ProtoReflect.Descriptor instead. +func (*ResolveAdminUserIdentifierRequest) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{182} +} + +func (x *ResolveAdminUserIdentifierRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ResolveAdminUserIdentifierRequest) GetUserIdentifier() string { + if x != nil { + return x.UserIdentifier + } + return "" +} + +// ResolveAdminUserIdentifierResponse 返回匹配用户的当前有效身份投影。 +type ResolveAdminUserIdentifierResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity *UserIdentity `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolveAdminUserIdentifierResponse) Reset() { + *x = ResolveAdminUserIdentifierResponse{} + mi := &file_proto_user_v1_user_proto_msgTypes[183] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolveAdminUserIdentifierResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolveAdminUserIdentifierResponse) ProtoMessage() {} + +func (x *ResolveAdminUserIdentifierResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[183] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolveAdminUserIdentifierResponse.ProtoReflect.Descriptor instead. +func (*ResolveAdminUserIdentifierResponse) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{183} +} + +func (x *ResolveAdminUserIdentifierResponse) GetIdentity() *UserIdentity { + if x != nil { + return x.Identity + } + return nil +} + var File_proto_user_v1_user_proto protoreflect.FileDescriptor const file_proto_user_v1_user_proto_rawDesc = "" + @@ -14477,7 +14576,12 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\x0fListAppsRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\":\n" + "\x10ListAppsResponse\x12&\n" + - "\x04apps\x18\x01 \x03(\v2\x12.hyapp.user.v1.AppR\x04apps*s\n" + + "\x04apps\x18\x01 \x03(\v2\x12.hyapp.user.v1.AppR\x04apps\"|\n" + + "!ResolveAdminUserIdentifierRequest\x12.\n" + + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12'\n" + + "\x0fuser_identifier\x18\x02 \x01(\tR\x0euserIdentifier\"]\n" + + "\"ResolveAdminUserIdentifierResponse\x127\n" + + "\bidentity\x18\x01 \x01(\v2\x1b.hyapp.user.v1.UserIdentityR\bidentity*s\n" + "\n" + "UserStatus\x12\x1b\n" + "\x17USER_STATUS_UNSPECIFIED\x10\x00\x12\x16\n" + @@ -14561,10 +14665,11 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\vListRegions\x12!.hyapp.user.v1.ListRegionsRequest\x1a\".hyapp.user.v1.ListRegionsResponse\x12K\n" + "\tGetRegion\x12\x1f.hyapp.user.v1.GetRegionRequest\x1a\x1d.hyapp.user.v1.RegionResponse\x12Q\n" + "\fUpdateRegion\x12\".hyapp.user.v1.UpdateRegionRequest\x1a\x1d.hyapp.user.v1.RegionResponse\x12e\n" + - "\x16ReplaceRegionCountries\x12,.hyapp.user.v1.ReplaceRegionCountriesRequest\x1a\x1d.hyapp.user.v1.RegionResponse2\xea\x06\n" + + "\x16ReplaceRegionCountries\x12,.hyapp.user.v1.ReplaceRegionCountriesRequest\x1a\x1d.hyapp.user.v1.RegionResponse2\xee\a\n" + "\x13UserIdentityService\x12`\n" + "\x0fGetUserIdentity\x12%.hyapp.user.v1.GetUserIdentityRequest\x1a&.hyapp.user.v1.GetUserIdentityResponse\x12o\n" + - "\x14ResolveDisplayUserID\x12*.hyapp.user.v1.ResolveDisplayUserIDRequest\x1a+.hyapp.user.v1.ResolveDisplayUserIDResponse\x12l\n" + + "\x14ResolveDisplayUserID\x12*.hyapp.user.v1.ResolveDisplayUserIDRequest\x1a+.hyapp.user.v1.ResolveDisplayUserIDResponse\x12\x81\x01\n" + + "\x1aResolveAdminUserIdentifier\x120.hyapp.user.v1.ResolveAdminUserIdentifierRequest\x1a1.hyapp.user.v1.ResolveAdminUserIdentifierResponse\x12l\n" + "\x13ChangeDisplayUserID\x12).hyapp.user.v1.ChangeDisplayUserIDRequest\x1a*.hyapp.user.v1.ChangeDisplayUserIDResponse\x12{\n" + "\x18ApplyPrettyDisplayUserID\x12..hyapp.user.v1.ApplyPrettyDisplayUserIDRequest\x1a/.hyapp.user.v1.ApplyPrettyDisplayUserIDResponse\x12\x8a\x01\n" + "\x1dListAvailablePrettyDisplayIDs\x123.hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest\x1a4.hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse\x12\x87\x01\n" + @@ -14593,7 +14698,7 @@ func file_proto_user_v1_user_proto_rawDescGZIP() []byte { } var file_proto_user_v1_user_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 185) +var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 187) var file_proto_user_v1_user_proto_goTypes = []any{ (UserStatus)(0), // 0: hyapp.user.v1.UserStatus (*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta @@ -14778,9 +14883,11 @@ var file_proto_user_v1_user_proto_goTypes = []any{ (*ListCPFormationGiftFeedResponse)(nil), // 180: hyapp.user.v1.ListCPFormationGiftFeedResponse (*ListAppsRequest)(nil), // 181: hyapp.user.v1.ListAppsRequest (*ListAppsResponse)(nil), // 182: hyapp.user.v1.ListAppsResponse - nil, // 183: hyapp.user.v1.BatchGetUsersResponse.UsersEntry - nil, // 184: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry - nil, // 185: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry + (*ResolveAdminUserIdentifierRequest)(nil), // 183: hyapp.user.v1.ResolveAdminUserIdentifierRequest + (*ResolveAdminUserIdentifierResponse)(nil), // 184: hyapp.user.v1.ResolveAdminUserIdentifierResponse + nil, // 185: hyapp.user.v1.BatchGetUsersResponse.UsersEntry + nil, // 186: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry + nil, // 187: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry } var file_proto_user_v1_user_proto_depIdxs = []int32{ 1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta @@ -14854,15 +14961,15 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{ 1, // 68: hyapp.user.v1.SubmitReportRequest.meta:type_name -> hyapp.user.v1.RequestMeta 78, // 69: hyapp.user.v1.SubmitReportResponse.report:type_name -> hyapp.user.v1.UserReport 1, // 70: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 183, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry + 185, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry 0, // 72: hyapp.user.v1.ActiveUserBanSummary.user_status:type_name -> hyapp.user.v1.UserStatus 5, // 73: hyapp.user.v1.UserAdminProfile.user:type_name -> hyapp.user.v1.User 83, // 74: hyapp.user.v1.UserAdminProfile.ban:type_name -> hyapp.user.v1.ActiveUserBanSummary 1, // 75: hyapp.user.v1.BatchGetUserAdminProfilesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 184, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry + 186, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry 1, // 77: hyapp.user.v1.AdminIssueUserAccessTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 78: hyapp.user.v1.BatchGetRoomBasicUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 185, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry + 187, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry 1, // 80: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 81: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta 5, // 82: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User @@ -14952,176 +15059,180 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{ 178, // 166: hyapp.user.v1.ListCPFormationGiftFeedResponse.items:type_name -> hyapp.user.v1.CPFormationGiftFeedItem 1, // 167: hyapp.user.v1.ListAppsRequest.meta:type_name -> hyapp.user.v1.RequestMeta 2, // 168: hyapp.user.v1.ListAppsResponse.apps:type_name -> hyapp.user.v1.App - 5, // 169: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User - 84, // 170: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile - 89, // 171: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser - 17, // 172: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest - 9, // 173: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest - 19, // 174: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest - 23, // 175: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest - 81, // 176: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest - 85, // 177: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest - 87, // 178: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest - 90, // 179: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest - 92, // 180: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest - 12, // 181: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest - 94, // 182: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest - 96, // 183: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest - 98, // 184: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest - 100, // 185: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest - 102, // 186: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest - 103, // 187: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest - 105, // 188: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest - 108, // 189: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest - 110, // 190: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest - 113, // 191: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest - 115, // 192: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest - 117, // 193: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest - 119, // 194: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest - 121, // 195: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest - 123, // 196: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest - 25, // 197: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest - 28, // 198: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest - 30, // 199: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest - 32, // 200: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest - 35, // 201: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest - 37, // 202: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest - 39, // 203: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest - 41, // 204: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest - 44, // 205: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest - 47, // 206: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest - 79, // 207: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest - 59, // 208: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest - 61, // 209: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest - 63, // 210: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest - 65, // 211: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest - 67, // 212: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest - 69, // 213: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest - 71, // 214: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest - 73, // 215: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest - 179, // 216: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest - 76, // 217: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest - 57, // 218: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest - 14, // 219: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 220: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 221: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 222: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 223: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 224: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 225: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest - 125, // 226: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest - 127, // 227: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest - 3, // 228: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest - 181, // 229: hyapp.user.v1.AppRegistryService.ListApps:input_type -> hyapp.user.v1.ListAppsRequest - 131, // 230: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest - 133, // 231: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest - 135, // 232: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest - 138, // 233: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest - 140, // 234: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest - 142, // 235: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest - 143, // 236: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest - 144, // 237: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest - 147, // 238: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest - 149, // 239: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest - 151, // 240: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest - 153, // 241: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest - 160, // 242: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest - 162, // 243: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest - 155, // 244: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest - 164, // 245: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest - 166, // 246: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest - 167, // 247: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest - 169, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest - 171, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest - 173, // 250: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest - 174, // 251: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest - 176, // 252: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest - 18, // 253: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse - 10, // 254: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse - 21, // 255: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse - 24, // 256: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse - 82, // 257: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse - 86, // 258: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse - 88, // 259: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse - 91, // 260: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse - 93, // 261: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse - 13, // 262: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse - 95, // 263: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse - 97, // 264: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse - 99, // 265: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse - 101, // 266: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse - 104, // 267: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse - 104, // 268: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse - 106, // 269: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse - 109, // 270: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse - 111, // 271: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse - 114, // 272: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse - 116, // 273: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse - 118, // 274: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse - 120, // 275: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse - 122, // 276: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse - 124, // 277: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse - 26, // 278: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse - 29, // 279: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse - 31, // 280: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse - 33, // 281: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse - 36, // 282: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse - 38, // 283: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse - 40, // 284: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse - 42, // 285: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse - 45, // 286: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse - 48, // 287: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse - 80, // 288: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse - 60, // 289: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse - 62, // 290: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse - 64, // 291: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse - 66, // 292: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse - 68, // 293: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse - 70, // 294: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse - 72, // 295: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse - 74, // 296: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse - 180, // 297: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse - 77, // 298: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse - 58, // 299: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse - 15, // 300: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 301: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 302: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 303: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 304: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 305: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 306: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse - 126, // 307: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse - 128, // 308: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse - 4, // 309: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse - 182, // 310: hyapp.user.v1.AppRegistryService.ListApps:output_type -> hyapp.user.v1.ListAppsResponse - 132, // 311: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse - 134, // 312: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse - 136, // 313: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse - 139, // 314: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse - 141, // 315: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse - 145, // 316: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse - 145, // 317: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse - 145, // 318: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse - 148, // 319: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse - 150, // 320: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse - 152, // 321: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse - 154, // 322: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse - 161, // 323: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse - 163, // 324: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse - 156, // 325: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse - 165, // 326: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse - 168, // 327: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse - 168, // 328: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse - 170, // 329: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse - 172, // 330: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse - 175, // 331: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse - 175, // 332: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse - 177, // 333: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse - 253, // [253:334] is the sub-list for method output_type - 172, // [172:253] is the sub-list for method input_type - 172, // [172:172] is the sub-list for extension type_name - 172, // [172:172] is the sub-list for extension extendee - 0, // [0:172] is the sub-list for field type_name + 1, // 169: hyapp.user.v1.ResolveAdminUserIdentifierRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 146, // 170: hyapp.user.v1.ResolveAdminUserIdentifierResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 5, // 171: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User + 84, // 172: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile + 89, // 173: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser + 17, // 174: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest + 9, // 175: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest + 19, // 176: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest + 23, // 177: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest + 81, // 178: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest + 85, // 179: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest + 87, // 180: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest + 90, // 181: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest + 92, // 182: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest + 12, // 183: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest + 94, // 184: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest + 96, // 185: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest + 98, // 186: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest + 100, // 187: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest + 102, // 188: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest + 103, // 189: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest + 105, // 190: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest + 108, // 191: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest + 110, // 192: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest + 113, // 193: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest + 115, // 194: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest + 117, // 195: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest + 119, // 196: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest + 121, // 197: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest + 123, // 198: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest + 25, // 199: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest + 28, // 200: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest + 30, // 201: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest + 32, // 202: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest + 35, // 203: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest + 37, // 204: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest + 39, // 205: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest + 41, // 206: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest + 44, // 207: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest + 47, // 208: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest + 79, // 209: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest + 59, // 210: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest + 61, // 211: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest + 63, // 212: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest + 65, // 213: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest + 67, // 214: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest + 69, // 215: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest + 71, // 216: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest + 73, // 217: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest + 179, // 218: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest + 76, // 219: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest + 57, // 220: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest + 14, // 221: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 222: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 223: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 224: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 225: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 226: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 227: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest + 125, // 228: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest + 127, // 229: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest + 3, // 230: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest + 181, // 231: hyapp.user.v1.AppRegistryService.ListApps:input_type -> hyapp.user.v1.ListAppsRequest + 131, // 232: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest + 133, // 233: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest + 135, // 234: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest + 138, // 235: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest + 140, // 236: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest + 142, // 237: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest + 143, // 238: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest + 144, // 239: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest + 147, // 240: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest + 149, // 241: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest + 183, // 242: hyapp.user.v1.UserIdentityService.ResolveAdminUserIdentifier:input_type -> hyapp.user.v1.ResolveAdminUserIdentifierRequest + 151, // 243: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest + 153, // 244: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest + 160, // 245: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest + 162, // 246: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest + 155, // 247: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest + 164, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest + 166, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest + 167, // 250: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest + 169, // 251: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest + 171, // 252: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest + 173, // 253: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest + 174, // 254: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest + 176, // 255: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest + 18, // 256: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse + 10, // 257: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse + 21, // 258: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse + 24, // 259: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse + 82, // 260: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse + 86, // 261: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse + 88, // 262: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse + 91, // 263: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse + 93, // 264: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse + 13, // 265: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse + 95, // 266: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse + 97, // 267: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse + 99, // 268: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse + 101, // 269: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse + 104, // 270: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse + 104, // 271: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse + 106, // 272: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse + 109, // 273: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse + 111, // 274: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse + 114, // 275: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse + 116, // 276: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse + 118, // 277: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse + 120, // 278: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse + 122, // 279: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse + 124, // 280: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse + 26, // 281: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse + 29, // 282: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse + 31, // 283: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse + 33, // 284: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse + 36, // 285: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse + 38, // 286: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse + 40, // 287: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse + 42, // 288: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse + 45, // 289: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse + 48, // 290: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse + 80, // 291: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse + 60, // 292: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse + 62, // 293: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse + 64, // 294: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse + 66, // 295: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse + 68, // 296: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse + 70, // 297: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse + 72, // 298: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse + 74, // 299: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse + 180, // 300: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse + 77, // 301: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse + 58, // 302: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse + 15, // 303: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 304: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 305: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 306: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 307: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 308: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 309: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse + 126, // 310: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse + 128, // 311: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse + 4, // 312: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse + 182, // 313: hyapp.user.v1.AppRegistryService.ListApps:output_type -> hyapp.user.v1.ListAppsResponse + 132, // 314: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse + 134, // 315: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse + 136, // 316: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse + 139, // 317: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse + 141, // 318: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse + 145, // 319: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse + 145, // 320: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse + 145, // 321: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse + 148, // 322: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse + 150, // 323: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse + 184, // 324: hyapp.user.v1.UserIdentityService.ResolveAdminUserIdentifier:output_type -> hyapp.user.v1.ResolveAdminUserIdentifierResponse + 152, // 325: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse + 154, // 326: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse + 161, // 327: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse + 163, // 328: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse + 156, // 329: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse + 165, // 330: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse + 168, // 331: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse + 168, // 332: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse + 170, // 333: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse + 172, // 334: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse + 175, // 335: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse + 175, // 336: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse + 177, // 337: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse + 256, // [256:338] is the sub-list for method output_type + 174, // [174:256] is the sub-list for method input_type + 174, // [174:174] is the sub-list for extension type_name + 174, // [174:174] is the sub-list for extension extendee + 0, // [0:174] is the sub-list for field type_name } func init() { file_proto_user_v1_user_proto_init() } @@ -15137,7 +15248,7 @@ func file_proto_user_v1_user_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_user_proto_rawDesc), len(file_proto_user_v1_user_proto_rawDesc)), NumEnums: 1, - NumMessages: 185, + NumMessages: 187, NumExtensions: 0, NumServices: 12, }, diff --git a/api/proto/user/v1/user.proto b/api/proto/user/v1/user.proto index 4d0add45..759f781c 100644 --- a/api/proto/user/v1/user.proto +++ b/api/proto/user/v1/user.proto @@ -1568,6 +1568,18 @@ message ListAppsResponse { repeated App apps = 1; } +// ResolveAdminUserIdentifierRequest 是后台精确查人的内部契约。与客户端展示号解析不同, +// user_identifier 可以是永久默认短号、当前有效展示号或系统长 user_id。 +message ResolveAdminUserIdentifierRequest { + RequestMeta meta = 1; + string user_identifier = 2; +} + +// ResolveAdminUserIdentifierResponse 返回匹配用户的当前有效身份投影。 +message ResolveAdminUserIdentifierResponse { + UserIdentity identity = 1; +} + // AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。 service AppRegistryService { rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse); @@ -1600,6 +1612,8 @@ service RegionAdminService { service UserIdentityService { rpc GetUserIdentity(GetUserIdentityRequest) returns (GetUserIdentityResponse); rpc ResolveDisplayUserID(ResolveDisplayUserIDRequest) returns (ResolveDisplayUserIDResponse); + // ResolveAdminUserIdentifier 仅供内部后台精确搜索;不得替代客户端 active 展示号解析语义。 + rpc ResolveAdminUserIdentifier(ResolveAdminUserIdentifierRequest) returns (ResolveAdminUserIdentifierResponse); rpc ChangeDisplayUserID(ChangeDisplayUserIDRequest) returns (ChangeDisplayUserIDResponse); rpc ApplyPrettyDisplayUserID(ApplyPrettyDisplayUserIDRequest) returns (ApplyPrettyDisplayUserIDResponse); rpc ListAvailablePrettyDisplayIDs(ListAvailablePrettyDisplayIDsRequest) returns (ListAvailablePrettyDisplayIDsResponse); diff --git a/api/proto/user/v1/user_grpc.pb.go b/api/proto/user/v1/user_grpc.pb.go index 9a0f5660..3049a8dc 100644 --- a/api/proto/user/v1/user_grpc.pb.go +++ b/api/proto/user/v1/user_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/user/v1/user.proto package userv1 @@ -3213,6 +3213,7 @@ var RegionAdminService_ServiceDesc = grpc.ServiceDesc{ const ( UserIdentityService_GetUserIdentity_FullMethodName = "/hyapp.user.v1.UserIdentityService/GetUserIdentity" UserIdentityService_ResolveDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveDisplayUserID" + UserIdentityService_ResolveAdminUserIdentifier_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveAdminUserIdentifier" UserIdentityService_ChangeDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ChangeDisplayUserID" UserIdentityService_ApplyPrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayUserID" UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserIdentityService/ListAvailablePrettyDisplayIDs" @@ -3228,6 +3229,8 @@ const ( type UserIdentityServiceClient interface { GetUserIdentity(ctx context.Context, in *GetUserIdentityRequest, opts ...grpc.CallOption) (*GetUserIdentityResponse, error) ResolveDisplayUserID(ctx context.Context, in *ResolveDisplayUserIDRequest, opts ...grpc.CallOption) (*ResolveDisplayUserIDResponse, error) + // ResolveAdminUserIdentifier 仅供内部后台精确搜索;不得替代客户端 active 展示号解析语义。 + ResolveAdminUserIdentifier(ctx context.Context, in *ResolveAdminUserIdentifierRequest, opts ...grpc.CallOption) (*ResolveAdminUserIdentifierResponse, error) ChangeDisplayUserID(ctx context.Context, in *ChangeDisplayUserIDRequest, opts ...grpc.CallOption) (*ChangeDisplayUserIDResponse, error) ApplyPrettyDisplayUserID(ctx context.Context, in *ApplyPrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayUserIDResponse, error) ListAvailablePrettyDisplayIDs(ctx context.Context, in *ListAvailablePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListAvailablePrettyDisplayIDsResponse, error) @@ -3263,6 +3266,16 @@ func (c *userIdentityServiceClient) ResolveDisplayUserID(ctx context.Context, in return out, nil } +func (c *userIdentityServiceClient) ResolveAdminUserIdentifier(ctx context.Context, in *ResolveAdminUserIdentifierRequest, opts ...grpc.CallOption) (*ResolveAdminUserIdentifierResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResolveAdminUserIdentifierResponse) + err := c.cc.Invoke(ctx, UserIdentityService_ResolveAdminUserIdentifier_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *userIdentityServiceClient) ChangeDisplayUserID(ctx context.Context, in *ChangeDisplayUserIDRequest, opts ...grpc.CallOption) (*ChangeDisplayUserIDResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ChangeDisplayUserIDResponse) @@ -3321,6 +3334,8 @@ func (c *userIdentityServiceClient) ExpirePrettyDisplayUserID(ctx context.Contex type UserIdentityServiceServer interface { GetUserIdentity(context.Context, *GetUserIdentityRequest) (*GetUserIdentityResponse, error) ResolveDisplayUserID(context.Context, *ResolveDisplayUserIDRequest) (*ResolveDisplayUserIDResponse, error) + // ResolveAdminUserIdentifier 仅供内部后台精确搜索;不得替代客户端 active 展示号解析语义。 + ResolveAdminUserIdentifier(context.Context, *ResolveAdminUserIdentifierRequest) (*ResolveAdminUserIdentifierResponse, error) ChangeDisplayUserID(context.Context, *ChangeDisplayUserIDRequest) (*ChangeDisplayUserIDResponse, error) ApplyPrettyDisplayUserID(context.Context, *ApplyPrettyDisplayUserIDRequest) (*ApplyPrettyDisplayUserIDResponse, error) ListAvailablePrettyDisplayIDs(context.Context, *ListAvailablePrettyDisplayIDsRequest) (*ListAvailablePrettyDisplayIDsResponse, error) @@ -3342,6 +3357,9 @@ func (UnimplementedUserIdentityServiceServer) GetUserIdentity(context.Context, * func (UnimplementedUserIdentityServiceServer) ResolveDisplayUserID(context.Context, *ResolveDisplayUserIDRequest) (*ResolveDisplayUserIDResponse, error) { return nil, status.Error(codes.Unimplemented, "method ResolveDisplayUserID not implemented") } +func (UnimplementedUserIdentityServiceServer) ResolveAdminUserIdentifier(context.Context, *ResolveAdminUserIdentifierRequest) (*ResolveAdminUserIdentifierResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ResolveAdminUserIdentifier not implemented") +} func (UnimplementedUserIdentityServiceServer) ChangeDisplayUserID(context.Context, *ChangeDisplayUserIDRequest) (*ChangeDisplayUserIDResponse, error) { return nil, status.Error(codes.Unimplemented, "method ChangeDisplayUserID not implemented") } @@ -3414,6 +3432,24 @@ func _UserIdentityService_ResolveDisplayUserID_Handler(srv interface{}, ctx cont return interceptor(ctx, in, info, handler) } +func _UserIdentityService_ResolveAdminUserIdentifier_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResolveAdminUserIdentifierRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserIdentityServiceServer).ResolveAdminUserIdentifier(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserIdentityService_ResolveAdminUserIdentifier_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserIdentityServiceServer).ResolveAdminUserIdentifier(ctx, req.(*ResolveAdminUserIdentifierRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _UserIdentityService_ChangeDisplayUserID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ChangeDisplayUserIDRequest) if err := dec(in); err != nil { @@ -3519,6 +3555,10 @@ var UserIdentityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ResolveDisplayUserID", Handler: _UserIdentityService_ResolveDisplayUserID_Handler, }, + { + MethodName: "ResolveAdminUserIdentifier", + Handler: _UserIdentityService_ResolveAdminUserIdentifier_Handler, + }, { MethodName: "ChangeDisplayUserID", Handler: _UserIdentityService_ChangeDisplayUserID_Handler, diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index a73329e1..6d14f257 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/wallet/v1/wallet.proto package walletv1 diff --git a/api/proto/wallet/v1/wallet_grpc.pb.go b/api/proto/wallet/v1/wallet_grpc.pb.go index fe093be7..5a1368a3 100644 --- a/api/proto/wallet/v1/wallet_grpc.pb.go +++ b/api/proto/wallet/v1/wallet_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/wallet/v1/wallet.proto package walletv1 diff --git a/docs/幸运礼物动态策略V3实现与模拟.md b/docs/幸运礼物动态策略V3实现与模拟.md index ea6ca083..8435a9d8 100644 --- a/docs/幸运礼物动态策略V3实现与模拟.md +++ b/docs/幸运礼物动态策略V3实现与模拟.md @@ -6,9 +6,9 @@ - `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`。 - `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。 -- 新奖池未配置时返回 `dynamic_v3` 的 `disabled` 草稿。各 App 必须先补齐累计消费大奖门槛、普通/高阶充值门槛及六项金额上限,才允许启用。 +- 新奖池未配置时返回 `dynamic_v3` 的 `disabled` 草稿。各 App 必须先补齐普通/高阶充值门槛及六项金额上限,才允许启用;独立的“累计消费达到门槛就获得大奖”机制已经停用,不再是发布必填项,也不能绕过 RTP 资格。 - 启用前还必须确认 user/gateway/room 已升级到由 `auth_sessions.device_id` 签入 access JWT,再通过 `room.RequestMeta` 和可恢复 command 原样传递设备作用域。旧 JWT 缺 `device_id` 时 `fixed_v2` 仍兼容,`dynamic_v3` 必须 fail-close,不得用 `session_id`/`command_id` 伪造设备。 -- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级:access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`,wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`。任一动态字段缺失或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时运行侧 fail-close。 +- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级:access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`,wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`。可信 `device_id`、owner `paid_at_ms` 缺失,或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时,整次动态开奖 fail-close;`user_registered_at_ms` 缺失或晚于扣费时间时普通开奖继续,但本轮补偿大奖资格 fail-close。 - 比例、概率和倍率统一使用 ppm,`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。 - 离线模拟使用固定 seed 的 `math/rand`;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。 @@ -38,7 +38,8 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent - RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。 - 95%~101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。 - 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `underpaid` 或短期偏离 95%~101%,禁止为了追 RTP 透支奖池。 -- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,下一抽才开启新窗口。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。 +- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,该抽完成后立即关闭并结算当前大盘轮次,下一抽进入新轮次。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。 +- 大盘轮次按 `app_code + pool_id + rule_version` 独立结算。大盘在 9:00 重置、10:00 再次重置时,10:00 形成的资格只读取刚关闭的 9:00~10:00 大盘 RTP,以及同一个 9:00~10:00 轮次内该用户实际发生的总流水和总返奖;不能拿用户 UTC 日 RTP 或正在进行的新轮次代替。 ### 2.3 原图概率表不能作为生产默认概率 @@ -55,7 +56,7 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent 因此该表的名义 `EV=26.5x`,即 `RTP=2650%`,不可能同时是 98% RTP。它只用于验证原图的 `P/W` 删档、删 0 和重抽分支,不能写入生产默认基础奖档。 -`dynamic_v3` 的安全发布草稿采用独立的 98% 静态 EV:`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x`。`200x/500x/1000x` 作为大奖集合单独配置,不能在缺少经过成本校验的概率时照搬进基础概率表。 +`dynamic_v3` 的安全发布草稿采用 98% 静态 EV:`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x`。普通概率表可以配置 `200x/500x/1000x`,也可以与大奖集合使用相同倍率,但这些普通高倍档必须与其他普通档一起纳入静态 EV 和控制带校验,不能因为倍率也出现在大奖集合就从成本计算中扣除。运行时为同倍率建立独立的 `multiplier_*` 普通 tier 和 `jackpot_*` 补偿 tier:前者按阶段概率随机,后者普通权重固定为 0,只能通过大奖资格路径命中。 ## 3. 一次抽奖的确定顺序 @@ -65,10 +66,10 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent 2. `wallet-service` 完成真实扣费,并在幂等回执中固化 `coin_spent`、交易 `paid_at_ms`、最近 7/30 个 UTC 自然日充值额、最后充值时间及主播收益金额。 3. `lucky-gift-service` 只消费上述可信事实,不直连钱包库,不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。 4. 本抽金币按 98/1/1 拆分;公共池份额先计入 `P`,再比较本抽奖金 `W`。 -5. 按 `app_code + pool_id + rule_version` 读取不可变规则,锁定公共池、用户日状态、72 小时桶/边界事件、连 0 状态和六维风控计数。 +5. 按 `app_code + pool_id + rule_version` 读取不可变规则,锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。 6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子;0 档接收剩余概率。 -7. 优先处理已持久化的“消费里程碑大奖 token”,其次判断 RTP 补偿大奖,最后进入普通概率抽奖。 -8. 对选中的 `W` 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖。 +7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。 +8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功时本抽直接结束,奖池不足或风控阻断时消费该轮资格并回落普通规则。普通路径即使命中同倍率也不是大奖,不增加或受制于日大奖次数。 9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`。 这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。 @@ -126,33 +127,44 @@ adjusted_weight = base_weight × water_factor × recharge_factor 原图 `P=800`、单价 10 的脚本化示例只用于分支验收:先抽到 `200x(W=2000)` 时删除 `200x+0x`;若再抽到 `1000x(W=10000)`,再删除 `1000x`;随后只能在仍有权重且 `W<=800` 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。 -奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”;其他风控拒绝不能伪装成奖池不足。 +奖池不足、风险额度不足和补偿大奖次数达到上限是不同的审计原因。只有普通候选的 `W>P` 触发原图规定的“删中奖档并同时删 0”;其他风控拒绝不能伪装成奖池不足。普通奖档不读取补偿大奖次数,即使倍率相同也仍按普通 P/W 和六维风控执行。 -## 6. 两种大奖机制 +## 6. 大奖资格:已结算轮次的下一次尝试 -大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。 +大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这个等权只决定补偿路径内部如何选大奖,与普通基础概率相互独立。相同倍率可以同时出现在普通概率表和大奖集合,审计分别记录 `multiplier_*` 与 `jackpot_*` tier ID,不能用倍率反推命中来源。若产品需要补偿大奖非等权,必须先新增显式权重契约和成本验收,不能借用普通奖档概率猜算。 -### 6.1 机制一:RTP 亏损补偿大奖 +### 6.1 RTP、注册年龄和安全门必须同时通过 -本抽开始时同时满足以下条件,才允许从当前可支付大奖集合随机选择: +大盘轮次结算时固化本轮快照;用户下一次送出幸运礼物时,只有以下条件同时通过,才允许从当前可支付大奖集合随机选择: -- 全局结算 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%; -- 用户当前 UTC 日 RTP `<= jackpot_user_day_rtp_max_ppm`,默认 96%; -- 用户滚动 72 小时 RTP `<= jackpot_user_72h_rtp_max_ppm`,默认 96%; -- 用户本 UTC 日大奖次数 `< max_jackpot_hits_per_user_day`,默认 5; +- 刚关闭的大盘轮次 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%;等于 98% 可以继续判断,超过 98% 直接失败,保证平台不因补偿大奖扩大亏损; +- 该用户在同一个刚关闭轮次内的 RTP **严格 `< jackpot_user_round_rtp_max_ppm`**,默认严格小于 96%;等于 96% 不通过; +- 该用户在轮次关闭时已经注册满 48 小时;判断的是账号注册年龄,不是累计活跃、累计游戏或累计有流水的时长; +- `closed_at_ms` 保存触发结算的末笔支付毫秒;汇总使用 `[closed_at_ms+1ms-48h, closed_at_ms+1ms)`,因此会纳入这笔结算事实。全部真实消费与全部真实返奖相除后,用户滚动 48 小时总 RTP **严格 `< jackpot_user_48h_rtp_max_ppm`**,默认严格小于 96%; +- 用户本 UTC 日通过补偿路径命中的大奖总次数 `< max_jackpot_hits_per_user_day`,默认 5;所有 `jackpot_*` 倍率合计计数,`multiplier_*` 普通命中不计数; - 候选 `W<=P` 且不超过六维风控的最小剩余额度。 -任一 RTP 分母为 0、任一 RTP 高于阈值、奖池不足或风控不足,都只回到普通抽奖,不强发大奖。滚动 72 小时是 `[now_ms-72h, now_ms)` 的 UTC 业务窗口,不能替换成“最近 3 个本地自然日”。 +用户两条 RTP 门是逻辑 **AND**:同轮次 95% 但滚动 48 小时 97% 不通过;同轮次 97% 但滚动 48 小时 95% 也不通过。两个 96% 边界都使用精确分子/分母交叉相乘判断,不能先向下取整成 ppm 后把实际略高于或等于 96% 误判为通过。 -### 6.2 机制二:消费里程碑大奖 +滚动 48 小时必须先汇总总额再相除,不允许对小时 RTP、单次 RTP或若干分段 RTP 做算术平均。例如 48 小时消费 10,000 金币、返奖 9,500 金币,RTP 为 95%。分母为 0 表示没有有效样本,不是 0% RTP,不能获得大奖资格。 -- `jackpot_spend_threshold_coins` 是当前不可变规则版本动态配置的“用户 UTC 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。 -- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token;同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。 -- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。 -- 下一抽优先消费已有 token;若大奖集合因 `P`、日 5 次上限或风控不可支付,token 保留,用户仍获得本次普通抽奖。 -- 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。 +### 6.2 注册时间和“只玩最后 5 分钟”边界 -原图写的是“定时任务”。实现改为在 owner 事务里按已结算送礼事实同步计算跨过的整数门槛并落 token:结果仍只影响下一抽,但不会出现“用户已经完成门槛、下一抽先于 cron 扫描到达”的竞态,也不需要 cron-service 回扫明细或直接拥有大奖状态。这个事件驱动实现是有意的工程等价替换;若产品需要固定延迟发放,必须另增明确生效时间字段,而不是依赖不确定的扫描周期。 +- 注册 47 小时的用户,即使本轮 RTP=95%、已有流水的滚动 RTP=95%,也因账号未满 48 小时而不通过。 +- 注册恰满 48 小时即满足成熟度边界;不需要“玩满 48 小时”。 +- 用户已注册超过 48 小时,但前 47 小时 55 分钟都没玩、最后 5 分钟消费 100 金币并返奖 95 金币时,最近 48 小时的有效总流水就是 100/95,滚动 RTP=95%。若这 5 分钟也正好构成刚关闭的用户轮次,则两条用户 RTP 门都通过。 +- 注册时间必须来自 user owner 的 `created_at_ms` 可信事实。内部链路缺失或恢复记录无法还原时,大奖资格 fail-close;不能用首次送礼时间、请求到达时间或外部自报的活跃时长兜底。 + +### 6.3 一轮资格只尝试一次 + +资格属于一个已关闭的 `app_code + pool_id + rule_version + window_index + user_id` 轮次,只能用于该用户的下一次子抽: + +- 能支付且风控允许:随机命中一个可支付大奖,资格消费; +- 奖池不足、达到日次数上限或任一风控不足:不强发大奖,资格仍消费,本抽继续走普通概率和保底; +- 用户迟迟不送礼:该轮资格可以等到下一次个人送礼再尝试,但不会因后续关闭更多轮次而叠加多个机会;只保留当前规则版本下最新待尝试轮次,旧轮次失效; +- 规则版本改变:旧版本资格不跨版本继承。 + +独立的累计消费门槛/里程碑 token 机制在新版规则中停用。历史字段只用于旧数据兼容读取,生产 `dynamic_v3` 不产生、不优先、不保留这类 token,也不能把消费金额作为第三条获奖通道。 ## 7. 六维风控与时间口径 @@ -171,20 +183,20 @@ adjusted_weight = base_weight × water_factor × recharge_factor 设备日 scope 只接受 user-service 已落库 session 的 `device_id`,由签名 JWT 贯通到 room 和 lucky;旧 token/旧 command 缺字段时 `fixed_v2` 可继续重放,但 `dynamic_v3` 必须拒绝开奖,不能回退到 `session_id` 或每单变化的 `command_id`。外部 App 的 device ID 由已认证调用方负责稳定提供,V3 缺失同样拒绝;服务只保存 App 内稳定 hash,避免暴露原始标识。 -所有时间范围遵守 `[start_ms,end_ms)`:包含开始毫秒,不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 72 小时都不得使用 `time.Local`、容器时区或客户端时区切分。 +所有时间范围遵守 `[start_ms,end_ms)`:包含开始毫秒,不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 48 小时都不得使用 `time.Local`、容器时区或客户端时区切分。滚动 48 小时以上一个已关闭轮次的 `closed_at_ms` 为上界,不能用用户下一次送礼的到达时间漂移窗口。 ## 8. 批量 N 次的语义 `gift_count=N` 表示按顺序执行 N 次抽奖,不是把总金额当成一次抽奖,也不是“只开奖一次但保证中奖”。例如 `gift_count=99` 必须产生 99 次状态推进。 - 总 `coin_spent` 拆成 N 个整数单份,余数从前往后每份加 1;三资金桶使用联合累计配额,保证每一抽 `public_i+profit_i+anchor_i=unit_spent_i`,同时整批三桶精确等于钱包回执总拆账。 -- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、RTP、token、日大奖次数和风控计数;不能并行抽 N 次后再合并。 -- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager`,第 `i+1` 抽必须先滚到新窗口;不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。 +- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、已结算轮次资格、日大奖次数和风控计数;不能并行抽 N 次后再合并。 +- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager`,该抽完成后立即关闭大盘和参与用户的同轮次快照;第 `i+1` 抽先使用这个已关闭轮次判断该用户的一次性资格,再进入新窗口。不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。 - 每个子抽用稳定子幂等键(现有约定为 `command_id#序号`)保留独立 draw record;重放整批不能跳过、补写或重复支付其中一抽。 - 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。 - 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。 -外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id` 和真实扣费 `paid_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist,不能独立证明设备真实性,所以调用方必须在自身认证/请求签名边界内绑定这些值;owner 不得用 `external_user_id`、`request_id` 或请求到达时间兜底。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。 +外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id`、真实扣费 `paid_at_ms` 和账号真实 `user_registered_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist,不能独立证明这些调用方事实,所以接入方必须在自身认证/请求签名边界内把它们绑定到 `external_user_id`。owner 不得用 `request_id`、首次送礼时间或请求到达时间兜底注册年龄。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。 ## 9. 接口和配置字段 @@ -200,13 +212,14 @@ adjusted_weight = base_weight × water_factor × recharge_factor `luckygift.v1.LuckyGiftMeta` 接收: - `device_id`(从已验签 access JWT 穿过 room command 恢复链路) +- `user_registered_at_ms`(来自 user owner 的账号创建时间,供轮次关闭时判断是否注册满 48 小时) - `paid_at_ms`(逐目标 wallet transaction 的稳定创建时间) - `recharge_7d_coins` - `recharge_30d_coins` - `last_recharged_at_ms` - `gift_income_coins` -`room.v1.RequestMeta.device_id` 只由 gateway 从验签 JWT 的 `device_id` claim 写入;`ExternalGiftDrawRequest.device_id` 由外部认证调用方承担稳定性责任。V3 对两个入口都不提供 session/command fallback。其余结算字段只来自 wallet owner 的成功回执。`request_id` 只做链路追踪;抽奖幂等仍使用 `command_id`。 +`room.v1.RequestMeta.device_id` 只由 gateway 从验签 JWT 的 `device_id` claim 写入;内部送礼请求的注册时间由 gateway 查询 user owner 后固化到可恢复 room command;`ExternalGiftDrawRequest.device_id` 和 `user_registered_at_ms` 由外部认证调用方承担真实性责任。V3 对两个入口都不提供 session/command/首次送礼时间 fallback。其余结算字段只来自 wallet owner 的成功回执。`request_id` 只做链路追踪;抽奖幂等仍使用 `command_id`。 ### 9.2 不可变规则字段 @@ -218,7 +231,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor | 资金拆分 | `pool_rate_ppm`, `profit_rate_ppm`, `anchor_rate_ppm`, `initial_pool_coins` | | 保底和水位 | `loss_streak_guarantee`, `low_watermark_coins`, `low_water_nonzero_factor_ppm`, `high_watermark_coins`, `high_water_nonzero_factor_ppm` | | 最近充值 | `recharge_boost_window_ms`, `recharge_boost_factor_ppm` | -| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_day_rtp_max_ppm`, `jackpot_user_72h_rtp_max_ppm`, `jackpot_spend_threshold_coins`, `max_jackpot_hits_per_user_day` | +| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_round_rtp_max_ppm`, `jackpot_user_48h_rtp_max_ppm`, `max_jackpot_hits_per_user_day`;历史 `jackpot_spend_threshold_coins` 在新版规则中停用 | | 六维风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` | | 充值层级 | `stages[].min_recharge_7d_coins`, `stages[].min_recharge_30d_coins`, `stages[].tiers[]` | @@ -231,18 +244,19 @@ adjusted_weight = base_weight × water_factor × recharge_factor | `lucky_gift_rule_versions` | `fixed_v2/dynamic_v3` 不可变版本、资金拆分、水位、大奖门槛和六项上限 | | `lucky_gift_stage_tiers` | 普通/高阶充值门槛、新手兜底 sentinel、基础倍率和基础概率 | | `lucky_pools` | 公共池 `balance/total_in/total_out`,以及 `profit_total/anchor_income_total/initial_seed_total` | -| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `open/closed/underpaid` 状态 | -| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时 | -| `lucky_user_rtp_boundary_events` | 只补齐 `[now-72h,now)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw | -| `lucky_user_strategy_days` | 用户 UTC 日流水、返奖、消费和已命中大奖次数 | -| `lucky_user_states` | 累计抽数、等价流水、`loss_streak` 和可跨日保留的待消费 token | +| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖、`started_at_ms/closed_at_ms` 和 `open/closed/underpaid` 状态;关闭时间是48小时统计与注册年龄判断的固定锚点 | +| `lucky_user_rtp_windows` | 每个用户在同一大盘 `rule_version + window_index` 内的总消费/总返奖,以及 `pending/attempted/ineligible/expired` 资格状态;一轮只尝试一次 | +| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于汇总严格滚动 48 小时 | +| `lucky_user_rtp_boundary_events` | 只补齐 `[closed_at+1ms-48h,closed_at+1ms)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw | +| `lucky_user_strategy_days` | 用户 UTC 日消费和已命中大奖次数;不再作为用户 RTP 资格来源,也不再生成消费里程碑资格 | +| `lucky_user_states` | 累计抽数、等价流水和 `loss_streak`;历史 token 字段仅兼容旧记录,新版规则不产生或消费 | | `lucky_risk_counters` | 用户/设备/房间/主播的 UTC 小时或日累计返奖 | | `lucky_draw_records` | 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态 | | `external_lucky_gift_draw_items` | 外部 App `gift_count=N` 的 N 条顺序子抽事实;外部聚合行只负责幂等响应 | | `lucky_gift_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 | | `lucky_gift_outbox` | 钱包补偿与投递审计,不替代 draw 业务事实 | -每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、三项 RTP 条件、大奖机制、token 产生/消费/保留、最终原因和阻断原因。 +每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、来源轮次及关闭时间、大盘 RTP、用户同轮次 RTP、滚动 48 小时总消费/总返奖、注册年龄、一次性资格是否已尝试、最终原因和阻断原因。同倍率双来源必须保存实际 `selected_tier_id` 和该来源的实际选择权重:普通奖记录调整后的阶段权重,补偿大奖记录大奖集合内权重,不能把补偿 tier 的普通权重 0 写成命中权重。 状态边界: @@ -267,12 +281,13 @@ adjusted_weight = base_weight × water_factor × recharge_factor | 充值分层 | 各 7/30 日门槛的下 1、等于、上 1 | 必须同时达到两维,选择最高满足层 | | 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 | | 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100,后 98 抽进入新窗;证明不按抽数提前滚动 | -| 大奖机制一 | 全局/当日/72h 各自通过和失败、分母 0 | 三门同时通过才有资格,零样本不触发 | -| 大奖机制二 | 门槛前、恰好跨过、一次跨多个门槛、资金不足 | token 只影响下一抽,幂等产生,资金不足保留 | +| 已结算轮次资格 | 注册47h、恰48h、只玩最后5分钟、用户轮次=96%、滚动48h=96%、全局=98%/略高于98%、任一用户门单独失败、零样本 | 注册年龄按账号创建时间;两个用户RTP严格<96%且AND;大盘允许=98%;只玩5分钟按实际总额计算;零样本不触发 | +| 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 | | 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 | -| 日 5 次 | 已中 4 次、5 次;UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 | +| 日 5 次 | 已中 4 次、5 次;UTC 日边界 | 第 5 次补偿大奖可中,第 6 次补偿大奖被阻断,新 UTC 日重新计数;普通奖不占次数 | +| 同倍率双来源 | 100/200/500/1000x 分别配置普通与补偿路径,并在达到日上限后再抽普通 1000x | 每个倍率的普通命中都不计大奖、补偿命中都计数;补偿达到上限后同倍率普通奖仍可命中,tier ID 和选择权重可区分来源 | | 六维风控 | 每一维分别取 `W=remaining` 和 `W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 | -| 组合优先级 | token + RTP 补偿 + 连 0 + 低水位 + 充值 | token 优先,其后 RTP,再普通;安全门永远最后裁决 | +| 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 | | 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 | | 有资金长跑 | 固定 seed、足量冷启动资金、各充值层 | 报告实际 RTP/命中率/池底,不把 95%~101%当资金不足时硬断言 | @@ -289,6 +304,8 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \ -json-out /tmp/lucky-gift-dynamic-v3-simulation.json ``` +2026-07-15 按上述默认规模生成的完整机器可读报告保存在 `幸运礼物动态策略V3模拟结果-20260715.json`;旧的 2026-07-12 文件只代表历史 day/72h 规则,不得作为本次 48 小时版本的验收依据。 + 策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足: - `nominal_probability_table.expected_multiplier=26.5`; @@ -301,6 +318,6 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \ 1. 存量规则空 `strategy_version` 读取和重放仍为 `fixed_v2`,且无需补 V3 金额字段。 2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。 -3. 累计消费大奖门槛、低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。 -4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 必须 fail-close。 +3. 低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。累计消费大奖门槛保持停用,不能成为独立大奖通道。 +4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 时整次开奖 fail-close,缺失/无效 `user_registered_at_ms` 时只关闭补偿大奖资格,普通开奖继续。 5. 本地单测、完整模拟、`make proto`、`make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。 diff --git a/docs/幸运礼物动态策略V3模拟结果-20260715.json b/docs/幸运礼物动态策略V3模拟结果-20260715.json new file mode 100644 index 00000000..ae1899c4 --- /dev/null +++ b/docs/幸运礼物动态策略V3模拟结果-20260715.json @@ -0,0 +1,2029 @@ +{ + "seed": 20260712, + "monte_carlo_draws": 100000, + "long_run_draws": 100000, + "group_long_run_draws": 30000, + "nominal_probability_table": { + "expected_multiplier": 26.5, + "nominal_rtp_percent": 2650, + "target_rtp_percent": 98, + "passes_target": false, + "conclusion": "图示固定概率表的名义EV为26.5x(2650%),不能作为98% RTP方案;只有P/W资金约束后的有资金长跑口径可验证不透支。" + }, + "scenarios": [ + { + "name": "图示800重抽序列", + "passed": true, + "summary": "P=800,固定脚本命中[200x 1000x 5x],最终赔付=50,池后=750", + "data": { + "sequence": [ + "200x", + "1000x", + "5x" + ], + "trace": { + "stage": "novice", + "pool_before_coins": 800, + "pool_contribution_coins": 0, + "available_pool_coins": 800, + "effective_risk_capacity_coins": 9223372036854775807, + "weights": [ + { + "tier_id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 600000, + "raw_adjusted_weight_ppm": 600000, + "adjusted_weight_ppm": 600000, + "factors": [ + { + "name": "zero_remainder", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "5x", + "multiplier_ppm": 5000000, + "base_weight_ppm": 200000, + "raw_adjusted_weight_ppm": 200000, + "adjusted_weight_ppm": 200000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "50x", + "multiplier_ppm": 50000000, + "base_weight_ppm": 150000, + "raw_adjusted_weight_ppm": 150000, + "adjusted_weight_ppm": 150000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "200x", + "multiplier_ppm": 200000000, + "base_weight_ppm": 40000, + "raw_adjusted_weight_ppm": 40000, + "adjusted_weight_ppm": 40000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "500x", + "multiplier_ppm": 500000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 10000, + "raw_adjusted_weight_ppm": 10000, + "adjusted_weight_ppm": 10000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + } + ], + "draws": [ + { + "sequence": 1, + "kind": "original", + "tier_id": "200x", + "multiplier_ppm": 200000000, + "weight_ppm": 40000, + "payout_coins": 2000, + "removed_reason": "pool_insufficient_w_gt_p" + }, + { + "sequence": 2, + "kind": "redraw", + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "weight_ppm": 10000, + "payout_coins": 10000, + "removed_reason": "pool_insufficient_w_gt_p" + }, + { + "sequence": 3, + "kind": "redraw", + "tier_id": "5x", + "multiplier_ppm": 5000000, + "weight_ppm": 200000, + "payout_coins": 50 + } + ], + "removed": [ + { + "tier_id": "200x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "0x", + "reason": "w_gt_p_redraw_excludes_zero" + }, + { + "tier_id": "1000x", + "reason": "pool_insufficient_w_gt_p" + } + ], + "original_tier_id": "200x", + "redraw_tier_ids": [ + "1000x", + "5x" + ], + "final_reason": "paid", + "milestone_token_consumed": false, + "milestone_token_retained": false, + "milestone_tokens_earned": 0 + } + } + }, + { + "name": "P=W边界/候选穷尽", + "passed": true, + "summary": "W=P赔付2000且池归零;P=49时正奖候选穷尽安全回0,池仍49", + "data": { + "equality": { + "stage": "novice", + "pool_before_coins": 2000, + "pool_contribution_coins": 0, + "available_pool_coins": 2000, + "effective_risk_capacity_coins": 9223372036854775807, + "weights": [ + { + "tier_id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 600000, + "raw_adjusted_weight_ppm": 600000, + "adjusted_weight_ppm": 600000, + "factors": [ + { + "name": "zero_remainder", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "5x", + "multiplier_ppm": 5000000, + "base_weight_ppm": 200000, + "raw_adjusted_weight_ppm": 200000, + "adjusted_weight_ppm": 200000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "50x", + "multiplier_ppm": 50000000, + "base_weight_ppm": 150000, + "raw_adjusted_weight_ppm": 150000, + "adjusted_weight_ppm": 150000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "200x", + "multiplier_ppm": 200000000, + "base_weight_ppm": 40000, + "raw_adjusted_weight_ppm": 40000, + "adjusted_weight_ppm": 40000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "500x", + "multiplier_ppm": 500000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 10000, + "raw_adjusted_weight_ppm": 10000, + "adjusted_weight_ppm": 10000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + } + ], + "draws": [ + { + "sequence": 1, + "kind": "original", + "tier_id": "200x", + "multiplier_ppm": 200000000, + "weight_ppm": 40000, + "payout_coins": 2000 + } + ], + "removed": null, + "original_tier_id": "200x", + "final_reason": "paid", + "milestone_token_consumed": false, + "milestone_token_retained": false, + "milestone_tokens_earned": 0 + }, + "exhaustion": { + "stage": "novice", + "pool_before_coins": 49, + "pool_contribution_coins": 0, + "available_pool_coins": 49, + "effective_risk_capacity_coins": 9223372036854775807, + "weights": [ + { + "tier_id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 600000, + "raw_adjusted_weight_ppm": 600000, + "adjusted_weight_ppm": 600000, + "factors": [ + { + "name": "zero_remainder", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "5x", + "multiplier_ppm": 5000000, + "base_weight_ppm": 200000, + "raw_adjusted_weight_ppm": 200000, + "adjusted_weight_ppm": 200000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "50x", + "multiplier_ppm": 50000000, + "base_weight_ppm": 150000, + "raw_adjusted_weight_ppm": 150000, + "adjusted_weight_ppm": 150000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "200x", + "multiplier_ppm": 200000000, + "base_weight_ppm": 40000, + "raw_adjusted_weight_ppm": 40000, + "adjusted_weight_ppm": 40000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "500x", + "multiplier_ppm": 500000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 10000, + "raw_adjusted_weight_ppm": 10000, + "adjusted_weight_ppm": 10000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + } + ], + "draws": [ + { + "sequence": 1, + "kind": "original", + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "weight_ppm": 10000, + "payout_coins": 10000, + "removed_reason": "pool_insufficient_w_gt_p" + }, + { + "sequence": 2, + "kind": "redraw", + "tier_id": "200x", + "multiplier_ppm": 200000000, + "weight_ppm": 40000, + "payout_coins": 2000, + "removed_reason": "pool_insufficient_w_gt_p" + }, + { + "sequence": 3, + "kind": "redraw", + "tier_id": "50x", + "multiplier_ppm": 50000000, + "weight_ppm": 150000, + "payout_coins": 500, + "removed_reason": "pool_insufficient_w_gt_p" + }, + { + "sequence": 4, + "kind": "redraw", + "tier_id": "5x", + "multiplier_ppm": 5000000, + "weight_ppm": 200000, + "payout_coins": 50, + "removed_reason": "pool_insufficient_w_gt_p" + } + ], + "removed": [ + { + "tier_id": "1000x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "0x", + "reason": "w_gt_p_redraw_excludes_zero" + }, + { + "tier_id": "200x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "50x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "5x", + "reason": "pool_insufficient_w_gt_p" + } + ], + "original_tier_id": "1000x", + "redraw_tier_ids": [ + "200x", + "50x", + "5x" + ], + "final_reason": "no_payable_nonzero_tier_fallback_zero", + "blocked_reason": "no_payable_nonzero_tier_fallback_zero", + "milestone_token_consumed": false, + "milestone_token_retained": false, + "milestone_tokens_earned": 0 + } + } + }, + { + "name": "拆账与冷启动", + "passed": true, + "summary": "100金币拆为98/1/1;10金币按公共池floor、余数归盈利拆为9/1/0;动态池冷启动=0,启动资金只能审计注资", + "data": { + "cold_start_state": { + "pool_balance_coins": 0, + "consecutive_zero_draws": 0, + "pending_milestone_tokens": 0, + "user_daily_jackpot_wins": 0, + "user_day_spend_coins": 0, + "global_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "user_round_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "user_48h_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "settled_round_pending": false, + "qualification_closed_at_ms": 0, + "user_registered_at_ms": 0, + "user_day_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "user_72h_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "version": 0 + }, + "conserved": true, + "rounding_policy": "public=floor, anchor=floor, residue=profit", + "split_100": { + "public_pool_coins": 98, + "profit_pool_coins": 1, + "anchor_return_coins": 1 + }, + "split_10_rounding": { + "public_pool_coins": 9, + "profit_pool_coins": 1, + "anchor_return_coins": 0 + } + } + }, + { + "name": "1/5/6/99批量", + "passed": true, + "summary": "每份礼物独立推进一次状态,1/5/6/99均完整执行且任一中间池余额不负", + "data": [ + { + "requested": 1, + "executed": 1, + "positive": 0, + "payout_coins": 0, + "pool_after": 98, + "tier_counts": { + "0x": 1 + } + }, + { + "requested": 5, + "executed": 5, + "positive": 0, + "payout_coins": 0, + "pool_after": 490, + "tier_counts": { + "0x": 5 + } + }, + { + "requested": 6, + "executed": 6, + "positive": 1, + "payout_coins": 500, + "pool_after": 88, + "tier_counts": { + "0x": 5, + "5x": 1 + } + }, + { + "requested": 99, + "executed": 99, + "positive": 19, + "payout_coins": 9500, + "pool_after": 202, + "tier_counts": { + "0x": 80, + "5x": 19 + } + } + ] + }, + { + "name": "真实流水结算窗口", + "passed": true, + "summary": "阈值99、参考价10、预置10抽/98流水;99份共100金币后窗口流水=100/98,子抽归属=1/98", + "data": { + "draws_in_window_1": 1, + "draws_in_window_2": 98, + "legacy_derived_draw_threshold": 10, + "reference_price": 10, + "settlement_window_wager": 99, + "windows": [ + { + "index": 1, + "paid_draws": 11, + "wager_coins": 100 + }, + { + "index": 2, + "paid_draws": 98, + "wager_coins": 98 + } + ] + } + }, + { + "name": "低中高水位边界", + "passed": true, + "summary": "\u003c1000万应用0.7;等于1000万/2000万均为中水位;\u003e2000万应用1.3", + "data": [ + { + "pool": 9999999, + "five_x_weight_ppm": 140000, + "factors": [ + "low_water" + ] + }, + { + "pool": 10000000, + "five_x_weight_ppm": 200000, + "factors": [ + "identity" + ] + }, + { + "pool": 20000000, + "five_x_weight_ppm": 200000, + "factors": [ + "identity" + ] + }, + { + "pool": 20000001, + "five_x_weight_ppm": 260000, + "factors": [ + "high_water" + ] + } + ] + }, + { + "name": "充值0/299999/300000ms", + "passed": true, + "summary": "充值加成严格使用[0,300000ms):0和299999生效,300000失效", + "data": [ + { + "age_ms": 0, + "five_x_weight_ppm": 220000, + "boosted": true + }, + { + "age_ms": 299999, + "five_x_weight_ppm": 220000, + "boosted": true + }, + { + "age_ms": 300000, + "five_x_weight_ppm": 200000, + "boosted": false + } + ] + }, + { + "name": "概率Monte Carlo", + "passed": true, + "summary": "100000抽经验EV=26.3058x(只验证图示概率采样,不声称通过98%资金RTP)", + "data": { + "counts": { + "0x": 60043, + "1000x": 967, + "200x": 4045, + "50x": 15108, + "5x": 19837 + }, + "empirical_ev_multiplier": 26.30585, + "empirical_probability": { + "0x": 0.60043, + "1000x": 0.00967, + "200x": 0.04045, + "500x": 0, + "50x": 0.15108, + "5x": 0.19837 + }, + "expected_probability": { + "0x": 0.6, + "1000x": 0.01, + "200x": 0.04, + "500x": 0, + "50x": 0.15, + "5x": 0.2 + } + } + }, + { + "name": "RTP长跑", + "passed": true, + "summary": "100000抽实付RTP=97.9950%,投入=9800000,赔付=9799500,池余=500", + "data": { + "contributed": 9800000, + "fund_conserved": true, + "payout": 9799500, + "pool_after": 500, + "rtp_percent": 97.995 + } + }, + { + "name": "已结算轮次+注册48小时大奖资格", + "passed": true, + "summary": "注册\u003c48h失败;恰48h及只玩最后5分钟按实际总流水判断;用户轮次/48h严格\u003c96%,大盘允许=98%,超限或零样本失败", + "data": [ + { + "case": "注册47小时,即使两个用户RTP都是95%也失败", + "registered_age_hours": 47, + "round_wager_coins": 10000, + "round_payout_coins": 9500, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9500, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": false, + "triggered": false, + "selected_tier": "0x" + }, + { + "case": "注册恰满48小时,两个用户RTP都是95%通过", + "registered_age_hours": 48, + "round_wager_coins": 10000, + "round_payout_coins": 9500, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9500, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": true, + "triggered": true, + "selected_tier": "1000x" + }, + { + "case": "注册已满48小时且只在最后5分钟玩,48小时总RTP就是这5分钟的95%", + "registered_age_hours": 72, + "round_wager_coins": 100, + "round_payout_coins": 95, + "rolling_48h_wager_coins": 100, + "rolling_48h_payout_coins": 95, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": true, + "triggered": true, + "selected_tier": "500x" + }, + { + "case": "已结算用户轮次RTP恰好96%失败", + "registered_age_hours": 48, + "round_wager_coins": 10000, + "round_payout_coins": 9600, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9500, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": false, + "triggered": false, + "selected_tier": "0x" + }, + { + "case": "滚动48小时总RTP恰好96%失败", + "registered_age_hours": 48, + "round_wager_coins": 10000, + "round_payout_coins": 9500, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9600, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": false, + "triggered": false, + "selected_tier": "0x" + }, + { + "case": "已结算大盘RTP恰好98%仍通过", + "registered_age_hours": 48, + "round_wager_coins": 10000, + "round_payout_coins": 9500, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9500, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": true, + "triggered": true, + "selected_tier": "500x" + }, + { + "case": "已结算大盘RTP超过98%失败", + "registered_age_hours": 48, + "round_wager_coins": 10000, + "round_payout_coins": 9500, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9500, + "global_wager_coins": 10000, + "global_payout_coins": 9801, + "wanted": false, + "triggered": false, + "selected_tier": "5x" + }, + { + "case": "仅用户轮次门失败,48小时门通过,仍失败", + "registered_age_hours": 48, + "round_wager_coins": 10000, + "round_payout_coins": 9700, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9500, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": false, + "triggered": false, + "selected_tier": "50x" + }, + { + "case": "仅48小时门失败,用户轮次门通过,仍失败", + "registered_age_hours": 48, + "round_wager_coins": 10000, + "round_payout_coins": 9500, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9700, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": false, + "triggered": false, + "selected_tier": "5x" + }, + { + "case": "用户轮次没有消费样本失败", + "registered_age_hours": 48, + "round_wager_coins": 0, + "round_payout_coins": 0, + "rolling_48h_wager_coins": 10000, + "rolling_48h_payout_coins": 9500, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": false, + "triggered": false, + "selected_tier": "0x" + }, + { + "case": "滚动48小时没有消费样本失败", + "registered_age_hours": 48, + "round_wager_coins": 10000, + "round_payout_coins": 9500, + "rolling_48h_wager_coins": 0, + "rolling_48h_payout_coins": 0, + "global_wager_coins": 10000, + "global_payout_coins": 9800, + "wanted": false, + "triggered": false, + "selected_tier": "0x" + } + ] + }, + { + "name": "池不足仍消费已结算轮次资格", + "passed": true, + "summary": "首抽池=49不足支付200x,资格尝试后pending=false;补池到20000再抽也不会复用旧资格", + "data": { + "after_top_up_without_new_closed_round": { + "selected_tier": { + "id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 1000000, + "jackpot": false, + "enabled": true + }, + "payout_coins": 0, + "pool_after_coins": 20000, + "stage": "novice", + "jackpot": false, + "consumed_milestone_token": false, + "next_state": { + "pool_balance_coins": 20000, + "consecutive_zero_draws": 2, + "pending_milestone_tokens": 0, + "user_daily_jackpot_wins": 0, + "user_day_spend_coins": 20, + "global_rtp": { + "wager_coins": 10000, + "payout_coins": 9800 + }, + "user_round_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "user_48h_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "settled_round_pending": false, + "qualification_closed_at_ms": 360000000, + "user_registered_at_ms": 187200000, + "user_day_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "user_72h_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "version": 2 + }, + "trace": { + "stage": "novice", + "pool_before_coins": 20000, + "pool_contribution_coins": 0, + "available_pool_coins": 20000, + "effective_risk_capacity_coins": 9223372036854775807, + "weights": [ + { + "tier_id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 1000000, + "raw_adjusted_weight_ppm": 1000000, + "adjusted_weight_ppm": 1000000, + "factors": [ + { + "name": "zero_remainder", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "200x", + "multiplier_ppm": 200000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "500x", + "multiplier_ppm": 500000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + } + ], + "draws": [ + { + "sequence": 1, + "kind": "original", + "tier_id": "0x", + "multiplier_ppm": 0, + "weight_ppm": 1000000, + "payout_coins": 0 + } + ], + "removed": null, + "conditions": [ + { + "name": "settled_round_qualification", + "passed": false, + "reason": "no_closed_round_pending" + }, + { + "name": "global_rtp", + "numerator": 9800, + "denominator": 10000, + "ratio_ppm": 980000, + "limit_ppm": 980000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_round_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_registered_48h", + "numerator": 172800000, + "denominator": 172800000, + "passed": true, + "reason": "registered_48h" + }, + { + "name": "user_48h_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "daily_jackpot_limit", + "limit_ppm": 5, + "passed": true, + "reason": "below_limit" + } + ], + "original_tier_id": "0x", + "final_reason": "zero_selected", + "milestone_token_consumed": false, + "milestone_token_retained": false, + "milestone_tokens_earned": 0 + } + }, + "pool_blocked_attempt": { + "selected_tier": { + "id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 1000000, + "jackpot": false, + "enabled": true + }, + "payout_coins": 0, + "pool_after_coins": 49, + "stage": "novice", + "jackpot": false, + "consumed_milestone_token": false, + "next_state": { + "pool_balance_coins": 49, + "consecutive_zero_draws": 1, + "pending_milestone_tokens": 0, + "user_daily_jackpot_wins": 0, + "user_day_spend_coins": 10, + "global_rtp": { + "wager_coins": 10000, + "payout_coins": 9800 + }, + "user_round_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "user_48h_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "settled_round_pending": false, + "qualification_closed_at_ms": 360000000, + "user_registered_at_ms": 187200000, + "user_day_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "user_72h_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "version": 1 + }, + "trace": { + "stage": "novice", + "pool_before_coins": 49, + "pool_contribution_coins": 0, + "available_pool_coins": 49, + "effective_risk_capacity_coins": 9223372036854775807, + "weights": [ + { + "tier_id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 1000000, + "raw_adjusted_weight_ppm": 1000000, + "adjusted_weight_ppm": 1000000, + "factors": [ + { + "name": "zero_remainder", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "200x", + "multiplier_ppm": 200000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "500x", + "multiplier_ppm": 500000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + } + ], + "draws": [ + { + "sequence": 1, + "kind": "original", + "tier_id": "0x", + "multiplier_ppm": 0, + "weight_ppm": 1000000, + "payout_coins": 0 + } + ], + "removed": [ + { + "tier_id": "200x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "500x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "1000x", + "reason": "pool_insufficient_w_gt_p" + } + ], + "conditions": [ + { + "name": "settled_round_qualification", + "passed": true, + "reason": "closed_round_pending" + }, + { + "name": "global_rtp", + "numerator": 9800, + "denominator": 10000, + "ratio_ppm": 980000, + "limit_ppm": 980000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_round_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_registered_48h", + "numerator": 172800000, + "denominator": 172800000, + "passed": true, + "reason": "registered_48h" + }, + { + "name": "user_48h_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "daily_jackpot_limit", + "limit_ppm": 5, + "passed": true, + "reason": "below_limit" + }, + { + "name": "payable_jackpot_candidate", + "passed": false, + "reason": "no_payable_jackpot_candidate" + } + ], + "original_tier_id": "0x", + "final_reason": "zero_selected", + "blocked_reason": "no_payable_jackpot_candidate", + "milestone_token_consumed": false, + "milestone_token_retained": false, + "milestone_tokens_earned": 0 + } + } + } + }, + { + "name": "大奖集合", + "passed": true, + "summary": "池足时大奖集合覆盖200/500/1000x=true;P=4999时只可能200x=map[200x:40]", + "data": { + "pool_10000_counts": { + "1000x": 16, + "200x": 66, + "500x": 38 + }, + "pool_4999_counts": { + "200x": 40 + } + } + }, + { + "name": "日5次上限", + "passed": true, + "summary": "5个独立已结算轮次命中后计数=5;第6个轮次受日上限阻断且该轮资格仍按一次尝试消费", + "data": { + "sixth": { + "selected_tier": { + "id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 600000, + "jackpot": false, + "enabled": true + }, + "payout_coins": 0, + "pool_after_coins": 987000, + "stage": "novice", + "jackpot": false, + "consumed_milestone_token": false, + "next_state": { + "pool_balance_coins": 987000, + "consecutive_zero_draws": 1, + "pending_milestone_tokens": 0, + "user_daily_jackpot_wins": 5, + "user_day_spend_coins": 60, + "global_rtp": { + "wager_coins": 10000, + "payout_coins": 9800 + }, + "user_round_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "user_48h_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "settled_round_pending": false, + "qualification_closed_at_ms": 381600000, + "user_registered_at_ms": 187200000, + "user_day_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "user_72h_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "version": 6 + }, + "trace": { + "stage": "novice", + "pool_before_coins": 987000, + "pool_contribution_coins": 0, + "available_pool_coins": 987000, + "effective_risk_capacity_coins": 9223372036854775807, + "weights": [ + { + "tier_id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 600000, + "raw_adjusted_weight_ppm": 600000, + "adjusted_weight_ppm": 600000, + "factors": [ + { + "name": "zero_remainder", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "5x", + "multiplier_ppm": 5000000, + "base_weight_ppm": 200000, + "raw_adjusted_weight_ppm": 200000, + "adjusted_weight_ppm": 200000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "50x", + "multiplier_ppm": 50000000, + "base_weight_ppm": 150000, + "raw_adjusted_weight_ppm": 150000, + "adjusted_weight_ppm": 150000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "200x", + "multiplier_ppm": 200000000, + "base_weight_ppm": 40000, + "raw_adjusted_weight_ppm": 40000, + "adjusted_weight_ppm": 40000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "500x", + "multiplier_ppm": 500000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 10000, + "raw_adjusted_weight_ppm": 10000, + "adjusted_weight_ppm": 10000, + "factors": [ + { + "name": "identity", + "factor_ppm": 1000000 + } + ] + } + ], + "draws": [ + { + "sequence": 1, + "kind": "original", + "tier_id": "200x", + "multiplier_ppm": 200000000, + "weight_ppm": 40000, + "payout_coins": 2000, + "removed_reason": "daily_jackpot_limit" + }, + { + "sequence": 2, + "kind": "redraw", + "tier_id": "0x", + "multiplier_ppm": 0, + "weight_ppm": 600000, + "payout_coins": 0 + } + ], + "removed": [ + { + "tier_id": "200x", + "reason": "daily_jackpot_limit" + } + ], + "conditions": [ + { + "name": "settled_round_qualification", + "passed": true, + "reason": "closed_round_pending" + }, + { + "name": "global_rtp", + "numerator": 9800, + "denominator": 10000, + "ratio_ppm": 980000, + "limit_ppm": 980000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_round_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_registered_48h", + "numerator": 194400000, + "denominator": 172800000, + "passed": true, + "reason": "registered_48h" + }, + { + "name": "user_48h_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "daily_jackpot_limit", + "numerator": 5, + "limit_ppm": 5, + "passed": false, + "reason": "daily_jackpot_limit" + } + ], + "original_tier_id": "200x", + "redraw_tier_ids": [ + "0x" + ], + "final_reason": "zero_selected", + "milestone_token_consumed": false, + "milestone_token_retained": false, + "milestone_tokens_earned": 0 + } + } + } + }, + { + "name": "六维risk capacity代表边界", + "passed": true, + "summary": "单次/用户小时/用户日/设备日/房间小时/主播日六维均验证W=capacity可赔、W=capacity+1硬阻断", + "data": { + "anchor_day": { + "below_capacity_payout": 0, + "equal_capacity_payout": 50, + "pass": true + }, + "device_day": { + "below_capacity_payout": 0, + "equal_capacity_payout": 50, + "pass": true + }, + "room_hour": { + "below_capacity_payout": 0, + "equal_capacity_payout": 50, + "pass": true + }, + "single_draw": { + "below_capacity_payout": 0, + "equal_capacity_payout": 50, + "pass": true + }, + "user_day": { + "below_capacity_payout": 0, + "equal_capacity_payout": 50, + "pass": true + }, + "user_hour": { + "below_capacity_payout": 0, + "equal_capacity_payout": 50, + "pass": true + } + } + }, + { + "name": "充值分层", + "passed": true, + "summary": "normal/advanced同时达到7日与30日下限才提档;任一维低于normal即使非零也是novice", + "data": { + "boundary_rows": [ + { + "recharge_7d": 99, + "recharge_30d": 499, + "stage": "novice" + }, + { + "recharge_7d": 100, + "recharge_30d": 499, + "stage": "novice" + }, + { + "recharge_7d": 99, + "recharge_30d": 500, + "stage": "novice" + }, + { + "recharge_7d": 100, + "recharge_30d": 500, + "stage": "normal" + }, + { + "recharge_7d": 500, + "recharge_30d": 2000, + "stage": "advanced" + } + ], + "thresholds": [ + { + "name": "novice", + "min_recharge_7d_coins": 0, + "min_recharge_30d_coins": 0 + }, + { + "name": "normal", + "min_recharge_7d_coins": 100, + "min_recharge_30d_coins": 500 + }, + { + "name": "advanced", + "min_recharge_7d_coins": 500, + "min_recharge_30d_coins": 2000 + } + ] + } + }, + { + "name": "组合优先级", + "passed": true, + "summary": "优先级=已结算轮次资格\u003e普通P/W/第6抽;大奖池不足时资格消费,本抽仅赔可支付5x", + "data": { + "all_eligible": { + "selected_tier": { + "id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 10000, + "jackpot": true, + "jackpot_weight": 1, + "enabled": true + }, + "payout_coins": 10000, + "pool_after_coins": 0, + "stage": "advanced", + "jackpot": true, + "jackpot_mechanism": "rtp_compensation", + "consumed_milestone_token": false, + "next_state": { + "pool_balance_coins": 0, + "consecutive_zero_draws": 0, + "pending_milestone_tokens": 0, + "user_daily_jackpot_wins": 1, + "user_day_spend_coins": 10, + "global_rtp": { + "wager_coins": 10000, + "payout_coins": 9800 + }, + "user_round_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "user_48h_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "settled_round_pending": false, + "qualification_closed_at_ms": 360000000, + "user_registered_at_ms": 187200000, + "user_day_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "user_72h_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "version": 1 + }, + "trace": { + "stage": "advanced", + "pool_before_coins": 10000, + "pool_contribution_coins": 0, + "available_pool_coins": 10000, + "effective_risk_capacity_coins": 9223372036854775807, + "weights": [ + { + "tier_id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 600000, + "raw_adjusted_weight_ppm": 600000, + "adjusted_weight_ppm": 692000, + "factors": [ + { + "name": "zero_remainder", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "5x", + "multiplier_ppm": 5000000, + "base_weight_ppm": 200000, + "raw_adjusted_weight_ppm": 154000, + "adjusted_weight_ppm": 154000, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + }, + { + "tier_id": "50x", + "multiplier_ppm": 50000000, + "base_weight_ppm": 150000, + "raw_adjusted_weight_ppm": 115500, + "adjusted_weight_ppm": 115500, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + }, + { + "tier_id": "200x", + "multiplier_ppm": 200000000, + "base_weight_ppm": 40000, + "raw_adjusted_weight_ppm": 30800, + "adjusted_weight_ppm": 30800, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + }, + { + "tier_id": "500x", + "multiplier_ppm": 500000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + }, + { + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 10000, + "raw_adjusted_weight_ppm": 7700, + "adjusted_weight_ppm": 7700, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + } + ], + "draws": [ + { + "sequence": 1, + "kind": "jackpot_mechanism_1", + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "weight_ppm": 1, + "payout_coins": 10000 + } + ], + "removed": null, + "conditions": [ + { + "name": "settled_round_qualification", + "passed": true, + "reason": "closed_round_pending" + }, + { + "name": "global_rtp", + "numerator": 9800, + "denominator": 10000, + "ratio_ppm": 980000, + "limit_ppm": 980000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_round_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_registered_48h", + "numerator": 172800000, + "denominator": 172800000, + "passed": true, + "reason": "registered_48h" + }, + { + "name": "user_48h_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "daily_jackpot_limit", + "limit_ppm": 5, + "passed": true, + "reason": "below_limit" + }, + { + "name": "payable_jackpot_candidate", + "passed": true, + "reason": "payable_jackpot_selected" + } + ], + "final_reason": "rtp_compensation_jackpot", + "jackpot_mechanism": "rtp_compensation", + "milestone_token_consumed": false, + "milestone_token_retained": false, + "milestone_tokens_earned": 0 + } + }, + "special_pool_blocked": { + "selected_tier": { + "id": "5x", + "multiplier_ppm": 5000000, + "base_weight_ppm": 200000, + "jackpot": false, + "enabled": true + }, + "payout_coins": 50, + "pool_after_coins": 0, + "stage": "advanced", + "jackpot": false, + "consumed_milestone_token": false, + "next_state": { + "pool_balance_coins": 0, + "consecutive_zero_draws": 0, + "pending_milestone_tokens": 0, + "user_daily_jackpot_wins": 0, + "user_day_spend_coins": 10, + "global_rtp": { + "wager_coins": 10000, + "payout_coins": 9800 + }, + "user_round_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "user_48h_rtp": { + "wager_coins": 10000, + "payout_coins": 9500 + }, + "settled_round_pending": false, + "qualification_closed_at_ms": 360000000, + "user_registered_at_ms": 187200000, + "user_day_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "user_72h_rtp": { + "wager_coins": 0, + "payout_coins": 0 + }, + "version": 1 + }, + "trace": { + "stage": "advanced", + "pool_before_coins": 50, + "pool_contribution_coins": 0, + "available_pool_coins": 50, + "effective_risk_capacity_coins": 9223372036854775807, + "weights": [ + { + "tier_id": "0x", + "multiplier_ppm": 0, + "base_weight_ppm": 600000, + "raw_adjusted_weight_ppm": 600000, + "adjusted_weight_ppm": 692000, + "factors": [ + { + "name": "zero_remainder", + "factor_ppm": 1000000 + } + ] + }, + { + "tier_id": "5x", + "multiplier_ppm": 5000000, + "base_weight_ppm": 200000, + "raw_adjusted_weight_ppm": 154000, + "adjusted_weight_ppm": 154000, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + }, + { + "tier_id": "50x", + "multiplier_ppm": 50000000, + "base_weight_ppm": 150000, + "raw_adjusted_weight_ppm": 115500, + "adjusted_weight_ppm": 115500, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + }, + { + "tier_id": "200x", + "multiplier_ppm": 200000000, + "base_weight_ppm": 40000, + "raw_adjusted_weight_ppm": 30800, + "adjusted_weight_ppm": 30800, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + }, + { + "tier_id": "500x", + "multiplier_ppm": 500000000, + "base_weight_ppm": 0, + "raw_adjusted_weight_ppm": 0, + "adjusted_weight_ppm": 0, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + }, + { + "tier_id": "1000x", + "multiplier_ppm": 1000000000, + "base_weight_ppm": 10000, + "raw_adjusted_weight_ppm": 7700, + "adjusted_weight_ppm": 7700, + "factors": [ + { + "name": "low_water", + "factor_ppm": 700000 + }, + { + "name": "recent_recharge", + "factor_ppm": 1100000 + } + ] + } + ], + "draws": [ + { + "sequence": 1, + "kind": "original", + "tier_id": "5x", + "multiplier_ppm": 5000000, + "weight_ppm": 154000, + "payout_coins": 50 + } + ], + "removed": [ + { + "tier_id": "200x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "500x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "1000x", + "reason": "pool_insufficient_w_gt_p" + }, + { + "tier_id": "0x", + "reason": "miss_protection_excludes_zero" + } + ], + "conditions": [ + { + "name": "settled_round_qualification", + "passed": true, + "reason": "closed_round_pending" + }, + { + "name": "global_rtp", + "numerator": 9800, + "denominator": 10000, + "ratio_ppm": 980000, + "limit_ppm": 980000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_round_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "user_registered_48h", + "numerator": 172800000, + "denominator": 172800000, + "passed": true, + "reason": "registered_48h" + }, + { + "name": "user_48h_rtp", + "numerator": 9500, + "denominator": 10000, + "ratio_ppm": 950000, + "limit_ppm": 960000, + "passed": true, + "reason": "within_limit" + }, + { + "name": "daily_jackpot_limit", + "limit_ppm": 5, + "passed": true, + "reason": "below_limit" + }, + { + "name": "payable_jackpot_candidate", + "passed": false, + "reason": "no_payable_jackpot_candidate" + } + ], + "original_tier_id": "5x", + "final_reason": "paid", + "blocked_reason": "no_payable_jackpot_candidate", + "milestone_token_consumed": false, + "milestone_token_retained": false, + "milestone_tokens_earned": 0 + } + } + } + }, + { + "name": "并发/幂等模型", + "passed": true, + "summary": "32个同幂等键仅1次状态迁移;100个并发唯一键串行化为100个版本且池余=300", + "data": { + "duplicate": { + "errors": [], + "requests": 32, + "transitions": 1, + "version": 1 + }, + "unique": { + "errors": [], + "pool_after": 300, + "requests": 100, + "transitions": 100, + "version": 100 + } + } + }, + { + "name": "长跑分群", + "passed": true, + "summary": "novice/normal/advanced各30000抽,分层权重不同但三组资金RTP均\u003c=98%且守恒", + "data": [ + { + "expected_stage": "novice", + "observed_stage": "novice", + "draws": 30000, + "tier_counts": { + "0.5x": 3036, + "0x": 3062, + "1x": 23902 + }, + "payout_coins": 2542000, + "pool_after": 398000, + "rtp_percent": 84.73333333333333 + }, + { + "expected_stage": "normal", + "observed_stage": "normal", + "draws": 30000, + "tier_counts": { + "0.5x": 1507, + "0x": 1598, + "1x": 25370, + "2x": 1525 + }, + "payout_coins": 2917350, + "pool_after": 22650, + "rtp_percent": 97.245 + }, + { + "expected_stage": "advanced", + "observed_stage": "advanced", + "draws": 30000, + "tier_counts": { + "0.5x": 1224, + "0x": 1802, + "1x": 25190, + "2x": 1784 + }, + "payout_coins": 2937000, + "pool_after": 3000, + "rtp_percent": 97.89999999999999 + } + ] + } + ], + "all_passed": true +} diff --git a/docs/幸运礼物外接接口对接文档.md b/docs/幸运礼物外接接口对接文档.md index cb6fa94e..e7590008 100644 --- a/docs/幸运礼物外接接口对接文档.md +++ b/docs/幸运礼物外接接口对接文档.md @@ -17,6 +17,8 @@ 对接 App 负责: - 调用前完成用户扣费。 +- 从自己的账务和用户主数据提供真实的扣费时间、稳定设备标识和账号注册时间。 +- 在自己的鉴权或请求签名边界内绑定上述业务事实,不能让 App 或 Web 客户端直接伪造。 - 成功收到结果后,在自己的账务系统中向用户发放 `reward_amount`。 - 对超时或网络失败使用原 `request_id` 重试,不得重新生成业务请求 ID。 @@ -43,9 +45,19 @@ GET https://lucky-api.global-interaction.com/healthz/ready 1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。 2. 确认该 App 的幸运礼物规则已启用。 3. 对接 App 必须先扣费,再调用抽奖接口。 -4. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。 +4. 如果使用 `dynamic_v3`,对接 App 必须能够提供真实 `paid_at_ms`、稳定 `device_id` 和真实 `user_registered_at_ms`。 +5. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。 -当前版本的 HTTP 入口使用 `app_code` 白名单,尚未启用 Authorization Token 或请求签名,因此必须仅由已开通的对接 App 服务端调用。 +### 3.1 真实性与信任边界 + +当前 HTTP 入口只校验 `app_code` 是否在白名单,尚未启用 Authorization Token 或平台侧请求签名。白名单只能决定“这个 App 是否已开通”,不能独立证明某次请求中的用户、设备、扣费时间和注册时间是真实的。 + +对接 App 必须在自己的服务端边界内完成以下保障: + +- `external_user_id` 来自已登录用户,不直接信任客户端自报值。 +- `device_id` 与该次已鉴权的用户/设备关系绑定,并在同一设备的后续请求中保持稳定;不能用随机数、请求号或会话 token 临时代替。 +- `paid_at_ms` 由扣费成功的账务事实产生,`user_registered_at_ms` 从用户主数据读取,不使用幸运礼物接口的收包时间或用户首次抽奖时间代替。 +- 如果对接 App 自身使用请求签名,签名内容必须覆盖这些字段;重试时必须保持原始业务事实不变。 ## 4. 发送幸运礼物 @@ -67,8 +79,11 @@ X-Request-Id: trace-20260715-000001 "app_code": "partner_app", "request_id": "gift-order-20260715-000001", "external_user_id": "user-10001", + "device_id": "device-installation-7f80d90b", "gift_count": 3, "unit_amount": 100, + "paid_at_ms": 1784073590000, + "user_registered_at_ms": 1783814400000, "currency": "COIN", "metadata": { "external_order_id": "order-1000001", @@ -85,8 +100,11 @@ X-Request-Id: trace-20260715-000001 | `app_code` | string | 是 | 去除首尾空格后 1–32 字符 | 平台分配的 App 编码;按小写存储和校验。 | | `request_id` | string | 是 | 去除首尾空格后 1–128 字符 | 对接 App 生成的业务幂等 ID;建议直接使用送礼订单号或 UUID。 | | `external_user_id` | string | 是 | 去除首尾空格后 1–128 字符 | 用户在对接 App 中的稳定唯一 ID。 | +| `device_id` | string | `dynamic_v3` 是 | 去除首尾空格后 1–128 字符 | 对接 App 在自己鉴权/签名边界内绑定的稳定设备标识;用于设备级风控,不得每次请求重新生成。 | | `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 | | `unit_amount` | int64 | 是 | `> 0` | 单个礼物的金币整数价格。 | +| `paid_at_ms` | int64 | `dynamic_v3` 是 | `> 0`,Unix epoch milliseconds | 对接 App 账务系统完成本次扣费的真实 UTC 时间;系统不会用接口收到请求的时间代替。 | +| `user_registered_at_ms` | int64 | `dynamic_v3` 业务必填 | `> 0`,Unix epoch milliseconds | 用户账号的真实创建时间,必须从对接 App 的用户主数据获取。技术上缺失不会阻断普通抽奖,但系统会按不满足注册 48 小时处理,关闭该用户的 RTP 补偿大奖资格。 | | `currency` | string | 否 | 只支持 `COIN` | 缺省或空字符串时按 `COIN` 处理。 | | `metadata` | object | 否 | JSON object | 对接方的展示或排查元数据;不参与金额、规则或幂等判定。 | @@ -96,7 +114,9 @@ X-Request-Id: trace-20260715-000001 total_amount = gift_count * unit_amount ``` -请求体不得传入 `total_amount`、`pool_id` 或 `paid_at_ms`。奖池由平台规则选择,扣费完成时间由网关按 UTC 服务端时间写入。接口会拒绝所有未定义的顶层字段。 +请求体不得传入 `total_amount` 或 `pool_id`。`total_amount` 由系统根据数量和单价计算,奖池由平台已发布的规则选择。接口会拒绝所有未定义的顶层字段。 + +`dynamic_v3` 下,缺失 `paid_at_ms` 或 `device_id` 会使整次抽奖失败;缺失、不合理或尚未满 48 小时的 `user_registered_at_ms` 不阻断普通抽奖,但 RTP 补偿大奖资格会严格按“不符合”处理。`fixed_v2` 为历史接入保留了缺省兼容,对接方不应依赖该兼容,否则规则切换到 `dynamic_v3` 后会失败或丢失大奖资格。 一个请求只返回一个聚合抽奖结果;`gift_count` 不会产生多个独立响应。 @@ -183,7 +203,7 @@ app_code + request_id | 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 | | 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 | | 404 | 无统一业务码 | 请求路径或 HTTP 方法错误 | 检查 Base URL、路径和请求方法。 | -| 500 | 50000 | 抽奖服务超时或内部处理失败 | 保持原 `request_id` 重试;不得重新扣费。 | +| 500 | 50000 | 抽奖服务超时、内部处理失败,或 `dynamic_v3` 在规则层发现 `paid_at_ms` / `device_id` 缺失 | 先本地校验 V3 业务事实已齐全;事实齐全时保持原 `request_id` 重试,不得重新扣费。 | 业务成功必须同时满足 HTTP Status 为 `200` 且响应 `code` 为 `0`。 @@ -198,8 +218,11 @@ curl --request POST \ "app_code": "partner_app", "request_id": "gift-order-20260715-000001", "external_user_id": "user-10001", + "device_id": "device-installation-7f80d90b", "gift_count": 3, "unit_amount": 100, + "paid_at_ms": 1784073590000, + "user_registered_at_ms": 1783814400000, "currency": "COIN", "metadata": { "external_order_id": "order-1000001", @@ -211,17 +234,22 @@ curl --request POST \ ## 8. 标准处理流程 -1. 对接 App 生成全局唯一的 `request_id`。 -2. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`。 -3. 对接 App 调用幸运礼物接口。 -4. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。 -5. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`。 -6. 任何未知结果都使用原 `request_id` 重试;禁止重复扣费或重新生成抽奖业务号。 +1. 对接 App 在已鉴权的服务端上确定 `external_user_id`、稳定 `device_id` 和用户主数据中的 `user_registered_at_ms`。 +2. 对接 App 生成全局唯一的 `request_id`。 +3. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`,并以扣费成功事实的 UTC 毫秒时间作为 `paid_at_ms`。 +4. 对接 App 调用幸运礼物接口,一次传入上述业务事实。 +5. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。 +6. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`。 +7. 任何未知结果都使用原 `request_id` 和原始业务事实重试;禁止重复扣费或重新生成抽奖业务号。 ## 9. 联调检查清单 - [ ] `app_code` 已加入幸运礼物网关白名单。 - [ ] 规则已启用,并确认金币单位一致。 +- [ ] `dynamic_v3` 请求中的 `paid_at_ms` 来自真实扣费事实,不是当前时间的临时填充值。 +- [ ] `device_id` 已绑定已鉴权设备且跨请求稳定,不用请求号或会话 token 代替。 +- [ ] `user_registered_at_ms` 来自用户主数据;已验证缺失时普通抽奖可继续、RTP 补偿大奖资格会关闭。 +- [ ] 调用能力仅保留在对接 App 服务端,上述业务事实已纳入自身鉴权或请求签名边界。 - [ ] 正常送礼可获得 HTTP 200 / `code=0`。 - [ ] 重复发送相同 `request_id` 可获得相同 `draw_id` 和结果。 - [ ] 模拟超时后只重试抽奖请求,不重复扣费。 diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 414e95b8..bf00ea17 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -353,7 +353,8 @@ func main() { databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...), databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...), ) - luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler) + luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler). + BindUserClient(userclient.NewGRPC(userConn)) handlers := router.Handlers{ Audit: auditHandler, Auth: authmodule.New(store, auth, auditHandler, cfg), diff --git a/server/admin/internal/integration/luckygiftadmin/client.go b/server/admin/internal/integration/luckygiftadmin/client.go index 2bd9c241..24260819 100644 --- a/server/admin/internal/integration/luckygiftadmin/client.go +++ b/server/admin/internal/integration/luckygiftadmin/client.go @@ -16,6 +16,8 @@ type Client interface { ListLuckyGiftConfigs(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigsRequest) (*luckygiftv1.ListLuckyGiftConfigsResponse, error) ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error) + ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error) + GetLuckyGiftUserProfile(ctx context.Context, req *luckygiftv1.GetLuckyGiftUserProfileRequest) (*luckygiftv1.GetLuckyGiftUserProfileResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) @@ -53,6 +55,14 @@ func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygift return c.client.GetLuckyGiftDrawSummary(ctx, req) } +func (c *GRPCClient) ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error) { + return c.client.ListLuckyGiftUserProfiles(ctx, req) +} + +func (c *GRPCClient) GetLuckyGiftUserProfile(ctx context.Context, req *luckygiftv1.GetLuckyGiftUserProfileRequest) (*luckygiftv1.GetLuckyGiftUserProfileResponse, error) { + return c.client.GetLuckyGiftUserProfile(ctx, req) +} + func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) { return c.client.ListLuckyGiftPoolBalances(ctx, req) } diff --git a/server/admin/internal/integration/userclient/client.go b/server/admin/internal/integration/userclient/client.go index ac43f207..75973316 100644 --- a/server/admin/internal/integration/userclient/client.go +++ b/server/admin/internal/integration/userclient/client.go @@ -177,6 +177,13 @@ type ResolveDisplayUserIDRequest struct { DisplayUserID string } +// ResolveAdminUserIdentifierRequest 仅供后台精确搜索永久默认短号、当前有效展示号或系统长 user_id。 +type ResolveAdminUserIdentifierRequest struct { + RequestID string + Caller string + UserIdentifier string +} + type UserIdentity struct { UserID int64 `json:"userId,string"` DisplayUserID string `json:"displayUserId"` @@ -437,6 +444,19 @@ func (c *GRPCClient) ResolveDisplayUserID(ctx context.Context, req ResolveDispla return fromProtoUserIdentity(resp.GetIdentity()), nil } +// ResolveAdminUserIdentifier 调用 user-service owner 的后台专用解析契约;admin-server 不读取用户库, +// 也不会为了搜索默认短号而改变客户端 ResolveDisplayUserID 的 active-only 语义。 +func (c *GRPCClient) ResolveAdminUserIdentifier(ctx context.Context, req ResolveAdminUserIdentifierRequest) (*UserIdentity, error) { + resp, err := c.identityClient.ResolveAdminUserIdentifier(ctx, &userv1.ResolveAdminUserIdentifierRequest{ + Meta: requestMeta(ctx, req.RequestID, req.Caller), + UserIdentifier: req.UserIdentifier, + }) + if err != nil { + return nil, err + } + return fromProtoUserIdentity(resp.GetIdentity()), nil +} + func fromProtoRegisterRiskConfig(config *userv1.RegisterRiskConfig) *RegisterRiskConfig { if config == nil { return nil diff --git a/server/admin/internal/modules/luckygift/handler.go b/server/admin/internal/modules/luckygift/handler.go index 387e331b..b02a1d4b 100644 --- a/server/admin/internal/modules/luckygift/handler.go +++ b/server/admin/internal/modules/luckygift/handler.go @@ -2,12 +2,14 @@ package luckygift import ( "context" + "encoding/json" "fmt" "math" "strings" "time" "hyapp-admin-server/internal/integration/luckygiftadmin" + "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" @@ -20,10 +22,18 @@ import ( type Handler struct { luckyGift luckygiftadmin.Client + users luckyGiftUserClient requestTimeout time.Duration audit shared.OperationLogger } +// luckyGiftUserClient 只暴露画像页面需要的 owner 能力。后台搜索必须走专用精确解析 RPC, +// 用户资料仍批量读取,避免给全局 userclient.Client 增加画像模块专属依赖。 +type luckyGiftUserClient interface { + BatchGetUsers(ctx context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) + ResolveAdminUserIdentifier(ctx context.Context, req userclient.ResolveAdminUserIdentifierRequest) (*userclient.UserIdentity, error) +} + func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler { if requestTimeout <= 0 { requestTimeout = 3 * time.Second @@ -31,42 +41,81 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh return &Handler{luckyGift: luckyGift, requestTimeout: requestTimeout, audit: audit} } +// BindUserClient 注入 user-service 主数据读取能力。幸运礼物 owner 只保存不可变的内部 user_id, +// 短号、默认号、靓号和用户资料必须在 admin-server 展示边界实时解析,不能复制进幸运礼物画像表。 +func (h *Handler) BindUserClient(users luckyGiftUserClient) *Handler { + h.users = users + return h +} + type configRequest struct { - AppCode string `json:"app_code"` - PoolID string `json:"pool_id"` - StrategyVersion string `json:"strategy_version"` - Enabled bool `json:"enabled"` - TargetRTPPercent float64 `json:"target_rtp_percent"` - PoolRatePercent float64 `json:"pool_rate_percent"` - ProfitRatePercent float64 `json:"profit_rate_percent"` - AnchorRatePercent float64 `json:"anchor_rate_percent"` - SettlementWindowWager int64 `json:"settlement_window_wager"` - ControlBandPercent float64 `json:"control_band_percent"` - GiftPriceReference int64 `json:"gift_price_reference"` - NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` - NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` - EffectiveFromMS int64 `json:"effective_from_ms"` - InitialPoolCoins int64 `json:"initial_pool_coins"` - LossStreakGuarantee int64 `json:"loss_streak_guarantee"` - LowWatermarkCoins int64 `json:"low_watermark_coins"` - LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"` - HighWatermarkCoins int64 `json:"high_watermark_coins"` - HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"` - RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` - RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"` - JackpotMultipliers []float64 `json:"jackpot_multipliers"` - JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"` - JackpotUserDayRTPMaxPercent float64 `json:"jackpot_user_day_rtp_max_percent"` - JackpotUser72hRTPMaxPercent float64 `json:"jackpot_user_72h_rtp_max_percent"` - JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"` - MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"` - MaxSinglePayout int64 `json:"max_single_payout"` - UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"` - UserDailyPayoutCap int64 `json:"user_daily_payout_cap"` - DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"` - RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"` - AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"` - Stages []stageDTO `json:"stages"` + AppCode string `json:"app_code"` + PoolID string `json:"pool_id"` + StrategyVersion string `json:"strategy_version"` + Enabled bool `json:"enabled"` + TargetRTPPercent float64 `json:"target_rtp_percent"` + PoolRatePercent float64 `json:"pool_rate_percent"` + ProfitRatePercent float64 `json:"profit_rate_percent"` + AnchorRatePercent float64 `json:"anchor_rate_percent"` + SettlementWindowWager int64 `json:"settlement_window_wager"` + ControlBandPercent float64 `json:"control_band_percent"` + GiftPriceReference int64 `json:"gift_price_reference"` + NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` + NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` + EffectiveFromMS int64 `json:"effective_from_ms"` + InitialPoolCoins int64 `json:"initial_pool_coins"` + LossStreakGuarantee int64 `json:"loss_streak_guarantee"` + LowWatermarkCoins int64 `json:"low_watermark_coins"` + LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"` + HighWatermarkCoins int64 `json:"high_watermark_coins"` + HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"` + RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` + RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"` + User24HourRTPThresholdPercent float64 `json:"user_24h_rtp_threshold_percent"` + User24HourOrdinaryWinFactorPercent float64 `json:"user_24h_ordinary_win_factor_percent"` + JackpotMultipliers []float64 `json:"jackpot_multipliers"` + JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"` + JackpotUserRoundRTPMaxPercent float64 `json:"jackpot_user_round_rtp_max_percent"` + JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"` + // Deprecated in-process aliases keep historical handler builders source-compatible. They are not + // accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names. + JackpotUserDayRTPMaxPercent float64 `json:"-"` + JackpotUser72hRTPMaxPercent float64 `json:"-"` + JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"` + MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"` + MaxSinglePayout int64 `json:"max_single_payout"` + UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"` + UserDailyPayoutCap int64 `json:"user_daily_payout_cap"` + DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"` + RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"` + AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"` + Stages []stageDTO `json:"stages"` +} + +// configUpsertRequest 只服务 HTTP 写入口。滚动发布期间旧前端仍可能发送 day/72h 字段, +// 因此输入 DTO 明确接收两代名字;configRequest 继续只暴露 canonical JSON,保证 GET 和写入响应不会双写字段。 +type configUpsertRequest struct { + configRequest + JackpotUserRoundRTPMaxPercent *float64 `json:"jackpot_user_round_rtp_max_percent"` + JackpotUser48hRTPMaxPercent *float64 `json:"jackpot_user_48h_rtp_max_percent"` + LegacyUserDayRTPMaxPercent *float64 `json:"jackpot_user_day_rtp_max_percent"` + LegacyUser72hRTPMaxPercent *float64 `json:"jackpot_user_72h_rtp_max_percent"` +} + +func (req configUpsertRequest) canonicalConfig() configRequest { + config := req.configRequest + // canonical 字段优先;指针用于区分“未发送”和显式发送 0,避免新旧字段同时存在时旧值覆盖新值。 + if req.JackpotUserRoundRTPMaxPercent != nil { + config.JackpotUserRoundRTPMaxPercent = *req.JackpotUserRoundRTPMaxPercent + } else if req.LegacyUserDayRTPMaxPercent != nil { + config.JackpotUserRoundRTPMaxPercent = *req.LegacyUserDayRTPMaxPercent + } + if req.JackpotUser48hRTPMaxPercent != nil { + config.JackpotUser48hRTPMaxPercent = *req.JackpotUser48hRTPMaxPercent + } else if req.LegacyUser72hRTPMaxPercent != nil { + config.JackpotUser48hRTPMaxPercent = *req.LegacyUser72hRTPMaxPercent + } + return config } type configDTO struct { @@ -189,11 +238,15 @@ func (h *Handler) GetLuckyGiftConfig(c *gin.Context) { } func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) { - var req configRequest - if err := c.ShouldBindJSON(&req); err != nil { + var input configUpsertRequest + decoder := json.NewDecoder(c.Request.Body) + // 兼容字段必须进入显式 DTO,不能退回 map 或宽松二次反序列化,否则拼错的配置字段会被静默忽略。 + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { response.BadRequest(c, "幸运礼物配置参数不正确") return } + req := input.canonicalConfig() if req.PoolID == "" { req.PoolID = strings.TrimSpace(c.Query("pool_id")) } @@ -413,6 +466,11 @@ func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.Can func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { strategyVersion := strings.ToLower(strings.TrimSpace(req.StrategyVersion)) + user24HourOrdinaryWinFactorPercent := req.User24HourOrdinaryWinFactorPercent + if req.User24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 { + // 旧 ops-center 不认识这两个字段,proto3 又会把缺失标量解成 0;0/0 统一解释为关闭并保留 100% 权重。 + user24HourOrdinaryWinFactorPercent = 100 + } initialPoolCoins := req.InitialPoolCoins if strategyVersion == "dynamic_v3" { // V3 启动资金已经迁移为独立人工 credit;旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。 @@ -443,42 +501,52 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { Tiers: tiers, }) } + roundRTPMaxPercent := req.JackpotUserRoundRTPMaxPercent + if roundRTPMaxPercent == 0 { + roundRTPMaxPercent = req.JackpotUserDayRTPMaxPercent + } + rolling48hRTPMaxPercent := req.JackpotUser48hRTPMaxPercent + if rolling48hRTPMaxPercent == 0 { + rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent + } return &luckygiftv1.LuckyGiftRuleConfig{ - AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), - PoolId: strings.TrimSpace(req.PoolID), - StrategyVersion: strategyVersion, - Enabled: req.Enabled, - TargetRtpPpm: percentToPPM(req.TargetRTPPercent), - PoolRatePpm: percentToPPM(req.PoolRatePercent), - ProfitRatePpm: percentToPPM(req.ProfitRatePercent), - AnchorRatePpm: percentToPPM(req.AnchorRatePercent), - SettlementWindowWager: req.SettlementWindowWager, - ControlBandPpm: percentToPPM(req.ControlBandPercent), - GiftPriceReference: req.GiftPriceReference, - NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, - NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, - EffectiveFromMs: req.EffectiveFromMS, - InitialPoolCoins: initialPoolCoins, - LossStreakGuarantee: req.LossStreakGuarantee, - LowWatermarkCoins: req.LowWatermarkCoins, - LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent), - HighWatermarkCoins: req.HighWatermarkCoins, - HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent), - RechargeBoostWindowMs: req.RechargeBoostWindowMS, - RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent), - JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers), - JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent), - JackpotUserDayRtpMaxPpm: percentToPPM(req.JackpotUserDayRTPMaxPercent), - JackpotUser_72HRtpMaxPpm: percentToPPM(req.JackpotUser72hRTPMaxPercent), - JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins, - MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay, - MaxSinglePayout: req.MaxSinglePayout, - UserHourlyPayoutCap: req.UserHourlyPayoutCap, - UserDailyPayoutCap: req.UserDailyPayoutCap, - DeviceDailyPayoutCap: req.DeviceDailyPayoutCap, - RoomHourlyPayoutCap: req.RoomHourlyPayoutCap, - AnchorDailyPayoutCap: req.AnchorDailyPayoutCap, - Stages: stages, + AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), + PoolId: strings.TrimSpace(req.PoolID), + StrategyVersion: strategyVersion, + Enabled: req.Enabled, + TargetRtpPpm: percentToPPM(req.TargetRTPPercent), + PoolRatePpm: percentToPPM(req.PoolRatePercent), + ProfitRatePpm: percentToPPM(req.ProfitRatePercent), + AnchorRatePpm: percentToPPM(req.AnchorRatePercent), + SettlementWindowWager: req.SettlementWindowWager, + ControlBandPpm: percentToPPM(req.ControlBandPercent), + GiftPriceReference: req.GiftPriceReference, + NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, + NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, + EffectiveFromMs: req.EffectiveFromMS, + InitialPoolCoins: initialPoolCoins, + LossStreakGuarantee: req.LossStreakGuarantee, + LowWatermarkCoins: req.LowWatermarkCoins, + LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent), + HighWatermarkCoins: req.HighWatermarkCoins, + HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent), + RechargeBoostWindowMs: req.RechargeBoostWindowMS, + RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent), + User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent), + User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent), + JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers), + JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent), + JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent), + JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent), + JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins, + MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay, + MaxSinglePayout: req.MaxSinglePayout, + UserHourlyPayoutCap: req.UserHourlyPayoutCap, + UserDailyPayoutCap: req.UserDailyPayoutCap, + DeviceDailyPayoutCap: req.DeviceDailyPayoutCap, + RoomHourlyPayoutCap: req.RoomHourlyPayoutCap, + AnchorDailyPayoutCap: req.AnchorDailyPayoutCap, + Stages: stages, } } @@ -512,46 +580,56 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO { // 兼容读取迁移前 V3 快照时也展示 0,避免运营误以为规则字段仍会自动形成账本资金。 initialPoolCoins = 0 } + user24HourRTPThresholdPercent := ppmToPercent(config.GetUser_24HRtpThresholdPpm()) + user24HourOrdinaryWinFactorPercent := ppmToPercent(config.GetUser_24HOrdinaryWinFactorPpm()) + if user24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 { + // 读取迁移前规则或滚动升级中的旧服务时也只返回唯一禁用态,前端无需猜测 0 因子是否表示全禁中。 + user24HourOrdinaryWinFactorPercent = 100 + } return configDTO{ RuleVersion: config.GetRuleVersion(), CreatedByAdminID: config.GetCreatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), configRequest: configRequest{ - AppCode: config.GetAppCode(), - PoolID: poolID, - StrategyVersion: strategyVersion, - Enabled: config.GetEnabled(), - TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), - PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), - ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()), - AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()), - SettlementWindowWager: config.GetSettlementWindowWager(), - ControlBandPercent: ppmToPercent(config.GetControlBandPpm()), - GiftPriceReference: config.GetGiftPriceReference(), - NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), - NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), - EffectiveFromMS: config.GetEffectiveFromMs(), - InitialPoolCoins: initialPoolCoins, - LossStreakGuarantee: config.GetLossStreakGuarantee(), - LowWatermarkCoins: config.GetLowWatermarkCoins(), - LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()), - HighWatermarkCoins: config.GetHighWatermarkCoins(), - HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()), - RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(), - RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()), - JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()), - JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()), - JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserDayRtpMaxPpm()), - JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_72HRtpMaxPpm()), - JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(), - MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(), - MaxSinglePayout: config.GetMaxSinglePayout(), - UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(), - UserDailyPayoutCap: config.GetUserDailyPayoutCap(), - DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(), - RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(), - AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(), - Stages: stages, + AppCode: config.GetAppCode(), + PoolID: poolID, + StrategyVersion: strategyVersion, + Enabled: config.GetEnabled(), + TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), + PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), + ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()), + AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()), + SettlementWindowWager: config.GetSettlementWindowWager(), + ControlBandPercent: ppmToPercent(config.GetControlBandPpm()), + GiftPriceReference: config.GetGiftPriceReference(), + NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), + NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), + EffectiveFromMS: config.GetEffectiveFromMs(), + InitialPoolCoins: initialPoolCoins, + LossStreakGuarantee: config.GetLossStreakGuarantee(), + LowWatermarkCoins: config.GetLowWatermarkCoins(), + LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()), + HighWatermarkCoins: config.GetHighWatermarkCoins(), + HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()), + RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(), + RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()), + User24HourRTPThresholdPercent: user24HourRTPThresholdPercent, + User24HourOrdinaryWinFactorPercent: user24HourOrdinaryWinFactorPercent, + JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()), + JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()), + JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()), + JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()), + JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()), + JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()), + JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(), + MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(), + MaxSinglePayout: config.GetMaxSinglePayout(), + UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(), + UserDailyPayoutCap: config.GetUserDailyPayoutCap(), + DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(), + RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(), + AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(), + Stages: stages, }, } } diff --git a/server/admin/internal/modules/luckygift/user_profile.go b/server/admin/internal/modules/luckygift/user_profile.go new file mode 100644 index 00000000..9c1d954d --- /dev/null +++ b/server/admin/internal/modules/luckygift/user_profile.go @@ -0,0 +1,604 @@ +package luckygift + +import ( + "context" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/integration/userclient" + "hyapp-admin-server/internal/middleware" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + luckygiftv1 "hyapp.local/api/proto/luckygift/v1" + + "github.com/gin-gonic/gin" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" +) + +type luckyGiftInternalUserDTO struct { + // UserID 使用字符串返回,避免浏览器把 64 位长号转成 JavaScript number 后丢失末位精度。 + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id"` + DefaultDisplayID string `json:"default_display_user_id"` + PrettyID string `json:"pretty_id"` + PrettyDisplayUserID string `json:"pretty_display_user_id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Status string `json:"status"` +} + +type luckyGiftUserProfileWindowDTO struct { + Draws int64 `json:"draws"` + WagerCoins int64 `json:"wager_coins"` + PayoutCoins int64 `json:"payout_coins"` + NetCoins int64 `json:"net_coins"` + RTPPPM int64 `json:"rtp_ppm"` + HasRTP bool `json:"has_rtp"` +} + +type luckyGiftUserProfileDTO struct { + AppCode string `json:"app_code"` + PoolID string `json:"pool_id"` + IdentityType string `json:"identity_type"` + InternalUserID string `json:"internal_user_id,omitempty"` + ExternalUserID string `json:"external_user_id,omitempty"` + InternalUser *luckyGiftInternalUserDTO `json:"internal_user,omitempty"` + + RuleVersion int64 `json:"rule_version"` + StrategyVersion string `json:"strategy_version"` + Stage string `json:"stage"` + PaidDraws int64 `json:"paid_draws"` + EquivalentDraws int64 `json:"equivalent_draws"` + LossStreak int64 `json:"loss_streak"` + PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"` + GuaranteeDrawsRemaining int64 `json:"guarantee_draws_remaining"` + DownweightActive bool `json:"downweight_active"` + DownweightFactorPPM int64 `json:"downweight_factor_ppm"` + User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"` + CurrentRTPPPM int64 `json:"current_rtp_ppm"` + HasCurrentRTP bool `json:"has_current_rtp"` + OneHour luckyGiftUserProfileWindowDTO `json:"one_hour"` + TwentyFourHours luckyGiftUserProfileWindowDTO `json:"twenty_four_hours"` + FortyEightHours luckyGiftUserProfileWindowDTO `json:"forty_eight_hours"` + Lifetime luckyGiftUserProfileWindowDTO `json:"lifetime"` + + BaseRewardCoins int64 `json:"base_reward_coins"` + OrdinaryWinCount int64 `json:"ordinary_win_count"` + HighMultiplierOrdinaryWinCount int64 `json:"high_multiplier_ordinary_win_count"` + RTPCompensationJackpotCount int64 `json:"rtp_compensation_jackpot_count"` + CumulativeSpendJackpotCount int64 `json:"cumulative_spend_jackpot_count"` + JackpotWinCount int64 `json:"jackpot_win_count"` + GuaranteeHitCount int64 `json:"guarantee_hit_count"` + ZeroDrawCount int64 `json:"zero_draw_count"` + GrantedDrawCount int64 `json:"granted_draw_count"` + PendingDrawCount int64 `json:"pending_draw_count"` + FailedDrawCount int64 `json:"failed_draw_count"` + MaxMultiplierPPM int64 `json:"max_multiplier_ppm"` + MaxRewardCoins int64 `json:"max_reward_coins"` + FirstDrawAtMS int64 `json:"first_draw_at_ms"` + LastDrawAtMS int64 `json:"last_draw_at_ms"` + AggregatedAtMS int64 `json:"aggregated_at_ms"` + DataLagMS int64 `json:"data_lag_ms"` +} + +type luckyGiftUserProfilePageDTO struct { + Items []luckyGiftUserProfileDTO `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + SnapshotAtMS int64 `json:"snapshot_at_ms"` + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more"` +} + +type luckyGiftUserMultiplierStatDTO struct { + HitType string `json:"hit_type"` + HitTypeLabel string `json:"hit_type_label"` + MultiplierPPM int64 `json:"multiplier_ppm"` + DrawCount int64 `json:"draw_count"` + WagerCoins int64 `json:"wager_coins"` + PayoutCoins int64 `json:"payout_coins"` +} + +type luckyGiftUserJackpotConditionDTO struct { + Name string `json:"name"` + Label string `json:"label"` + Actual string `json:"actual"` + Threshold string `json:"threshold"` + Operator string `json:"operator"` + Numerator int64 `json:"numerator"` + Denominator int64 `json:"denominator"` + RatioPPM int64 `json:"ratio_ppm"` + LimitPPM int64 `json:"limit_ppm"` + FactorPPM int64 `json:"factor_ppm"` + Factor string `json:"factor"` + Passed bool `json:"passed"` + Reason string `json:"reason"` +} + +type luckyGiftUserJackpotHitDTO struct { + EventKey string `json:"event_key"` + DrawID string `json:"draw_id"` + RequestID string `json:"request_id"` + RuleVersion int64 `json:"rule_version"` + Stage string `json:"stage"` + Mechanism string `json:"mechanism"` + MechanismLabel string `json:"mechanism_label"` + ReasonCode string `json:"reason_code"` + ReasonSummary string `json:"reason_summary"` + TierID string `json:"tier_id"` + MultiplierPPM int64 `json:"multiplier_ppm"` + WagerCoins int64 `json:"wager_coins"` + PayoutCoins int64 `json:"payout_coins"` + OccurredAtMS int64 `json:"occurred_at_ms"` + Conditions []luckyGiftUserJackpotConditionDTO `json:"conditions"` +} + +type luckyGiftUserProfileDetailDTO struct { + Profile luckyGiftUserProfileDTO `json:"profile"` + MultiplierDistribution []luckyGiftUserMultiplierStatDTO `json:"multiplier_distribution"` + JackpotHits []luckyGiftUserJackpotHitDTO `json:"jackpot_hits"` + JackpotHitTotal int64 `json:"jackpot_hit_total"` + JackpotPage int32 `json:"jackpot_page"` + JackpotPageSize int32 `json:"jackpot_page_size"` + NextJackpotCursor string `json:"next_jackpot_cursor,omitempty"` + JackpotHasMore bool `json:"jackpot_has_more"` + SnapshotAtMS int64 `json:"snapshot_at_ms"` +} + +// ListLuckyGiftUserProfiles 查询已经由 lucky-gift-service 增量物化的用户画像。列表只按 app+pool +// 读取聚合表;内部账号的短号/靓号解析和资料补全统一走 user-service,不在 HTTP 请求里扫描开奖事实表。 +func (h *Handler) ListLuckyGiftUserProfiles(c *gin.Context) { + appCode, poolID, ok := requiredLuckyGiftProfileScope(c) + if !ok { + return + } + identityType, ok := normalizeLuckyGiftProfileIdentity(firstNonEmpty(c.Query("identity_type"), c.Query("source"))) + if !ok { + response.BadRequest(c, "identity_type must be all, internal or external") + return + } + options := shared.ListOptions(c) + if options.PageSize > 100 { + options.PageSize = 100 + } + ctx, cancel := h.luckyGiftContext(c) + defer cancel() + ctx = appctx.WithContext(ctx, appCode) + + internalUserID := parseOptionalInt64(firstNonEmpty(c.Query("internal_user_id"), c.Query("user_id"))) + externalUserID := strings.TrimSpace(c.Query("external_user_id")) + keyword := strings.TrimSpace(c.Query("user_keyword")) + if keyword != "" { + resolvedInternalID, resolvedExternalID, internalMatched, err := h.resolveLuckyGiftProfileKeyword(ctx, appCode, identityType, keyword, middleware.CurrentRequestID(c)) + if err != nil { + response.ServerError(c, "搜索用户失败") + return + } + internalUserID, externalUserID = resolvedInternalID, resolvedExternalID + if identityType == "internal" && !internalMatched { + // 未解析的内部短号/长号/靓号必须返回空页,不能把“查无此人”退化为无条件全量列表。 + response.OK(c, luckyGiftUserProfilePageDTO{Items: []luckyGiftUserProfileDTO{}, Page: options.Page, PageSize: options.PageSize}) + return + } + } + + resp, err := h.luckyGift.ListLuckyGiftUserProfiles(ctx, &luckygiftv1.ListLuckyGiftUserProfilesRequest{ + Meta: h.metaForApp(c, appCode), + PoolId: poolID, + IdentityType: identityType, + InternalUserId: internalUserID, + ExternalUserId: externalUserID, + Stage: strings.TrimSpace(c.Query("stage")), + Status: strings.TrimSpace(c.Query("status")), + SortBy: strings.TrimSpace(c.Query("sort_by")), + SortDirection: strings.TrimSpace(c.Query("sort_direction")), + Page: int32(options.Page), + PageSize: int32(options.PageSize), + Cursor: strings.TrimSpace(c.Query("cursor")), + }) + if err != nil { + h.writeLuckyGiftUserProfileError(c, err, "获取幸运礼物用户画像失败") + return + } + + users, err := h.loadLuckyGiftInternalUsers(ctx, resp.GetProfiles(), middleware.CurrentRequestID(c)) + if err != nil { + response.ServerError(c, "获取用户资料失败") + return + } + items := make([]luckyGiftUserProfileDTO, 0, len(resp.GetProfiles())) + for _, profile := range resp.GetProfiles() { + items = append(items, luckyGiftUserProfileFromProto(profile, users[profile.GetInternalUserId()])) + } + response.OK(c, luckyGiftUserProfilePageDTO{ + Items: items, Page: options.Page, PageSize: options.PageSize, + Total: resp.GetTotal(), SnapshotAtMS: resp.GetSnapshotAtMs(), + NextCursor: resp.GetNextCursor(), HasMore: resp.GetHasMore(), + }) +} + +// GetLuckyGiftUserProfile 返回单个用户的倍率分布和大奖解释。大奖明细只在展开行时请求, +// 避免列表页同时解码所有用户的条件 trace,保证高密度页面仍保持稳定响应时间。 +func (h *Handler) GetLuckyGiftUserProfile(c *gin.Context) { + appCode, poolID, ok := requiredLuckyGiftProfileScope(c) + if !ok { + return + } + identityType, ok := normalizeLuckyGiftProfileIdentity(firstNonEmpty(c.Query("identity_type"), c.Query("source"))) + if !ok || identityType == "" { + response.BadRequest(c, "identity_type must be internal or external") + return + } + ctx, cancel := h.luckyGiftContext(c) + defer cancel() + ctx = appctx.WithContext(ctx, appCode) + + internalUserID := parseOptionalInt64(firstNonEmpty(c.Query("internal_user_id"), c.Query("user_id"))) + externalUserID := strings.TrimSpace(c.Query("external_user_id")) + if keyword := strings.TrimSpace(c.Query("user_keyword")); keyword != "" { + resolvedInternalID, resolvedExternalID, internalMatched, err := h.resolveLuckyGiftProfileKeyword(ctx, appCode, identityType, keyword, middleware.CurrentRequestID(c)) + if err != nil { + response.ServerError(c, "搜索用户失败") + return + } + internalUserID, externalUserID = resolvedInternalID, resolvedExternalID + if identityType == "internal" && !internalMatched { + response.NotFound(c, "未找到该用户的幸运礼物画像") + return + } + } + if (identityType == "internal" && internalUserID <= 0) || (identityType == "external" && externalUserID == "") { + response.BadRequest(c, "internal_user_id or external_user_id is required") + return + } + jackpotPage := parseLuckyGiftProfilePositiveInt(c.Query("jackpot_page"), 1, 1_000_000) + jackpotPageSize := parseLuckyGiftProfilePositiveInt(c.Query("jackpot_page_size"), 20, 50) + + resp, err := h.luckyGift.GetLuckyGiftUserProfile(ctx, &luckygiftv1.GetLuckyGiftUserProfileRequest{ + Meta: h.metaForApp(c, appCode), PoolId: poolID, IdentityType: identityType, + InternalUserId: internalUserID, ExternalUserId: externalUserID, + JackpotPage: int32(jackpotPage), JackpotPageSize: int32(jackpotPageSize), + JackpotCursor: strings.TrimSpace(c.Query("jackpot_cursor")), + }) + if err != nil { + h.writeLuckyGiftUserProfileError(c, err, "获取幸运礼物用户画像详情失败") + return + } + users, err := h.loadLuckyGiftInternalUsers(ctx, []*luckygiftv1.LuckyGiftUserProfile{resp.GetProfile()}, middleware.CurrentRequestID(c)) + if err != nil { + response.ServerError(c, "获取用户资料失败") + return + } + detail := luckyGiftUserProfileDetailDTO{ + Profile: luckyGiftUserProfileFromProto(resp.GetProfile(), users[resp.GetProfile().GetInternalUserId()]), + MultiplierDistribution: make([]luckyGiftUserMultiplierStatDTO, 0, len(resp.GetMultiplierDistribution())), + JackpotHits: make([]luckyGiftUserJackpotHitDTO, 0, len(resp.GetJackpotHits())), + JackpotHitTotal: resp.GetJackpotHitTotal(), JackpotPage: resp.GetJackpotPage(), + JackpotPageSize: resp.GetJackpotPageSize(), NextJackpotCursor: resp.GetNextJackpotCursor(), + JackpotHasMore: resp.GetJackpotHasMore(), SnapshotAtMS: resp.GetSnapshotAtMs(), + } + for _, stat := range resp.GetMultiplierDistribution() { + detail.MultiplierDistribution = append(detail.MultiplierDistribution, luckyGiftUserMultiplierStatFromProto(stat)) + } + for _, hit := range resp.GetJackpotHits() { + detail.JackpotHits = append(detail.JackpotHits, luckyGiftUserJackpotHitFromProto(hit)) + } + response.OK(c, detail) +} + +func parseLuckyGiftProfilePositiveInt(value string, fallback, maximum int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return fallback + } + if parsed > maximum { + return maximum + } + return parsed +} + +func requiredLuckyGiftProfileScope(c *gin.Context) (string, string, bool) { + appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code"))) + poolID := strings.TrimSpace(c.Query("pool_id")) + if appCode == "" || poolID == "" { + response.BadRequest(c, "app_code and pool_id are required") + return "", "", false + } + return appCode, poolID, true +} + +func normalizeLuckyGiftProfileIdentity(value string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + // owner 服务用空值表达“内部与外部”,避免引入第三种持久身份类型。 + return "", true + case "internal", "external": + return strings.ToLower(strings.TrimSpace(value)), true + default: + return "", false + } +} + +func (h *Handler) resolveLuckyGiftProfileKeyword(ctx context.Context, appCode, identityType, keyword, requestID string) (int64, string, bool, error) { + var externalUserID string + if identityType == "" || identityType == "external" { + // 外部接入没有 HyApp 用户主数据,原样按 external_user_id 精确匹配;稳定策略哈希永不出 owner 服务。 + externalUserID = keyword + } + if identityType == "external" { + return 0, externalUserID, false, nil + } + if h.users == nil { + return 0, externalUserID, false, fmt.Errorf("lucky gift user profile resolver is not configured") + } + identity, err := h.users.ResolveAdminUserIdentifier(appctx.WithContext(ctx, appCode), userclient.ResolveAdminUserIdentifierRequest{ + RequestID: requestID, Caller: "admin-server", UserIdentifier: keyword, + }) + if err == nil && identity != nil && identity.UserID > 0 { + return identity.UserID, externalUserID, true, nil + } + if err != nil { + switch grpcstatus.Code(err) { + case codes.NotFound, codes.InvalidArgument: + // all 模式下 keyword 也可能是任意 external_user_id;内部展示号格式不合法只代表内部未命中,不能阻断外部精确搜索。 + return 0, externalUserID, false, nil + default: + return 0, externalUserID, false, err + } + } + return 0, externalUserID, false, nil +} + +func (h *Handler) loadLuckyGiftInternalUsers(ctx context.Context, profiles []*luckygiftv1.LuckyGiftUserProfile, requestID string) (map[int64]*userclient.User, error) { + ids := make([]int64, 0, len(profiles)) + seen := make(map[int64]struct{}, len(profiles)) + for _, profile := range profiles { + if profile == nil || profile.GetIdentityType() != "internal" || profile.GetInternalUserId() <= 0 { + continue + } + if _, exists := seen[profile.GetInternalUserId()]; exists { + continue + } + seen[profile.GetInternalUserId()] = struct{}{} + ids = append(ids, profile.GetInternalUserId()) + } + if len(ids) == 0 { + return map[int64]*userclient.User{}, nil + } + if h.users == nil { + return nil, fmt.Errorf("lucky gift user profile user client is not configured") + } + return h.users.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{RequestID: requestID, Caller: "admin-server", UserIDs: ids}) +} + +func luckyGiftUserProfileFromProto(profile *luckygiftv1.LuckyGiftUserProfile, user *userclient.User) luckyGiftUserProfileDTO { + if profile == nil { + return luckyGiftUserProfileDTO{} + } + jackpotCount := profile.GetRtpCompensationJackpotCount() + profile.GetCumulativeSpendJackpotCount() + dto := luckyGiftUserProfileDTO{ + AppCode: profile.GetAppCode(), PoolID: profile.GetPoolId(), IdentityType: profile.GetIdentityType(), + RuleVersion: profile.GetRuleVersion(), + StrategyVersion: profile.GetStrategyVersion(), Stage: profile.GetStage(), PaidDraws: profile.GetPaidDraws(), + EquivalentDraws: profile.GetEquivalentDraws(), LossStreak: profile.GetLossStreak(), + PendingSpendJackpotTokens: profile.GetPendingSpendJackpotTokens(), GuaranteeDrawsRemaining: profile.GetGuaranteeDrawsRemaining(), + DownweightActive: profile.GetDownweightActive(), DownweightFactorPPM: profile.GetDownweightFactorPpm(), + User24HourRTPThresholdPPM: profile.GetUser_24HRtpThresholdPpm(), + OneHour: luckyGiftUserProfileWindowFromProto(profile.GetOneHour()), + TwentyFourHours: luckyGiftUserProfileWindowFromProto(profile.GetTwentyFourHours()), + FortyEightHours: luckyGiftUserProfileWindowFromProto(profile.GetFortyEightHours()), + Lifetime: luckyGiftUserProfileWindowFromProto(profile.GetLifetime()), + BaseRewardCoins: profile.GetBaseRewardCoins(), OrdinaryWinCount: profile.GetOrdinaryWinCount(), + HighMultiplierOrdinaryWinCount: profile.GetHighMultiplierOrdinaryWinCount(), + RTPCompensationJackpotCount: profile.GetRtpCompensationJackpotCount(), + CumulativeSpendJackpotCount: profile.GetCumulativeSpendJackpotCount(), JackpotWinCount: jackpotCount, + GuaranteeHitCount: profile.GetGuaranteeHitCount(), ZeroDrawCount: profile.GetZeroDrawCount(), + GrantedDrawCount: profile.GetGrantedDrawCount(), PendingDrawCount: profile.GetPendingDrawCount(), FailedDrawCount: profile.GetFailedDrawCount(), + MaxMultiplierPPM: profile.GetMaxMultiplierPpm(), MaxRewardCoins: profile.GetMaxRewardCoins(), + FirstDrawAtMS: profile.GetFirstDrawAtMs(), LastDrawAtMS: profile.GetLastDrawAtMs(), + AggregatedAtMS: profile.GetAggregatedAtMs(), DataLagMS: profile.GetDataLagMs(), + CurrentRTPPPM: profile.GetTwentyFourHours().GetRtpPpm(), HasCurrentRTP: profile.GetTwentyFourHours().GetHasRtp(), + } + if profile.GetIdentityType() == "internal" && profile.GetInternalUserId() > 0 { + // 外部画像即使 owner 发生协议回归误填了 internal_user_id,也不得在 admin HTTP 边界透出策略哈希。 + dto.InternalUserID = strconv.FormatInt(profile.GetInternalUserId(), 10) + dto.InternalUser = luckyGiftInternalUserFromClient(profile.GetInternalUserId(), user) + } else if profile.GetIdentityType() == "external" { + dto.ExternalUserID = profile.GetExternalUserId() + } + return dto +} + +func luckyGiftInternalUserFromClient(userID int64, user *userclient.User) *luckyGiftInternalUserDTO { + dto := &luckyGiftInternalUserDTO{UserID: strconv.FormatInt(userID, 10)} + if user == nil { + return dto + } + dto.DisplayUserID = user.DisplayUserID + dto.DefaultDisplayID = user.DefaultDisplayUserID + dto.PrettyID = user.PrettyID + dto.PrettyDisplayUserID = user.PrettyDisplayUserID + dto.Name = user.Username + dto.Avatar = user.Avatar + dto.Status = user.Status + return dto +} + +func luckyGiftUserProfileWindowFromProto(window *luckygiftv1.LuckyGiftUserProfileWindow) luckyGiftUserProfileWindowDTO { + if window == nil { + return luckyGiftUserProfileWindowDTO{} + } + return luckyGiftUserProfileWindowDTO{ + Draws: window.GetDraws(), WagerCoins: window.GetWagerCoins(), PayoutCoins: window.GetPayoutCoins(), + NetCoins: window.GetNetCoins(), RTPPPM: window.GetRtpPpm(), HasRTP: window.GetHasRtp(), + } +} + +func luckyGiftUserMultiplierStatFromProto(stat *luckygiftv1.LuckyGiftUserMultiplierStat) luckyGiftUserMultiplierStatDTO { + if stat == nil { + return luckyGiftUserMultiplierStatDTO{} + } + return luckyGiftUserMultiplierStatDTO{ + HitType: stat.GetHitType(), HitTypeLabel: luckyGiftHitTypeLabel(stat.GetHitType()), + MultiplierPPM: stat.GetMultiplierPpm(), DrawCount: stat.GetDrawCount(), + WagerCoins: stat.GetWagerCoins(), PayoutCoins: stat.GetPayoutCoins(), + } +} + +func luckyGiftUserJackpotHitFromProto(hit *luckygiftv1.LuckyGiftUserJackpotHit) luckyGiftUserJackpotHitDTO { + if hit == nil { + return luckyGiftUserJackpotHitDTO{} + } + conditions := make([]luckyGiftUserJackpotConditionDTO, 0, len(hit.GetConditions())) + for _, condition := range hit.GetConditions() { + conditions = append(conditions, luckyGiftUserJackpotConditionFromProto(condition)) + } + return luckyGiftUserJackpotHitDTO{ + EventKey: hit.GetEventKey(), DrawID: hit.GetDrawId(), RequestID: hit.GetRequestId(), RuleVersion: hit.GetRuleVersion(), Stage: hit.GetStage(), + Mechanism: hit.GetMechanism(), MechanismLabel: luckyGiftJackpotMechanismLabel(hit.GetMechanism()), + ReasonCode: hit.GetReasonCode(), ReasonSummary: luckyGiftJackpotReasonSummary(hit), TierID: hit.GetTierId(), + MultiplierPPM: hit.GetMultiplierPpm(), WagerCoins: hit.GetWagerCoins(), PayoutCoins: hit.GetPayoutCoins(), + OccurredAtMS: hit.GetOccurredAtMs(), Conditions: conditions, + } +} + +func luckyGiftUserJackpotConditionFromProto(condition *luckygiftv1.LuckyGiftUserJackpotCondition) luckyGiftUserJackpotConditionDTO { + if condition == nil { + return luckyGiftUserJackpotConditionDTO{} + } + actual, threshold, operator := luckyGiftJackpotConditionValues(condition) + return luckyGiftUserJackpotConditionDTO{ + Name: condition.GetName(), Label: luckyGiftJackpotConditionLabel(condition.GetName()), + Actual: actual, Threshold: threshold, Operator: operator, + Numerator: condition.GetNumerator(), Denominator: condition.GetDenominator(), + RatioPPM: condition.GetRatioPpm(), LimitPPM: condition.GetLimitPpm(), FactorPPM: condition.GetFactorPpm(), + Factor: luckyGiftPercent(condition.GetFactorPpm()), Passed: condition.GetPassed(), Reason: condition.GetReason(), + } +} + +func luckyGiftJackpotConditionValues(condition *luckygiftv1.LuckyGiftUserJackpotCondition) (string, string, string) { + name := condition.GetName() + switch name { + case "global_rtp", "spend_global_rtp", "user_round_rtp", "user_48h_rtp", "user_day_rtp", "user_72h_rtp": + return fmt.Sprintf("%s(返奖 %d / 消耗 %d)", luckyGiftPercent(condition.GetRatioPpm()), condition.GetNumerator(), condition.GetDenominator()), luckyGiftPercent(condition.GetLimitPpm()), "≤" + case "user_24h_rtp_downweight": + return fmt.Sprintf("%s(返奖 %d / 消耗 %d)", luckyGiftPercent(condition.GetRatioPpm()), condition.GetNumerator(), condition.GetDenominator()), luckyGiftPercent(condition.GetLimitPpm()), ">" + case "user_registered_48h": + return luckyGiftDurationHours(condition.GetNumerator()), luckyGiftDurationHours(condition.GetDenominator()), "≥" + case "daily_jackpot_limit", "spend_daily_jackpot_limit": + return fmt.Sprintf("%d 次", condition.GetNumerator()), fmt.Sprintf("%d 次", condition.GetLimitPpm()), "<" + case "settled_round_qualification", "payable_jackpot_candidate": + if condition.GetPassed() { + return "已满足", "必须满足", "=" + } + return "未满足", "必须满足", "=" + default: + if condition.GetLimitPpm() != 0 || condition.GetRatioPpm() != 0 { + return luckyGiftPercent(condition.GetRatioPpm()), luckyGiftPercent(condition.GetLimitPpm()), "≤" + } + if condition.GetPassed() { + return "已满足", "必须满足", "=" + } + return "未满足", "必须满足", "=" + } +} + +func luckyGiftJackpotConditionLabel(name string) string { + labels := map[string]string{ + "global_rtp": "全奖池当前返奖率", + "spend_global_rtp": "累计消费大奖检查时的全奖池返奖率", + "settled_round_qualification": "上一结算轮次是否达到补偿条件", + "user_round_rtp": "用户当前结算轮次返奖率", + "user_48h_rtp": "用户近 48 小时返奖率", + "user_registered_48h": "用户注册是否已满 48 小时", + "user_24h_rtp_downweight": "用户近 24 小时普通中奖降权", + "user_day_rtp": "用户当天返奖率", + "user_72h_rtp": "用户近 72 小时返奖率", + "daily_jackpot_limit": "用户当天已中大奖次数", + "spend_daily_jackpot_limit": "累计消费大奖的当天次数限制", + "payable_jackpot_candidate": "奖池余额是否足够支付所选大奖", + } + if label := labels[name]; label != "" { + return label + } + return strings.ReplaceAll(strings.TrimSpace(name), "_", " ") +} + +func luckyGiftJackpotMechanismLabel(mechanism string) string { + switch mechanism { + case "rtp_compensation", "jackpot_mechanism_1": + return "RTP 补偿大奖" + case "spend_milestone", "jackpot_mechanism_2": + return "累计消费大奖" + default: + return "大奖" + } +} + +func luckyGiftJackpotReasonSummary(hit *luckygiftv1.LuckyGiftUserJackpotHit) string { + switch hit.GetReasonCode() { + case "rtp_compensation_jackpot": + return "用户与奖池均满足补偿条件,且奖池余额足够支付,因此触发 RTP 补偿大奖。" + case "milestone_token_jackpot": + return "用户累计消费已获得一次大奖机会,且奖池余额足够支付,因此触发累计消费大奖。" + } + if summary := strings.TrimSpace(hit.GetReasonSummary()); summary != "" { + return summary + } + return "本次开奖满足下列全部条件,因此命中该大奖。" +} + +func luckyGiftHitTypeLabel(hitType string) string { + switch hitType { + case "zero": + return "未中奖" + case "ordinary": + return "普通奖" + case "rtp_compensation": + return "RTP 补偿大奖" + case "spend_milestone": + return "累计消费大奖" + default: + return hitType + } +} + +func luckyGiftPercent(ppm int64) string { + value := strconv.FormatFloat(float64(ppm)/10_000, 'f', 2, 64) + value = strings.TrimRight(strings.TrimRight(value, "0"), ".") + if value == "" { + value = "0" + } + return value + "%" +} + +func luckyGiftDurationHours(milliseconds int64) string { + value := strconv.FormatFloat(float64(milliseconds)/3_600_000, 'f', 1, 64) + value = strings.TrimRight(strings.TrimRight(value, "0"), ".") + return value + " 小时" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func (h *Handler) writeLuckyGiftUserProfileError(c *gin.Context, err error, fallback string) { + status := grpcstatus.Convert(err) + switch status.Code() { + case codes.InvalidArgument: + response.BadRequest(c, status.Message()) + case codes.NotFound: + response.NotFound(c, "未找到该用户的幸运礼物画像") + default: + response.ServerError(c, fallback) + } +} diff --git a/server/admin/internal/modules/opscenter/routes.go b/server/admin/internal/modules/opscenter/routes.go index f3433314..a4e7b3d2 100644 --- a/server/admin/internal/modules/opscenter/routes.go +++ b/server/admin/internal/modules/opscenter/routes.go @@ -24,6 +24,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { group.GET("/lucky-gifts/configs", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftConfigs) group.GET("/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftDraws) group.GET("/lucky-gifts/summary", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftDrawSummary) + group.GET("/lucky-gifts/user-profiles", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftUserProfiles) + group.GET("/lucky-gifts/user-profile", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftUserProfile) group.GET("/lucky-gifts/pools", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftPoolBalances) group.POST("/lucky-gifts/pools/credit", middleware.RequirePermission("lucky-gift:pool-credit"), h.luckyGift.CreditLuckyGiftPoolBalance) group.POST("/lucky-gifts/pools/debit", middleware.RequirePermission("lucky-gift:pool-debit"), h.luckyGift.DebitLuckyGiftPoolBalance) diff --git a/server/luck-gateway/internal/integration/luckygiftclient/client.go b/server/luck-gateway/internal/integration/luckygiftclient/client.go index 7496c0ca..d0b12ea3 100644 --- a/server/luck-gateway/internal/integration/luckygiftclient/client.go +++ b/server/luck-gateway/internal/integration/luckygiftclient/client.go @@ -15,12 +15,15 @@ type SendCommand struct { RequestID string ExternalUserID string DeviceID string - GiftCount int64 - UnitAmount int64 - TotalAmount int64 - Currency string - MetadataJSON string - PaidAtMS int64 + // UserRegisteredAtMS 是外部 App 用户 owner 提供的账号创建事实;integration 层只原样透传, + // 不用网关收包时间或该用户首次请求时间填充,否则会错误改变完整历史窗口资格。 + UserRegisteredAtMS int64 + GiftCount int64 + UnitAmount int64 + TotalAmount int64 + Currency string + MetadataJSON string + PaidAtMS int64 } type SendResult struct { @@ -58,16 +61,17 @@ func (c *GRPCClient) SendLuckyGift(ctx context.Context, cmd SendCommand) (SendRe SentAtMs: time.Now().UTC().UnixMilli(), AppCode: cmd.AppCode, }, - AppCode: cmd.AppCode, - ExternalUserId: cmd.ExternalUserID, - DeviceId: cmd.DeviceID, - RequestId: cmd.RequestID, - GiftCount: cmd.GiftCount, - UnitAmount: cmd.UnitAmount, - TotalAmount: cmd.TotalAmount, - Currency: cmd.Currency, - PaidAtMs: cmd.PaidAtMS, - MetadataJson: cmd.MetadataJSON, + AppCode: cmd.AppCode, + ExternalUserId: cmd.ExternalUserID, + DeviceId: cmd.DeviceID, + UserRegisteredAtMs: cmd.UserRegisteredAtMS, + RequestId: cmd.RequestID, + GiftCount: cmd.GiftCount, + UnitAmount: cmd.UnitAmount, + TotalAmount: cmd.TotalAmount, + Currency: cmd.Currency, + PaidAtMs: cmd.PaidAtMS, + MetadataJson: cmd.MetadataJSON, }) if err != nil { return SendResult{}, err diff --git a/server/luck-gateway/internal/modules/luckygift/handler.go b/server/luck-gateway/internal/modules/luckygift/handler.go index 33f3fa1b..41b5d876 100644 --- a/server/luck-gateway/internal/modules/luckygift/handler.go +++ b/server/luck-gateway/internal/modules/luckygift/handler.go @@ -35,11 +35,14 @@ type sendRequest struct { DeviceID string `json:"device_id"` // PaidAtMS 是外部 App 自己完成扣费的 UTC epoch ms;调用方必须把它纳入自己的认证/签名边界。 // gateway 不用到达时间替代。fixed_v2 允许 0 兼容,dynamic_v3 由 owner 读取规则后强制要求正值。 - PaidAtMS int64 `json:"paid_at_ms"` - GiftCount int64 `json:"gift_count"` - UnitAmount int64 `json:"unit_amount"` - Currency string `json:"currency"` - Metadata map[string]any `json:"metadata"` + PaidAtMS int64 `json:"paid_at_ms"` + // UserRegisteredAtMS 是外部 App 用户主数据中的账号创建时间,不是本次请求首次出现时间。 + // fixed_v2 允许 0 兼容旧接入;dynamic_v3 缺失时由 owner 让补偿大奖资格 fail-close。 + UserRegisteredAtMS int64 `json:"user_registered_at_ms"` + GiftCount int64 `json:"gift_count"` + UnitAmount int64 `json:"unit_amount"` + Currency string `json:"currency"` + Metadata map[string]any `json:"metadata"` } func (h *Handler) Send(w http.ResponseWriter, r *http.Request) { @@ -64,16 +67,17 @@ func (h *Handler) Send(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), h.requestTimeout) defer cancel() result, err := h.client.SendLuckyGift(ctx, luckygiftclient.SendCommand{ - TraceRequestID: requestID, - AppCode: appCode, - RequestID: strings.TrimSpace(req.RequestID), - ExternalUserID: strings.TrimSpace(req.ExternalUserID), - DeviceID: strings.TrimSpace(req.DeviceID), - GiftCount: req.GiftCount, - UnitAmount: req.UnitAmount, - TotalAmount: req.GiftCount * req.UnitAmount, - Currency: strings.TrimSpace(req.Currency), - MetadataJSON: metadataJSON(req.Metadata), + TraceRequestID: requestID, + AppCode: appCode, + RequestID: strings.TrimSpace(req.RequestID), + ExternalUserID: strings.TrimSpace(req.ExternalUserID), + DeviceID: strings.TrimSpace(req.DeviceID), + UserRegisteredAtMS: req.UserRegisteredAtMS, + GiftCount: req.GiftCount, + UnitAmount: req.UnitAmount, + TotalAmount: req.GiftCount * req.UnitAmount, + Currency: strings.TrimSpace(req.Currency), + MetadataJSON: metadataJSON(req.Metadata), // pool 仍由 owner 默认规则选择;paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。 PaidAtMS: req.PaidAtMS, }) @@ -109,8 +113,11 @@ func (r sendRequest) validate() error { if r.PaidAtMS < 0 { return errors.New("paid_at_ms is invalid") } - // 不在这里统一要求 device_id:网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。 - // owner 读到规则后对 dynamic_v3 权威拒绝空值,fixed_v2 继续兼容旧调用方。 + if r.UserRegisteredAtMS < 0 { + return errors.New("user_registered_at_ms is invalid") + } + // 不在这里统一要求 device_id/user_registered_at_ms:网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。 + // owner 读到规则后对 dynamic_v3 权威处理缺失事实,fixed_v2 继续兼容旧调用方。 if r.GiftCount <= 0 || r.UnitAmount <= 0 { return errors.New("gift_count and unit_amount must be positive") } diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index a19ab6dc..3a18ef80 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -2030,13 +2030,16 @@ func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Reque firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs)) resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{ - Meta: httpkit.RoomMeta(request, roomID, commandID), - TargetType: targetType, - TargetUserIds: targetUserIDs, - TargetUserId: firstUserID(targetUserIDs), - GiftId: body.GiftID, - GiftCount: body.GiftCount, - PoolId: body.PoolID, + Meta: httpkit.RoomMeta(request, roomID, commandID), + TargetType: targetType, + TargetUserIds: targetUserIDs, + TargetUserId: firstUserID(targetUserIDs), + GiftId: body.GiftID, + GiftCount: body.GiftCount, + PoolId: body.PoolID, + // 注册时间属于 user-service 主数据快照;只允许 gateway 从已鉴权 sender 资料注入, + // room/lucky-gift 后续据此判断账号是否已跨过完整历史窗口,不能信任客户端自报。 + SenderRegisteredAtMs: userSnapshots.senderRegisteredAtMS, SenderCountryId: userSnapshots.senderCountryID, SenderRegionId: userSnapshots.senderRegionID, TargetIsHost: firstTargetScope.GetTargetIsHost(), @@ -2126,6 +2129,7 @@ func (h *Handler) resolveRoomUserDisplayProfileBestEffort(request *http.Request, } type giftUserSnapshots struct { + senderRegisteredAtMS int64 senderCountryID int64 senderRegionID int64 senderDisplayProfile *roomv1.SendGiftDisplayProfile @@ -2161,6 +2165,9 @@ func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs } } return giftUserSnapshots{ + // created_at_ms 是账号 owner 返回的稳定事实;即使同一 command_id 重试时资料发生变化, + // room-service 也会恢复首次持久化的命令快照,避免补偿大奖资格随重试漂移。 + senderRegisteredAtMS: sender.GetCreatedAtMs(), senderCountryID: sender.GetCountryId(), senderRegionID: sender.GetRegionId(), senderDisplayProfile: giftDisplayProfileFromUser(sender), diff --git a/services/lucky-gift-service/cmd/strategy-sim/main.go b/services/lucky-gift-service/cmd/strategy-sim/main.go index 55c5d364..64273826 100644 --- a/services/lucky-gift-service/cmd/strategy-sim/main.go +++ b/services/lucky-gift-service/cmd/strategy-sim/main.go @@ -92,16 +92,21 @@ func runSimulation(options simulationOptions) simulationReport { simulateBatchCounts(config, options.Seed), simulateSettlementWagerWindow(), simulateWaterBoundaries(config), + simulateUser24HourRTPDownweight(config, options.Seed+5), + simulateUser24HourRTPBatchCrossing(config), + simulateUser24HourRTPMillionDraws(config, options.Seed+6), simulateRechargeWindowBoundaries(config), simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN), simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN), - simulateMechanismOneTruthTable(config, options.Seed+30), - simulateMechanismTwo(config, options.Seed+40), + simulateSettledRoundQualification(config, options.Seed+30), + simulatePoolBlockedQualificationConsumption(config, options.Seed+40), simulateJackpotSet(config, options.Seed+50), simulateDailyJackpotLimit(config, options.Seed+60), + simulateDualSourceOverlap(config), simulateSixRiskCapacities(config), simulateRechargeStages(config), simulateCombinationPriority(config, options.Seed+70), + simulateDualJackpotSpendStrategy(config, options.Seed+75), simulateConcurrencyAndIdempotency(config, options.Seed+80), simulateLongRunGroups(config, options.Seed+90, options.GroupLongRunN), } diff --git a/services/lucky-gift-service/cmd/strategy-sim/scenarios.go b/services/lucky-gift-service/cmd/strategy-sim/scenarios.go index fa765979..0601bcb2 100644 --- a/services/lucky-gift-service/cmd/strategy-sim/scenarios.go +++ b/services/lucky-gift-service/cmd/strategy-sim/scenarios.go @@ -7,6 +7,7 @@ import ( "sync" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" + mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql" ) type scriptedRandom struct { @@ -28,10 +29,18 @@ func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig { {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, {ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true}, {ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true}, - {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true}, - {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true}, - {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true}, + {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true}, + {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true}, + {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Enabled: true}, + {ID: "jackpot_200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true}, + {ID: "jackpot_500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true}, + {ID: "jackpot_1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true}, } + // Existing scenarios isolate their original product axis. The dedicated 24h scenario below + // enables the new control explicitly so a high payout in one legacy scenario cannot silently + // change every subsequent expected probability. + config.User24HourRTPThresholdPPM = 0 + config.User24HourOrdinaryWinFactorPPM = domain.StrategyPPMScale return config } @@ -248,6 +257,254 @@ func simulateWaterBoundaries(config domain.StrategyConfig) scenarioResult { return scenarioResult{Name: name, Passed: passed, Summary: "<1000万应用0.7;等于1000万/2000万均为中水位;>2000万应用1.3", Data: points} } +func simulateUser24HourRTPDownweight(config domain.StrategyConfig, seed int64) scenarioResult { + const name = "用户滚动24小时RTP普通奖降权" + config = baseProbabilityConfig(config) + config.User24HourRTPThresholdPPM = 1_500_000 + config.User24HourOrdinaryWinFactorPPM = 700_000 + config.MissProtectionZeroDraws = 5 + + type boundaryRow struct { + Case string `json:"case"` + WagerCoins int64 `json:"wager_coins"` + PayoutCoins int64 `json:"payout_coins"` + PositiveWeight int64 `json:"positive_weight_ppm"` + ZeroWeight int64 `json:"zero_weight_ppm"` + Active bool `json:"active"` + } + rows := make([]boundaryRow, 0, 3) + for _, item := range []struct { + name string + wager, payout int64 + wantPositive int64 + wantActive bool + }{ + {name: "149.999%", wager: 100_000, payout: 149_999, wantPositive: 400_000}, + {name: "150%", wager: 100_000, payout: 150_000, wantPositive: 400_000}, + {name: "150.001%", wager: 100_000, payout: 150_001, wantPositive: 280_000, wantActive: true}, + } { + state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000, User24HourRTP: domain.StrategyRTP{WagerCoins: item.wager, PayoutCoins: item.payout}} + _, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, state, domain.StrategyInput{GiftPriceCoins: 10}) + if err != nil { + return failedScenario(name, err) + } + zero := strategyWeightTrace(traces, "0x").AdjustedWeightPPM + positive := domain.StrategyPPMScale - zero + active := strategyTraceHasFactor(traces, domain.StrategyConditionUser24HourRTPDownweight) + rows = append(rows, boundaryRow{Case: item.name, WagerCoins: item.wager, PayoutCoins: item.payout, PositiveWeight: positive, ZeroWeight: zero, Active: active}) + if positive != item.wantPositive || active != item.wantActive { + return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("%s 边界权重错误: positive=%d active=%v", item.name, positive, active), Data: rows} + } + } + + // Force high-water+recharge raw positive weight above 100%. The ordinary table must normalize to + // 100% first and only then retain 70%, yielding an exact 700k/300k split. + combined := config + combined.HighWaterThresholdCoins = 100 + combined.HighWaterFactorPPM = 3_000_000 + combined.RechargeFactorPPM = 2_000_000 + combinedState := domain.StrategyState{PoolBalanceCoins: 101, User24HourRTP: domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001}} + _, combinedTraces, err := domain.PreviewLuckyGiftStrategyWeights(combined, combinedState, domain.StrategyInput{ + GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true, LastRechargeAtMS: 1_000_000, + }) + if err != nil { + return failedScenario(name, err) + } + combinedZero := strategyWeightTrace(combinedTraces, "0x").AdjustedWeightPPM + + // At the configured miss threshold, protection restores the pre-downweight ordinary table before + // removing 0x. This also covers tiny retained factors whose integer apportionment rounded an + // otherwise payable tier to zero: protection remains strictly stronger than personal downweight. + protectedState := combinedState + protectedState.PoolBalanceCoins = 1_000_000_000 + protectedState.ConsecutiveZeroDraws = config.MissProtectionZeroDraws + protected, err := domain.DecideLuckyGiftStrategy(config, protectedState, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{0}, bounds: []int64{400_000}}) + if err != nil { + return failedScenario(name, err) + } + // A 1-PPM retained factor leaves exactly one integer weight unit. Largest-remainder assigns that + // unit to the unaffordable 1000x tier and rounds the payable 1x tier to zero. Miss protection must + // rebuild the pre-downweight 1/999999 table, where scripted point zero selects the payable 1x. + roundingConfig := config + roundingConfig.User24HourOrdinaryWinFactorPPM = 1 + roundingConfig.Tiers = []domain.StrategyTier{ + {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true}, + {ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 1, Enabled: true}, + {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 999_999, Enabled: true}, + } + roundingState := domain.StrategyState{ + PoolBalanceCoins: 10, + ConsecutiveZeroDraws: roundingConfig.MissProtectionZeroDraws, + User24HourRTP: domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001}, + } + roundingProtected, err := domain.DecideLuckyGiftStrategy( + roundingConfig, roundingState, domain.StrategyInput{GiftPriceCoins: 10}, + &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}}, + ) + if err != nil { + return failedScenario(name, err) + } + + // The condition is visible in both jackpot traces but must not gate or rescale either independent + // jackpot source. High personal 24h RTP therefore still permits an otherwise-qualified jackpot. + compensationConfig := settledRoundJackpotConfig(config) + compensationState := qualifiedSettledRoundState(1_000_000_000, 100*60*60*1000) + compensationState.User24HourRTP = domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001} + compensation, err := domain.DecideLuckyGiftStrategy(compensationConfig, compensationState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed)) + if err != nil { + return failedScenario(name, err) + } + milestoneConfig := compensationConfig + milestoneConfig.JackpotMechanism1Enabled = false + milestoneConfig.JackpotMechanism2Enabled = true + milestoneConfig.MilestoneSpendCoins = 50 + milestoneState := domain.StrategyState{ + PoolBalanceCoins: 1_000_000_000, PendingSpendJackpotTokens: 1, + SpendGlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800}, + User24HourRTP: domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001}, + } + milestone, err := domain.DecideLuckyGiftStrategy(milestoneConfig, milestoneState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1)) + if err != nil { + return failedScenario(name, err) + } + + passed := combinedZero == 300_000 && protected.PayoutCoins > 0 && + roundingProtected.SelectedTier.ID == "1x" && roundingProtected.PayoutCoins == 10 && + compensation.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && + milestone.JackpotMechanism == domain.StrategyJackpotMechanismMilestone + return scenarioResult{ + Name: name, Passed: passed, + Summary: fmt.Sprintf("149.999%%/150%%正常、150.001%%降为70%%;叠加归一后正奖/0x=%d/%d;保底=%s且1PPM舍入仍命中%s;两类大奖=%s/%s", + domain.StrategyPPMScale-combinedZero, combinedZero, protected.SelectedTier.ID, roundingProtected.SelectedTier.ID, compensation.JackpotMechanism, milestone.JackpotMechanism), + Data: map[string]any{ + "boundaries": rows, "combined_weight_trace": combinedTraces, + "miss_protection": protected.Trace, "rounding_miss_protection": roundingProtected.Trace, + "rtp_compensation": compensation.Trace, "spend_milestone": milestone.Trace, + }, + } +} + +func strategyTraceHasFactor(traces []domain.StrategyWeightTrace, name string) bool { + for _, trace := range traces { + for _, factor := range trace.Factors { + if factor.Name == name { + return true + } + } + } + return false +} + +func simulateUser24HourRTPBatchCrossing(config domain.StrategyConfig) scenarioResult { + const name = "用户24小时RTP批内跨线" + config = baseProbabilityConfig(config) + config.User24HourRTPThresholdPPM = 1_500_000 + config.User24HourOrdinaryWinFactorPPM = 700_000 + config.MissProtectionZeroDraws = math.MaxInt64 + config.Tiers = []domain.StrategyTier{ + {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, + {ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 200_000, Enabled: true}, + {ID: "2x", MultiplierPPM: 2_000_000, BaseWeightPPM: 200_000, Enabled: true}, + } + state := domain.StrategyState{ + PoolBalanceCoins: 1_000_000, + User24HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 140}, + } + // 800000 is the first point of 2x in the normal 600k/200k/200k table. The 200-coin payout + // changes the rolling snapshot from 140/100 to 340/200 only after this selection. + first, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100}, &scriptedRandom{indexes: []int64{800_000}, bounds: []int64{1_000_000}}) + if err != nil { + return failedScenario(name, err) + } + // The next subdraw now sees 170% and its ordinary mass is 280k. Random point zero proves the + // enlarged 720k zero tier is live, while the first draw's own probability remained untouched. + second, err := domain.DecideLuckyGiftStrategy(config, first.NextState, domain.StrategyInput{GiftPriceCoins: 100}, &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}}) + if err != nil { + return failedScenario(name, err) + } + firstCondition := strategyConditionTrace(first.Trace, domain.StrategyConditionUser24HourRTPDownweight) + secondCondition := strategyConditionTrace(second.Trace, domain.StrategyConditionUser24HourRTPDownweight) + secondZeroWeight := strategyWeightTrace(second.Trace.Weights, "0x").AdjustedWeightPPM + passed := first.SelectedTier.ID == "2x" && !firstCondition.Passed && + first.NextState.User24HourRTP.WagerCoins == 200 && first.NextState.User24HourRTP.PayoutCoins == 340 && + secondCondition.Passed && secondZeroWeight == 720_000 && second.SelectedTier.ID == "0x" + return scenarioResult{ + Name: name, Passed: passed, + Summary: fmt.Sprintf("第一抽用140%%正常表命中%s,抽后变170%%;第二抽正奖/0x=280000/%d并命中%s", first.SelectedTier.ID, secondZeroWeight, second.SelectedTier.ID), + Data: map[string]any{"first": first, "second": second}, + } +} + +func simulateUser24HourRTPMillionDraws(config domain.StrategyConfig, seed int64) scenarioResult { + const ( + name = "用户24小时RTP百万抽概率" + draws = 1_000_000 + ) + config = baseProbabilityConfig(config) + config.User24HourRTPThresholdPPM = 1_500_000 + config.User24HourOrdinaryWinFactorPPM = 700_000 + config.MissProtectionZeroDraws = math.MaxInt64 + config.Tiers = []domain.StrategyTier{ + {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, + {ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 400_000, Enabled: true}, + } + type group struct { + Name string `json:"name"` + ExpectedWinPercent float64 `json:"expected_win_percent"` + ObservedWinPercent float64 `json:"observed_win_percent"` + AbsoluteErrorPoints float64 `json:"absolute_error_percentage_points"` + PositiveDraws int `json:"positive_draws"` + FinalPoolBalanceCoins int64 `json:"final_pool_balance_coins"` + } + run := func(groupName string, initial domain.StrategyRTP, expected float64, offset int64) (group, error) { + state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000, User24HourRTP: initial} + random := domain.NewSeededStrategyRandom(seed + offset) + positive := 0 + for index := 0; index < draws; index++ { + decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100}, random) + if err != nil { + return group{}, err + } + state = decision.NextState + if decision.PayoutCoins > 0 { + positive++ + } + } + observed := float64(positive) / draws * 100 + return group{ + Name: groupName, ExpectedWinPercent: expected, ObservedWinPercent: observed, + AbsoluteErrorPoints: math.Abs(observed - expected), PositiveDraws: positive, + FinalPoolBalanceCoins: state.PoolBalanceCoins, + }, nil + } + // Very large historical denominators keep each group on its intended side of 150% while the + // million current draws still advance the exact StrategyState on every iteration. + normal, err := run("normal_100_percent_rtp", domain.StrategyRTP{WagerCoins: 1_000_000_000_000_000, PayoutCoins: 1_000_000_000_000_000}, 40, 0) + if err != nil { + return failedScenario(name, err) + } + downweighted, err := run("above_150_percent_rtp", domain.StrategyRTP{WagerCoins: 1_000_000_000_000_000, PayoutCoins: 1_500_010_000_000_000}, 28, 1) + if err != nil { + return failedScenario(name, err) + } + passed := normal.AbsoluteErrorPoints <= 0.2 && downweighted.AbsoluteErrorPoints <= 0.2 && + normal.FinalPoolBalanceCoins >= 0 && downweighted.FinalPoolBalanceCoins >= 0 + return scenarioResult{ + Name: name, Passed: passed, + Summary: fmt.Sprintf("固定种子各%d抽:正常 %.4f%%(目标40%%),超线 %.4f%%(目标28%%),误差均<=0.2个百分点", draws, normal.ObservedWinPercent, downweighted.ObservedWinPercent), + Data: []group{normal, downweighted}, + } +} + +func strategyConditionTrace(trace domain.DecisionTrace, name string) domain.StrategyConditionTrace { + for _, condition := range trace.Conditions { + if condition.Name == name { + return condition + } + } + return domain.StrategyConditionTrace{} +} + func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult { const name = "充值0/299999/300000ms" config.JackpotMechanism1Enabled = false @@ -354,108 +611,154 @@ func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) dom return domain.StrategyWeightTrace{} } -type mechanismOneRow struct { - GlobalPass bool `json:"global_pass"` - UserDayPass bool `json:"user_day_pass"` - User72HourPass bool `json:"user_72h_pass"` - Triggered bool `json:"triggered"` - SelectedTier string `json:"selected_tier"` +type settledRoundQualificationRow struct { + Case string `json:"case"` + RegisteredAgeHours int64 `json:"registered_age_hours"` + RoundWagerCoins int64 `json:"round_wager_coins"` + RoundPayoutCoins int64 `json:"round_payout_coins"` + RollingWagerCoins int64 `json:"rolling_48h_wager_coins"` + RollingPayoutCoins int64 `json:"rolling_48h_payout_coins"` + GlobalWagerCoins int64 `json:"global_wager_coins"` + GlobalPayoutCoins int64 `json:"global_payout_coins"` + Wanted bool `json:"wanted"` + Triggered bool `json:"triggered"` + SelectedTier string `json:"selected_tier"` } -func simulateMechanismOneTruthTable(config domain.StrategyConfig, seed int64) scenarioResult { - const name = "大奖机制1真值表" - config = baseProbabilityConfig(config) - config.JackpotMechanism1Enabled = true - config.DailyJackpotLimit = 5 - rows := make([]mechanismOneRow, 0, 8) +func simulateSettledRoundQualification(config domain.StrategyConfig, seed int64) scenarioResult { + const name = "已结算轮次+注册48小时大奖资格" + config = settledRoundJackpotConfig(config) + const ( + hourMS = int64(60 * 60 * 1000) + closedAtMS = int64(100 * 60 * 60 * 1000) + ) + base := qualifiedSettledRoundState(20_000, closedAtMS) + cases := []struct { + name string + state domain.StrategyState + want bool + }{ + {name: "注册47小时,即使两个用户RTP都是95%也失败", state: withRegisteredAge(base, 47*hourMS), want: false}, + {name: "注册恰满48小时,两个用户RTP都是95%通过", state: withRegisteredAge(base, 48*hourMS), want: true}, + {name: "注册已满48小时且只在最后5分钟玩,48小时总RTP就是这5分钟的95%", state: withLastFiveMinuteHistory(withRegisteredAge(base, 72*hourMS)), want: true}, + {name: "已结算用户轮次RTP恰好96%失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_600), want: false}, + {name: "滚动48小时总RTP恰好96%失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_600), want: false}, + {name: "已结算大盘RTP恰好98%仍通过", state: withRegisteredAge(base, 48*hourMS), want: true}, + {name: "已结算大盘RTP超过98%失败", state: withGlobalRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_801), want: false}, + {name: "仅用户轮次门失败,48小时门通过,仍失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_700), want: false}, + {name: "仅48小时门失败,用户轮次门通过,仍失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_700), want: false}, + {name: "用户轮次没有消费样本失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 0, 0), want: false}, + {name: "滚动48小时没有消费样本失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 0, 0), want: false}, + } + rows := make([]settledRoundQualificationRow, 0, len(cases)) passed := true - rowIndex := int64(0) - for mask := 0; mask < 8; mask++ { - globalPass := mask&1 != 0 - dayPass := mask&2 != 0 - hour72Pass := mask&4 != 0 - state := domain.StrategyState{ - PoolBalanceCoins: 20_000, - GlobalRTP: rtpForTruth(globalPass, 98), - UserDayRTP: rtpForTruth(dayPass, 96), - User72HourRTP: rtpForTruth(hour72Pass, 96), - } - decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+rowIndex)) + for index, item := range cases { + decision, err := domain.DecideLuckyGiftStrategy(config, item.state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index))) if err != nil { return failedScenario(name, err) } triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation - want := globalPass && dayPass && hour72Pass - passed = passed && triggered == want - rows = append(rows, mechanismOneRow{GlobalPass: globalPass, UserDayPass: dayPass, User72HourPass: hour72Pass, Triggered: triggered, SelectedTier: decision.SelectedTier.ID}) - rowIndex++ + passed = passed && triggered == item.want + rows = append(rows, settledRoundQualificationRow{ + Case: item.name, RegisteredAgeHours: (item.state.QualificationClosedAtMS - item.state.UserRegisteredAtMS) / hourMS, + RoundWagerCoins: item.state.UserRoundRTP.WagerCoins, RoundPayoutCoins: item.state.UserRoundRTP.PayoutCoins, + RollingWagerCoins: item.state.User48HourRTP.WagerCoins, RollingPayoutCoins: item.state.User48HourRTP.PayoutCoins, + GlobalWagerCoins: item.state.GlobalRTP.WagerCoins, GlobalPayoutCoins: item.state.GlobalRTP.PayoutCoins, + Wanted: item.want, Triggered: triggered, SelectedTier: decision.SelectedTier.ID, + }) } - // The denominator-zero row is separate from boolean “ratio above threshold” so - // the report proves an empty window never qualifies as an apparent 0% RTP. - zeroState := domain.StrategyState{ - PoolBalanceCoins: 20_000, GlobalRTP: domain.StrategyRTP{}, - UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96}, - User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96}, - } - zeroDecision, err := domain.DecideLuckyGiftStrategy(config, zeroState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+99)) - if err != nil { - return failedScenario(name, err) - } - zeroBlocked := zeroDecision.JackpotMechanism != domain.StrategyJackpotMechanismRTPCompensation - passed = passed && zeroBlocked return scenarioResult{ Name: name, Passed: passed, - Summary: "global<=98%、用户日<=96%、用户72h<=96%三项全真才触发;任一分母为0明确阻断", - Data: map[string]any{"truth_table": rows, "denominator_zero_blocked": zeroBlocked, "denominator_zero_conditions": zeroDecision.Trace.Conditions}, + Summary: "注册<48h失败;恰48h及只玩最后5分钟按实际总流水判断;用户轮次/48h严格<96%,大盘允许=98%,超限或零样本失败", + Data: rows, } } -func rtpForTruth(pass bool, limitPercent int64) domain.StrategyRTP { - payout := limitPercent - if !pass { - payout++ - } - return domain.StrategyRTP{WagerCoins: 100, PayoutCoins: payout} -} - -func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResult { - const name = "机制2里程碑/池不足" +func settledRoundJackpotConfig(config domain.StrategyConfig) domain.StrategyConfig { config = baseProbabilityConfig(config) - config.JackpotMechanism2Enabled = true - // 模拟只用一个便于验证跨档的金币值;生产值必须来自当前不可变规则版本。 - config.MilestoneSpendCoins = 50 + config.JackpotMechanism1Enabled = true + config.JackpotMechanism2Enabled = false + config.MilestoneSpendCoins = 0 + config.SettledRoundRTPEligibility = true + config.GlobalRTPMaxPPM = 980_000 + config.UserRoundRTPMaxPPM = 960_000 + config.User48HourRTPMaxPPM = 960_000 config.DailyJackpotLimit = 5 - input := domain.StrategyInput{GiftPriceCoins: 10} - blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}, input, domain.NewSeededStrategyRandom(seed)) + return config +} + +func qualifiedSettledRoundState(pool, closedAtMS int64) domain.StrategyState { + return domain.StrategyState{ + PoolBalanceCoins: pool, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS, + UserRegisteredAtMS: closedAtMS - 48*60*60*1000, + GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800}, + UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500}, + User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500}, + } +} + +func withRegisteredAge(state domain.StrategyState, ageMS int64) domain.StrategyState { + state.UserRegisteredAtMS = state.QualificationClosedAtMS - ageMS + return state +} + +func withLastFiveMinuteHistory(state domain.StrategyState) domain.StrategyState { + // 前47小时55分钟没有送礼不会制造“空白样本”;滚动48小时的总分子/分母就是最后5分钟实际发生的95/100。 + state.UserRoundRTP = domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 95} + state.User48HourRTP = domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 95} + return state +} + +func withUserRoundRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState { + state.UserRoundRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout} + return state +} + +func withUser48HourRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState { + state.User48HourRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout} + return state +} + +func withGlobalRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState { + state.GlobalRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout} + return state +} + +func simulatePoolBlockedQualificationConsumption(config domain.StrategyConfig, seed int64) scenarioResult { + const name = "池不足仍消费已结算轮次资格" + config = settledRoundJackpotConfig(config) + // 普通档固定为0,隔离出“大奖不可支付后只消费一次资格”的状态变化。 + config.Tiers = []domain.StrategyTier{ + {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true}, + {ID: "200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true}, + {ID: "500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true}, + {ID: "1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true}, + } + state := qualifiedSettledRoundState(49, 100*60*60*1000) + first, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed)) if err != nil { return failedScenario(name, err) } - payableState := blocked.NextState - payableState.PoolBalanceCoins = 20_000 - paid, err := domain.DecideLuckyGiftStrategy(config, payableState, input, domain.NewSeededStrategyRandom(seed+1)) + afterTopUp := first.NextState + afterTopUp.PoolBalanceCoins = 20_000 + second, err := domain.DecideLuckyGiftStrategy(config, afterTopUp, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1)) if err != nil { return failedScenario(name, err) } - crossed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}, input, domain.NewSeededStrategyRandom(seed+2)) - if err != nil { - return failedScenario(name, err) - } - passed := blocked.NextState.PendingMilestoneTokens == 1 && blocked.Trace.MilestoneTokenRetained && paid.ConsumedMilestoneToken && paid.NextState.PendingMilestoneTokens == 0 && crossed.NextState.PendingMilestoneTokens == 1 && !crossed.ConsumedMilestoneToken + passed := !first.Jackpot && !first.NextState.SettledRoundPending && !second.Jackpot && second.JackpotMechanism == "" return scenarioResult{ Name: name, Passed: passed, - Summary: fmt.Sprintf("49池余额时资格保留=%v;补池后命中%s并消费;40→50本抽新增token=%d供下一抽", blocked.Trace.MilestoneTokenRetained, paid.SelectedTier.ID, crossed.Trace.MilestoneTokensEarned), - Data: map[string]any{"milestone_crossings_49_to_101": domain.MilestoneTokensEarned(49, 101, 50), "blocked": blocked, "paid": paid, "earned_for_next_draw": crossed}, + Summary: fmt.Sprintf("首抽池=%d不足支付200x,资格尝试后pending=%v;补池到20000再抽也不会复用旧资格", state.PoolBalanceCoins, first.NextState.SettledRoundPending), + Data: map[string]any{"pool_blocked_attempt": first, "after_top_up_without_new_closed_round": second}, } } func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult { const name = "大奖集合" - config = baseProbabilityConfig(config) - config.JackpotMechanism2Enabled = true - config.DailyJackpotLimit = 5 + config = settledRoundJackpotConfig(config) all := map[string]int{} for index := int64(0); index < 120; index++ { - decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 10_000, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index)) + decision, err := domain.DecideLuckyGiftStrategy(config, qualifiedSettledRoundState(10_000, 100*60*60*1000), domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index)) if err != nil { return failedScenario(name, err) } @@ -463,16 +766,16 @@ func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult } limited := map[string]int{} for index := int64(0); index < 40; index++ { - decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 4_999, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index)) + decision, err := domain.DecideLuckyGiftStrategy(config, qualifiedSettledRoundState(4_999, 100*60*60*1000), domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index)) if err != nil { return failedScenario(name, err) } limited[decision.SelectedTier.ID]++ } - _, has200 := all["200x"] - _, has500 := all["500x"] - _, has1000 := all["1000x"] - passed := has200 && has500 && has1000 && len(limited) == 1 && limited["200x"] == 40 + _, has200 := all["jackpot_200x"] + _, has500 := all["jackpot_500x"] + _, has1000 := all["jackpot_1000x"] + passed := has200 && has500 && has1000 && len(limited) == 1 && limited["jackpot_200x"] == 40 return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%v;P=4999时只可能200x=%v", has200 && has500 && has1000, limited), @@ -482,12 +785,12 @@ func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult { const name = "日5次上限" - config = baseProbabilityConfig(config) - config.JackpotMechanism2Enabled = true - config.DailyJackpotLimit = 5 - state := domain.StrategyState{PoolBalanceCoins: 1_000_000} + config = settledRoundJackpotConfig(config) + state := qualifiedSettledRoundState(1_000_000, 100*60*60*1000) for index := 0; index < 5; index++ { - state.PendingMilestoneTokens = 1 + // 每次循环代表又结算了一个新的大盘轮次;同一轮资格在完成一次尝试后不会自动续期。 + state.SettledRoundPending = true + state.QualificationClosedAtMS += 60 * 60 * 1000 decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index))) if err != nil { return failedScenario(name, err) @@ -497,21 +800,323 @@ func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenari return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d次未按资格出大奖", index+1)} } } - state.PendingMilestoneTokens = 1 - // Force ordinary 200x after the special path is blocked, then force 0x on the - // retry. This proves the daily ceiling also constrains accidental base jackpots. - sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000, 0}, bounds: []int64{1_000_000, 960_000}}) + state.SettledRoundPending = true + state.QualificationClosedAtMS += 60 * 60 * 1000 + // 大奖路径达到上限后仍回落普通随机;同倍率 ordinary 200x 不属于大奖,不能被 cap 删除。 + sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}}) if err != nil { return failedScenario(name, err) } - passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && sixth.NextState.PendingMilestoneTokens == 1 + passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.SelectedTier.ID == "200x" && sixth.NextState.UserDailyJackpotWins == 5 && !sixth.NextState.SettledRoundPending return scenarioResult{ Name: name, Passed: passed, - Summary: fmt.Sprintf("前5次大奖计数=%d;第6次特殊与基础大奖均被硬阻断且资格保留", state.UserDailyJackpotWins), + Summary: fmt.Sprintf("5个独立补偿大奖后计数=%d;第6个轮次补偿受限但普通200x仍可命中且不增加大奖次数", state.UserDailyJackpotWins), Data: map[string]any{"sixth": sixth}, } } +type laluLuckyV5TierFixture struct { + id string + multiplier int64 + weight int64 + high bool +} + +// laluLuckyV5RuleFixture freezes the disabled test rule lalu/lucky/v5 that exposed the +// overlap bug. Values are intentionally explicit rather than generated from a curve: +// a later test-rule edit must produce a visible fixture diff before it can change this +// regression report. The simulator never publishes or enables this snapshot. +func laluLuckyV5RuleFixture() domain.RuleConfig { + stage := func(name string, min7D, min30D int64, rows []laluLuckyV5TierFixture) domain.RuleStage { + out := domain.RuleStage{Stage: name, MinRecharge7DCoins: min7D, MinRecharge30DCoins: min30D, Tiers: make([]domain.RuleTier, 0, len(rows))} + for _, row := range rows { + out.Tiers = append(out.Tiers, domain.RuleTier{ + Stage: name, TierID: name + "_" + row.id, MultiplierPPM: row.multiplier, + BaseWeightPPM: row.weight, HighWaterOnly: row.high, Enabled: true, + }) + } + return out + } + return domain.RuleConfig{ + AppCode: "lalu", PoolID: "lucky", RuleVersion: 5, StrategyVersion: domain.StrategyDynamicV3, Enabled: false, + TargetRTPPPM: 890_000, PoolRatePPM: 890_000, ProfitRatePPM: 10_000, AnchorRatePPM: 100_000, + SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 100, + NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, EffectiveFromMS: 1_780_032_226_505, + InitialPoolCoins: 0, LossStreakGuarantee: 4, + LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000, + HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPPM: 1_300_000, + RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000, + JackpotMultiplierPPMs: []int64{100_000_000, 200_000_000, 500_000_000, 1_000_000_000}, + JackpotGlobalRTPMaxPPM: 980_000, JackpotUserRoundRTPMaxPPM: 950_000, JackpotUser48hRTPMaxPPM: 950_000, + SettledRoundRTPEligibility: true, JackpotSpendThresholdCoins: 800_000, MaxJackpotHitsPerUserDay: 10, + MaxSinglePayout: 4_000_000_000, UserHourlyPayoutCap: 4_000_000_000, UserDailyPayoutCap: 4_000_000_000, + DeviceDailyPayoutCap: 4_000_000_000, RoomHourlyPayoutCap: 4_000_000_000, AnchorDailyPayoutCap: 4_000_000_000, + Stages: []domain.RuleStage{ + stage(domain.StageNovice, 0, 0, []laluLuckyV5TierFixture{ + {id: "none", multiplier: 0, weight: 508_000}, + {id: "0_5x", multiplier: 500_000, weight: 254_261}, + {id: "1x", multiplier: 1_000_000, weight: 127_131}, + {id: "2x", multiplier: 2_000_000, weight: 63_567}, + {id: "5x", multiplier: 5_000_000, weight: 25_427}, + {id: "10x", multiplier: 10_000_000, weight: 12_713, high: true}, + {id: "20x", multiplier: 20_000_000, weight: 6_357, high: true}, + {id: "50x", multiplier: 50_000_000, weight: 2_544, high: true}, + }), + stage(domain.StageNormal, 8, 100, []laluLuckyV5TierFixture{ + {id: "none", multiplier: 0, weight: 530_785}, + {id: "0_5x", multiplier: 500_000, weight: 161_748}, + {id: "0_8x", multiplier: 800_000, weight: 101_094}, + {id: "1x", multiplier: 1_000_000, weight: 80_875}, + {id: "1_5x", multiplier: 1_500_000, weight: 53_917}, + {id: "2x", multiplier: 2_000_000, weight: 40_438, high: true}, + {id: "5x", multiplier: 5_000_000, weight: 16_175, high: true}, + {id: "10x", multiplier: 10_000_000, weight: 8_088, high: true}, + {id: "20x", multiplier: 20_000_000, weight: 4_046, high: true}, + {id: "50x", multiplier: 50_000_000, weight: 1_619, high: true}, + {id: "100x", multiplier: 100_000_000, weight: 810, high: true}, + {id: "200x", multiplier: 200_000_000, weight: 405, high: true}, + }), + stage(domain.StageAdvanced, 10, 200, []laluLuckyV5TierFixture{ + {id: "none", multiplier: 0, weight: 658_929}, + {id: "0_5x", multiplier: 500_000, weight: 126_588}, + {id: "1x", multiplier: 1_000_000, weight: 63_295}, + {id: "1_2x", multiplier: 1_200_000, weight: 52_746}, + {id: "1_5x", multiplier: 1_500_000, weight: 42_197}, + {id: "2x", multiplier: 2_000_000, weight: 31_648}, + {id: "5x", multiplier: 5_000_000, weight: 12_659, high: true}, + {id: "10x", multiplier: 10_000_000, weight: 6_331, high: true}, + {id: "20x", multiplier: 20_000_000, weight: 3_166, high: true}, + {id: "50x", multiplier: 50_000_000, weight: 1_266, high: true}, + {id: "100x", multiplier: 100_000_000, weight: 634, high: true}, + {id: "200x", multiplier: 200_000_000, weight: 317, high: true}, + {id: "500x", multiplier: 500_000_000, weight: 127, high: true}, + {id: "1000x", multiplier: 1_000_000_000, weight: 64, high: true}, + {id: "2000x", multiplier: 2_000_000_000, weight: 33, high: true}, + }), + }, + } +} + +func v5FixtureInput(config domain.Config, stage string, risk domain.StrategyRiskCapacity) domain.StrategyInput { + input := domain.StrategyInput{GiftPriceCoins: config.GiftPrice, PoolContributionCoins: 89, RiskCapacity: risk} + switch stage { + case domain.StageNormal: + input.Recharge7DCoins, input.Recharge30DCoins = 8, 100 + case domain.StageAdvanced: + input.Recharge7DCoins, input.Recharge30DCoins = 10, 200 + } + return input +} + +func v5FixtureRiskCapacity(limit int64) domain.StrategyRiskCapacity { + return domain.StrategyRiskCapacity{ + Enabled: true, SingleDrawCoins: limit, UserHourCoins: limit, UserDayCoins: limit, + DeviceDayCoins: limit, RoomHourCoins: limit, AnchorDayCoins: limit, + } +} + +func qualifiedV5FixtureState(pool int64) domain.StrategyState { + const closedAtMS = int64(100 * 60 * 60 * 1000) + return domain.StrategyState{ + PoolBalanceCoins: pool, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS, + UserRegisteredAtMS: closedAtMS - 49*60*60*1000, + GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 8_900}, + UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, + User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, + } +} + +func strategyOrdinaryDrawPoint(config domain.StrategyConfig, state domain.StrategyState, input domain.StrategyInput, tierID string) (int64, int64, int64, error) { + _, weights, err := domain.PreviewLuckyGiftStrategyWeights(config, state, input) + if err != nil { + return 0, 0, 0, err + } + var index, bound int64 + tierWeight := int64(0) + for _, item := range weights { + if item.TierID == tierID { + tierWeight = item.AdjustedWeightPPM + index = bound + } + bound += item.AdjustedWeightPPM + } + if tierWeight <= 0 || bound <= 0 { + return 0, 0, 0, fmt.Errorf("ordinary tier %s has no selectable weight", tierID) + } + return index, bound, tierWeight, nil +} + +func fixtureStageExpectedRTP(stage domain.RuleStage) int64 { + var expected int64 + for _, tier := range stage.Tiers { + if tier.Enabled { + expected += tier.MultiplierPPM * tier.BaseWeightPPM / domain.StrategyPPMScale + } + } + return expected +} + +func removalCount(trace domain.DecisionTrace, reason string) int { + count := 0 + for _, removed := range trace.Removed { + if removed.Reason == reason { + count++ + } + } + return count +} + +func simulateDualSourceOverlap(_ domain.StrategyConfig) scenarioResult { + const name = "test lalu/lucky/v5 同倍率双来源" + rule := laluLuckyV5RuleFixture() + runtimeConfig, config, err := mysqlstorage.BuildLuckyDynamicStrategyForRule(rule) + if err != nil { + return failedScenario(name, err) + } + passed := runtimeConfig.TargetRTPPPM == 890_000 && config.DailyJackpotLimit == 10 + stageRTP := make(map[string]int64, len(rule.Stages)) + for _, stage := range rule.Stages { + stageRTP[stage.Stage] = fixtureStageExpectedRTP(stage) + passed = passed && stageRTP[stage.Stage] == rule.TargetRTPPPM + } + + const poolBefore = int64(15_000_000) // 真实低/高水位之间,基础权重不被水位因子改变。 + fullRisk := v5FixtureRiskCapacity(4_000_000_000) + type overlapCase struct { + label string + stage string + multiplier int64 + } + overlaps := []overlapCase{ + {label: "100x", stage: domain.StageNormal, multiplier: 100_000_000}, + {label: "200x", stage: domain.StageNormal, multiplier: 200_000_000}, + {label: "500x", stage: domain.StageAdvanced, multiplier: 500_000_000}, + {label: "1000x", stage: domain.StageAdvanced, multiplier: 1_000_000_000}, + } + ordinaryEvidence := make(map[string]any, len(overlaps)) + jackpotEvidence := make(map[string]any, len(overlaps)) + for jackpotIndex, overlap := range overlaps { + ordinaryID := fmt.Sprintf("multiplier_%d", overlap.multiplier) + ordinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore} + ordinaryInput := v5FixtureInput(runtimeConfig, overlap.stage, fullRisk) + ordinaryIndex, ordinaryBound, ordinaryWeight, pointErr := strategyOrdinaryDrawPoint(config, ordinaryState, ordinaryInput, ordinaryID) + if pointErr != nil { + return failedScenario(name, pointErr) + } + ordinary, drawErr := domain.DecideLuckyGiftStrategy(config, ordinaryState, ordinaryInput, &scriptedRandom{indexes: []int64{ordinaryIndex}, bounds: []int64{ordinaryBound}}) + if drawErr != nil { + return failedScenario(name, drawErr) + } + ordinaryPayout := runtimeConfig.GiftPrice * overlap.multiplier / domain.StrategyPPMScale + ordinaryOK := ordinary.SelectedTier.ID == ordinaryID && !ordinary.Jackpot && ordinary.NextState.UserDailyJackpotWins == 0 && + ordinary.PayoutCoins == ordinaryPayout && ordinary.PoolAfterCoins == poolBefore+ordinaryInput.PoolContributionCoins-ordinaryPayout && len(ordinary.Trace.Draws) == 1 + passed = passed && ordinaryOK + ordinaryEvidence[overlap.label] = map[string]any{"tier_id": ordinary.SelectedTier.ID, "stage": ordinary.Stage, "weight": ordinaryWeight, "payout": ordinary.PayoutCoins, "pool_after": ordinary.PoolAfterCoins, "jackpot_hits": ordinary.NextState.UserDailyJackpotWins} + + jackpotState := qualifiedV5FixtureState(poolBefore) + jackpotInput := v5FixtureInput(runtimeConfig, domain.StageAdvanced, fullRisk) + jackpot, drawErr := domain.DecideLuckyGiftStrategy(config, jackpotState, jackpotInput, &scriptedRandom{indexes: []int64{int64(jackpotIndex)}, bounds: []int64{int64(len(overlaps))}}) + if drawErr != nil { + return failedScenario(name, drawErr) + } + jackpotID := fmt.Sprintf("jackpot_%d", overlap.multiplier) + jackpotOK := jackpot.SelectedTier.ID == jackpotID && jackpot.Jackpot && jackpot.NextState.UserDailyJackpotWins == 1 && + jackpot.PayoutCoins == ordinaryPayout && jackpot.PoolAfterCoins == poolBefore+jackpotInput.PoolContributionCoins-ordinaryPayout && + len(jackpot.Trace.Draws) == 1 && jackpot.Trace.Draws[0].Kind == domain.StrategyDrawKindRTPCompensation + passed = passed && jackpotOK + jackpotEvidence[overlap.label] = map[string]any{"tier_id": jackpot.SelectedTier.ID, "weight": jackpot.SelectedTier.JackpotWeight, "payout": jackpot.PayoutCoins, "pool_after": jackpot.PoolAfterCoins, "jackpot_hits": jackpot.NextState.UserDailyJackpotWins, "draw_count": len(jackpot.Trace.Draws)} + } + + // 真实日 cap=10 时补偿路径被挡住,但同倍率普通 1000x 仍按 advanced 的 64ppm 命中; + // scriptedRandom 只提供一次随机数,若实现错误地“先发大奖再发普通奖”会立刻耗尽并失败。 + capState := qualifiedV5FixtureState(poolBefore) + capState.UserDailyJackpotWins = rule.MaxJackpotHitsPerUserDay + capInput := v5FixtureInput(runtimeConfig, domain.StageAdvanced, fullRisk) + capIndex, capBound, capWeight, err := strategyOrdinaryDrawPoint(config, capState, capInput, "multiplier_1000000000") + if err != nil { + return failedScenario(name, err) + } + afterCap, err := domain.DecideLuckyGiftStrategy(config, capState, capInput, &scriptedRandom{indexes: []int64{capIndex}, bounds: []int64{capBound}}) + if err != nil { + return failedScenario(name, err) + } + passed = passed && afterCap.SelectedTier.ID == "multiplier_1000000000" && !afterCap.Jackpot && len(afterCap.Trace.Draws) == 1 && + afterCap.NextState.UserDailyJackpotWins == rule.MaxJackpotHitsPerUserDay && !afterCap.NextState.SettledRoundPending + + // 奖池不足会一次性消费本轮补偿资格并回落普通 1x;普通路径仍使用本抽 89 金币入池后的 P/W。 + fallbackState := qualifiedV5FixtureState(100) + fallbackInput := v5FixtureInput(runtimeConfig, domain.StageNormal, fullRisk) + fallbackIndex, fallbackBound, _, err := strategyOrdinaryDrawPoint(config, fallbackState, fallbackInput, "multiplier_1000000") + if err != nil { + return failedScenario(name, err) + } + fallback, err := domain.DecideLuckyGiftStrategy(config, fallbackState, fallbackInput, &scriptedRandom{indexes: []int64{fallbackIndex}, bounds: []int64{fallbackBound}}) + if err != nil { + return failedScenario(name, err) + } + passed = passed && fallback.SelectedTier.ID == "multiplier_1000000" && !fallback.Jackpot && len(fallback.Trace.Draws) == 1 && + removalCount(fallback.Trace, domain.StrategyReasonPoolInsufficient) == 4 && !fallback.NextState.SettledRoundPending && fallback.PoolAfterCoins == 89 + + // 六个维度逐个压到 9,999:所有补偿候选最小为 100x=10,000,必须被同一个硬门挡住; + // 同时强抽普通 100x,证明普通奖也不能绕开该门,只是不会读取大奖日 cap。 + type riskDimension struct { + name string + set func(*domain.StrategyRiskCapacity, int64) + } + riskDimensions := []riskDimension{ + {name: "single", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }}, + {name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }}, + {name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }}, + {name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }}, + {name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }}, + {name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }}, + } + riskEvidence := make(map[string]bool, len(riskDimensions)) + for _, dimension := range riskDimensions { + risk := fullRisk + dimension.set(&risk, 9_999) + blockedSpecialState := qualifiedV5FixtureState(poolBefore) + blockedSpecialInput := v5FixtureInput(runtimeConfig, domain.StageNormal, risk) + oneXIndex, oneXBound, _, pointErr := strategyOrdinaryDrawPoint(config, blockedSpecialState, blockedSpecialInput, "multiplier_1000000") + if pointErr != nil { + return failedScenario(name, pointErr) + } + blockedSpecial, drawErr := domain.DecideLuckyGiftStrategy(config, blockedSpecialState, blockedSpecialInput, &scriptedRandom{indexes: []int64{oneXIndex}, bounds: []int64{oneXBound}}) + if drawErr != nil { + return failedScenario(name, drawErr) + } + + blockedOrdinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore} + blockedOrdinaryInput := v5FixtureInput(runtimeConfig, domain.StageNormal, risk) + highIndex, highBound, highWeight, pointErr := strategyOrdinaryDrawPoint(config, blockedOrdinaryState, blockedOrdinaryInput, "multiplier_100000000") + if pointErr != nil { + return failedScenario(name, pointErr) + } + blockedOrdinary, drawErr := domain.DecideLuckyGiftStrategy(config, blockedOrdinaryState, blockedOrdinaryInput, &scriptedRandom{indexes: []int64{highIndex, 0}, bounds: []int64{highBound, highBound - highWeight}}) + if drawErr != nil { + return failedScenario(name, drawErr) + } + dimensionOK := blockedSpecial.SelectedTier.ID == "multiplier_1000000" && !blockedSpecial.Jackpot && + removalCount(blockedSpecial.Trace, domain.StrategyReasonRiskCapacity) == 4 && + blockedOrdinary.SelectedTier.ID == "multiplier_0" && len(blockedOrdinary.Trace.Draws) == 2 && + blockedOrdinary.Trace.Draws[0].TierID == "multiplier_100000000" && blockedOrdinary.Trace.Draws[0].RemovedReason == domain.StrategyReasonRiskCapacity + riskEvidence[dimension.name] = dimensionOK + passed = passed && dimensionOK + } + + return scenarioResult{ + Name: name, Passed: passed, + Summary: "真实v5三阶段RTP=89%;100/200/500/1000x普通与补偿独立,补偿优先且单抽单发,阻断/cap后回落普通,池与六维门共用", + Data: map[string]any{ + "fixture": map[string]any{"app": rule.AppCode, "pool": rule.PoolID, "version": rule.RuleVersion, "enabled": rule.Enabled, "target_rtp_ppm": rule.TargetRTPPPM, "stage_expected_rtp_ppm": stageRTP, "daily_jackpot_cap": rule.MaxJackpotHitsPerUserDay}, + "ordinary_by_multiplier": ordinaryEvidence, "compensation_by_multiplier": jackpotEvidence, + "ordinary_after_cap": map[string]any{"tier_id": afterCap.SelectedTier.ID, "weight": capWeight, "jackpot_hits": afterCap.NextState.UserDailyJackpotWins, "draw_count": len(afterCap.Trace.Draws)}, + "pool_blocked_fallback": map[string]any{"tier_id": fallback.SelectedTier.ID, "pool_after": fallback.PoolAfterCoins, "removed_jackpots": removalCount(fallback.Trace, domain.StrategyReasonPoolInsufficient)}, + "six_risk_dimensions": riskEvidence, + }, + } +} + func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult { const name = "六维risk capacity代表边界" config = baseProbabilityConfig(config) @@ -579,22 +1184,18 @@ func simulateRechargeStages(config domain.StrategyConfig) scenarioResult { func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult { const name = "组合优先级" - config = baseProbabilityConfig(config) + config = settledRoundJackpotConfig(config) config.LowWaterThresholdCoins = 20_000 config.HighWaterThresholdCoins = 30_000 config.LowWaterFactorPPM = 700_000 config.HighWaterFactorPPM = 1_300_000 config.RechargeFactorPPM = 1_100_000 - config.JackpotMechanism1Enabled = true + state := qualifiedSettledRoundState(10_000, 100*60*60*1000) config.JackpotMechanism2Enabled = true - config.DailyJackpotLimit = 5 config.MilestoneSpendCoins = 50 - state := domain.StrategyState{ - PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1, - GlobalRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, - UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96}, - User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96}, - } + state.PendingSpendJackpotTokens = 1 + state.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800} + state.ConsecutiveZeroDraws = 5 input := domain.StrategyInput{ GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true, LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000, @@ -603,9 +1204,8 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena if err != nil { return failedScenario(name, err) } - // When both jackpot mechanisms are eligible, the persisted token is contractual - // state and therefore wins before RTP compensation or sixth-draw protection. - priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && priority.ConsumedMilestoneToken && priority.Stage == domain.StageAdvanced + // 两套资格同时存在时,48h 补偿先支付,消费 token 保留到下一个子抽。 + priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.NextState.PendingSpendJackpotTokens == 1 && priority.Stage == domain.StageAdvanced blockedState := state blockedState.PoolBalanceCoins = 50 @@ -613,16 +1213,118 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena if err != nil { return failedScenario(name, err) } - // Neither special mechanism may overdraw the pool. The token survives and the - // ordinary sixth-draw protection may pay only the affordable 5x boundary. - blockedPass := blocked.Trace.MilestoneTokenRetained && blocked.NextState.PendingMilestoneTokens == 1 && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0 + // 大奖资格不能透支奖池;48h 资格本轮结束,消费 token 则等待后续补水。 + blockedPass := !blocked.NextState.SettledRoundPending && blocked.NextState.PendingSpendJackpotTokens == 1 && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0 return scenarioResult{ Name: name, Passed: priorityPass && blockedPass, - Summary: fmt.Sprintf("优先级=持久token>%s>普通P/W/第6抽;特殊档池不足时资格保留并仅赔可支付%s", domain.StrategyJackpotMechanismRTPCompensation, blocked.SelectedTier.ID), + Summary: fmt.Sprintf("优先级=48h补偿>消费达标>普通P/W;48h成功不扣消费token,池不足时消费token保留,本抽仅赔%s", blocked.SelectedTier.ID), Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked}, } } +func simulateDualJackpotSpendStrategy(config domain.StrategyConfig, seed int64) scenarioResult { + const name = "双大奖消费资格矩阵" + config = settledRoundJackpotConfig(config) + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + input := domain.StrategyInput{GiftPriceCoins: 10} + base := domain.StrategyState{ + PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, + SpendGlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800}, + } + type row struct { + Case string `json:"case"` + GlobalWager int64 `json:"global_wager"` + GlobalPayout int64 `json:"global_payout"` + TokensBefore int64 `json:"tokens_before"` + TokensAfter int64 `json:"tokens_after"` + Mechanism string `json:"mechanism"` + Jackpot bool `json:"jackpot"` + Payout int64 `json:"payout"` + PoolAfter int64 `json:"pool_after"` + Reason string `json:"reason"` + } + rows := make([]row, 0, 8) + run := func(caseName string, state domain.StrategyState, drawInput domain.StrategyInput, offset int64) (domain.StrategyDecision, error) { + decision, err := domain.DecideLuckyGiftStrategy(config, state, drawInput, domain.NewSeededStrategyRandom(seed+offset)) + if err == nil { + rows = append(rows, row{ + Case: caseName, GlobalWager: state.SpendGlobalRTP.WagerCoins, GlobalPayout: state.SpendGlobalRTP.PayoutCoins, + TokensBefore: state.PendingSpendJackpotTokens, TokensAfter: decision.NextState.PendingSpendJackpotTokens, + Mechanism: decision.JackpotMechanism, Jackpot: decision.Jackpot, Payout: decision.PayoutCoins, + PoolAfter: decision.PoolAfterCoins, Reason: decision.Trace.BlockedReason, + }) + } + return decision, err + } + + equal, err := run("大盘等于98%", base, input, 0) + if err != nil { + return failedScenario(name, err) + } + aboveState := base + aboveState.SpendGlobalRTP.PayoutCoins = 9_801 + above, err := run("大盘高于98%", aboveState, input, 1) + if err != nil { + return failedScenario(name, err) + } + noSampleState := base + noSampleState.SpendGlobalRTP = domain.StrategyRTP{} + noSample, err := run("无已结算大盘", noSampleState, input, 2) + if err != nil { + return failedScenario(name, err) + } + + // 40->150 crosses 50/100/150 in one paid child draw. A scripted zero proves those three new + // tokens cannot influence the current selection; the following child draw consumes only one. + crossState := base + crossState.PendingSpendJackpotTokens = 0 + crossState.UserDaySpendCoins = 40 + crossInput := domain.StrategyInput{GiftPriceCoins: 110} + crossed, err := domain.DecideLuckyGiftStrategy(config, crossState, crossInput, &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}}) + if err != nil { + return failedScenario(name, err) + } + rows = append(rows, row{Case: "当前子抽跨三档", GlobalWager: crossState.SpendGlobalRTP.WagerCoins, GlobalPayout: crossState.SpendGlobalRTP.PayoutCoins, TokensBefore: 0, TokensAfter: crossed.NextState.PendingSpendJackpotTokens, Mechanism: crossed.JackpotMechanism, Jackpot: crossed.Jackpot, Payout: crossed.PayoutCoins, PoolAfter: crossed.PoolAfterCoins, Reason: crossed.Trace.BlockedReason}) + next, err := run("下一子抽消费一档", crossed.NextState, input, 3) + if err != nil { + return failedScenario(name, err) + } + + qualified := qualifiedSettledRoundState(10_000, 100*60*60*1000) + qualified.PendingSpendJackpotTokens = 1 + qualified.SpendGlobalRTP = base.SpendGlobalRTP + priority, err := run("双资格48h优先", qualified, input, 4) + if err != nil { + return failedScenario(name, err) + } + qualified.UserRoundRTP.PayoutCoins = 9_600 + fallback, err := run("48h不合格消费接续", qualified, input, 5) + if err != nil { + return failedScenario(name, err) + } + poolBlockedState := base + poolBlockedState.PoolBalanceCoins = 49 + poolBlocked, err := run("奖池不足资格保留", poolBlockedState, input, 6) + if err != nil { + return failedScenario(name, err) + } + + passed := equal.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && equal.NextState.PendingSpendJackpotTokens == 0 && + !above.Jackpot && above.NextState.PendingSpendJackpotTokens == 1 && + !noSample.Jackpot && noSample.NextState.PendingSpendJackpotTokens == 1 && + !crossed.Jackpot && crossed.NextState.PendingSpendJackpotTokens == 3 && + next.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && next.NextState.PendingSpendJackpotTokens == 2 && + priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && priority.NextState.PendingSpendJackpotTokens == 1 && + fallback.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && fallback.NextState.PendingSpendJackpotTokens == 0 && + !poolBlocked.Jackpot && poolBlocked.NextState.PendingSpendJackpotTokens == 1 + return scenarioResult{ + Name: name, Passed: passed, + Summary: "验证大盘<=边界、无样本关闭、每档累计、下一子抽生效、48h优先/接续和资金不足跨轮保留", + Data: rows, + } +} + // simulationStore models the repository contract that the pure kernel expects: // command idempotency and state locking wrap the draw in one critical section. The // simulator does not claim an in-memory mutex is the production implementation; it diff --git a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql index 942db2a2..ab697ff5 100644 --- a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql +++ b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql @@ -99,10 +99,12 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions ( high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '高水位非零奖档概率因子,ppm', recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒', recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子,ppm', + user_24h_rtp_threshold_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '用户滚动24小时RTP降权触发线,ppm;0表示关闭', + user_24h_ordinary_win_factor_ppm BIGINT NOT NULL DEFAULT 1000000 COMMENT '触发后普通非零奖档保留权重,ppm;1000000表示不降权', jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT '动态大奖倍率列表,1x=1000000', jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖全局 RTP 上限,ppm', - jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户当日 RTP 上限,ppm', - jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 72 小时 RTP 上限,ppm', + jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户已结算轮次 RTP 严格上限,ppm', + jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 48 小时 RTP 严格上限,ppm', jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本动态配置的用户 UTC 日累计消费大奖门槛金币', max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日动态大奖命中上限', max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次返奖金币上限', @@ -145,12 +147,37 @@ CREATE TABLE IF NOT EXISTS lucky_rtp_windows ( actual_base_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口实际基础返奖', carry_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '整数除法余数', status VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT 'open/closed/underpaid', + started_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本窗口首笔扣费事实时间,UTC epoch ms;0 表示历史窗口未知', + last_paid_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本窗口末笔扣费事实时间,UTC epoch ms;0 表示历史窗口未知', + closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '达到结算流水并冻结资格的时间,UTC epoch ms;未关闭或历史未知为 0', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, scope_type, scope_id, window_index), KEY idx_lucky_rtp_windows_open (app_code, scope_type, scope_id, status, window_index) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 RTP 控制窗口'; +-- 每个用户在每个全局结算轮次只保留一行聚合与一次性资格状态。滚动 48 小时合计在闭窗时冻结, +-- 后续同毫秒子抽或延迟使用资格都读取该快照,不会被闭窗后的新事实反向污染。 +CREATE TABLE IF NOT EXISTS lucky_user_rtp_windows ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + rule_version BIGINT NOT NULL COMMENT '不可变规则版本;跨版本资格不能继承', + window_index BIGINT NOT NULL COMMENT '对应全局 RTP 结算窗口序号', + user_id BIGINT NOT NULL COMMENT '用户 ID', + user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭', + wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总消耗金币', + payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总返奖金币', + rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总消耗快照', + rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总返奖快照', + qualification_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/attempted/ineligible/expired', + attempted_draw_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消费本轮资格的下一次子抽 ID;未尝试为空', + evaluated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本轮资格判定或消费时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, rule_version, window_index, user_id), + KEY idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户按全局结算轮次聚合的 RTP 与一次性大奖资格'; + CREATE TABLE IF NOT EXISTS lucky_pools ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', scope_type VARCHAR(32) NOT NULL COMMENT 'pool', @@ -214,10 +241,11 @@ CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets ( updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms), KEY idx_lucky_user_rtp_hour_retention (app_code, bucket_hour_ms) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动72小时RTP完整小时聚合桶'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动48小时RTP完整小时聚合桶'; --- 小时桶负责完整小时,边界事件只用于精确补齐 [now-72h,now) 两端不足一小时的片段。 --- 这样热路径最多扫描 71 个小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。 +-- 小时桶负责完整小时;closed_at_ms 保存实际末笔支付毫秒,查询用 [closed_at+1ms-48h,closed_at+1ms) +-- 纳入触发结算的末笔事实,边界事件只补齐两端不足一小时的片段。 +-- 这样闭窗热路径最多扫描 47 个完整小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。 CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', @@ -229,7 +257,7 @@ CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', PRIMARY KEY (app_code, pool_id, user_id, draw_id), KEY idx_lucky_user_rtp_boundary_range (app_code, pool_id, user_id, occurred_at_ms) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动72小时RTP边界事件'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动48小时RTP边界事件'; CREATE TABLE IF NOT EXISTS lucky_user_strategy_days ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', @@ -254,7 +282,8 @@ CREATE TABLE IF NOT EXISTS lucky_user_states ( cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币', equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数', loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零', - pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '未消费大奖机会;按UTC日消费产生,资金不足时允许跨日保留', + pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '旧版消费资格;新版运行时仅惰性清零,永不参与开奖', + pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '新版累计消费大奖资格;受阻时跨轮次和UTC日保留,成功开奖仅扣1', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, user_id, gift_id) @@ -375,6 +404,118 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors ( PRIMARY KEY (app_code, cursor_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标'; +CREATE TABLE IF NOT EXISTS lucky_gift_user_profiles ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3;两套奖池画像严格隔离', + identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external', + identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '内部 user_id 文本或外部 external_user_id', + strategy_user_id BIGINT NOT NULL COMMENT '仅供本服务关联策略状态,绝不下发', + internal_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '内部真实 user_id', + external_user_id VARCHAR(128) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '外部原始用户 ID', + rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '最近规则版本', + current_stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近阶段', + total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计子抽次数', + total_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗金币', + total_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计返奖金币', + lifetime_rtp_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '累计 RTP,物化后用于无 filesort 游标排序', + total_base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖', + ordinary_win_count BIGINT NOT NULL DEFAULT 0 COMMENT '普通非零奖次数', + high_multiplier_ordinary_win_count BIGINT NOT NULL DEFAULT 0 COMMENT '高倍率普通奖次数', + rtp_compensation_jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT 'RTP 补偿大奖次数', + cumulative_spend_jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计消费大奖次数', + jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT '两类独立大奖累计次数,物化后用于排序和筛选', + guarantee_hit_count BIGINT NOT NULL DEFAULT 0 COMMENT '连续未中保护命中次数', + zero_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '未中奖次数', + granted_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '已发放次数', + pending_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '待发放次数', + failed_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败次数', + max_multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最高倍率', + max_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '最高单抽返奖', + last_downweight_active TINYINT(1) NOT NULL DEFAULT 0 COMMENT '最近是否触发 24h RTP 降权', + current_equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '当前运行状态的等价抽数快照', + current_loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '当前连续未中奖次数快照', + current_pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '当前累计消费大奖资格快照', + first_draw_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '首次抽奖时间', + last_draw_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近抽奖时间', + aggregated_at_ms BIGINT NOT NULL COMMENT '最近聚合时间', + created_at_ms BIGINT NOT NULL COMMENT '创建时间', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间', + PRIMARY KEY (app_code, pool_id, strategy_version, identity_type, identity_key), + KEY idx_lucky_user_profile_sort_last (app_code, pool_id, strategy_version, last_draw_at_ms DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_rtp (app_code, pool_id, strategy_version, lifetime_rtp_ppm DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_wager (app_code, pool_id, strategy_version, total_wager_coins DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_payout (app_code, pool_id, strategy_version, total_payout_coins DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_jackpot (app_code, pool_id, strategy_version, jackpot_count DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_loss (app_code, pool_id, strategy_version, current_loss_streak DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_stage (app_code, pool_id, strategy_version, current_stage, identity_type, identity_key), + KEY idx_lucky_user_profile_pending (app_code, pool_id, strategy_version, current_pending_spend_jackpot_tokens DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_internal (app_code, pool_id, strategy_version, internal_user_id), + KEY idx_lucky_user_profile_external (app_code, pool_id, strategy_version, external_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像累计快照'; + +CREATE TABLE IF NOT EXISTS lucky_gift_user_profile_events ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + source_type VARCHAR(24) NOT NULL COMMENT 'internal/external_item/external_legacy', + source_draw_id VARCHAR(128) NOT NULL COMMENT '来源抽奖事实 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3', + identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external', + identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '用户身份键', + draw_count BIGINT NOT NULL DEFAULT 1 COMMENT '代表的子抽次数;fixed_v2 外部整单固定为 1', + wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币', + payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '返奖金币', + base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '基础返奖', + multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '命中倍率', + hit_type VARCHAR(32) NOT NULL COMMENT 'zero/ordinary/rtp_compensation/spend_milestone', + reward_status VARCHAR(32) NOT NULL COMMENT 'pending/granted/failed', + occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间', + aggregation_token VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首次插入者的聚合批次令牌', + created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间', + PRIMARY KEY (app_code, source_type, source_draw_id), + KEY idx_lucky_user_profile_event_window (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms), + KEY idx_lucky_user_profile_event_token (app_code, source_type, aggregation_token) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像窗口事件'; + +CREATE TABLE IF NOT EXISTS lucky_gift_user_multiplier_stats ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3', + identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external', + identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '用户身份键', + hit_type VARCHAR(32) NOT NULL COMMENT '普通奖和两类大奖独立分类', + multiplier_ppm BIGINT NOT NULL COMMENT '倍率', + draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '命中次数', + wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币', + payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '返奖金币', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间', + PRIMARY KEY (app_code, pool_id, strategy_version, identity_type, identity_key, hit_type, multiplier_ppm) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像倍率分布'; + +CREATE TABLE IF NOT EXISTS lucky_gift_user_jackpot_hits ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + source_type VARCHAR(24) NOT NULL COMMENT 'internal/external_item', + source_draw_id VARCHAR(128) NOT NULL COMMENT '来源抽奖事实 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3', + identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external', + identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '用户身份键', + request_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '内部 command_id 或外部 request_id', + rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本', + stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '命中时阶段', + mechanism VARCHAR(32) NOT NULL COMMENT '大奖机制', + reason_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '最终原因码', + tier_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '大奖 tier ID', + multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '大奖倍率', + wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本抽消耗', + payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本抽返奖', + conditions_json JSON NOT NULL COMMENT '大奖条件快照', + occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间', + created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间', + PRIMARY KEY (app_code, source_type, source_draw_id), + KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像大奖解释'; + CREATE TABLE IF NOT EXISTS external_lucky_gift_request_locks ( app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码', request_id VARCHAR(128) NOT NULL COMMENT '外部业务幂等 ID', @@ -405,7 +546,8 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draws ( UNIQUE KEY uk_external_lucky_draw_id (app_code, draw_id), KEY idx_external_lucky_user (app_code, external_user_id, created_at_ms), KEY idx_external_lucky_pool (app_code, pool_id, created_at_ms), - KEY idx_external_lucky_status (app_code, reward_status, created_at_ms) + KEY idx_external_lucky_status (app_code, reward_status, created_at_ms), + KEY idx_external_lucky_profile_cursor (app_code, created_at_ms, draw_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表'; CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items ( @@ -430,7 +572,8 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', PRIMARY KEY (app_code, request_id, item_index), UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id), - KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms) + KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms), + KEY idx_external_lucky_item_profile_cursor (app_code, created_at_ms, draw_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计'; -- AB 实验事实表:两组各钉住一个不可变 rule_version,运行时按 bucket_salt + user 哈希分流。 diff --git a/services/lucky-gift-service/deploy/mysql/migrations/006_settled_round_rtp_48h.sql b/services/lucky-gift-service/deploy/mysql/migrations/006_settled_round_rtp_48h.sql new file mode 100644 index 00000000..04ae2f59 --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/006_settled_round_rtp_48h.sql @@ -0,0 +1,103 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_lucky_gift; + +-- 性能边界: +-- 1. 规则版本表只保存低频发布的不可变配置,两个 CHANGE 仅改列名/注释且保持 BIGINT/NOT NULL/DEFAULT 不变; +-- 显式要求 ALGORITHM=INSTANT,若目标 MySQL 不支持即时 DDL 就直接失败,禁止静默退化为整表 COPY。 +-- 只有旧、新列异常并存时才扫描这张小配置表把旧非零值补到新列,再即时删除旧列;不会扫描任何抽奖事实。 +-- 2. lucky_rtp_windows 是热状态表。三个常量默认列分别使用即时 DDL,不回写历史行;每条 ALTER 仍需短暂 MDL, +-- 上线前应确认没有长事务占用该表,否则迁移会等待元数据锁,但不会扫描 lucky_draw_records 等明细大表。 +-- 3. lucky_user_rtp_windows 是全新空表,主键与待资格索引随建表一次完成;迁移不做历史资格回填,避免全表聚合。 + +-- 旧库保留 day/72h 列,新库可能已由 initdb 创建 round/48h 列;逐列探测保证两种环境和重复执行都安全。 +-- CHANGE 保持列类型、空值属性与默认值完全不变,只把唯一业务口径改成“已结算轮次”和“滚动 48 小时”。 +SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm'); +SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm'); +SET @ddl := CASE + WHEN @has_old_column > 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions CHANGE COLUMN jackpot_user_day_rtp_max_ppm jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户已结算轮次 RTP 严格上限,ppm'', ALGORITHM=INSTANT' + WHEN @has_old_column = 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户已结算轮次 RTP 严格上限,ppm'', ALGORITHM=INSTANT' + ELSE 'SELECT 1' +END; +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 历史 004 可能在全量重放时再次补出旧列。若旧、新列同时存在,以已发布的新列为准,仅在新值为 0 时承接旧非零值, +-- 随后删除旧列,保证仓储永远只面对一个口径;规则版本表数据量是发布次数级,不是送礼流水级。 +SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm'); +SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm'); +SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0, + 'UPDATE lucky_gift_rule_versions SET jackpot_user_round_rtp_max_ppm = jackpot_user_day_rtp_max_ppm WHERE jackpot_user_round_rtp_max_ppm = 0 AND jackpot_user_day_rtp_max_ppm <> 0', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0, + 'ALTER TABLE lucky_gift_rule_versions DROP COLUMN jackpot_user_day_rtp_max_ppm, ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm'); +SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_48h_rtp_max_ppm'); +SET @ddl := CASE + WHEN @has_old_column > 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions CHANGE COLUMN jackpot_user_72h_rtp_max_ppm jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户滚动 48 小时 RTP 严格上限,ppm'', ALGORITHM=INSTANT' + WHEN @has_old_column = 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户滚动 48 小时 RTP 严格上限,ppm'', ALGORITHM=INSTANT' + ELSE 'SELECT 1' +END; +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm'); +SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_48h_rtp_max_ppm'); +SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0, + 'UPDATE lucky_gift_rule_versions SET jackpot_user_48h_rtp_max_ppm = jackpot_user_72h_rtp_max_ppm WHERE jackpot_user_48h_rtp_max_ppm = 0 AND jackpot_user_72h_rtp_max_ppm <> 0', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0, + 'ALTER TABLE lucky_gift_rule_versions DROP COLUMN jackpot_user_72h_rtp_max_ppm, ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 历史窗口无法可靠推导首笔、末笔和闭窗业务时间,因此默认 0 明确表示 unknown;新运行逻辑只对新窗口写真实 paid_at。 +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'started_at_ms') = 0, + 'ALTER TABLE lucky_rtp_windows ADD COLUMN started_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''本窗口首笔扣费事实时间,UTC epoch ms;0 表示历史窗口未知'', ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'last_paid_at_ms') = 0, + 'ALTER TABLE lucky_rtp_windows ADD COLUMN last_paid_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''本窗口末笔扣费事实时间,UTC epoch ms;0 表示历史窗口未知'', ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'closed_at_ms') = 0, + 'ALTER TABLE lucky_rtp_windows ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''达到结算流水并冻结资格的时间,UTC epoch ms;未关闭或历史未知为 0'', ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 用户轮次行与全局窗口同版本、同 window_index 对齐。滚动 48 小时分子/分母在闭窗时冻结, +-- qualification_status 只允许业务层在 pending/attempted/ineligible/expired 间单向推进,资格不会跨规则版本复用。 +CREATE TABLE IF NOT EXISTS lucky_user_rtp_windows ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + rule_version BIGINT NOT NULL COMMENT '不可变规则版本;跨版本资格不能继承', + window_index BIGINT NOT NULL COMMENT '对应全局 RTP 结算窗口序号', + user_id BIGINT NOT NULL COMMENT '用户 ID', + user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭', + wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总消耗金币', + payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总返奖金币', + rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总消耗快照', + rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总返奖快照', + qualification_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/attempted/ineligible/expired', + attempted_draw_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消费本轮资格的下一次子抽 ID;未尝试为空', + evaluated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本轮资格判定或消费时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, rule_version, window_index, user_id), + KEY idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户按全局结算轮次聚合的 RTP 与一次性大奖资格'; + +-- 兼容极少数曾提前创建初版用户轮次表的环境;正式 CREATE 已包含这些列,正常路径均为空操作。 +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'user_registered_at_ms') = 0, + 'ALTER TABLE lucky_user_rtp_windows ADD COLUMN user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭'', ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'rolling_48h_wager_coins') = 0, + 'ALTER TABLE lucky_user_rtp_windows ADD COLUMN rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''闭窗时最近 48 小时总消耗快照'', ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'rolling_48h_payout_coins') = 0, + 'ALTER TABLE lucky_user_rtp_windows ADD COLUMN rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''闭窗时最近 48 小时总返奖快照'', ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND INDEX_NAME = 'idx_lucky_user_rtp_windows_pending') = 0, + 'ALTER TABLE lucky_user_rtp_windows ADD INDEX idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/services/lucky-gift-service/deploy/mysql/migrations/007_dual_jackpot_spend_tokens.sql b/services/lucky-gift-service/deploy/mysql/migrations/007_dual_jackpot_spend_tokens.sql new file mode 100644 index 00000000..2cd03f85 --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/007_dual_jackpot_spend_tokens.sql @@ -0,0 +1,17 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_lucky_gift; + +-- 性能边界:lucky_user_states 是热状态表,本迁移只添加一个常量默认列并强制 +-- ALGORITHM=INSTANT。若目标 MySQL 不支持即时 DDL 就直接失败,绝不退化成 COPY; +-- 上线前仍需检查长事务,避免短暂 MDL 等待。迁移不扫描、不更新历史用户行。 +-- 历史 pending_jackpot_tokens 保持原值,业务在该用户下一次付费抽奖事务内按主键惰性清零, +-- 且从不把旧值复制到新列,因此停用期间的资格和消费都不会被追溯补发。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_states' + AND COLUMN_NAME = 'pending_spend_jackpot_tokens') = 0, + 'ALTER TABLE lucky_user_states ADD COLUMN pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''新版累计消费大奖资格;受阻时跨轮次和UTC日保留,成功开奖仅扣1'' AFTER pending_jackpot_tokens, ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/services/lucky-gift-service/deploy/mysql/migrations/008_user_24h_rtp_downweight.sql b/services/lucky-gift-service/deploy/mysql/migrations/008_user_24h_rtp_downweight.sql new file mode 100644 index 00000000..308ef975 --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/008_user_24h_rtp_downweight.sql @@ -0,0 +1,23 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_lucky_gift; + +-- 性能边界:规则版本表只增加常量默认列,强制 ALGORITHM=INSTANT,迁移不扫描或回写历史版本。 +-- 若目标 MySQL 不支持即时 DDL 则直接失败,不能退化为 COPY;执行前仍需检查长事务,避免元数据锁等待。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' + AND COLUMN_NAME = 'user_24h_rtp_threshold_ppm') = 0, + 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_24h_rtp_threshold_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''用户滚动24小时RTP降权触发线,ppm;0表示关闭'' AFTER recharge_boost_factor_ppm, ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' + AND COLUMN_NAME = 'user_24h_ordinary_win_factor_ppm') = 0, + 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_24h_ordinary_win_factor_ppm BIGINT NOT NULL DEFAULT 1000000 COMMENT ''触发后普通非零奖档保留权重,ppm;1000000表示不降权'' AFTER user_24h_rtp_threshold_ppm, ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/services/lucky-gift-service/deploy/mysql/migrations/009_lucky_gift_user_profiles.sql b/services/lucky-gift-service/deploy/mysql/migrations/009_lucky_gift_user_profiles.sql new file mode 100644 index 00000000..363e718e --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/009_lucky_gift_user_profiles.sql @@ -0,0 +1,213 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_lucky_gift; + +-- 用户画像是幸运礼物事实的专用读模型。列表、窗口指标和大奖解释都只能读取这些表, +-- 不能在运营查询时临时 GROUP BY 全量内部/外部抽奖事实。 +CREATE TABLE IF NOT EXISTS lucky_gift_user_profiles ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3;两套奖池画像严格隔离', + identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external', + identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '内部 user_id 文本或外部 external_user_id;永不存展示稳定哈希', + strategy_user_id BIGINT NOT NULL COMMENT '策略状态用户键;外部值仅供本服务关联状态,绝不下发', + internal_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '内部真实 user_id;外部固定为 0', + external_user_id VARCHAR(128) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '外部原始用户 ID;内部固定为空', + rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '最近一次不可变规则版本', + current_stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近一次抽奖阶段', + total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计子抽次数', + total_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗金币', + total_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计用户可见返奖金币', + lifetime_rtp_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '累计 RTP,物化后用于无 filesort 游标排序', + total_base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖金币', + ordinary_win_count BIGINT NOT NULL DEFAULT 0 COMMENT '普通非零奖命中次数', + high_multiplier_ordinary_win_count BIGINT NOT NULL DEFAULT 0 COMMENT '高倍率普通奖命中次数;与大奖独立', + rtp_compensation_jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT 'RTP 补偿大奖命中次数', + cumulative_spend_jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计消费大奖命中次数', + jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT '两类独立大奖累计次数,物化后用于排序和筛选', + guarantee_hit_count BIGINT NOT NULL DEFAULT 0 COMMENT '连续未中奖保护命中次数', + zero_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '0 倍未中奖次数', + granted_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '已发放次数', + pending_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '待发放次数', + failed_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败次数', + max_multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '历史最高倍率', + max_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '历史单抽最高返奖', + last_downweight_active TINYINT(1) NOT NULL DEFAULT 0 COMMENT '最近一次抽奖是否触发 24h RTP 降权', + current_equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '当前运行状态的等价抽数快照', + current_loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '当前连续未中奖次数快照', + current_pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '当前累计消费大奖资格快照', + first_draw_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '首次抽奖事实时间', + last_draw_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近抽奖事实时间', + aggregated_at_ms BIGINT NOT NULL COMMENT '最近聚合时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, strategy_version, identity_type, identity_key), + KEY idx_lucky_user_profile_sort_last (app_code, pool_id, strategy_version, last_draw_at_ms DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_rtp (app_code, pool_id, strategy_version, lifetime_rtp_ppm DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_wager (app_code, pool_id, strategy_version, total_wager_coins DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_payout (app_code, pool_id, strategy_version, total_payout_coins DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_jackpot (app_code, pool_id, strategy_version, jackpot_count DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_sort_loss (app_code, pool_id, strategy_version, current_loss_streak DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_stage (app_code, pool_id, strategy_version, current_stage, identity_type, identity_key), + KEY idx_lucky_user_profile_pending (app_code, pool_id, strategy_version, current_pending_spend_jackpot_tokens DESC, identity_type DESC, identity_key DESC), + KEY idx_lucky_user_profile_internal (app_code, pool_id, strategy_version, internal_user_id), + KEY idx_lucky_user_profile_external (app_code, pool_id, strategy_version, external_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像累计快照'; + +-- 画像事件只保留统计所需的窄字段。永久唯一键既支持严格滚动窗口,也让历史回灌与实时写入幂等共存。 +CREATE TABLE IF NOT EXISTS lucky_gift_user_profile_events ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + source_type VARCHAR(24) NOT NULL COMMENT 'internal/external_item/external_legacy', + source_draw_id VARCHAR(128) NOT NULL COMMENT '来源抽奖事实 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3', + identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external', + identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '可搜索用户身份键', + draw_count BIGINT NOT NULL DEFAULT 1 COMMENT '该事件代表的子抽次数;fixed_v2 外部整单固定为 1,旧 dynamic 聚合事实可大于 1', + wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币', + payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户可见返奖金币', + base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '基础返奖金币', + multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '命中倍率', + hit_type VARCHAR(32) NOT NULL COMMENT 'zero/ordinary/rtp_compensation/spend_milestone', + reward_status VARCHAR(32) NOT NULL COMMENT 'pending/granted/failed', + occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间,UTC epoch ms', + aggregation_token VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首次插入者的聚合批次令牌;用于在线写入与回灌并发去重', + created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间,UTC epoch ms', + PRIMARY KEY (app_code, source_type, source_draw_id), + KEY idx_lucky_user_profile_event_window (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms), + KEY idx_lucky_user_profile_event_token (app_code, source_type, aggregation_token) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像窗口事件'; + +CREATE TABLE IF NOT EXISTS lucky_gift_user_multiplier_stats ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3', + identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external', + identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '可搜索用户身份键', + hit_type VARCHAR(32) NOT NULL COMMENT '普通奖和两类大奖分别聚合,允许同倍率共存', + multiplier_ppm BIGINT NOT NULL COMMENT '倍率,1x=1000000', + draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '命中次数', + wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '对应抽奖消耗', + payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '对应返奖', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, pool_id, strategy_version, identity_type, identity_key, hit_type, multiplier_ppm) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像倍率分布'; + +CREATE TABLE IF NOT EXISTS lucky_gift_user_jackpot_hits ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + source_type VARCHAR(24) NOT NULL COMMENT 'internal/external_item', + source_draw_id VARCHAR(128) NOT NULL COMMENT '来源抽奖事实 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3', + identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external', + identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '可搜索用户身份键', + request_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '内部 command_id 或外部 request_id', + rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '不可变规则版本', + stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '命中时阶段', + mechanism VARCHAR(32) NOT NULL COMMENT 'rtp_compensation/spend_milestone', + reason_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '内核最终原因码', + tier_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '独立大奖 tier ID', + multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '大奖倍率', + wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本抽消耗', + payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本抽返奖', + conditions_json JSON NOT NULL COMMENT '命中当刻条件快照', + occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间,UTC epoch ms', + PRIMARY KEY (app_code, source_type, source_draw_id), + KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像大奖解释'; + +-- migration 可在建表完成、进程尚未回灌时安全重跑。列使用 INSTANT;画像表在首次上线时为空, +-- 排序索引使用 INPLACE+LOCK=NONE,明确禁止意外 COPY 大表。 +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='lifetime_rtp_ppm')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD COLUMN lifetime_rtp_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''累计 RTP,物化后用于无 filesort 游标排序'' AFTER total_payout_coins, ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='jackpot_count')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD COLUMN jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT ''两类独立大奖累计次数'' AFTER cumulative_spend_jackpot_count, ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='current_equivalent_draws')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD COLUMN current_equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT ''当前运行状态的等价抽数快照'' AFTER last_downweight_active, ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='current_loss_streak')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD COLUMN current_loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT ''当前连续未中奖次数快照'' AFTER current_equivalent_draws, ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='current_pending_spend_jackpot_tokens')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD COLUMN current_pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''当前累计消费大奖资格快照'' AFTER current_loss_streak, ALGORITHM=INSTANT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_last')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_last (app_code, pool_id, strategy_version, last_draw_at_ms DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_rtp')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_rtp (app_code, pool_id, strategy_version, lifetime_rtp_ppm DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_wager')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_wager (app_code, pool_id, strategy_version, total_wager_coins DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_payout')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_payout (app_code, pool_id, strategy_version, total_payout_coins DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_jackpot')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_jackpot (app_code, pool_id, strategy_version, jackpot_count DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_loss')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_loss (app_code, pool_id, strategy_version, current_loss_streak DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_stage')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_stage (app_code, pool_id, strategy_version, current_stage, identity_type, identity_key), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_pending')=0, + 'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_pending (app_code, pool_id, strategy_version, current_pending_spend_jackpot_tokens DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 早期本地 009 使用全 ASC 的 list 索引;完整 sort_last 已覆盖其正向和反向扫描,删除重复索引避免每抽双写。 +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_list')>0, + 'ALTER TABLE lucky_gift_user_profiles DROP INDEX idx_lucky_user_profile_list, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 正常首次上线时画像表尚为空;该更新只用于半完成 migration 后已有画像的幂等修复。 +-- 只扫描专用小型读模型并按主键关联状态,不触碰 8 万级开奖事实表。 +UPDATE lucky_gift_user_profiles p +LEFT JOIN lucky_user_states s + ON s.app_code = p.app_code AND s.user_id = p.strategy_user_id AND s.gift_id = p.pool_id +SET p.lifetime_rtp_ppm = CASE + WHEN p.total_wager_coins <= 0 THEN 0 + ELSE CAST(LEAST(CAST(9223372036854775807 AS DECIMAL(65,0)), + FLOOR(CAST(p.total_payout_coins AS DECIMAL(65,0)) * 1000000 / p.total_wager_coins)) AS SIGNED) + END, + p.jackpot_count = p.rtp_compensation_jackpot_count + p.cumulative_spend_jackpot_count, + p.current_equivalent_draws = COALESCE(s.equivalent_draws, 0), + p.current_loss_streak = COALESCE(s.loss_streak, 0), + p.current_pending_spend_jackpot_tokens = COALESCE(s.pending_spend_jackpot_tokens, 0); + +-- 早期半完成的 009 只有 occurred_at_ms;完整大奖游标还必须把 source_type/source_draw_id 纳入索引顺序。 +SET @jackpot_cursor_columns := (SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_jackpot_hits' AND INDEX_NAME='idx_lucky_user_jackpot_profile'); +SET @ddl := CASE + WHEN COALESCE(@jackpot_cursor_columns, '') = '' THEN + 'ALTER TABLE lucky_gift_user_jackpot_hits ADD KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC), ALGORITHM=INPLACE, LOCK=NONE' + WHEN @jackpot_cursor_columns <> 'app_code,pool_id,strategy_version,identity_type,identity_key,occurred_at_ms,source_type,source_draw_id' THEN + 'ALTER TABLE lucky_gift_user_jackpot_hits DROP INDEX idx_lucky_user_jackpot_profile, ADD KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC), ALGORITHM=INPLACE, LOCK=NONE' + ELSE 'SELECT 1' +END; +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 两个来源索引按 information_schema 守卫,允许 migration 在网络中断或半完成后安全重跑。 +-- INPLACE+LOCK=NONE 禁止退化成 COPY;上线前仍必须检查表大小和长事务,避免 MDL 等待。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'external_lucky_gift_draws' + AND INDEX_NAME = 'idx_external_lucky_profile_cursor') = 0, + 'ALTER TABLE external_lucky_gift_draws ADD KEY idx_external_lucky_profile_cursor (app_code, created_at_ms, draw_id), ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'external_lucky_gift_draw_items' + AND INDEX_NAME = 'idx_external_lucky_item_profile_cursor') = 0, + 'ALTER TABLE external_lucky_gift_draw_items ADD KEY idx_external_lucky_item_profile_cursor (app_code, created_at_ms, draw_id), ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index c27b4f6c..279d5a13 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -57,27 +57,37 @@ type Config struct { AnchorRatePPM int64 `json:"anchor_rate_ppm"` ControlBandPPM int64 `json:"control_band_ppm"` // SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。 - SettlementWindowWager int64 `json:"settlement_window_wager"` - GlobalWindowDraws int64 `json:"global_window_draws"` - GiftWindowDraws int64 `json:"gift_window_draws"` - NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` - NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` - HighMultiplier int64 `json:"high_multiplier"` - HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"` - InitialBasePool int64 `json:"initial_base_pool"` - BasePoolReserve int64 `json:"base_pool_reserve"` - InitialPoolCoins int64 `json:"initial_pool_coins"` - LossStreakGuarantee int64 `json:"loss_streak_guarantee"` - LowWatermarkCoins int64 `json:"low_watermark_coins"` - LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"` - HighWatermarkCoins int64 `json:"high_watermark_coins"` - HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"` - RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` - RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"` - JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"` - JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"` - JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"` - JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"` + SettlementWindowWager int64 `json:"settlement_window_wager"` + GlobalWindowDraws int64 `json:"global_window_draws"` + GiftWindowDraws int64 `json:"gift_window_draws"` + NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` + NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` + HighMultiplier int64 `json:"high_multiplier"` + HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"` + InitialBasePool int64 `json:"initial_base_pool"` + BasePoolReserve int64 `json:"base_pool_reserve"` + InitialPoolCoins int64 `json:"initial_pool_coins"` + LossStreakGuarantee int64 `json:"loss_streak_guarantee"` + LowWatermarkCoins int64 `json:"low_watermark_coins"` + LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"` + HighWatermarkCoins int64 `json:"high_watermark_coins"` + HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"` + RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` + RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"` + // User24HourRTPThresholdPPM/Factor 只调整普通非零奖档;大奖资格和保底仍由各自策略独立决定。 + User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"` + User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"` + JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"` + JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"` + JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"` + JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"` + // Deprecated aliases only keep in-process callers built against the previous draft source-compatible. + // Persistence, protobuf and runtime decisions use the round/48h fields above; these aliases never create a second rule meaning. + JackpotUserDayRTPMaxPPM int64 `json:"-"` + JackpotUser72hRTPMaxPPM int64 `json:"-"` + // SettledRoundRTPEligibility marks configs received through the round/48h contract. + // It is transport metadata only: persistence stores the two canonical limits and reconstructs this marker on read. + SettledRoundRTPEligibility bool `json:"-"` JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"` MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"` MaxSinglePayout int64 `json:"max_single_payout"` @@ -117,35 +127,42 @@ type RuleStage struct { // RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释; // 比例和倍率统一使用 ppm,后台百分比/倍率业务单位只在发布入口转换。 type RuleConfig struct { - AppCode string `json:"app_code"` - PoolID string `json:"pool_id"` - RuleVersion int64 `json:"rule_version"` - StrategyVersion string `json:"strategy_version"` - Enabled bool `json:"enabled"` - TargetRTPPPM int64 `json:"target_rtp_ppm"` - PoolRatePPM int64 `json:"pool_rate_ppm"` - ProfitRatePPM int64 `json:"profit_rate_ppm"` - AnchorRatePPM int64 `json:"anchor_rate_ppm"` - SettlementWindowWager int64 `json:"settlement_window_wager"` - ControlBandPPM int64 `json:"control_band_ppm"` - GiftPriceReference int64 `json:"gift_price_reference"` - NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` - NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` - EffectiveFromMS int64 `json:"effective_from_ms"` - CreatedByAdminID int64 `json:"created_by_admin_id"` - CreatedAtMS int64 `json:"created_at_ms"` - InitialPoolCoins int64 `json:"initial_pool_coins"` - LossStreakGuarantee int64 `json:"loss_streak_guarantee"` - LowWatermarkCoins int64 `json:"low_watermark_coins"` - LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"` - HighWatermarkCoins int64 `json:"high_watermark_coins"` - HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"` - RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` - RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"` - JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"` - JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"` - JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"` - JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"` + AppCode string `json:"app_code"` + PoolID string `json:"pool_id"` + RuleVersion int64 `json:"rule_version"` + StrategyVersion string `json:"strategy_version"` + Enabled bool `json:"enabled"` + TargetRTPPPM int64 `json:"target_rtp_ppm"` + PoolRatePPM int64 `json:"pool_rate_ppm"` + ProfitRatePPM int64 `json:"profit_rate_ppm"` + AnchorRatePPM int64 `json:"anchor_rate_ppm"` + SettlementWindowWager int64 `json:"settlement_window_wager"` + ControlBandPPM int64 `json:"control_band_ppm"` + GiftPriceReference int64 `json:"gift_price_reference"` + NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` + NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` + EffectiveFromMS int64 `json:"effective_from_ms"` + CreatedByAdminID int64 `json:"created_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + InitialPoolCoins int64 `json:"initial_pool_coins"` + LossStreakGuarantee int64 `json:"loss_streak_guarantee"` + LowWatermarkCoins int64 `json:"low_watermark_coins"` + LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"` + HighWatermarkCoins int64 `json:"high_watermark_coins"` + HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"` + RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` + RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"` + // 0/100% 是历史兼容 sentinel;阈值大于 0 时,factor 表示触发后保留的普通中奖权重。 + User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"` + User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"` + JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"` + JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"` + JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"` + JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"` + // Deprecated aliases are normalized at service/repository boundaries and are never persisted as separate values. + JackpotUserDayRTPMaxPPM int64 `json:"-"` + JackpotUser72hRTPMaxPPM int64 `json:"-"` + SettledRoundRTPEligibility bool `json:"-"` JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"` MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"` MaxSinglePayout int64 `json:"max_single_payout"` @@ -165,23 +182,26 @@ 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 - VisibleRegionID int64 - CountryID int64 - Recharge7DCoins int64 - Recharge30DCoins int64 - LastRechargedAtMS int64 - GiftIncomeCoins int64 + CommandID string + PoolID string + UserID int64 + TargetUserID int64 + DeviceID string + RoomID string + AnchorID string + GiftID string + GiftCount int32 + CoinSpent int64 + PaidAtMS int64 + // UserRegisteredAtMS is the user owner snapshot captured by gateway at the original gift request. + // Zero is tolerated for an old recovered command but makes the 48-hour jackpot maturity gate fail closed. + UserRegisteredAtMS int64 + VisibleRegionID int64 + CountryID int64 + Recharge7DCoins int64 + Recharge30DCoins int64 + LastRechargedAtMS int64 + GiftIncomeCoins int64 // Sender* 是扣费时刻的公开展示快照;owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。 SenderName string SenderAvatar string @@ -327,6 +347,138 @@ type DrawQuery struct { ExternalOnly bool } +const ( + UserProfileIdentityInternal = "internal" + UserProfileIdentityExternal = "external" + + UserProfileHitZero = "zero" + UserProfileHitOrdinary = "ordinary" + UserProfileHitRTPCompensation = "rtp_compensation" + UserProfileHitSpendMilestone = "spend_milestone" +) + +// UserProfileQuery 只允许按已经物化的身份键分页。内部短号、长号和靓号由 admin-server +// 通过 user-service 解析成 InternalUserID;外部用户只用原始 ExternalUserID 搜索,稳定哈希绝不跨服务暴露。 +type UserProfileQuery struct { + AppCode string + PoolID string + IdentityType string + InternalUserID int64 + ExternalUserID string + Stage string + Status string + SortBy string + SortDirection string + Cursor string + Page int32 + PageSize int32 + JackpotPage int32 + JackpotPageSize int32 + JackpotCursor string +} + +// UserProfileWindow 是严格时间窗内的用户资金结果。NetCoins 从用户视角计算为返奖减消耗; +// RTP 分母为 0 时保持 0,并由 HasRTP 区分“真实 0%”和“没有样本”。 +type UserProfileWindow struct { + Draws int64 + WagerCoins int64 + PayoutCoins int64 + NetCoins int64 + RTPPPM int64 + HasRTP bool +} + +// UserProfile 是 app+pool+用户的运营画像,不携带外部 strategy_user_id。 +// StrategyUserID 只在 lucky-gift-service 仓储层关联 current state,transport 映射时必须丢弃。 +type UserProfile struct { + AppCode string + PoolID string + IdentityType string + IdentityKey string + StrategyUserID int64 + InternalUserID int64 + ExternalUserID string + RuleVersion int64 + StrategyVersion string + Stage string + PaidDraws int64 + EquivalentDraws int64 + LossStreak int64 + PendingSpendJackpotTokens int64 + GuaranteeDrawsRemaining int64 + DownweightActive bool + DownweightFactorPPM int64 + User24HourRTPThresholdPPM int64 + OneHour UserProfileWindow + TwentyFourHour UserProfileWindow + FortyEightHour UserProfileWindow + Lifetime UserProfileWindow + BaseRewardCoins int64 + OrdinaryWinCount int64 + HighMultiplierOrdinaryWinCount int64 + RTPCompensationJackpotCount int64 + CumulativeSpendJackpotCount int64 + GuaranteeHitCount int64 + ZeroDrawCount int64 + GrantedDrawCount int64 + PendingDrawCount int64 + FailedDrawCount int64 + MaxMultiplierPPM int64 + MaxRewardCoins int64 + FirstDrawAtMS int64 + LastDrawAtMS int64 + AggregatedAtMS int64 + DataLagMS int64 +} + +type UserProfileMultiplierStat struct { + HitType string + MultiplierPPM int64 + DrawCount int64 + WagerCoins int64 + PayoutCoins int64 +} + +type UserProfileJackpotCondition struct { + Name string + Numerator int64 + Denominator int64 + RatioPPM int64 + LimitPPM int64 + FactorPPM int64 + Passed bool + Reason string +} + +type UserProfileJackpotHit struct { + // SourceType 只参与 owner service 的稳定 keyset 游标,不通过 protobuf/HTTP 对外展示。 + SourceType string + DrawID string + RequestID string + RuleVersion int64 + Stage string + Mechanism string + ReasonCode string + ReasonSummary string + TierID string + MultiplierPPM int64 + WagerCoins int64 + PayoutCoins int64 + OccurredAtMS int64 + Conditions []UserProfileJackpotCondition +} + +type UserProfileDetail struct { + Profile UserProfile + MultiplierDistribution []UserProfileMultiplierStat + JackpotHits []UserProfileJackpotHit + JackpotHitTotal int64 + JackpotPage int32 + JackpotPageSize int32 + NextJackpotCursor string + JackpotHasMore bool +} + // ExternalDrawCommand 是外部 App “已扣费后请求抽奖”的业务事实。 // HyApp 不接管外部 App 余额,因此这里没有 target_user_id、room_id 或 wallet command_id;幂等边界固定为 // app_code + request_id,任何重试都必须返回同一条抽奖结果。 @@ -334,15 +486,18 @@ type ExternalDrawCommand struct { AppCode string ExternalUserID string // DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识;dynamic_v3 必填。 - DeviceID string - RequestID string - GiftCount int64 - UnitAmount int64 - TotalAmount int64 - Currency string - PaidAtMS int64 - MetadataJSON string - PoolID string + DeviceID string + RequestID string + GiftCount int64 + UnitAmount int64 + TotalAmount int64 + Currency string + PaidAtMS int64 + // UserRegisteredAtMS is supplied by the external App's authenticated user-data boundary. + // Missing or inconsistent facts do not block an ordinary draw, but can never mature a compensation jackpot. + UserRegisteredAtMS int64 + MetadataJSON string + PoolID string } // ExternalDrawResult 是返回给外部 App 的最终抽奖结果。 diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy.go b/services/lucky-gift-service/internal/domain/luckygift/strategy.go index d96ec23f..ebf08c75 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy.go @@ -26,7 +26,8 @@ const ( StrategyReasonPoolInsufficient = "pool_insufficient_w_gt_p" StrategyReasonRiskCapacity = "risk_capacity_exceeded" StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate" - StrategyJackpotMechanismMilestone = "milestone_token" + StrategyReasonSpendGlobalRTPBlocked = "spend_global_rtp_gate_failed" + StrategyJackpotMechanismMilestone = "spend_milestone" StrategyJackpotMechanismRTPCompensation = "rtp_compensation" StrategyDrawKindOriginal = "original" StrategyDrawKindRedraw = "redraw" @@ -35,11 +36,21 @@ const ( StrategyRemovalMissProtection = "miss_protection_excludes_zero" StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero" StrategyConditionGlobalRTP = "global_rtp" - StrategyConditionUserDayRTP = "user_day_rtp" - StrategyConditionUser72HourRTP = "user_72h_rtp" - StrategyConditionDailyJackpotLimit = "daily_jackpot_limit" - StrategyConditionPayableJackpot = "payable_jackpot_candidate" - StrategyDefaultZeroTierID = "0x" + StrategyConditionSpendGlobalRTP = "spend_global_rtp" + StrategyConditionSpendDailyJackpotLimit = "spend_daily_jackpot_limit" + StrategyConditionSettledRound = "settled_round_qualification" + StrategyConditionUserRoundRTP = "user_round_rtp" + StrategyConditionUser48HourRTP = "user_48h_rtp" + StrategyConditionUser48HourMaturity = "user_registered_48h" + // StrategyConditionUser24HourRTPDownweight is an ordinary-draw control only. A trace can show + // this condition as active on a jackpot decision, but neither jackpot selection path reads it. + StrategyConditionUser24HourRTPDownweight = "user_24h_rtp_downweight" + // Deprecated trace names remain available to the pure legacy compatibility path only. + StrategyConditionUserDayRTP = "user_day_rtp" + StrategyConditionUser72HourRTP = "user_72h_rtp" + StrategyConditionDailyJackpotLimit = "daily_jackpot_limit" + StrategyConditionPayableJackpot = "payable_jackpot_candidate" + StrategyDefaultZeroTierID = "0x" ) var ( @@ -76,9 +87,10 @@ func (r *seededStrategyRandom) Int63n(n int64) (int64, error) { return r.source.Int63n(n), nil } -// StrategyTier is one configured multiplier. BaseWeightPPM is the ordinary draw -// weight; JackpotWeight only chooses among the configured large-prize set after a -// jackpot mechanism has passed all RTP, pool, daily-limit and risk gates. +// StrategyTier is one source-specific selectable tier, not a multiplier identity. +// The same multiplier may have an ordinary tier and a separate jackpot tier: +// BaseWeightPPM drives only ordinary draws, while JackpotWeight is used only after +// all RTP, pool, daily-limit and risk gates pass. type StrategyTier struct { ID string `json:"id"` MultiplierPPM int64 `json:"multiplier_ppm"` @@ -114,6 +126,10 @@ type StrategyConfig struct { RechargeFactorPPM int64 `json:"recharge_factor_ppm"` RechargeBoostStartMS int64 `json:"recharge_boost_start_ms"` RechargeBoostEndMS int64 `json:"recharge_boost_end_ms"` + // A zero threshold disables the rolling control for historical rules. When enabled, FactorPPM + // is the retained ordinary winning weight (700000 means keep 70%, not subtract 70 points). + User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"` + User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"` MissProtectionZeroDraws int64 `json:"miss_protection_zero_draws"` DailyJackpotLimit int64 `json:"daily_jackpot_limit"` @@ -122,8 +138,14 @@ type StrategyConfig struct { JackpotMechanism1Enabled bool `json:"jackpot_mechanism_1_enabled"` JackpotMechanism2Enabled bool `json:"jackpot_mechanism_2_enabled"` GlobalRTPMaxPPM int64 `json:"global_rtp_max_ppm"` - UserDayRTPMaxPPM int64 `json:"user_day_rtp_max_ppm"` - User72HourRTPMaxPPM int64 `json:"user_72h_rtp_max_ppm"` + UserRoundRTPMaxPPM int64 `json:"user_round_rtp_max_ppm"` + User48HourRTPMaxPPM int64 `json:"user_48h_rtp_max_ppm"` + // SettledRoundRTPEligibility switches production V3 to the closed-window qualification flow. + // False preserves the isolated legacy kernel API used by fixed historical simulations; runtime V3 always sets true. + SettledRoundRTPEligibility bool `json:"settled_round_rtp_eligibility"` + // Deprecated aliases are read only by the legacy kernel branch. + UserDayRTPMaxPPM int64 `json:"user_day_rtp_max_ppm"` + User72HourRTPMaxPPM int64 `json:"user_72h_rtp_max_ppm"` RechargeStages []StrategyRechargeStage `json:"recharge_stages"` } @@ -140,15 +162,31 @@ type StrategyRTP struct { // must update it atomically with the draw record; Version is provided for CAS/optimistic // locking but the kernel itself intentionally performs no I/O. type StrategyState struct { - PoolBalanceCoins int64 `json:"pool_balance_coins"` - ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"` - PendingMilestoneTokens int64 `json:"pending_milestone_tokens"` - UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"` - UserDaySpendCoins int64 `json:"user_day_spend_coins"` - GlobalRTP StrategyRTP `json:"global_rtp"` - UserDayRTP StrategyRTP `json:"user_day_rtp"` - User72HourRTP StrategyRTP `json:"user_72h_rtp"` - Version int64 `json:"version"` + PoolBalanceCoins int64 `json:"pool_balance_coins"` + ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"` + // PendingMilestoneTokens is the legacy pure-kernel field. Production dynamic_v3 never reads it: + // the separate field below prevents tokens created by the retired implementation from becoming + // a jackpot promise after the dual-strategy rollout. + PendingMilestoneTokens int64 `json:"pending_milestone_tokens"` + PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"` + UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"` + UserDaySpendCoins int64 `json:"user_day_spend_coins"` + // SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is + // deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification. + SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"` + GlobalRTP StrategyRTP `json:"global_rtp"` + UserRoundRTP StrategyRTP `json:"user_round_rtp"` + User48HourRTP StrategyRTP `json:"user_48h_rtp"` + // User24HourRTP is loaded once at batch entry and advanced in memory after each subdraw. The + // current subdraw therefore cannot downweight itself, while the next subdraw sees its outcome. + User24HourRTP StrategyRTP `json:"user_24h_rtp"` + SettledRoundPending bool `json:"settled_round_pending"` + QualificationClosedAtMS int64 `json:"qualification_closed_at_ms"` + UserRegisteredAtMS int64 `json:"user_registered_at_ms"` + // Deprecated aliases are updated only by the legacy kernel branch. + UserDayRTP StrategyRTP `json:"user_day_rtp"` + User72HourRTP StrategyRTP `json:"user_72h_rtp"` + Version int64 `json:"version"` } // StrategyRiskCapacity contains the six hard payout ceilings from the product rule. @@ -216,6 +254,7 @@ type StrategyConditionTrace struct { Denominator int64 `json:"denominator,omitempty"` RatioPPM int64 `json:"ratio_ppm,omitempty"` LimitPPM int64 `json:"limit_ppm,omitempty"` + FactorPPM int64 `json:"factor_ppm,omitempty"` Passed bool `json:"passed"` Reason string `json:"reason"` } @@ -237,9 +276,14 @@ type DecisionTrace struct { FinalReason string `json:"final_reason"` BlockedReason string `json:"blocked_reason,omitempty"` JackpotMechanism string `json:"jackpot_mechanism,omitempty"` - MilestoneTokenConsumed bool `json:"milestone_token_consumed"` - MilestoneTokenRetained bool `json:"milestone_token_retained"` - MilestoneTokensEarned int64 `json:"milestone_tokens_earned"` + MilestoneTokenConsumed bool `json:"spend_milestone_token_consumed"` + MilestoneTokenRetained bool `json:"spend_milestone_token_retained"` + MilestoneTokensEarned int64 `json:"spend_milestone_tokens_earned"` + // The exact before/after balance is persisted for every draw, including a successful 48h + // compensation that deliberately leaves an existing spend token untouched. The boolean fields + // above keep their event meaning; these counters make multi-token incident replay unambiguous. + SpendMilestoneTokensBefore int64 `json:"pending_spend_jackpot_tokens_before"` + SpendMilestoneTokensAfter int64 `json:"pending_spend_jackpot_tokens_after"` } type StrategyDecision struct { @@ -263,6 +307,11 @@ type StrategyFundSplit struct { type weightedStrategyTier struct { Tier StrategyTier Weight int64 + // MissProtectionWeight freezes the ordinary probability after the existing water/recharge + // normalization but before the optional 24-hour RTP downweight. Protection is a hard product + // guarantee: integer apportioning may round a small retained tier to zero, but that rounding must + // not make an otherwise payable ordinary prize ineligible once the miss threshold is reached. + MissProtectionWeight int64 } // DefaultLuckyGiftStrategyConfig is a financially safe production baseline: its @@ -285,6 +334,7 @@ func DefaultLuckyGiftStrategyConfig() StrategyConfig { LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000, LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000, RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000, + User24HourRTPThresholdPPM: 1_500_000, User24HourOrdinaryWinFactorPPM: 700_000, // 累计消费门槛是不可变规则版本的动态金币配置;0 表示该内核默认值尚未配置。 MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0, JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true, @@ -379,14 +429,14 @@ func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState, return "", nil, err } stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins) - _, traces, err := buildStrategyWeights(config, stage, available, input) + _, traces, err := buildStrategyWeights(config, state, stage, available, input) return stage, traces, err } -// DecideLuckyGiftStrategy is the shared production/simulation kernel. Its ordering is -// intentional and traceable: persisted mechanism-2 token -> mechanism-1 RTP gates -> -// ordinary weighted P/W draw and redraw -> state transition. A blocked special -// mechanism falls through to an ordinary draw while preserving its token. +// DecideLuckyGiftStrategy is the shared production/simulation kernel. Production V3 evaluates the +// one-shot closed-round/48h qualification first, then the independent durable spend milestone, and +// finally the ordinary weighted draw. A spend token is consumed only by a paid spend jackpot; all +// failed global-RTP, daily-limit, pool and risk checks leave it available for a later paid draw. func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input StrategyInput, random StrategyRandomSource) (StrategyDecision, error) { if random == nil { return StrategyDecision{}, ErrStrategyRandomSource @@ -399,7 +449,7 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S return StrategyDecision{}, err } stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins) - weighted, weightTrace, err := buildStrategyWeights(config, stage, available, input) + weighted, weightTrace, err := buildStrategyWeights(config, state, stage, available, input) if err != nil { return StrategyDecision{}, err } @@ -407,48 +457,45 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S Stage: stage, PoolBeforeCoins: state.PoolBalanceCoins, PoolContributionCoins: input.PoolContributionCoins, AvailablePoolCoins: available, EffectiveRiskCapacityCoins: input.RiskCapacity.capacity(), Weights: weightTrace, + SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state), + SpendMilestoneTokensAfter: pendingSpendJackpotTokens(config, state), } + // Record the personal rolling condition before either jackpot path runs. It is intentionally + // observational for jackpots: only buildStrategyWeights consumes the active flag. + condition, _ := user24HourRTPDownweightCondition(config, state.User24HourRTP) + trace.Conditions = append(trace.Conditions, condition) - // A persisted milestone token is a promise about the next draw, so it outranks the - // opportunistic RTP compensation mechanism. Insufficient pool/risk capacity keeps - // the token durable and lets the user retain the ordinary draw they paid for. - if config.JackpotMechanism2Enabled && state.PendingMilestoneTokens > 0 { - tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) + if config.SettledRoundRTPEligibility { + decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace) if err != nil { return StrategyDecision{}, err } - trace.Removed = append(trace.Removed, removed...) - if ok { - trace.JackpotMechanism = StrategyJackpotMechanismMilestone - trace.MilestoneTokenConsumed = true - trace.FinalReason = StrategyReasonMilestoneJackpot - trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) - return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, trace) + if decided { + return decision, nil } - trace.MilestoneTokenRetained = true - trace.BlockedReason = reason - trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: false, Reason: reason}) - } - - if config.JackpotMechanism1Enabled { - conditions, eligible := mechanismOneConditions(config, state) - trace.Conditions = append(trace.Conditions, conditions...) - if eligible { - tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) - if err != nil { - return StrategyDecision{}, err - } - trace.Removed = append(trace.Removed, removed...) - trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason}) - if ok { - trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation - trace.FinalReason = StrategyReasonRTPCompensationJackpot - trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) - return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, trace) - } - if trace.BlockedReason == "" { - trace.BlockedReason = reason - } + decision, decided, err = trySpendMilestoneJackpot(config, state, input, available, random, true, &trace) + if err != nil { + return StrategyDecision{}, err + } + if decided { + return decision, nil + } + } else { + // The isolated legacy kernel keeps its historical order and gate shape. Runtime dynamic_v3 + // always takes the branch above and therefore never reads PendingMilestoneTokens. + decision, decided, err := trySpendMilestoneJackpot(config, state, input, available, random, false, &trace) + if err != nil { + return StrategyDecision{}, err + } + if decided { + return decision, nil + } + decision, decided, err = tryRTPCompensationJackpot(config, state, input, available, random, &trace) + if err != nil { + return StrategyDecision{}, err + } + if decided { + return decision, nil } } @@ -467,11 +514,91 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S return finalizeStrategyDecision(config, state, input, available, tier, payout, "", false, trace) } +func tryRTPCompensationJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, trace *DecisionTrace) (StrategyDecision, bool, error) { + if !config.JackpotMechanism1Enabled { + return StrategyDecision{}, false, nil + } + conditions, eligible := mechanismOneConditions(config, state) + trace.Conditions = append(trace.Conditions, conditions...) + if !eligible { + return StrategyDecision{}, false, nil + } + tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) + if err != nil { + return StrategyDecision{}, false, err + } + trace.Removed = append(trace.Removed, removed...) + trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason}) + if !ok { + if trace.BlockedReason == "" { + trace.BlockedReason = reason + } + return StrategyDecision{}, false, nil + } + trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation + trace.FinalReason = StrategyReasonRTPCompensationJackpot + trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) + decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, *trace) + return decision, err == nil, err +} + +func trySpendMilestoneJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, requireSettledGlobal bool, trace *DecisionTrace) (StrategyDecision, bool, error) { + if !config.JackpotMechanism2Enabled || pendingSpendJackpotTokens(config, state) <= 0 { + return StrategyDecision{}, false, nil + } + if requireSettledGlobal { + ratio, valid := state.SpendGlobalRTP.RatioPPM() + globalPassed := valid && rtpAtOrBelowExact(state.SpendGlobalRTP, config.GlobalRTPMaxPPM) + trace.Conditions = append(trace.Conditions, rtpAtOrBelowConditionTrace(StrategyConditionSpendGlobalRTP, state.SpendGlobalRTP, ratio, config.GlobalRTPMaxPPM, valid, globalPassed)) + dailyPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit + trace.Conditions = append(trace.Conditions, StrategyConditionTrace{ + Name: StrategyConditionSpendDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, + Passed: dailyPassed, Reason: boolReason(dailyPassed, "below_limit", StrategyReasonDailyJackpotLimit), + }) + if !globalPassed || !dailyPassed { + trace.MilestoneTokenRetained = true + if trace.BlockedReason == "" { + trace.BlockedReason = StrategyReasonSpendGlobalRTPBlocked + if globalPassed { + trace.BlockedReason = StrategyReasonDailyJackpotLimit + } + } + return StrategyDecision{}, false, nil + } + } + tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) + if err != nil { + return StrategyDecision{}, false, err + } + trace.Removed = append(trace.Removed, removed...) + trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason}) + if !ok { + trace.MilestoneTokenRetained = true + if trace.BlockedReason == "" { + trace.BlockedReason = reason + } + return StrategyDecision{}, false, nil + } + trace.JackpotMechanism = StrategyJackpotMechanismMilestone + trace.MilestoneTokenConsumed = true + trace.FinalReason = StrategyReasonMilestoneJackpot + trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) + decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, *trace) + return decision, err == nil, err +} + +func pendingSpendJackpotTokens(config StrategyConfig, state StrategyState) int64 { + if config.SettledRoundRTPEligibility { + return state.PendingSpendJackpotTokens + } + return state.PendingMilestoneTokens +} + func validateStrategy(config StrategyConfig, state StrategyState, input StrategyInput, requireTiers bool) error { - if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 { + if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.PendingSpendJackpotTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 { return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput) } - for _, rtp := range []StrategyRTP{state.GlobalRTP, state.UserDayRTP, state.User72HourRTP} { + for _, rtp := range []StrategyRTP{state.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.User24HourRTP, state.UserDayRTP, state.User72HourRTP} { if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 { return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput) } @@ -485,10 +612,19 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy if config.LowWaterThresholdCoins < 0 || config.HighWaterThresholdCoins < config.LowWaterThresholdCoins || config.LowWaterFactorPPM <= 0 || config.HighWaterFactorPPM <= 0 || config.RechargeFactorPPM <= 0 { return fmt.Errorf("%w: water thresholds or dynamic factors are invalid", ErrStrategyConfig) } + user24HourFactor, _ := normalizedUser24HourOrdinaryWinFactor(config) + if config.User24HourRTPThresholdPPM < 0 || user24HourFactor <= 0 || user24HourFactor > StrategyPPMScale || + (config.User24HourRTPThresholdPPM == 0 && user24HourFactor != StrategyPPMScale) { + return fmt.Errorf("%w: user 24h RTP threshold/factor must be disabled as 0/100%% or use a positive threshold and retained factor within (0,100%%]", ErrStrategyConfig) + } if config.RechargeBoostStartMS < 0 || config.RechargeBoostEndMS <= config.RechargeBoostStartMS || config.MissProtectionZeroDraws < 0 || config.DailyJackpotLimit < 0 || config.MilestoneSpendCoins < 0 || config.ColdStartPoolCoins < 0 { return fmt.Errorf("%w: recharge window, miss protection or daily limit is invalid", ErrStrategyConfig) } - if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale || config.UserDayRTPMaxPPM < 0 || config.UserDayRTPMaxPPM > StrategyPPMScale || config.User72HourRTPMaxPPM < 0 || config.User72HourRTPMaxPPM > StrategyPPMScale { + if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale || + config.UserRoundRTPMaxPPM < 0 || config.UserRoundRTPMaxPPM > StrategyPPMScale || + config.User48HourRTPMaxPPM < 0 || config.User48HourRTPMaxPPM > StrategyPPMScale || + config.UserDayRTPMaxPPM < 0 || config.UserDayRTPMaxPPM > StrategyPPMScale || + config.User72HourRTPMaxPPM < 0 || config.User72HourRTPMaxPPM > StrategyPPMScale { return fmt.Errorf("%w: RTP jackpot limits must be within [0,100%%]", ErrStrategyConfig) } var splitRateTotal int64 @@ -526,6 +662,11 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy if strings.TrimSpace(tier.ID) == "" || seen[tier.ID] || tier.MultiplierPPM < 0 || tier.BaseWeightPPM < 0 || tier.JackpotWeight < 0 { return fmt.Errorf("%w: enabled tiers require unique IDs and non-negative multiplier/weights", ErrStrategyConfig) } + // 大奖身份属于受 RTP 条件控制的独立路径,不能同时携带普通随机权重; + // 同倍率普通奖应使用另一个 Jackpot=false tier,避免每日 cap 和审计来源再次耦合。 + if tier.Jackpot && tier.BaseWeightPPM > 0 { + return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary base weight", ErrStrategyConfig, tier.ID) + } seen[tier.ID] = true if tier.MultiplierPPM == 0 { zeroCount++ @@ -534,6 +675,9 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy if !seenStages[stage] || weight < 0 { return fmt.Errorf("%w: tier %s has unknown stage or negative stage weight", ErrStrategyConfig, tier.ID) } + if tier.Jackpot && weight > 0 { + return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary stage weight", ErrStrategyConfig, tier.ID) + } } } if len(seen) == 0 || zeroCount != 1 { @@ -549,10 +693,11 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy return nil } -func buildStrategyWeights(config StrategyConfig, stage string, available int64, input StrategyInput) ([]weightedStrategyTier, []StrategyWeightTrace, error) { +func buildStrategyWeights(config StrategyConfig, state StrategyState, stage string, available int64, input StrategyInput) ([]weightedStrategyTier, []StrategyWeightTrace, error) { tiers := make([]weightedStrategyTier, 0, len(config.Tiers)) traces := make([]StrategyWeightTrace, 0, len(config.Tiers)) positiveIndexes := make([]int, 0, len(config.Tiers)) + ordinaryPositiveIndexes := make([]int, 0, len(config.Tiers)) var positiveSum int64 zeroIndex := -1 for _, tier := range config.Tiers { @@ -592,6 +737,9 @@ func buildStrategyWeights(config StrategyConfig, stage string, available int64, return nil, nil, err } positiveIndexes = append(positiveIndexes, len(tiers)) + if !tier.Jackpot { + ordinaryPositiveIndexes = append(ordinaryPositiveIndexes, len(tiers)) + } } trace.RawAdjustedWeightPPM = weight tiers = append(tiers, weightedStrategyTier{Tier: tier, Weight: weight}) @@ -616,6 +764,50 @@ func buildStrategyWeights(config StrategyConfig, stage string, available int64, } tiers[zeroIndex].Weight = 0 } + // Snapshot the complete ordinary table at the exact point where the historical algorithm ended. + // The 24-hour control is applied below only to normal random selection; miss protection restores + // these weights so its eligibility cannot be weakened by the later retained-mass rounding. + for index := range tiers { + if tiers[index].Tier.MultiplierPPM > 0 && !tiers[index].Tier.Jackpot { + tiers[index].MissProtectionWeight = tiers[index].Weight + } + } + _, downweightActive := user24HourRTPDownweightCondition(config, state.User24HourRTP) + if downweightActive { + // Water-level and recharge factors first shape the relative ordinary table. Only after the + // normal 1,000,000-PPM normalization do we shrink the complete non-zero ordinary mass, so a + // table previously normalized to 100% becomes exactly 70% when the retained factor is 70%. + factor, _ := normalizedUser24HourOrdinaryWinFactor(config) + var ordinaryPositiveSum int64 + ordinaryWeights := make([]int64, 0, len(ordinaryPositiveIndexes)) + for _, index := range ordinaryPositiveIndexes { + ordinaryWeights = append(ordinaryWeights, tiers[index].Weight) + var err error + ordinaryPositiveSum, err = safeAdd(ordinaryPositiveSum, tiers[index].Weight) + if err != nil { + return nil, nil, err + } + } + if ordinaryPositiveSum > 0 { + targetPositiveSum := mulPPM(ordinaryPositiveSum, factor) + // PPM is discrete. Preserve one selectable unit for any non-empty configured table so miss + // protection can still guarantee a payable ordinary award even at a very small factor. + if targetPositiveSum == 0 { + targetPositiveSum = 1 + } + downweighted, err := apportion(targetPositiveSum, ordinaryWeights, ordinaryPositiveSum) + if err != nil { + return nil, nil, err + } + for offset, index := range ordinaryPositiveIndexes { + tiers[index].Weight = downweighted[offset] + traces[index].Factors = append(traces[index].Factors, StrategyWeightFactor{ + Name: StrategyConditionUser24HourRTPDownweight, FactorPPM: factor, + }) + } + tiers[zeroIndex].Weight = StrategyPPMScale - targetPositiveSum + } + } for index := range tiers { traces[index].AdjustedWeightPPM = tiers[index].Weight } @@ -630,7 +822,46 @@ func rechargeBoostActive(config StrategyConfig, input StrategyInput) bool { return age >= config.RechargeBoostStartMS && age < config.RechargeBoostEndMS } +func normalizedUser24HourOrdinaryWinFactor(config StrategyConfig) (int64, bool) { + factor := config.User24HourOrdinaryWinFactorPPM + // Pure-kernel callers compiled before this field existed still produce 0/0 struct literals. The + // published-rule adapter canonicalizes them to 0/100%, while this guard preserves source-level + // compatibility without turning a missing factor into “remove every win”. + if config.User24HourRTPThresholdPPM == 0 && factor == 0 { + factor = StrategyPPMScale + } + enabled := config.User24HourRTPThresholdPPM > 0 && factor > 0 && factor < StrategyPPMScale + return factor, enabled +} + +func user24HourRTPDownweightCondition(config StrategyConfig, rtp StrategyRTP) (StrategyConditionTrace, bool) { + factor, enabled := normalizedUser24HourOrdinaryWinFactor(config) + ratio, valid := rtp.RatioPPM() + active := enabled && valid && compareRTPExact(rtp, config.User24HourRTPThresholdPPM) > 0 + reason := "at_or_below_threshold_normal_weight" + switch { + case !enabled: + reason = "disabled_or_identity_factor" + case !valid: + reason = "denominator_zero_normal_weight" + case active: + reason = "above_threshold_downweighted" + } + return StrategyConditionTrace{ + Name: StrategyConditionUser24HourRTPDownweight, + Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, + RatioPPM: ratio, LimitPPM: config.User24HourRTPThresholdPPM, FactorPPM: factor, + Passed: active, Reason: reason, + }, active +} + func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) { + if config.SettledRoundRTPEligibility { + return settledRoundJackpotConditions(config, state) + } + // Historical pure-kernel callers used opportunistic current/day/72h checks. Keeping this branch + // source-compatible does not expose it to dynamic_v3: the production adapter always enables the + // settled-round flag and disables the independent milestone mechanism. checks := []struct { name string rtp StrategyRTP @@ -659,6 +890,85 @@ func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]Strat return traces, eligible && limitPassed } +func settledRoundJackpotConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) { + traces := make([]StrategyConditionTrace, 0, 6) + pending := state.SettledRoundPending + traces = append(traces, StrategyConditionTrace{ + Name: StrategyConditionSettledRound, Passed: pending, + Reason: boolReason(pending, "closed_round_pending", "no_closed_round_pending"), + }) + eligible := pending + + // The platform permits equality at 98%, while both user gates are deliberately strict: an exact + // 96% user ratio is not “losing more than two points” and must not receive compensation. + globalRatio, globalValid := state.GlobalRTP.RatioPPM() + globalPassed := globalValid && rtpAtOrBelowExact(state.GlobalRTP, config.GlobalRTPMaxPPM) + traces = append(traces, rtpConditionTrace(StrategyConditionGlobalRTP, state.GlobalRTP, globalRatio, config.GlobalRTPMaxPPM, globalValid, globalPassed)) + eligible = eligible && globalPassed + + roundRatio, roundValid := state.UserRoundRTP.RatioPPM() + roundPassed := roundValid && rtpBelowExact(state.UserRoundRTP, config.UserRoundRTPMaxPPM) + traces = append(traces, rtpConditionTrace(StrategyConditionUserRoundRTP, state.UserRoundRTP, roundRatio, config.UserRoundRTPMaxPPM, roundValid, roundPassed)) + eligible = eligible && roundPassed + + const maturityMS = int64(48 * 60 * 60 * 1000) + ageMS := int64(0) + if state.UserRegisteredAtMS > 0 && state.QualificationClosedAtMS >= state.UserRegisteredAtMS { + ageMS = state.QualificationClosedAtMS - state.UserRegisteredAtMS + } + mature := state.UserRegisteredAtMS > 0 && ageMS >= maturityMS + traces = append(traces, StrategyConditionTrace{ + Name: StrategyConditionUser48HourMaturity, Numerator: ageMS, Denominator: maturityMS, + Passed: mature, Reason: boolReason(mature, "registered_48h", "registered_under_48h_or_unknown"), + }) + eligible = eligible && mature + + rollingRatio, rollingValid := state.User48HourRTP.RatioPPM() + rollingPassed := rollingValid && rtpBelowExact(state.User48HourRTP, config.User48HourRTPMaxPPM) + traces = append(traces, rtpConditionTrace(StrategyConditionUser48HourRTP, state.User48HourRTP, rollingRatio, config.User48HourRTPMaxPPM, rollingValid, rollingPassed)) + eligible = eligible && rollingPassed + + limitPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit + traces = append(traces, StrategyConditionTrace{Name: StrategyConditionDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, Passed: limitPassed, Reason: boolReason(limitPassed, "below_limit", StrategyReasonDailyJackpotLimit)}) + return traces, eligible && limitPassed +} + +func rtpConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace { + reason := "within_limit" + if !valid { + reason = "denominator_zero" + } else if !passed { + reason = "above_or_equal_limit" + } + return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason} +} + +func rtpAtOrBelowConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace { + reason := "within_limit" + if !valid { + reason = "denominator_zero" + } else if !passed { + reason = "above_limit" + } + return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason} +} + +func rtpBelowExact(rtp StrategyRTP, limitPPM int64) bool { + return compareRTPExact(rtp, limitPPM) < 0 +} + +func rtpAtOrBelowExact(rtp StrategyRTP, limitPPM int64) bool { + return compareRTPExact(rtp, limitPPM) <= 0 +} + +func compareRTPExact(rtp StrategyRTP, limitPPM int64) int { + // Cross multiplication keeps the configured mathematical boundary exact even when payout*1e6 + // exceeds int64. RatioPPM remains a display/audit value and is never trusted for eligibility. + left := new(big.Int).Mul(big.NewInt(rtp.PayoutCoins), big.NewInt(StrategyPPMScale)) + right := new(big.Int).Mul(big.NewInt(rtp.WagerCoins), big.NewInt(limitPPM)) + return left.Cmp(right) +} + func selectPayableJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource) (StrategyTier, int64, bool, string, []StrategyRemovalTrace, error) { if state.UserDailyJackpotWins >= config.DailyJackpotLimit { return StrategyTier{}, 0, false, StrategyReasonDailyJackpotLimit, nil, nil @@ -700,7 +1010,18 @@ func selectOrdinaryTier(config StrategyConfig, state StrategyState, input Strate candidates := append([]weightedStrategyTier(nil), weighted...) if state.ConsecutiveZeroDraws >= config.MissProtectionZeroDraws { trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: zeroTier.ID, Reason: StrategyRemovalMissProtection}) - candidates = withoutStrategyTier(candidates, zeroTier.ID) + // Protection deliberately rebuilds only ordinary positive candidates from their pre-downweight + // weights. A 1-PPM retained mass can round some tiers to zero, but if any such tier is payable the + // guarantee still has to award one; jackpot tiers remain exclusively controlled by jackpot gates. + protected := make([]weightedStrategyTier, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.Tier.MultiplierPPM <= 0 || candidate.Tier.Jackpot || candidate.MissProtectionWeight <= 0 { + continue + } + candidate.Weight = candidate.MissProtectionWeight + protected = append(protected, candidate) + } + candidates = protected if !hasPayableOrdinaryTier(config, state, input, available, candidates) { trace.FinalReason = StrategyReasonMissProtectionBlocked trace.BlockedReason = StrategyReasonMissProtectionBlocked @@ -788,6 +1109,11 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input return StrategyDecision{}, fmt.Errorf("%w: payout=%d exceeds available pool=%d", ErrStrategyInput, payout, available) } next := state + if config.SettledRoundRTPEligibility { + // A closed-round qualification is exactly one attempt. Pool/risk rejection falls through to the + // ordinary draw in this same call, but the qualification can never repeat on a later paid draw. + next.SettledRoundPending = false + } next.PoolBalanceCoins = available - payout if next.PoolBalanceCoins < 0 { return StrategyDecision{}, fmt.Errorf("%w: strategy would make pool negative", ErrStrategyInput) @@ -798,10 +1124,14 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input next.ConsecutiveZeroDraws = 0 } if consumeToken { - if next.PendingMilestoneTokens <= 0 { + if pendingSpendJackpotTokens(config, next) <= 0 { return StrategyDecision{}, fmt.Errorf("%w: cannot consume absent milestone token", ErrStrategyInput) } - next.PendingMilestoneTokens-- + if config.SettledRoundRTPEligibility { + next.PendingSpendJackpotTokens-- + } else { + next.PendingMilestoneTokens-- + } } jackpot := payout > 0 && tier.Jackpot if jackpot { @@ -812,24 +1142,45 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input if next.UserDaySpendCoins, err = safeAdd(next.UserDaySpendCoins, input.GiftPriceCoins); err != nil { return StrategyDecision{}, err } - // A threshold crossed by this draw is persisted after selection, so the newly - // earned token can affect only the next locked draw and never the current one. - earnedTokens := MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins) + // A threshold crossed by this draw is persisted after selection, so the newly earned token can + // affect only the next locked subdraw and never the current one. Production V3 stores it in the + // dedicated field; the retired legacy token is neither read nor migrated into the new strategy. + earnedTokens := int64(0) + if config.JackpotMechanism2Enabled { + earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins) + } if earnedTokens > 0 { - next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens) + if config.SettledRoundRTPEligibility { + next.PendingSpendJackpotTokens, err = safeAdd(next.PendingSpendJackpotTokens, earnedTokens) + } else { + next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens) + } if err != nil { return StrategyDecision{}, err } trace.MilestoneTokensEarned = earnedTokens } - if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil { - return StrategyDecision{}, err + // Set the final balance after both the optional consumption and threshold-crossing accrual. + // This assignment runs for ordinary, 48h and spend-jackpot decisions alike, so a 48h win with + // one pending spend promise is durably recorded as 1 -> 1 instead of looking like no promise existed. + trace.SpendMilestoneTokensAfter = pendingSpendJackpotTokens(config, next) + if _, enabled := normalizedUser24HourOrdinaryWinFactor(config); enabled { + // The caller supplies the pre-draw rolling snapshot. Advancing it only after selection makes + // this draw visible to the next item in the same gift_count batch, never to its own decision. + if next.User24HourRTP, err = next.User24HourRTP.add(input.GiftPriceCoins, payout); err != nil { + return StrategyDecision{}, err + } } - if next.UserDayRTP, err = next.UserDayRTP.add(input.GiftPriceCoins, payout); err != nil { - return StrategyDecision{}, err - } - if next.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil { - return StrategyDecision{}, err + if !config.SettledRoundRTPEligibility { + if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil { + return StrategyDecision{}, err + } + if next.UserDayRTP, err = next.UserDayRTP.add(input.GiftPriceCoins, payout); err != nil { + return StrategyDecision{}, err + } + if next.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil { + return StrategyDecision{}, err + } } if next.Version == math.MaxInt64 { return StrategyDecision{}, fmt.Errorf("%w: state version overflow", ErrStrategyInput) diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go b/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go index 346772fb..cb098bdb 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go @@ -34,9 +34,12 @@ func strategyTestConfig() StrategyConfig { {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, {ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true}, {ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true}, - {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true}, - {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true}, - {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true}, + {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true}, + {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true}, + {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Enabled: true}, + {ID: "jackpot_200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true}, + {ID: "jackpot_500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true}, + {ID: "jackpot_1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true}, } // Core P/W tests isolate the base distribution; dynamic factors have dedicated // boundary tests below and must not silently move scripted intervals here. @@ -291,23 +294,196 @@ func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) { } } -func TestLuckyGiftStrategyDailyJackpotLimitAlsoConstrainsBaseDraw(t *testing.T) { +func TestLuckyGiftStrategyDualJackpotSpendGlobalGateAndDurability(t *testing.T) { + config := strategyTestConfig() + config.SettledRoundRTPEligibility = true + config.JackpotMechanism1Enabled = true + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + config.GlobalRTPMaxPPM = 980_000 + config.UserRoundRTPMaxPPM = 960_000 + config.User48HourRTPMaxPPM = 960_000 + config.DailyJackpotLimit = 5 + + base := StrategyState{PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}} + paid, err := DecideLuckyGiftStrategy(config, base, strategyTestInput(10), NewSeededStrategyRandom(80)) + if err != nil { + t.Fatal(err) + } + if paid.JackpotMechanism != StrategyJackpotMechanismMilestone || !paid.ConsumedMilestoneToken || paid.NextState.PendingSpendJackpotTokens != 0 || + paid.Trace.SpendMilestoneTokensBefore != 1 || paid.Trace.SpendMilestoneTokensAfter != 0 { + t.Fatalf("global equality should pay exactly one spend token: %+v", paid) + } + + for _, test := range []struct { + name string + rtp StrategyRTP + }{ + {name: "no closed platform sample", rtp: StrategyRTP{}}, + {name: "platform above configured maximum", rtp: StrategyRTP{WagerCoins: 100, PayoutCoins: 99}}, + } { + t.Run(test.name, func(t *testing.T) { + state := base + state.SpendGlobalRTP = test.rtp + blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(81)) + if err != nil { + t.Fatal(err) + } + if blocked.JackpotMechanism != "" || blocked.NextState.PendingSpendJackpotTokens != 1 || !blocked.Trace.MilestoneTokenRetained { + t.Fatalf("blocked spend token must remain durable: %+v", blocked) + } + }) + } + + capState := base + capState.UserDailyJackpotWins = config.DailyJackpotLimit + blockedBySharedCap, err := DecideLuckyGiftStrategy(config, capState, strategyTestInput(10), NewSeededStrategyRandom(82)) + if err != nil { + t.Fatal(err) + } + if blockedBySharedCap.NextState.PendingSpendJackpotTokens != 1 || blockedBySharedCap.Jackpot { + t.Fatalf("daily cap must be shared without consuming spend token: %+v", blockedBySharedCap) + } +} + +func TestLuckyGiftStrategyDualJackpotCrossingAndPriority(t *testing.T) { + config := strategyTestConfig() + config.SettledRoundRTPEligibility = true + config.JackpotMechanism1Enabled = true + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + config.GlobalRTPMaxPPM = 980_000 + config.UserRoundRTPMaxPPM = 960_000 + config.User48HourRTPMaxPPM = 960_000 + config.DailyJackpotLimit = 5 + + // One paid subdraw can cross several exact multiples, but its new promises are appended only + // after selection. Even with a valid global sample this draw must stay on the ordinary path. + crossingState := StrategyState{ + PoolBalanceCoins: 10_000, UserDaySpendCoins: 40, + SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, + } + crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(110), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}}) + if err != nil { + t.Fatal(err) + } + if crossing.Jackpot || crossing.NextState.PendingSpendJackpotTokens != 3 || crossing.Trace.MilestoneTokensEarned != 3 || + crossing.Trace.SpendMilestoneTokensBefore != 0 || crossing.Trace.SpendMilestoneTokensAfter != 3 { + t.Fatalf("current draw must only earn three next-draw tokens: %+v", crossing) + } + + const hourMS = int64(60 * 60 * 1000) + qualified := StrategyState{ + PoolBalanceCoins: 10_000, + PendingSpendJackpotTokens: 1, + SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, + SettledRoundPending: true, + GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, + UserRoundRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 95}, + User48HourRTP: StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500}, + UserRegisteredAtMS: hourMS, + QualificationClosedAtMS: 49 * hourMS, + } + compensated, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(83)) + if err != nil { + t.Fatal(err) + } + if compensated.JackpotMechanism != StrategyJackpotMechanismRTPCompensation || compensated.NextState.PendingSpendJackpotTokens != 1 || compensated.NextState.SettledRoundPending || + compensated.Trace.SpendMilestoneTokensBefore != 1 || compensated.Trace.SpendMilestoneTokensAfter != 1 { + t.Fatalf("48h qualification must win first without consuming spend token: %+v", compensated) + } + + // A failed personal RTP condition consumes only the one-shot 48h qualification. The same child + // draw may still use the independent spend promise because registration and personal RTP do not gate it. + qualified.UserRoundRTP.PayoutCoins = 96 + spendFallback, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(84)) + if err != nil { + t.Fatal(err) + } + if spendFallback.JackpotMechanism != StrategyJackpotMechanismMilestone || spendFallback.NextState.PendingSpendJackpotTokens != 0 || spendFallback.NextState.SettledRoundPending { + t.Fatalf("ineligible 48h path should fall through to spend jackpot: %+v", spendFallback) + } + + qualified.PoolBalanceCoins = 49 + qualified.UserRoundRTP.PayoutCoins = 95 + poolBlocked, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(85)) + if err != nil { + t.Fatal(err) + } + if poolBlocked.Jackpot || poolBlocked.NextState.SettledRoundPending || poolBlocked.NextState.PendingSpendJackpotTokens != 1 { + t.Fatalf("48h is one-shot while pool-blocked spend token remains: %+v", poolBlocked) + } +} + +func TestLuckyGiftStrategySpendJackpotSharesAllSixInclusiveRiskCaps(t *testing.T) { + config := strategyTestConfig() + config.SettledRoundRTPEligibility = true + config.JackpotMechanism1Enabled = true + config.JackpotMechanism2Enabled = true + config.MilestoneSpendCoins = 50 + config.GlobalRTPMaxPPM = 980_000 + config.UserRoundRTPMaxPPM = 960_000 + config.User48HourRTPMaxPPM = 960_000 + config.DailyJackpotLimit = 5 + setters := []struct { + name string + set func(*StrategyRiskCapacity, int64) + }{ + {name: "single", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }}, + {name: "user-hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }}, + {name: "user-day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }}, + {name: "device-day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }}, + {name: "room-hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }}, + {name: "anchor-day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }}, + } + for _, test := range setters { + t.Run(test.name, func(t *testing.T) { + state := StrategyState{ + PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, + SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, + } + capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 10_000, UserHourCoins: 10_000, UserDayCoins: 10_000, DeviceDayCoins: 10_000, RoomHourCoins: 10_000, AnchorDayCoins: 10_000} + test.set(&capacity, 1_999) + input := strategyTestInput(10) + input.RiskCapacity = capacity + blocked, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(90)) + if err != nil { + t.Fatal(err) + } + if blocked.Jackpot || blocked.NextState.PendingSpendJackpotTokens != 1 { + t.Fatalf("capacity below minimum jackpot must retain token: %+v", blocked) + } + + test.set(&capacity, 2_000) + input.RiskCapacity = capacity + paid, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(91)) + if err != nil { + t.Fatal(err) + } + if !paid.Jackpot || paid.PayoutCoins != 2_000 || paid.NextState.PendingSpendJackpotTokens != 0 { + t.Fatalf("capacity equality must permit the 200x jackpot: %+v", paid) + } + }) + } +} + +func TestLuckyGiftStrategyDailyJackpotLimitDoesNotConstrainOrdinarySameMultiplier(t *testing.T) { config := strategyTestConfig() config.JackpotMechanism2Enabled = true config.DailyJackpotLimit = 5 state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5} - // Milestone is blocked first; ordinary draw then selects 200x, removes it for - // the same hard daily limit, removes zero, and redraws an allowed 5x. - random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000, 600_000}, bounds: []int64{1_000_000, 960_000}} + // 大奖 token 受每日上限阻断,但普通概率里的同倍率 200x 仍按原权重命中; + // 倍率相同不代表来源相同,也不能消耗大奖次数或 token。 + random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}} decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random) if err != nil { t.Fatal(err) } - if decision.Jackpot || decision.SelectedTier.ID != "5x" || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 { + if decision.Jackpot || decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 { t.Fatalf("daily-limit decision=%+v", decision) } - if decision.Trace.Draws[0].RemovedReason != StrategyReasonDailyJackpotLimit { - t.Fatalf("daily-limit removal=%+v", decision.Trace.Draws) + if len(decision.Trace.Draws) != 1 || decision.Trace.Draws[0].RemovedReason != "" { + t.Fatalf("ordinary overlap must not be removed by jackpot cap: %+v", decision.Trace.Draws) } } @@ -422,6 +598,24 @@ func TestDefaultLuckyGiftStrategyNominalEVIsExactlyNinetyEightPercent(t *testing } } +func TestLuckyGiftStrategyRejectsJackpotTierWithOrdinaryWeight(t *testing.T) { + for _, mutate := range []func(*StrategyTier){ + func(tier *StrategyTier) { tier.BaseWeightPPM = 1 }, + func(tier *StrategyTier) { tier.StageWeightPPM = map[string]int64{StageNovice: 1} }, + } { + config := DefaultLuckyGiftStrategyConfig() + for index := range config.Tiers { + if config.Tiers[index].Jackpot { + mutate(&config.Tiers[index]) + break + } + } + if _, _, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{}, strategyTestInput(10)); err == nil { + t.Fatal("jackpot tier carrying an ordinary weight should be rejected") + } + } +} + func TestLuckyGiftStrategyCombinationPriority(t *testing.T) { config := strategyTestConfig() config.JackpotMechanism1Enabled = true diff --git a/services/lucky-gift-service/internal/service/luckygift/config.go b/services/lucky-gift-service/internal/service/luckygift/config.go index 77cfbc7f..211602c4 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config.go +++ b/services/lucky-gift-service/internal/service/luckygift/config.go @@ -38,11 +38,17 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { HighWaterNonzeroFactorPPM: 1_300_000, RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000, - JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000}, - JackpotGlobalRTPMaxPPM: 980_000, - JackpotUserDayRTPMaxPPM: 960_000, - JackpotUser72hRTPMaxPPM: 960_000, - MaxJackpotHitsPerUserDay: 5, + // 这里只生成 rule_version=0 的未发布草稿,因此使用产品新建默认 150%/70%。已发布 + // 历史版本始终从不可变规则表读取其 0/100% sentinel,不会因本默认值被自动升级。 + User24HourRTPThresholdPPM: 1_500_000, + User24HourOrdinaryWinFactorPPM: 700_000, + JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000}, + JackpotGlobalRTPMaxPPM: 980_000, + // Deprecated aliases keep old in-process draft builders source-compatible. New API requests populate + // the canonical round/48h fields and therefore opt into the settled-window algorithm. + JackpotUserDayRTPMaxPPM: 960_000, + JackpotUser72hRTPMaxPPM: 960_000, + MaxJackpotHitsPerUserDay: 5, // 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。 NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, @@ -94,6 +100,29 @@ func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int6 } func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig { + if config.User24HourRTPThresholdPPM == 0 && config.User24HourOrdinaryWinFactorPPM == 0 { + // proto3 标量无法区分旧客户端“未发送”和显式 0;将 0/0 收敛到唯一的禁用 sentinel, + // 使升级前规则重发后仍保持原中奖概率,而不是意外启用或因新增字段发布失败。 + config.User24HourOrdinaryWinFactorPPM = ppmScale + } + // The new contract names are the only persisted/runtime meaning. During a rolling source upgrade an + // old caller may still fill the day/72h aliases; copy them once without pretending that caller opted + // into the settled-round algorithm. A config read from the new database columns sets the marker. + if config.JackpotUserRoundRTPMaxPPM > 0 || config.JackpotUser48hRTPMaxPPM > 0 { + config.SettledRoundRTPEligibility = true + } + if config.JackpotUserRoundRTPMaxPPM == 0 { + config.JackpotUserRoundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM + } + if config.JackpotUser48hRTPMaxPPM == 0 { + config.JackpotUser48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM + } + if config.JackpotUserDayRTPMaxPPM == 0 { + config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM + } + if config.JackpotUser72hRTPMaxPPM == 0 { + config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM + } config.PoolID = normalizePoolID(config.PoolID) config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion)) if config.StrategyVersion == "" { @@ -116,6 +145,7 @@ func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig { } func validateRuleConfig(config domain.RuleConfig) error { + config = normalizeRuleConfig(config) if normalizePoolID(config.PoolID) == "" { return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required") } @@ -197,12 +227,21 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai if config.RechargeBoostWindowMS <= 0 || config.RechargeBoostFactorPPM <= ppmScale { return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge boost window must be positive and factor must exceed 100%") } + if config.User24HourRTPThresholdPPM == 0 { + if config.User24HourOrdinaryWinFactorPPM != ppmScale { + return xerr.New(xerr.InvalidArgument, "dynamic_v3 disabled 24-hour RTP downweight must keep ordinary win factor at 100%") + } + } else if config.User24HourRTPThresholdPPM < 0 || config.User24HourOrdinaryWinFactorPPM <= 0 || config.User24HourOrdinaryWinFactorPPM > ppmScale { + return xerr.New(xerr.InvalidArgument, "dynamic_v3 24-hour RTP threshold must be positive and ordinary win factor must be greater than 0% and no more than 100%") + } if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil { return err } + // 倍率只是返奖金额,不再承担“大奖身份”。同一倍率可以同时存在于普通概率表和补偿大奖集合; + // 运行适配器会生成两个独立 tier,避免普通随机绕过补偿门槛或误占每日大奖次数。 if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale || - config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM || - config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM { + config.JackpotUserRoundRTPMaxPPM <= 0 || config.JackpotUserRoundRTPMaxPPM > config.JackpotGlobalRTPMaxPPM || + config.JackpotUser48hRTPMaxPPM <= 0 || config.JackpotUser48hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM { return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot RTP limits must be positive; user limits cannot exceed the global limit") } if config.MaxJackpotHitsPerUserDay <= 0 { @@ -222,6 +261,8 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai return nil } if config.JackpotSpendThresholdCoins <= 0 { + // Enabled V3 runs the 48h compensation and spend-milestone strategies together. A zero amount + // would silently disable one of the two published promises, so only disabled drafts may keep 0. return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot cumulative spend threshold coins must be configured as a positive amount before enabling") } return validateDynamicPayoutCaps(config, true) diff --git a/services/lucky-gift-service/internal/service/luckygift/config_test.go b/services/lucky-gift-service/internal/service/luckygift/config_test.go index a86a3317..940e7d3a 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config_test.go +++ b/services/lucky-gift-service/internal/service/luckygift/config_test.go @@ -106,6 +106,27 @@ func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) { } } +func TestValidateDynamicV3AllowsOrdinaryProbabilityAtJackpotMultiplier(t *testing.T) { + config := DefaultRuleConfig("lalu", "lucky") + for stageIndex := range config.Stages { + stage := &config.Stages[stageIndex] + for tierIndex := range stage.Tiers { + if stage.Tiers[tierIndex].MultiplierPPM == 1_000_000 { + stage.Tiers[tierIndex].BaseWeightPPM -= 100 + } + } + // 200x 同时位于 jackpot_multipliers 与普通概率表是合法配置;这里用极小权重 + // 保持静态 RTP 位于控制带内,确保测试只验证“来源可重叠”这一条边界。 + stage.Tiers = append(stage.Tiers, domain.RuleTier{ + Stage: stage.Stage, TierID: stage.Stage + "_ordinary_200x", + MultiplierPPM: 200_000_000, BaseWeightPPM: 100, HighWaterOnly: true, Enabled: true, + }) + } + if err := validateRuleConfig(config); err != nil { + t.Fatalf("ordinary and jackpot sources at the same multiplier should validate independently: %v", err) + } +} + func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) { config := DefaultRuleConfig("lalu", "lucky") config.StrategyVersion = "" diff --git a/services/lucky-gift-service/internal/service/luckygift/service.go b/services/lucky-gift-service/internal/service/luckygift/service.go index 4b2370a2..1069196f 100644 --- a/services/lucky-gift-service/internal/service/luckygift/service.go +++ b/services/lucky-gift-service/internal/service/luckygift/service.go @@ -42,6 +42,8 @@ type Repository interface { ExecuteExternalGiftDraw(ctx context.Context, cmd domain.ExternalDrawCommand, nowMS int64) (domain.ExternalDrawResult, error) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) + ListLuckyGiftUserProfiles(ctx context.Context, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error) + GetLuckyGiftUserProfile(ctx context.Context, query domain.UserProfileQuery, nowMS int64) (domain.UserProfileDetail, bool, error) ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error) GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error) @@ -59,6 +61,12 @@ type Repository interface { MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error } +// luckyGiftUserProfileRefresher 是 MySQL 读模型的可选历史回灌能力。测试仓储和其他最小实现 +// 不需要为了后台异步任务扩宽业务接口;生产仓储实现后由 worker 自动发现并运行。 +type luckyGiftUserProfileRefresher interface { + RefreshLuckyGiftUserProfiles(ctx context.Context, nowMS int64, batchSize int) (int, error) +} + // WalletClient 是幸运礼物返奖唯一账务依赖;wallet-service 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 IM。 type WalletClient interface { CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error) @@ -283,6 +291,7 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) { ticker := time.NewTicker(options.PollInterval) defer ticker.Stop() nextStatsRefresh := time.Time{} + nextUserProfileRefresh := time.Time{} for { if _, err := s.ProcessPendingDrawOutbox(ctx, options); err != nil && ctx.Err() == nil { // 批处理失败不删除 outbox;锁过期后下一轮或其他实例会继续补偿。 @@ -298,6 +307,24 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) { } nextStatsRefresh = now.Add(options.StatsInterval) } + if refresher, ok := s.repository.(luckyGiftUserProfileRefresher); ok && + options.StatsInterval > 0 && (nextUserProfileRefresh.IsZero() || !now.Before(nextUserProfileRefresh)) { + // 画像历史回灌独立于 admin/databi 的十分钟快照:每轮只串行处理 5000 条,保持事务和内存有界; + // 有进度就在下一个 worker poll 继续,直到全部游标返回 0 才进入低频等待。 + const profileBatchSize = 5000 + processed, refreshErr := refresher.RefreshLuckyGiftUserProfiles(ctx, now.UnixMilli(), profileBatchSize) + switch { + case refreshErr != nil: + if ctx.Err() == nil { + logx.Error(ctx, "lucky_gift_user_profile_refresh_failed", refreshErr, slog.String("worker_id", options.WorkerID)) + } + nextUserProfileRefresh = now.Add(options.StatsInterval) + case processed > 0: + nextUserProfileRefresh = now.Add(options.PollInterval) + default: + nextUserProfileRefresh = now.Add(options.StatsInterval) + } + } select { case <-ctx.Done(): return @@ -779,6 +806,52 @@ func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (d return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query)) } +func (s *Service) ListUserProfiles(ctx context.Context, query domain.UserProfileQuery) ([]domain.UserProfile, int64, string, bool, int64, error) { + if err := s.requireRepository(); err != nil { + return nil, 0, "", false, 0, err + } + query, err := normalizeUserProfileQuery(query) + if err != nil { + return nil, 0, "", false, 0, err + } + snapshotAtMS := s.now().UTC().UnixMilli() + profiles, total, nextCursor, hasMore, err := s.repository.ListLuckyGiftUserProfiles(ctx, query, snapshotAtMS) + return profiles, total, nextCursor, hasMore, snapshotAtMS, err +} + +func (s *Service) GetUserProfile(ctx context.Context, query domain.UserProfileQuery) (domain.UserProfileDetail, int64, error) { + if err := s.requireRepository(); err != nil { + return domain.UserProfileDetail{}, 0, err + } + query, err := normalizeUserProfileQuery(query) + if err != nil { + return domain.UserProfileDetail{}, 0, err + } + // 详情入口必须选择且只选择一种真实身份键;否则空 identity_type 会退化成“取列表第一行”, + // 错配的另一种 key 还可能让运营打开与请求不一致的用户画像。 + switch query.IdentityType { + case domain.UserProfileIdentityInternal: + if query.InternalUserID <= 0 || query.ExternalUserID != "" { + return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "internal lucky gift profile requires only user_id") + } + case domain.UserProfileIdentityExternal: + if query.ExternalUserID == "" || query.InternalUserID != 0 { + return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "external lucky gift profile requires only external_user_id") + } + default: + return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "lucky gift profile identity_type must be internal or external") + } + snapshotAtMS := s.now().UTC().UnixMilli() + detail, found, err := s.repository.GetLuckyGiftUserProfile(ctx, query, snapshotAtMS) + if err != nil { + return domain.UserProfileDetail{}, 0, err + } + if !found { + return domain.UserProfileDetail{}, 0, xerr.New(xerr.NotFound, "lucky gift user profile not found") + } + return detail, snapshotAtMS, nil +} + func (s *Service) ListPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) { if err := s.requireRepository(); err != nil { return nil, err @@ -855,6 +928,53 @@ func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery { return query } +func normalizeUserProfileQuery(query domain.UserProfileQuery) (domain.UserProfileQuery, error) { + query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode)) + if query.AppCode == "" { + return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift user profile app_code is required") + } + if strings.TrimSpace(query.PoolID) == "" { + return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift user profile pool_id is required") + } + query.PoolID = normalizePoolID(query.PoolID) + query.IdentityType = strings.ToLower(strings.TrimSpace(query.IdentityType)) + if query.IdentityType != "" && query.IdentityType != domain.UserProfileIdentityInternal && query.IdentityType != domain.UserProfileIdentityExternal { + return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "unsupported lucky gift profile identity_type") + } + query.ExternalUserID = strings.TrimSpace(query.ExternalUserID) + if utf8.RuneCountInString(query.ExternalUserID) > 128 { + return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "external_user_id is too long") + } + query.Stage = strings.ToLower(strings.TrimSpace(query.Stage)) + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy)) + query.SortDirection = strings.ToLower(strings.TrimSpace(query.SortDirection)) + query.Cursor = strings.TrimSpace(query.Cursor) + query.JackpotCursor = strings.TrimSpace(query.JackpotCursor) + if len(query.Cursor) > 2048 || len(query.JackpotCursor) > 2048 { + return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift profile cursor is too long") + } + if query.Page <= 0 { + query.Page = 1 + } + if query.PageSize <= 0 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + if query.JackpotPage <= 0 { + query.JackpotPage = 1 + } + if query.JackpotPageSize <= 0 { + query.JackpotPageSize = 20 + } + if query.JackpotPageSize > 50 { + query.JackpotPageSize = 50 + } + return query, nil +} + type luckyGiftDrawnPayload struct { EventType string `json:"event_type"` EventID string `json:"event_id"` diff --git a/services/lucky-gift-service/internal/storage/mysql/external_repository.go b/services/lucky-gift-service/internal/storage/mysql/external_repository.go index 6452b4b9..c76370fd 100644 --- a/services/lucky-gift-service/internal/storage/mysql/external_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/external_repository.go @@ -95,14 +95,16 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext poolID := luckyPoolID(cmd.PoolID) // 外部用户与内部用户共用实验分流,只是 userKey 前缀不同;同一 external_user_id 的分组同样确定。 - rule, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, cmd.AppCode, poolID, domain.ExperimentExternalUserKey(cmd.ExternalUserID), false) + // 与内部开奖一致必须先锁规则行(FOR UPDATE):REPEATABLE READ 下无锁读会钉住 + // 上一个用户结算提交之前的快照,导致滚动 48 小时分子/分母漏掉已完成的开奖。 + rule, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, cmd.AppCode, poolID, domain.ExperimentExternalUserKey(cmd.ExternalUserID), true) if err != nil { return domain.ExternalDrawResult{}, err } if exists && rule.StrategyVersion == domain.StrategyDynamicV3 { if cmd.PaidAtMS <= 0 { // 外部 dynamic_v3 同样只能消费调用方已完成扣费的事实时间;请求到达 lucky 的时间 - // 不是支付时间,不能用于充值短窗、UTC 日/小时或精确 72h 风控归属。 + // 不是支付时间,不能用于充值短窗、UTC 日/小时或精确滚动 48h 风控归属。 return domain.ExternalDrawResult{}, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift paid_at_ms is required") } } else if cmd.PaidAtMS <= 0 { @@ -151,6 +153,29 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext ); err != nil { return domain.ExternalDrawResult{}, err } + // dynamic_v3 已由逐抽 item 同事务写画像;fixed_v2 和停用规则没有 item,必须用聚合事实补一条,二者不能重复累计。 + if !(exists && rule.Enabled && rule.StrategyVersion == domain.StrategyDynamicV3) { + strategyVersion := strings.TrimSpace(rule.StrategyVersion) + if strategyVersion == "" { + strategyVersion = domain.StrategyFixedV2 + } + fact := luckyUserProfileFact{ + AppCode: cmd.AppCode, SourceType: luckyUserProfileSourceExternalLegacy, SourceDrawID: drawID, + RequestID: cmd.RequestID, PoolID: poolID, IdentityType: domain.UserProfileIdentityExternal, + IdentityKey: cmd.ExternalUserID, StrategyUserID: externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID), + ExternalUserID: cmd.ExternalUserID, RuleVersion: ruleVersion, StrategyVersion: strategyVersion, + // fixed_v2 的外部请求把 gift_count 合并成一次随机:流水是整单 total_amount, + // 但 paid_draws/loss_streak 只推进一次。画像必须沿用同一口径,不能把一条结果放大成 N 次命中。 + Stage: selected.Stage, DrawCount: 1, WagerCoins: cmd.TotalAmount, + PayoutCoins: rewardAmount, BaseRewardCoins: rewardAmount, MultiplierPPM: selected.MultiplierPPM, + RewardStatus: row.RewardStatus, TierID: selected.TierID, OccurredAtMS: cmd.PaidAtMS, CreatedAtMS: nowMS, + } + fact.HitType = luckyUserProfileHitType(fact.PayoutCoins, "", nil, nil) + fact.HighOrdinary = selected.HighMultiplier && fact.HitType == domain.UserProfileHitOrdinary + if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, []luckyUserProfileFact{fact}, nowMS); err != nil { + return domain.ExternalDrawResult{}, err + } + } payload, err := json.Marshal(map[string]any{ "event_type": domain.EventTypeExternalLuckyGiftDrawn, "event_id": domain.EventTypeExternalLuckyGiftDrawn + ":" + drawID, @@ -256,7 +281,10 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, if err := r.applyExternalLuckyEconomy(ctx, tx, cmd.AppCode, config, drawCommand, candidate, globalWindow, giftWindow, basePool, nextCumulativeWager, nextEquivalentDraws, nowMS); err != nil { return externalDrawCandidate{}, 0, err } - return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil + return externalDrawCandidate{ + TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM, + Stage: experiencePool, HighMultiplier: candidate.HighMultiplier, + }, candidate.effectiveReward(), nil } // executeExternalDynamicGiftEconomy 让外部 App 复用与房间送礼完全相同的 dynamic_v3 内核。 @@ -279,17 +307,18 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx * } externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID) drawCommand := domain.DrawCommand{ - CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID, - PoolID: config.PoolID, - UserID: externalUserID, - DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID), - RoomID: externalLuckyScopeID(cmd.AppCode), - AnchorID: externalLuckyAnchorID(cmd.AppCode), - GiftID: config.GiftID, - GiftCount: int32(cmd.GiftCount), - CoinSpent: cmd.TotalAmount, - PaidAtMS: cmd.PaidAtMS, - GiftIncomeCoins: split.AnchorReturnCoins, + CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID, + PoolID: config.PoolID, + UserID: externalUserID, + DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID), + RoomID: externalLuckyScopeID(cmd.AppCode), + AnchorID: externalLuckyAnchorID(cmd.AppCode), + GiftID: config.GiftID, + GiftCount: int32(cmd.GiftCount), + CoinSpent: cmd.TotalAmount, + PaidAtMS: cmd.PaidAtMS, + UserRegisteredAtMS: cmd.UserRegisteredAtMS, + GiftIncomeCoins: split.AnchorReturnCoins, } // 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成, // 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。 @@ -309,6 +338,9 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx * reward += result.EffectiveRewardCoins } candidate := externalDrawCandidate{TierID: "dynamic_v3_batch"} + if len(results) > 0 { + candidate.Stage = results[len(results)-1].ExperiencePool + } if len(results) == 1 { candidate.TierID = results[0].SelectedTierID candidate.MultiplierPPM = results[0].MultiplierPPM @@ -392,8 +424,10 @@ func (r *Repository) getExternalGiftDrawForUpdate(ctx context.Context, tx *sql.T } type externalDrawCandidate struct { - TierID string - MultiplierPPM int64 + TierID string + MultiplierPPM int64 + Stage string + HighMultiplier bool } func selectExternalDrawCandidate(rule domain.RuleConfig) (externalDrawCandidate, error) { diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go index 8cbe6b54..8b3d45cc 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "strings" + "time" "hyapp/pkg/appcode" "hyapp/pkg/idgen" @@ -30,7 +31,12 @@ type luckyDynamicBatchState struct { RiskCounters map[string]luckyDynamicRiskCounter DayState luckyUserStrategyDay HourState luckyUserRTPHour - Strategy domain.StrategyState + UserWindow luckyUserRTPWindow + UserWindows []luckyUserRTPWindow + // QualificationWindow is the one closed round consumed by the next personal subdraw. It is + // persisted after decision so pool/risk rejection cannot leave an endlessly reusable promise. + QualificationWindow *luckyUserRTPWindow + Strategy domain.StrategyState } // luckyDynamicPersistence 把内部钱包结算与外部 App 自行结算区分开。 @@ -48,10 +54,18 @@ func (p luckyDynamicPersistence) external() bool { // 所有共享行只锁一次,gift_count=N 在内存里严格推进 N 次,事务尾再批量落 draw facts 和聚合钱包 outbox。 func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, drawCount int32, rule domain.RuleConfig, persistence luckyDynamicPersistence, nowMS int64) ([]domain.DrawResult, error) { if cmd.PaidAtMS <= 0 { - // dynamic_v3 的充值后五分钟、UTC 日/小时和精确 72h 都必须归属 wallet owner 的扣费事实时间。 + // dynamic_v3 的充值后五分钟、UTC 日/小时和精确滚动 48h 都必须归属 wallet owner 的扣费事实时间。 // 外部调用也必须传它自己的扣费事实;缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。 return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required") } + // Account creation is an eligibility-only owner fact: an unknown value or a + // timestamp later than this payment cannot describe the paying account. Keep + // ordinary drawing available, but freeze 0 into the round so compensation + // remains fail-closed even if that malformed timestamp would age past 48 hours + // before the platform window eventually settles. + if cmd.UserRegisteredAtMS <= 0 || cmd.UserRegisteredAtMS > cmd.PaidAtMS { + cmd.UserRegisteredAtMS = 0 + } // 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。 // 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。 deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID) @@ -69,17 +83,24 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s } strategyConfig := luckyDynamicStrategyConfig(config) referencePrice := config.GiftPrice + paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli() userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS) if err != nil { return nil, err } windowScopeID := luckyRuleWindowScopeID(config) - globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS) + globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS) if err != nil { return nil, err } - giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, nowMS) + // Consumption milestones use the newest fully closed platform sample, independent of whether + // this user participated in that round. Missing history deliberately leaves a zero denominator. + spendGlobalWindow, hasSpendGlobalWindow, err := r.getLatestClosedLuckyDynamicRTPWindow(ctx, tx, appCode, windowScopeID, globalWindow.WindowIndex) + if err != nil { + return nil, err + } + giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS) if err != nil { return nil, err } @@ -100,7 +121,29 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s if err != nil { return nil, err } - rolling72, err := r.getLuckyUserRolling72HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, luckyPaidTime(cmd, nowMS).UnixMilli()) + rolling24HourRTP := domain.StrategyRTP{} + if strategyConfig.User24HourRTPThresholdPPM > 0 && + strategyConfig.User24HourOrdinaryWinFactorPPM > 0 && + strategyConfig.User24HourOrdinaryWinFactorPPM < domain.StrategyPPMScale { + // One exact [paid_at+1ms-24h, paid_at+1ms) read covers the whole gift_count batch. Existing + // persisted events at paid_at_ms are included, while this batch is not inserted until commit; + // finalizeStrategyDecision advances the snapshot after every subdraw entirely in memory. + rolling24HourRTP, err = r.getLuckyUserRolling24HourRTP( + ctx, tx, appCode, config.PoolID, cmd.UserID, luckyExclusiveEndMS(paidAtMS), + ) + if err != nil { + // Failing closed here rolls back the complete draw transaction. Silently using zero would + // let a user bypass the control whenever the aggregate source is unavailable. + return nil, err + } + } + userWindow, err := r.getOrCreateLuckyUserRTPWindow(ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS) + if err != nil { + return nil, err + } + qualificationWindow, qualificationGlobal, hasQualification, err := r.getLatestPendingLuckyUserRTPWindow( + ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, windowScopeID, nowMS, + ) if err != nil { return nil, err } @@ -125,29 +168,41 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s RiskCounters: riskCounters, DayState: dayState, HourState: hourState, + UserWindow: userWindow, + UserWindows: make([]luckyUserRTPWindow, 0, 2), Strategy: domain.StrategyState{ - PoolBalanceCoins: basePool.Balance, - ConsecutiveZeroDraws: userState.LossStreak, - PendingMilestoneTokens: userState.PendingJackpotTokens, - UserDailyJackpotWins: dayState.JackpotHits, - UserDaySpendCoins: dayState.SpendCoins, - GlobalRTP: domain.StrategyRTP{WagerCoins: globalWindow.WagerCoins, PayoutCoins: globalWindow.ActualPayoutCoins}, - UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins}, - User72HourRTP: rolling72, - Version: userState.PaidDraws, + PoolBalanceCoins: basePool.Balance, + ConsecutiveZeroDraws: userState.LossStreak, + // The old pending_jackpot_tokens column is intentionally not copied. It is cleared in the same + // transaction while only the new column can authorize the restored spend strategy. + PendingSpendJackpotTokens: userState.PendingSpendJackpotTokens, + UserDailyJackpotWins: dayState.JackpotHits, + UserDaySpendCoins: dayState.SpendCoins, + UserRegisteredAtMS: cmd.UserRegisteredAtMS, + UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins}, + User24HourRTP: rolling24HourRTP, + Version: userState.PaidDraws, }, } + if hasSpendGlobalWindow { + state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: spendGlobalWindow.WagerCoins, PayoutCoins: spendGlobalWindow.ActualPayoutCoins} + } + if hasQualification { + if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil { + return nil, err + } + } for index := int32(1); index <= drawCount; index++ { - // gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满, - // 本抽先创建下一窗口并让 mechanism1 使用新窗口 RTP,不能等整批结束后才滚动。 - if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config.SettlementWindowWager, &state, nowMS); err != nil { - return nil, err - } unitCommand := cmd unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount) unitCommand.GiftCount = 1 unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index) + // gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满, + // 本抽先创建下一窗口并把刚闭窗的用户快照冻结成一次性资格,不能等整批结束后才结算。 + if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config, unitCommand, &state, nowMS); err != nil { + return nil, err + } unitSplit := unitSplits[index-1] unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins if unitCommand.CoinSpent <= 0 { @@ -199,36 +254,53 @@ func normalizeLuckyDynamicDeviceID(value string) (string, error) { return value, nil } -func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, controlWager int64, state *luckyDynamicBatchState, nowMS int64) error { - if controlWager <= 0 { +func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, state *luckyDynamicBatchState, nowMS int64) error { + if config.SettlementWindowWager <= 0 { return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive") } - // 图中“结算窗口流水”是金币阈值,不是抽数。整笔单抽归属进入时所在窗口;本抽把流水推过阈值后,下一抽才开启新窗口,避免拆分一次中奖事实。 - if state.GlobalWindow.WagerCoins >= controlWager { + paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli() + // 图中“结算窗口流水”是金币阈值,不是抽数。越过阈值的完整单抽归属旧窗口;窗口已在 + // 上一抽结束时冻结,本抽先创建下一窗口并消费刚结算用户轮次的一次性资格。 + if state.GlobalWindow.Status == "closed" || state.GlobalWindow.WagerCoins >= config.SettlementWindowWager { closed := state.GlobalWindow - closed.Status = "closed" + if closed.Status != "closed" { + closed.Status = "closed" + closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS) + } + // The next subdraw in this batch must immediately see the just-closed platform sample. The draw + // that completed the window was already selected before this assignment and cannot use it. + state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: closed.WagerCoins, PayoutCoins: closed.ActualPayoutCoins} + closedUser := state.UserWindow + if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, closed, closedUser, state, nowMS); err != nil { + return err + } state.GlobalWindows = append(state.GlobalWindows, closed) - next, err := r.createLuckyRTPWindow( + state.UserWindows = append(state.UserWindows, closedUser) + next, err := r.createLuckyRTPWindowAt( ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1, - closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS, + closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, paidAtMS, nowMS, ) if err != nil { return err } state.GlobalWindow = next - // 大奖机制一只看当前结算窗口;窗口滚动后分母为 0,必须先积累新样本,不能沿用旧窗口资格。 - state.Strategy.GlobalRTP = domain.StrategyRTP{ - WagerCoins: next.WagerCoins, - PayoutCoins: next.ActualPayoutCoins, + state.UserWindow, err = r.getOrCreateLuckyUserRTPWindow( + ctx, tx, appCode, config.PoolID, config.RuleVersion, next.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS, + ) + if err != nil { + return err } } - if state.GiftWindow.WagerCoins >= controlWager { + if state.GiftWindow.Status == "closed" || state.GiftWindow.WagerCoins >= config.SettlementWindowWager { closed := state.GiftWindow - closed.Status = "closed" + if closed.Status != "closed" { + closed.Status = "closed" + closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS) + } state.GiftWindows = append(state.GiftWindows, closed) - next, err := r.createLuckyRTPWindow( + next, err := r.createLuckyRTPWindowAt( ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1, - closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS, + closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, paidAtMS, nowMS, ) if err != nil { return err @@ -238,6 +310,57 @@ func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, ap return nil } +func (r *Repository) prepareLuckySettledRoundQualification(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, global luckyRTPWindow, user luckyUserRTPWindow, state *luckyDynamicBatchState, nowMS int64) error { + if global.WindowIndex != user.WindowIndex || user.UserID != cmd.UserID { + return xerr.New(xerr.Internal, "dynamic lucky gift user RTP window is not aligned with platform settlement") + } + rolling := domain.StrategyRTP{WagerCoins: user.Rolling48hWagerCoins, PayoutCoins: user.Rolling48hPayoutCoins} + if rolling.WagerCoins == 0 && global.ClosedAtMS > 0 { + rollingEndMS := luckyExclusiveEndMS(global.ClosedAtMS) + var err error + rolling, err = r.getLuckyUserRolling48HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, rollingEndMS) + if err != nil { + return err + } + startMS := rollingEndMS - int64(48*time.Hour/time.Millisecond) + for _, record := range state.Records { + occurredAtMS := luckyPaidTime(record.Command, nowMS).UnixMilli() + if occurredAtMS < startMS || occurredAtMS >= rollingEndMS { + continue + } + var addErr error + rolling.WagerCoins, addErr = luckyCheckedAdd(rolling.WagerCoins, record.Command.CoinSpent) + if addErr != nil { + return addErr + } + rolling.PayoutCoins, addErr = luckyCheckedAdd(rolling.PayoutCoins, record.Candidate.effectiveReward()) + if addErr != nil { + return addErr + } + } + user.Rolling48hWagerCoins = rolling.WagerCoins + user.Rolling48hPayoutCoins = rolling.PayoutCoins + } + qualification := user + state.QualificationWindow = &qualification + state.Strategy.SettledRoundPending = true + state.Strategy.GlobalRTP = domain.StrategyRTP{WagerCoins: global.WagerCoins, PayoutCoins: global.ActualPayoutCoins} + state.Strategy.UserRoundRTP = domain.StrategyRTP{WagerCoins: user.WagerCoins, PayoutCoins: user.PayoutCoins} + state.Strategy.User48HourRTP = rolling + state.Strategy.QualificationClosedAtMS = global.ClosedAtMS + // Maturity is evaluated from the owner snapshot captured while the user participated in the + // closed round, not a value supplied again on the later draw that consumes the qualification. + state.Strategy.UserRegisteredAtMS = user.UserRegisteredAtMS + return nil +} + +func luckyCheckedAdd(left, right int64) (int64, error) { + if right < 0 || left > int64(^uint64(0)>>1)-right { + return 0, xerr.New(xerr.Internal, "dynamic lucky gift RTP aggregate overflow") + } + return left + right, nil +} + func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Config, referencePrice int64, cmd domain.DrawCommand, split domain.StrategyFundSplit, decision domain.StrategyDecision, nowMS int64, state *luckyDynamicBatchState) error { state.PublicIn += split.PublicPoolCoins state.ProfitIn += split.ProfitPoolCoins @@ -250,16 +373,31 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent) state.GlobalWindow.PaidDraws++ state.GlobalWindow.ActualPayoutCoins += decision.PayoutCoins + state.GlobalWindow.LastPaidAtMS = maxInt64(state.GlobalWindow.LastPaidAtMS, luckyPaidTime(cmd, nowMS).UnixMilli()) state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent) state.GiftWindow.PaidDraws++ state.GiftWindow.ActualPayoutCoins += decision.PayoutCoins + state.GiftWindow.LastPaidAtMS = maxInt64(state.GiftWindow.LastPaidAtMS, luckyPaidTime(cmd, nowMS).UnixMilli()) + state.UserWindow.WagerCoins += cmd.CoinSpent + state.UserWindow.PayoutCoins += decision.PayoutCoins + // Reaching the configured wager resets the platform RTP immediately at this draw boundary. The + // complete draw remains in the closed round; the next subdraw is the first member of the new round. + if state.GlobalWindow.WagerCoins >= config.SettlementWindowWager { + state.GlobalWindow.Status = "closed" + state.GlobalWindow.ClosedAtMS = state.GlobalWindow.LastPaidAtMS + } + if state.GiftWindow.WagerCoins >= config.SettlementWindowWager { + state.GiftWindow.Status = "closed" + state.GiftWindow.ClosedAtMS = state.GiftWindow.LastPaidAtMS + } state.UserState.PaidDraws++ state.UserState.CumulativeWagerCoins += cmd.CoinSpent state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice) state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws - state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens - state.DayState.WagerCoins = decision.NextState.UserDayRTP.WagerCoins - state.DayState.PayoutCoins = decision.NextState.UserDayRTP.PayoutCoins + state.UserState.PendingJackpotTokens = 0 + state.UserState.PendingSpendJackpotTokens = decision.NextState.PendingSpendJackpotTokens + state.DayState.WagerCoins += cmd.CoinSpent + state.DayState.PayoutCoins += decision.PayoutCoins state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins state.HourState.WagerCoins += cmd.CoinSpent @@ -278,6 +416,18 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con HighMultiplier: decision.Jackpot || decision.SelectedTier.MultiplierPPM >= 10_000_000, } drawID := idgen.New("lucky_draw") + if state.QualificationWindow != nil { + qualification := *state.QualificationWindow + qualification.AttemptedDrawID = drawID + qualification.EvaluatedAtMS = nowMS + if luckySettledRoundConditionsPassed(decision.Trace) { + qualification.QualificationStatus = "attempted" + } else { + qualification.QualificationStatus = "ineligible" + } + state.UserWindows = append(state.UserWindows, qualification) + state.QualificationWindow = nil + } rewardStatus := luckyInitialRewardStatus(candidate, false) limited := luckyDynamicLimitedReasons(decision.Trace) decisionCopy := decision @@ -297,14 +447,14 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con BasePool: poolSnapshot, StrategyDecision: &decisionCopy, FundSplit: split, - UserDayRTP: decision.NextState.UserDayRTP, - User72HourRTP: decision.NextState.User72HourRTP, + UserRoundRTP: domain.StrategyRTP{WagerCoins: state.UserWindow.WagerCoins, PayoutCoins: state.UserWindow.PayoutCoins}, + User48HourRTP: decision.NextState.User48HourRTP, RewardStatus: rewardStatus, NowMS: nowMS, } state.DrawIDs = append(state.DrawIDs, drawID) state.Records = append(state.Records, record) - globalRTP, _ := decision.NextState.GlobalRTP.RatioPPM() + globalRTP := luckyRTPPPM(globalSnapshot.WagerCoins, globalSnapshot.ActualPayoutCoins) giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins) state.Results = append(state.Results, domain.DrawResult{ DrawID: drawID, @@ -329,6 +479,33 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con return nil } +func luckySettledRoundConditionsPassed(trace domain.DecisionTrace) bool { + required := map[string]bool{ + domain.StrategyConditionSettledRound: false, + domain.StrategyConditionGlobalRTP: false, + domain.StrategyConditionUserRoundRTP: false, + domain.StrategyConditionUser48HourMaturity: false, + domain.StrategyConditionUser48HourRTP: false, + domain.StrategyConditionDailyJackpotLimit: false, + } + seen := make(map[string]bool, len(required)) + for _, condition := range trace.Conditions { + if _, ok := required[condition.Name]; !ok { + continue + } + seen[condition.Name] = true + if !condition.Passed { + return false + } + } + for name := range required { + if !seen[name] { + return false + } + } + return true +} + func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, config domain.Config, state luckyDynamicBatchState, persistence luckyDynamicPersistence, nowMS int64) error { if err := validateLuckyDynamicBatchState(state, cmd.CoinSpent); err != nil { return err @@ -339,24 +516,33 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a if _, err := tx.ExecContext(ctx, ` UPDATE lucky_rtp_windows SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?, - actual_base_payout_coins = ?, carry_ppm = ?, status = ?, updated_at_ms = ? + actual_base_payout_coins = ?, carry_ppm = ?, status = ?, + last_paid_at_ms = ?, closed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`, - window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status, nowMS, + window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status, + window.LastPaidAtMS, window.ClosedAtMS, nowMS, appCode, window.ScopeType, window.ScopeID, window.WindowIndex, ); err != nil { return err } } + // Values are ordered so a later evaluated copy of the same closed row overwrites its earlier + // pending snapshot. Multiple UPDATEs stay bounded by subdraw window crossings, never user count. + for _, userWindow := range append(state.UserWindows, state.UserWindow) { + if err := r.persistLuckyUserRTPWindow(ctx, tx, appCode, userWindow, nowMS); err != nil { + return err + } + } if err := r.updateLuckyDynamicPoolNet(ctx, tx, appCode, luckyBasePoolScopeID(config), state.PublicIn, state.Payout, state.ProfitIn, state.AnchorIn, nowMS); err != nil { return err } if _, err := tx.ExecContext(ctx, ` UPDATE lucky_user_states SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?, - pending_jackpot_tokens = ?, updated_at_ms = ? + pending_jackpot_tokens = 0, pending_spend_jackpot_tokens = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND gift_id = ?`, state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak, - state.UserState.PendingJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID, + state.UserState.PendingSpendJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID, ); err != nil { return err } @@ -387,6 +573,11 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a } func luckyDynamicSelectedWeight(decision domain.StrategyDecision) int64 { + // 大奖 tier 的 stage weight 按设计恒为 0;审计必须记录大奖集合内的选择权重, + // 不能从普通概率 trace 读到 0 后误报为“零概率命中”。 + if decision.Jackpot { + return decision.SelectedTier.JackpotWeight + } for _, item := range decision.Trace.Weights { if item.TierID == decision.SelectedTier.ID { return item.AdjustedWeightPPM @@ -452,5 +643,11 @@ func (r *Repository) insertExternalLuckyGiftDrawItems(ctx context.Context, tx *s return err } } - return nil + facts := make([]luckyUserProfileFact, 0, len(records)) + strategyUserID := externalLuckyUserID(appCode, persistence.ExternalUserID) + for _, record := range records { + facts = append(facts, luckyUserProfileFactFromRecord(record, luckyUserProfileSourceExternalItem, + domain.UserProfileIdentityExternal, persistence.ExternalUserID, persistence.ExternalUserID, strategyUserID)) + } + return r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS) } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go index 8d373b30..c9863001 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go @@ -131,6 +131,8 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) { } assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1) assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now) + assertDynamicSpendBatchRollbackAndRestart(t, repo, schema.DB, ctx, now+10_000) + assertExternalNewUserSpendBatch(t, repo, schema.DB, ctx, now+20_000) externalRule := dynamicMySQLTestRule("external-dynamic") if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil { @@ -244,9 +246,10 @@ func seedDynamicRTPWindowsBelowWagerThreshold(t *testing.T, db *sql.DB, poolID s if _, err := db.Exec(` INSERT INTO lucky_rtp_windows ( app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws, - paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,created_at_ms,updated_at_ms - ) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?)`, - scopeType, scopeID, nowMS, nowMS, + paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status, + started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms + ) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?,0,?,?)`, + scopeType, scopeID, nowMS-1_000, nowMS, nowMS, nowMS, ); err != nil { t.Fatalf("seed %s RTP window: %v", scopeType, err) } @@ -316,6 +319,11 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db t.Fatalf("earn milestone token: %v", err) } assertPendingTokenCount(t, db, rule.PoolID, userID, 1) + // A retired token is deliberately planted after the new token was earned. The next transaction + // must clear it without copying or adding it to the new durable balance. + if _, err := db.Exec(`UPDATE lucky_user_states SET pending_jackpot_tokens=7 WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID); err != nil { + t.Fatalf("seed retired pending token: %v", err) + } if result, err := db.Exec(`UPDATE lucky_pools SET balance=0 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil { t.Fatalf("drain token test pool: %v", err) } else if rows, _ := result.RowsAffected(); rows != 1 { @@ -331,11 +339,27 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db t.Fatalf("retain token across UTC day: %v", err) } assertPendingTokenCount(t, db, rule.PoolID, userID, 1) + var retired int64 + if err := db.QueryRow(`SELECT pending_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID).Scan(&retired); err != nil || retired != 0 { + t.Fatalf("retired token was not lazily cleared: got=%d err=%v", retired, err) + } if result, err := db.Exec(`UPDATE lucky_pools SET balance=1000000 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil { t.Fatalf("refill token test pool: %v", err) } else if rows, _ := result.RowsAffected(); rows != 1 { t.Fatalf("refill token test pool affected=%d, want 1", rows) } + // Freeze the current platform window as a valid <=98% sample. Until this close, the spend token + // correctly remains blocked because an open/current RTP can never authorize a spend jackpot. + if result, err := db.Exec(` + UPDATE lucky_rtp_windows + SET wager_coins=1000000, actual_base_payout_coins=980000, status='closed', + started_at_ms=?, last_paid_at_ms=?, closed_at_ms=? + WHERE app_code='lalu' AND scope_type='pool' AND scope_id LIKE CONCAT(?, ':v%') AND status='open'`, + dayOneMS, dayTwoMS, dayTwoMS, rule.PoolID); err != nil { + t.Fatalf("close platform RTP sample: %v", err) + } else if rows, _ := result.RowsAffected(); rows != 1 { + t.Fatalf("closed platform RTP windows=%d want=1", rows) + } consume := retain consume.CommandID = "token-consume-next-day" consume.PaidAtMS++ @@ -349,11 +373,228 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db assertPendingTokenCount(t, db, rule.PoolID, userID, 0) } +// assertDynamicSpendBatchRollbackAndRestart exercises the production transaction rather than only +// the pure kernel: child 2 earns the token after selection and child 3 consumes it. A trigger fails +// the first attempt at the final fact insert, proving all earlier in-transaction state transitions +// roll back; rebuilding Repository and retrying the same command must then apply the sequence once. +func assertDynamicSpendBatchRollbackAndRestart(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) { + t.Helper() + rule := dynamicMySQLTestRule("dual-spend-batch") + rule.JackpotSpendThresholdCoins = 20 + published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000) + if err != nil { + t.Fatalf("publish dual spend batch rule: %v", err) + } + fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-batch-credit", nowMS-1_900) + seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500) + + command := domain.DrawCommand{ + CommandID: "dual-spend-batch-command", PoolID: published.PoolID, UserID: 4_040, TargetUserID: 1, + DeviceID: "dual-spend-batch-device", RoomID: "dual-spend-batch-room", AnchorID: "dual-spend-batch-anchor", GiftID: "super-lucky", + GiftCount: 3, CoinSpent: 30, GiftIncomeCoins: 0, PaidAtMS: nowMS, + } + const rollbackTrigger = "trg_lucky_dual_spend_rollback" + if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil { + t.Fatalf("drop stale dual spend rollback trigger: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger) }) + if _, err := db.Exec(` + CREATE TRIGGER trg_lucky_dual_spend_rollback + BEFORE INSERT ON lucky_draw_records + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced dual spend rollback'`); err != nil { + t.Fatalf("create dual spend rollback trigger: %v", err) + } + if _, err := repo.ExecuteLuckyGiftDraw(ctx, command, nowMS); err == nil { + t.Fatal("forced persistence failure unexpectedly committed dual spend batch") + } + if _, err := db.Exec("DROP TRIGGER " + rollbackTrigger); err != nil { + t.Fatalf("drop dual spend rollback trigger: %v", err) + } + + var stateRows, drawRows, dayRows, balanceAfterRollback int64 + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&stateRows); err != nil { + t.Fatalf("query rolled-back spend state: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&drawRows); err != nil { + t.Fatalf("query rolled-back spend draws: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=?`, published.PoolID, command.UserID).Scan(&dayRows); err != nil { + t.Fatalf("query rolled-back spend day: %v", err) + } + if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterRollback); err != nil { + t.Fatalf("query rolled-back spend pool: %v", err) + } + if stateRows != 0 || drawRows != 0 || dayRows != 0 || balanceAfterRollback != 1_000_000 { + t.Fatalf("failed spend batch leaked state/draw/day/pool=%d/%d/%d/%d", stateRows, drawRows, dayRows, balanceAfterRollback) + } + + restarted := &Repository{db: db} + first, err := restarted.ExecuteLuckyGiftDraw(ctx, command, nowMS+1) + if err != nil { + t.Fatalf("retry dual spend batch after repository restart: %v", err) + } + assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 1, 3), 0, 0, 0, "") + assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 2, 3), 0, 1, 1, "") + assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 3, 3), 1, 0, 0, domain.StrategyJackpotMechanismMilestone) + + var paidDraws, pendingTokens, daySpend, committedDraws, balanceBeforeReplay int64 + if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil { + t.Fatalf("query committed dual spend state: %v", err) + } + if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&daySpend); err != nil { + t.Fatalf("query committed dual spend day: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&committedDraws); err != nil { + t.Fatalf("query committed dual spend draws: %v", err) + } + if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceBeforeReplay); err != nil { + t.Fatalf("query committed dual spend pool: %v", err) + } + if paidDraws != 3 || pendingTokens != 0 || daySpend != 30 || committedDraws != 3 { + t.Fatalf("committed spend batch state paid/token/day/draws=%d/%d/%d/%d", paidDraws, pendingTokens, daySpend, committedDraws) + } + + replayed, err := (&Repository{db: db}).ExecuteLuckyGiftDraw(ctx, command, nowMS+2) + if err != nil { + t.Fatalf("replay committed dual spend batch: %v", err) + } + var replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay int64 + if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&replayPaidDraws, &replayPendingTokens); err != nil { + t.Fatalf("query replayed dual spend state: %v", err) + } + if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&replayDaySpend); err != nil { + t.Fatalf("query replayed dual spend day: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&replayDraws); err != nil { + t.Fatalf("query replayed dual spend draws: %v", err) + } + if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterReplay); err != nil { + t.Fatalf("query replayed dual spend pool: %v", err) + } + if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayPendingTokens != 0 || replayDaySpend != 30 || replayDraws != 3 || balanceAfterReplay != balanceBeforeReplay { + t.Fatalf("replay duplicated spend batch: first/replay=%s/%s paid/token/day/draws/pool=%d/%d/%d/%d/%d want pool=%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay, balanceBeforeReplay) + } +} + +// assertExternalNewUserSpendBatch proves the external path needs neither recharge facts nor a +// 48-hour-old account: a one-hour-old authenticated user earns at child 2 and spends at child 3. +func assertExternalNewUserSpendBatch(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) { + t.Helper() + rule := dynamicMySQLTestRule("dual-spend-external-new-user") + rule.JackpotSpendThresholdCoins = 20 + published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000) + if err != nil { + t.Fatalf("publish external new-user spend rule: %v", err) + } + fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-external-credit", nowMS-1_900) + seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500) + + command := domain.ExternalDrawCommand{ + AppCode: "lalu", ExternalUserID: "dual-spend-new-external-user", DeviceID: "dual-spend-new-external-device", + RequestID: "dual-spend-new-external-request", GiftCount: 3, UnitAmount: 10, TotalAmount: 30, + Currency: "COIN", PaidAtMS: nowMS, UserRegisteredAtMS: nowMS - int64(time.Hour/time.Millisecond), PoolID: published.PoolID, + } + first, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS) + if err != nil { + t.Fatalf("execute external new-user spend batch: %v", err) + } + assertSpendTraceSnapshot(t, db, "external_lucky_gift_draw_items", "request_id", command.RequestID, 1, 0, 0, domain.StrategyJackpotMechanismMilestone) + + externalUserID := externalLuckyUserID(command.AppCode, command.ExternalUserID) + var paidDraws, pendingTokens, itemCount, aggregateCount int64 + if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil { + t.Fatalf("query external new-user spend state: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&itemCount); err != nil { + t.Fatalf("query external new-user spend items: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&aggregateCount); err != nil { + t.Fatalf("query external new-user spend aggregate: %v", err) + } + if first.RewardAmount <= 0 || paidDraws != 3 || pendingTokens != 0 || itemCount != 3 || aggregateCount != 1 { + t.Fatalf("external new-user spend result reward/paid/token/items/aggregate=%d/%d/%d/%d/%d", first.RewardAmount, paidDraws, pendingTokens, itemCount, aggregateCount) + } + + replayed, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS+1) + if err != nil { + t.Fatalf("replay external new-user spend batch: %v", err) + } + var replayPaidDraws, replayItems int64 + if err := db.QueryRow(`SELECT paid_draws FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&replayPaidDraws); err != nil { + t.Fatalf("query replayed external new-user state: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&replayItems); err != nil { + t.Fatalf("query replayed external new-user items: %v", err) + } + if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayItems != 3 { + t.Fatalf("external retry duplicated spend batch: first/replay=%s/%s paid/items=%d/%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayItems) + } +} + +func fundDynamicSpendTestPool(t *testing.T, repo *Repository, ctx context.Context, poolID, adjustmentID string, nowMS int64) { + t.Helper() + result, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{ + AppCode: "lalu", PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3, + AdjustmentID: adjustmentID, Direction: domain.PoolAdjustmentIn, AmountCoins: 1_000_000, + Reason: "dual spend integration fixture", OperatorAdminID: 1, + }, nowMS) + if err != nil { + t.Fatalf("fund dynamic spend test pool: %v", err) + } + if result.Pool.Balance != 1_000_000 { + t.Fatalf("funded dynamic spend pool balance=%d want=1000000", result.Pool.Balance) + } +} + +func seedClosedDynamicSpendGlobalWindow(t *testing.T, db *sql.DB, rule domain.RuleConfig, nowMS int64) { + t.Helper() + runtimeConfig, err := luckyRuntimeConfigFromRuleConfig(rule) + if err != nil { + t.Fatalf("build dynamic spend runtime config: %v", err) + } + scopeID := luckyRuleWindowScopeID(runtimeConfig) + if _, err := db.Exec(` + INSERT INTO lucky_rtp_windows ( + app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws, + paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status, + started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms + ) VALUES ('lalu','pool',?,1,980000,1000,100,1000,980,980,0,'closed',?,?,?,?,?)`, + scopeID, nowMS-1_000, nowMS, nowMS, nowMS-1_000, nowMS, + ); err != nil { + t.Fatalf("seed closed dynamic spend global window: %v", err) + } +} + +func assertSpendTraceSnapshot(t *testing.T, db *sql.DB, table, keyColumn, key string, wantBefore, wantAfter, wantEarned int64, wantMechanism string) { + t.Helper() + jsonColumn := "candidate_tiers_json" + itemPredicate := "" + if table == "external_lucky_gift_draw_items" { + jsonColumn = "candidate_snapshot_json" + itemPredicate = " AND item_index = 3" + } + query := fmt.Sprintf(` + SELECT + CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_before') AS SIGNED), + CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_after') AS SIGNED), + CAST(JSON_EXTRACT(%s, '$.dynamic_v3.spend_milestone_tokens_earned') AS SIGNED), + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s, '$.dynamic_v3.jackpot_mechanism')), '') + FROM %s WHERE app_code='lalu' AND %s=?%s`, jsonColumn, jsonColumn, jsonColumn, jsonColumn, table, keyColumn, itemPredicate) + var before, after, earned int64 + var mechanism string + if err := db.QueryRow(query, key).Scan(&before, &after, &earned, &mechanism); err != nil { + t.Fatalf("query %s spend trace for %s: %v", table, key, err) + } + if before != wantBefore || after != wantAfter || earned != wantEarned || mechanism != wantMechanism { + t.Fatalf("%s spend trace for %s before/after/earned/mechanism=%d/%d/%d/%q want=%d/%d/%d/%q", table, key, before, after, earned, mechanism, wantBefore, wantAfter, wantEarned, wantMechanism) + } +} + func assertPendingTokenCount(t *testing.T, db *sql.DB, poolID string, userID, want int64) { t.Helper() var got int64 if err := db.QueryRow(` - SELECT pending_jackpot_tokens FROM lucky_user_states + SELECT pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID, ).Scan(&got); err != nil { t.Fatalf("query pending token: %v", err) @@ -497,3 +738,233 @@ func dynamicMySQLTestRule(poolID string) domain.RuleConfig { Stages: stages, } } + +func dualSourceV5ShapeRule(poolID string) domain.RuleConfig { + rule := dynamicMySQLTestRule(poolID) + // 运行参数与测试服 lalu/lucky/v5 对齐;阶段基础表仍沿用轻量测试表,下面只把 + // v5 的真实重叠档追加进去,避免这个 repository 单测复制整份 35 档 fixture。 + rule.TargetRTPPPM, rule.PoolRatePPM, rule.ProfitRatePPM, rule.AnchorRatePPM = 890_000, 890_000, 10_000, 100_000 + rule.GiftPriceReference = 100 + rule.JackpotMultiplierPPMs = []int64{100_000_000, 200_000_000, 500_000_000, 1_000_000_000} + rule.JackpotGlobalRTPMaxPPM, rule.JackpotUserRoundRTPMaxPPM, rule.JackpotUser48hRTPMaxPPM = 980_000, 950_000, 950_000 + rule.MaxJackpotHitsPerUserDay = 10 + rule.MaxSinglePayout, rule.UserHourlyPayoutCap, rule.UserDailyPayoutCap = 4_000_000_000, 4_000_000_000, 4_000_000_000 + rule.DeviceDailyPayoutCap, rule.RoomHourlyPayoutCap, rule.AnchorDailyPayoutCap = 4_000_000_000, 4_000_000_000, 4_000_000_000 + // 回归测试服 lalu/lucky v5 的真实重叠形状:normal 重叠 100/200x,advanced + // 重叠 100/200/500/1000x。这里仅验证运行适配,不依赖停用规则的空奖池开奖。 + overlaps := map[string][]struct { + multiplier int64 + weight int64 + }{ + domain.StageNormal: { + {multiplier: 100_000_000, weight: 810}, + {multiplier: 200_000_000, weight: 405}, + }, + domain.StageAdvanced: { + {multiplier: 100_000_000, weight: 634}, + {multiplier: 200_000_000, weight: 317}, + {multiplier: 500_000_000, weight: 127}, + {multiplier: 1_000_000_000, weight: 64}, + }, + } + for stageIndex := range rule.Stages { + stage := &rule.Stages[stageIndex] + var overlapWeight int64 + for _, overlap := range overlaps[stage.Stage] { + overlapWeight += overlap.weight + } + for tierIndex := range stage.Tiers { + if stage.Tiers[tierIndex].MultiplierPPM == 1_000_000 { + stage.Tiers[tierIndex].BaseWeightPPM -= overlapWeight + } + } + for _, overlap := range overlaps[stage.Stage] { + stage.Tiers = append(stage.Tiers, domain.RuleTier{ + Stage: stage.Stage, TierID: fmt.Sprintf("%s_ordinary_%d", stage.Stage, overlap.multiplier), + MultiplierPPM: overlap.multiplier, BaseWeightPPM: overlap.weight, HighWaterOnly: true, Enabled: true, + }) + } + } + return rule +} + +func TestLuckyDynamicStrategyConfigSeparatesOrdinaryAndJackpotSources(t *testing.T) { + rule := dualSourceV5ShapeRule("dual-source") + runtimeConfig, strategy, err := BuildLuckyDynamicStrategyForRule(rule) + if err != nil { + t.Fatal(err) + } + if runtimeConfig.TargetRTPPPM != 890_000 || strategy.DailyJackpotLimit != 10 { + t.Fatalf("v5 runtime target/cap=%d/%d want=890000/10", runtimeConfig.TargetRTPPPM, strategy.DailyJackpotLimit) + } + if !strategy.JackpotMechanism2Enabled || strategy.MilestoneSpendCoins != rule.JackpotSpendThresholdCoins { + t.Fatalf("runtime spend strategy enabled/threshold=%v/%d want=true/%d", strategy.JackpotMechanism2Enabled, strategy.MilestoneSpendCoins, rule.JackpotSpendThresholdCoins) + } + ordinary := make(map[int64]*domain.StrategyTier) + jackpot := make(map[int64]*domain.StrategyTier) + for index := range strategy.Tiers { + tier := &strategy.Tiers[index] + if tier.Jackpot { + jackpot[tier.MultiplierPPM] = tier + } else if tier.MultiplierPPM >= 100_000_000 { + ordinary[tier.MultiplierPPM] = tier + } + } + wantWeights := map[int64]map[string]int64{ + 100_000_000: {domain.StageNormal: 810, domain.StageAdvanced: 634}, + 200_000_000: {domain.StageNormal: 405, domain.StageAdvanced: 317}, + 500_000_000: {domain.StageAdvanced: 127}, + 1_000_000_000: {domain.StageAdvanced: 64}, + } + for multiplier, stageWeights := range wantWeights { + ordinaryTier, jackpotTier := ordinary[multiplier], jackpot[multiplier] + if ordinaryTier == nil || ordinaryTier.Jackpot || ordinaryTier.ID != fmt.Sprintf("multiplier_%d", multiplier) { + t.Fatalf("ordinary tier multiplier=%d = %+v", multiplier, ordinaryTier) + } + if jackpotTier == nil || !jackpotTier.Jackpot || jackpotTier.ID != fmt.Sprintf("jackpot_%d", multiplier) || jackpotTier.JackpotWeight != 1 { + t.Fatalf("jackpot tier multiplier=%d = %+v", multiplier, jackpotTier) + } + for _, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} { + if ordinaryTier.StageWeightPPM[stageName] != stageWeights[stageName] || jackpotTier.StageWeightPPM[stageName] != 0 { + t.Fatalf("multiplier=%d stage=%s ordinary/jackpot weights=%d/%d want=%d/0", multiplier, stageName, ordinaryTier.StageWeightPPM[stageName], jackpotTier.StageWeightPPM[stageName], stageWeights[stageName]) + } + } + } +} + +func TestLuckyDynamicDualSourcesSharePoolRTPRiskAndAuditAccounting(t *testing.T) { + rule := dualSourceV5ShapeRule("dual-source-accounting") + runtimeConfig, strategy, err := BuildLuckyDynamicStrategyForRule(rule) + if err != nil { + t.Fatal(err) + } + const ( + poolBefore = int64(15_000_000) + paidAtMS = int64(1_784_000_000_000) + ) + fullRisk := domain.StrategyRiskCapacity{ + Enabled: true, SingleDrawCoins: 4_000_000_000, UserHourCoins: 4_000_000_000, UserDayCoins: 4_000_000_000, + DeviceDayCoins: 4_000_000_000, RoomHourCoins: 4_000_000_000, AnchorDayCoins: 4_000_000_000, + } + input := domain.StrategyInput{ + GiftPriceCoins: runtimeConfig.GiftPrice, PoolContributionCoins: 89, + Recharge30DCoins: 1, RiskCapacity: fullRisk, + } + ordinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore} + _, preview, err := domain.PreviewLuckyGiftStrategyWeights(strategy, ordinaryState, input) + if err != nil { + t.Fatal(err) + } + ordinaryIndex, ordinaryBound, ordinaryWeight := int64(0), int64(0), int64(0) + for _, item := range preview { + if item.TierID == "multiplier_100000000" { + ordinaryIndex, ordinaryWeight = ordinaryBound, item.AdjustedWeightPPM + } + ordinaryBound += item.AdjustedWeightPPM + } + if ordinaryWeight <= 0 { + t.Fatal("normal ordinary 100x is not selectable") + } + ordinaryRandomCalls := 0 + ordinary, err := domain.DecideLuckyGiftStrategy(strategy, ordinaryState, input, domain.StrategyRandomFunc(func(bound int64) (int64, error) { + ordinaryRandomCalls++ + if bound != ordinaryBound { + t.Fatalf("ordinary random bound=%d want=%d", bound, ordinaryBound) + } + return ordinaryIndex, nil + })) + if err != nil { + t.Fatal(err) + } + + const closedAtMS = int64(100 * 60 * 60 * 1000) + jackpotState := domain.StrategyState{ + PoolBalanceCoins: poolBefore, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS, + UserRegisteredAtMS: closedAtMS - 49*60*60*1000, + GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 8_900}, + UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, + User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, + } + jackpotRandomCalls := 0 + jackpot, err := domain.DecideLuckyGiftStrategy(strategy, jackpotState, input, domain.StrategyRandomFunc(func(bound int64) (int64, error) { + jackpotRandomCalls++ + if bound != 4 { + t.Fatalf("jackpot random bound=%d want=4", bound) + } + return 0, nil + })) + if err != nil { + t.Fatal(err) + } + if ordinaryRandomCalls != 1 || jackpotRandomCalls != 1 || ordinary.SelectedTier.ID != "multiplier_100000000" || ordinary.Jackpot || + jackpot.SelectedTier.ID != "jackpot_100000000" || !jackpot.Jackpot || ordinary.PayoutCoins != jackpot.PayoutCoins { + t.Fatalf("ordinary/jackpot decisions not source-isolated: ordinary=%+v jackpot=%+v calls=%d/%d", ordinary, jackpot, ordinaryRandomCalls, jackpotRandomCalls) + } + + // stageLuckyDynamicDecision is the production transaction accumulator. Running both + // sources through it proves source identity changes only jackpot_hits/audit weight; + // public pool, every RTP numerator and all six risk counters still receive the same payout. + assertStaged := func(label string, before domain.StrategyState, decision domain.StrategyDecision, wantJackpotHits, wantWeight int64) { + t.Helper() + riskCounters := make(map[string]luckyDynamicRiskCounter, 6) + for _, key := range []string{"single", "user_hour", "user_day", "device_day", "room_hour", "anchor_day"} { + riskCounters[key] = luckyDynamicRiskCounter{ScopeType: key} + } + state := luckyDynamicBatchState{ + BasePool: luckyPool{Balance: before.PoolBalanceCoins}, + GlobalWindow: luckyRTPWindow{TargetRTPPPM: runtimeConfig.TargetRTPPPM, Status: "open"}, + GiftWindow: luckyRTPWindow{TargetRTPPPM: runtimeConfig.TargetRTPPPM, Status: "open"}, + UserWindow: luckyUserRTPWindow{PoolID: runtimeConfig.PoolID, RuleVersion: runtimeConfig.RuleVersion}, + RiskCounters: riskCounters, + Strategy: before, + } + command := domain.DrawCommand{ + CommandID: label, PoolID: runtimeConfig.PoolID, GiftID: runtimeConfig.GiftID, GiftCount: 1, + UserID: 1001, TargetUserID: 2001, RoomID: "room-v5", AnchorID: "anchor-v5", + CoinSpent: 100, GiftIncomeCoins: 10, PaidAtMS: paidAtMS, + } + split := domain.StrategyFundSplit{PublicPoolCoins: 89, ProfitPoolCoins: 1, AnchorReturnCoins: 10} + if err := (&Repository{}).stageLuckyDynamicDecision("lalu", runtimeConfig, runtimeConfig.GiftPrice, command, split, decision, paidAtMS, &state); err != nil { + t.Fatalf("%s stage decision: %v", label, err) + } + if state.PublicIn != 89 || state.ProfitIn != 1 || state.AnchorIn != 10 || state.Payout != decision.PayoutCoins || + state.BasePool.Balance != decision.PoolAfterCoins || state.BasePool.TotalIn != 89 || state.BasePool.TotalOut != decision.PayoutCoins || + state.GlobalWindow.WagerCoins != 100 || state.GlobalWindow.ActualPayoutCoins != decision.PayoutCoins || + state.GiftWindow.WagerCoins != 100 || state.GiftWindow.ActualPayoutCoins != decision.PayoutCoins || + state.UserWindow.WagerCoins != 100 || state.UserWindow.PayoutCoins != decision.PayoutCoins || + state.DayState.WagerCoins != 100 || state.DayState.PayoutCoins != decision.PayoutCoins || state.DayState.JackpotHits != wantJackpotHits || + state.HourState.WagerCoins != 100 || state.HourState.PayoutCoins != decision.PayoutCoins { + t.Fatalf("%s shared accounting mismatch: %+v", label, state) + } + for key, counter := range state.RiskCounters { + if counter.Payout != decision.PayoutCoins { + t.Fatalf("%s risk %s payout=%d want=%d", label, key, counter.Payout, decision.PayoutCoins) + } + } + if len(state.Records) != 1 || state.Records[0].Candidate.TierID != decision.SelectedTier.ID || state.Records[0].Candidate.Weight != wantWeight || + state.Records[0].StrategyDecision == nil || state.Records[0].StrategyDecision.Jackpot != decision.Jackpot { + t.Fatalf("%s audit record mismatch: %+v", label, state.Records) + } + } + assertStaged("ordinary-v5-100x", ordinaryState, ordinary, 0, ordinaryWeight) + assertStaged("jackpot-v5-100x", jackpotState, jackpot, 1, 1) +} + +func TestLuckyDynamicSelectedWeightUsesWinningSource(t *testing.T) { + ordinary := domain.StrategyDecision{ + SelectedTier: domain.StrategyTier{ID: "multiplier_200000000", MultiplierPPM: 200_000_000}, + Trace: domain.DecisionTrace{Weights: []domain.StrategyWeightTrace{{TierID: "multiplier_200000000", AdjustedWeightPPM: 1_000}}}, + } + if got := luckyDynamicSelectedWeight(ordinary); got != 1_000 { + t.Fatalf("ordinary audit weight=%d want=1000", got) + } + jackpot := domain.StrategyDecision{ + SelectedTier: domain.StrategyTier{ID: "jackpot_200000000", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 1}, + Jackpot: true, + // 普通权重预览会保留 jackpot tier 的零权重事实;大奖审计必须使用独立选择权重 1。 + Trace: domain.DecisionTrace{Weights: []domain.StrategyWeightTrace{{TierID: "jackpot_200000000", AdjustedWeightPPM: 0}}}, + } + if got := luckyDynamicSelectedWeight(jackpot); got != 1 { + t.Fatalf("jackpot audit weight=%d want=1", got) + } +} diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go index 79a1a900..356d77fb 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go @@ -34,6 +34,140 @@ type luckyUserRTPHour struct { PayoutCoins int64 } +// luckyUserRTPWindow is the user's money aggregate for one platform settlement window. +// qualification_status starts as pending while the platform window is open. It is evaluated lazily +// on that user's next paid draw after the platform window closes, avoiding a full-user scan at reset. +type luckyUserRTPWindow struct { + PoolID string + RuleVersion int64 + WindowIndex int64 + UserID int64 + UserRegisteredAtMS int64 + WagerCoins int64 + PayoutCoins int64 + Rolling48hWagerCoins int64 + Rolling48hPayoutCoins int64 + QualificationStatus string + AttemptedDrawID string + EvaluatedAtMS int64 +} + +func (r *Repository) getOrCreateLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, windowIndex, userID, userRegisteredAtMS, nowMS int64) (luckyUserRTPWindow, error) { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO lucky_user_rtp_windows ( + app_code, pool_id, rule_version, window_index, user_id, user_registered_at_ms, + wager_coins, payout_coins, rolling_48h_wager_coins, rolling_48h_payout_coins, + qualification_status, attempted_draw_id, evaluated_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 'pending', '', 0, ?, ?) + ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`, + appCode, poolID, ruleVersion, windowIndex, userID, userRegisteredAtMS, nowMS, nowMS, + ); err != nil { + return luckyUserRTPWindow{}, err + } + state := luckyUserRTPWindow{PoolID: poolID, RuleVersion: ruleVersion, WindowIndex: windowIndex, UserID: userID} + err := tx.QueryRowContext(ctx, ` + SELECT user_registered_at_ms, wager_coins, payout_coins, rolling_48h_wager_coins, rolling_48h_payout_coins, + qualification_status, attempted_draw_id, evaluated_at_ms + FROM lucky_user_rtp_windows + WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND window_index = ? AND user_id = ? + FOR UPDATE`, appCode, poolID, ruleVersion, windowIndex, userID, + ).Scan(&state.UserRegisteredAtMS, &state.WagerCoins, &state.PayoutCoins, &state.Rolling48hWagerCoins, &state.Rolling48hPayoutCoins, + &state.QualificationStatus, &state.AttemptedDrawID, &state.EvaluatedAtMS) + return state, err +} + +// getLatestPendingLuckyUserRTPWindow returns at most one closed qualification. A user cannot bank +// multiple compensation attempts: if historical pending rows exist, only the newest is retained and +// older rows are expired in the same transaction before the next paid draw is selected. +func (r *Repository) getLatestPendingLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, beforeWindowIndex, userID int64, globalScopeID string, nowMS int64) (luckyUserRTPWindow, luckyRTPWindow, bool, error) { + var userWindow luckyUserRTPWindow + userWindow.PoolID = poolID + userWindow.RuleVersion = ruleVersion + userWindow.UserID = userID + var global luckyRTPWindow + err := tx.QueryRowContext(ctx, ` + SELECT u.window_index, u.user_registered_at_ms, u.wager_coins, u.payout_coins, + u.rolling_48h_wager_coins, u.rolling_48h_payout_coins, + u.qualification_status, u.attempted_draw_id, u.evaluated_at_ms, + g.window_index, g.scope_type, g.scope_id, g.target_rtp_ppm, g.control_window_draws, + g.paid_draws, g.wager_coins, g.target_payout_coins, g.actual_base_payout_coins, + g.carry_ppm, g.status, g.started_at_ms, g.last_paid_at_ms, g.closed_at_ms + FROM lucky_user_rtp_windows u + JOIN lucky_rtp_windows g + ON g.app_code = u.app_code AND g.scope_type = 'pool' AND g.scope_id = ? + AND g.window_index = u.window_index AND g.status = 'closed' + WHERE u.app_code = ? AND u.pool_id = ? AND u.rule_version = ? AND u.user_id = ? + AND u.qualification_status = 'pending' AND u.window_index < ? + ORDER BY u.window_index DESC + LIMIT 1 + FOR UPDATE`, globalScopeID, appCode, poolID, ruleVersion, userID, beforeWindowIndex, + ).Scan(&userWindow.WindowIndex, &userWindow.UserRegisteredAtMS, &userWindow.WagerCoins, &userWindow.PayoutCoins, + &userWindow.Rolling48hWagerCoins, &userWindow.Rolling48hPayoutCoins, + &userWindow.QualificationStatus, &userWindow.AttemptedDrawID, &userWindow.EvaluatedAtMS, + // JOIN 的全局轮次必须显式扫回;若只读取用户轮次,global 默认 0 会让所有正常闭窗资格 + // 在 prepare 对齐校验中失败并回滚下一次送礼。 + &global.WindowIndex, &global.ScopeType, &global.ScopeID, &global.TargetRTPPPM, &global.ControlDraws, + &global.PaidDraws, &global.WagerCoins, &global.TargetPayoutCoins, &global.ActualPayoutCoins, + &global.CarryPPM, &global.Status, &global.StartedAtMS, &global.LastPaidAtMS, &global.ClosedAtMS) + if err != nil { + if err == sql.ErrNoRows { + return luckyUserRTPWindow{}, luckyRTPWindow{}, false, nil + } + return luckyUserRTPWindow{}, luckyRTPWindow{}, false, err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_user_rtp_windows + SET qualification_status = 'expired', evaluated_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND user_id = ? + AND qualification_status = 'pending' AND window_index < ?`, + nowMS, nowMS, appCode, poolID, ruleVersion, userID, userWindow.WindowIndex, + ); err != nil { + return luckyUserRTPWindow{}, luckyRTPWindow{}, false, err + } + return userWindow, global, true, nil +} + +// getLatestClosedLuckyDynamicRTPWindow returns the platform sample used only by the durable spend +// strategy. It intentionally does not join a user window: a user may have been inactive throughout +// the latest platform round. Legacy rows without an exact start/close boundary are excluded, because +// treating an administrative migration close as a settled sample could manufacture jackpot access. +func (r *Repository) getLatestClosedLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, globalScopeID string, beforeWindowIndex int64) (luckyRTPWindow, bool, error) { + var window luckyRTPWindow + err := tx.QueryRowContext(ctx, ` + SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws, + paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, + carry_ppm, status, started_at_ms, last_paid_at_ms, closed_at_ms + FROM lucky_rtp_windows + WHERE app_code = ? AND scope_type = 'pool' AND scope_id = ? AND status = 'closed' + AND window_index < ? AND wager_coins > 0 AND started_at_ms > 0 AND closed_at_ms > 0 + ORDER BY window_index DESC + LIMIT 1`, appCode, globalScopeID, beforeWindowIndex, + ).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws, + &window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, + &window.CarryPPM, &window.Status, &window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS) + if err == sql.ErrNoRows { + return luckyRTPWindow{}, false, nil + } + if err != nil { + return luckyRTPWindow{}, false, err + } + return window, true, nil +} + +func (r *Repository) persistLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode string, state luckyUserRTPWindow, nowMS int64) error { + _, err := tx.ExecContext(ctx, ` + UPDATE lucky_user_rtp_windows + SET wager_coins = ?, payout_coins = ?, + rolling_48h_wager_coins = ?, rolling_48h_payout_coins = ?, + qualification_status = ?, attempted_draw_id = ?, evaluated_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND window_index = ? AND user_id = ?`, + state.WagerCoins, state.PayoutCoins, state.Rolling48hWagerCoins, state.Rolling48hPayoutCoins, + state.QualificationStatus, state.AttemptedDrawID, state.EvaluatedAtMS, nowMS, + appCode, state.PoolID, state.RuleVersion, state.WindowIndex, state.UserID, + ) + return err +} + func luckyPaidTime(cmd domain.DrawCommand, nowMS int64) time.Time { ms := cmd.PaidAtMS if ms <= 0 { @@ -174,13 +308,26 @@ func (r *Repository) getOrCreateLuckyUserRTPHour(ctx context.Context, tx *sql.Tx return state, err } +func (r *Repository) getLuckyUserRolling48HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) { + return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 48) +} + +func (r *Repository) getLuckyUserRolling24HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) { + return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 24) +} + +// getLuckyUserRolling72HourRTP remains only for old in-package callers during rolling source upgrades. func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) { - // 严格口径是 [end-72h,end),不能把“当前小时+前71整点桶”近似成 72h。 + return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 72) +} + +func (r *Repository) getLuckyUserRollingHourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS, hours int64) (domain.StrategyRTP, error) { + // 严格口径是 [end-hours,end),不能把“当前小时+前 N 个整点桶”近似成滚动窗口。 // 完整小时从聚合桶读取;头尾两个不足一小时的片段从专用边界事件读取,避免回扫 lucky_draw_records。 const hourMS = int64(time.Hour / time.Millisecond) - startMS := endMS - 72*hourMS - if startMS < 0 || endMS <= 0 { - return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h range is invalid") + startMS := endMS - hours*hourMS + if hours <= 0 || startMS < 0 || endMS <= 0 { + return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP range is invalid") } fullStartMS := startMS if remainder := startMS % hourMS; remainder != 0 { @@ -206,7 +353,7 @@ func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.T var wager, payout int64 if err := tx.QueryRowContext(ctx, ` SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0) - FROM lucky_user_rtp_boundary_events + FROM lucky_user_rtp_boundary_events FORCE INDEX (idx_lucky_user_rtp_boundary_range) WHERE app_code = ? AND pool_id = ? AND user_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?`, appCode, poolID, userID, boundary[0], boundary[1], @@ -214,7 +361,7 @@ func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.T return domain.StrategyRTP{}, err } if state.WagerCoins > int64(^uint64(0)>>1)-wager || state.PayoutCoins > int64(^uint64(0)>>1)-payout { - return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h RTP overflow") + return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP overflow") } state.WagerCoins += wager state.PayoutCoins += payout diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go index 1f1447f4..d35552a3 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go @@ -26,9 +26,23 @@ func luckyUsesDynamicStrategy(config domain.Config) bool { return config.StrategyVersion == domain.StrategyDynamicV3 } +// BuildLuckyDynamicStrategyForRule exposes the same read-only RuleConfig -> runtime -> +// pure-kernel adapter used by production. Offline simulators must enter through this +// boundary instead of hand-assembling StrategyTier values, otherwise a report can pass +// while silently omitting stage merging, generated source IDs or a newly added rule field. +// The function performs no database I/O and does not enable or publish the supplied rule. +func BuildLuckyDynamicStrategyForRule(rule domain.RuleConfig) (domain.Config, domain.StrategyConfig, error) { + config, err := luckyRuntimeConfigFromRuleConfig(rule) + if err != nil { + return domain.Config{}, domain.StrategyConfig{}, err + } + return config, luckyDynamicStrategyConfig(config), nil +} + // luckyDynamicStrategyConfig 把不可变规则快照转换为纯策略输入。 -// 同一倍率在三个充值阶段可能使用不同 tier_id;运行内核按倍率合并并保存 stage->weight, -// 从而既只有一个 0x 候选,又不会把某阶段不存在的倍率错误继承到另一阶段。 +// 同一倍率在三个充值阶段可能使用不同 tier_id;普通路径按倍率合并并保存 stage->weight, +// 大奖路径则始终生成独立 jackpot tier。倍率只决定金额,ordinary/jackpot tier ID 才决定 +// 命中来源,这样普通 200x 与补偿 200x 可以共存且不会共享每日大奖计数语义。 func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig { stageNames := make([]string, 0, len(config.Stages)) rechargeStages := make([]domain.StrategyRechargeStage, 0, len(config.Stages)) @@ -44,80 +58,89 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig { for _, multiplier := range config.JackpotMultiplierPPMs { jackpotSet[multiplier] = struct{}{} } - tiersByMultiplier := make(map[int64]*domain.StrategyTier) + ordinaryByMultiplier := make(map[int64]*domain.StrategyTier) for _, stage := range config.Stages { for _, configured := range stage.Tiers { if !configured.Enabled { continue } - tier := tiersByMultiplier[configured.MultiplierPPM] + tier := ordinaryByMultiplier[configured.MultiplierPPM] if tier == nil { stageWeights := make(map[string]int64, len(stageNames)) for _, stageName := range stageNames { stageWeights[stageName] = 0 } tier = &domain.StrategyTier{ + // 保留既有普通奖档审计 ID;来源隔离由普通 multiplier_ 与大奖 jackpot_ 前缀完成。 ID: fmt.Sprintf("multiplier_%d", configured.MultiplierPPM), MultiplierPPM: configured.MultiplierPPM, StageWeightPPM: stageWeights, Enabled: true, } - tiersByMultiplier[configured.MultiplierPPM] = tier + ordinaryByMultiplier[configured.MultiplierPPM] = tier } tier.StageWeightPPM[stage.Stage] += configured.BaseWeightPPM } } - // 大奖机制从显式倍率集合随机,不要求运营把 200x/500x/1000x 塞进普通概率表。 - // 图片没有提供大奖之间的权重,因此统一使用 1;这一选择会完整进入 decision trace,不能被误认为基础概率。 + // 大奖集合即使与普通倍率重合也另建 tier;其普通阶段权重固定为 0,只能在 RTP + // 补偿条件通过后由 selectPayableJackpot 选中。当前协议没有大奖内部权重,统一记 1。 + jackpotByMultiplier := make(map[int64]domain.StrategyTier, len(jackpotSet)) for multiplier := range jackpotSet { - tier := tiersByMultiplier[multiplier] - if tier == nil { - stageWeights := make(map[string]int64, len(stageNames)) - for _, stageName := range stageNames { - stageWeights[stageName] = 0 - } - tier = &domain.StrategyTier{ - ID: fmt.Sprintf("jackpot_%d", multiplier), - MultiplierPPM: multiplier, - StageWeightPPM: stageWeights, - Enabled: true, - } - tiersByMultiplier[multiplier] = tier + stageWeights := make(map[string]int64, len(stageNames)) + for _, stageName := range stageNames { + stageWeights[stageName] = 0 + } + jackpotByMultiplier[multiplier] = domain.StrategyTier{ + ID: fmt.Sprintf("jackpot_%d", multiplier), + MultiplierPPM: multiplier, + StageWeightPPM: stageWeights, + Jackpot: true, + JackpotWeight: 1, + Enabled: true, } - tier.Jackpot = true - tier.JackpotWeight = 1 } - multipliers := make([]int64, 0, len(tiersByMultiplier)) - for multiplier := range tiersByMultiplier { + multipliers := make([]int64, 0, len(ordinaryByMultiplier)) + for multiplier := range ordinaryByMultiplier { multipliers = append(multipliers, multiplier) } sort.Slice(multipliers, func(i, j int) bool { return multipliers[i] < multipliers[j] }) - tiers := make([]domain.StrategyTier, 0, len(multipliers)) + jackpotMultipliers := make([]int64, 0, len(jackpotByMultiplier)) + for multiplier := range jackpotByMultiplier { + jackpotMultipliers = append(jackpotMultipliers, multiplier) + } + sort.Slice(jackpotMultipliers, func(i, j int) bool { return jackpotMultipliers[i] < jackpotMultipliers[j] }) + tiers := make([]domain.StrategyTier, 0, len(multipliers)+len(jackpotMultipliers)) for _, multiplier := range multipliers { - tiers = append(tiers, *tiersByMultiplier[multiplier]) + tiers = append(tiers, *ordinaryByMultiplier[multiplier]) + } + for _, multiplier := range jackpotMultipliers { + tiers = append(tiers, jackpotByMultiplier[multiplier]) } return domain.StrategyConfig{ - Tiers: tiers, - PublicPoolRatePPM: config.PoolRatePPM, - ProfitPoolRatePPM: config.ProfitRatePPM, - AnchorReturnRatePPM: config.AnchorRatePPM, - ColdStartPoolCoins: config.InitialPoolCoins, - LowWaterThresholdCoins: config.LowWatermarkCoins, - HighWaterThresholdCoins: config.HighWatermarkCoins, - LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM, - HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM, - RechargeFactorPPM: config.RechargeBoostFactorPPM, - RechargeBoostStartMS: 0, - RechargeBoostEndMS: config.RechargeBoostWindowMS, - MissProtectionZeroDraws: config.LossStreakGuarantee, - DailyJackpotLimit: config.MaxJackpotHitsPerUserDay, - MilestoneSpendCoins: config.JackpotSpendThresholdCoins, - JackpotMechanism1Enabled: true, - JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0, - GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM, - UserDayRTPMaxPPM: config.JackpotUserDayRTPMaxPPM, - User72HourRTPMaxPPM: config.JackpotUser72hRTPMaxPPM, - RechargeStages: rechargeStages, + Tiers: tiers, + PublicPoolRatePPM: config.PoolRatePPM, + ProfitPoolRatePPM: config.ProfitRatePPM, + AnchorReturnRatePPM: config.AnchorRatePPM, + ColdStartPoolCoins: config.InitialPoolCoins, + LowWaterThresholdCoins: config.LowWatermarkCoins, + HighWaterThresholdCoins: config.HighWatermarkCoins, + LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM, + HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM, + RechargeFactorPPM: config.RechargeBoostFactorPPM, + RechargeBoostStartMS: 0, + RechargeBoostEndMS: config.RechargeBoostWindowMS, + User24HourRTPThresholdPPM: config.User24HourRTPThresholdPPM, + User24HourOrdinaryWinFactorPPM: config.User24HourOrdinaryWinFactorPPM, + MissProtectionZeroDraws: config.LossStreakGuarantee, + DailyJackpotLimit: config.MaxJackpotHitsPerUserDay, + MilestoneSpendCoins: config.JackpotSpendThresholdCoins, + JackpotMechanism1Enabled: true, + JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0, + GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM, + UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM, + User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM, + SettledRoundRTPEligibility: true, + RechargeStages: rechargeStages, } } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go index 5d093b8f..50c9cf8d 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go @@ -39,6 +39,11 @@ type luckyRTPWindow struct { ActualPayoutCoins int64 CarryPPM int64 Status string + // StartedAtMS/LastPaidAtMS/ClosedAtMS are business-time boundaries from the wallet payment fact. + // created_at_ms remains an infrastructure audit timestamp and must not be used for RTP eligibility. + StartedAtMS int64 + LastPaidAtMS int64 + ClosedAtMS int64 } func (w luckyRTPWindow) withCurrentWager(spent int64) luckyRTPWindow { @@ -73,9 +78,12 @@ type luckyUserState struct { CumulativeWagerCoins int64 EquivalentDraws int64 LossStreak int64 - // PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。 - // 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。 + // PendingJackpotTokens is the retired token column. Dynamic V3 reads it only so the next paid + // transaction can clear it; its value must never become a new spend jackpot qualification. PendingJackpotTokens int64 + // PendingSpendJackpotTokens belongs to the app/pool/user lifetime. UTC-day spend creates tokens, + // but a blocked token survives midnight until one spend jackpot is actually paid. + PendingSpendJackpotTokens int64 } type luckyCandidate struct { @@ -111,8 +119,8 @@ type luckyDrawRecordInput struct { // 后台以后修改规则版本也不会让历史结果失去可解释性。 StrategyDecision *domain.StrategyDecision FundSplit domain.StrategyFundSplit - UserDayRTP domain.StrategyRTP - User72HourRTP domain.StrategyRTP + UserRoundRTP domain.StrategyRTP + User48HourRTP domain.StrategyRTP RewardStatus string NowMS int64 } @@ -563,6 +571,9 @@ func (r *Repository) updateLuckyPoolNet(ctx context.Context, tx *sql.Tx, appCode // insertLuckyDrawRecords 批量写入审计明细。 // 仍然保留每一抽一条记录,因为后台统计“抽奖次数”和问题复盘都需要单抽粒度;优化点是批量 INSERT 而不是 N 次 INSERT。 func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput) error { + if len(records) == 0 { + return nil + } const chunkSize = 200 for start := 0; start < len(records); start += chunkSize { end := start + chunkSize @@ -592,7 +603,14 @@ func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, rec return err } } - return nil + // 画像事件与抽奖事实同事务提交;后台页面因此不依赖下一轮历史回灌,同时事件唯一键仍允许回灌安全补旧数据。 + facts := make([]luckyUserProfileFact, 0, len(records)) + for _, record := range records { + userID := record.Command.UserID + facts = append(facts, luckyUserProfileFactFromRecord(record, luckyUserProfileSourceInternal, + domain.UserProfileIdentityInternal, fmt.Sprintf("%d", userID), "", userID)) + } + return r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, records[len(records)-1].NowMS) } type luckyDrawPoolStatKey struct { @@ -1039,9 +1057,26 @@ func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, strin if input.StrategyDecision != nil { candidateData["dynamic_v3"] = input.StrategyDecision.Trace poolData["fund_split"] = input.FundSplit - rtpData["user_day"] = input.UserDayRTP - rtpData["user_72h"] = input.User72HourRTP - rtpData["global"] = input.StrategyDecision.NextState.GlobalRTP + rtpData["user_round"] = input.UserRoundRTP + rtpData["user_48h"] = input.User48HourRTP + for _, condition := range input.StrategyDecision.Trace.Conditions { + if condition.Name != domain.StrategyConditionUser24HourRTPDownweight { + continue + } + // Persist explicit business names rather than forcing incident tooling to reinterpret the + // generic condition numerator/denominator. This is the pre-draw snapshot; current payout is + // added only to StrategyDecision.NextState for the next subdraw. + rtpData["user_24h_downweight"] = map[string]any{ + "wager_coins": condition.Denominator, "payout_coins": condition.Numerator, + "rtp_ppm": condition.RatioPPM, "threshold_ppm": condition.LimitPPM, + "ordinary_win_factor_ppm": condition.FactorPPM, + "active": condition.Passed, "reason": condition.Reason, + } + break + } + // Eligibility uses the just-closed platform snapshot; it is intentionally frozen while the + // current open window advances independently on this draw. + rtpData["qualification_global"] = input.StrategyDecision.NextState.GlobalRTP } candidateSnapshot, _ := json.Marshal(candidateData) poolSnapshot, _ := json.Marshal(poolData) @@ -1402,6 +1437,18 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky ); err != nil { return err } + profileRecord := luckyDrawRecordInput{ + AppCode: input.AppCode, DrawID: input.DrawID, Command: input.Command, Config: input.Config, + ExperiencePool: input.ExperiencePool, Candidate: input.Candidate, Limited: input.Limited, + StageFeedback: input.StageFeedback, GlobalWindow: input.GlobalWindow, GiftWindow: input.GiftWindow, + BasePool: input.BasePool, RewardStatus: input.RewardStatus, NowMS: input.NowMS, + } + if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, []luckyUserProfileFact{ + luckyUserProfileFactFromRecord(profileRecord, luckyUserProfileSourceInternal, + domain.UserProfileIdentityInternal, fmt.Sprintf("%d", input.Command.UserID), "", input.Command.UserID), + }, input.NowMS); err != nil { + return err + } if luckyDrawNeedsRewardSettlementOutbox(input.Candidate) { // 同一 owner outbox 先补偿钱包返奖,再在 granted 收敛后发布中奖事实;room/activity 的展示副作用 // 由各自本地 outbox 独立补偿,不能在抽奖事务里直接调用腾讯云 IM。 @@ -1846,6 +1893,10 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra _ = tx.Rollback() return err } + if err := r.migrateLuckyGiftUserProfileRewardStatuses(ctx, tx, appCode, chunk, status, onlyPending, nowMS); err != nil { + _ = tx.Rollback() + return err + } if err := tx.Commit(); err != nil { return err } @@ -2300,55 +2351,64 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf if !hasEnabledBaseTier { return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift enabled base tiers are required") } + user24HourOrdinaryWinFactorPPM := ruleConfig.User24HourOrdinaryWinFactorPPM + if ruleConfig.User24HourRTPThresholdPPM == 0 && user24HourOrdinaryWinFactorPPM == 0 { + // Rules published before the field existed deserialize as 0/0. Canonicalize that absence to + // the documented disabled 0/100% sentinel so no legacy rule can become a zero-win table. + user24HourOrdinaryWinFactorPPM = luckyPPMScale + } // 单池模型把旧 platform/room/gift 三层责任池合并为 app+pool_id 一口基础池; // 初始水位和安全水位使用旧三池默认值求和,保证新规则上线后高倍奖支付能力不会因模型收敛而突然降低。 config := domain.Config{ - AppCode: ruleConfig.AppCode, - GiftID: poolID, - PoolID: poolID, - Enabled: ruleConfig.Enabled, - RuleVersion: ruleConfig.RuleVersion, - StrategyVersion: ruleConfig.StrategyVersion, - GiftPrice: referencePrice, - TargetRTPPPM: ruleConfig.TargetRTPPPM, - PoolRatePPM: ruleConfig.PoolRatePPM, - ProfitRatePPM: ruleConfig.ProfitRatePPM, - AnchorRatePPM: ruleConfig.AnchorRatePPM, - ControlBandPPM: ruleConfig.ControlBandPPM, - SettlementWindowWager: ruleConfig.SettlementWindowWager, - GlobalWindowDraws: windowDraws, - GiftWindowDraws: windowDraws, - NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws, - NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws, - HighMultiplier: 10, - HighWaterPoolMultiple: 2, - InitialBasePool: referencePrice * (950 + 500 + 2_375), - BasePoolReserve: referencePrice * (100 + 10 + 100), - InitialPoolCoins: ruleConfig.InitialPoolCoins, - LossStreakGuarantee: ruleConfig.LossStreakGuarantee, - LowWatermarkCoins: ruleConfig.LowWatermarkCoins, - LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM, - HighWatermarkCoins: ruleConfig.HighWatermarkCoins, - HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM, - RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS, - RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM, - JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...), - JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM, - JackpotUserDayRTPMaxPPM: ruleConfig.JackpotUserDayRTPMaxPPM, - JackpotUser72hRTPMaxPPM: ruleConfig.JackpotUser72hRTPMaxPPM, - JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins, - MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay, - MaxSinglePayout: ruleConfig.MaxSinglePayout, - UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap, - UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap, - DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap, - RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap, - AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap, - Tiers: tiers, - Stages: ruleConfig.Stages, - UpdatedByAdminID: ruleConfig.CreatedByAdminID, - CreatedAtMS: ruleConfig.CreatedAtMS, - UpdatedAtMS: ruleConfig.CreatedAtMS, + AppCode: ruleConfig.AppCode, + GiftID: poolID, + PoolID: poolID, + Enabled: ruleConfig.Enabled, + RuleVersion: ruleConfig.RuleVersion, + StrategyVersion: ruleConfig.StrategyVersion, + GiftPrice: referencePrice, + TargetRTPPPM: ruleConfig.TargetRTPPPM, + PoolRatePPM: ruleConfig.PoolRatePPM, + ProfitRatePPM: ruleConfig.ProfitRatePPM, + AnchorRatePPM: ruleConfig.AnchorRatePPM, + ControlBandPPM: ruleConfig.ControlBandPPM, + SettlementWindowWager: ruleConfig.SettlementWindowWager, + GlobalWindowDraws: windowDraws, + GiftWindowDraws: windowDraws, + NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws, + NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws, + HighMultiplier: 10, + HighWaterPoolMultiple: 2, + InitialBasePool: referencePrice * (950 + 500 + 2_375), + BasePoolReserve: referencePrice * (100 + 10 + 100), + InitialPoolCoins: ruleConfig.InitialPoolCoins, + LossStreakGuarantee: ruleConfig.LossStreakGuarantee, + LowWatermarkCoins: ruleConfig.LowWatermarkCoins, + LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM, + HighWatermarkCoins: ruleConfig.HighWatermarkCoins, + HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM, + RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS, + RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM, + User24HourRTPThresholdPPM: ruleConfig.User24HourRTPThresholdPPM, + User24HourOrdinaryWinFactorPPM: user24HourOrdinaryWinFactorPPM, + JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...), + JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM, + JackpotUserRoundRTPMaxPPM: ruleConfig.JackpotUserRoundRTPMaxPPM, + JackpotUser48hRTPMaxPPM: ruleConfig.JackpotUser48hRTPMaxPPM, + SettledRoundRTPEligibility: true, + JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins, + MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay, + MaxSinglePayout: ruleConfig.MaxSinglePayout, + UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap, + UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap, + DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap, + RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap, + AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap, + Tiers: tiers, + Stages: ruleConfig.Stages, + UpdatedByAdminID: ruleConfig.CreatedByAdminID, + CreatedAtMS: ruleConfig.CreatedAtMS, + UpdatedAtMS: ruleConfig.CreatedAtMS, } if config.StrategyVersion == "" { config.StrategyVersion = domain.StrategyFixedV2 @@ -2415,11 +2475,13 @@ func luckyCeilDiv(value, divisor int64) int64 { func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) { var state luckyUserState err := r.db.QueryRowContext(ctx, ` - SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens + SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, + pending_jackpot_tokens, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code = ? AND user_id = ? AND gift_id = ?`, appCode, userID, giftID, - ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens) + ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, + &state.PendingJackpotTokens, &state.PendingSpendJackpotTokens) if errors.Is(err, sql.ErrNoRows) { return luckyUserState{}, nil } @@ -2432,8 +2494,8 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, if _, err := tx.ExecContext(ctx, ` INSERT INTO lucky_user_states ( app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, - pending_jackpot_tokens, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?, ?) + pending_jackpot_tokens, pending_spend_jackpot_tokens, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`, appCode, userID, giftID, nowMS, nowMS, ); err != nil { @@ -2441,12 +2503,14 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, } var state luckyUserState err := tx.QueryRowContext(ctx, ` - SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens + SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, + pending_jackpot_tokens, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code = ? AND user_id = ? AND gift_id = ? FOR UPDATE`, appCode, userID, giftID, - ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens) + ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, + &state.PendingJackpotTokens, &state.PendingSpendJackpotTokens) return state, err } @@ -2476,31 +2540,38 @@ func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appC // getOpenLuckyDynamicRTPWindow 只服务 dynamic_v3:不可变 rule_version scope 已经固定真实流水阈值, // 因此窗口是否结束只看 wager_coins。control_window_draws 继续保存 fixed_v2 的兼容观测值,不能参与 V3 滚动判断。 -func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) { +func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, paidAtMS, nowMS int64) (luckyRTPWindow, error) { if windowWager <= 0 { return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift settlement window wager must be positive") } + if paidAtMS <= 0 { + return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required for settlement window") + } window, exists, err := r.getLatestLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, true) if err != nil { return luckyRTPWindow{}, err } if !exists { - return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, nowMS) + return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, paidAtMS, nowMS) } - if window.Status == "open" && window.WagerCoins < windowWager { + if window.Status == "open" && window.StartedAtMS > 0 && window.WagerCoins < windowWager { return window, nil } if window.Status == "open" { + // 006 gives pre-upgrade windows started_at_ms=0 because their exact first payment cannot be + // reconstructed safely. Close that legacy partial window without manufacturing user-round + // aggregates; the first post-upgrade draw starts a fully aligned platform/user round. + closedAtMS := maxInt64(window.LastPaidAtMS, paidAtMS) if _, err := tx.ExecContext(ctx, ` UPDATE lucky_rtp_windows - SET status = 'closed', updated_at_ms = ? + SET status = 'closed', closed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`, - nowMS, appCode, scopeType, scopeID, window.WindowIndex, + closedAtMS, nowMS, appCode, scopeType, scopeID, window.WindowIndex, ); err != nil { return luckyRTPWindow{}, err } } - return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, nowMS) + return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, paidAtMS, nowMS) } func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) { @@ -2510,7 +2581,8 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap } row := tx.QueryRowContext(ctx, ` SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws, - paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status + paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, + started_at_ms, last_paid_at_ms, closed_at_ms FROM lucky_rtp_windows WHERE app_code = ? AND scope_type = ? AND scope_id = ? ORDER BY window_index DESC @@ -2519,7 +2591,8 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap ) var window luckyRTPWindow if err := row.Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws, - &window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status); err != nil { + &window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status, + &window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { return luckyRTPWindow{}, false, nil } @@ -2529,30 +2602,46 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap } func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) { + return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, index, carry, windowDraws, targetPPM, nowMS, nowMS) +} + +func (r *Repository) createLuckyRTPWindowAt(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, startedAtMS, nowMS int64) (luckyRTPWindow, error) { // 同一 scope 的首次请求可能并发创建同一 window;幂等 INSERT 先以唯一键串行化, // 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。 if _, err := tx.ExecContext(ctx, ` INSERT INTO lucky_rtp_windows ( app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws, - paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?) + paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, + started_at_ms, last_paid_at_ms, closed_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`, - appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, nowMS, nowMS, + appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, startedAtMS, nowMS, nowMS, ); err != nil { return luckyRTPWindow{}, err } var window luckyRTPWindow err := tx.QueryRowContext(ctx, ` SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws, - paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status + paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, + started_at_ms, last_paid_at_ms, closed_at_ms FROM lucky_rtp_windows WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ? FOR UPDATE`, appCode, scopeType, scopeID, index, ).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws, - &window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status) + &window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status, + &window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS) return window, err } +func luckyExclusiveEndMS(value int64) int64 { + // RTP storage keeps the owner's actual settlement millisecond for maturity/audit. Range queries are + // [start,end), so only the query boundary is advanced by one millisecond to include that paid fact. + if value >= int64(^uint64(0)>>1) { + return value + } + return value + 1 +} + func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) { // 初始注资只能由唯一键胜出的事务写一次;第二个首次请求等待后读取真实余额,不能重复 seed。 if _, err := tx.ExecContext(ctx, ` diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go index 8d5de005..fc95d633 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go @@ -72,9 +72,21 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T // 仓储边界再次守住零冷启动,即使内部调用绕过 service 校验,也不能发布一个会暗示自动注资的 V3 快照。 return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero") } + if config.User24HourRTPThresholdPPM == 0 && config.User24HourOrdinaryWinFactorPPM == 0 { + // 旧 proto 客户端不会发送新增标量;仓储兜底成 0/100% 禁用态,确保绕过 service 的内部发布也不改变历史概率。 + config.User24HourOrdinaryWinFactorPPM = 1_000_000 + } if config.JackpotMultiplierPPMs == nil { config.JackpotMultiplierPPMs = []int64{} } + // 旧的进程内调用方可能仍填写 day/72h 别名;仓储只持久化轮次/48h 两个唯一口径, + // 兼容赋值必须在生成不可变版本前完成,避免同一规则出现两套可漂移的数据库字段。 + if config.JackpotUserRoundRTPMaxPPM == 0 { + config.JackpotUserRoundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM + } + if config.JackpotUser48hRTPMaxPPM == 0 { + config.JackpotUser48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM + } jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs) if err != nil { return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err) @@ -110,12 +122,13 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T initial_pool_coins, loss_streak_guarantee, low_watermark_coins, low_water_nonzero_factor_ppm, high_watermark_coins, high_water_nonzero_factor_ppm, - recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms, - jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm, + recharge_boost_window_ms, recharge_boost_factor_ppm, + user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms, + jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm, jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day, max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM, config.ProfitRatePPM, config.AnchorRatePPM, config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference, @@ -124,8 +137,9 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T config.InitialPoolCoins, config.LossStreakGuarantee, config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM, config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM, - config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON, - config.JackpotGlobalRTPMaxPPM, config.JackpotUserDayRTPMaxPPM, config.JackpotUser72hRTPMaxPPM, + config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, + config.User24HourRTPThresholdPPM, config.User24HourOrdinaryWinFactorPPM, jackpotMultiplierPPMsJSON, + config.JackpotGlobalRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM, config.JackpotUser48hRTPMaxPPM, config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay, config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap, config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap, @@ -186,8 +200,9 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin v.initial_pool_coins, v.loss_streak_guarantee, v.low_watermark_coins, v.low_water_nonzero_factor_ppm, v.high_watermark_coins, v.high_water_nonzero_factor_ppm, - v.recharge_boost_window_ms, v.recharge_boost_factor_ppm, v.jackpot_multiplier_ppms, - v.jackpot_global_rtp_max_ppm, v.jackpot_user_day_rtp_max_ppm, v.jackpot_user_72h_rtp_max_ppm, + v.recharge_boost_window_ms, v.recharge_boost_factor_ppm, + v.user_24h_rtp_threshold_ppm, v.user_24h_ordinary_win_factor_ppm, v.jackpot_multiplier_ppms, + v.jackpot_global_rtp_max_ppm, v.jackpot_user_round_rtp_max_ppm, v.jackpot_user_48h_rtp_max_ppm, v.jackpot_spend_threshold_coins, v.max_jackpot_hits_per_user_day, v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap, v.device_daily_payout_cap, v.room_hourly_payout_cap, v.anchor_daily_payout_cap @@ -230,8 +245,9 @@ const luckyRuleConfigSelectSQL = ` initial_pool_coins, loss_streak_guarantee, low_watermark_coins, low_water_nonzero_factor_ppm, high_watermark_coins, high_water_nonzero_factor_ppm, - recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms, - jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm, + recharge_boost_window_ms, recharge_boost_factor_ppm, + user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms, + jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm, jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day, max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap @@ -363,10 +379,12 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) { &config.HighWaterNonzeroFactorPPM, &config.RechargeBoostWindowMS, &config.RechargeBoostFactorPPM, + &config.User24HourRTPThresholdPPM, + &config.User24HourOrdinaryWinFactorPPM, &jackpotMultiplierPPMsJSON, &config.JackpotGlobalRTPMaxPPM, - &config.JackpotUserDayRTPMaxPPM, - &config.JackpotUser72hRTPMaxPPM, + &config.JackpotUserRoundRTPMaxPPM, + &config.JackpotUser48hRTPMaxPPM, &config.JackpotSpendThresholdCoins, &config.MaxJackpotHitsPerUserDay, &config.MaxSinglePayout, @@ -390,5 +408,9 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) { if config.JackpotMultiplierPPMs == nil { config.JackpotMultiplierPPMs = []int64{} } + // 数据库只有轮次/48h 两个真实字段;回填旧别名仅用于同进程源码兼容,运行策略仍由 marker 选择新语义。 + config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM + config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM + config.SettledRoundRTPEligibility = true return config, nil } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go index c2d9f648..a5244ae9 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go @@ -153,6 +153,14 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) { // initdb 已包含最终结构,004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。 runLuckyGiftSQLScript(t, schema.DB, migrationPath) runLuckyGiftSQLScript(t, schema.DB, migrationPath) + // 007 must also work on a pre-dual-strategy schema and remain replay-safe. Dropping only the new + // constant-default column reproduces that shape without scanning or rewriting any user row. + if _, err := schema.DB.Exec(`ALTER TABLE lucky_user_states DROP COLUMN pending_spend_jackpot_tokens, ALGORITHM=INSTANT`); err != nil { + t.Fatalf("prepare pre-007 schema: %v", err) + } + spendTokenMigrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "007_dual_jackpot_spend_tokens.sql") + runLuckyGiftSQLScript(t, schema.DB, spendTokenMigrationPath) + runLuckyGiftSQLScript(t, schema.DB, spendTokenMigrationPath) for _, table := range []string{ "lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events", @@ -171,6 +179,7 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) { minLength int64 }{ {table: "lucky_user_states", name: "pending_jackpot_tokens"}, + {table: "lucky_user_states", name: "pending_spend_jackpot_tokens"}, {table: "lucky_rtp_windows", name: "scope_id", minLength: 128}, {table: "lucky_risk_counters", name: "scope_id", minLength: 255}, {table: "lucky_gift_command_locks", name: "command_id", minLength: 128}, diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_user_profile_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_user_profile_repository.go new file mode 100644 index 00000000..9ec3ef94 --- /dev/null +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_user_profile_repository.go @@ -0,0 +1,1471 @@ +package mysql + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/idgen" + "hyapp/pkg/xerr" + domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" +) + +const ( + luckyUserProfileSourceInternal = "internal" + luckyUserProfileSourceExternalItem = "external_item" + luckyUserProfileSourceExternalLegacy = "external_legacy" + + luckyUserProfileCursorInternal = "user_profile_internal" + luckyUserProfileCursorExternalItem = "user_profile_external_item" + luckyUserProfileCursorExternalLegacy = "user_profile_external_legacy" +) + +// luckyUserProfileFact 是内部、外部逐抽和外部旧聚合事实的统一窄模型。大奖类型只能来自 +// dynamic_v3 trace.JackpotMechanism;倍率再高也不能把普通 tier 误算成大奖。 +type luckyUserProfileFact struct { + AppCode string + SourceType string + SourceDrawID string + RequestID string + PoolID string + IdentityType string + IdentityKey string + StrategyUserID int64 + InternalUserID int64 + ExternalUserID string + RuleVersion int64 + StrategyVersion string + Stage string + DrawCount int64 + WagerCoins int64 + PayoutCoins int64 + BaseRewardCoins int64 + MultiplierPPM int64 + HitType string + RewardStatus string + HighOrdinary bool + GuaranteeHit bool + Downweight bool + TierID string + ReasonCode string + Conditions []domain.StrategyConditionTrace + OccurredAtMS int64 + CreatedAtMS int64 +} + +type luckyUserProfileCandidateSnapshot struct { + DynamicV3 *domain.DecisionTrace `json:"dynamic_v3"` +} + +type luckyUserProfileKey struct { + AppCode string + PoolID string + StrategyVersion string + IdentityType string + IdentityKey string +} + +// luckyUserProfileListCursor 固化“排序值 + 完整身份主键”。客户端只把 base64url token +// 原样回传;ScopeHash 阻止把某个筛选/排序下的游标误用于另一个查询,避免静默漏行。 +type luckyUserProfileListCursor struct { + Version int `json:"v"` + ScopeHash string `json:"s"` + SortValue int64 `json:"n"` + IdentityType string `json:"t"` + IdentityKey string `json:"k"` +} + +// luckyUserJackpotCursor 使用大奖索引的完整 DESC 唯一顺序;source_type 是主键的一部分, +// 同一毫秒且两个来源 draw_id 相同时仍不会重读或跳过。 +type luckyUserJackpotCursor struct { + Version int `json:"v"` + ScopeHash string `json:"s"` + OccurredAtMS int64 `json:"o"` + SourceType string `json:"t"` + DrawID string `json:"d"` +} + +type luckyUserProfileDelta struct { + Latest luckyUserProfileFact + TotalDraws int64 + TotalWagerCoins int64 + TotalPayoutCoins int64 + TotalBaseRewardCoins int64 + OrdinaryWinCount int64 + HighMultiplierOrdinaryWinCount int64 + RTPCompensationJackpotCount int64 + CumulativeSpendJackpotCount int64 + GuaranteeHitCount int64 + ZeroDrawCount int64 + GrantedDrawCount int64 + PendingDrawCount int64 + FailedDrawCount int64 + MaxMultiplierPPM int64 + MaxRewardCoins int64 + FirstDrawAtMS int64 + LastDrawAtMS int64 +} + +type luckyUserMultiplierKey struct { + ProfileKey luckyUserProfileKey + HitType string + MultiplierPPM int64 +} + +type luckyUserMultiplierDelta struct { + DrawCount int64 + WagerCoins int64 + PayoutCoins int64 +} + +func luckyUserProfileFactFromRecord(record luckyDrawRecordInput, sourceType, identityType, identityKey, externalUserID string, strategyUserID int64) luckyUserProfileFact { + fact := luckyUserProfileFact{ + AppCode: record.AppCode, SourceType: sourceType, SourceDrawID: record.DrawID, + RequestID: record.Command.CommandID, PoolID: record.Config.PoolID, + IdentityType: identityType, IdentityKey: identityKey, StrategyUserID: strategyUserID, + ExternalUserID: externalUserID, RuleVersion: record.Config.RuleVersion, + StrategyVersion: record.Config.StrategyVersion, Stage: record.ExperiencePool, DrawCount: 1, + WagerCoins: record.Command.CoinSpent, PayoutCoins: record.Candidate.effectiveReward(), + BaseRewardCoins: record.Candidate.BaseReward, MultiplierPPM: record.Candidate.MultiplierPPM, + RewardStatus: record.RewardStatus, HighOrdinary: record.Candidate.HighMultiplier, + TierID: record.Candidate.TierID, OccurredAtMS: luckyPaidTime(record.Command, record.NowMS).UnixMilli(), + CreatedAtMS: record.NowMS, + } + if identityType == domain.UserProfileIdentityInternal { + fact.InternalUserID = strategyUserID + } + if record.StrategyDecision != nil { + applyLuckyUserProfileTrace(&fact, &record.StrategyDecision.Trace) + } + fact.HitType = luckyUserProfileHitType(fact.PayoutCoins, fact.ReasonCode, fact.Conditions, record.StrategyDecision) + fact.HighOrdinary = fact.HighOrdinary && fact.HitType == domain.UserProfileHitOrdinary + return fact +} + +func luckyUserProfileHitType(payout int64, _ string, _ []domain.StrategyConditionTrace, decision *domain.StrategyDecision) string { + if payout <= 0 { + return domain.UserProfileHitZero + } + if decision != nil { + // 只认固化在审计 trace 中的独立大奖机制。普通概率允许配置同倍率,绝不能按倍率或 tier.Jackpot 猜大奖。 + switch decision.Trace.JackpotMechanism { + case domain.StrategyJackpotMechanismRTPCompensation: + return domain.UserProfileHitRTPCompensation + case domain.StrategyJackpotMechanismMilestone: + return domain.UserProfileHitSpendMilestone + } + } + return domain.UserProfileHitOrdinary +} + +func applyLuckyUserProfileTrace(fact *luckyUserProfileFact, trace *domain.DecisionTrace) { + if fact == nil || trace == nil { + return + } + fact.ReasonCode = strings.TrimSpace(trace.FinalReason) + fact.Conditions = append([]domain.StrategyConditionTrace(nil), trace.Conditions...) + for _, condition := range trace.Conditions { + if condition.Name == domain.StrategyConditionUser24HourRTPDownweight { + fact.Downweight = condition.Passed + } + } + for _, removed := range trace.Removed { + if removed.Reason == domain.StrategyRemovalMissProtection { + fact.GuaranteeHit = true + break + } + } + if strings.TrimSpace(trace.Stage) != "" { + fact.Stage = trace.Stage + } +} + +func luckyUserProfileFactFromSnapshot(fact luckyUserProfileFact, snapshot []byte) luckyUserProfileFact { + var decoded luckyUserProfileCandidateSnapshot + if len(snapshot) == 0 || json.Unmarshal(snapshot, &decoded) != nil || decoded.DynamicV3 == nil { + fact.StrategyVersion = domain.StrategyFixedV2 + fact.HitType = luckyUserProfileHitType(fact.PayoutCoins, "", nil, nil) + fact.HighOrdinary = fact.HighOrdinary && fact.HitType == domain.UserProfileHitOrdinary + return fact + } + fact.StrategyVersion = domain.StrategyDynamicV3 + applyLuckyUserProfileTrace(&fact, decoded.DynamicV3) + if fact.PayoutCoins <= 0 { + fact.HitType = domain.UserProfileHitZero + } else { + // 历史回灌直接读取持久 trace 的机制字段;不从倍率、tier_id 或旧 high_multiplier 标记推断大奖。 + switch decoded.DynamicV3.JackpotMechanism { + case domain.StrategyJackpotMechanismRTPCompensation: + fact.HitType = domain.UserProfileHitRTPCompensation + case domain.StrategyJackpotMechanismMilestone: + fact.HitType = domain.UserProfileHitSpendMilestone + default: + fact.HitType = domain.UserProfileHitOrdinary + } + } + fact.HighOrdinary = fact.HighOrdinary && fact.HitType == domain.UserProfileHitOrdinary + return fact +} + +// persistLuckyGiftUserProfileFacts 将事实唯一键和累计画像放在调用方事务内提交。aggregation_token +// 只会落在首次 INSERT IGNORE 成功的事件上,在线写与历史回灌并发时也不会双算累计值。 +func (r *Repository) persistLuckyGiftUserProfileFacts(ctx context.Context, tx *sql.Tx, facts []luckyUserProfileFact, nowMS int64) error { + if len(facts) == 0 { + return nil + } + grouped := make(map[string][]luckyUserProfileFact) + for _, fact := range facts { + if fact.DrawCount <= 0 { + fact.DrawCount = 1 + } + if fact.OccurredAtMS <= 0 { + fact.OccurredAtMS = nowMS + } + if fact.CreatedAtMS <= 0 { + fact.CreatedAtMS = nowMS + } + key := fact.AppCode + "\x00" + fact.SourceType + grouped[key] = append(grouped[key], fact) + } + for _, sourceFacts := range grouped { + if err := r.persistLuckyGiftUserProfileSourceFacts(ctx, tx, sourceFacts, nowMS); err != nil { + return err + } + } + return nil +} + +func (r *Repository) persistLuckyGiftUserProfileSourceFacts(ctx context.Context, tx *sql.Tx, facts []luckyUserProfileFact, nowMS int64) error { + if len(facts) == 0 { + return nil + } + token := idgen.New("lucky_profile_batch") + const chunkSize = 200 + for start := 0; start < len(facts); start += chunkSize { + end := start + chunkSize + if end > len(facts) { + end = len(facts) + } + var query strings.Builder + query.WriteString(`INSERT IGNORE INTO lucky_gift_user_profile_events ( + app_code, source_type, source_draw_id, pool_id, strategy_version, identity_type, identity_key, + draw_count, wager_coins, payout_coins, base_reward_coins, multiplier_ppm, + hit_type, reward_status, occurred_at_ms, aggregation_token, created_at_ms + ) VALUES `) + args := make([]any, 0, (end-start)*17) + for index, fact := range facts[start:end] { + if index > 0 { + query.WriteByte(',') + } + query.WriteString("(" + luckySQLPlaceholders(17) + ")") + args = append(args, fact.AppCode, fact.SourceType, fact.SourceDrawID, fact.PoolID, fact.StrategyVersion, fact.IdentityType, fact.IdentityKey, + fact.DrawCount, fact.WagerCoins, fact.PayoutCoins, fact.BaseRewardCoins, fact.MultiplierPPM, + fact.HitType, fact.RewardStatus, fact.OccurredAtMS, token, nowMS) + } + if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil { + return err + } + } + + insertedRows, err := tx.QueryContext(ctx, ` + SELECT source_draw_id + FROM lucky_gift_user_profile_events FORCE INDEX (idx_lucky_user_profile_event_token) + WHERE app_code = ? AND source_type = ? AND aggregation_token = ?`, facts[0].AppCode, facts[0].SourceType, token) + if err != nil { + return err + } + inserted := make(map[string]struct{}, len(facts)) + for insertedRows.Next() { + var drawID string + if err := insertedRows.Scan(&drawID); err != nil { + _ = insertedRows.Close() + return err + } + inserted[drawID] = struct{}{} + } + if err := insertedRows.Err(); err != nil { + _ = insertedRows.Close() + return err + } + if err := insertedRows.Close(); err != nil { + return err + } + if len(inserted) == 0 { + return nil + } + newFacts := make([]luckyUserProfileFact, 0, len(inserted)) + for _, fact := range facts { + if _, ok := inserted[fact.SourceDrawID]; ok { + newFacts = append(newFacts, fact) + // 输入即使被上游意外重复,也只能由事件唯一键对应的一份事实推进累计值。 + delete(inserted, fact.SourceDrawID) + } + } + return r.applyLuckyGiftUserProfileDeltas(ctx, tx, newFacts, nowMS) +} + +func (r *Repository) applyLuckyGiftUserProfileDeltas(ctx context.Context, tx *sql.Tx, facts []luckyUserProfileFact, nowMS int64) error { + profiles := make(map[luckyUserProfileKey]*luckyUserProfileDelta) + multipliers := make(map[luckyUserMultiplierKey]*luckyUserMultiplierDelta) + jackpots := make([]luckyUserProfileFact, 0) + for _, fact := range facts { + key := luckyUserProfileKey{AppCode: fact.AppCode, PoolID: fact.PoolID, StrategyVersion: fact.StrategyVersion, IdentityType: fact.IdentityType, IdentityKey: fact.IdentityKey} + delta := profiles[key] + if delta == nil { + delta = &luckyUserProfileDelta{Latest: fact, FirstDrawAtMS: fact.OccurredAtMS, LastDrawAtMS: fact.OccurredAtMS} + profiles[key] = delta + } + if fact.OccurredAtMS > delta.LastDrawAtMS || (fact.OccurredAtMS == delta.LastDrawAtMS && fact.SourceDrawID > delta.Latest.SourceDrawID) { + delta.Latest = fact + delta.LastDrawAtMS = fact.OccurredAtMS + } + if fact.OccurredAtMS < delta.FirstDrawAtMS { + delta.FirstDrawAtMS = fact.OccurredAtMS + } + delta.TotalDraws += fact.DrawCount + delta.TotalWagerCoins += fact.WagerCoins + delta.TotalPayoutCoins += fact.PayoutCoins + delta.TotalBaseRewardCoins += fact.BaseRewardCoins + switch fact.HitType { + case domain.UserProfileHitRTPCompensation: + delta.RTPCompensationJackpotCount += fact.DrawCount + jackpots = append(jackpots, fact) + case domain.UserProfileHitSpendMilestone: + delta.CumulativeSpendJackpotCount += fact.DrawCount + jackpots = append(jackpots, fact) + case domain.UserProfileHitOrdinary: + if fact.PayoutCoins > 0 { + delta.OrdinaryWinCount += fact.DrawCount + if fact.HighOrdinary { + delta.HighMultiplierOrdinaryWinCount += fact.DrawCount + } + } + default: + delta.ZeroDrawCount += fact.DrawCount + } + if fact.GuaranteeHit && fact.PayoutCoins > 0 { + delta.GuaranteeHitCount += fact.DrawCount + } + switch fact.RewardStatus { + case domain.StatusPending: + delta.PendingDrawCount += fact.DrawCount + case domain.StatusFailed: + delta.FailedDrawCount += fact.DrawCount + default: + delta.GrantedDrawCount += fact.DrawCount + } + if fact.MultiplierPPM > delta.MaxMultiplierPPM { + delta.MaxMultiplierPPM = fact.MultiplierPPM + } + if fact.PayoutCoins > delta.MaxRewardCoins { + delta.MaxRewardCoins = fact.PayoutCoins + } + multiplierKey := luckyUserMultiplierKey{ProfileKey: key, HitType: fact.HitType, MultiplierPPM: fact.MultiplierPPM} + multiplier := multipliers[multiplierKey] + if multiplier == nil { + multiplier = &luckyUserMultiplierDelta{} + multipliers[multiplierKey] = multiplier + } + multiplier.DrawCount += fact.DrawCount + multiplier.WagerCoins += fact.WagerCoins + multiplier.PayoutCoins += fact.PayoutCoins + } + for key, delta := range profiles { + if err := r.upsertLuckyGiftUserProfileDelta(ctx, tx, key, *delta, nowMS); err != nil { + return err + } + } + for key, delta := range multipliers { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO lucky_gift_user_multiplier_stats ( + app_code, pool_id, strategy_version, identity_type, identity_key, hit_type, multiplier_ppm, + draw_count, wager_coins, payout_coins, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + draw_count = draw_count + VALUES(draw_count), + wager_coins = wager_coins + VALUES(wager_coins), + payout_coins = payout_coins + VALUES(payout_coins), + updated_at_ms = VALUES(updated_at_ms)`, + key.ProfileKey.AppCode, key.ProfileKey.PoolID, key.ProfileKey.StrategyVersion, key.ProfileKey.IdentityType, key.ProfileKey.IdentityKey, + key.HitType, key.MultiplierPPM, delta.DrawCount, delta.WagerCoins, delta.PayoutCoins, nowMS); err != nil { + return err + } + } + for _, fact := range jackpots { + conditionsJSON, err := json.Marshal(fact.Conditions) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO lucky_gift_user_jackpot_hits ( + app_code, source_type, source_draw_id, pool_id, strategy_version, identity_type, identity_key, + request_id, rule_version, stage, mechanism, reason_code, tier_id, + multiplier_ppm, wager_coins, payout_coins, conditions_json, occurred_at_ms, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + fact.AppCode, fact.SourceType, fact.SourceDrawID, fact.PoolID, fact.StrategyVersion, fact.IdentityType, fact.IdentityKey, + fact.RequestID, fact.RuleVersion, fact.Stage, fact.HitType, fact.ReasonCode, fact.TierID, + fact.MultiplierPPM, fact.WagerCoins, fact.PayoutCoins, string(conditionsJSON), fact.OccurredAtMS, nowMS); err != nil { + return err + } + } + return nil +} + +func (r *Repository) upsertLuckyGiftUserProfileDelta(ctx context.Context, tx *sql.Tx, key luckyUserProfileKey, delta luckyUserProfileDelta, nowMS int64) error { + latest := delta.Latest + if _, err := tx.ExecContext(ctx, ` + INSERT INTO lucky_gift_user_profiles ( + app_code, pool_id, strategy_version, identity_type, identity_key, strategy_user_id, internal_user_id, external_user_id, + rule_version, current_stage, total_draws, total_wager_coins, total_payout_coins, + total_base_reward_coins, ordinary_win_count, high_multiplier_ordinary_win_count, + rtp_compensation_jackpot_count, cumulative_spend_jackpot_count, guarantee_hit_count, zero_draw_count, + granted_draw_count, pending_draw_count, failed_draw_count, max_multiplier_ppm, max_reward_coins, + last_downweight_active, first_draw_at_ms, last_draw_at_ms, aggregated_at_ms, created_at_ms, updated_at_ms + ) VALUES (`+luckySQLPlaceholders(31)+`) + ON DUPLICATE KEY UPDATE + strategy_user_id = VALUES(strategy_user_id), + internal_user_id = VALUES(internal_user_id), + external_user_id = VALUES(external_user_id), + rule_version = IF(VALUES(last_draw_at_ms) >= last_draw_at_ms, VALUES(rule_version), rule_version), + current_stage = IF(VALUES(last_draw_at_ms) >= last_draw_at_ms, VALUES(current_stage), current_stage), + total_draws = total_draws + VALUES(total_draws), + total_wager_coins = total_wager_coins + VALUES(total_wager_coins), + total_payout_coins = total_payout_coins + VALUES(total_payout_coins), + total_base_reward_coins = total_base_reward_coins + VALUES(total_base_reward_coins), + ordinary_win_count = ordinary_win_count + VALUES(ordinary_win_count), + high_multiplier_ordinary_win_count = high_multiplier_ordinary_win_count + VALUES(high_multiplier_ordinary_win_count), + rtp_compensation_jackpot_count = rtp_compensation_jackpot_count + VALUES(rtp_compensation_jackpot_count), + cumulative_spend_jackpot_count = cumulative_spend_jackpot_count + VALUES(cumulative_spend_jackpot_count), + guarantee_hit_count = guarantee_hit_count + VALUES(guarantee_hit_count), + zero_draw_count = zero_draw_count + VALUES(zero_draw_count), + granted_draw_count = granted_draw_count + VALUES(granted_draw_count), + pending_draw_count = pending_draw_count + VALUES(pending_draw_count), + failed_draw_count = failed_draw_count + VALUES(failed_draw_count), + max_multiplier_ppm = GREATEST(max_multiplier_ppm, VALUES(max_multiplier_ppm)), + max_reward_coins = GREATEST(max_reward_coins, VALUES(max_reward_coins)), + last_downweight_active = IF(VALUES(last_draw_at_ms) >= last_draw_at_ms, VALUES(last_downweight_active), last_downweight_active), + first_draw_at_ms = IF(first_draw_at_ms = 0, VALUES(first_draw_at_ms), LEAST(first_draw_at_ms, VALUES(first_draw_at_ms))), + last_draw_at_ms = GREATEST(last_draw_at_ms, VALUES(last_draw_at_ms)), + aggregated_at_ms = GREATEST(aggregated_at_ms, VALUES(aggregated_at_ms)), + updated_at_ms = VALUES(updated_at_ms)`, + key.AppCode, key.PoolID, key.StrategyVersion, key.IdentityType, key.IdentityKey, latest.StrategyUserID, latest.InternalUserID, latest.ExternalUserID, + latest.RuleVersion, latest.Stage, delta.TotalDraws, delta.TotalWagerCoins, delta.TotalPayoutCoins, + delta.TotalBaseRewardCoins, delta.OrdinaryWinCount, delta.HighMultiplierOrdinaryWinCount, + delta.RTPCompensationJackpotCount, delta.CumulativeSpendJackpotCount, delta.GuaranteeHitCount, delta.ZeroDrawCount, + delta.GrantedDrawCount, delta.PendingDrawCount, delta.FailedDrawCount, delta.MaxMultiplierPPM, delta.MaxRewardCoins, + latest.Downweight, delta.FirstDrawAtMS, delta.LastDrawAtMS, nowMS, nowMS, nowMS); err != nil { + return err + } + return r.syncLuckyGiftUserProfileSortState(ctx, tx, key) +} + +// syncLuckyGiftUserProfileSortState 在同一开奖/回灌事务内固化所有列表排序字段。列表因此不再 +// JOIN lucky_user_states,也不需要对累计 RTP 或大奖数做表达式 filesort;状态行缺失时明确回落 0。 +func (r *Repository) syncLuckyGiftUserProfileSortState(ctx context.Context, tx *sql.Tx, key luckyUserProfileKey) error { + _, err := tx.ExecContext(ctx, ` + UPDATE lucky_gift_user_profiles p + LEFT JOIN lucky_user_states s + ON s.app_code = p.app_code AND s.user_id = p.strategy_user_id AND s.gift_id = p.pool_id + SET p.lifetime_rtp_ppm = CASE + WHEN p.total_wager_coins <= 0 THEN 0 + ELSE CAST(LEAST(CAST(9223372036854775807 AS DECIMAL(65,0)), + FLOOR(CAST(p.total_payout_coins AS DECIMAL(65,0)) * 1000000 / p.total_wager_coins)) AS SIGNED) + END, + p.jackpot_count = p.rtp_compensation_jackpot_count + p.cumulative_spend_jackpot_count, + p.current_equivalent_draws = COALESCE(s.equivalent_draws, 0), + p.current_loss_streak = COALESCE(s.loss_streak, 0), + p.current_pending_spend_jackpot_tokens = COALESCE(s.pending_spend_jackpot_tokens, 0) + WHERE p.app_code = ? AND p.pool_id = ? AND p.strategy_version = ? + AND p.identity_type = ? AND p.identity_key = ?`, + key.AppCode, key.PoolID, key.StrategyVersion, key.IdentityType, key.IdentityKey) + return err +} + +func (r *Repository) ListLuckyGiftUserProfiles(ctx context.Context, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error) { + if r == nil || r.db == nil { + return nil, 0, "", false, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + // COUNT、当前页、窗口指标必须来自同一 InnoDB 一致性快照;否则并发开奖可能让 + // total/累计值仍是旧提交,而 24h 窗口已经包含新提交,页面同一行会自相矛盾。 + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + if err != nil { + return nil, 0, "", false, err + } + defer func() { _ = tx.Rollback() }() + profiles, total, nextCursor, hasMore, err := r.listLuckyGiftUserProfiles(ctx, tx, query, nowMS) + if err != nil { + return nil, 0, "", false, err + } + if err := tx.Commit(); err != nil { + return nil, 0, "", false, err + } + return profiles, total, nextCursor, hasMore, nil +} + +func (r *Repository) listLuckyGiftUserProfiles(ctx context.Context, q luckyRuleQueryer, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error) { + rule, found, err := r.getLuckyGiftRuleConfig(ctx, q, query.AppCode, query.PoolID, false) + if err != nil { + return nil, 0, "", false, err + } + if !found { + return []domain.UserProfile{}, 0, "", false, nil + } + whereSQL, args, err := luckyUserProfileWhereClause(query, rule.StrategyVersion) + if err != nil { + return nil, 0, "", false, err + } + order, err := luckyUserProfileOrderClause(query.SortBy, query.SortDirection) + if err != nil { + return nil, 0, "", false, err + } + var total int64 + if err := q.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM lucky_gift_user_profiles p FORCE INDEX (`+luckyUserProfileCountIndex(query, order)+`) + WHERE `+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, "", false, err + } + listWhereSQL := whereSQL + listArgs := append([]any(nil), args...) + if query.Cursor != "" { + cursor, cursorErr := decodeLuckyUserProfileListCursor(query.Cursor, query, rule.StrategyVersion, order) + if cursorErr != nil { + return nil, 0, "", false, cursorErr + } + comparison := "<" + if order.Direction == "ASC" { + comparison = ">" + } + // 三列统一方向,和 FORCE 的 DESC 复合索引完全一致;ASC 由 InnoDB 反向扫描同一索引。 + listWhereSQL += " AND (" + order.Column + " " + comparison + " ? OR (" + order.Column + " = ? AND " + + "(p.identity_type " + comparison + " ? OR (p.identity_type = ? AND p.identity_key " + comparison + " ?))))" + listArgs = append(listArgs, cursor.SortValue, cursor.SortValue, cursor.IdentityType, cursor.IdentityType, cursor.IdentityKey) + } + listArgs = append(listArgs, int(query.PageSize)+1) + rows, err := q.QueryContext(ctx, ` + SELECT p.app_code, p.pool_id, p.identity_type, p.identity_key, p.strategy_user_id, + p.internal_user_id, p.external_user_id, p.rule_version, p.strategy_version, p.current_stage, + p.total_draws, p.current_equivalent_draws, p.current_loss_streak, + p.current_pending_spend_jackpot_tokens, p.total_draws, p.total_wager_coins, + p.total_payout_coins, p.lifetime_rtp_ppm, p.total_base_reward_coins, p.ordinary_win_count, + p.high_multiplier_ordinary_win_count, p.rtp_compensation_jackpot_count, + p.cumulative_spend_jackpot_count, p.guarantee_hit_count, p.zero_draw_count, + p.granted_draw_count, p.pending_draw_count, p.failed_draw_count, p.max_multiplier_ppm, + p.max_reward_coins, p.first_draw_at_ms, p.last_draw_at_ms, p.aggregated_at_ms + FROM lucky_gift_user_profiles p FORCE INDEX (`+order.Index+`) + WHERE `+listWhereSQL+` + ORDER BY `+order.Column+` `+order.Direction+`, p.identity_type `+order.Direction+`, p.identity_key `+order.Direction+` + LIMIT ?`, listArgs...) + if err != nil { + return nil, 0, "", false, err + } + defer rows.Close() + profiles := make([]domain.UserProfile, 0, query.PageSize+1) + for rows.Next() { + var profile domain.UserProfile + var totalDraws, totalWager, totalPayout, lifetimeRTPPPM int64 + if err := rows.Scan( + &profile.AppCode, &profile.PoolID, &profile.IdentityType, &profile.IdentityKey, &profile.StrategyUserID, + &profile.InternalUserID, &profile.ExternalUserID, &profile.RuleVersion, &profile.StrategyVersion, &profile.Stage, + &profile.PaidDraws, &profile.EquivalentDraws, &profile.LossStreak, &profile.PendingSpendJackpotTokens, + &totalDraws, &totalWager, &totalPayout, &lifetimeRTPPPM, &profile.BaseRewardCoins, &profile.OrdinaryWinCount, + &profile.HighMultiplierOrdinaryWinCount, &profile.RTPCompensationJackpotCount, + &profile.CumulativeSpendJackpotCount, &profile.GuaranteeHitCount, &profile.ZeroDrawCount, + &profile.GrantedDrawCount, &profile.PendingDrawCount, &profile.FailedDrawCount, &profile.MaxMultiplierPPM, + &profile.MaxRewardCoins, &profile.FirstDrawAtMS, &profile.LastDrawAtMS, &profile.AggregatedAtMS, + ); err != nil { + return nil, 0, "", false, err + } + profile.Lifetime = luckyUserProfileWindow(totalDraws, totalWager, totalPayout) + if totalWager > 0 { + profile.Lifetime.RTPPPM = lifetimeRTPPPM + profile.Lifetime.HasRTP = true + } + if nowMS > profile.AggregatedAtMS { + // data_lag 表达后台读到的画像距当前快照有多久;抽奖时间到聚合时间只是处理耗时, + // 会把历史回灌误显示成实时画像延迟,无法回答运营真正关心的“这行数据有多旧”。 + profile.DataLagMS = nowMS - profile.AggregatedAtMS + } + profiles = append(profiles, profile) + } + if err := rows.Err(); err != nil { + return nil, 0, "", false, err + } + hasMore := len(profiles) > int(query.PageSize) + if hasMore { + profiles = profiles[:query.PageSize] + } + if err := r.loadLuckyGiftUserProfileWindows(ctx, q, query.AppCode, query.PoolID, rule.StrategyVersion, profiles, nowMS); err != nil { + return nil, 0, "", false, err + } + applyLuckyGiftUserProfileCurrentControls(rule, profiles) + nextCursor := "" + if hasMore && len(profiles) > 0 { + nextCursor, err = encodeLuckyUserProfileListCursor(query, rule.StrategyVersion, order, profiles[len(profiles)-1]) + if err != nil { + return nil, 0, "", false, err + } + } + return profiles, total, nextCursor, hasMore, nil +} + +func luckyUserProfileCountIndex(query domain.UserProfileQuery, order luckyUserProfileOrder) string { + // 精确身份搜索优先使用专用索引;其余状态 COUNT 选择同前缀物化索引,永不回表 JOIN current state。 + if query.InternalUserID > 0 && query.ExternalUserID == "" { + return "idx_lucky_user_profile_internal" + } + if query.ExternalUserID != "" && query.InternalUserID <= 0 { + return "idx_lucky_user_profile_external" + } + if query.Stage != "" { + return "idx_lucky_user_profile_stage" + } + switch query.Status { + case "jackpot": + return "idx_lucky_user_profile_sort_jackpot" + case "loss_streak": + return "idx_lucky_user_profile_sort_loss" + case "pending_token": + return "idx_lucky_user_profile_pending" + default: + return order.Index + } +} + +func luckyUserProfileWhereClause(query domain.UserProfileQuery, strategyVersion string) (string, []any, error) { + where := []string{"p.app_code = ?", "p.pool_id = ?", "p.strategy_version = ?"} + args := []any{query.AppCode, query.PoolID, strategyVersion} + identityType := strings.TrimSpace(query.IdentityType) + if identityType != "" { + where = append(where, "p.identity_type = ?") + args = append(args, identityType) + } + // identity_type=all 时内部和外部搜索条件必须是 OR;AND 会让任何一行都不可能同时满足两种身份。 + if identityType == "" && query.InternalUserID > 0 && query.ExternalUserID != "" { + where = append(where, "((p.identity_type = 'internal' AND p.internal_user_id = ?) OR (p.identity_type = 'external' AND p.external_user_id = ?))") + args = append(args, query.InternalUserID, query.ExternalUserID) + } else { + if query.InternalUserID > 0 { + where = append(where, "p.internal_user_id = ?") + args = append(args, query.InternalUserID) + } + if query.ExternalUserID != "" { + where = append(where, "p.external_user_id = ?") + args = append(args, query.ExternalUserID) + } + } + if query.Stage != "" { + where = append(where, "p.current_stage = ?") + args = append(args, query.Stage) + } + switch query.Status { + case "": + case "jackpot": + where = append(where, "p.jackpot_count > 0") + case "loss_streak": + where = append(where, "p.current_loss_streak > 0") + case "pending_token": + where = append(where, "p.current_pending_spend_jackpot_tokens > 0") + default: + return "", nil, xerr.New(xerr.InvalidArgument, "unsupported lucky gift user profile status") + } + return strings.Join(where, " AND "), args, nil +} + +type luckyUserProfileOrder struct { + Name string + Column string + Index string + Direction string +} + +func luckyUserProfileOrderClause(sortBy, direction string) (luckyUserProfileOrder, error) { + order := luckyUserProfileOrder{Name: "last_draw_at", Column: "p.last_draw_at_ms", Index: "idx_lucky_user_profile_sort_last"} + switch strings.TrimSpace(sortBy) { + case "", "last_draw_at": + case "lifetime_rtp": + order.Name, order.Column, order.Index = "lifetime_rtp", "p.lifetime_rtp_ppm", "idx_lucky_user_profile_sort_rtp" + case "lifetime_wager": + order.Name, order.Column, order.Index = "lifetime_wager", "p.total_wager_coins", "idx_lucky_user_profile_sort_wager" + case "lifetime_payout": + order.Name, order.Column, order.Index = "lifetime_payout", "p.total_payout_coins", "idx_lucky_user_profile_sort_payout" + case "jackpot_count": + order.Name, order.Column, order.Index = "jackpot_count", "p.jackpot_count", "idx_lucky_user_profile_sort_jackpot" + case "loss_streak": + order.Name, order.Column, order.Index = "loss_streak", "p.current_loss_streak", "idx_lucky_user_profile_sort_loss" + default: + return luckyUserProfileOrder{}, xerr.New(xerr.InvalidArgument, "unsupported lucky gift user profile sort_by") + } + direction = strings.ToUpper(strings.TrimSpace(direction)) + if direction == "" { + direction = "DESC" + } + if direction != "ASC" && direction != "DESC" { + return luckyUserProfileOrder{}, xerr.New(xerr.InvalidArgument, "unsupported lucky gift user profile sort_direction") + } + order.Direction = direction + return order, nil +} + +func luckyUserProfileCursorScope(query domain.UserProfileQuery, strategyVersion string, order luckyUserProfileOrder) string { + canonical := strings.Join([]string{ + query.AppCode, query.PoolID, strategyVersion, query.IdentityType, + strconv.FormatInt(query.InternalUserID, 10), query.ExternalUserID, query.Stage, query.Status, + order.Name, order.Direction, + }, "\x00") + sum := sha256.Sum256([]byte(canonical)) + return fmt.Sprintf("%x", sum[:]) +} + +func encodeLuckyUserProfileListCursor(query domain.UserProfileQuery, strategyVersion string, order luckyUserProfileOrder, profile domain.UserProfile) (string, error) { + cursor := luckyUserProfileListCursor{ + Version: 1, ScopeHash: luckyUserProfileCursorScope(query, strategyVersion, order), + SortValue: luckyUserProfileSortValue(profile, order.Name), + IdentityType: profile.IdentityType, IdentityKey: profile.IdentityKey, + } + payload, err := json.Marshal(cursor) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload), nil +} + +func decodeLuckyUserProfileListCursor(raw string, query domain.UserProfileQuery, strategyVersion string, order luckyUserProfileOrder) (luckyUserProfileListCursor, error) { + payload, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil { + return luckyUserProfileListCursor{}, xerr.New(xerr.InvalidArgument, "invalid lucky gift user profile cursor") + } + var cursor luckyUserProfileListCursor + if json.Unmarshal(payload, &cursor) != nil || cursor.Version != 1 || + cursor.ScopeHash != luckyUserProfileCursorScope(query, strategyVersion, order) || + (cursor.IdentityType != domain.UserProfileIdentityInternal && cursor.IdentityType != domain.UserProfileIdentityExternal) || + cursor.IdentityKey == "" || len(cursor.IdentityKey) > 128 { + return luckyUserProfileListCursor{}, xerr.New(xerr.InvalidArgument, "invalid or mismatched lucky gift user profile cursor") + } + return cursor, nil +} + +func luckyUserProfileSortValue(profile domain.UserProfile, sortName string) int64 { + switch sortName { + case "lifetime_rtp": + return profile.Lifetime.RTPPPM + case "lifetime_wager": + return profile.Lifetime.WagerCoins + case "lifetime_payout": + return profile.Lifetime.PayoutCoins + case "jackpot_count": + return profile.RTPCompensationJackpotCount + profile.CumulativeSpendJackpotCount + case "loss_streak": + return profile.LossStreak + default: + return profile.LastDrawAtMS + } +} + +func luckyUserProfileWindow(draws, wager, payout int64) domain.UserProfileWindow { + window := domain.UserProfileWindow{Draws: draws, WagerCoins: wager, PayoutCoins: payout, NetCoins: payout - wager} + if ratio, ok := (domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout}).RatioPPM(); ok { + window.RTPPPM = ratio + window.HasRTP = true + } + return window +} + +type luckyUserProfileWindowAggregate struct { + OneHourDraws, OneHourWager, OneHourPayout int64 + TwentyFourDraws, TwentyFourWager, TwentyFourPayout int64 + FortyEightDraws, FortyEightWager, FortyEightPayout int64 +} + +func (r *Repository) loadLuckyGiftUserProfileWindows(ctx context.Context, q luckyRuleQueryer, appCode, poolID, strategyVersion string, profiles []domain.UserProfile, nowMS int64) error { + if len(profiles) == 0 { + return nil + } + var identitySQL strings.Builder + args := []any{nowMS - 48*time.Hour.Milliseconds(), nowMS, appCode, poolID, strategyVersion} + for index, profile := range profiles { + if index > 0 { + identitySQL.WriteString(" OR ") + } + identitySQL.WriteString("(identity_type = ? AND identity_key = ?)") + args = append(args, profile.IdentityType, profile.IdentityKey) + } + args = append(args, + nowMS-time.Hour.Milliseconds(), nowMS-time.Hour.Milliseconds(), nowMS-time.Hour.Milliseconds(), + nowMS-24*time.Hour.Milliseconds(), nowMS-24*time.Hour.Milliseconds(), nowMS-24*time.Hour.Milliseconds(), + ) + rows, err := q.QueryContext(ctx, ` + SELECT identity_type, identity_key, + COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN draw_count ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN wager_coins ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN payout_coins ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN draw_count ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN wager_coins ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN payout_coins ELSE 0 END), 0), + COALESCE(SUM(draw_count), 0), COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0) + FROM lucky_gift_user_profile_events FORCE INDEX (idx_lucky_user_profile_event_window) + WHERE occurred_at_ms >= ? AND occurred_at_ms < ? AND app_code = ? AND pool_id = ? AND strategy_version = ? + AND (`+identitySQL.String()+`) + GROUP BY identity_type, identity_key`, rotateLuckyUserWindowArgs(args, len(profiles))...) + if err != nil { + return err + } + defer rows.Close() + byIdentity := make(map[string]luckyUserProfileWindowAggregate, len(profiles)) + for rows.Next() { + var identityType, identityKey string + var aggregate luckyUserProfileWindowAggregate + if err := rows.Scan(&identityType, &identityKey, + &aggregate.OneHourDraws, &aggregate.OneHourWager, &aggregate.OneHourPayout, + &aggregate.TwentyFourDraws, &aggregate.TwentyFourWager, &aggregate.TwentyFourPayout, + &aggregate.FortyEightDraws, &aggregate.FortyEightWager, &aggregate.FortyEightPayout); err != nil { + return err + } + byIdentity[identityType+"\x00"+identityKey] = aggregate + } + if err := rows.Err(); err != nil { + return err + } + for index := range profiles { + aggregate := byIdentity[profiles[index].IdentityType+"\x00"+profiles[index].IdentityKey] + profiles[index].OneHour = luckyUserProfileWindow(aggregate.OneHourDraws, aggregate.OneHourWager, aggregate.OneHourPayout) + profiles[index].TwentyFourHour = luckyUserProfileWindow(aggregate.TwentyFourDraws, aggregate.TwentyFourWager, aggregate.TwentyFourPayout) + profiles[index].FortyEightHour = luckyUserProfileWindow(aggregate.FortyEightDraws, aggregate.FortyEightWager, aggregate.FortyEightPayout) + } + return nil +} + +// rotateLuckyUserWindowArgs 将固定窗口参数放到 SELECT 占位符前,再接 WHERE 和当前页身份键。 +// 调用方按可读顺序构造参数,此处只做一次机械重排,避免动态 SQL 拼接错位。 +func rotateLuckyUserWindowArgs(args []any, profileCount int) []any { + whereFixed := args[:5] + identities := args[5 : 5+profileCount*2] + windowCutoffs := args[5+profileCount*2:] + out := make([]any, 0, len(args)) + out = append(out, windowCutoffs...) + out = append(out, whereFixed...) + out = append(out, identities...) + return out +} + +func applyLuckyGiftUserProfileCurrentControls(rule domain.RuleConfig, profiles []domain.UserProfile) { + if len(profiles) == 0 { + return + } + for index := range profiles { + profile := &profiles[index] + profile.DownweightFactorPPM = luckyPPMScale + profile.User24HourRTPThresholdPPM = rule.User24HourRTPThresholdPPM + if rule.LossStreakGuarantee > profile.LossStreak { + profile.GuaranteeDrawsRemaining = rule.LossStreakGuarantee - profile.LossStreak + } + if profile.StrategyVersion != domain.StrategyDynamicV3 { + if config, configErr := luckyRuntimeConfigFromRuleConfig(rule); configErr == nil { + profile.Stage = luckyExperiencePool(profile.EquivalentDraws, config) + } + } + factor := rule.User24HourOrdinaryWinFactorPPM + if factor <= 0 || factor > luckyPPMScale { + factor = luckyPPMScale + } + profile.DownweightFactorPPM = factor + profile.DownweightActive = rule.StrategyVersion == domain.StrategyDynamicV3 && + rule.User24HourRTPThresholdPPM > 0 && factor < luckyPPMScale && + profile.TwentyFourHour.HasRTP && profile.TwentyFourHour.RTPPPM > rule.User24HourRTPThresholdPPM + } +} + +func (r *Repository) GetLuckyGiftUserProfile(ctx context.Context, query domain.UserProfileQuery, nowMS int64) (domain.UserProfileDetail, bool, error) { + query.Page = 1 + query.PageSize = 1 + query.Stage = "" + query.Status = "" + query.SortBy = "last_draw_at" + // 展开详情仍必须和列表头部画像、倍率分布、大奖总数处于同一快照;加载更多页之间 + // 可以看到新开奖,但单次响应内部不能出现“总数 10、当前页第 11 条”的撕裂数据。 + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + if err != nil { + return domain.UserProfileDetail{}, false, err + } + defer func() { _ = tx.Rollback() }() + profiles, _, _, _, err := r.listLuckyGiftUserProfiles(ctx, tx, query, nowMS) + if err != nil { + return domain.UserProfileDetail{}, false, err + } + if len(profiles) == 0 { + return domain.UserProfileDetail{}, false, tx.Commit() + } + profile := profiles[0] + detail := domain.UserProfileDetail{ + Profile: profile, JackpotPage: query.JackpotPage, JackpotPageSize: query.JackpotPageSize, + } + rows, err := tx.QueryContext(ctx, ` + SELECT hit_type, multiplier_ppm, draw_count, wager_coins, payout_coins + FROM lucky_gift_user_multiplier_stats + WHERE app_code = ? AND pool_id = ? AND strategy_version = ? AND identity_type = ? AND identity_key = ? + ORDER BY hit_type ASC, multiplier_ppm ASC`, profile.AppCode, profile.PoolID, profile.StrategyVersion, profile.IdentityType, profile.IdentityKey) + if err != nil { + return domain.UserProfileDetail{}, false, err + } + for rows.Next() { + var stat domain.UserProfileMultiplierStat + if err := rows.Scan(&stat.HitType, &stat.MultiplierPPM, &stat.DrawCount, &stat.WagerCoins, &stat.PayoutCoins); err != nil { + _ = rows.Close() + return domain.UserProfileDetail{}, false, err + } + detail.MultiplierDistribution = append(detail.MultiplierDistribution, stat) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return domain.UserProfileDetail{}, false, err + } + if err := rows.Close(); err != nil { + return domain.UserProfileDetail{}, false, err + } + if err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM lucky_gift_user_jackpot_hits + WHERE app_code = ? AND pool_id = ? AND strategy_version = ? AND identity_type = ? AND identity_key = ?`, + profile.AppCode, profile.PoolID, profile.StrategyVersion, profile.IdentityType, profile.IdentityKey).Scan(&detail.JackpotHitTotal); err != nil { + return domain.UserProfileDetail{}, false, err + } + jackpotWhere := `app_code = ? AND pool_id = ? AND strategy_version = ? AND identity_type = ? AND identity_key = ?` + jackpotArgs := []any{profile.AppCode, profile.PoolID, profile.StrategyVersion, profile.IdentityType, profile.IdentityKey} + if query.JackpotCursor != "" { + cursor, cursorErr := decodeLuckyUserJackpotCursor(query.JackpotCursor, profile) + if cursorErr != nil { + return domain.UserProfileDetail{}, false, cursorErr + } + jackpotWhere += ` AND (occurred_at_ms < ? OR (occurred_at_ms = ? AND + (source_type < ? OR (source_type = ? AND source_draw_id < ?))))` + jackpotArgs = append(jackpotArgs, cursor.OccurredAtMS, cursor.OccurredAtMS, cursor.SourceType, cursor.SourceType, cursor.DrawID) + } + jackpotArgs = append(jackpotArgs, query.JackpotPageSize+1) + jackpotRows, err := tx.QueryContext(ctx, ` + SELECT source_type, source_draw_id, request_id, rule_version, stage, mechanism, reason_code, tier_id, + multiplier_ppm, wager_coins, payout_coins, occurred_at_ms, conditions_json + FROM lucky_gift_user_jackpot_hits FORCE INDEX (idx_lucky_user_jackpot_profile) + WHERE `+jackpotWhere+` + ORDER BY occurred_at_ms DESC, source_type DESC, source_draw_id DESC + LIMIT ?`, jackpotArgs...) + if err != nil { + return domain.UserProfileDetail{}, false, err + } + defer jackpotRows.Close() + for jackpotRows.Next() { + var hit domain.UserProfileJackpotHit + var conditionsJSON []byte + if err := jackpotRows.Scan(&hit.SourceType, &hit.DrawID, &hit.RequestID, &hit.RuleVersion, &hit.Stage, &hit.Mechanism, + &hit.ReasonCode, &hit.TierID, &hit.MultiplierPPM, &hit.WagerCoins, &hit.PayoutCoins, + &hit.OccurredAtMS, &conditionsJSON); err != nil { + return domain.UserProfileDetail{}, false, err + } + var conditions []domain.StrategyConditionTrace + if err := json.Unmarshal(conditionsJSON, &conditions); err != nil { + return domain.UserProfileDetail{}, false, err + } + for _, condition := range conditions { + hit.Conditions = append(hit.Conditions, domain.UserProfileJackpotCondition{ + Name: condition.Name, Numerator: condition.Numerator, Denominator: condition.Denominator, + RatioPPM: condition.RatioPPM, LimitPPM: condition.LimitPPM, FactorPPM: condition.FactorPPM, + Passed: condition.Passed, Reason: condition.Reason, + }) + } + hit.ReasonSummary = luckyUserProfileJackpotReasonSummary(hit.Mechanism) + detail.JackpotHits = append(detail.JackpotHits, hit) + } + if err := jackpotRows.Err(); err != nil { + return domain.UserProfileDetail{}, false, err + } + if err := jackpotRows.Close(); err != nil { + return domain.UserProfileDetail{}, false, err + } + detail.JackpotHasMore = len(detail.JackpotHits) > int(query.JackpotPageSize) + if detail.JackpotHasMore { + detail.JackpotHits = detail.JackpotHits[:query.JackpotPageSize] + last := detail.JackpotHits[len(detail.JackpotHits)-1] + detail.NextJackpotCursor, err = encodeLuckyUserJackpotCursor(profile, last) + if err != nil { + return domain.UserProfileDetail{}, false, err + } + } + if err := tx.Commit(); err != nil { + return domain.UserProfileDetail{}, false, err + } + return detail, true, nil +} + +func luckyUserJackpotCursorScope(profile domain.UserProfile) string { + canonical := strings.Join([]string{ + profile.AppCode, profile.PoolID, profile.StrategyVersion, profile.IdentityType, profile.IdentityKey, + }, "\x00") + sum := sha256.Sum256([]byte(canonical)) + return fmt.Sprintf("%x", sum[:]) +} + +func encodeLuckyUserJackpotCursor(profile domain.UserProfile, hit domain.UserProfileJackpotHit) (string, error) { + payload, err := json.Marshal(luckyUserJackpotCursor{ + Version: 1, ScopeHash: luckyUserJackpotCursorScope(profile), OccurredAtMS: hit.OccurredAtMS, + SourceType: hit.SourceType, DrawID: hit.DrawID, + }) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload), nil +} + +func decodeLuckyUserJackpotCursor(raw string, profile domain.UserProfile) (luckyUserJackpotCursor, error) { + payload, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil { + return luckyUserJackpotCursor{}, xerr.New(xerr.InvalidArgument, "invalid lucky gift jackpot cursor") + } + var cursor luckyUserJackpotCursor + if json.Unmarshal(payload, &cursor) != nil || cursor.Version != 1 || + cursor.ScopeHash != luckyUserJackpotCursorScope(profile) || cursor.OccurredAtMS < 0 || + cursor.SourceType == "" || len(cursor.SourceType) > 24 || cursor.DrawID == "" || len(cursor.DrawID) > 128 { + return luckyUserJackpotCursor{}, xerr.New(xerr.InvalidArgument, "invalid or mismatched lucky gift jackpot cursor") + } + return cursor, nil +} + +func luckyUserProfileJackpotReasonSummary(mechanism string) string { + switch mechanism { + case domain.UserProfileHitRTPCompensation: + return "用户满足补偿大奖的 RTP、成熟度、每日次数和奖池支付条件,随后在独立大奖奖档中命中。" + case domain.UserProfileHitSpendMilestone: + return "用户已有累计消费大奖资格,RTP、每日次数和奖池支付条件通过,本次命中并消耗一个资格。" + default: + return "本次由独立大奖路径命中;下方条件保留了开奖当刻的真实判断结果。" + } +} + +// RefreshLuckyGiftUserProfiles 用三个独立游标回灌内部逐抽、外部逐抽和外部旧聚合事实。 +// 游标从 0 开始且每批有上限;运营查询永远只读画像表,不会因为历史回灌扫描事实表。 +func (r *Repository) RefreshLuckyGiftUserProfiles(ctx context.Context, nowMS int64, batchSize int) (int, error) { + if r == nil || r.db == nil { + return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if batchSize <= 0 { + batchSize = 5000 + } + if batchSize > 50000 { + batchSize = 50000 + } + apps, err := r.listLuckyGiftUserProfileSourceApps(ctx) + if err != nil { + return 0, err + } + if app := strings.TrimSpace(appcode.FromContext(ctx)); app != "" { + apps[appcode.Normalize(app)] = struct{}{} + } + appCodes := make([]string, 0, len(apps)) + for app := range apps { + appCodes = append(appCodes, app) + } + sort.Strings(appCodes) + processed := 0 + for _, app := range appCodes { + count, err := r.refreshLuckyGiftUserProfileInternal(ctx, app, nowMS, batchSize) + if err != nil { + return processed, err + } + processed += count + count, err = r.refreshLuckyGiftUserProfileExternalItems(ctx, app, nowMS, batchSize) + if err != nil { + return processed, err + } + processed += count + count, err = r.refreshLuckyGiftUserProfileExternalLegacy(ctx, app, nowMS, batchSize) + if err != nil { + return processed, err + } + processed += count + } + return processed, nil +} + +func (r *Repository) listLuckyGiftUserProfileSourceApps(ctx context.Context) (map[string]struct{}, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT app_code FROM lucky_gift_rule_versions GROUP BY app_code + UNION + SELECT app_code FROM lucky_gift_user_profiles GROUP BY app_code + UNION + SELECT app_code FROM lucky_draw_pool_stat_cursors + WHERE cursor_name IN (?, ?, ?) + GROUP BY app_code`, luckyUserProfileCursorInternal, luckyUserProfileCursorExternalItem, luckyUserProfileCursorExternalLegacy) + if err != nil { + return nil, err + } + defer rows.Close() + apps := make(map[string]struct{}) + for rows.Next() { + var app string + if err := rows.Scan(&app); err != nil { + return nil, err + } + if app = appcode.Normalize(app); app != "" { + apps[app] = struct{}{} + } + } + return apps, rows.Err() +} + +func (r *Repository) lockLuckyGiftUserProfileCursor(ctx context.Context, tx *sql.Tx, appCode, cursorName string, nowMS int64) (luckyDrawStatsCursor, error) { + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO lucky_draw_pool_stat_cursors ( + app_code, cursor_name, last_draw_created_at_ms, last_draw_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, 0, '', ?, ?)`, appCode, cursorName, nowMS, nowMS); err != nil { + return luckyDrawStatsCursor{}, err + } + var cursor luckyDrawStatsCursor + if err := tx.QueryRowContext(ctx, ` + SELECT last_draw_created_at_ms, last_draw_id + FROM lucky_draw_pool_stat_cursors + WHERE app_code = ? AND cursor_name = ? + FOR UPDATE`, appCode, cursorName).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID); err != nil { + return luckyDrawStatsCursor{}, err + } + return cursor, nil +} + +func (r *Repository) advanceLuckyGiftUserProfileCursor(ctx context.Context, tx *sql.Tx, appCode, cursorName string, cursor luckyDrawStatsCursor, nowMS int64) error { + _, err := tx.ExecContext(ctx, ` + UPDATE lucky_draw_pool_stat_cursors + SET last_draw_created_at_ms = ?, last_draw_id = ?, updated_at_ms = ? + WHERE app_code = ? AND cursor_name = ?`, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, nowMS, appCode, cursorName) + return err +} + +func (r *Repository) refreshLuckyGiftUserProfileInternal(ctx context.Context, appCode string, nowMS int64, batchSize int) (int, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + cursor, err := r.lockLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorInternal, nowMS) + if err != nil { + return 0, err + } + rows, err := tx.QueryContext(ctx, ` + SELECT draw_id, command_id, user_id, pool_id, rule_version, experience_pool, selected_tier_id, + coin_spent, base_reward_coins, effective_reward_coins, high_multiplier, + candidate_tiers_json, reward_status, paid_at_ms, created_at_ms + FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created) + WHERE app_code = ? AND (created_at_ms > ? OR (created_at_ms = ? AND draw_id > ?)) + ORDER BY created_at_ms ASC, draw_id ASC + LIMIT ?`, appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, batchSize) + if err != nil { + return 0, err + } + facts := make([]luckyUserProfileFact, 0, batchSize) + last := cursor + for rows.Next() { + var fact luckyUserProfileFact + var userID int64 + var highMultiplier bool + var snapshot []byte + if err := rows.Scan(&fact.SourceDrawID, &fact.RequestID, &userID, &fact.PoolID, &fact.RuleVersion, + &fact.Stage, &fact.TierID, &fact.WagerCoins, &fact.BaseRewardCoins, &fact.PayoutCoins, + &highMultiplier, &snapshot, &fact.RewardStatus, &fact.OccurredAtMS, &fact.CreatedAtMS); err != nil { + _ = rows.Close() + return 0, err + } + fact.AppCode = appCode + fact.SourceType = luckyUserProfileSourceInternal + fact.IdentityType = domain.UserProfileIdentityInternal + fact.IdentityKey = strconv.FormatInt(userID, 10) + fact.StrategyUserID = userID + fact.InternalUserID = userID + fact.DrawCount = 1 + fact.HighOrdinary = highMultiplier + fact.MultiplierPPM = luckyUserProfileMultiplierFromSnapshot(snapshot) + fact = luckyUserProfileFactFromSnapshot(fact, snapshot) + facts = append(facts, fact) + last = luckyDrawStatsCursor{LastDrawCreatedAtMS: fact.CreatedAtMS, LastDrawID: fact.SourceDrawID} + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, err + } + if err := rows.Close(); err != nil { + return 0, err + } + if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS); err != nil { + return 0, err + } + if len(facts) > 0 { + if err := r.advanceLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorInternal, last, nowMS); err != nil { + return 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return len(facts), nil +} + +func luckyUserProfileMultiplierFromSnapshot(snapshot []byte) int64 { + var data struct { + MultiplierPPM int64 `json:"multiplier_ppm"` + } + _ = json.Unmarshal(snapshot, &data) + return data.MultiplierPPM +} + +func (r *Repository) refreshLuckyGiftUserProfileExternalItems(ctx context.Context, appCode string, nowMS int64, batchSize int) (int, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + cursor, err := r.lockLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorExternalItem, nowMS) + if err != nil { + return 0, err + } + rows, err := tx.QueryContext(ctx, ` + SELECT draw_id, request_id, external_user_id, pool_id, rule_version, stage, selected_tier_id, + unit_amount, reward_amount, multiplier_ppm, candidate_snapshot_json, reward_status, + paid_at_ms, created_at_ms + FROM external_lucky_gift_draw_items FORCE INDEX (idx_external_lucky_item_profile_cursor) + WHERE app_code = ? AND (created_at_ms > ? OR (created_at_ms = ? AND draw_id > ?)) + ORDER BY created_at_ms ASC, draw_id ASC + LIMIT ?`, appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, batchSize) + if err != nil { + return 0, err + } + facts := make([]luckyUserProfileFact, 0, batchSize) + last := cursor + for rows.Next() { + var fact luckyUserProfileFact + var snapshot []byte + if err := rows.Scan(&fact.SourceDrawID, &fact.RequestID, &fact.ExternalUserID, &fact.PoolID, + &fact.RuleVersion, &fact.Stage, &fact.TierID, &fact.WagerCoins, &fact.PayoutCoins, + &fact.MultiplierPPM, &snapshot, &fact.RewardStatus, &fact.OccurredAtMS, &fact.CreatedAtMS); err != nil { + _ = rows.Close() + return 0, err + } + fact.AppCode = appCode + fact.SourceType = luckyUserProfileSourceExternalItem + fact.IdentityType = domain.UserProfileIdentityExternal + fact.IdentityKey = fact.ExternalUserID + fact.StrategyUserID = externalLuckyUserID(appCode, fact.ExternalUserID) + fact.DrawCount = 1 + fact.BaseRewardCoins = fact.PayoutCoins + fact = luckyUserProfileFactFromSnapshot(fact, snapshot) + facts = append(facts, fact) + last = luckyDrawStatsCursor{LastDrawCreatedAtMS: fact.CreatedAtMS, LastDrawID: fact.SourceDrawID} + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, err + } + if err := rows.Close(); err != nil { + return 0, err + } + if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS); err != nil { + return 0, err + } + if len(facts) > 0 { + if err := r.advanceLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorExternalItem, last, nowMS); err != nil { + return 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return len(facts), nil +} + +func (r *Repository) refreshLuckyGiftUserProfileExternalLegacy(ctx context.Context, appCode string, nowMS int64, batchSize int) (int, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + cursor, err := r.lockLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorExternalLegacy, nowMS) + if err != nil { + return 0, err + } + rows, err := tx.QueryContext(ctx, ` + SELECT d.draw_id, d.request_id, d.external_user_id, d.pool_id, d.gift_count, d.total_amount, + d.reward_amount, d.multiplier_ppm, d.reward_status, d.rule_version, d.selected_tier_id, + d.paid_at_ms, d.created_at_ms, COALESCE(rv.strategy_version, 'fixed_v2'), + EXISTS(SELECT 1 FROM external_lucky_gift_draw_items i + WHERE i.app_code = d.app_code AND i.request_id = d.request_id LIMIT 1) + FROM external_lucky_gift_draws d FORCE INDEX (idx_external_lucky_profile_cursor) + LEFT JOIN lucky_gift_rule_versions rv + ON rv.app_code = d.app_code AND rv.pool_id = d.pool_id AND rv.rule_version = d.rule_version + WHERE d.app_code = ? AND (d.created_at_ms > ? OR (d.created_at_ms = ? AND d.draw_id > ?)) + ORDER BY d.created_at_ms ASC, d.draw_id ASC + LIMIT ?`, appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, batchSize) + if err != nil { + return 0, err + } + facts := make([]luckyUserProfileFact, 0, batchSize) + last := cursor + rowCount := 0 + for rows.Next() { + var fact luckyUserProfileFact + var hasItems bool + if err := rows.Scan(&fact.SourceDrawID, &fact.RequestID, &fact.ExternalUserID, &fact.PoolID, + &fact.DrawCount, &fact.WagerCoins, &fact.PayoutCoins, &fact.MultiplierPPM, &fact.RewardStatus, + &fact.RuleVersion, &fact.TierID, &fact.OccurredAtMS, &fact.CreatedAtMS, &fact.StrategyVersion, &hasItems); err != nil { + _ = rows.Close() + return 0, err + } + rowCount++ + last = luckyDrawStatsCursor{LastDrawCreatedAtMS: fact.CreatedAtMS, LastDrawID: fact.SourceDrawID} + if hasItems { + continue + } + fact.AppCode = appCode + fact.SourceType = luckyUserProfileSourceExternalLegacy + fact.IdentityType = domain.UserProfileIdentityExternal + fact.IdentityKey = fact.ExternalUserID + fact.StrategyUserID = externalLuckyUserID(appCode, fact.ExternalUserID) + if fact.StrategyVersion == domain.StrategyFixedV2 { + // fixed_v2 的外部聚合行无论 gift_count 多大都只随机一次;dynamic_v3 才是逐份开奖。 + // dynamic_v3 正常会被 item 游标消费,这个分支只为兼容极早期缺 item 的聚合事实。 + fact.DrawCount = 1 + } + fact.BaseRewardCoins = fact.PayoutCoins + fact.HitType = luckyUserProfileHitType(fact.PayoutCoins, "", nil, nil) + // 旧外部聚合事实没有 high_multiplier 审计列,保持 false;不能自行发明倍率阈值补猜。 + fact.HighOrdinary = false + facts = append(facts, fact) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, err + } + if err := rows.Close(); err != nil { + return 0, err + } + if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS); err != nil { + return 0, err + } + if rowCount > 0 { + if err := r.advanceLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorExternalLegacy, last, nowMS); err != nil { + return 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return rowCount, nil +} + +type luckyUserProfileRewardStatusDelta struct { + Pending int64 + Granted int64 + Failed int64 +} + +// migrateLuckyGiftUserProfileRewardStatuses 与 lucky_draw_records 的终态更新共用事务。 +// 它先锁画像事件并记住旧状态,再原子迁移三个计数,避免初写 pending 的中奖在画像中永久停留。 +func (r *Repository) migrateLuckyGiftUserProfileRewardStatuses(ctx context.Context, tx *sql.Tx, appCode string, drawIDs []string, status string, onlyPending bool, nowMS int64) error { + if len(drawIDs) == 0 { + return nil + } + args := []any{appCode} + for _, drawID := range drawIDs { + args = append(args, drawID) + } + query := ` + SELECT pool_id, strategy_version, identity_type, identity_key, reward_status, draw_count + FROM lucky_gift_user_profile_events + WHERE app_code = ? AND source_type = 'internal' + AND source_draw_id IN (` + luckySQLPlaceholders(len(drawIDs)) + `) + AND reward_status <> ?` + args = append(args, status) + if onlyPending { + query += ` AND reward_status = ?` + args = append(args, domain.StatusPending) + } + query += ` FOR UPDATE` + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return err + } + deltas := make(map[luckyUserProfileKey]*luckyUserProfileRewardStatusDelta) + for rows.Next() { + var key luckyUserProfileKey + var oldStatus string + var drawCount int64 + key.AppCode = appCode + if err := rows.Scan(&key.PoolID, &key.StrategyVersion, &key.IdentityType, &key.IdentityKey, &oldStatus, &drawCount); err != nil { + _ = rows.Close() + return err + } + delta := deltas[key] + if delta == nil { + delta = &luckyUserProfileRewardStatusDelta{} + deltas[key] = delta + } + luckyUserProfileAddRewardStatusDelta(delta, oldStatus, -drawCount) + luckyUserProfileAddRewardStatusDelta(delta, status, drawCount) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if len(deltas) == 0 { + return nil + } + updateArgs := []any{status, appCode} + for _, drawID := range drawIDs { + updateArgs = append(updateArgs, drawID) + } + updateQuery := ` + UPDATE lucky_gift_user_profile_events + SET reward_status = ? + WHERE app_code = ? AND source_type = 'internal' + AND source_draw_id IN (` + luckySQLPlaceholders(len(drawIDs)) + `) + AND reward_status <> ?` + updateArgs = append(updateArgs, status) + if onlyPending { + updateQuery += ` AND reward_status = ?` + updateArgs = append(updateArgs, domain.StatusPending) + } + if _, err := tx.ExecContext(ctx, updateQuery, updateArgs...); err != nil { + return err + } + for key, delta := range deltas { + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_gift_user_profiles + SET pending_draw_count = GREATEST(pending_draw_count + ?, 0), + granted_draw_count = GREATEST(granted_draw_count + ?, 0), + failed_draw_count = GREATEST(failed_draw_count + ?, 0), + aggregated_at_ms = GREATEST(aggregated_at_ms, ?), updated_at_ms = ? + WHERE app_code = ? AND pool_id = ? AND strategy_version = ? AND identity_type = ? AND identity_key = ?`, + delta.Pending, delta.Granted, delta.Failed, nowMS, nowMS, + key.AppCode, key.PoolID, key.StrategyVersion, key.IdentityType, key.IdentityKey); err != nil { + return err + } + } + return nil +} + +func luckyUserProfileAddRewardStatusDelta(delta *luckyUserProfileRewardStatusDelta, status string, value int64) { + if delta == nil { + return + } + switch status { + case domain.StatusPending: + delta.Pending += value + case domain.StatusFailed: + delta.Failed += value + default: + delta.Granted += value + } +} diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go index 1470610e..30ee071b 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go @@ -87,17 +87,18 @@ func (s *LuckyGiftServer) BatchExecuteLuckyGiftDraw(ctx context.Context, req *lu func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luckygiftv1.ExternalGiftDrawRequest) (*luckygiftv1.ExternalGiftDrawResponse, error) { ctx = appcode.WithContext(ctx, req.GetAppCode()) result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{ - AppCode: req.GetAppCode(), - ExternalUserID: req.GetExternalUserId(), - DeviceID: req.GetDeviceId(), - RequestID: req.GetRequestId(), - GiftCount: req.GetGiftCount(), - UnitAmount: req.GetUnitAmount(), - TotalAmount: req.GetTotalAmount(), - Currency: req.GetCurrency(), - PaidAtMS: req.GetPaidAtMs(), - MetadataJSON: req.GetMetadataJson(), - PoolID: req.GetPoolId(), + AppCode: req.GetAppCode(), + ExternalUserID: req.GetExternalUserId(), + DeviceID: req.GetDeviceId(), + RequestID: req.GetRequestId(), + GiftCount: req.GetGiftCount(), + UnitAmount: req.GetUnitAmount(), + TotalAmount: req.GetTotalAmount(), + Currency: req.GetCurrency(), + PaidAtMS: req.GetPaidAtMs(), + MetadataJSON: req.GetMetadataJson(), + PoolID: req.GetPoolId(), + UserRegisteredAtMS: req.GetUserRegisteredAtMs(), }) if err != nil { return nil, xerr.ToGRPCError(err) @@ -118,6 +119,7 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma GiftCount: meta.GetGiftCount(), CoinSpent: meta.GetCoinSpent(), PaidAtMS: meta.GetPaidAtMs(), + UserRegisteredAtMS: meta.GetUserRegisteredAtMs(), VisibleRegionID: meta.GetVisibleRegionId(), CountryID: meta.GetCountryId(), SenderName: meta.GetSenderName(), @@ -222,6 +224,93 @@ func (s *AdminLuckyGiftServer) GetLuckyGiftDrawSummary(ctx context.Context, req return &luckygiftv1.GetLuckyGiftDrawSummaryResponse{Summary: luckyDrawSummaryToProto(summary)}, nil } +func (s *AdminLuckyGiftServer) ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error) { + appCode := strings.TrimSpace(req.GetMeta().GetAppCode()) + ctx = appcode.WithContext(ctx, appCode) + profiles, total, nextCursor, hasMore, snapshotAtMS, err := s.svc.ListUserProfiles(ctx, domain.UserProfileQuery{ + AppCode: appCode, + PoolID: req.GetPoolId(), + IdentityType: req.GetIdentityType(), + InternalUserID: req.GetInternalUserId(), + ExternalUserID: req.GetExternalUserId(), + Stage: req.GetStage(), + Status: req.GetStatus(), + SortBy: req.GetSortBy(), + SortDirection: req.GetSortDirection(), + Cursor: req.GetCursor(), + Page: req.GetPage(), + PageSize: req.GetPageSize(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &luckygiftv1.ListLuckyGiftUserProfilesResponse{ + Profiles: make([]*luckygiftv1.LuckyGiftUserProfile, 0, len(profiles)), + Total: total, + SnapshotAtMs: snapshotAtMS, + NextCursor: nextCursor, + HasMore: hasMore, + } + for _, profile := range profiles { + resp.Profiles = append(resp.Profiles, luckyUserProfileToProto(profile)) + } + return resp, nil +} + +func (s *AdminLuckyGiftServer) GetLuckyGiftUserProfile(ctx context.Context, req *luckygiftv1.GetLuckyGiftUserProfileRequest) (*luckygiftv1.GetLuckyGiftUserProfileResponse, error) { + appCode := strings.TrimSpace(req.GetMeta().GetAppCode()) + ctx = appcode.WithContext(ctx, appCode) + detail, snapshotAtMS, err := s.svc.GetUserProfile(ctx, domain.UserProfileQuery{ + AppCode: appCode, + PoolID: req.GetPoolId(), + IdentityType: req.GetIdentityType(), + InternalUserID: req.GetInternalUserId(), + ExternalUserID: req.GetExternalUserId(), + JackpotPage: req.GetJackpotPage(), + JackpotPageSize: req.GetJackpotPageSize(), + JackpotCursor: req.GetJackpotCursor(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &luckygiftv1.GetLuckyGiftUserProfileResponse{ + Profile: luckyUserProfileToProto(detail.Profile), + MultiplierDistribution: make([]*luckygiftv1.LuckyGiftUserMultiplierStat, 0, len(detail.MultiplierDistribution)), + JackpotHits: make([]*luckygiftv1.LuckyGiftUserJackpotHit, 0, len(detail.JackpotHits)), + JackpotHitTotal: detail.JackpotHitTotal, + SnapshotAtMs: snapshotAtMS, + JackpotPage: detail.JackpotPage, + JackpotPageSize: detail.JackpotPageSize, + NextJackpotCursor: detail.NextJackpotCursor, + JackpotHasMore: detail.JackpotHasMore, + } + for _, stat := range detail.MultiplierDistribution { + resp.MultiplierDistribution = append(resp.MultiplierDistribution, &luckygiftv1.LuckyGiftUserMultiplierStat{ + HitType: stat.HitType, MultiplierPpm: stat.MultiplierPPM, DrawCount: stat.DrawCount, + WagerCoins: stat.WagerCoins, PayoutCoins: stat.PayoutCoins, + }) + } + for _, hit := range detail.JackpotHits { + item := &luckygiftv1.LuckyGiftUserJackpotHit{ + DrawId: hit.DrawID, RequestId: hit.RequestID, RuleVersion: hit.RuleVersion, Stage: hit.Stage, + EventKey: hit.SourceType + ":" + hit.DrawID, + Mechanism: hit.Mechanism, ReasonCode: hit.ReasonCode, ReasonSummary: hit.ReasonSummary, + TierId: hit.TierID, MultiplierPpm: hit.MultiplierPPM, WagerCoins: hit.WagerCoins, + PayoutCoins: hit.PayoutCoins, OccurredAtMs: hit.OccurredAtMS, + Conditions: make([]*luckygiftv1.LuckyGiftUserJackpotCondition, 0, len(hit.Conditions)), + } + for _, condition := range hit.Conditions { + item.Conditions = append(item.Conditions, &luckygiftv1.LuckyGiftUserJackpotCondition{ + Name: condition.Name, Numerator: condition.Numerator, Denominator: condition.Denominator, + RatioPpm: condition.RatioPPM, LimitPpm: condition.LimitPPM, FactorPpm: condition.FactorPPM, + Passed: condition.Passed, Reason: condition.Reason, + }) + } + resp.JackpotHits = append(resp.JackpotHits, item) + } + return resp, nil +} + func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) { appCode := req.GetMeta().GetAppCode() if appCode != "" { @@ -443,48 +532,58 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru }) } return domain.RuleConfig{ - AppCode: config.GetAppCode(), - PoolID: config.GetPoolId(), - RuleVersion: config.GetRuleVersion(), - Enabled: config.GetEnabled(), - TargetRTPPPM: config.GetTargetRtpPpm(), - PoolRatePPM: config.GetPoolRatePpm(), - SettlementWindowWager: config.GetSettlementWindowWager(), - ControlBandPPM: config.GetControlBandPpm(), - GiftPriceReference: config.GetGiftPriceReference(), - NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), - NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), - EffectiveFromMS: config.GetEffectiveFromMs(), - CreatedByAdminID: config.GetCreatedByAdminId(), - CreatedAtMS: config.GetCreatedAtMs(), - StrategyVersion: config.GetStrategyVersion(), - ProfitRatePPM: config.GetProfitRatePpm(), - AnchorRatePPM: config.GetAnchorRatePpm(), - InitialPoolCoins: config.GetInitialPoolCoins(), - LossStreakGuarantee: config.GetLossStreakGuarantee(), - LowWatermarkCoins: config.GetLowWatermarkCoins(), - LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(), - HighWatermarkCoins: config.GetHighWatermarkCoins(), - HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(), - RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(), - RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(), - JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...), - JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(), - JackpotUserDayRTPMaxPPM: config.GetJackpotUserDayRtpMaxPpm(), - JackpotUser72hRTPMaxPPM: config.GetJackpotUser_72HRtpMaxPpm(), - JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(), - MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(), - MaxSinglePayout: config.GetMaxSinglePayout(), - UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(), - UserDailyPayoutCap: config.GetUserDailyPayoutCap(), - DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(), - RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(), - AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(), - Stages: stages, + AppCode: config.GetAppCode(), + PoolID: config.GetPoolId(), + RuleVersion: config.GetRuleVersion(), + Enabled: config.GetEnabled(), + TargetRTPPPM: config.GetTargetRtpPpm(), + PoolRatePPM: config.GetPoolRatePpm(), + SettlementWindowWager: config.GetSettlementWindowWager(), + ControlBandPPM: config.GetControlBandPpm(), + GiftPriceReference: config.GetGiftPriceReference(), + NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), + NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), + EffectiveFromMS: config.GetEffectiveFromMs(), + CreatedByAdminID: config.GetCreatedByAdminId(), + CreatedAtMS: config.GetCreatedAtMs(), + StrategyVersion: config.GetStrategyVersion(), + ProfitRatePPM: config.GetProfitRatePpm(), + AnchorRatePPM: config.GetAnchorRatePpm(), + InitialPoolCoins: config.GetInitialPoolCoins(), + LossStreakGuarantee: config.GetLossStreakGuarantee(), + LowWatermarkCoins: config.GetLowWatermarkCoins(), + LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(), + HighWatermarkCoins: config.GetHighWatermarkCoins(), + HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(), + RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(), + RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(), + User24HourRTPThresholdPPM: config.GetUser_24HRtpThresholdPpm(), + User24HourOrdinaryWinFactorPPM: config.GetUser_24HOrdinaryWinFactorPpm(), + JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...), + JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(), + JackpotUserRoundRTPMaxPPM: config.GetJackpotUserRoundRtpMaxPpm(), + JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(), + JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(), + MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(), + MaxSinglePayout: config.GetMaxSinglePayout(), + UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(), + UserDailyPayoutCap: config.GetUserDailyPayoutCap(), + DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(), + RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(), + AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(), + Stages: stages, } } func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRuleConfig { + roundRTPMaxPPM := config.JackpotUserRoundRTPMaxPPM + if roundRTPMaxPPM == 0 { + roundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM + } + rolling48hRTPMaxPPM := config.JackpotUser48hRTPMaxPPM + if rolling48hRTPMaxPPM == 0 { + rolling48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM + } stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(config.Stages)) for _, stage := range config.Stages { tiers := make([]*luckygiftv1.LuckyGiftRuleTier, 0, len(stage.Tiers)) @@ -506,44 +605,46 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule }) } return &luckygiftv1.LuckyGiftRuleConfig{ - AppCode: config.AppCode, - PoolId: config.PoolID, - RuleVersion: config.RuleVersion, - Enabled: config.Enabled, - TargetRtpPpm: config.TargetRTPPPM, - PoolRatePpm: config.PoolRatePPM, - SettlementWindowWager: config.SettlementWindowWager, - ControlBandPpm: config.ControlBandPPM, - GiftPriceReference: config.GiftPriceReference, - NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws, - NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws, - EffectiveFromMs: config.EffectiveFromMS, - CreatedByAdminId: config.CreatedByAdminID, - CreatedAtMs: config.CreatedAtMS, - StrategyVersion: config.StrategyVersion, - ProfitRatePpm: config.ProfitRatePPM, - AnchorRatePpm: config.AnchorRatePPM, - InitialPoolCoins: config.InitialPoolCoins, - LossStreakGuarantee: config.LossStreakGuarantee, - LowWatermarkCoins: config.LowWatermarkCoins, - LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM, - HighWatermarkCoins: config.HighWatermarkCoins, - HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM, - RechargeBoostWindowMs: config.RechargeBoostWindowMS, - RechargeBoostFactorPpm: config.RechargeBoostFactorPPM, - JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...), - JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM, - JackpotUserDayRtpMaxPpm: config.JackpotUserDayRTPMaxPPM, - JackpotUser_72HRtpMaxPpm: config.JackpotUser72hRTPMaxPPM, - JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins, - MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay, - MaxSinglePayout: config.MaxSinglePayout, - UserHourlyPayoutCap: config.UserHourlyPayoutCap, - UserDailyPayoutCap: config.UserDailyPayoutCap, - DeviceDailyPayoutCap: config.DeviceDailyPayoutCap, - RoomHourlyPayoutCap: config.RoomHourlyPayoutCap, - AnchorDailyPayoutCap: config.AnchorDailyPayoutCap, - Stages: stages, + AppCode: config.AppCode, + PoolId: config.PoolID, + RuleVersion: config.RuleVersion, + Enabled: config.Enabled, + TargetRtpPpm: config.TargetRTPPPM, + PoolRatePpm: config.PoolRatePPM, + SettlementWindowWager: config.SettlementWindowWager, + ControlBandPpm: config.ControlBandPPM, + GiftPriceReference: config.GiftPriceReference, + NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws, + NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws, + EffectiveFromMs: config.EffectiveFromMS, + CreatedByAdminId: config.CreatedByAdminID, + CreatedAtMs: config.CreatedAtMS, + StrategyVersion: config.StrategyVersion, + ProfitRatePpm: config.ProfitRatePPM, + AnchorRatePpm: config.AnchorRatePPM, + InitialPoolCoins: config.InitialPoolCoins, + LossStreakGuarantee: config.LossStreakGuarantee, + LowWatermarkCoins: config.LowWatermarkCoins, + LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM, + HighWatermarkCoins: config.HighWatermarkCoins, + HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM, + RechargeBoostWindowMs: config.RechargeBoostWindowMS, + RechargeBoostFactorPpm: config.RechargeBoostFactorPPM, + User_24HRtpThresholdPpm: config.User24HourRTPThresholdPPM, + User_24HOrdinaryWinFactorPpm: config.User24HourOrdinaryWinFactorPPM, + JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...), + JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM, + JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM, + JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM, + JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins, + MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay, + MaxSinglePayout: config.MaxSinglePayout, + UserHourlyPayoutCap: config.UserHourlyPayoutCap, + UserDailyPayoutCap: config.UserDailyPayoutCap, + DeviceDailyPayoutCap: config.DeviceDailyPayoutCap, + RoomHourlyPayoutCap: config.RoomHourlyPayoutCap, + AnchorDailyPayoutCap: config.AnchorDailyPayoutCap, + Stages: stages, } } @@ -632,6 +733,41 @@ func luckyPoolBalanceToProto(pool domain.PoolBalance) *luckygiftv1.LuckyGiftPool } } +func luckyUserProfileWindowToProto(window domain.UserProfileWindow) *luckygiftv1.LuckyGiftUserProfileWindow { + return &luckygiftv1.LuckyGiftUserProfileWindow{ + Draws: window.Draws, WagerCoins: window.WagerCoins, PayoutCoins: window.PayoutCoins, + NetCoins: window.NetCoins, RtpPpm: window.RTPPPM, HasRtp: window.HasRTP, + } +} + +func luckyUserProfileToProto(profile domain.UserProfile) *luckygiftv1.LuckyGiftUserProfile { + // StrategyUserID 可能是 external_user_id 的稳定哈希,只允许仓储层关联 lucky_user_states; + // transport 显式逐字段映射且没有该字段,避免未来通过反射或 JSON 误下发。 + return &luckygiftv1.LuckyGiftUserProfile{ + AppCode: profile.AppCode, PoolId: profile.PoolID, IdentityType: profile.IdentityType, + InternalUserId: profile.InternalUserID, ExternalUserId: profile.ExternalUserID, + RuleVersion: profile.RuleVersion, StrategyVersion: profile.StrategyVersion, Stage: profile.Stage, + PaidDraws: profile.PaidDraws, EquivalentDraws: profile.EquivalentDraws, LossStreak: profile.LossStreak, + PendingSpendJackpotTokens: profile.PendingSpendJackpotTokens, + GuaranteeDrawsRemaining: profile.GuaranteeDrawsRemaining, + DownweightActive: profile.DownweightActive, DownweightFactorPpm: profile.DownweightFactorPPM, + User_24HRtpThresholdPpm: profile.User24HourRTPThresholdPPM, + OneHour: luckyUserProfileWindowToProto(profile.OneHour), + TwentyFourHours: luckyUserProfileWindowToProto(profile.TwentyFourHour), + FortyEightHours: luckyUserProfileWindowToProto(profile.FortyEightHour), + Lifetime: luckyUserProfileWindowToProto(profile.Lifetime), + BaseRewardCoins: profile.BaseRewardCoins, OrdinaryWinCount: profile.OrdinaryWinCount, + HighMultiplierOrdinaryWinCount: profile.HighMultiplierOrdinaryWinCount, + RtpCompensationJackpotCount: profile.RTPCompensationJackpotCount, + CumulativeSpendJackpotCount: profile.CumulativeSpendJackpotCount, + GuaranteeHitCount: profile.GuaranteeHitCount, ZeroDrawCount: profile.ZeroDrawCount, + GrantedDrawCount: profile.GrantedDrawCount, PendingDrawCount: profile.PendingDrawCount, + FailedDrawCount: profile.FailedDrawCount, MaxMultiplierPpm: profile.MaxMultiplierPPM, + MaxRewardCoins: profile.MaxRewardCoins, FirstDrawAtMs: profile.FirstDrawAtMS, + LastDrawAtMs: profile.LastDrawAtMS, AggregatedAtMs: profile.AggregatedAtMS, DataLagMs: profile.DataLagMS, + } +} + func luckyPoolAdjustmentToProto(adjustment domain.PoolAdjustment) *luckygiftv1.LuckyGiftPoolAdjustment { return &luckygiftv1.LuckyGiftPoolAdjustment{ AdjustmentId: adjustment.AdjustmentID, diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index 7531ad83..ae8f1681 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -461,6 +461,9 @@ type SendGift struct { SenderRegionID int64 `json:"sender_region_id,omitempty"` // SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。 SenderCountryID int64 `json:"sender_country_id,omitempty"` + // SenderRegisteredAtMS 是 gateway 从 user-service 固化的账号创建时间。 + // room 恢复首次送礼 saga 时必须继续使用该值;旧命令缺失时保留 0,让 lucky-gift 对补偿大奖资格 fail-close。 + SenderRegisteredAtMS int64 `json:"sender_registered_at_ms,omitempty"` // TargetIsHost 是 gateway 从 user-service active host profile 注入的工资入账开关。 TargetIsHost bool `json:"target_is_host,omitempty"` // TargetHostRegionID 是工资政策匹配区域;room-service 不自行推导主播身份或区域。 @@ -684,6 +687,9 @@ func IdempotencyPayload(payload []byte) ([]byte, error) { // not conflict merely because the user changed profile between timeout and replay. delete(values, "sender_region_id") delete(values, "sender_country_id") + // 注册时间与国家/区域同属 gateway 从 user owner 解析的可信快照。首次请求会完整持久化, + // 但它不是客户端送礼载荷;同一 command_id 重试不能因为用户快照修复或迁移而产生冲突。 + delete(values, "sender_registered_at_ms") // SendGift 的以下字段由 wallet-service 结算后写入 command log,不属于客户端请求幂等语义。 delete(values, "billing_receipt_id") delete(values, "coin_spent") diff --git a/services/room-service/internal/room/service/gift_flow.go b/services/room-service/internal/room/service/gift_flow.go index a1c486d3..c9cc4f2f 100644 --- a/services/room-service/internal/room/service/gift_flow.go +++ b/services/room-service/internal/room/service/gift_flow.go @@ -82,7 +82,10 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o BatchSeq: req.GetBatchSeq(), SenderCountryID: req.GetSenderCountryId(), SenderRegionID: req.GetSenderRegionId(), - TargetIsHost: req.GetTargetIsHost(), + // 注册时间随 command 一起进入 gift_operation 和最终 command log;恢复时使用首次快照, + // 不在 room-service 回查 user-service,也不让重试请求覆盖已经用于开奖判定的事实。 + SenderRegisteredAtMS: req.GetSenderRegisteredAtMs(), + TargetIsHost: req.GetTargetIsHost(), // 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。 TargetHostRegionID: req.GetTargetHostRegionId(), TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), diff --git a/services/room-service/internal/room/service/gift_lucky.go b/services/room-service/internal/room/service/gift_lucky.go index 1d470930..e55f64cc 100644 --- a/services/room-service/internal/room/service/gift_lucky.go +++ b/services/room-service/internal/room/service/gift_lucky.go @@ -113,6 +113,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c PoolId: cmd.PoolID, VisibleRegionId: roomMeta.VisibleRegionID, CountryId: cmd.SenderCountryID, + // 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化;owner 只用它判断 + // 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。 + UserRegisteredAtMs: cmd.SenderRegisteredAtMS, // 展示资料来自 gateway 本次送礼入口的用户快照,随抽奖事实持久化后,下游不需要在异步消费时反查当前资料。 SenderName: giftDisplayName(cmd.SenderDisplayProfile), SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), diff --git a/services/user-service/internal/service/user/display_id.go b/services/user-service/internal/service/user/display_id.go index c062c9ee..240623f3 100644 --- a/services/user-service/internal/service/user/display_id.go +++ b/services/user-service/internal/service/user/display_id.go @@ -2,6 +2,7 @@ package user import ( "context" + "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" @@ -51,6 +52,26 @@ func (s *Service) ResolveDisplayUserID(ctx context.Context, displayUserID string return s.identityRepository.ResolveDisplayUserID(ctx, displayUserID, s.now().UnixMilli()) } +// ResolveAdminUserIdentifier 供内部后台按默认短号、当前有效展示号或系统长 user_id 精确查人。 +// 它与客户端 ResolveDisplayUserID 分离,避免把 held 默认号意外恢复成登录或普通业务可解析身份。 +func (s *Service) ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string) (userdomain.Identity, error) { + userIdentifier = strings.TrimSpace(userIdentifier) + if !userdomain.ValidResolvableDisplayUserID(userIdentifier) { + return userdomain.Identity{}, xerr.New(xerr.InvalidArgument, "user_identifier format is invalid") + } + if s.identityRepository == nil { + return userdomain.Identity{}, xerr.New(xerr.Unavailable, "identity repository is not configured") + } + + userID, err := s.identityRepository.ResolveAdminUserIdentifier(ctx, userIdentifier, s.now().UnixMilli()) + if err != nil { + return userdomain.Identity{}, err + } + // repository 只决定搜索别名归属;最终复用 GetUserIdentity 返回当前 active 投影, + // 因此用 held 默认号搜索到有靓号的用户时,响应仍展示当前有效靓号。 + return s.GetUserIdentity(ctx, userID) +} + // ChangeDisplayUserID 修改用户默认短号,并由 repository 在事务内维护当前展示快照和审计。 func (s *Service) ChangeDisplayUserID(ctx context.Context, userID int64, newDisplayUserID string, reason string, operatorUserID int64, requestID string) (userdomain.Identity, error) { if userID <= 0 { diff --git a/services/user-service/internal/service/user/service.go b/services/user-service/internal/service/user/service.go index 45eafbdb..b463527d 100644 --- a/services/user-service/internal/service/user/service.go +++ b/services/user-service/internal/service/user/service.go @@ -124,6 +124,8 @@ type IdentityRepository interface { GetUserIdentity(ctx context.Context, userID int64) (userdomain.Identity, error) // ResolveDisplayUserID 把短号解析成 user_id,并按 nowMs 触发懒过期。 ResolveDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (userdomain.Identity, error) + // ResolveAdminUserIdentifier 为内部后台精确解析永久默认短号、当前有效展示号或系统长 user_id。 + ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string, nowMs int64) (int64, error) // ChangeDisplayUserID 原子修改默认短号并写变更日志。 ChangeDisplayUserID(ctx context.Context, command userdomain.DisplayUserIDChangeCommand) (userdomain.Identity, error) // ApplyPrettyDisplayUserID 原子创建靓号租约并覆盖当前展示号。 diff --git a/services/user-service/internal/storage/mysql/identity/identity.go b/services/user-service/internal/storage/mysql/identity/identity.go index 408d81e8..88c9595f 100644 --- a/services/user-service/internal/storage/mysql/identity/identity.go +++ b/services/user-service/internal/storage/mysql/identity/identity.go @@ -3,6 +3,8 @@ package identity import ( "context" "database/sql" + "strconv" + "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" @@ -52,6 +54,97 @@ func (r *Repository) ResolveDisplayUserID(ctx context.Context, displayUserID str return identity, nil } +// ResolveAdminUserIdentifier 是后台精确查人的 owner 入口。它刻意不复用 ResolveDisplayUserID: +// 客户端只能解析当前 active 展示号,而运营搜索还必须能用被有效靓号覆盖的永久默认短号定位同一用户。 +func (r *Repository) ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string, nowMs int64) (int64, error) { + userIdentifier = strings.TrimSpace(userIdentifier) + appCode := appcode.FromContext(ctx) + + // 输入本身是已过期靓号时先执行既有懒过期流程;这样过期靓号不会继续作为后台搜索别名, + // 同时用户的默认短号会恢复 active,后续 GetUserIdentity 返回的始终是当前身份。 + if err := r.expirePrettyByDisplayUserID(ctx, userIdentifier, nowMs, "lazy_admin_resolve"); err != nil { + return 0, err + } + + var userID int64 + // live_display_user_id 是 active/held 事实的生成列并有 app 级唯一索引。active 表示当前有效展示号; + // held 只接受仍等于 users.default_display_user_id 的永久默认号,避免 reserved/blocked 或历史释放号被解析。 + err := r.db.QueryRowContext(ctx, ` + SELECT du.user_id + FROM user_display_user_ids du FORCE INDEX (uk_user_display_user_ids_live) + INNER JOIN users u + ON u.app_code = du.app_code + AND u.user_id = du.user_id + WHERE du.app_code = ? + AND du.live_display_user_id = ? + AND ( + (du.status = ? AND (du.expires_at_ms IS NULL OR du.expires_at_ms = 0 OR du.expires_at_ms > ?)) + OR (du.status = ? AND du.display_user_id = u.default_display_user_id) + ) + LIMIT 1 + `, appCode, userIdentifier, string(userdomain.DisplayUserIDStatusActive), nowMs, string(userdomain.DisplayUserIDStatusHeld)).Scan(&userID) + if err == nil { + return userID, nil + } + if err != sql.ErrNoRows { + return 0, err + } + + // 兼容 identity 事实表补齐前的旧用户:两次独立等值查询分别命中 current/default 唯一索引, + // 不使用 OR 扫描 app 用户集合。过期 pretty 快照不能作为当前展示号命中。 + err = r.db.QueryRowContext(ctx, ` + SELECT user_id + FROM users FORCE INDEX (uk_users_current_display_user_id) + WHERE app_code = ? + AND current_display_user_id = ? + AND ( + current_display_user_id_kind <> ? + OR current_display_user_id_expires_at_ms IS NULL + OR current_display_user_id_expires_at_ms = 0 + OR current_display_user_id_expires_at_ms > ? + ) + LIMIT 1 + `, appCode, userIdentifier, string(userdomain.DisplayUserIDKindPretty), nowMs).Scan(&userID) + if err == nil { + return userID, nil + } + if err != sql.ErrNoRows { + return 0, err + } + + err = r.db.QueryRowContext(ctx, ` + SELECT user_id + FROM users FORCE INDEX (uk_users_default_display_user_id) + WHERE app_code = ? AND default_display_user_id = ? + LIMIT 1 + `, appCode, userIdentifier).Scan(&userID) + if err == nil { + return userID, nil + } + if err != sql.ErrNoRows { + return 0, err + } + + // 展示号优先于长 user_id,保持既有后台搜索的碰撞决策;只有所有展示别名都未命中时才查主键。 + parsedUserID, parseErr := strconv.ParseInt(userIdentifier, 10, 64) + if parseErr == nil && parsedUserID > 0 { + err = r.db.QueryRowContext(ctx, ` + SELECT user_id + FROM users FORCE INDEX (PRIMARY) + WHERE user_id = ? AND app_code = ? + LIMIT 1 + `, parsedUserID, appCode).Scan(&userID) + if err == nil { + return userID, nil + } + if err != sql.ErrNoRows { + return 0, err + } + } + + return 0, xerr.New(xerr.DisplayUserIDNotFound, "user identifier not found") +} + func (r *Repository) queryActiveIdentity(ctx context.Context, clause string, args ...any) (userdomain.Identity, error) { // user_display_user_ids 的 active 行是展示号归属事实,users.current_display_user_id 只是高频读取快照。 // 解析入口优先读取事实表,可以容忍历史脚本或异常中断导致 users 快照暂时没有同步到最新靓号。 diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index cb44b9f4..0a37d150 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -214,6 +214,10 @@ func (r *Repository) ResolveDisplayUserID(ctx context.Context, displayUserID str return r.Repository.IdentityRepository().ResolveDisplayUserID(ctx, displayUserID, nowMs) } +func (r *Repository) ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string, nowMs int64) (int64, error) { + return r.Repository.IdentityRepository().ResolveAdminUserIdentifier(ctx, userIdentifier, nowMs) +} + // ChangeDisplayUserID 让测试 wrapper 继续满足短号接口。 func (r *Repository) ChangeDisplayUserID(ctx context.Context, command userdomain.DisplayUserIDChangeCommand) (userdomain.Identity, error) { return r.Repository.IdentityRepository().ChangeDisplayUserID(ctx, command) diff --git a/services/user-service/internal/transport/grpc/server.go b/services/user-service/internal/transport/grpc/server.go index 444f2816..22fa14c4 100644 --- a/services/user-service/internal/transport/grpc/server.go +++ b/services/user-service/internal/transport/grpc/server.go @@ -1118,6 +1118,18 @@ func (s *Server) ResolveDisplayUserID(ctx context.Context, req *userv1.ResolveDi return &userv1.ResolveDisplayUserIDResponse{Identity: toProtoIdentity(identity)}, nil } +// ResolveAdminUserIdentifier 为内部后台精确解析默认短号、当前有效展示号或系统长 user_id。 +func (s *Server) ResolveAdminUserIdentifier(ctx context.Context, req *userv1.ResolveAdminUserIdentifierRequest) (*userv1.ResolveAdminUserIdentifierResponse, error) { + ctx = contextWithApp(ctx, req.GetMeta()) + // 后台搜索语义由独立 service 用例承载,禁止扩大客户端 ResolveDisplayUserID 的 active-only 边界。 + identity, err := s.userSvc.ResolveAdminUserIdentifier(ctx, req.GetUserIdentifier()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &userv1.ResolveAdminUserIdentifierResponse{Identity: toProtoIdentity(identity)}, nil +} + // ChangeDisplayUserID 修改当前 active 短号并返回新绑定。 func (s *Server) ChangeDisplayUserID(ctx context.Context, req *userv1.ChangeDisplayUserIDRequest) (*userv1.ChangeDisplayUserIDResponse, error) { ctx = contextWithApp(ctx, req.GetMeta()) diff --git a/tools/lucky-gift-room-demo/server.go b/tools/lucky-gift-room-demo/server.go index 6e352701..618d8db3 100644 --- a/tools/lucky-gift-room-demo/server.go +++ b/tools/lucky-gift-room-demo/server.go @@ -542,7 +542,7 @@ func (s *demoServer) ensureDynamicRule(ctx context.Context, operatorID int64) (* HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPpm: 1_300_000, RechargeBoostWindowMs: 300_000, RechargeBoostFactorPpm: 1_100_000, JackpotMultiplierPpms: []int64{200_000_000, 500_000_000, 1_000_000_000}, - JackpotGlobalRtpMaxPpm: 980_000, JackpotUserDayRtpMaxPpm: 960_000, JackpotUser_72HRtpMaxPpm: 960_000, + JackpotGlobalRtpMaxPpm: 980_000, JackpotUserRoundRtpMaxPpm: 960_000, JackpotUser_48HRtpMaxPpm: 960_000, JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5, MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 5_000_000, DeviceDailyPayoutCap: 5_000_000, RoomHourlyPayoutCap: 10_000_000, AnchorDailyPayoutCap: 10_000_000,