merge: integrate lucky gift dynamic strategy
This commit is contained in:
commit
9ee45a3a52
@ -122,6 +122,12 @@ type LuckyGiftMeta struct {
|
|||||||
SenderAvatar string `protobuf:"bytes,16,opt,name=sender_avatar,json=senderAvatar,proto3" json:"sender_avatar,omitempty"`
|
SenderAvatar string `protobuf:"bytes,16,opt,name=sender_avatar,json=senderAvatar,proto3" json:"sender_avatar,omitempty"`
|
||||||
SenderDisplayUserId string `protobuf:"bytes,17,opt,name=sender_display_user_id,json=senderDisplayUserId,proto3" json:"sender_display_user_id,omitempty"`
|
SenderDisplayUserId string `protobuf:"bytes,17,opt,name=sender_display_user_id,json=senderDisplayUserId,proto3" json:"sender_display_user_id,omitempty"`
|
||||||
SenderPrettyDisplayUserId string `protobuf:"bytes,18,opt,name=sender_pretty_display_user_id,json=senderPrettyDisplayUserId,proto3" json:"sender_pretty_display_user_id,omitempty"`
|
SenderPrettyDisplayUserId string `protobuf:"bytes,18,opt,name=sender_pretty_display_user_id,json=senderPrettyDisplayUserId,proto3" json:"sender_pretty_display_user_id,omitempty"`
|
||||||
|
// 充值分层与充值后短时加权必须由调用方传入已确认的用户事实;幸运礼物服务不跨库回查用户或支付明细。
|
||||||
|
Recharge_7DCoins int64 `protobuf:"varint,19,opt,name=recharge_7d_coins,json=recharge7dCoins,proto3" json:"recharge_7d_coins,omitempty"`
|
||||||
|
Recharge_30DCoins int64 `protobuf:"varint,20,opt,name=recharge_30d_coins,json=recharge30dCoins,proto3" json:"recharge_30d_coins,omitempty"`
|
||||||
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -282,6 +288,34 @@ func (x *LuckyGiftMeta) GetSenderPrettyDisplayUserId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftMeta) GetRecharge_7DCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Recharge_7DCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftMeta) GetRecharge_30DCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Recharge_30DCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftMeta) GetLastRechargedAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LastRechargedAtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftMeta) GetGiftIncomeCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.GiftIncomeCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type LuckyGiftRuleTier struct {
|
type LuckyGiftRuleTier struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"`
|
Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"`
|
||||||
@ -370,6 +404,9 @@ type LuckyGiftRuleStage struct {
|
|||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"`
|
Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"`
|
||||||
Tiers []*LuckyGiftRuleTier `protobuf:"bytes,2,rep,name=tiers,proto3" json:"tiers,omitempty"`
|
Tiers []*LuckyGiftRuleTier `protobuf:"bytes,2,rep,name=tiers,proto3" json:"tiers,omitempty"`
|
||||||
|
// 两个充值门槛共同决定 dynamic_v3 分层;novice 固定为 0/0,后续阶段逐维单调递增。
|
||||||
|
MinRecharge_7DCoins int64 `protobuf:"varint,3,opt,name=min_recharge_7d_coins,json=minRecharge7dCoins,proto3" json:"min_recharge_7d_coins,omitempty"`
|
||||||
|
MinRecharge_30DCoins int64 `protobuf:"varint,4,opt,name=min_recharge_30d_coins,json=minRecharge30dCoins,proto3" json:"min_recharge_30d_coins,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -418,6 +455,20 @@ func (x *LuckyGiftRuleStage) GetTiers() []*LuckyGiftRuleTier {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleStage) GetMinRecharge_7DCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MinRecharge_7DCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleStage) GetMinRecharge_30DCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MinRecharge_30DCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type LuckyGiftRuleConfig struct {
|
type LuckyGiftRuleConfig struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||||
@ -435,6 +486,30 @@ type LuckyGiftRuleConfig struct {
|
|||||||
CreatedByAdminId int64 `protobuf:"varint,13,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"`
|
CreatedByAdminId int64 `protobuf:"varint,13,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"`
|
||||||
CreatedAtMs int64 `protobuf:"varint,14,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -574,6 +649,167 @@ func (x *LuckyGiftRuleConfig) GetStages() []*LuckyGiftRuleStage {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetStrategyVersion() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.StrategyVersion
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetProfitRatePpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ProfitRatePpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetAnchorRatePpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AnchorRatePpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetInitialPoolCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.InitialPoolCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetLossStreakGuarantee() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LossStreakGuarantee
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetLowWatermarkCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LowWatermarkCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetLowWaterNonzeroFactorPpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LowWaterNonzeroFactorPpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetHighWatermarkCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.HighWatermarkCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetHighWaterNonzeroFactorPpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.HighWaterNonzeroFactorPpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetRechargeBoostWindowMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.RechargeBoostWindowMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetRechargeBoostFactorPpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.RechargeBoostFactorPpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetJackpotMultiplierPpms() []int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.JackpotMultiplierPpms
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetJackpotGlobalRtpMaxPpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.JackpotGlobalRtpMaxPpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetJackpotUserDayRtpMaxPpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.JackpotUserDayRtpMaxPpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetJackpotUser_72HRtpMaxPpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.JackpotUser_72HRtpMaxPpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetJackpotSpendThresholdCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.JackpotSpendThresholdCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetMaxJackpotHitsPerUserDay() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MaxJackpotHitsPerUserDay
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetMaxSinglePayout() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MaxSinglePayout
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetUserHourlyPayoutCap() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UserHourlyPayoutCap
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetUserDailyPayoutCap() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UserDailyPayoutCap
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetDeviceDailyPayoutCap() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.DeviceDailyPayoutCap
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetRoomHourlyPayoutCap() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.RoomHourlyPayoutCap
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetAnchorDailyPayoutCap() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AnchorDailyPayoutCap
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type CheckLuckyGiftRequest struct {
|
type CheckLuckyGiftRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||||
@ -660,6 +896,8 @@ type CheckLuckyGiftResponse struct {
|
|||||||
TargetRtpPpm int64 `protobuf:"varint,6,opt,name=target_rtp_ppm,json=targetRtpPpm,proto3" json:"target_rtp_ppm,omitempty"`
|
TargetRtpPpm int64 `protobuf:"varint,6,opt,name=target_rtp_ppm,json=targetRtpPpm,proto3" json:"target_rtp_ppm,omitempty"`
|
||||||
ExperiencePool string `protobuf:"bytes,7,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"`
|
ExperiencePool string `protobuf:"bytes,7,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"`
|
||||||
PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||||
|
// strategy_version 由 lucky-gift owner 返回并由 room saga 固化,用于滚动发布时区分 fixed_v2 兼容与 dynamic_v3 强校验。
|
||||||
|
StrategyVersion string `protobuf:"bytes,9,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -750,6 +988,13 @@ func (x *CheckLuckyGiftResponse) GetPoolId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *CheckLuckyGiftResponse) GetStrategyVersion() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.StrategyVersion
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type LuckyGiftDrawResult struct {
|
type LuckyGiftDrawResult struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
|
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
|
||||||
@ -1169,6 +1414,8 @@ type ExternalGiftDrawRequest struct {
|
|||||||
PaidAtMs int64 `protobuf:"varint,10,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"`
|
PaidAtMs int64 `protobuf:"varint,10,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"`
|
||||||
MetadataJson string `protobuf:"bytes,11,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -1280,6 +1527,13 @@ func (x *ExternalGiftDrawRequest) GetPoolId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ExternalGiftDrawRequest) GetDeviceId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.DeviceId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type ExternalGiftDrawResponse struct {
|
type ExternalGiftDrawResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
|
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
|
||||||
@ -2003,6 +2257,9 @@ type LuckyGiftPoolBalance struct {
|
|||||||
TotalOut int64 `protobuf:"varint,7,opt,name=total_out,json=totalOut,proto3" json:"total_out,omitempty"`
|
TotalOut int64 `protobuf:"varint,7,opt,name=total_out,json=totalOut,proto3" json:"total_out,omitempty"`
|
||||||
Materialized bool `protobuf:"varint,8,opt,name=materialized,proto3" json:"materialized,omitempty"`
|
Materialized bool `protobuf:"varint,8,opt,name=materialized,proto3" json:"materialized,omitempty"`
|
||||||
UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
||||||
|
StrategyVersion string `protobuf:"bytes,10,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"`
|
||||||
|
ManualCreditTotal int64 `protobuf:"varint,11,opt,name=manual_credit_total,json=manualCreditTotal,proto3" json:"manual_credit_total,omitempty"`
|
||||||
|
ManualDebitTotal int64 `protobuf:"varint,12,opt,name=manual_debit_total,json=manualDebitTotal,proto3" json:"manual_debit_total,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -2100,6 +2357,27 @@ func (x *LuckyGiftPoolBalance) GetUpdatedAtMs() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolBalance) GetStrategyVersion() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.StrategyVersion
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolBalance) GetManualCreditTotal() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ManualCreditTotal
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolBalance) GetManualDebitTotal() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ManualDebitTotal
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type GetLuckyGiftDrawSummaryRequest struct {
|
type GetLuckyGiftDrawSummaryRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||||
@ -2248,6 +2526,7 @@ type ListLuckyGiftPoolBalancesRequest struct {
|
|||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||||
PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||||
|
StrategyVersion string `protobuf:"bytes,3,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -2296,6 +2575,13 @@ func (x *ListLuckyGiftPoolBalancesRequest) GetPoolId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ListLuckyGiftPoolBalancesRequest) GetStrategyVersion() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.StrategyVersion
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type ListLuckyGiftPoolBalancesResponse struct {
|
type ListLuckyGiftPoolBalancesResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Pools []*LuckyGiftPoolBalance `protobuf:"bytes,1,rep,name=pools,proto3" json:"pools,omitempty"`
|
Pools []*LuckyGiftPoolBalance `protobuf:"bytes,1,rep,name=pools,proto3" json:"pools,omitempty"`
|
||||||
@ -2418,6 +2704,292 @@ func (x *LuckyGiftHit) GetRewardCoins() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调账消息也只追加在文件尾,保持所有既有 protobuf message index 稳定。
|
||||||
|
// adjustment_id 是业务幂等键;RequestMeta.request_id 只做链路追踪,不能替代资金幂等语义。
|
||||||
|
type AdjustLuckyGiftPoolBalanceRequest 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"`
|
||||||
|
StrategyVersion string `protobuf:"bytes,3,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"`
|
||||||
|
AdjustmentId string `protobuf:"bytes,4,opt,name=adjustment_id,json=adjustmentId,proto3" json:"adjustment_id,omitempty"`
|
||||||
|
Direction string `protobuf:"bytes,5,opt,name=direction,proto3" json:"direction,omitempty"`
|
||||||
|
AmountCoins int64 `protobuf:"varint,6,opt,name=amount_coins,json=amountCoins,proto3" json:"amount_coins,omitempty"`
|
||||||
|
Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||||
|
OperatorAdminId int64 `protobuf:"varint,8,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) Reset() {
|
||||||
|
*x = AdjustLuckyGiftPoolBalanceRequest{}
|
||||||
|
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[29]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AdjustLuckyGiftPoolBalanceRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[29]
|
||||||
|
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 AdjustLuckyGiftPoolBalanceRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*AdjustLuckyGiftPoolBalanceRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{29}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) GetMeta() *RequestMeta {
|
||||||
|
if x != nil {
|
||||||
|
return x.Meta
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) GetStrategyVersion() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.StrategyVersion
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) GetAdjustmentId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.AdjustmentId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) GetDirection() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Direction
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) GetAmountCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AmountCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) GetReason() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reason
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceRequest) GetOperatorAdminId() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.OperatorAdminId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type LuckyGiftPoolAdjustment struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
AdjustmentId string `protobuf:"bytes,1,opt,name=adjustment_id,json=adjustmentId,proto3" json:"adjustment_id,omitempty"`
|
||||||
|
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||||
|
PoolId string `protobuf:"bytes,3,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||||
|
StrategyVersion string `protobuf:"bytes,4,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"`
|
||||||
|
Direction string `protobuf:"bytes,5,opt,name=direction,proto3" json:"direction,omitempty"`
|
||||||
|
AmountCoins int64 `protobuf:"varint,6,opt,name=amount_coins,json=amountCoins,proto3" json:"amount_coins,omitempty"`
|
||||||
|
Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||||
|
OperatorAdminId int64 `protobuf:"varint,8,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"`
|
||||||
|
BalanceBefore int64 `protobuf:"varint,9,opt,name=balance_before,json=balanceBefore,proto3" json:"balance_before,omitempty"`
|
||||||
|
BalanceAfter int64 `protobuf:"varint,10,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
|
||||||
|
CreatedAtMs int64 `protobuf:"varint,11,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) Reset() {
|
||||||
|
*x = LuckyGiftPoolAdjustment{}
|
||||||
|
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[30]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*LuckyGiftPoolAdjustment) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[30]
|
||||||
|
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 LuckyGiftPoolAdjustment.ProtoReflect.Descriptor instead.
|
||||||
|
func (*LuckyGiftPoolAdjustment) Descriptor() ([]byte, []int) {
|
||||||
|
return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{30}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetAdjustmentId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.AdjustmentId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetAppCode() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.AppCode
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetStrategyVersion() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.StrategyVersion
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetDirection() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Direction
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetAmountCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.AmountCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetReason() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Reason
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetOperatorAdminId() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.OperatorAdminId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetBalanceBefore() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.BalanceBefore
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetBalanceAfter() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.BalanceAfter
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftPoolAdjustment) GetCreatedAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CreatedAtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdjustLuckyGiftPoolBalanceResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Adjustment *LuckyGiftPoolAdjustment `protobuf:"bytes,1,opt,name=adjustment,proto3" json:"adjustment,omitempty"`
|
||||||
|
Pool *LuckyGiftPoolBalance `protobuf:"bytes,2,opt,name=pool,proto3" json:"pool,omitempty"`
|
||||||
|
IdempotentReplay bool `protobuf:"varint,3,opt,name=idempotent_replay,json=idempotentReplay,proto3" json:"idempotent_replay,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceResponse) Reset() {
|
||||||
|
*x = AdjustLuckyGiftPoolBalanceResponse{}
|
||||||
|
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[31]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AdjustLuckyGiftPoolBalanceResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[31]
|
||||||
|
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 AdjustLuckyGiftPoolBalanceResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*AdjustLuckyGiftPoolBalanceResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{31}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceResponse) GetAdjustment() *LuckyGiftPoolAdjustment {
|
||||||
|
if x != nil {
|
||||||
|
return x.Adjustment
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceResponse) GetPool() *LuckyGiftPoolBalance {
|
||||||
|
if x != nil {
|
||||||
|
return x.Pool
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AdjustLuckyGiftPoolBalanceResponse) GetIdempotentReplay() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.IdempotentReplay
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var File_proto_luckygift_v1_luckygift_proto protoreflect.FileDescriptor
|
var File_proto_luckygift_v1_luckygift_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
||||||
@ -2430,7 +3002,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\x0fgateway_node_id\x18\x03 \x01(\tR\rgatewayNodeId\x12\x1c\n" +
|
"\x0fgateway_node_id\x18\x03 \x01(\tR\rgatewayNodeId\x12\x1c\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"sent_at_ms\x18\x04 \x01(\x03R\bsentAtMs\x12\x19\n" +
|
"sent_at_ms\x18\x04 \x01(\x03R\bsentAtMs\x12\x19\n" +
|
||||||
"\bapp_code\x18\x05 \x01(\tR\aappCode\"\x8b\x05\n" +
|
"\bapp_code\x18\x05 \x01(\tR\aappCode\"\xc2\x06\n" +
|
||||||
"\rLuckyGiftMeta\x123\n" +
|
"\rLuckyGiftMeta\x123\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@ -2456,17 +3028,23 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"senderName\x12#\n" +
|
"senderName\x12#\n" +
|
||||||
"\rsender_avatar\x18\x10 \x01(\tR\fsenderAvatar\x123\n" +
|
"\rsender_avatar\x18\x10 \x01(\tR\fsenderAvatar\x123\n" +
|
||||||
"\x16sender_display_user_id\x18\x11 \x01(\tR\x13senderDisplayUserId\x12@\n" +
|
"\x16sender_display_user_id\x18\x11 \x01(\tR\x13senderDisplayUserId\x12@\n" +
|
||||||
"\x1dsender_pretty_display_user_id\x18\x12 \x01(\tR\x19senderPrettyDisplayUserId\"\xd3\x01\n" +
|
"\x1dsender_pretty_display_user_id\x18\x12 \x01(\tR\x19senderPrettyDisplayUserId\x12*\n" +
|
||||||
|
"\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" +
|
||||||
"\x11LuckyGiftRuleTier\x12\x14\n" +
|
"\x11LuckyGiftRuleTier\x12\x14\n" +
|
||||||
"\x05stage\x18\x01 \x01(\tR\x05stage\x12\x17\n" +
|
"\x05stage\x18\x01 \x01(\tR\x05stage\x12\x17\n" +
|
||||||
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12%\n" +
|
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12%\n" +
|
||||||
"\x0emultiplier_ppm\x18\x03 \x01(\x03R\rmultiplierPpm\x12&\n" +
|
"\x0emultiplier_ppm\x18\x03 \x01(\x03R\rmultiplierPpm\x12&\n" +
|
||||||
"\x0fbase_weight_ppm\x18\x04 \x01(\x03R\rbaseWeightPpm\x12&\n" +
|
"\x0fbase_weight_ppm\x18\x04 \x01(\x03R\rbaseWeightPpm\x12&\n" +
|
||||||
"\x0fhigh_water_only\x18\x05 \x01(\bR\rhighWaterOnly\x12\x18\n" +
|
"\x0fhigh_water_only\x18\x05 \x01(\bR\rhighWaterOnly\x12\x18\n" +
|
||||||
"\aenabled\x18\x06 \x01(\bR\aenabled\"g\n" +
|
"\aenabled\x18\x06 \x01(\bR\aenabled\"\xcf\x01\n" +
|
||||||
"\x12LuckyGiftRuleStage\x12\x14\n" +
|
"\x12LuckyGiftRuleStage\x12\x14\n" +
|
||||||
"\x05stage\x18\x01 \x01(\tR\x05stage\x12;\n" +
|
"\x05stage\x18\x01 \x01(\tR\x05stage\x12;\n" +
|
||||||
"\x05tiers\x18\x02 \x03(\v2%.hyapp.luckygift.v1.LuckyGiftRuleTierR\x05tiers\"\xa1\x05\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" +
|
||||||
"\x13LuckyGiftRuleConfig\x12\x19\n" +
|
"\x13LuckyGiftRuleConfig\x12\x19\n" +
|
||||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
||||||
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12!\n" +
|
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12!\n" +
|
||||||
@ -2483,13 +3061,36 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\x11effective_from_ms\x18\f \x01(\x03R\x0feffectiveFromMs\x12-\n" +
|
"\x11effective_from_ms\x18\f \x01(\x03R\x0feffectiveFromMs\x12-\n" +
|
||||||
"\x13created_by_admin_id\x18\r \x01(\x03R\x10createdByAdminId\x12\"\n" +
|
"\x13created_by_admin_id\x18\r \x01(\x03R\x10createdByAdminId\x12\"\n" +
|
||||||
"\rcreated_at_ms\x18\x0e \x01(\x03R\vcreatedAtMs\x12>\n" +
|
"\rcreated_at_ms\x18\x0e \x01(\x03R\vcreatedAtMs\x12>\n" +
|
||||||
"\x06stages\x18\x0f \x03(\v2&.hyapp.luckygift.v1.LuckyGiftRuleStageR\x06stages\"\xb0\x01\n" +
|
"\x06stages\x18\x0f \x03(\v2&.hyapp.luckygift.v1.LuckyGiftRuleStageR\x06stages\x12)\n" +
|
||||||
|
"\x10strategy_version\x18\x10 \x01(\tR\x0fstrategyVersion\x12&\n" +
|
||||||
|
"\x0fprofit_rate_ppm\x18\x11 \x01(\x03R\rprofitRatePpm\x12&\n" +
|
||||||
|
"\x0fanchor_rate_ppm\x18\x12 \x01(\x03R\ranchorRatePpm\x12,\n" +
|
||||||
|
"\x12initial_pool_coins\x18\x13 \x01(\x03R\x10initialPoolCoins\x122\n" +
|
||||||
|
"\x15loss_streak_guarantee\x18\x14 \x01(\x03R\x13lossStreakGuarantee\x12.\n" +
|
||||||
|
"\x13low_watermark_coins\x18\x15 \x01(\x03R\x11lowWatermarkCoins\x12>\n" +
|
||||||
|
"\x1clow_water_nonzero_factor_ppm\x18\x16 \x01(\x03R\x18lowWaterNonzeroFactorPpm\x120\n" +
|
||||||
|
"\x14high_watermark_coins\x18\x17 \x01(\x03R\x12highWatermarkCoins\x12@\n" +
|
||||||
|
"\x1dhigh_water_nonzero_factor_ppm\x18\x18 \x01(\x03R\x19highWaterNonzeroFactorPpm\x127\n" +
|
||||||
|
"\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" +
|
||||||
|
"\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" +
|
||||||
|
"\x16user_hourly_payout_cap\x18\" \x01(\x03R\x13userHourlyPayoutCap\x121\n" +
|
||||||
|
"\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" +
|
||||||
"\x15CheckLuckyGiftRequest\x123\n" +
|
"\x15CheckLuckyGiftRequest\x123\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\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" +
|
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
|
||||||
"\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\x17\n" +
|
"\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\x17\n" +
|
||||||
"\agift_id\x18\x04 \x01(\tR\x06giftId\x12\x17\n" +
|
"\agift_id\x18\x04 \x01(\tR\x06giftId\x12\x17\n" +
|
||||||
"\apool_id\x18\x05 \x01(\tR\x06poolId\"\x8d\x02\n" +
|
"\apool_id\x18\x05 \x01(\tR\x06poolId\"\xb8\x02\n" +
|
||||||
"\x16CheckLuckyGiftResponse\x12\x18\n" +
|
"\x16CheckLuckyGiftResponse\x12\x18\n" +
|
||||||
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" +
|
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" +
|
||||||
"\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\n" +
|
"\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\n" +
|
||||||
@ -2499,7 +3100,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
|
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
|
||||||
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" +
|
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" +
|
||||||
"\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" +
|
"\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" +
|
||||||
"\apool_id\x18\b \x01(\tR\x06poolId\"\xc4\a\n" +
|
"\apool_id\x18\b \x01(\tR\x06poolId\x12)\n" +
|
||||||
|
"\x10strategy_version\x18\t \x01(\tR\x0fstrategyVersion\"\xc4\a\n" +
|
||||||
"\x13LuckyGiftDrawResult\x12\x17\n" +
|
"\x13LuckyGiftDrawResult\x12\x17\n" +
|
||||||
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@ -2536,7 +3138,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\vlucky_gifts\x18\x01 \x03(\v2!.hyapp.luckygift.v1.LuckyGiftMetaR\n" +
|
"\vlucky_gifts\x18\x01 \x03(\v2!.hyapp.luckygift.v1.LuckyGiftMetaR\n" +
|
||||||
"luckyGifts\"f\n" +
|
"luckyGifts\"f\n" +
|
||||||
"!BatchExecuteLuckyGiftDrawResponse\x12A\n" +
|
"!BatchExecuteLuckyGiftDrawResponse\x12A\n" +
|
||||||
"\aresults\x18\x01 \x03(\v2'.hyapp.luckygift.v1.LuckyGiftDrawResultR\aresults\"\x8d\x03\n" +
|
"\aresults\x18\x01 \x03(\v2'.hyapp.luckygift.v1.LuckyGiftDrawResultR\aresults\"\xaa\x03\n" +
|
||||||
"\x17ExternalGiftDrawRequest\x123\n" +
|
"\x17ExternalGiftDrawRequest\x123\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x19\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x19\n" +
|
||||||
"\bapp_code\x18\x03 \x01(\tR\aappCode\x12(\n" +
|
"\bapp_code\x18\x03 \x01(\tR\aappCode\x12(\n" +
|
||||||
@ -2553,7 +3155,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"paid_at_ms\x18\n" +
|
"paid_at_ms\x18\n" +
|
||||||
" \x01(\x03R\bpaidAtMs\x12#\n" +
|
" \x01(\x03R\bpaidAtMs\x12#\n" +
|
||||||
"\rmetadata_json\x18\v \x01(\tR\fmetadataJson\x12\x17\n" +
|
"\rmetadata_json\x18\v \x01(\tR\fmetadataJson\x12\x17\n" +
|
||||||
"\apool_id\x18\f \x01(\tR\x06poolId\"\xb2\x03\n" +
|
"\apool_id\x18\f \x01(\tR\x06poolId\x12\x1b\n" +
|
||||||
|
"\tdevice_id\x18\r \x01(\tR\bdeviceId\"\xb2\x03\n" +
|
||||||
"\x18ExternalGiftDrawResponse\x12\x17\n" +
|
"\x18ExternalGiftDrawResponse\x12\x17\n" +
|
||||||
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@ -2614,7 +3217,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\rpending_draws\x18\t \x01(\x03R\fpendingDraws\x12#\n" +
|
"\rpending_draws\x18\t \x01(\x03R\fpendingDraws\x12#\n" +
|
||||||
"\rgranted_draws\x18\n" +
|
"\rgranted_draws\x18\n" +
|
||||||
" \x01(\x03R\fgrantedDraws\x12!\n" +
|
" \x01(\x03R\fgrantedDraws\x12!\n" +
|
||||||
"\ffailed_draws\x18\v \x01(\x03R\vfailedDraws\"\xb6\x02\n" +
|
"\ffailed_draws\x18\v \x01(\x03R\vfailedDraws\"\xbf\x03\n" +
|
||||||
"\x14LuckyGiftPoolBalance\x12\x19\n" +
|
"\x14LuckyGiftPoolBalance\x12\x19\n" +
|
||||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
||||||
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12\x18\n" +
|
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12\x18\n" +
|
||||||
@ -2624,7 +3227,11 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\btotal_in\x18\x06 \x01(\x03R\atotalIn\x12\x1b\n" +
|
"\btotal_in\x18\x06 \x01(\x03R\atotalIn\x12\x1b\n" +
|
||||||
"\ttotal_out\x18\a \x01(\x03R\btotalOut\x12\"\n" +
|
"\ttotal_out\x18\a \x01(\x03R\btotalOut\x12\"\n" +
|
||||||
"\fmaterialized\x18\b \x01(\bR\fmaterialized\x12\"\n" +
|
"\fmaterialized\x18\b \x01(\bR\fmaterialized\x12\"\n" +
|
||||||
"\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\xa0\x02\n" +
|
"\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\x12)\n" +
|
||||||
|
"\x10strategy_version\x18\n" +
|
||||||
|
" \x01(\tR\x0fstrategyVersion\x12.\n" +
|
||||||
|
"\x13manual_credit_total\x18\v \x01(\x03R\x11manualCreditTotal\x12,\n" +
|
||||||
|
"\x12manual_debit_total\x18\f \x01(\x03R\x10manualDebitTotal\"\xa0\x02\n" +
|
||||||
"\x1eGetLuckyGiftDrawSummaryRequest\x123\n" +
|
"\x1eGetLuckyGiftDrawSummaryRequest\x123\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||||
"\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x17\n" +
|
"\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x17\n" +
|
||||||
@ -2635,10 +3242,11 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\x10external_user_id\x18\b \x01(\tR\x0eexternalUserId\x12#\n" +
|
"\x10external_user_id\x18\b \x01(\tR\x0eexternalUserId\x12#\n" +
|
||||||
"\rexternal_only\x18\t \x01(\bR\fexternalOnly\"e\n" +
|
"\rexternal_only\x18\t \x01(\bR\fexternalOnly\"e\n" +
|
||||||
"\x1fGetLuckyGiftDrawSummaryResponse\x12B\n" +
|
"\x1fGetLuckyGiftDrawSummaryResponse\x12B\n" +
|
||||||
"\asummary\x18\x01 \x01(\v2(.hyapp.luckygift.v1.LuckyGiftDrawSummaryR\asummary\"p\n" +
|
"\asummary\x18\x01 \x01(\v2(.hyapp.luckygift.v1.LuckyGiftDrawSummaryR\asummary\"\x9b\x01\n" +
|
||||||
" ListLuckyGiftPoolBalancesRequest\x123\n" +
|
" ListLuckyGiftPoolBalancesRequest\x123\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||||
"\apool_id\x18\x02 \x01(\tR\x06poolId\"c\n" +
|
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12)\n" +
|
||||||
|
"\x10strategy_version\x18\x03 \x01(\tR\x0fstrategyVersion\"c\n" +
|
||||||
"!ListLuckyGiftPoolBalancesResponse\x12>\n" +
|
"!ListLuckyGiftPoolBalancesResponse\x12>\n" +
|
||||||
"\x05pools\x18\x01 \x03(\v2(.hyapp.luckygift.v1.LuckyGiftPoolBalanceR\x05pools\"\xba\x01\n" +
|
"\x05pools\x18\x01 \x03(\v2(.hyapp.luckygift.v1.LuckyGiftPoolBalanceR\x05pools\"\xba\x01\n" +
|
||||||
"\fLuckyGiftHit\x12\x1d\n" +
|
"\fLuckyGiftHit\x12\x1d\n" +
|
||||||
@ -2647,19 +3255,48 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\adraw_id\x18\x02 \x01(\tR\x06drawId\x12(\n" +
|
"\adraw_id\x18\x02 \x01(\tR\x06drawId\x12(\n" +
|
||||||
"\x10selected_tier_id\x18\x03 \x01(\tR\x0eselectedTierId\x12%\n" +
|
"\x10selected_tier_id\x18\x03 \x01(\tR\x0eselectedTierId\x12%\n" +
|
||||||
"\x0emultiplier_ppm\x18\x04 \x01(\x03R\rmultiplierPpm\x12!\n" +
|
"\x0emultiplier_ppm\x18\x04 \x01(\x03R\rmultiplierPpm\x12!\n" +
|
||||||
"\freward_coins\x18\x05 \x01(\x03R\vrewardCoins2\xf7\x03\n" +
|
"\freward_coins\x18\x05 \x01(\x03R\vrewardCoins\"\xc6\x02\n" +
|
||||||
|
"!AdjustLuckyGiftPoolBalanceRequest\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" +
|
||||||
|
"\x10strategy_version\x18\x03 \x01(\tR\x0fstrategyVersion\x12#\n" +
|
||||||
|
"\radjustment_id\x18\x04 \x01(\tR\fadjustmentId\x12\x1c\n" +
|
||||||
|
"\tdirection\x18\x05 \x01(\tR\tdirection\x12!\n" +
|
||||||
|
"\famount_coins\x18\x06 \x01(\x03R\vamountCoins\x12\x16\n" +
|
||||||
|
"\x06reason\x18\a \x01(\tR\x06reason\x12*\n" +
|
||||||
|
"\x11operator_admin_id\x18\b \x01(\x03R\x0foperatorAdminId\"\x92\x03\n" +
|
||||||
|
"\x17LuckyGiftPoolAdjustment\x12#\n" +
|
||||||
|
"\radjustment_id\x18\x01 \x01(\tR\fadjustmentId\x12\x19\n" +
|
||||||
|
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" +
|
||||||
|
"\apool_id\x18\x03 \x01(\tR\x06poolId\x12)\n" +
|
||||||
|
"\x10strategy_version\x18\x04 \x01(\tR\x0fstrategyVersion\x12\x1c\n" +
|
||||||
|
"\tdirection\x18\x05 \x01(\tR\tdirection\x12!\n" +
|
||||||
|
"\famount_coins\x18\x06 \x01(\x03R\vamountCoins\x12\x16\n" +
|
||||||
|
"\x06reason\x18\a \x01(\tR\x06reason\x12*\n" +
|
||||||
|
"\x11operator_admin_id\x18\b \x01(\x03R\x0foperatorAdminId\x12%\n" +
|
||||||
|
"\x0ebalance_before\x18\t \x01(\x03R\rbalanceBefore\x12#\n" +
|
||||||
|
"\rbalance_after\x18\n" +
|
||||||
|
" \x01(\x03R\fbalanceAfter\x12\"\n" +
|
||||||
|
"\rcreated_at_ms\x18\v \x01(\x03R\vcreatedAtMs\"\xdc\x01\n" +
|
||||||
|
"\"AdjustLuckyGiftPoolBalanceResponse\x12K\n" +
|
||||||
|
"\n" +
|
||||||
|
"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" +
|
||||||
"\x10LuckyGiftService\x12g\n" +
|
"\x10LuckyGiftService\x12g\n" +
|
||||||
"\x0eCheckLuckyGift\x12).hyapp.luckygift.v1.CheckLuckyGiftRequest\x1a*.hyapp.luckygift.v1.CheckLuckyGiftResponse\x12y\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" +
|
"\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" +
|
"\x19BatchExecuteLuckyGiftDraw\x124.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest\x1a5.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse\x12t\n" +
|
||||||
"\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\x8a\x06\n" +
|
"\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\x98\a\n" +
|
||||||
"\x15AdminLuckyGiftService\x12s\n" +
|
"\x15AdminLuckyGiftService\x12s\n" +
|
||||||
"\x12GetLuckyGiftConfig\x12-.hyapp.luckygift.v1.GetLuckyGiftConfigRequest\x1a..hyapp.luckygift.v1.GetLuckyGiftConfigResponse\x12|\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" +
|
"\x15UpsertLuckyGiftConfig\x120.hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest\x1a1.hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse\x12y\n" +
|
||||||
"\x14ListLuckyGiftConfigs\x12/.hyapp.luckygift.v1.ListLuckyGiftConfigsRequest\x1a0.hyapp.luckygift.v1.ListLuckyGiftConfigsResponse\x12s\n" +
|
"\x14ListLuckyGiftConfigs\x12/.hyapp.luckygift.v1.ListLuckyGiftConfigsRequest\x1a0.hyapp.luckygift.v1.ListLuckyGiftConfigsResponse\x12s\n" +
|
||||||
"\x12ListLuckyGiftDraws\x12-.hyapp.luckygift.v1.ListLuckyGiftDrawsRequest\x1a..hyapp.luckygift.v1.ListLuckyGiftDrawsResponse\x12\x82\x01\n" +
|
"\x12ListLuckyGiftDraws\x12-.hyapp.luckygift.v1.ListLuckyGiftDrawsRequest\x1a..hyapp.luckygift.v1.ListLuckyGiftDrawsResponse\x12\x82\x01\n" +
|
||||||
"\x17GetLuckyGiftDrawSummary\x122.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest\x1a3.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse\x12\x88\x01\n" +
|
"\x17GetLuckyGiftDrawSummary\x122.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest\x1a3.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse\x12\x88\x01\n" +
|
||||||
"\x19ListLuckyGiftPoolBalances\x124.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest\x1a5.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3"
|
"\x19ListLuckyGiftPoolBalances\x124.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest\x1a5.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse\x12\x8b\x01\n" +
|
||||||
|
"\x1aAdjustLuckyGiftPoolBalance\x125.hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest\x1a6.hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_proto_luckygift_v1_luckygift_proto_rawDescOnce sync.Once
|
file_proto_luckygift_v1_luckygift_proto_rawDescOnce sync.Once
|
||||||
@ -2673,7 +3310,7 @@ func file_proto_luckygift_v1_luckygift_proto_rawDescGZIP() []byte {
|
|||||||
return file_proto_luckygift_v1_luckygift_proto_rawDescData
|
return file_proto_luckygift_v1_luckygift_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
|
var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 32)
|
||||||
var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
|
var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
|
||||||
(*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta
|
(*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta
|
||||||
(*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta
|
(*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta
|
||||||
@ -2704,6 +3341,9 @@ var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
|
|||||||
(*ListLuckyGiftPoolBalancesRequest)(nil), // 26: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
|
(*ListLuckyGiftPoolBalancesRequest)(nil), // 26: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
|
||||||
(*ListLuckyGiftPoolBalancesResponse)(nil), // 27: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
|
(*ListLuckyGiftPoolBalancesResponse)(nil), // 27: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
|
||||||
(*LuckyGiftHit)(nil), // 28: hyapp.luckygift.v1.LuckyGiftHit
|
(*LuckyGiftHit)(nil), // 28: hyapp.luckygift.v1.LuckyGiftHit
|
||||||
|
(*AdjustLuckyGiftPoolBalanceRequest)(nil), // 29: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest
|
||||||
|
(*LuckyGiftPoolAdjustment)(nil), // 30: hyapp.luckygift.v1.LuckyGiftPoolAdjustment
|
||||||
|
(*AdjustLuckyGiftPoolBalanceResponse)(nil), // 31: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse
|
||||||
}
|
}
|
||||||
var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{
|
var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{
|
||||||
0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta
|
0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta
|
||||||
@ -2729,31 +3369,36 @@ var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{
|
|||||||
22, // 20: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.luckygift.v1.LuckyGiftDrawSummary
|
22, // 20: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.luckygift.v1.LuckyGiftDrawSummary
|
||||||
0, // 21: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
|
0, // 21: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
|
||||||
23, // 22: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse.pools:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance
|
23, // 22: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse.pools:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance
|
||||||
5, // 23: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest
|
0, // 23: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
|
||||||
8, // 24: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest
|
30, // 24: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.adjustment:type_name -> hyapp.luckygift.v1.LuckyGiftPoolAdjustment
|
||||||
10, // 25: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest
|
23, // 25: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.pool:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance
|
||||||
12, // 26: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest
|
5, // 26: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest
|
||||||
14, // 27: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest
|
8, // 27: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest
|
||||||
16, // 28: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest
|
10, // 28: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest
|
||||||
18, // 29: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest
|
12, // 29: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest
|
||||||
20, // 30: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest
|
14, // 30: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest
|
||||||
24, // 31: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest
|
16, // 31: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest
|
||||||
26, // 32: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
|
18, // 32: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest
|
||||||
6, // 33: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse
|
20, // 33: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest
|
||||||
9, // 34: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse
|
24, // 34: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest
|
||||||
11, // 35: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse
|
26, // 35: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
|
||||||
13, // 36: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse
|
29, // 36: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest
|
||||||
15, // 37: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse
|
6, // 37: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse
|
||||||
17, // 38: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse
|
9, // 38: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse
|
||||||
19, // 39: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse
|
11, // 39: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse
|
||||||
21, // 40: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse
|
13, // 40: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse
|
||||||
25, // 41: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
|
15, // 41: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse
|
||||||
27, // 42: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
|
17, // 42: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse
|
||||||
33, // [33:43] is the sub-list for method output_type
|
19, // 43: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse
|
||||||
23, // [23:33] is the sub-list for method input_type
|
21, // 44: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse
|
||||||
23, // [23:23] is the sub-list for extension type_name
|
25, // 45: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
|
||||||
23, // [23:23] is the sub-list for extension extendee
|
27, // 46: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
|
||||||
0, // [0:23] is the sub-list for field type_name
|
31, // 47: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse
|
||||||
|
37, // [37:48] is the sub-list for method output_type
|
||||||
|
26, // [26:37] is the sub-list for method input_type
|
||||||
|
26, // [26:26] is the sub-list for extension type_name
|
||||||
|
26, // [26:26] is the sub-list for extension extendee
|
||||||
|
0, // [0:26] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_proto_luckygift_v1_luckygift_proto_init() }
|
func init() { file_proto_luckygift_v1_luckygift_proto_init() }
|
||||||
@ -2767,7 +3412,7 @@ func file_proto_luckygift_v1_luckygift_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
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)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_luckygift_v1_luckygift_proto_rawDesc), len(file_proto_luckygift_v1_luckygift_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 29,
|
NumMessages: 32,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 2,
|
NumServices: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -36,6 +36,12 @@ message LuckyGiftMeta {
|
|||||||
string sender_avatar = 16;
|
string sender_avatar = 16;
|
||||||
string sender_display_user_id = 17;
|
string sender_display_user_id = 17;
|
||||||
string sender_pretty_display_user_id = 18;
|
string sender_pretty_display_user_id = 18;
|
||||||
|
// 充值分层与充值后短时加权必须由调用方传入已确认的用户事实;幸运礼物服务不跨库回查用户或支付明细。
|
||||||
|
int64 recharge_7d_coins = 19;
|
||||||
|
int64 recharge_30d_coins = 20;
|
||||||
|
int64 last_recharged_at_ms = 21;
|
||||||
|
// gift_income_coins 是 wallet 已实际入账给收礼人的收益金币;dynamic_v3 用它校验规则主播拆账,缺失或比例不一致均拒绝开奖。
|
||||||
|
int64 gift_income_coins = 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LuckyGiftRuleTier {
|
message LuckyGiftRuleTier {
|
||||||
@ -50,6 +56,9 @@ message LuckyGiftRuleTier {
|
|||||||
message LuckyGiftRuleStage {
|
message LuckyGiftRuleStage {
|
||||||
string stage = 1;
|
string stage = 1;
|
||||||
repeated LuckyGiftRuleTier tiers = 2;
|
repeated LuckyGiftRuleTier tiers = 2;
|
||||||
|
// 两个充值门槛共同决定 dynamic_v3 分层;novice 固定为 0/0,后续阶段逐维单调递增。
|
||||||
|
int64 min_recharge_7d_coins = 3;
|
||||||
|
int64 min_recharge_30d_coins = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LuckyGiftRuleConfig {
|
message LuckyGiftRuleConfig {
|
||||||
@ -68,6 +77,30 @@ message LuckyGiftRuleConfig {
|
|||||||
int64 created_by_admin_id = 13;
|
int64 created_by_admin_id = 13;
|
||||||
int64 created_at_ms = 14;
|
int64 created_at_ms = 14;
|
||||||
repeated LuckyGiftRuleStage stages = 15;
|
repeated LuckyGiftRuleStage stages = 15;
|
||||||
|
// strategy_version 隔离历史 fixed_v2 与动态算法 dynamic_v3,避免新增约束破坏已经发布的旧规则。
|
||||||
|
string strategy_version = 16;
|
||||||
|
int64 profit_rate_ppm = 17;
|
||||||
|
int64 anchor_rate_ppm = 18;
|
||||||
|
int64 initial_pool_coins = 19;
|
||||||
|
int64 loss_streak_guarantee = 20;
|
||||||
|
int64 low_watermark_coins = 21;
|
||||||
|
int64 low_water_nonzero_factor_ppm = 22;
|
||||||
|
int64 high_watermark_coins = 23;
|
||||||
|
int64 high_water_nonzero_factor_ppm = 24;
|
||||||
|
int64 recharge_boost_window_ms = 25;
|
||||||
|
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_spend_threshold_coins = 31;
|
||||||
|
int64 max_jackpot_hits_per_user_day = 32;
|
||||||
|
int64 max_single_payout = 33;
|
||||||
|
int64 user_hourly_payout_cap = 34;
|
||||||
|
int64 user_daily_payout_cap = 35;
|
||||||
|
int64 device_daily_payout_cap = 36;
|
||||||
|
int64 room_hourly_payout_cap = 37;
|
||||||
|
int64 anchor_daily_payout_cap = 38;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CheckLuckyGiftRequest {
|
message CheckLuckyGiftRequest {
|
||||||
@ -87,6 +120,8 @@ message CheckLuckyGiftResponse {
|
|||||||
int64 target_rtp_ppm = 6;
|
int64 target_rtp_ppm = 6;
|
||||||
string experience_pool = 7;
|
string experience_pool = 7;
|
||||||
string pool_id = 8;
|
string pool_id = 8;
|
||||||
|
// strategy_version 由 lucky-gift owner 返回并由 room saga 固化,用于滚动发布时区分 fixed_v2 兼容与 dynamic_v3 强校验。
|
||||||
|
string strategy_version = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LuckyGiftDrawResult {
|
message LuckyGiftDrawResult {
|
||||||
@ -146,6 +181,8 @@ message ExternalGiftDrawRequest {
|
|||||||
int64 paid_at_ms = 10;
|
int64 paid_at_ms = 10;
|
||||||
string metadata_json = 11;
|
string metadata_json = 11;
|
||||||
string pool_id = 12;
|
string pool_id = 12;
|
||||||
|
// dynamic_v3 必填的稳定设备作用域。luck-gateway 不独立证明其真实性,调用方必须在自己的认证/签名边界内绑定该值;fixed_v2 允许缺失。
|
||||||
|
string device_id = 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExternalGiftDrawResponse {
|
message ExternalGiftDrawResponse {
|
||||||
@ -232,6 +269,9 @@ message LuckyGiftPoolBalance {
|
|||||||
int64 total_out = 7;
|
int64 total_out = 7;
|
||||||
bool materialized = 8;
|
bool materialized = 8;
|
||||||
int64 updated_at_ms = 9;
|
int64 updated_at_ms = 9;
|
||||||
|
string strategy_version = 10;
|
||||||
|
int64 manual_credit_total = 11;
|
||||||
|
int64 manual_debit_total = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetLuckyGiftDrawSummaryRequest {
|
message GetLuckyGiftDrawSummaryRequest {
|
||||||
@ -252,6 +292,7 @@ message GetLuckyGiftDrawSummaryResponse {
|
|||||||
message ListLuckyGiftPoolBalancesRequest {
|
message ListLuckyGiftPoolBalancesRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
string pool_id = 2;
|
string pool_id = 2;
|
||||||
|
string strategy_version = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListLuckyGiftPoolBalancesResponse {
|
message ListLuckyGiftPoolBalancesResponse {
|
||||||
@ -272,6 +313,7 @@ service AdminLuckyGiftService {
|
|||||||
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
|
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
|
||||||
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
|
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
|
||||||
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
|
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
|
||||||
|
rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。
|
// LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。
|
||||||
@ -283,3 +325,36 @@ message LuckyGiftHit {
|
|||||||
int64 multiplier_ppm = 4;
|
int64 multiplier_ppm = 4;
|
||||||
int64 reward_coins = 5;
|
int64 reward_coins = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调账消息也只追加在文件尾,保持所有既有 protobuf message index 稳定。
|
||||||
|
// adjustment_id 是业务幂等键;RequestMeta.request_id 只做链路追踪,不能替代资金幂等语义。
|
||||||
|
message AdjustLuckyGiftPoolBalanceRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string pool_id = 2;
|
||||||
|
string strategy_version = 3;
|
||||||
|
string adjustment_id = 4;
|
||||||
|
string direction = 5;
|
||||||
|
int64 amount_coins = 6;
|
||||||
|
string reason = 7;
|
||||||
|
int64 operator_admin_id = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LuckyGiftPoolAdjustment {
|
||||||
|
string adjustment_id = 1;
|
||||||
|
string app_code = 2;
|
||||||
|
string pool_id = 3;
|
||||||
|
string strategy_version = 4;
|
||||||
|
string direction = 5;
|
||||||
|
int64 amount_coins = 6;
|
||||||
|
string reason = 7;
|
||||||
|
int64 operator_admin_id = 8;
|
||||||
|
int64 balance_before = 9;
|
||||||
|
int64 balance_after = 10;
|
||||||
|
int64 created_at_ms = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdjustLuckyGiftPoolBalanceResponse {
|
||||||
|
LuckyGiftPoolAdjustment adjustment = 1;
|
||||||
|
LuckyGiftPoolBalance pool = 2;
|
||||||
|
bool idempotent_replay = 3;
|
||||||
|
}
|
||||||
|
|||||||
@ -241,6 +241,7 @@ const (
|
|||||||
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
|
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
|
||||||
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
|
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
|
||||||
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
|
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
|
||||||
|
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
|
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
|
||||||
@ -253,6 +254,7 @@ type AdminLuckyGiftServiceClient interface {
|
|||||||
ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error)
|
ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error)
|
||||||
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
|
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
|
||||||
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error)
|
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error)
|
||||||
|
AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminLuckyGiftServiceClient struct {
|
type adminLuckyGiftServiceClient struct {
|
||||||
@ -323,6 +325,16 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftPoolBalances(ctx context.Cont
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *adminLuckyGiftServiceClient) AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(AdjustLuckyGiftPoolBalanceResponse)
|
||||||
|
err := c.cc.Invoke(ctx, AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
|
// AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
|
||||||
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
|
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@ -333,6 +345,7 @@ type AdminLuckyGiftServiceServer interface {
|
|||||||
ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error)
|
ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error)
|
||||||
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
|
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
|
||||||
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
|
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
|
||||||
|
AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error)
|
||||||
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
|
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,6 +374,9 @@ func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftDrawSummary(context.
|
|||||||
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) {
|
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftPoolBalances not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftPoolBalances not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method AdjustLuckyGiftPoolBalance not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
|
func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
|
||||||
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@ -490,6 +506,24 @@ func _AdminLuckyGiftService_ListLuckyGiftPoolBalances_Handler(srv interface{}, c
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(AdjustLuckyGiftPoolBalanceRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(AdminLuckyGiftServiceServer).AdjustLuckyGiftPoolBalance(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(AdminLuckyGiftServiceServer).AdjustLuckyGiftPoolBalance(ctx, req.(*AdjustLuckyGiftPoolBalanceRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
|
// AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@ -521,6 +555,10 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ListLuckyGiftPoolBalances",
|
MethodName: "ListLuckyGiftPoolBalances",
|
||||||
Handler: _AdminLuckyGiftService_ListLuckyGiftPoolBalances_Handler,
|
Handler: _AdminLuckyGiftService_ListLuckyGiftPoolBalances_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AdjustLuckyGiftPoolBalance",
|
||||||
|
Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "proto/luckygift/v1/luckygift.proto",
|
Metadata: "proto/luckygift/v1/luckygift.proto",
|
||||||
|
|||||||
@ -35,6 +35,8 @@ type RequestMeta struct {
|
|||||||
SessionId string `protobuf:"bytes,6,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
SessionId string `protobuf:"bytes,6,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
||||||
SentAtMs int64 `protobuf:"varint,7,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"`
|
SentAtMs int64 `protobuf:"varint,7,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"`
|
||||||
AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||||
|
// device_id 只能来自 gateway 已验签 access JWT 的同名 claim;旧 JWT 缺字段时保持空值,不得用 session_id/command_id 伪造。
|
||||||
|
DeviceId string `protobuf:"bytes,9,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -125,6 +127,13 @@ func (x *RequestMeta) GetAppCode() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *RequestMeta) GetDeviceId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.DeviceId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// CommandResult 统一返回命令是否真正落地,以及落地后的房间版本。
|
// CommandResult 统一返回命令是否真正落地,以及落地后的房间版本。
|
||||||
type CommandResult struct {
|
type CommandResult struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -12594,7 +12603,7 @@ var File_proto_room_v1_room_proto protoreflect.FileDescriptor
|
|||||||
|
|
||||||
const file_proto_room_v1_room_proto_rawDesc = "" +
|
const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x18proto/room/v1/room.proto\x12\rhyapp.room.v1\"\x88\x02\n" +
|
"\x18proto/room/v1/room.proto\x12\rhyapp.room.v1\"\xa5\x02\n" +
|
||||||
"\vRequestMeta\x12\x1d\n" +
|
"\vRequestMeta\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" +
|
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" +
|
||||||
@ -12607,7 +12616,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
|||||||
"session_id\x18\x06 \x01(\tR\tsessionId\x12\x1c\n" +
|
"session_id\x18\x06 \x01(\tR\tsessionId\x12\x1c\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"sent_at_ms\x18\a \x01(\x03R\bsentAtMs\x12\x19\n" +
|
"sent_at_ms\x18\a \x01(\x03R\bsentAtMs\x12\x19\n" +
|
||||||
"\bapp_code\x18\b \x01(\tR\aappCode\"r\n" +
|
"\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\n" +
|
||||||
|
"\tdevice_id\x18\t \x01(\tR\bdeviceId\"r\n" +
|
||||||
"\rCommandResult\x12\x18\n" +
|
"\rCommandResult\x12\x18\n" +
|
||||||
"\aapplied\x18\x01 \x01(\bR\aapplied\x12!\n" +
|
"\aapplied\x18\x01 \x01(\bR\aapplied\x12!\n" +
|
||||||
"\froom_version\x18\x02 \x01(\x03R\vroomVersion\x12$\n" +
|
"\froom_version\x18\x02 \x01(\x03R\vroomVersion\x12$\n" +
|
||||||
|
|||||||
@ -17,6 +17,8 @@ message RequestMeta {
|
|||||||
string session_id = 6;
|
string session_id = 6;
|
||||||
int64 sent_at_ms = 7;
|
int64 sent_at_ms = 7;
|
||||||
string app_code = 8;
|
string app_code = 8;
|
||||||
|
// device_id 只能来自 gateway 已验签 access JWT 的同名 claim;旧 JWT 缺字段时保持空值,不得用 session_id/command_id 伪造。
|
||||||
|
string device_id = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommandResult 统一返回命令是否真正落地,以及落地后的房间版本。
|
// CommandResult 统一返回命令是否真正落地,以及落地后的房间版本。
|
||||||
|
|||||||
@ -13259,6 +13259,95 @@ func (x *ListCPFormationGiftFeedResponse) GetItems() []*CPFormationGiftFeedItem
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAppsRequest 由内部调度器读取启用租户;不接受租户过滤,避免后台任务再次退化成单 App。
|
||||||
|
type ListAppsRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListAppsRequest) Reset() {
|
||||||
|
*x = ListAppsRequest{}
|
||||||
|
mi := &file_proto_user_v1_user_proto_msgTypes[180]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListAppsRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ListAppsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ListAppsRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_proto_user_v1_user_proto_msgTypes[180]
|
||||||
|
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 ListAppsRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ListAppsRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_proto_user_v1_user_proto_rawDescGZIP(), []int{180}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListAppsRequest) GetMeta() *RequestMeta {
|
||||||
|
if x != nil {
|
||||||
|
return x.Meta
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListAppsResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Apps []*App `protobuf:"bytes,1,rep,name=apps,proto3" json:"apps,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListAppsResponse) Reset() {
|
||||||
|
*x = ListAppsResponse{}
|
||||||
|
mi := &file_proto_user_v1_user_proto_msgTypes[181]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListAppsResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ListAppsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ListAppsResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_proto_user_v1_user_proto_msgTypes[181]
|
||||||
|
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 ListAppsResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ListAppsResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_proto_user_v1_user_proto_rawDescGZIP(), []int{181}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListAppsResponse) GetApps() []*App {
|
||||||
|
if x != nil {
|
||||||
|
return x.Apps
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var File_proto_user_v1_user_proto protoreflect.FileDescriptor
|
var File_proto_user_v1_user_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_proto_user_v1_user_proto_rawDesc = "" +
|
const file_proto_user_v1_user_proto_rawDesc = "" +
|
||||||
@ -14384,7 +14473,11 @@ const file_proto_user_v1_user_proto_rawDesc = "" +
|
|||||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x14\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x14\n" +
|
||||||
"\x05limit\x18\x02 \x01(\x05R\x05limit\"_\n" +
|
"\x05limit\x18\x02 \x01(\x05R\x05limit\"_\n" +
|
||||||
"\x1fListCPFormationGiftFeedResponse\x12<\n" +
|
"\x1fListCPFormationGiftFeedResponse\x12<\n" +
|
||||||
"\x05items\x18\x01 \x03(\v2&.hyapp.user.v1.CPFormationGiftFeedItemR\x05items*s\n" +
|
"\x05items\x18\x01 \x03(\v2&.hyapp.user.v1.CPFormationGiftFeedItemR\x05items\"A\n" +
|
||||||
|
"\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" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"UserStatus\x12\x1b\n" +
|
"UserStatus\x12\x1b\n" +
|
||||||
"\x17USER_STATUS_UNSPECIFIED\x10\x00\x12\x16\n" +
|
"\x17USER_STATUS_UNSPECIFIED\x10\x00\x12\x16\n" +
|
||||||
@ -14453,10 +14546,11 @@ const file_proto_user_v1_user_proto_rawDesc = "" +
|
|||||||
"\x1cRefreshCPIntimacyLeaderboard\x12\x1f.hyapp.user.v1.CronBatchRequest\x1a .hyapp.user.v1.CronBatchResponse2\xd1\x01\n" +
|
"\x1cRefreshCPIntimacyLeaderboard\x12\x1f.hyapp.user.v1.CronBatchRequest\x1a .hyapp.user.v1.CronBatchResponse2\xd1\x01\n" +
|
||||||
"\x11UserDeviceService\x12Z\n" +
|
"\x11UserDeviceService\x12Z\n" +
|
||||||
"\rBindPushToken\x12#.hyapp.user.v1.BindPushTokenRequest\x1a$.hyapp.user.v1.BindPushTokenResponse\x12`\n" +
|
"\rBindPushToken\x12#.hyapp.user.v1.BindPushTokenRequest\x1a$.hyapp.user.v1.BindPushTokenResponse\x12`\n" +
|
||||||
"\x0fDeletePushToken\x12%.hyapp.user.v1.DeletePushTokenRequest\x1a&.hyapp.user.v1.DeletePushTokenResponse2g\n" +
|
"\x0fDeletePushToken\x12%.hyapp.user.v1.DeletePushTokenRequest\x1a&.hyapp.user.v1.DeletePushTokenResponse2\xb4\x01\n" +
|
||||||
"\x12AppRegistryService\x12Q\n" +
|
"\x12AppRegistryService\x12Q\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"ResolveApp\x12 .hyapp.user.v1.ResolveAppRequest\x1a!.hyapp.user.v1.ResolveAppResponse2\xc7\x01\n" +
|
"ResolveApp\x12 .hyapp.user.v1.ResolveAppRequest\x1a!.hyapp.user.v1.ResolveAppResponse\x12K\n" +
|
||||||
|
"\bListApps\x12\x1e.hyapp.user.v1.ListAppsRequest\x1a\x1f.hyapp.user.v1.ListAppsResponse2\xc7\x01\n" +
|
||||||
"\x13CountryAdminService\x12Z\n" +
|
"\x13CountryAdminService\x12Z\n" +
|
||||||
"\rListCountries\x12#.hyapp.user.v1.ListCountriesRequest\x1a$.hyapp.user.v1.ListCountriesResponse\x12T\n" +
|
"\rListCountries\x12#.hyapp.user.v1.ListCountriesRequest\x1a$.hyapp.user.v1.ListCountriesResponse\x12T\n" +
|
||||||
"\rUpdateCountry\x12#.hyapp.user.v1.UpdateCountryRequest\x1a\x1e.hyapp.user.v1.CountryResponse2\xa2\x02\n" +
|
"\rUpdateCountry\x12#.hyapp.user.v1.UpdateCountryRequest\x1a\x1e.hyapp.user.v1.CountryResponse2\xa2\x02\n" +
|
||||||
@ -14499,7 +14593,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_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||||
var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 183)
|
var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 185)
|
||||||
var file_proto_user_v1_user_proto_goTypes = []any{
|
var file_proto_user_v1_user_proto_goTypes = []any{
|
||||||
(UserStatus)(0), // 0: hyapp.user.v1.UserStatus
|
(UserStatus)(0), // 0: hyapp.user.v1.UserStatus
|
||||||
(*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta
|
(*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta
|
||||||
@ -14682,9 +14776,11 @@ var file_proto_user_v1_user_proto_goTypes = []any{
|
|||||||
(*CPFormationGiftFeedItem)(nil), // 178: hyapp.user.v1.CPFormationGiftFeedItem
|
(*CPFormationGiftFeedItem)(nil), // 178: hyapp.user.v1.CPFormationGiftFeedItem
|
||||||
(*ListCPFormationGiftFeedRequest)(nil), // 179: hyapp.user.v1.ListCPFormationGiftFeedRequest
|
(*ListCPFormationGiftFeedRequest)(nil), // 179: hyapp.user.v1.ListCPFormationGiftFeedRequest
|
||||||
(*ListCPFormationGiftFeedResponse)(nil), // 180: hyapp.user.v1.ListCPFormationGiftFeedResponse
|
(*ListCPFormationGiftFeedResponse)(nil), // 180: hyapp.user.v1.ListCPFormationGiftFeedResponse
|
||||||
nil, // 181: hyapp.user.v1.BatchGetUsersResponse.UsersEntry
|
(*ListAppsRequest)(nil), // 181: hyapp.user.v1.ListAppsRequest
|
||||||
nil, // 182: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
|
(*ListAppsResponse)(nil), // 182: hyapp.user.v1.ListAppsResponse
|
||||||
nil, // 183: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
|
nil, // 183: hyapp.user.v1.BatchGetUsersResponse.UsersEntry
|
||||||
|
nil, // 184: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
|
||||||
|
nil, // 185: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
|
||||||
}
|
}
|
||||||
var file_proto_user_v1_user_proto_depIdxs = []int32{
|
var file_proto_user_v1_user_proto_depIdxs = []int32{
|
||||||
1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||||
@ -14758,15 +14854,15 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{
|
|||||||
1, // 68: hyapp.user.v1.SubmitReportRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
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
|
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
|
1, // 70: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||||
181, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry
|
183, // 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
|
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
|
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
|
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
|
1, // 75: hyapp.user.v1.BatchGetUserAdminProfilesRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||||
182, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
|
184, // 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, // 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
|
1, // 78: hyapp.user.v1.BatchGetRoomBasicUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||||
183, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
|
185, // 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, // 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
|
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
|
5, // 82: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User
|
||||||
@ -14854,174 +14950,178 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{
|
|||||||
49, // 164: hyapp.user.v1.CPFormationGiftFeedItem.target:type_name -> hyapp.user.v1.CPUserProfile
|
49, // 164: hyapp.user.v1.CPFormationGiftFeedItem.target:type_name -> hyapp.user.v1.CPUserProfile
|
||||||
1, // 165: hyapp.user.v1.ListCPFormationGiftFeedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
1, // 165: hyapp.user.v1.ListCPFormationGiftFeedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||||
178, // 166: hyapp.user.v1.ListCPFormationGiftFeedResponse.items:type_name -> hyapp.user.v1.CPFormationGiftFeedItem
|
178, // 166: hyapp.user.v1.ListCPFormationGiftFeedResponse.items:type_name -> hyapp.user.v1.CPFormationGiftFeedItem
|
||||||
5, // 167: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User
|
1, // 167: hyapp.user.v1.ListAppsRequest.meta:type_name -> hyapp.user.v1.RequestMeta
|
||||||
84, // 168: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile
|
2, // 168: hyapp.user.v1.ListAppsResponse.apps:type_name -> hyapp.user.v1.App
|
||||||
89, // 169: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser
|
5, // 169: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User
|
||||||
17, // 170: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest
|
84, // 170: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile
|
||||||
9, // 171: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest
|
89, // 171: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser
|
||||||
19, // 172: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest
|
17, // 172: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest
|
||||||
23, // 173: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest
|
9, // 173: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest
|
||||||
81, // 174: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest
|
19, // 174: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest
|
||||||
85, // 175: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest
|
23, // 175: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest
|
||||||
87, // 176: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest
|
81, // 176: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest
|
||||||
90, // 177: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest
|
85, // 177: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest
|
||||||
92, // 178: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest
|
87, // 178: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest
|
||||||
12, // 179: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest
|
90, // 179: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest
|
||||||
94, // 180: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest
|
92, // 180: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest
|
||||||
96, // 181: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest
|
12, // 181: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest
|
||||||
98, // 182: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest
|
94, // 182: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest
|
||||||
100, // 183: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest
|
96, // 183: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest
|
||||||
102, // 184: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest
|
98, // 184: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest
|
||||||
103, // 185: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest
|
100, // 185: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest
|
||||||
105, // 186: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest
|
102, // 186: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest
|
||||||
108, // 187: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest
|
103, // 187: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest
|
||||||
110, // 188: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest
|
105, // 188: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest
|
||||||
113, // 189: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest
|
108, // 189: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest
|
||||||
115, // 190: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest
|
110, // 190: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest
|
||||||
117, // 191: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest
|
113, // 191: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest
|
||||||
119, // 192: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest
|
115, // 192: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest
|
||||||
121, // 193: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest
|
117, // 193: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest
|
||||||
123, // 194: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest
|
119, // 194: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest
|
||||||
25, // 195: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest
|
121, // 195: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest
|
||||||
28, // 196: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest
|
123, // 196: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest
|
||||||
30, // 197: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest
|
25, // 197: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest
|
||||||
32, // 198: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest
|
28, // 198: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest
|
||||||
35, // 199: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest
|
30, // 199: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest
|
||||||
37, // 200: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest
|
32, // 200: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest
|
||||||
39, // 201: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest
|
35, // 201: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest
|
||||||
41, // 202: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest
|
37, // 202: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest
|
||||||
44, // 203: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest
|
39, // 203: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest
|
||||||
47, // 204: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest
|
41, // 204: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest
|
||||||
79, // 205: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest
|
44, // 205: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest
|
||||||
59, // 206: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest
|
47, // 206: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest
|
||||||
61, // 207: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest
|
79, // 207: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest
|
||||||
63, // 208: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest
|
59, // 208: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest
|
||||||
65, // 209: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest
|
61, // 209: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest
|
||||||
67, // 210: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest
|
63, // 210: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest
|
||||||
69, // 211: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest
|
65, // 211: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest
|
||||||
71, // 212: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest
|
67, // 212: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest
|
||||||
73, // 213: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest
|
69, // 213: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest
|
||||||
179, // 214: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest
|
71, // 214: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest
|
||||||
76, // 215: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest
|
73, // 215: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest
|
||||||
57, // 216: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest
|
179, // 216: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest
|
||||||
14, // 217: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest
|
76, // 217: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest
|
||||||
14, // 218: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest
|
57, // 218: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest
|
||||||
14, // 219: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest
|
14, // 219: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest
|
||||||
14, // 220: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions: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.ExpireManagerUserBlocks: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.ExpireAdminUserBans: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.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest
|
14, // 223: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest
|
||||||
125, // 224: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest
|
14, // 224: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest
|
||||||
127, // 225: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest
|
14, // 225: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest
|
||||||
3, // 226: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest
|
125, // 226: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest
|
||||||
131, // 227: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest
|
127, // 227: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest
|
||||||
133, // 228: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest
|
3, // 228: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest
|
||||||
135, // 229: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest
|
181, // 229: hyapp.user.v1.AppRegistryService.ListApps:input_type -> hyapp.user.v1.ListAppsRequest
|
||||||
138, // 230: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest
|
131, // 230: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest
|
||||||
140, // 231: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest
|
133, // 231: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest
|
||||||
142, // 232: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest
|
135, // 232: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest
|
||||||
143, // 233: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest
|
138, // 233: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest
|
||||||
144, // 234: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest
|
140, // 234: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest
|
||||||
147, // 235: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest
|
142, // 235: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest
|
||||||
149, // 236: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest
|
143, // 236: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest
|
||||||
151, // 237: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest
|
144, // 237: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest
|
||||||
153, // 238: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest
|
147, // 238: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest
|
||||||
160, // 239: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest
|
149, // 239: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest
|
||||||
162, // 240: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest
|
151, // 240: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest
|
||||||
155, // 241: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest
|
153, // 241: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest
|
||||||
164, // 242: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest
|
160, // 242: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest
|
||||||
166, // 243: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest
|
162, // 243: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest
|
||||||
167, // 244: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest
|
155, // 244: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest
|
||||||
169, // 245: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest
|
164, // 245: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest
|
||||||
171, // 246: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest
|
166, // 246: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest
|
||||||
173, // 247: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest
|
167, // 247: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest
|
||||||
174, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest
|
169, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest
|
||||||
176, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest
|
171, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest
|
||||||
18, // 250: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse
|
173, // 250: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest
|
||||||
10, // 251: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse
|
174, // 251: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest
|
||||||
21, // 252: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse
|
176, // 252: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest
|
||||||
24, // 253: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse
|
18, // 253: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse
|
||||||
82, // 254: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse
|
10, // 254: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse
|
||||||
86, // 255: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse
|
21, // 255: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse
|
||||||
88, // 256: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse
|
24, // 256: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse
|
||||||
91, // 257: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse
|
82, // 257: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse
|
||||||
93, // 258: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse
|
86, // 258: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse
|
||||||
13, // 259: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse
|
88, // 259: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse
|
||||||
95, // 260: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse
|
91, // 260: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse
|
||||||
97, // 261: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse
|
93, // 261: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse
|
||||||
99, // 262: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse
|
13, // 262: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse
|
||||||
101, // 263: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse
|
95, // 263: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse
|
||||||
104, // 264: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
|
97, // 264: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse
|
||||||
104, // 265: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
|
99, // 265: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse
|
||||||
106, // 266: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse
|
101, // 266: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse
|
||||||
109, // 267: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse
|
104, // 267: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
|
||||||
111, // 268: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse
|
104, // 268: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
|
||||||
114, // 269: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse
|
106, // 269: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse
|
||||||
116, // 270: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse
|
109, // 270: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse
|
||||||
118, // 271: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse
|
111, // 271: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse
|
||||||
120, // 272: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse
|
114, // 272: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse
|
||||||
122, // 273: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse
|
116, // 273: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse
|
||||||
124, // 274: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse
|
118, // 274: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse
|
||||||
26, // 275: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse
|
120, // 275: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse
|
||||||
29, // 276: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse
|
122, // 276: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse
|
||||||
31, // 277: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse
|
124, // 277: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse
|
||||||
33, // 278: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse
|
26, // 278: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse
|
||||||
36, // 279: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse
|
29, // 279: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse
|
||||||
38, // 280: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse
|
31, // 280: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse
|
||||||
40, // 281: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse
|
33, // 281: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse
|
||||||
42, // 282: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse
|
36, // 282: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse
|
||||||
45, // 283: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse
|
38, // 283: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse
|
||||||
48, // 284: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse
|
40, // 284: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse
|
||||||
80, // 285: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse
|
42, // 285: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse
|
||||||
60, // 286: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse
|
45, // 286: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse
|
||||||
62, // 287: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse
|
48, // 287: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse
|
||||||
64, // 288: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse
|
80, // 288: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse
|
||||||
66, // 289: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse
|
60, // 289: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse
|
||||||
68, // 290: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse
|
62, // 290: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse
|
||||||
70, // 291: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse
|
64, // 291: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse
|
||||||
72, // 292: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse
|
66, // 292: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse
|
||||||
74, // 293: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse
|
68, // 293: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse
|
||||||
180, // 294: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse
|
70, // 294: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse
|
||||||
77, // 295: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse
|
72, // 295: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse
|
||||||
58, // 296: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse
|
74, // 296: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse
|
||||||
15, // 297: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse
|
180, // 297: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse
|
||||||
15, // 298: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse
|
77, // 298: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse
|
||||||
15, // 299: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
|
58, // 299: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse
|
||||||
15, // 300: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
|
15, // 300: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse
|
||||||
15, // 301: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks: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.ExpireAdminUserBans: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.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse
|
15, // 303: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
|
||||||
126, // 304: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse
|
15, // 304: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse
|
||||||
128, // 305: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse
|
15, // 305: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse
|
||||||
4, // 306: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse
|
15, // 306: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse
|
||||||
132, // 307: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse
|
126, // 307: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse
|
||||||
134, // 308: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse
|
128, // 308: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse
|
||||||
136, // 309: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse
|
4, // 309: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse
|
||||||
139, // 310: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse
|
182, // 310: hyapp.user.v1.AppRegistryService.ListApps:output_type -> hyapp.user.v1.ListAppsResponse
|
||||||
141, // 311: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse
|
132, // 311: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse
|
||||||
145, // 312: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse
|
134, // 312: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse
|
||||||
145, // 313: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse
|
136, // 313: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse
|
||||||
145, // 314: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse
|
139, // 314: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse
|
||||||
148, // 315: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse
|
141, // 315: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse
|
||||||
150, // 316: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse
|
145, // 316: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse
|
||||||
152, // 317: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse
|
145, // 317: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse
|
||||||
154, // 318: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse
|
145, // 318: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse
|
||||||
161, // 319: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse
|
148, // 319: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse
|
||||||
163, // 320: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse
|
150, // 320: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse
|
||||||
156, // 321: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse
|
152, // 321: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse
|
||||||
165, // 322: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse
|
154, // 322: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse
|
||||||
168, // 323: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
|
161, // 323: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse
|
||||||
168, // 324: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
|
163, // 324: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse
|
||||||
170, // 325: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse
|
156, // 325: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse
|
||||||
172, // 326: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse
|
165, // 326: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse
|
||||||
175, // 327: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
|
168, // 327: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
|
||||||
175, // 328: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
|
168, // 328: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
|
||||||
177, // 329: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse
|
170, // 329: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse
|
||||||
250, // [250:330] is the sub-list for method output_type
|
172, // 330: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse
|
||||||
170, // [170:250] is the sub-list for method input_type
|
175, // 331: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
|
||||||
170, // [170:170] is the sub-list for extension type_name
|
175, // 332: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
|
||||||
170, // [170:170] is the sub-list for extension extendee
|
177, // 333: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse
|
||||||
0, // [0:170] is the sub-list for field type_name
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_proto_user_v1_user_proto_init() }
|
func init() { file_proto_user_v1_user_proto_init() }
|
||||||
@ -15037,7 +15137,7 @@ func file_proto_user_v1_user_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
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)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_user_proto_rawDesc), len(file_proto_user_v1_user_proto_rawDesc)),
|
||||||
NumEnums: 1,
|
NumEnums: 1,
|
||||||
NumMessages: 183,
|
NumMessages: 185,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 12,
|
NumServices: 12,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1559,9 +1559,19 @@ service UserDeviceService {
|
|||||||
rpc DeletePushToken(DeletePushTokenRequest) returns (DeletePushTokenResponse);
|
rpc DeletePushToken(DeletePushTokenRequest) returns (DeletePushTokenResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAppsRequest 由内部调度器读取启用租户;不接受租户过滤,避免后台任务再次退化成单 App。
|
||||||
|
message ListAppsRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListAppsResponse {
|
||||||
|
repeated App apps = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
|
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
|
||||||
service AppRegistryService {
|
service AppRegistryService {
|
||||||
rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse);
|
rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse);
|
||||||
|
rpc ListApps(ListAppsRequest) returns (ListAppsResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountryAdminService 只保留 App 后端需要承接的国家查询和展示字段更新 RPC。
|
// CountryAdminService 只保留 App 后端需要承接的国家查询和展示字段更新 RPC。
|
||||||
|
|||||||
@ -2556,6 +2556,7 @@ var UserDeviceService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
AppRegistryService_ResolveApp_FullMethodName = "/hyapp.user.v1.AppRegistryService/ResolveApp"
|
AppRegistryService_ResolveApp_FullMethodName = "/hyapp.user.v1.AppRegistryService/ResolveApp"
|
||||||
|
AppRegistryService_ListApps_FullMethodName = "/hyapp.user.v1.AppRegistryService/ListApps"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppRegistryServiceClient is the client API for AppRegistryService service.
|
// AppRegistryServiceClient is the client API for AppRegistryService service.
|
||||||
@ -2565,6 +2566,7 @@ const (
|
|||||||
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
|
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
|
||||||
type AppRegistryServiceClient interface {
|
type AppRegistryServiceClient interface {
|
||||||
ResolveApp(ctx context.Context, in *ResolveAppRequest, opts ...grpc.CallOption) (*ResolveAppResponse, error)
|
ResolveApp(ctx context.Context, in *ResolveAppRequest, opts ...grpc.CallOption) (*ResolveAppResponse, error)
|
||||||
|
ListApps(ctx context.Context, in *ListAppsRequest, opts ...grpc.CallOption) (*ListAppsResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type appRegistryServiceClient struct {
|
type appRegistryServiceClient struct {
|
||||||
@ -2585,6 +2587,16 @@ func (c *appRegistryServiceClient) ResolveApp(ctx context.Context, in *ResolveAp
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *appRegistryServiceClient) ListApps(ctx context.Context, in *ListAppsRequest, opts ...grpc.CallOption) (*ListAppsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListAppsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, AppRegistryService_ListApps_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AppRegistryServiceServer is the server API for AppRegistryService service.
|
// AppRegistryServiceServer is the server API for AppRegistryService service.
|
||||||
// All implementations must embed UnimplementedAppRegistryServiceServer
|
// All implementations must embed UnimplementedAppRegistryServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@ -2592,6 +2604,7 @@ func (c *appRegistryServiceClient) ResolveApp(ctx context.Context, in *ResolveAp
|
|||||||
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
|
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
|
||||||
type AppRegistryServiceServer interface {
|
type AppRegistryServiceServer interface {
|
||||||
ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error)
|
ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error)
|
||||||
|
ListApps(context.Context, *ListAppsRequest) (*ListAppsResponse, error)
|
||||||
mustEmbedUnimplementedAppRegistryServiceServer()
|
mustEmbedUnimplementedAppRegistryServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2605,6 +2618,9 @@ type UnimplementedAppRegistryServiceServer struct{}
|
|||||||
func (UnimplementedAppRegistryServiceServer) ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error) {
|
func (UnimplementedAppRegistryServiceServer) ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method ResolveApp not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ResolveApp not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedAppRegistryServiceServer) ListApps(context.Context, *ListAppsRequest) (*ListAppsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method ListApps not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedAppRegistryServiceServer) mustEmbedUnimplementedAppRegistryServiceServer() {}
|
func (UnimplementedAppRegistryServiceServer) mustEmbedUnimplementedAppRegistryServiceServer() {}
|
||||||
func (UnimplementedAppRegistryServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedAppRegistryServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@ -2644,6 +2660,24 @@ func _AppRegistryService_ResolveApp_Handler(srv interface{}, ctx context.Context
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _AppRegistryService_ListApps_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListAppsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(AppRegistryServiceServer).ListApps(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: AppRegistryService_ListApps_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(AppRegistryServiceServer).ListApps(ctx, req.(*ListAppsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// AppRegistryService_ServiceDesc is the grpc.ServiceDesc for AppRegistryService service.
|
// AppRegistryService_ServiceDesc is the grpc.ServiceDesc for AppRegistryService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@ -2655,6 +2689,10 @@ var AppRegistryService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ResolveApp",
|
MethodName: "ResolveApp",
|
||||||
Handler: _AppRegistryService_ResolveApp_Handler,
|
Handler: _AppRegistryService_ResolveApp_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListApps",
|
||||||
|
Handler: _AppRegistryService_ListApps_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "proto/user/v1/user.proto",
|
Metadata: "proto/user/v1/user.proto",
|
||||||
|
|||||||
@ -379,6 +379,15 @@ type DebitGiftResponse struct {
|
|||||||
HostPointTemplateVersion string `protobuf:"bytes,27,opt,name=host_point_template_version,json=hostPointTemplateVersion,proto3" json:"host_point_template_version,omitempty"`
|
HostPointTemplateVersion string `protobuf:"bytes,27,opt,name=host_point_template_version,json=hostPointTemplateVersion,proto3" json:"host_point_template_version,omitempty"`
|
||||||
// balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。
|
// balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。
|
||||||
BalanceVersion int64 `protobuf:"varint,28,opt,name=balance_version,json=balanceVersion,proto3" json:"balance_version,omitempty"`
|
BalanceVersion int64 `protobuf:"varint,28,opt,name=balance_version,json=balanceVersion,proto3" json:"balance_version,omitempty"`
|
||||||
|
// recharge_seven_day_coins / recharge_thirty_day_coins 是扣费时由 wallet owner 固化的 UTC 自然日充值快照。
|
||||||
|
// lucky-gift-service 只能消费这份结算事实做概率分层,不能为了开奖直连钱包库或临时扫描充值流水。
|
||||||
|
RechargeSevenDayCoins int64 `protobuf:"varint,29,opt,name=recharge_seven_day_coins,json=rechargeSevenDayCoins,proto3" json:"recharge_seven_day_coins,omitempty"`
|
||||||
|
RechargeThirtyDayCoins int64 `protobuf:"varint,30,opt,name=recharge_thirty_day_coins,json=rechargeThirtyDayCoins,proto3" json:"recharge_thirty_day_coins,omitempty"`
|
||||||
|
// last_recharged_at_ms 仅在真实 lucky/super_lucky 金币送礼时返回;0 表示近 30 个 UTC 自然日没有充值。
|
||||||
|
LastRechargedAtMs int64 `protobuf:"varint,31,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"`
|
||||||
|
// paid_at_ms 是 wallet_transactions.created_at_ms 的稳定快照;首次扣费与同 command_id 重放必须完全一致。
|
||||||
|
// room/lucky 只能用该 wallet owner 事实做 UTC 日、小时和充值后短窗归属,不能用 saga 或恢复 worker 时钟替代。
|
||||||
|
PaidAtMs int64 `protobuf:"varint,32,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -609,6 +618,34 @@ func (x *DebitGiftResponse) GetBalanceVersion() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *DebitGiftResponse) GetRechargeSevenDayCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.RechargeSevenDayCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DebitGiftResponse) GetRechargeThirtyDayCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.RechargeThirtyDayCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DebitGiftResponse) GetLastRechargedAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LastRechargedAtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *DebitGiftResponse) GetPaidAtMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PaidAtMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。
|
// DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。
|
||||||
type DebitGiftTarget struct {
|
type DebitGiftTarget struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -26262,7 +26299,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" +
|
"\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" +
|
||||||
"\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\x12%\n" +
|
"\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\x12%\n" +
|
||||||
"\x0eentitlement_id\x18\r \x01(\tR\rentitlementId\x12#\n" +
|
"\x0eentitlement_id\x18\r \x01(\tR\rentitlementId\x12#\n" +
|
||||||
"\rcharge_source\x18\x0e \x01(\tR\fchargeSource\"\xff\t\n" +
|
"\rcharge_source\x18\x0e \x01(\tR\fchargeSource\"\xc2\v\n" +
|
||||||
"\x11DebitGiftResponse\x12,\n" +
|
"\x11DebitGiftResponse\x12,\n" +
|
||||||
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
|
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
|
||||||
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
|
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
|
||||||
@ -26294,7 +26331,12 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\x1fhost_point_policy_instance_code\x18\x19 \x01(\tR\x1bhostPointPolicyInstanceCode\x127\n" +
|
"\x1fhost_point_policy_instance_code\x18\x19 \x01(\tR\x1bhostPointPolicyInstanceCode\x127\n" +
|
||||||
"\x18host_point_template_code\x18\x1a \x01(\tR\x15hostPointTemplateCode\x12=\n" +
|
"\x18host_point_template_code\x18\x1a \x01(\tR\x15hostPointTemplateCode\x12=\n" +
|
||||||
"\x1bhost_point_template_version\x18\x1b \x01(\tR\x18hostPointTemplateVersion\x12'\n" +
|
"\x1bhost_point_template_version\x18\x1b \x01(\tR\x18hostPointTemplateVersion\x12'\n" +
|
||||||
"\x0fbalance_version\x18\x1c \x01(\x03R\x0ebalanceVersion\"\xed\x01\n" +
|
"\x0fbalance_version\x18\x1c \x01(\x03R\x0ebalanceVersion\x127\n" +
|
||||||
|
"\x18recharge_seven_day_coins\x18\x1d \x01(\x03R\x15rechargeSevenDayCoins\x129\n" +
|
||||||
|
"\x19recharge_thirty_day_coins\x18\x1e \x01(\x03R\x16rechargeThirtyDayCoins\x12/\n" +
|
||||||
|
"\x14last_recharged_at_ms\x18\x1f \x01(\x03R\x11lastRechargedAtMs\x12\x1c\n" +
|
||||||
|
"\n" +
|
||||||
|
"paid_at_ms\x18 \x01(\x03R\bpaidAtMs\"\xed\x01\n" +
|
||||||
"\x0fDebitGiftTarget\x12\x1d\n" +
|
"\x0fDebitGiftTarget\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12$\n" +
|
"command_id\x18\x01 \x01(\tR\tcommandId\x12$\n" +
|
||||||
|
|||||||
@ -92,6 +92,15 @@ message DebitGiftResponse {
|
|||||||
string host_point_template_version = 27;
|
string host_point_template_version = 27;
|
||||||
// balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。
|
// balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。
|
||||||
int64 balance_version = 28;
|
int64 balance_version = 28;
|
||||||
|
// recharge_seven_day_coins / recharge_thirty_day_coins 是扣费时由 wallet owner 固化的 UTC 自然日充值快照。
|
||||||
|
// lucky-gift-service 只能消费这份结算事实做概率分层,不能为了开奖直连钱包库或临时扫描充值流水。
|
||||||
|
int64 recharge_seven_day_coins = 29;
|
||||||
|
int64 recharge_thirty_day_coins = 30;
|
||||||
|
// last_recharged_at_ms 仅在真实 lucky/super_lucky 金币送礼时返回;0 表示近 30 个 UTC 自然日没有充值。
|
||||||
|
int64 last_recharged_at_ms = 31;
|
||||||
|
// paid_at_ms 是 wallet_transactions.created_at_ms 的稳定快照;首次扣费与同 command_id 重放必须完全一致。
|
||||||
|
// room/lucky 只能用该 wallet owner 事实做 UTC 日、小时和充值后短窗归属,不能用 saga 或恢复 worker 时钟替代。
|
||||||
|
int64 paid_at_ms = 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。
|
// DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。
|
||||||
|
|||||||
306
docs/幸运礼物动态策略V3实现与模拟.md
Normal file
306
docs/幸运礼物动态策略V3实现与模拟.md
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
# 幸运礼物动态策略 V3:实现边界与模拟验收
|
||||||
|
|
||||||
|
## 1. 版本边界
|
||||||
|
|
||||||
|
本文只描述 `dynamic_v3` 的规则、持久化契约和可重复模拟口径,不改变已经发布的 `fixed_v2` 规则语义,也不代表该策略已在线上自动启用。
|
||||||
|
|
||||||
|
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`。
|
||||||
|
- `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
|
||||||
|
- 新奖池未配置时返回 `dynamic_v3` 的 `disabled` 草稿。各 App 必须先补齐累计消费大奖门槛、普通/高阶充值门槛及六项金额上限,才允许启用。
|
||||||
|
- 启用前还必须确认 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。
|
||||||
|
- 比例、概率和倍率统一使用 ppm,`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
|
||||||
|
- 离线模拟使用固定 seed 的 `math/rand`;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。
|
||||||
|
|
||||||
|
## 2. 已确认的产品口径
|
||||||
|
|
||||||
|
### 2.1 资金拆分采用 98/1/1
|
||||||
|
|
||||||
|
每笔已成功扣费的幸运礼物按整数守恒拆分:
|
||||||
|
|
||||||
|
| 去向 | 默认比例 | 作用 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| 公共奖金池 `P` | 98% | 在本抽开奖前先入池,承担用户返奖 |
|
||||||
|
| 平台盈利 | 1% | 只记盈利拆账,不进入可返奖余额 |
|
||||||
|
| 收礼主播收益 | 1% | 规则计算值必须与钱包已落账回执完全一致,不允许缺字段或其他返币比例静默覆盖规则 |
|
||||||
|
|
||||||
|
原图同一行同时出现“抽取 98%”和“92% 可灵活配置”,两者冲突。V3 采用能够与 1%+1% 合计为 100% 的 **98/1/1**;“92%”视为原图笔误,不进入默认配置、代码或模拟。比例可发布为其他值时,三项仍必须非负且合计 100%,不允许出现去向不明的余额。
|
||||||
|
|
||||||
|
公共池与主播份额分别向下取整,无法再拆的整币余数归平台盈利,任何价格都满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
public_pool_coins + profit_coins + anchor_return_coins = coin_spent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 RTP 是观察目标,不是透支承诺
|
||||||
|
|
||||||
|
- 发布目标为 98%,正常观察带为 95%~101%(98%±3 个百分点)。
|
||||||
|
- RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
|
||||||
|
- 95%~101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
|
||||||
|
- 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `underpaid` 或短期偏离 95%~101%,禁止为了追 RTP 透支奖池。
|
||||||
|
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,下一抽才开启新窗口。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
|
||||||
|
|
||||||
|
### 2.3 原图概率表不能作为生产默认概率
|
||||||
|
|
||||||
|
原图示例为:
|
||||||
|
|
||||||
|
| 倍率 | 概率 | EV 贡献 |
|
||||||
|
| ---: | ---: | ---: |
|
||||||
|
| 0x | 60% | 0x |
|
||||||
|
| 5x | 20% | 1x |
|
||||||
|
| 50x | 15% | 7.5x |
|
||||||
|
| 200x | 4% | 8x |
|
||||||
|
| 1000x | 1% | 10x |
|
||||||
|
| 合计 | 100% | **26.5x** |
|
||||||
|
|
||||||
|
因此该表的名义 `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` 作为大奖集合单独配置,不能在缺少经过成本校验的概率时照搬进基础概率表。
|
||||||
|
|
||||||
|
## 3. 一次抽奖的确定顺序
|
||||||
|
|
||||||
|
一次真实抽奖按以下顺序完成;同一事务内锁住的状态必须与 draw record 一起提交:
|
||||||
|
|
||||||
|
1. `user-service` 把 `auth_sessions.device_id` 签入 access JWT;gateway 验签后写入 room `RequestMeta`,room saga 固化该设备快照,禁止用可轮换的 session 或 command ID 代替。
|
||||||
|
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 状态和六维风控计数。
|
||||||
|
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子;0 档接收剩余概率。
|
||||||
|
7. 优先处理已持久化的“消费里程碑大奖 token”,其次判断 RTP 补偿大奖,最后进入普通概率抽奖。
|
||||||
|
8. 对选中的 `W` 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖。
|
||||||
|
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`。
|
||||||
|
|
||||||
|
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
|
||||||
|
|
||||||
|
## 4. 动态概率、分层和保底
|
||||||
|
|
||||||
|
### 4.1 充值分层
|
||||||
|
|
||||||
|
V3 固定有 `novice/normal/advanced` 三层,但两个门槛由每个不可变规则版本配置:
|
||||||
|
|
||||||
|
- 最近 7 日和最近 30 日均按 UTC 自然日统计且包含当天;分别覆盖“今天及前 6 个 UTC 日”和“今天及前 29 个 UTC 日”,上界为钱包扣费时刻。
|
||||||
|
- 用户只在同时满足某层的 7 日和 30 日最低充值门槛时才进入该层;不能只满足其中一维就越级。
|
||||||
|
- `novice` 是无条件兜底层,它的 `0/0` 只是协议和存储 sentinel,不表示“只有充值为 0 才是新手”。若 `7d < normal_7d` **或** `30d < normal_30d`,即使两个充值值都非零,用户仍属于 `novice`。
|
||||||
|
- `normal` 启用前至少一维必须为正数;`advanced` 两维均不得低于 `normal`,且至少一维严格更高。disabled 草稿可保留 `0/0` 表示尚未配置,但不能以该状态启用。
|
||||||
|
- 阶段阈值相等时算已达到,即使用 `>=`;两维都等于普通门槛时进入 `normal`。所有金额门槛按 App 金币口径显式发布。
|
||||||
|
|
||||||
|
充值快照由 `wallet-service` 在礼物扣费事务中查询并写入交易 metadata;同一 `command_id` 重放返回首次快照,避免重试时跨层或丢失五分钟加成。
|
||||||
|
|
||||||
|
### 4.2 水位和最近充值因子
|
||||||
|
|
||||||
|
设某层的非 0 档基础权重为 `base_weight`,则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
adjusted_weight = base_weight × water_factor × recharge_factor
|
||||||
|
```
|
||||||
|
|
||||||
|
因子是相对乘法,不是把百分点直接相加:
|
||||||
|
|
||||||
|
| 条件 | 默认因子 | 边界 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `P < low_watermark` | 0.70 | 恰等于低水位时不下调 |
|
||||||
|
| `low_watermark <= P <= high_watermark` | 1.00 | 两个端点都属于中水位 |
|
||||||
|
| `P > high_watermark` | 1.30 | 恰等于高水位时不上调 |
|
||||||
|
| 有可信充值事实且处于充值后窗口 | 1.10 | `[last_recharged_at_ms, last_recharged_at_ms + 300000)` |
|
||||||
|
|
||||||
|
例如低水位并处于充值后五分钟时,非 0 权重为 `base × 0.70 × 1.10 = base × 0.77`,不是 `base × 0.80`。五分钟窗口包含起点和第 `299999ms`,不包含第 `300000ms`;未来时间、缺失充值事实或 `last_recharged_at_ms=0` 都不加权。
|
||||||
|
|
||||||
|
调整后:若正奖权重和不超过 100%,0 档权重取剩余值;若正奖权重和超过 100%,按相同比例归一化正奖权重并把 0 档置为 0。该归一化只解决概率守恒,不绕过资金和风控。
|
||||||
|
|
||||||
|
### 4.3 连续 5 次 0 后的第 6 抽
|
||||||
|
|
||||||
|
- `loss_streak` 只在最终可见返奖为 0 时加一,任意正返奖后清零。
|
||||||
|
- 前 5 次连续为 0 后,第 6 抽从普通候选中排除 0 档,选择可支付的非 0 档。
|
||||||
|
- “第 6 抽不出 0”是有资金、有剩余额度时的体验保证;如果所有正奖都超过 `P` 或六维风控剩余额度,仍返回 0,并记录 `miss_protection_blocked_no_payable_tier`。不得透支来兑现保底。
|
||||||
|
|
||||||
|
## 5. `P/W` 安全重抽
|
||||||
|
|
||||||
|
`P` 是本抽入池后的公共奖金池可用余额,`W=gift_price × multiplier`。普通抽奖执行:
|
||||||
|
|
||||||
|
1. 第一次按当前动态权重抽档。
|
||||||
|
2. `W <= P` 且所有风控均允许时直接支付;`W == P` 可以支付,支付后 `P=0`。
|
||||||
|
3. 若 `W > P`,同时删除该中奖档和 0 档,再对剩余正奖档重抽。
|
||||||
|
4. 重抽仍有 `W > P` 时继续删除该中奖档;0 档只在第一次资金不足时删除一次。
|
||||||
|
5. 若最终没有可支付的正奖档,安全回退 0,不改变 `P` 为负数。
|
||||||
|
|
||||||
|
原图 `P=800`、单价 10 的脚本化示例只用于分支验收:先抽到 `200x(W=2000)` 时删除 `200x+0x`;若再抽到 `1000x(W=10000)`,再删除 `1000x`;随后只能在仍有权重且 `W<=800` 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。
|
||||||
|
|
||||||
|
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”;其他风控拒绝不能伪装成奖池不足。
|
||||||
|
|
||||||
|
## 6. 两种大奖机制
|
||||||
|
|
||||||
|
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。
|
||||||
|
|
||||||
|
### 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;
|
||||||
|
- 候选 `W<=P` 且不超过六维风控的最小剩余额度。
|
||||||
|
|
||||||
|
任一 RTP 分母为 0、任一 RTP 高于阈值、奖池不足或风控不足,都只回到普通抽奖,不强发大奖。滚动 72 小时是 `[now_ms-72h, now_ms)` 的 UTC 业务窗口,不能替换成“最近 3 个本地自然日”。
|
||||||
|
|
||||||
|
### 6.2 机制二:消费里程碑大奖
|
||||||
|
|
||||||
|
- `jackpot_spend_threshold_coins` 是当前不可变规则版本动态配置的“用户 UTC 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。
|
||||||
|
- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token;同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
|
||||||
|
- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
|
||||||
|
- 下一抽优先消费已有 token;若大奖集合因 `P`、日 5 次上限或风控不可支付,token 保留,用户仍获得本次普通抽奖。
|
||||||
|
- 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。
|
||||||
|
|
||||||
|
原图写的是“定时任务”。实现改为在 owner 事务里按已结算送礼事实同步计算跨过的整数门槛并落 token:结果仍只影响下一抽,但不会出现“用户已经完成门槛、下一抽先于 cron 扫描到达”的竞态,也不需要 cron-service 回扫明细或直接拥有大奖状态。这个事件驱动实现是有意的工程等价替换;若产品需要固定延迟发放,必须另增明确生效时间字段,而不是依赖不确定的扫描周期。
|
||||||
|
|
||||||
|
## 7. 六维风控与时间口径
|
||||||
|
|
||||||
|
所有启用的 `dynamic_v3` 规则必须配置正数上限:
|
||||||
|
|
||||||
|
| 维度 | 配置字段 | UTC 窗口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单次返奖 | `max_single_payout` | 当前 draw |
|
||||||
|
| 用户小时 | `user_hourly_payout_cap` | 当前 UTC 小时 |
|
||||||
|
| 用户日 | `user_daily_payout_cap` | 当前 UTC 自然日 |
|
||||||
|
| 设备日 | `device_daily_payout_cap` | 当前 UTC 自然日 |
|
||||||
|
| 房间小时 | `room_hourly_payout_cap` | 当前 UTC 小时 |
|
||||||
|
| 主播日 | `anchor_daily_payout_cap` | 当前 UTC 自然日 |
|
||||||
|
|
||||||
|
一次候选的有效容量是 `P` 和六项剩余额度的最小值。候选金额等于剩余额度时允许,超过任一维度即拒绝;计数更新与 draw record 必须在同一锁定事务内,避免并发抽奖分别看到同一份余额。
|
||||||
|
|
||||||
|
设备日 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`、容器时区或客户端时区切分。
|
||||||
|
|
||||||
|
## 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)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
|
||||||
|
- 每个子抽用稳定子幂等键(现有约定为 `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` 外部请求继续保持缺少新事实的历史兼容路径。
|
||||||
|
|
||||||
|
## 9. 接口和配置字段
|
||||||
|
|
||||||
|
### 9.1 事实输入
|
||||||
|
|
||||||
|
`wallet.v1.DebitGiftResponse` 新增以下幂等回执字段:
|
||||||
|
|
||||||
|
- `recharge_seven_day_coins`
|
||||||
|
- `recharge_thirty_day_coins`
|
||||||
|
- `last_recharged_at_ms`
|
||||||
|
- `paid_at_ms`
|
||||||
|
|
||||||
|
`luckygift.v1.LuckyGiftMeta` 接收:
|
||||||
|
|
||||||
|
- `device_id`(从已验签 access JWT 穿过 room command 恢复链路)
|
||||||
|
- `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`。
|
||||||
|
|
||||||
|
### 9.2 不可变规则字段
|
||||||
|
|
||||||
|
`LuckyGiftRuleConfig` / `lucky_gift_rule_versions` 的 V3 字段分组如下:
|
||||||
|
|
||||||
|
| 决策 | 字段 |
|
||||||
|
| --- | --- |
|
||||||
|
| 版本和目标 | `strategy_version`, `target_rtp_ppm`, `settlement_window_wager`, `control_band_ppm`, `gift_price_reference` |
|
||||||
|
| 资金拆分 | `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` |
|
||||||
|
| 六维风控 | `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[]` |
|
||||||
|
|
||||||
|
后台沿用现有 `/admin/activity/lucky-gifts/v2/config` 配置入口;URL 中的 `v2` 是接口兼容标识,不等于 `strategy_version`。发布时必须把上述字段完整映射到内部 protobuf、domain 和 MySQL,不允许只在 HTTP JSON 中临时扩字段。
|
||||||
|
|
||||||
|
## 10. 持久化与审计
|
||||||
|
|
||||||
|
| 表 | V3 事实 |
|
||||||
|
| --- | --- |
|
||||||
|
| `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_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 产生/消费/保留、最终原因和阻断原因。
|
||||||
|
|
||||||
|
状态边界:
|
||||||
|
|
||||||
|
- `reward_status=pending/granted/failed` 只表示自动钱包入账状态;抽奖一旦向用户展示就形成不可撤销负债,`failed` 表示仍欠用户、必须重放或人工补偿,不能反向释放公共池/RTP/风控额度,也不能取消奖励。已 `granted` 后 IM 失败不能把账务事实改成 `failed`。
|
||||||
|
- outbox 使用 `pending/retryable/delivering/delivered/failed`;失败原因、重试次数和锁到期时间必须可查。
|
||||||
|
- 规则不可变发布;事故回放必须读取 draw 当时的 `rule_version` 和快照,不能套用最新配置重算。
|
||||||
|
|
||||||
|
## 11. 本地模拟矩阵
|
||||||
|
|
||||||
|
模拟必须调用与生产相同的纯策略内核,并使用固定 seed 输出人类摘要和 JSON。以下每项都是独立验收,不用一个长跑 RTP 数字替代边界测试:
|
||||||
|
|
||||||
|
| 组 | 输入矩阵 | 必须证明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 名义 EV | 原图 0/5/50/200/1000 概率 | `26.5x/2650%`,`passes_target=false` 是预期成功 |
|
||||||
|
| 资金拆分 | 0、1、极小金额、普通金额 | 三份非负且逐笔整数守恒;冷启动只注资一次 |
|
||||||
|
| 原图 P/W | `P=800, price=10`,脚本依次命中 5x、200x、1000x | 5x 直付;200x 删除自身+0;1000x 继续删除;最终不透支 |
|
||||||
|
| P/W 边界 | `W=P-1/P/P+1`、全部正奖不可付 | 等于可付,大于重抽,候选耗尽回 0,`P` 永不为负 |
|
||||||
|
| 连 0 保底 | streak 0~5;第 6 抽资金足/不足 | 第 6 抽排除 0;无可支付正奖时安全回 0并留阻断原因 |
|
||||||
|
| 水位 | `low-1/low/low+1/high-1/high/high+1` | 只在 `<low` 下调、`>high` 上调,端点为中水位 |
|
||||||
|
| 充值窗口 | 起点、`+299999ms`、`+300000ms`、未来时间、无回执 | 严格 `[start,end)`,只有可信快照生效 |
|
||||||
|
| 因子组合 | 低/中/高水位 × 充值开/关 | 因子相乘;概率合计始终 100% |
|
||||||
|
| 充值分层 | 各 7/30 日门槛的下 1、等于、上 1 | 必须同时达到两维,选择最高满足层 |
|
||||||
|
| 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 |
|
||||||
|
| 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100,后 98 抽进入新窗;证明不按抽数提前滚动 |
|
||||||
|
| 大奖机制一 | 全局/当日/72h 各自通过和失败、分母 0 | 三门同时通过才有资格,零样本不触发 |
|
||||||
|
| 大奖机制二 | 门槛前、恰好跨过、一次跨多个门槛、资金不足 | token 只影响下一抽,幂等产生,资金不足保留 |
|
||||||
|
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
|
||||||
|
| 日 5 次 | 已中 4 次、5 次;UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 |
|
||||||
|
| 六维风控 | 每一维分别取 `W=remaining` 和 `W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
|
||||||
|
| 组合优先级 | token + RTP 补偿 + 连 0 + 低水位 + 充值 | token 优先,其后 RTP,再普通;安全门永远最后裁决 |
|
||||||
|
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
|
||||||
|
| 有资金长跑 | 固定 seed、足量冷启动资金、各充值层 | 报告实际 RTP/命中率/池底,不把 95%~101%当资金不足时硬断言 |
|
||||||
|
|
||||||
|
### 11.1 可重复命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./services/lucky-gift-service/internal/domain/luckygift
|
||||||
|
go test ./services/wallet-service/internal/storage/mysql -run 'LuckyGiftRechargeSnapshot'
|
||||||
|
go run ./services/lucky-gift-service/cmd/strategy-sim \
|
||||||
|
-seed 20260712 \
|
||||||
|
-monte-carlo-draws 100000 \
|
||||||
|
-long-run-draws 100000 \
|
||||||
|
-group-long-run-draws 30000 \
|
||||||
|
-json-out /tmp/lucky-gift-dynamic-v3-simulation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足:
|
||||||
|
|
||||||
|
- `nominal_probability_table.expected_multiplier=26.5`;
|
||||||
|
- `nominal_probability_table.nominal_rtp_percent=2650`;
|
||||||
|
- `nominal_probability_table.passes_target=false`;
|
||||||
|
- `all_passed=true`;
|
||||||
|
- 所有场景输出固定 seed、输入、最终奖档/状态、池底和阻断原因,不能只打印“通过”。
|
||||||
|
|
||||||
|
## 12. 上线前验收门
|
||||||
|
|
||||||
|
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。
|
||||||
|
5. 本地单测、完整模拟、`make proto`、`make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。
|
||||||
1914
docs/幸运礼物动态策略V3模拟结果-20260712.json
Normal file
1914
docs/幸运礼物动态策略V3模拟结果-20260712.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,7 @@ type Client interface {
|
|||||||
ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error)
|
ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error)
|
||||||
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error)
|
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error)
|
||||||
ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error)
|
ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error)
|
||||||
|
AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type GRPCClient struct {
|
type GRPCClient struct {
|
||||||
@ -50,3 +51,7 @@ func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygift
|
|||||||
func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) {
|
func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) {
|
||||||
return c.client.ListLuckyGiftPoolBalances(ctx, req)
|
return c.client.ListLuckyGiftPoolBalances(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||||
|
return c.client.AdjustLuckyGiftPoolBalance(ctx, req)
|
||||||
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
adapterBaishunV1 = "baishun_v1"
|
adapterBaishunV1 = "baishun_v1"
|
||||||
|
adapterLeaderCCV1 = "leadercc_v1"
|
||||||
adapterZeeOneV1 = "zeeone_v1"
|
adapterZeeOneV1 = "zeeone_v1"
|
||||||
adapterVivaGamesV1 = "vivagames_v1"
|
adapterVivaGamesV1 = "vivagames_v1"
|
||||||
adapterReyouV1 = "reyou_v1"
|
adapterReyouV1 = "reyou_v1"
|
||||||
@ -134,6 +135,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
|
|||||||
switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) {
|
switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) {
|
||||||
case adapterBaishunV1:
|
case adapterBaishunV1:
|
||||||
return fetchBaishunGameSyncPlan(ctx, platform, req)
|
return fetchBaishunGameSyncPlan(ctx, platform, req)
|
||||||
|
case adapterLeaderCCV1:
|
||||||
|
return fetchLeaderCCGameSyncPlan(ctx, platform, req)
|
||||||
case adapterZeeOneV1:
|
case adapterZeeOneV1:
|
||||||
return fetchZeeOneGameSyncPlan(platform, req)
|
return fetchZeeOneGameSyncPlan(platform, req)
|
||||||
case adapterVivaGamesV1:
|
case adapterVivaGamesV1:
|
||||||
@ -184,6 +187,21 @@ type baishunSyncConfig struct {
|
|||||||
GameURLs map[string]string `json:"game_urls"`
|
GameURLs map[string]string `json:"game_urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type leaderCCSyncConfig struct {
|
||||||
|
// game_list_url 是灵仙按环境提供的公开 JSON 清单,game_urls 由同步操作自动回写。
|
||||||
|
GameListURL string `json:"game_list_url"`
|
||||||
|
GameListURLCamel string `json:"gameListUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type leaderCCGameListItem struct {
|
||||||
|
GameID any `json:"gameId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Version any `json:"ver"`
|
||||||
|
FullURL string `json:"full_url"`
|
||||||
|
HalfURL string `json:"half_url"`
|
||||||
|
}
|
||||||
|
|
||||||
type zeeoneSyncConfig struct {
|
type zeeoneSyncConfig struct {
|
||||||
// ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API;后台“获取游戏列表”直接把这个映射转换为候选目录。
|
// ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API;后台“获取游戏列表”直接把这个映射转换为候选目录。
|
||||||
GameURLs map[string]string `json:"game_urls"`
|
GameURLs map[string]string `json:"game_urls"`
|
||||||
@ -232,6 +250,91 @@ type yomiSyncConfig struct {
|
|||||||
GameCovers map[string]string `json:"game_covers"`
|
GameCovers map[string]string `json:"game_covers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchLeaderCCGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||||
|
config, err := decodeLeaderCCSyncConfig(platform.GetAdapterConfigJson())
|
||||||
|
if err != nil {
|
||||||
|
return providerGameSyncPlan{}, err
|
||||||
|
}
|
||||||
|
endpoint := strings.TrimSpace(firstNonEmpty(config.GameListURL, config.GameListURLCamel))
|
||||||
|
if endpoint == "" {
|
||||||
|
return providerGameSyncPlan{}, fmt.Errorf("灵仙列表同步缺少配置: adapterConfigJson.game_list_url")
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表 URL 不合法: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return providerGameSyncPlan{}, fmt.Errorf("请求灵仙游戏列表失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return providerGameSyncPlan{}, fmt.Errorf("读取灵仙游戏列表失败: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
var providerItems []leaderCCGameListItem
|
||||||
|
if err := json.Unmarshal(raw, &providerItems); err != nil {
|
||||||
|
return providerGameSyncPlan{}, fmt.Errorf("解析灵仙游戏列表失败: %w", err)
|
||||||
|
}
|
||||||
|
games, gameURLs := leaderCCCatalogItems(platform.GetPlatformCode(), providerItems, req)
|
||||||
|
if len(games) == 0 {
|
||||||
|
return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表为空")
|
||||||
|
}
|
||||||
|
return providerGameSyncPlan{Games: games, GameURLs: gameURLs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLeaderCCSyncConfig(raw string) (leaderCCSyncConfig, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return leaderCCSyncConfig{}, nil
|
||||||
|
}
|
||||||
|
var config leaderCCSyncConfig
|
||||||
|
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
||||||
|
return leaderCCSyncConfig{}, fmt.Errorf("灵仙适配器配置 JSON 不合法")
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func leaderCCCatalogItems(platformCode string, providerItems []leaderCCGameListItem, req syncGamesRequest) ([]catalogRequest, map[string]string) {
|
||||||
|
items := make([]catalogRequest, 0, len(providerItems))
|
||||||
|
gameURLs := make(map[string]string, len(providerItems))
|
||||||
|
for index, providerItem := range providerItems {
|
||||||
|
providerGameID := stringFromJSONValue(providerItem.GameID)
|
||||||
|
if providerGameID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 半屏地址优先匹配语音房内嵌场景;厂商未提供时才回退全屏地址。
|
||||||
|
launchURL := firstNonEmpty(providerItem.HalfURL, providerItem.FullURL)
|
||||||
|
if launchURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
launchMode := strings.TrimSpace(req.LaunchMode)
|
||||||
|
if launchMode == "" {
|
||||||
|
launchMode = "half_screen"
|
||||||
|
if strings.TrimSpace(providerItem.HalfURL) == "" {
|
||||||
|
launchMode = defaultLaunchMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gameURLs[providerGameID] = launchURL
|
||||||
|
items = append(items, catalogRequest{
|
||||||
|
GameID: stableGameID(platformCode, providerGameID),
|
||||||
|
PlatformCode: strings.TrimSpace(platformCode),
|
||||||
|
ProviderGameID: providerGameID,
|
||||||
|
GameName: firstNonEmpty(providerItem.Name, providerItem.Title, providerGameID),
|
||||||
|
Category: defaulted(req.Category, defaultGameCategory),
|
||||||
|
LaunchMode: launchMode,
|
||||||
|
Orientation: defaultOrientation,
|
||||||
|
MinCoin: req.MinCoin,
|
||||||
|
Status: defaulted(req.Status, defaultGameStatus),
|
||||||
|
SortOrder: int32((index + 1) * 10),
|
||||||
|
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterLeaderCCV1}, req.Tags...)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, gameURLs
|
||||||
|
}
|
||||||
|
|
||||||
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||||
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
|
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -72,6 +72,42 @@ func TestFetchBaishunGameSyncPlanFetchesSignsAndFormatsCatalog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetchLeaderCCGameSyncPlanReadsRemoteJSONList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || r.URL.Path != "/test_game_list.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"gameId":"9","name":"GREEDY BOX","title":"过关斩将","ver":11,"half_url":"https://games.test/greedy_half/index.html"},
|
||||||
|
{"gameId":"7","name":"SuperRichGod","title":"超级财神","ver":11,"full_url":"https://games.test/rich/index.html"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
|
||||||
|
PlatformCode: "lingxian",
|
||||||
|
AdapterType: adapterLeaderCCV1,
|
||||||
|
AdapterConfigJson: `{"game_list_url":` + strconv.Quote(server.URL+"/test_game_list.json") + `}`,
|
||||||
|
}, syncGamesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Games) != 2 {
|
||||||
|
t.Fatalf("games len = %d, want 2: %+v", len(plan.Games), plan.Games)
|
||||||
|
}
|
||||||
|
first, second := plan.Games[0], plan.Games[1]
|
||||||
|
if first.GameID != "lingxian_9" || first.GameName != "GREEDY BOX" || first.LaunchMode != "half_screen" || first.Status != "disabled" {
|
||||||
|
t.Fatalf("first game mismatch: %+v", first)
|
||||||
|
}
|
||||||
|
if second.GameID != "lingxian_7" || second.LaunchMode != "full_screen" {
|
||||||
|
t.Fatalf("full URL fallback mismatch: %+v", second)
|
||||||
|
}
|
||||||
|
if plan.GameURLs["9"] != "https://games.test/greedy_half/index.html" || plan.GameURLs["7"] != "https://games.test/rich/index.html" {
|
||||||
|
t.Fatalf("game URLs mismatch: %+v", plan.GameURLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
|
func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
|
||||||
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
|
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
|
||||||
PlatformCode: "zeeone",
|
PlatformCode: "zeeone",
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import (
|
|||||||
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
grpcstatus "google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@ -32,15 +34,38 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
|
|||||||
type configRequest struct {
|
type configRequest struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
|
StrategyVersion string `json:"strategy_version"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||||||
PoolRatePercent float64 `json:"pool_rate_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"`
|
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||||
ControlBandPercent float64 `json:"control_band_percent"`
|
ControlBandPercent float64 `json:"control_band_percent"`
|
||||||
GiftPriceReference int64 `json:"gift_price_reference"`
|
GiftPriceReference int64 `json:"gift_price_reference"`
|
||||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
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"`
|
Stages []stageDTO `json:"stages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +78,8 @@ type configDTO struct {
|
|||||||
|
|
||||||
type stageDTO struct {
|
type stageDTO struct {
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
|
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
|
||||||
|
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
|
||||||
Tiers []tierDTO `json:"tiers"`
|
Tiers []tierDTO `json:"tiers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,15 +132,44 @@ type drawSummaryDTO struct {
|
|||||||
type poolBalanceDTO struct {
|
type poolBalanceDTO struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
|
StrategyVersion string `json:"strategy_version"`
|
||||||
Balance int64 `json:"balance"`
|
Balance int64 `json:"balance"`
|
||||||
ReserveFloor int64 `json:"reserve_floor"`
|
ReserveFloor int64 `json:"reserve_floor"`
|
||||||
AvailableBalance int64 `json:"available_balance"`
|
AvailableBalance int64 `json:"available_balance"`
|
||||||
TotalIn int64 `json:"total_in"`
|
TotalIn int64 `json:"total_in"`
|
||||||
TotalOut int64 `json:"total_out"`
|
TotalOut int64 `json:"total_out"`
|
||||||
|
ManualCreditTotal int64 `json:"manual_credit_total"`
|
||||||
|
ManualDebitTotal int64 `json:"manual_debit_total"`
|
||||||
Materialized bool `json:"materialized"`
|
Materialized bool `json:"materialized"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type poolAdjustmentRequest struct {
|
||||||
|
AdjustmentID string `json:"adjustment_id"`
|
||||||
|
AmountCoins int64 `json:"amount_coins"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type poolAdjustmentDTO struct {
|
||||||
|
AdjustmentID string `json:"adjustment_id"`
|
||||||
|
AppCode string `json:"app_code"`
|
||||||
|
PoolID string `json:"pool_id"`
|
||||||
|
StrategyVersion string `json:"strategy_version"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
AmountCoins int64 `json:"amount_coins"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
OperatorAdminID int64 `json:"operator_admin_id"`
|
||||||
|
BalanceBefore int64 `json:"balance_before"`
|
||||||
|
BalanceAfter int64 `json:"balance_after"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type poolAdjustmentResponseDTO struct {
|
||||||
|
Adjustment poolAdjustmentDTO `json:"adjustment"`
|
||||||
|
Pool poolBalanceDTO `json:"pool"`
|
||||||
|
IdempotentReplay bool `json:"idempotent_replay"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
|
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
|
||||||
if strings.TrimSpace(c.Query("app_code")) == "" {
|
if strings.TrimSpace(c.Query("app_code")) == "" {
|
||||||
response.BadRequest(c, "app_code is required")
|
response.BadRequest(c, "app_code is required")
|
||||||
@ -250,6 +306,7 @@ func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) {
|
|||||||
resp, err := h.luckyGift.ListLuckyGiftPoolBalances(ctx, &luckygiftv1.ListLuckyGiftPoolBalancesRequest{
|
resp, err := h.luckyGift.ListLuckyGiftPoolBalances(ctx, &luckygiftv1.ListLuckyGiftPoolBalancesRequest{
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||||
|
StrategyVersion: strings.ToLower(strings.TrimSpace(c.Query("strategy_version"))),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, "获取幸运礼物奖池余额失败")
|
response.ServerError(c, "获取幸运礼物奖池余额失败")
|
||||||
@ -262,6 +319,75 @@ func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreditLuckyGiftPoolBalance(c *gin.Context) {
|
||||||
|
h.adjustLuckyGiftPoolBalance(c, "in")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DebitLuckyGiftPoolBalance(c *gin.Context) {
|
||||||
|
h.adjustLuckyGiftPoolBalance(c, "out")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) adjustLuckyGiftPoolBalance(c *gin.Context, direction string) {
|
||||||
|
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
|
||||||
|
poolID := strings.TrimSpace(c.Query("pool_id"))
|
||||||
|
strategyVersion := strings.ToLower(strings.TrimSpace(c.Query("strategy_version")))
|
||||||
|
if appCode == "" || poolID == "" || (strategyVersion != "fixed_v2" && strategyVersion != "dynamic_v3") {
|
||||||
|
response.BadRequest(c, "app_code, pool_id and strategy_version are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req poolAdjustmentRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "幸运礼物奖池调整参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.AdjustmentID = strings.TrimSpace(req.AdjustmentID)
|
||||||
|
req.Reason = strings.TrimSpace(req.Reason)
|
||||||
|
if req.AdjustmentID == "" || req.AmountCoins <= 0 || req.Reason == "" {
|
||||||
|
response.BadRequest(c, "adjustment_id, positive amount_coins and reason are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := h.luckyGiftContext(c)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := h.luckyGift.AdjustLuckyGiftPoolBalance(ctx, &luckygiftv1.AdjustLuckyGiftPoolBalanceRequest{
|
||||||
|
Meta: h.metaForApp(c, appCode),
|
||||||
|
PoolId: poolID,
|
||||||
|
StrategyVersion: strategyVersion,
|
||||||
|
AdjustmentId: req.AdjustmentID,
|
||||||
|
Direction: direction,
|
||||||
|
AmountCoins: req.AmountCoins,
|
||||||
|
Reason: req.Reason,
|
||||||
|
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.writePoolAdjustmentError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := poolAdjustmentResponseDTO{
|
||||||
|
Adjustment: poolAdjustmentFromProto(resp.GetAdjustment()),
|
||||||
|
Pool: poolBalanceFromProto(resp.GetPool()),
|
||||||
|
IdempotentReplay: resp.GetIdempotentReplay(),
|
||||||
|
}
|
||||||
|
shared.OperationLogWithResourceID(
|
||||||
|
c, h.audit, "adjust-lucky-gift-pool-"+direction, "lucky_pool_adjustments", item.Adjustment.AdjustmentID, "success",
|
||||||
|
fmt.Sprintf("app_code=%s pool_id=%s strategy_version=%s direction=%s amount_coins=%d reason=%s", appCode, poolID, strategyVersion, direction, req.AmountCoins, req.Reason),
|
||||||
|
)
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writePoolAdjustmentError(c *gin.Context, err error) {
|
||||||
|
status := grpcstatus.Convert(err)
|
||||||
|
switch status.Code() {
|
||||||
|
case codes.InvalidArgument:
|
||||||
|
response.BadRequest(c, status.Message())
|
||||||
|
case codes.NotFound:
|
||||||
|
response.NotFound(c, status.Message())
|
||||||
|
case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
|
||||||
|
response.Conflict(c, status.Message())
|
||||||
|
default:
|
||||||
|
response.ServerError(c, "调整幸运礼物奖池余额失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) meta(c *gin.Context) *luckygiftv1.RequestMeta {
|
func (h *Handler) meta(c *gin.Context) *luckygiftv1.RequestMeta {
|
||||||
return h.metaForApp(c, "")
|
return h.metaForApp(c, "")
|
||||||
}
|
}
|
||||||
@ -286,6 +412,12 @@ func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.Can
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
|
func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
|
||||||
|
strategyVersion := strings.ToLower(strings.TrimSpace(req.StrategyVersion))
|
||||||
|
initialPoolCoins := req.InitialPoolCoins
|
||||||
|
if strategyVersion == "dynamic_v3" {
|
||||||
|
// V3 启动资金已经迁移为独立人工 credit;旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。
|
||||||
|
initialPoolCoins = 0
|
||||||
|
}
|
||||||
stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(req.Stages))
|
stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(req.Stages))
|
||||||
for _, stage := range req.Stages {
|
for _, stage := range req.Stages {
|
||||||
stageName := strings.TrimSpace(stage.Stage)
|
stageName := strings.TrimSpace(stage.Stage)
|
||||||
@ -306,21 +438,46 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
|
|||||||
}
|
}
|
||||||
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
|
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
|
||||||
Stage: stageName,
|
Stage: stageName,
|
||||||
|
MinRecharge_7DCoins: stage.MinRecharge7DCoins,
|
||||||
|
MinRecharge_30DCoins: stage.MinRecharge30DCoins,
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return &luckygiftv1.LuckyGiftRuleConfig{
|
return &luckygiftv1.LuckyGiftRuleConfig{
|
||||||
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
|
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
|
||||||
PoolId: strings.TrimSpace(req.PoolID),
|
PoolId: strings.TrimSpace(req.PoolID),
|
||||||
|
StrategyVersion: strategyVersion,
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
|
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
|
||||||
PoolRatePpm: percentToPPM(req.PoolRatePercent),
|
PoolRatePpm: percentToPPM(req.PoolRatePercent),
|
||||||
|
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
|
||||||
|
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
|
||||||
SettlementWindowWager: req.SettlementWindowWager,
|
SettlementWindowWager: req.SettlementWindowWager,
|
||||||
ControlBandPpm: percentToPPM(req.ControlBandPercent),
|
ControlBandPpm: percentToPPM(req.ControlBandPercent),
|
||||||
GiftPriceReference: req.GiftPriceReference,
|
GiftPriceReference: req.GiftPriceReference,
|
||||||
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
|
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
|
||||||
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
|
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
|
||||||
EffectiveFromMs: req.EffectiveFromMS,
|
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,
|
Stages: stages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -343,10 +500,18 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
|
|||||||
}
|
}
|
||||||
stages = append(stages, stageDTO{
|
stages = append(stages, stageDTO{
|
||||||
Stage: stage.GetStage(),
|
Stage: stage.GetStage(),
|
||||||
|
MinRecharge7DCoins: stage.GetMinRecharge_7DCoins(),
|
||||||
|
MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
poolID := strings.TrimSpace(config.GetPoolId())
|
poolID := strings.TrimSpace(config.GetPoolId())
|
||||||
|
strategyVersion := strings.ToLower(strings.TrimSpace(config.GetStrategyVersion()))
|
||||||
|
initialPoolCoins := config.GetInitialPoolCoins()
|
||||||
|
if strategyVersion == "dynamic_v3" {
|
||||||
|
// 兼容读取迁移前 V3 快照时也展示 0,避免运营误以为规则字段仍会自动形成账本资金。
|
||||||
|
initialPoolCoins = 0
|
||||||
|
}
|
||||||
return configDTO{
|
return configDTO{
|
||||||
RuleVersion: config.GetRuleVersion(),
|
RuleVersion: config.GetRuleVersion(),
|
||||||
CreatedByAdminID: config.GetCreatedByAdminId(),
|
CreatedByAdminID: config.GetCreatedByAdminId(),
|
||||||
@ -354,15 +519,38 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
|
|||||||
configRequest: configRequest{
|
configRequest: configRequest{
|
||||||
AppCode: config.GetAppCode(),
|
AppCode: config.GetAppCode(),
|
||||||
PoolID: poolID,
|
PoolID: poolID,
|
||||||
|
StrategyVersion: strategyVersion,
|
||||||
Enabled: config.GetEnabled(),
|
Enabled: config.GetEnabled(),
|
||||||
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
|
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
|
||||||
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
|
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
|
||||||
|
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
|
||||||
|
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
|
||||||
SettlementWindowWager: config.GetSettlementWindowWager(),
|
SettlementWindowWager: config.GetSettlementWindowWager(),
|
||||||
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
|
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
|
||||||
GiftPriceReference: config.GetGiftPriceReference(),
|
GiftPriceReference: config.GetGiftPriceReference(),
|
||||||
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
|
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
|
||||||
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
|
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
|
||||||
EffectiveFromMS: config.GetEffectiveFromMs(),
|
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,
|
Stages: stages,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -403,10 +591,26 @@ func multiplierToPPM(value float64) int64 {
|
|||||||
return int64(math.Round(value * 1_000_000))
|
return int64(math.Round(value * 1_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func multipliersToPPM(values []float64) []int64 {
|
||||||
|
out := make([]int64, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out = append(out, multiplierToPPM(value))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func ppmToMultiplier(value int64) float64 {
|
func ppmToMultiplier(value int64) float64 {
|
||||||
return float64(value) / 1_000_000
|
return float64(value) / 1_000_000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ppmsToMultipliers(values []int64) []float64 {
|
||||||
|
out := make([]float64, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out = append(out, ppmToMultiplier(value))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func drawFromProto(draw *luckygiftv1.LuckyGiftDrawResult) drawDTO {
|
func drawFromProto(draw *luckygiftv1.LuckyGiftDrawResult) drawDTO {
|
||||||
if draw == nil {
|
if draw == nil {
|
||||||
return drawDTO{}
|
return drawDTO{}
|
||||||
@ -462,16 +666,38 @@ func poolBalanceFromProto(pool *luckygiftv1.LuckyGiftPoolBalance) poolBalanceDTO
|
|||||||
return poolBalanceDTO{
|
return poolBalanceDTO{
|
||||||
AppCode: pool.GetAppCode(),
|
AppCode: pool.GetAppCode(),
|
||||||
PoolID: pool.GetPoolId(),
|
PoolID: pool.GetPoolId(),
|
||||||
|
StrategyVersion: pool.GetStrategyVersion(),
|
||||||
Balance: pool.GetBalance(),
|
Balance: pool.GetBalance(),
|
||||||
ReserveFloor: pool.GetReserveFloor(),
|
ReserveFloor: pool.GetReserveFloor(),
|
||||||
AvailableBalance: pool.GetAvailableBalance(),
|
AvailableBalance: pool.GetAvailableBalance(),
|
||||||
TotalIn: pool.GetTotalIn(),
|
TotalIn: pool.GetTotalIn(),
|
||||||
TotalOut: pool.GetTotalOut(),
|
TotalOut: pool.GetTotalOut(),
|
||||||
|
ManualCreditTotal: pool.GetManualCreditTotal(),
|
||||||
|
ManualDebitTotal: pool.GetManualDebitTotal(),
|
||||||
Materialized: pool.GetMaterialized(),
|
Materialized: pool.GetMaterialized(),
|
||||||
UpdatedAtMS: pool.GetUpdatedAtMs(),
|
UpdatedAtMS: pool.GetUpdatedAtMs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func poolAdjustmentFromProto(adjustment *luckygiftv1.LuckyGiftPoolAdjustment) poolAdjustmentDTO {
|
||||||
|
if adjustment == nil {
|
||||||
|
return poolAdjustmentDTO{}
|
||||||
|
}
|
||||||
|
return poolAdjustmentDTO{
|
||||||
|
AdjustmentID: adjustment.GetAdjustmentId(),
|
||||||
|
AppCode: adjustment.GetAppCode(),
|
||||||
|
PoolID: adjustment.GetPoolId(),
|
||||||
|
StrategyVersion: adjustment.GetStrategyVersion(),
|
||||||
|
Direction: adjustment.GetDirection(),
|
||||||
|
AmountCoins: adjustment.GetAmountCoins(),
|
||||||
|
Reason: adjustment.GetReason(),
|
||||||
|
OperatorAdminID: adjustment.GetOperatorAdminId(),
|
||||||
|
BalanceBefore: adjustment.GetBalanceBefore(),
|
||||||
|
BalanceAfter: adjustment.GetBalanceAfter(),
|
||||||
|
CreatedAtMS: adjustment.GetCreatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func parseOptionalInt64(value string) int64 {
|
func parseOptionalInt64(value string) int64 {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@ -1,11 +1,35 @@
|
|||||||
package luckygift
|
package luckygift
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type poolAdjustmentClientStub struct {
|
||||||
|
luckygiftadmin.Client
|
||||||
|
request *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest
|
||||||
|
response *luckygiftv1.AdjustLuckyGiftPoolBalanceResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *poolAdjustmentClientStub) AdjustLuckyGiftPoolBalance(_ context.Context, request *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||||
|
s.request = request
|
||||||
|
return s.response, s.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) {
|
func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) {
|
||||||
config := configToProto(configRequest{
|
config := configToProto(configRequest{
|
||||||
PoolID: " lucky_100 ",
|
PoolID: " lucky_100 ",
|
||||||
@ -62,6 +86,63 @@ func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreditLuckyGiftPoolBalanceForwardsAuditedOwnerCommand(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
client := &poolAdjustmentClientStub{response: &luckygiftv1.AdjustLuckyGiftPoolBalanceResponse{
|
||||||
|
Adjustment: &luckygiftv1.LuckyGiftPoolAdjustment{
|
||||||
|
AdjustmentId: "adjustment-1", AppCode: "aslan", PoolId: "lucky", StrategyVersion: "dynamic_v3",
|
||||||
|
Direction: "in", AmountCoins: 100, Reason: "launch budget", OperatorAdminId: 7, BalanceBefore: 0, BalanceAfter: 100,
|
||||||
|
},
|
||||||
|
Pool: &luckygiftv1.LuckyGiftPoolBalance{
|
||||||
|
AppCode: "aslan", PoolId: "lucky", StrategyVersion: "dynamic_v3", Balance: 100,
|
||||||
|
ManualCreditTotal: 100, Materialized: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/admin/ops-center/lucky-gifts/pools/credit?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3", bytes.NewBufferString(`{"adjustment_id":"adjustment-1","amount_coins":100,"reason":"launch budget"}`))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
c.Set(middleware.ContextUserID, uint(7))
|
||||||
|
c.Set(middleware.ContextRequestID, "request-1")
|
||||||
|
New(client, time.Second, nil).CreditLuckyGiftPoolBalance(c)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if client.request == nil || client.request.GetDirection() != "in" || client.request.GetOperatorAdminId() != 7 ||
|
||||||
|
client.request.GetMeta().GetAppCode() != "aslan" || client.request.GetMeta().GetRequestId() != "request-1" {
|
||||||
|
t.Fatalf("owner request=%+v", client.request)
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Pool struct {
|
||||||
|
StrategyVersion string `json:"strategy_version"`
|
||||||
|
ManualCreditTotal int64 `json:"manual_credit_total"`
|
||||||
|
} `json:"pool"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if envelope.Code != 0 || envelope.Data.Pool.StrategyVersion != "dynamic_v3" || envelope.Data.Pool.ManualCreditTotal != 100 {
|
||||||
|
t.Fatalf("response=%s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebitLuckyGiftPoolBalanceMapsOwnerConflictToHTTP409(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
client := &poolAdjustmentClientStub{err: status.Error(codes.FailedPrecondition, "debit would cross reserve floor")}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/admin/ops-center/lucky-gifts/pools/debit?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3", bytes.NewBufferString(`{"adjustment_id":"adjustment-2","amount_coins":101,"reason":"return budget"}`))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
c.Set(middleware.ContextUserID, uint(7))
|
||||||
|
New(client, time.Second, nil).DebitLuckyGiftPoolBalance(c)
|
||||||
|
if recorder.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status=%d want=409 body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigToProtoGeneratesUniqueTierIDs(t *testing.T) {
|
func TestConfigToProtoGeneratesUniqueTierIDs(t *testing.T) {
|
||||||
config := configToProto(configRequest{
|
config := configToProto(configRequest{
|
||||||
PoolID: "lucky",
|
PoolID: "lucky",
|
||||||
@ -137,3 +218,79 @@ func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) {
|
|||||||
t.Fatalf("probability percent = %v, want 22", config.Stages[0].Tiers[0].ProbabilityPercent)
|
t.Fatalf("probability percent = %v, want 22", config.Stages[0].Tiers[0].ProbabilityPercent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDynamicConfigAdminMappingRoundTrip(t *testing.T) {
|
||||||
|
protoConfig := configToProto(configRequest{
|
||||||
|
AppCode: " LALU ",
|
||||||
|
PoolID: " lucky ",
|
||||||
|
StrategyVersion: " DYNAMIC_V3 ",
|
||||||
|
Enabled: true,
|
||||||
|
TargetRTPPercent: 98,
|
||||||
|
PoolRatePercent: 98,
|
||||||
|
ProfitRatePercent: 1,
|
||||||
|
AnchorRatePercent: 1,
|
||||||
|
SettlementWindowWager: 1_000_000,
|
||||||
|
ControlBandPercent: 3,
|
||||||
|
GiftPriceReference: 100,
|
||||||
|
InitialPoolCoins: 1_000_000,
|
||||||
|
LossStreakGuarantee: 5,
|
||||||
|
LowWatermarkCoins: 10_000_000,
|
||||||
|
LowWaterNonzeroFactorPercent: 70,
|
||||||
|
HighWatermarkCoins: 20_000_000,
|
||||||
|
HighWaterNonzeroFactorPercent: 130,
|
||||||
|
RechargeBoostWindowMS: 300_000,
|
||||||
|
RechargeBoostFactorPercent: 110,
|
||||||
|
JackpotMultipliers: []float64{200, 500, 1000},
|
||||||
|
JackpotGlobalRTPMaxPercent: 98,
|
||||||
|
JackpotUserDayRTPMaxPercent: 96,
|
||||||
|
JackpotUser72hRTPMaxPercent: 96,
|
||||||
|
JackpotSpendThresholdCoins: 5_000,
|
||||||
|
MaxJackpotHitsPerUserDay: 5,
|
||||||
|
MaxSinglePayout: 10_000,
|
||||||
|
UserHourlyPayoutCap: 20_000,
|
||||||
|
UserDailyPayoutCap: 30_000,
|
||||||
|
DeviceDailyPayoutCap: 40_000,
|
||||||
|
RoomHourlyPayoutCap: 50_000,
|
||||||
|
AnchorDailyPayoutCap: 60_000,
|
||||||
|
Stages: []stageDTO{{
|
||||||
|
Stage: " normal ",
|
||||||
|
MinRecharge7DCoins: 7_000,
|
||||||
|
MinRecharge30DCoins: 30_000,
|
||||||
|
Tiers: []tierDTO{{Multiplier: 1, ProbabilityPercent: 100, Enabled: true}},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
if protoConfig.GetStrategyVersion() != "dynamic_v3" || protoConfig.GetProfitRatePpm() != 10_000 || protoConfig.GetAnchorRatePpm() != 10_000 {
|
||||||
|
t.Fatalf("strategy/fund split proto mismatch: %+v", protoConfig)
|
||||||
|
}
|
||||||
|
if protoConfig.GetInitialPoolCoins() != 0 {
|
||||||
|
t.Fatalf("dynamic_v3 legacy initial pool was forwarded: %d", protoConfig.GetInitialPoolCoins())
|
||||||
|
}
|
||||||
|
if protoConfig.GetLowWaterNonzeroFactorPpm() != 700_000 || protoConfig.GetHighWaterNonzeroFactorPpm() != 1_300_000 || protoConfig.GetRechargeBoostFactorPpm() != 1_100_000 {
|
||||||
|
t.Fatalf("dynamic factors lost precision in admin mapping: %+v", protoConfig)
|
||||||
|
}
|
||||||
|
if got := protoConfig.GetJackpotMultiplierPpms(); len(got) != 3 || got[0] != 200_000_000 || got[2] != 1_000_000_000 {
|
||||||
|
t.Fatalf("jackpot multiplier ppms=%v, want 200x/500x/1000x", got)
|
||||||
|
}
|
||||||
|
stage := protoConfig.GetStages()[0]
|
||||||
|
if stage.GetMinRecharge_7DCoins() != 7_000 || stage.GetMinRecharge_30DCoins() != 30_000 {
|
||||||
|
t.Fatalf("stage recharge thresholds were not mapped: %+v", stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
dto := configFromProto(protoConfig)
|
||||||
|
if dto.StrategyVersion != "dynamic_v3" || dto.ProfitRatePercent != 1 || dto.AnchorRatePercent != 1 {
|
||||||
|
t.Fatalf("strategy/fund split DTO mismatch: %+v", dto)
|
||||||
|
}
|
||||||
|
if dto.InitialPoolCoins != 0 {
|
||||||
|
t.Fatalf("dynamic_v3 DTO exposed legacy initial pool: %d", dto.InitialPoolCoins)
|
||||||
|
}
|
||||||
|
if dto.LowWaterNonzeroFactorPercent != 70 || dto.HighWaterNonzeroFactorPercent != 130 || dto.RechargeBoostFactorPercent != 110 {
|
||||||
|
t.Fatalf("dynamic factor DTO mismatch: %+v", dto)
|
||||||
|
}
|
||||||
|
if len(dto.JackpotMultipliers) != 3 || dto.JackpotMultipliers[1] != 500 || dto.JackpotUser72hRTPMaxPercent != 96 {
|
||||||
|
t.Fatalf("jackpot DTO mismatch: %+v", dto)
|
||||||
|
}
|
||||||
|
if dto.MaxSinglePayout != 10_000 || dto.AnchorDailyPayoutCap != 60_000 || dto.Stages[0].MinRecharge30DCoins != 30_000 {
|
||||||
|
t.Fatalf("risk/stage DTO mismatch: %+v", dto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -25,4 +25,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
|||||||
group.GET("/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftDraws)
|
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/summary", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftDrawSummary)
|
||||||
group.GET("/lucky-gifts/pools", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftPoolBalances)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
87
server/admin/internal/modules/opscenter/routes_test.go
Normal file
87
server/admin/internal/modules/opscenter/routes_test.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package opscenter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
luckygiftmodule "hyapp-admin-server/internal/modules/luckygift"
|
||||||
|
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type poolPermissionClient struct {
|
||||||
|
luckygiftadmin.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (poolPermissionClient) AdjustLuckyGiftPoolBalance(_ context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||||
|
return &luckygiftv1.AdjustLuckyGiftPoolBalanceResponse{
|
||||||
|
Adjustment: &luckygiftv1.LuckyGiftPoolAdjustment{
|
||||||
|
AdjustmentId: req.GetAdjustmentId(), AppCode: req.GetMeta().GetAppCode(), PoolId: req.GetPoolId(),
|
||||||
|
StrategyVersion: req.GetStrategyVersion(), Direction: req.GetDirection(), AmountCoins: req.GetAmountCoins(),
|
||||||
|
},
|
||||||
|
Pool: &luckygiftv1.LuckyGiftPoolBalance{
|
||||||
|
AppCode: req.GetMeta().GetAppCode(), PoolId: req.GetPoolId(), StrategyVersion: req.GetStrategyVersion(), Materialized: true,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRoutesExposesStrategyPoolAdjustmentEndpoints(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
engine := gin.New()
|
||||||
|
RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(poolPermissionClient{}, 0, nil)))
|
||||||
|
routes := make(map[string]struct{})
|
||||||
|
for _, route := range engine.Routes() {
|
||||||
|
routes[route.Method+" "+route.Path] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, expected := range []string{
|
||||||
|
"POST /api/v1/admin/ops-center/lucky-gifts/pools/credit",
|
||||||
|
"POST /api/v1/admin/ops-center/lucky-gifts/pools/debit",
|
||||||
|
} {
|
||||||
|
if _, ok := routes[expected]; !ok {
|
||||||
|
t.Fatalf("missing route %s; actual=%v", expected, routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolAdjustmentRoutesRequireDirectionSpecificPermission(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
permission string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{name: "credit allowed", path: "credit", permission: "lucky-gift:pool-credit", wantStatus: http.StatusOK},
|
||||||
|
{name: "credit rejects debit permission", path: "credit", permission: "lucky-gift:pool-debit", wantStatus: http.StatusForbidden},
|
||||||
|
{name: "debit allowed", path: "debit", permission: "lucky-gift:pool-debit", wantStatus: http.StatusOK},
|
||||||
|
{name: "debit rejects view permission", path: "debit", permission: "lucky-gift:view", wantStatus: http.StatusForbidden},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(func(c *gin.Context) {
|
||||||
|
c.Set(middleware.ContextPermissions, []string{tt.permission})
|
||||||
|
c.Set(middleware.ContextUserID, uint(7))
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(poolPermissionClient{}, 0, nil)))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/admin/ops-center/lucky-gifts/pools/"+tt.path+"?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3",
|
||||||
|
strings.NewReader(`{"adjustment_id":"route-adjustment","amount_coins":1,"reason":"permission test"}`),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
engine.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != tt.wantStatus {
|
||||||
|
t.Fatalf("status=%d want=%d body=%s", recorder.Code, tt.wantStatus, recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -129,7 +129,7 @@ var (
|
|||||||
"daily-task:view", "first-recharge-reward:view", "cumulative-recharge-reward:view",
|
"daily-task:view", "first-recharge-reward:view", "cumulative-recharge-reward:view",
|
||||||
"invite-activity-reward:view", "seven-day-checkin:view", "room-rocket:view", "room-turnover-reward:view",
|
"invite-activity-reward:view", "seven-day-checkin:view", "room-rocket:view", "room-turnover-reward:view",
|
||||||
"wheel:view", "weekly-star:view", "agency-opening:view", "user-leaderboard:view", "red-packet:view",
|
"wheel:view", "weekly-star:view", "agency-opening:view", "user-leaderboard:view", "red-packet:view",
|
||||||
"cp-config:view", "cp-weekly-rank:view", "vip-config:view", "achievement:view",
|
"cp-config:view", "cp-weekly-rank:view", "vip-config:view", "achievement:view", "lucky-gift:view",
|
||||||
}
|
}
|
||||||
activityManage = []string{
|
activityManage = []string{
|
||||||
"activity-template:create", "activity-template:update", "activity-template:publish", "activity-template:delete",
|
"activity-template:create", "activity-template:update", "activity-template:publish", "activity-template:delete",
|
||||||
@ -140,8 +140,10 @@ var (
|
|||||||
"wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
|
"wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
|
||||||
"agency-opening:create", "agency-opening:update", "red-packet:update",
|
"agency-opening:create", "agency-opening:update", "red-packet:update",
|
||||||
"cp-config:update", "cp-weekly-rank:update", "vip-config:update",
|
"cp-config:update", "cp-weekly-rank:update", "vip-config:update",
|
||||||
"achievement:create", "achievement:update",
|
"achievement:create", "achievement:update", "lucky-gift:update",
|
||||||
}
|
}
|
||||||
|
// 奖池人工增减属于资金高风险动作,只授予运营负责人;platform-admin 仍由全权限同步自动获得。
|
||||||
|
luckyGiftPoolManage = []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"}
|
||||||
|
|
||||||
gameRead = []string{
|
gameRead = []string{
|
||||||
"game-catalog:view", "self-game:view", "game-robot:view", "room-rps-config:view", "room-rps-order:view",
|
"game-catalog:view", "self-game:view", "game-robot:view", "room-rps-config:view", "room-rps-order:view",
|
||||||
@ -178,7 +180,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
roomRead, roomManage,
|
roomRead, roomManage,
|
||||||
[]string{"app-config:view"}, appConfigManage,
|
[]string{"app-config:view"}, appConfigManage,
|
||||||
resourceRead, resourceManage,
|
resourceRead, resourceManage,
|
||||||
operationsRead, operationsLeadManage,
|
operationsRead, operationsLeadManage, luckyGiftPoolManage,
|
||||||
externalAdminUserOperate,
|
externalAdminUserOperate,
|
||||||
paymentRead, paymentBillExport, paymentBillRefresh, paymentOperationsManage,
|
paymentRead, paymentBillExport, paymentBillRefresh, paymentOperationsManage,
|
||||||
activityRead, activityManage,
|
activityRead, activityManage,
|
||||||
|
|||||||
@ -204,6 +204,8 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
|
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
|
||||||
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
|
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
|
||||||
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"},
|
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"},
|
||||||
|
{Name: "幸运礼物奖池注资", Code: "lucky-gift:pool-credit", Kind: "button"},
|
||||||
|
{Name: "幸运礼物奖池扣资", Code: "lucky-gift:pool-debit", Kind: "button"},
|
||||||
{Name: "转盘抽奖查看", Code: "wheel:view", Kind: "menu"},
|
{Name: "转盘抽奖查看", Code: "wheel:view", Kind: "menu"},
|
||||||
{Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"},
|
{Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"},
|
||||||
{Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"},
|
{Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"},
|
||||||
|
|||||||
@ -160,6 +160,21 @@ func TestManagedDefaultRoleModuleBoundaries(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftPermissionsStayInManagedRoleSync(t *testing.T) {
|
||||||
|
assertRolePermissions(t, roleCodeOperationsLead,
|
||||||
|
[]string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, nil,
|
||||||
|
)
|
||||||
|
assertRolePermissions(t, roleCodeOperationsSpecialist,
|
||||||
|
[]string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||||
|
)
|
||||||
|
assertRolePermissions(t, roleCodeProductLead,
|
||||||
|
[]string{"lucky-gift:view", "lucky-gift:update"}, []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||||
|
)
|
||||||
|
assertRolePermissions(t, roleCodeProductSpecialist,
|
||||||
|
[]string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) {
|
func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) {
|
||||||
content, err := os.ReadFile("../../migrations/093_role_permission_matrix.sql")
|
content, err := os.ReadFile("../../migrations/093_role_permission_matrix.sql")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -327,6 +342,22 @@ func TestExternalAdminPermissionAssignmentMigrationIsIncrementalAndPlatformOnly(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftPoolAdjustmentPermissionMigrationRepairsManagedRoles(t *testing.T) {
|
||||||
|
content, err := os.ReadFile("../../migrations/098_lucky_gift_pool_adjust_permissions.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read lucky gift pool permission migration: %v", err)
|
||||||
|
}
|
||||||
|
sqlText := string(content)
|
||||||
|
for _, token := range []string{
|
||||||
|
"'lucky-gift:view'", "'lucky-gift:update'", "'lucky-gift:pool-credit'", "'lucky-gift:pool-debit'",
|
||||||
|
"'platform-admin', 'ops-admin'", "'operations-specialist'", "'product-lead'", "'product-specialist'",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(sqlText, token) {
|
||||||
|
t.Fatalf("lucky gift pool permission migration missing %s", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
|
func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)
|
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)
|
||||||
@ -343,6 +374,7 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin
|
|||||||
"activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish",
|
"activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish",
|
||||||
"activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry",
|
"activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry",
|
||||||
"external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password",
|
"external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password",
|
||||||
|
"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit",
|
||||||
})
|
})
|
||||||
if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist {
|
if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist {
|
||||||
// 093 仍记录旧的财务提现权限;102 才把固定运营岗位切换到独立运营审核权限。
|
// 093 仍记录旧的财务提现权限;102 才把固定运营岗位切换到独立运营审核权限。
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
-- 奖池人工增减是资金高风险动作,使用独立按钮权限;迁移仅按唯一 code 连接小型 RBAC 元数据表,不扫描业务流水。
|
||||||
|
SET @now_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('幸运礼物奖池注资', 'lucky-gift:pool-credit', 'button', '允许对指定策略奖池执行带幂等审计的人工注资', @now_ms, @now_ms),
|
||||||
|
('幸运礼物奖池扣资', 'lucky-gift:pool-debit', 'button', '允许在安全水位之上执行带幂等审计的人工扣资', @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
-- 093 的岗位同步矩阵曾漏掉既有 lucky-gift:view/update;增量迁移按当前岗位边界补齐,避免下次同步再次丢失。
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT role.id, permission.id
|
||||||
|
FROM admin_roles role
|
||||||
|
JOIN admin_permissions permission
|
||||||
|
WHERE role.code IN ('platform-admin', 'ops-admin', 'operations-specialist', 'product-lead', 'product-specialist')
|
||||||
|
AND permission.code = 'lucky-gift:view';
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT role.id, permission.id
|
||||||
|
FROM admin_roles role
|
||||||
|
JOIN admin_permissions permission
|
||||||
|
WHERE role.code IN ('platform-admin', 'ops-admin', 'product-lead')
|
||||||
|
AND permission.code = 'lucky-gift:update';
|
||||||
|
|
||||||
|
-- 实际资金操作只授予超级管理员和运营负责人;查看/规则编辑权限不能隐式获得调账能力。
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT role.id, permission.id
|
||||||
|
FROM admin_roles role
|
||||||
|
JOIN admin_permissions permission
|
||||||
|
WHERE role.code IN ('platform-admin', 'ops-admin')
|
||||||
|
AND permission.code IN ('lucky-gift:pool-credit', 'lucky-gift:pool-debit');
|
||||||
@ -14,6 +14,7 @@ type SendCommand struct {
|
|||||||
AppCode string
|
AppCode string
|
||||||
RequestID string
|
RequestID string
|
||||||
ExternalUserID string
|
ExternalUserID string
|
||||||
|
DeviceID string
|
||||||
GiftCount int64
|
GiftCount int64
|
||||||
UnitAmount int64
|
UnitAmount int64
|
||||||
TotalAmount int64
|
TotalAmount int64
|
||||||
@ -59,6 +60,7 @@ func (c *GRPCClient) SendLuckyGift(ctx context.Context, cmd SendCommand) (SendRe
|
|||||||
},
|
},
|
||||||
AppCode: cmd.AppCode,
|
AppCode: cmd.AppCode,
|
||||||
ExternalUserId: cmd.ExternalUserID,
|
ExternalUserId: cmd.ExternalUserID,
|
||||||
|
DeviceId: cmd.DeviceID,
|
||||||
RequestId: cmd.RequestID,
|
RequestId: cmd.RequestID,
|
||||||
GiftCount: cmd.GiftCount,
|
GiftCount: cmd.GiftCount,
|
||||||
UnitAmount: cmd.UnitAmount,
|
UnitAmount: cmd.UnitAmount,
|
||||||
|
|||||||
@ -17,20 +17,25 @@ type Handler struct {
|
|||||||
client luckygiftclient.Client
|
client luckygiftclient.Client
|
||||||
allowedApps map[string]struct{}
|
allowedApps map[string]struct{}
|
||||||
requestTimeout time.Duration
|
requestTimeout time.Duration
|
||||||
now func() time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(client luckygiftclient.Client, requestTimeout time.Duration, allowedApps map[string]struct{}) *Handler {
|
func New(client luckygiftclient.Client, requestTimeout time.Duration, allowedApps map[string]struct{}) *Handler {
|
||||||
if requestTimeout <= 0 {
|
if requestTimeout <= 0 {
|
||||||
requestTimeout = 3 * time.Second
|
requestTimeout = 3 * time.Second
|
||||||
}
|
}
|
||||||
return &Handler{client: client, allowedApps: cloneAllowedApps(allowedApps), requestTimeout: requestTimeout, now: time.Now}
|
return &Handler{client: client, allowedApps: cloneAllowedApps(allowedApps), requestTimeout: requestTimeout}
|
||||||
}
|
}
|
||||||
|
|
||||||
type sendRequest struct {
|
type sendRequest struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
ExternalUserID string `json:"external_user_id"`
|
ExternalUserID string `json:"external_user_id"`
|
||||||
|
// DeviceID 由外部 App 在自己的认证/请求签名边界内绑定。
|
||||||
|
// luck-gateway 目前只做 App allowlist,不能独立证明该值来自真实设备;dynamic_v3 由 owner 对缺失值 fail-close。
|
||||||
|
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"`
|
GiftCount int64 `json:"gift_count"`
|
||||||
UnitAmount int64 `json:"unit_amount"`
|
UnitAmount int64 `json:"unit_amount"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
@ -63,14 +68,14 @@ func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
|
|||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
RequestID: strings.TrimSpace(req.RequestID),
|
RequestID: strings.TrimSpace(req.RequestID),
|
||||||
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
|
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
|
||||||
|
DeviceID: strings.TrimSpace(req.DeviceID),
|
||||||
GiftCount: req.GiftCount,
|
GiftCount: req.GiftCount,
|
||||||
UnitAmount: req.UnitAmount,
|
UnitAmount: req.UnitAmount,
|
||||||
TotalAmount: req.GiftCount * req.UnitAmount,
|
TotalAmount: req.GiftCount * req.UnitAmount,
|
||||||
Currency: strings.TrimSpace(req.Currency),
|
Currency: strings.TrimSpace(req.Currency),
|
||||||
MetadataJSON: metadataJSON(req.Metadata),
|
MetadataJSON: metadataJSON(req.Metadata),
|
||||||
// 外部 HTTP 契约不暴露 pool_id/paid_at_ms;pool 由 owner service 默认规则选择,支付完成时间使用网关服务端 UTC 时间,
|
// pool 仍由 owner 默认规则选择;paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。
|
||||||
// 避免外部 App 通过请求体影响奖池分桶、预算日或风控窗口。
|
PaidAtMS: req.PaidAtMS,
|
||||||
PaidAtMS: normalizePaidAt(h.now),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Fail(w, http.StatusInternalServerError, response.CodeServerError, requestID, "send lucky gift failed")
|
response.Fail(w, http.StatusInternalServerError, response.CodeServerError, requestID, "send lucky gift failed")
|
||||||
@ -98,9 +103,20 @@ func (r sendRequest) validate() error {
|
|||||||
if len(strings.TrimSpace(r.ExternalUserID)) > 128 {
|
if len(strings.TrimSpace(r.ExternalUserID)) > 128 {
|
||||||
return errors.New("external_user_id is too long")
|
return errors.New("external_user_id is too long")
|
||||||
}
|
}
|
||||||
|
if len(strings.TrimSpace(r.DeviceID)) > 128 {
|
||||||
|
return errors.New("device_id is too long")
|
||||||
|
}
|
||||||
|
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.GiftCount <= 0 || r.UnitAmount <= 0 {
|
if r.GiftCount <= 0 || r.UnitAmount <= 0 {
|
||||||
return errors.New("gift_count and unit_amount must be positive")
|
return errors.New("gift_count and unit_amount must be positive")
|
||||||
}
|
}
|
||||||
|
if r.GiftCount > 999 {
|
||||||
|
return errors.New("gift_count cannot exceed 999")
|
||||||
|
}
|
||||||
// 网关只接受数量和单价;total_amount 由服务端计算,避免外部 App SDK 或恶意调用方传入不一致账面金额。
|
// 网关只接受数量和单价;total_amount 由服务端计算,避免外部 App SDK 或恶意调用方传入不一致账面金额。
|
||||||
if r.GiftCount > (1<<63-1)/r.UnitAmount {
|
if r.GiftCount > (1<<63-1)/r.UnitAmount {
|
||||||
return errors.New("gift amount is too large")
|
return errors.New("gift amount is too large")
|
||||||
@ -127,10 +143,6 @@ func metadataJSON(value map[string]any) string {
|
|||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePaidAt(now func() time.Time) int64 {
|
|
||||||
return now().UTC().UnixMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) isAllowedApp(appCode string) bool {
|
func (h *Handler) isAllowedApp(appCode string) bool {
|
||||||
if len(h.allowedApps) == 0 {
|
if len(h.allowedApps) == 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -30,7 +30,7 @@ func (f *fakeClient) SendLuckyGift(_ context.Context, cmd luckygiftclient.SendCo
|
|||||||
func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
|
func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
|
||||||
client := &fakeClient{}
|
client := &fakeClient{}
|
||||||
handler := testRouter(client, "aslan,yumi")
|
handler := testRouter(client, "aslan,yumi")
|
||||||
body := []byte(`{"app_code":"Aslan","request_id":"req-1","external_user_id":"u-1001","gift_count":3,"unit_amount":7,"currency":"COIN","metadata":{"gift":"rose"}}`)
|
body := []byte(`{"app_code":"Aslan","request_id":"req-1","external_user_id":"u-1001","device_id":"device-attested-by-aslan","paid_at_ms":1700000000000,"gift_count":3,"unit_amount":7,"currency":"COIN","metadata":{"gift":"rose"}}`)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
@ -41,12 +41,27 @@ func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
|
|||||||
if client.calls != 1 {
|
if client.calls != 1 {
|
||||||
t.Fatalf("client calls=%d", client.calls)
|
t.Fatalf("client calls=%d", client.calls)
|
||||||
}
|
}
|
||||||
if client.got.AppCode != "aslan" || client.got.RequestID != "req-1" || client.got.TotalAmount != 21 || client.got.GiftCount != 3 {
|
if client.got.AppCode != "aslan" || client.got.RequestID != "req-1" || client.got.DeviceID != "device-attested-by-aslan" || client.got.PaidAtMS != 1700000000000 || client.got.TotalAmount != 21 || client.got.GiftCount != 3 {
|
||||||
t.Fatalf("unexpected command: %+v", client.got)
|
t.Fatalf("unexpected command: %+v", client.got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendRejectsPoolAndPaidAtOverrides(t *testing.T) {
|
func TestSendKeepsMissingDeviceCompatibleForFixedV2OwnerDecision(t *testing.T) {
|
||||||
|
client := &fakeClient{}
|
||||||
|
handler := testRouter(client, "aslan")
|
||||||
|
// luck-gateway 不读规则版本,因此旧 fixed_v2 调用方可继续缺省 device_id;
|
||||||
|
// 如果本 App 已发布 dynamic_v3,lucky-gift owner 会在规则事务内明确拒绝。
|
||||||
|
body := []byte(`{"app_code":"aslan","request_id":"req-fixed-legacy","external_user_id":"u-legacy","gift_count":1,"unit_amount":7}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK || client.calls != 1 || client.got.DeviceID != "" {
|
||||||
|
t.Fatalf("fixed-compatible request status=%d calls=%d command=%+v body=%s", rec.Code, client.calls, client.got, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendRejectsPoolOverride(t *testing.T) {
|
||||||
client := &fakeClient{}
|
client := &fakeClient{}
|
||||||
handler := testRouter(client, "aslan")
|
handler := testRouter(client, "aslan")
|
||||||
body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1001","pool_id":"pool-1","paid_at_ms":1700000000000,"gift_count":1,"unit_amount":7}`)
|
body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1001","pool_id":"pool-1","paid_at_ms":1700000000000,"gift_count":1,"unit_amount":7}`)
|
||||||
@ -62,6 +77,19 @@ func TestSendRejectsPoolAndPaidAtOverrides(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendRejectsNegativePaidAt(t *testing.T) {
|
||||||
|
client := &fakeClient{}
|
||||||
|
handler := testRouter(client, "aslan")
|
||||||
|
body := []byte(`{"app_code":"aslan","request_id":"req-negative-paid-at","external_user_id":"u-1001","paid_at_ms":-1,"gift_count":1,"unit_amount":7}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest || client.calls != 0 {
|
||||||
|
t.Fatalf("negative paid_at_ms status=%d calls=%d body=%s", rec.Code, client.calls, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendRejectsDisallowedAppBeforeClientCall(t *testing.T) {
|
func TestSendRejectsDisallowedAppBeforeClientCall(t *testing.T) {
|
||||||
client := &fakeClient{}
|
client := &fakeClient{}
|
||||||
handler := testRouter(client, "aslan,yumi")
|
handler := testRouter(client, "aslan,yumi")
|
||||||
@ -100,6 +128,19 @@ func TestSendRejectsInvalidAmount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendRejectsMoreThan999SequentialDraws(t *testing.T) {
|
||||||
|
client := &fakeClient{}
|
||||||
|
handler := testRouter(client, "aslan")
|
||||||
|
body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1","gift_count":1000,"unit_amount":1}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest || client.calls != 0 {
|
||||||
|
t.Fatalf("oversized sequential draw status=%d calls=%d body=%s", rec.Code, client.calls, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendRejectsTooLongExternalFields(t *testing.T) {
|
func TestSendRejectsTooLongExternalFields(t *testing.T) {
|
||||||
client := &fakeClient{}
|
client := &fakeClient{}
|
||||||
handler := testRouter(client, "aslan")
|
handler := testRouter(client, "aslan")
|
||||||
|
|||||||
@ -21,84 +21,72 @@ tasks:
|
|||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
user_region_rebuild:
|
user_region_rebuild:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "5s"
|
timeout: "5s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
message_fanout:
|
message_fanout:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "1s"
|
interval: "1s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
vip_daily_coin_rebate:
|
vip_daily_coin_rebate:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "1m"
|
interval: "1m"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
growth_level_reward:
|
growth_level_reward:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
temporary_growth_level:
|
temporary_growth_level:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "15s"
|
timeout: "15s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
weekly_star_settlement:
|
weekly_star_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
activity_template_settlement:
|
activity_template_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
agency_opening_settlement:
|
agency_opening_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
room_turnover_reward_settlement:
|
room_turnover_reward_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5m"
|
interval: "5m"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "5m"
|
lock_ttl: "5m"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
cp_intimacy_leaderboard:
|
cp_intimacy_leaderboard:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5m"
|
interval: "5m"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "5m"
|
lock_ttl: "5m"
|
||||||
batch_size: 10000
|
batch_size: 10000
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
game_level_event_relay:
|
game_level_event_relay:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
mic_open_session_compensation:
|
mic_open_session_compensation:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "60s"
|
interval: "60s"
|
||||||
@ -107,7 +95,6 @@ tasks:
|
|||||||
batch_size: 100
|
batch_size: 100
|
||||||
pending_publish_max_age: "2m"
|
pending_publish_max_age: "2m"
|
||||||
publishing_session_max_age: "12h"
|
publishing_session_max_age: "12h"
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
room_open_session_compensation:
|
room_open_session_compensation:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "60s"
|
interval: "60s"
|
||||||
@ -115,18 +102,15 @@ tasks:
|
|||||||
lock_ttl: "60s"
|
lock_ttl: "60s"
|
||||||
batch_size: 200
|
batch_size: 200
|
||||||
open_session_max_age: "24h"
|
open_session_max_age: "24h"
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
manager_user_block_expiry:
|
manager_user_block_expiry:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "30s"
|
interval: "30s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
admin_user_ban_expiry:
|
admin_user_ban_expiry:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "30s"
|
interval: "30s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
|
|||||||
@ -21,84 +21,72 @@ tasks:
|
|||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
user_region_rebuild:
|
user_region_rebuild:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "5s"
|
timeout: "5s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
message_fanout:
|
message_fanout:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "1s"
|
interval: "1s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
vip_daily_coin_rebate:
|
vip_daily_coin_rebate:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "1m"
|
interval: "1m"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
growth_level_reward:
|
growth_level_reward:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
temporary_growth_level:
|
temporary_growth_level:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "15s"
|
timeout: "15s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
weekly_star_settlement:
|
weekly_star_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
activity_template_settlement:
|
activity_template_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
agency_opening_settlement:
|
agency_opening_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
room_turnover_reward_settlement:
|
room_turnover_reward_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5m"
|
interval: "5m"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "5m"
|
lock_ttl: "5m"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
cp_intimacy_leaderboard:
|
cp_intimacy_leaderboard:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5m"
|
interval: "5m"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "5m"
|
lock_ttl: "5m"
|
||||||
batch_size: 10000
|
batch_size: 10000
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
game_level_event_relay:
|
game_level_event_relay:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
mic_open_session_compensation:
|
mic_open_session_compensation:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "60s"
|
interval: "60s"
|
||||||
@ -107,7 +95,6 @@ tasks:
|
|||||||
batch_size: 100
|
batch_size: 100
|
||||||
pending_publish_max_age: "2m"
|
pending_publish_max_age: "2m"
|
||||||
publishing_session_max_age: "12h"
|
publishing_session_max_age: "12h"
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
room_open_session_compensation:
|
room_open_session_compensation:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "60s"
|
interval: "60s"
|
||||||
@ -115,18 +102,15 @@ tasks:
|
|||||||
lock_ttl: "60s"
|
lock_ttl: "60s"
|
||||||
batch_size: 200
|
batch_size: 200
|
||||||
open_session_max_age: "24h"
|
open_session_max_age: "24h"
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
manager_user_block_expiry:
|
manager_user_block_expiry:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "30s"
|
interval: "30s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
admin_user_ban_expiry:
|
admin_user_ban_expiry:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "30s"
|
interval: "30s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
|
|||||||
@ -21,84 +21,72 @@ tasks:
|
|||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
user_region_rebuild:
|
user_region_rebuild:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "5s"
|
timeout: "5s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
message_fanout:
|
message_fanout:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "1s"
|
interval: "1s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
vip_daily_coin_rebate:
|
vip_daily_coin_rebate:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "1m"
|
interval: "1m"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 500
|
batch_size: 500
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
growth_level_reward:
|
growth_level_reward:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
temporary_growth_level:
|
temporary_growth_level:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "15s"
|
timeout: "15s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
weekly_star_settlement:
|
weekly_star_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
activity_template_settlement:
|
activity_template_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
agency_opening_settlement:
|
agency_opening_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "1m"
|
lock_ttl: "1m"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
room_turnover_reward_settlement:
|
room_turnover_reward_settlement:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5m"
|
interval: "5m"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
lock_ttl: "5m"
|
lock_ttl: "5m"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
cp_intimacy_leaderboard:
|
cp_intimacy_leaderboard:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5m"
|
interval: "5m"
|
||||||
timeout: "20s"
|
timeout: "20s"
|
||||||
lock_ttl: "5m"
|
lock_ttl: "5m"
|
||||||
batch_size: 10000
|
batch_size: 10000
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
game_level_event_relay:
|
game_level_event_relay:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
mic_open_session_compensation:
|
mic_open_session_compensation:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "60s"
|
interval: "60s"
|
||||||
@ -107,7 +95,6 @@ tasks:
|
|||||||
batch_size: 100
|
batch_size: 100
|
||||||
pending_publish_max_age: "2m"
|
pending_publish_max_age: "2m"
|
||||||
publishing_session_max_age: "12h"
|
publishing_session_max_age: "12h"
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
room_open_session_compensation:
|
room_open_session_compensation:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "60s"
|
interval: "60s"
|
||||||
@ -115,18 +102,15 @@ tasks:
|
|||||||
lock_ttl: "60s"
|
lock_ttl: "60s"
|
||||||
batch_size: 200
|
batch_size: 200
|
||||||
open_session_max_age: "24h"
|
open_session_max_age: "24h"
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
manager_user_block_expiry:
|
manager_user_block_expiry:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "30s"
|
interval: "30s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
admin_user_ban_expiry:
|
admin_user_ban_expiry:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "30s"
|
interval: "30s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu","fami","huwaa"]
|
|
||||||
|
|||||||
@ -110,6 +110,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
|
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
|
||||||
|
appRegistry := integration.NewAppRegistryClient(userv1.NewAppRegistryServiceClient(userConn))
|
||||||
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
|
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
|
||||||
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
|
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
|
||||||
walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn))
|
walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn))
|
||||||
@ -136,7 +137,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
"vip_daily_coin_rebate": walletCron.ProcessVIPDailyCoinRebateBatch,
|
"vip_daily_coin_rebate": walletCron.ProcessVIPDailyCoinRebateBatch,
|
||||||
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,
|
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,
|
||||||
"host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch,
|
"host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch,
|
||||||
})
|
}, appRegistry)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
server: server,
|
server: server,
|
||||||
|
|||||||
@ -4,13 +4,10 @@ package config
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
|
||||||
"hyapp/pkg/configx"
|
"hyapp/pkg/configx"
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultAppCode = "lalu"
|
|
||||||
|
|
||||||
// Config describes cron-service startup and schedule settings.
|
// Config describes cron-service startup and schedule settings.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// ServiceName is written into logs and health metadata.
|
// ServiceName is written into logs and health metadata.
|
||||||
@ -51,8 +48,6 @@ type TaskConfig struct {
|
|||||||
LockTTL string `yaml:"lock_ttl"`
|
LockTTL string `yaml:"lock_ttl"`
|
||||||
// BatchSize is passed to owner service batch RPCs.
|
// BatchSize is passed to owner service batch RPCs.
|
||||||
BatchSize int `yaml:"batch_size"`
|
BatchSize int `yaml:"batch_size"`
|
||||||
// AppCodes scopes the task by tenant; every app_code gets an independent run.
|
|
||||||
AppCodes []string `yaml:"app_codes"`
|
|
||||||
// PendingPublishMaxAge is used by mic_open_session_compensation for pending_publish sessions.
|
// PendingPublishMaxAge is used by mic_open_session_compensation for pending_publish sessions.
|
||||||
PendingPublishMaxAge string `yaml:"pending_publish_max_age"`
|
PendingPublishMaxAge string `yaml:"pending_publish_max_age"`
|
||||||
// PublishingSessionMaxAge is used by mic_open_session_compensation for publishing sessions.
|
// PublishingSessionMaxAge is used by mic_open_session_compensation for publishing sessions.
|
||||||
@ -132,7 +127,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "3s",
|
Timeout: "3s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"user_region_rebuild": {
|
"user_region_rebuild": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -140,7 +134,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "5s",
|
Timeout: "5s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 500,
|
BatchSize: 500,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"message_fanout": {
|
"message_fanout": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -148,7 +141,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "10s",
|
Timeout: "10s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 500,
|
BatchSize: 500,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"vip_daily_coin_rebate": {
|
"vip_daily_coin_rebate": {
|
||||||
// scheduler 启动即执行,因此不能用 24h 间隔假装 UTC 0 点;每分钟触发一次,
|
// scheduler 启动即执行,因此不能用 24h 间隔假装 UTC 0 点;每分钟触发一次,
|
||||||
@ -158,7 +150,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "30s",
|
Timeout: "30s",
|
||||||
LockTTL: "1m",
|
LockTTL: "1m",
|
||||||
BatchSize: 500,
|
BatchSize: 500,
|
||||||
AppCodes: []string{"lalu", "fami"},
|
|
||||||
},
|
},
|
||||||
"growth_level_reward": {
|
"growth_level_reward": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -166,7 +157,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "10s",
|
Timeout: "10s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"temporary_growth_level": {
|
"temporary_growth_level": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -174,7 +164,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "15s",
|
Timeout: "15s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"achievement_reward": {
|
"achievement_reward": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -182,7 +171,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "10s",
|
Timeout: "10s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"weekly_star_settlement": {
|
"weekly_star_settlement": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -190,7 +178,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "20s",
|
Timeout: "20s",
|
||||||
LockTTL: "1m",
|
LockTTL: "1m",
|
||||||
BatchSize: 50,
|
BatchSize: 50,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"activity_template_settlement": {
|
"activity_template_settlement": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -198,7 +185,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "30s",
|
Timeout: "30s",
|
||||||
LockTTL: "1m",
|
LockTTL: "1m",
|
||||||
BatchSize: 50,
|
BatchSize: 50,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"agency_opening_settlement": {
|
"agency_opening_settlement": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -206,7 +192,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "20s",
|
Timeout: "20s",
|
||||||
LockTTL: "1m",
|
LockTTL: "1m",
|
||||||
BatchSize: 50,
|
BatchSize: 50,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"cp_weekly_rank_settlement": {
|
"cp_weekly_rank_settlement": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -214,7 +199,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "30s",
|
Timeout: "30s",
|
||||||
LockTTL: "5m",
|
LockTTL: "5m",
|
||||||
BatchSize: 50,
|
BatchSize: 50,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"room_turnover_reward_settlement": {
|
"room_turnover_reward_settlement": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -222,7 +206,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "30s",
|
Timeout: "30s",
|
||||||
LockTTL: "5m",
|
LockTTL: "5m",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"cp_intimacy_leaderboard": {
|
"cp_intimacy_leaderboard": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -230,7 +213,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "20s",
|
Timeout: "20s",
|
||||||
LockTTL: "5m",
|
LockTTL: "5m",
|
||||||
BatchSize: 10000,
|
BatchSize: 10000,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"game_level_event_relay": {
|
"game_level_event_relay": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -238,7 +220,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "10s",
|
Timeout: "10s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"host_salary_daily_settlement": {
|
"host_salary_daily_settlement": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -246,7 +227,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "30s",
|
Timeout: "30s",
|
||||||
LockTTL: "2m",
|
LockTTL: "2m",
|
||||||
BatchSize: 200,
|
BatchSize: 200,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"host_salary_half_month_settlement": {
|
"host_salary_half_month_settlement": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -254,7 +234,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "30s",
|
Timeout: "30s",
|
||||||
LockTTL: "2m",
|
LockTTL: "2m",
|
||||||
BatchSize: 200,
|
BatchSize: 200,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"host_salary_month_end": {
|
"host_salary_month_end": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -262,7 +241,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "60s",
|
Timeout: "60s",
|
||||||
LockTTL: "5m",
|
LockTTL: "5m",
|
||||||
BatchSize: 200,
|
BatchSize: 200,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"mic_open_session_compensation": {
|
"mic_open_session_compensation": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -270,7 +248,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "10s",
|
Timeout: "10s",
|
||||||
LockTTL: "60s",
|
LockTTL: "60s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
PendingPublishMaxAge: "2m",
|
PendingPublishMaxAge: "2m",
|
||||||
PublishingSessionMaxAge: "12h",
|
PublishingSessionMaxAge: "12h",
|
||||||
},
|
},
|
||||||
@ -280,7 +257,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "10s",
|
Timeout: "10s",
|
||||||
LockTTL: "60s",
|
LockTTL: "60s",
|
||||||
BatchSize: 200,
|
BatchSize: 200,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
// stale worker 2 分钟兜底断线离房;24h 只兜 room-service 节点崩溃或 outbox 丢失的极端场景。
|
// stale worker 2 分钟兜底断线离房;24h 只兜 room-service 节点崩溃或 outbox 丢失的极端场景。
|
||||||
OpenSessionMaxAge: "24h",
|
OpenSessionMaxAge: "24h",
|
||||||
},
|
},
|
||||||
@ -290,7 +266,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "10s",
|
Timeout: "10s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
"admin_user_ban_expiry": {
|
"admin_user_ban_expiry": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -298,7 +273,6 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
Timeout: "10s",
|
Timeout: "10s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -322,7 +296,6 @@ func normalizeTasks(tasks map[string]TaskConfig) map[string]TaskConfig {
|
|||||||
Timeout: "5s",
|
Timeout: "5s",
|
||||||
LockTTL: "30s",
|
LockTTL: "30s",
|
||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
|
||||||
}, task)
|
}, task)
|
||||||
}
|
}
|
||||||
return tasks
|
return tasks
|
||||||
@ -356,23 +329,5 @@ func mergeTaskConfig(def TaskConfig, current TaskConfig) TaskConfig {
|
|||||||
if current.BatchSize <= 0 {
|
if current.BatchSize <= 0 {
|
||||||
current.BatchSize = def.BatchSize
|
current.BatchSize = def.BatchSize
|
||||||
}
|
}
|
||||||
current.AppCodes = normalizeAppCodes(current.AppCodes)
|
|
||||||
if len(current.AppCodes) == 0 {
|
|
||||||
current.AppCodes = def.AppCodes
|
|
||||||
}
|
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeAppCodes(values []string) []string {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
code := appcode.Normalize(value)
|
|
||||||
if code == "" || seen[code] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[code] = true
|
|
||||||
result = append(result, code)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
@ -33,12 +33,9 @@ func TestDefaultCPIntimacyLeaderboardCronTaskRunsEveryFiveMinutes(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultVIPDailyCoinRebatePollsBothApps(t *testing.T) {
|
func TestDefaultVIPDailyCoinRebateIsEnabled(t *testing.T) {
|
||||||
task, ok := Default().Tasks["vip_daily_coin_rebate"]
|
task, ok := Default().Tasks["vip_daily_coin_rebate"]
|
||||||
if !ok || !task.Enabled || task.Interval != "1m" || task.BatchSize != 500 {
|
if !ok || !task.Enabled || task.Interval != "1m" || task.BatchSize != 500 {
|
||||||
t.Fatalf("VIP daily coin rebate task mismatch: %+v", task)
|
t.Fatalf("VIP daily coin rebate task mismatch: %+v", task)
|
||||||
}
|
}
|
||||||
if len(task.AppCodes) != 2 || task.AppCodes[0] != "lalu" || task.AppCodes[1] != "fami" {
|
|
||||||
t.Fatalf("VIP rebate must be app-scoped for both configured apps: %+v", task.AppCodes)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
services/cron-service/internal/integration/app_registry.go
Normal file
30
services/cron-service/internal/integration/app_registry.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppRegistryClient keeps tenant discovery behind user-service, the owner of the App registry.
|
||||||
|
type AppRegistryClient struct {
|
||||||
|
client userv1.AppRegistryServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppRegistryClient(client userv1.AppRegistryServiceClient) *AppRegistryClient {
|
||||||
|
return &AppRegistryClient{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AppRegistryClient) ListEnabledAppCodes(ctx context.Context) ([]string, error) {
|
||||||
|
resp, err := c.client.ListApps(ctx, &userv1.ListAppsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
appCodes := make([]string, 0, len(resp.GetApps()))
|
||||||
|
for _, app := range resp.GetApps() {
|
||||||
|
if app.GetStatus() == "active" {
|
||||||
|
appCodes = append(appCodes, app.GetAppCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appCodes, nil
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package scheduler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@ -53,22 +54,35 @@ type BatchResult struct {
|
|||||||
// Handler executes one batch through an owner service client.
|
// Handler executes one batch through an owner service client.
|
||||||
type Handler func(ctx context.Context, request BatchRequest) (BatchResult, error)
|
type Handler func(ctx context.Context, request BatchRequest) (BatchResult, error)
|
||||||
|
|
||||||
|
// AppCodeProvider 返回当前启用租户;调度器缓存短时间快照,同时会自动发现后续启用的 App。
|
||||||
|
type AppCodeProvider interface {
|
||||||
|
ListEnabledAppCodes(ctx context.Context) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Scheduler owns task loops. It never implements domain logic directly.
|
// Scheduler owns task loops. It never implements domain logic directly.
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
nodeID string
|
nodeID string
|
||||||
tasks map[string]config.TaskConfig
|
tasks map[string]config.TaskConfig
|
||||||
store Store
|
store Store
|
||||||
handlers map[string]Handler
|
handlers map[string]Handler
|
||||||
|
appCodes AppCodeProvider
|
||||||
|
appMu sync.Mutex
|
||||||
|
appCache []string
|
||||||
|
appUntil time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a scheduler with explicit handlers for tasks that are already migrated.
|
// New creates a scheduler with explicit handlers for tasks that are already migrated.
|
||||||
func New(nodeID string, tasks map[string]config.TaskConfig, store Store, handlers map[string]Handler) *Scheduler {
|
func New(nodeID string, tasks map[string]config.TaskConfig, store Store, handlers map[string]Handler, providers ...AppCodeProvider) *Scheduler {
|
||||||
return &Scheduler{
|
scheduler := &Scheduler{
|
||||||
nodeID: strings.TrimSpace(nodeID),
|
nodeID: strings.TrimSpace(nodeID),
|
||||||
tasks: tasks,
|
tasks: tasks,
|
||||||
store: store,
|
store: store,
|
||||||
handlers: handlers,
|
handlers: handlers,
|
||||||
}
|
}
|
||||||
|
if len(providers) > 0 {
|
||||||
|
scheduler.appCodes = providers[0]
|
||||||
|
}
|
||||||
|
return scheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts one loop per enabled task and app_code until ctx is cancelled.
|
// Run starts one loop per enabled task and app_code until ctx is cancelled.
|
||||||
@ -94,20 +108,14 @@ func (s *Scheduler) Run(ctx context.Context) {
|
|||||||
logx.Warn(ctx, "cron_task_handler_missing", slog.String("task_name", taskName))
|
logx.Warn(ctx, "cron_task_handler_missing", slog.String("task_name", taskName))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, appCode := range task.AppCodes {
|
|
||||||
taskName := taskName
|
|
||||||
appCode := appCode
|
|
||||||
task := task
|
|
||||||
handler := handler
|
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
s.runTaskLoop(ctx, taskName, appCode, task, handler)
|
s.runTaskLoop(ctx, taskName, task, handler)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) {
|
func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, task config.TaskConfig, handler Handler) {
|
||||||
interval := parseDuration(task.Interval, 5*time.Second)
|
interval := parseDuration(task.Interval, 5*time.Second)
|
||||||
timer := time.NewTimer(0)
|
timer := time.NewTimer(0)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
@ -117,7 +125,27 @@ func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode st
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
hasMore := s.runOnce(ctx, taskName, appCode, task, handler)
|
appCodes, err := s.enabledAppCodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logx.Error(ctx, "cron_app_registry_failed", err, slog.String("task_name", taskName))
|
||||||
|
timer.Reset(interval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 每个 app_code 仍使用独立租约和运行记录;这里只把租户来源从 YAML 改成启用 App 注册表。
|
||||||
|
results := make(chan bool, len(appCodes))
|
||||||
|
var batch sync.WaitGroup
|
||||||
|
for _, appCode := range appCodes {
|
||||||
|
appCode := appCode
|
||||||
|
batch.Go(func() {
|
||||||
|
results <- s.runOnce(ctx, taskName, appCode, task, handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
batch.Wait()
|
||||||
|
close(results)
|
||||||
|
hasMore := false
|
||||||
|
for result := range results {
|
||||||
|
hasMore = hasMore || result
|
||||||
|
}
|
||||||
if hasMore {
|
if hasMore {
|
||||||
timer.Reset(0)
|
timer.Reset(0)
|
||||||
continue
|
continue
|
||||||
@ -127,6 +155,38 @@ func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) enabledAppCodes(ctx context.Context) ([]string, error) {
|
||||||
|
s.appMu.Lock()
|
||||||
|
defer s.appMu.Unlock()
|
||||||
|
if len(s.appCache) > 0 && time.Now().UTC().Before(s.appUntil) {
|
||||||
|
return append([]string(nil), s.appCache...), nil
|
||||||
|
}
|
||||||
|
if s.appCodes == nil {
|
||||||
|
return nil, errors.New("enabled app registry is not configured")
|
||||||
|
}
|
||||||
|
values, err := s.appCodes.ListEnabledAppCodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(values))
|
||||||
|
appCodes := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
appCodes = append(appCodes, value)
|
||||||
|
}
|
||||||
|
sort.Strings(appCodes)
|
||||||
|
if len(appCodes) == 0 {
|
||||||
|
return nil, errors.New("enabled app registry returned no tenants")
|
||||||
|
}
|
||||||
|
s.appCache = append(s.appCache[:0], appCodes...)
|
||||||
|
s.appUntil = time.Now().UTC().Add(30 * time.Second)
|
||||||
|
return append([]string(nil), appCodes...), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) bool {
|
func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) bool {
|
||||||
if s.store == nil {
|
if s.store == nil {
|
||||||
logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode))
|
logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode))
|
||||||
|
|||||||
@ -18,6 +18,12 @@ type synctestStore struct {
|
|||||||
finished []mysqlstorage.TaskRun
|
finished []mysqlstorage.TaskRun
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type staticAppCodeProvider []string
|
||||||
|
|
||||||
|
func (p staticAppCodeProvider) ListEnabledAppCodes(context.Context) ([]string, error) {
|
||||||
|
return append([]string(nil), p...), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *synctestStore) TryAcquireTaskLease(_ context.Context, _ string, _ string, _ string, _ int64, _ time.Duration) (bool, error) {
|
func (s *synctestStore) TryAcquireTaskLease(_ context.Context, _ string, _ string, _ string, _ int64, _ time.Duration) (bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@ -52,7 +58,6 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
|
|||||||
s := New("node-a", map[string]config.TaskConfig{
|
s := New("node-a", map[string]config.TaskConfig{
|
||||||
"room_outbox": {
|
"room_outbox": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
AppCodes: []string{"lalu"},
|
|
||||||
Interval: "1s",
|
Interval: "1s",
|
||||||
Timeout: "100ms",
|
Timeout: "100ms",
|
||||||
LockTTL: "5s",
|
LockTTL: "5s",
|
||||||
@ -68,7 +73,7 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
|
|||||||
runTimes = append(runTimes, time.Now())
|
runTimes = append(runTimes, time.Now())
|
||||||
return BatchResult{ProcessedCount: 1}, nil
|
return BatchResult{ProcessedCount: 1}, nil
|
||||||
},
|
},
|
||||||
})
|
}, staticAppCodeProvider{"lalu"})
|
||||||
|
|
||||||
go s.Run(ctx)
|
go s.Run(ctx)
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
@ -99,3 +104,21 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchedulerDiscoversEveryEnabledTenant(t *testing.T) {
|
||||||
|
s := New("node-a", nil, nil, nil, staticAppCodeProvider{" huwaa ", "lalu", "huwaa", "fami", ""})
|
||||||
|
|
||||||
|
appCodes, err := s.enabledAppCodes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("enabledAppCodes failed: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"fami", "huwaa", "lalu"}
|
||||||
|
if len(appCodes) != len(want) {
|
||||||
|
t.Fatalf("app code count mismatch: got=%v want=%v", appCodes, want)
|
||||||
|
}
|
||||||
|
for index := range want {
|
||||||
|
if appCodes[index] != want[index] {
|
||||||
|
t.Fatalf("app code mismatch: got=%v want=%v", appCodes, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -35,6 +35,8 @@ type leaderccAdapterConfig struct {
|
|||||||
UIDMode string `json:"uid_mode"`
|
UIDMode string `json:"uid_mode"`
|
||||||
DefaultLang string `json:"default_lang"`
|
DefaultLang string `json:"default_lang"`
|
||||||
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
|
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
|
||||||
|
// game_urls 由后台灵仙列表同步写入,解决同一平台下每款游戏 H5 地址不同的问题。
|
||||||
|
GameURLs map[string]string `json:"game_urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
|
func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
|
||||||
@ -50,9 +52,19 @@ func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
|
|||||||
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
|
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
|
||||||
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
|
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
|
||||||
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
|
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
|
||||||
|
config.GameURLs = normalizeStringMap(config.GameURLs)
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func leaderccLaunchBaseURL(game gamedomain.LaunchableGame, config leaderccAdapterConfig) string {
|
||||||
|
for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
|
||||||
|
if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (c leaderccAdapterConfig) TokenTTL() time.Duration {
|
func (c leaderccAdapterConfig) TokenTTL() time.Duration {
|
||||||
// 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。
|
// 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。
|
||||||
if c.TokenTTLSeconds > 0 {
|
if c.TokenTTLSeconds > 0 {
|
||||||
|
|||||||
@ -827,10 +827,15 @@ func normalizeRecentGamesPageSize(pageSize int32) int32 {
|
|||||||
|
|
||||||
func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
|
func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
|
||||||
base := strings.TrimSpace(game.APIBaseURL)
|
base := strings.TrimSpace(game.APIBaseURL)
|
||||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) && base == "" {
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
|
||||||
// 灵仙当前只需要我们提供回调域名和接口,不要求服务端拼 H5/Auth URL。
|
// 灵仙不同游戏使用独立 H5 地址;保留平台 URL 作为旧配置兜底。
|
||||||
|
if configuredBase := leaderccLaunchBaseURL(game, leaderccConfigFromPlatform(game)); configuredBase != "" {
|
||||||
|
base = configuredBase
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
||||||
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。
|
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。
|
||||||
if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {
|
if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {
|
||||||
|
|||||||
@ -277,6 +277,45 @@ func TestLaunchGameBuildsLeaderCCURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLaunchGameUsesLeaderCCPerGameURL(t *testing.T) {
|
||||||
|
repo := &fakeRepository{
|
||||||
|
launchable: gamedomain.LaunchableGame{
|
||||||
|
CatalogItem: gamedomain.CatalogItem{
|
||||||
|
AppCode: "lalu", GameID: "lingxian_9", PlatformCode: "lingxian",
|
||||||
|
ProviderGameID: "9", GameName: "GREEDY BOX", Status: gamedomain.StatusActive,
|
||||||
|
},
|
||||||
|
PlatformStatus: gamedomain.StatusActive,
|
||||||
|
AdapterType: gamedomain.AdapterLeaderCCV1,
|
||||||
|
AdapterConfigJSON: `{
|
||||||
|
"default_lang":"en","uid_mode":"display_user_id",
|
||||||
|
"game_urls":{"9":"https://gztest.leadercc.com/lalu_games/greedy_box_half/index.html?pl=lalu"}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
|
||||||
|
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||||
|
|
||||||
|
result, err := svc.LaunchGame(context.Background(), LaunchCommand{
|
||||||
|
AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "lingxian_9",
|
||||||
|
RoomID: "room_1", AccessToken: "app_access_token_lingxian",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LaunchGame failed: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(result.LaunchURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse launch URL: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Host != "gztest.leadercc.com" || parsed.Path != "/lalu_games/greedy_box_half/index.html" {
|
||||||
|
t.Fatalf("launch base mismatch: %s", result.LaunchURL)
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{"pl": "lalu", "uid": "420001", "token": "app_access_token_lingxian", "lang": "en", "roomid": "room_1"} {
|
||||||
|
if got := parsed.Query().Get(key); got != want {
|
||||||
|
t.Fatalf("query %s = %q, want %q URL=%s", key, got, want, result.LaunchURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchGameBuildsZeeOneURL(t *testing.T) {
|
func TestLaunchGameBuildsZeeOneURL(t *testing.T) {
|
||||||
repo := &fakeRepository{
|
repo := &fakeRepository{
|
||||||
launchable: gamedomain.LaunchableGame{
|
launchable: gamedomain.LaunchableGame{
|
||||||
|
|||||||
@ -27,6 +27,8 @@ type Claims struct {
|
|||||||
AppCode string
|
AppCode string
|
||||||
UserID int64
|
UserID int64
|
||||||
SessionID string
|
SessionID string
|
||||||
|
// DeviceID 是 user-service 从 auth_session 签入 access token 的设备绑定;旧 token 缺字段时保持为空。
|
||||||
|
DeviceID string
|
||||||
ProfileCompleted bool
|
ProfileCompleted bool
|
||||||
OnboardingStatus string
|
OnboardingStatus string
|
||||||
AccessTokenExpiresAtMS int64
|
AccessTokenExpiresAtMS int64
|
||||||
@ -93,6 +95,7 @@ func (v *Verifier) Verify(header string) (Claims, error) {
|
|||||||
profileCompleted, _ := claims["profile_completed"].(bool)
|
profileCompleted, _ := claims["profile_completed"].(bool)
|
||||||
onboardingStatus, _ := claims["onboarding_status"].(string)
|
onboardingStatus, _ := claims["onboarding_status"].(string)
|
||||||
sessionID, _ := claims["sid"].(string)
|
sessionID, _ := claims["sid"].(string)
|
||||||
|
deviceID, _ := claims["device_id"].(string)
|
||||||
appCode, _ := claims["app_code"].(string)
|
appCode, _ := claims["app_code"].(string)
|
||||||
expiresAtMS := claimUnixSecondsMS(claims["exp"])
|
expiresAtMS := claimUnixSecondsMS(claims["exp"])
|
||||||
|
|
||||||
@ -100,6 +103,7 @@ func (v *Verifier) Verify(header string) (Claims, error) {
|
|||||||
AppCode: appcode.Normalize(appCode),
|
AppCode: appcode.Normalize(appCode),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
SessionID: strings.TrimSpace(sessionID),
|
SessionID: strings.TrimSpace(sessionID),
|
||||||
|
DeviceID: strings.TrimSpace(deviceID),
|
||||||
ProfileCompleted: profileCompleted,
|
ProfileCompleted: profileCompleted,
|
||||||
OnboardingStatus: strings.TrimSpace(onboardingStatus),
|
OnboardingStatus: strings.TrimSpace(onboardingStatus),
|
||||||
AccessTokenExpiresAtMS: expiresAtMS,
|
AccessTokenExpiresAtMS: expiresAtMS,
|
||||||
@ -159,6 +163,13 @@ func SessionIDFromContext(ctx context.Context) string {
|
|||||||
return strings.TrimSpace(claims.SessionID)
|
return strings.TrimSpace(claims.SessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceIDFromContext 只返回已验签 access token 的设备 claim。
|
||||||
|
// 不从请求体、header 或 session_id 兜底,否则 dynamic_v3 设备日风控可被轻易分裂。
|
||||||
|
func DeviceIDFromContext(ctx context.Context) string {
|
||||||
|
claims, _ := ctx.Value(claimsContextKey).(Claims)
|
||||||
|
return strings.TrimSpace(claims.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
// ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。
|
// ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。
|
||||||
func ProfileCompletedFromContext(ctx context.Context) bool {
|
func ProfileCompletedFromContext(ctx context.Context) bool {
|
||||||
claims, _ := ctx.Value(claimsContextKey).(Claims)
|
claims, _ := ctx.Value(claimsContextKey).(Claims)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
@ -16,6 +17,7 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) {
|
|||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
"user_id": strconv.FormatInt(userID, 10),
|
"user_id": strconv.FormatInt(userID, 10),
|
||||||
"app_code": "lalu",
|
"app_code": "lalu",
|
||||||
|
"device_id": " device-auth-1 ",
|
||||||
})
|
})
|
||||||
signed, err := token.SignedString([]byte("secret"))
|
signed, err := token.SignedString([]byte("secret"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -29,6 +31,32 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) {
|
|||||||
if claims.UserID != userID {
|
if claims.UserID != userID {
|
||||||
t.Fatalf("user_id lost precision: got %d want %d", claims.UserID, userID)
|
t.Fatalf("user_id lost precision: got %d want %d", claims.UserID, userID)
|
||||||
}
|
}
|
||||||
|
if claims.DeviceID != "device-auth-1" {
|
||||||
|
t.Fatalf("device_id was not normalized from signed claim: got %q", claims.DeviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifierKeepsLegacyTokenWithoutDeviceIDCompatible(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 存量 access token 在滚动升级期不携带 device_id;gateway 仍应允许其进入 fixed_v2,
|
||||||
|
// 但绝不得把 sid 自动填为设备,dynamic_v3 会在 owner 处对空设备 fail-close。
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": strconv.FormatInt(42, 10),
|
||||||
|
"sid": "sess-must-not-be-device",
|
||||||
|
})
|
||||||
|
signed, err := token.SignedString([]byte("secret"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign legacy token failed: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := NewVerifier("secret").Verify("Bearer " + signed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify legacy token failed: %v", err)
|
||||||
|
}
|
||||||
|
ctx := WithClaims(context.Background(), claims)
|
||||||
|
if got := DeviceIDFromContext(ctx); got != "" {
|
||||||
|
t.Fatalf("legacy token device_id=%q, want empty instead of sid fallback", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerifierDistinguishesExpiredAccessTokenFromInvalidCredentials(t *testing.T) {
|
func TestVerifierDistinguishesExpiredAccessTokenFromInvalidCredentials(t *testing.T) {
|
||||||
|
|||||||
@ -242,6 +242,8 @@ func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.Re
|
|||||||
AppCode: appcode.FromContext(request.Context()),
|
AppCode: appcode.FromContext(request.Context()),
|
||||||
GatewayNodeId: "gateway-local",
|
GatewayNodeId: "gateway-local",
|
||||||
SessionId: sessionID,
|
SessionId: sessionID,
|
||||||
|
// 设备作用域只从已验签 JWT 读取;旧 JWT 返回空值,不用临时 session_id 兜底。
|
||||||
|
DeviceId: auth.DeviceIDFromContext(request.Context()),
|
||||||
SentAtMs: time.Now().UnixMilli(),
|
SentAtMs: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package httpkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoomMetaUsesOnlySignedJWTDeviceClaim(t *testing.T) {
|
||||||
|
request := httptest.NewRequest("POST", "/api/v1/rooms/gift/send", nil)
|
||||||
|
request = request.WithContext(auth.WithClaims(request.Context(), auth.Claims{
|
||||||
|
AppCode: "lalu", UserID: 42, SessionID: "sess-not-a-device", DeviceID: "device-auth-session-42",
|
||||||
|
}))
|
||||||
|
meta := RoomMeta(request, "room-1", "cmd-1")
|
||||||
|
if meta.GetDeviceId() != "device-auth-session-42" || meta.GetSessionId() != "sess-not-a-device" {
|
||||||
|
t.Fatalf("room meta auth snapshots mismatch: %+v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy := httptest.NewRequest("POST", "/api/v1/rooms/gift/send", nil)
|
||||||
|
legacy = legacy.WithContext(auth.WithClaims(legacy.Context(), auth.Claims{
|
||||||
|
AppCode: "lalu", UserID: 42, SessionID: "sess-legacy-must-not-be-device",
|
||||||
|
}))
|
||||||
|
legacyMeta := RoomMeta(legacy, "room-1", "cmd-legacy")
|
||||||
|
if legacyMeta.GetDeviceId() != "" {
|
||||||
|
// 存量 JWT 缺 device_id 时保留空值,fixed_v2 兼容,dynamic_v3 交给 owner fail-close。
|
||||||
|
t.Fatalf("legacy room meta derived fake device_id=%q", legacyMeta.GetDeviceId())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9611,8 +9611,11 @@ func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
|
|||||||
walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{
|
walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{
|
||||||
ServerTimeMs: 4000,
|
ServerTimeMs: 4000,
|
||||||
Packets: []*walletv1.RedPacket{
|
Packets: []*walletv1.RedPacket{
|
||||||
{PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active"},
|
{PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 100, RemainingCount: 1},
|
||||||
{PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"},
|
{PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"},
|
||||||
|
{PacketId: "packet-expired", RoomId: "room-1", RegionId: 1001, SenderUserId: 10004, PacketType: "normal", Status: "active", ExpiresAtMs: 4000, RemainingAmount: 100, RemainingCount: 1},
|
||||||
|
{PacketId: "packet-empty", RoomId: "room-1", RegionId: 1001, SenderUserId: 10005, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 0, RemainingCount: 0},
|
||||||
|
{PacketId: "packet-claimed", RoomId: "room-1", RegionId: 1001, SenderUserId: 10006, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 100, RemainingCount: 1, ClaimedByViewer: true},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||||
@ -9633,7 +9636,7 @@ func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
|
|||||||
t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets)
|
t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets)
|
||||||
}
|
}
|
||||||
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
|
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
|
||||||
t.Fatalf("active red-packet should batch only visible sender profiles: %+v", profileClient.lastBatch)
|
t.Fatalf("active red-packet should batch only currently claimable sender profiles: %+v", profileClient.lastBatch)
|
||||||
}
|
}
|
||||||
var response httpkit.ResponseEnvelope
|
var response httpkit.ResponseEnvelope
|
||||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
userv1 "hyapp.local/api/proto/user/v1"
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
@ -296,10 +297,17 @@ func (h *Handler) listActiveRedPackets(writer http.ResponseWriter, request *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
activePackets := make([]*walletv1.RedPacket, 0, len(resp.GetPackets()))
|
activePackets := make([]*walletv1.RedPacket, 0, len(resp.GetPackets()))
|
||||||
for _, packet := range resp.GetPackets() {
|
serverTimeMS := resp.GetServerTimeMs()
|
||||||
if packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open" {
|
if serverTimeMS <= 0 {
|
||||||
activePackets = append(activePackets, packet)
|
serverTimeMS = time.Now().UTC().UnixMilli()
|
||||||
}
|
}
|
||||||
|
for _, packet := range resp.GetPackets() {
|
||||||
|
statusActive := packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open"
|
||||||
|
// active 是“当前用户仍可领取”列表:状态、有效期、剩余份数和用户领取态必须同时满足。
|
||||||
|
if !statusActive || packet.GetExpiresAtMs() <= serverTimeMS || packet.GetRemainingCount() <= 0 || packet.GetRemainingAmount() <= 0 || packet.GetClaimedByViewer() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
activePackets = append(activePackets, packet)
|
||||||
}
|
}
|
||||||
profiles, err := h.redPacketSenderProfiles(request, activePackets)
|
profiles, err := h.redPacketSenderProfiles(request, activePackets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
143
services/lucky-gift-service/cmd/strategy-sim/main.go
Normal file
143
services/lucky-gift-service/cmd/strategy-sim/main.go
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
// Command strategy-sim runs the product matrix against the exact pure kernel used by
|
||||||
|
// production. It performs no database or network I/O; fixed seeds make every report
|
||||||
|
// reproducible in CI, during review, and after a rule-version change.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultSimulationSeed int64 = 20_260_712
|
||||||
|
|
||||||
|
type simulationOptions struct {
|
||||||
|
Seed int64
|
||||||
|
MonteCarloN int
|
||||||
|
LongRunN int
|
||||||
|
GroupLongRunN int
|
||||||
|
}
|
||||||
|
|
||||||
|
type simulationReport struct {
|
||||||
|
Seed int64 `json:"seed"`
|
||||||
|
MonteCarloDraws int `json:"monte_carlo_draws"`
|
||||||
|
LongRunDraws int `json:"long_run_draws"`
|
||||||
|
GroupLongRunDraws int `json:"group_long_run_draws"`
|
||||||
|
Nominal nominalEVReport `json:"nominal_probability_table"`
|
||||||
|
Scenarios []scenarioResult `json:"scenarios"`
|
||||||
|
AllPassed bool `json:"all_passed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nominalEVReport struct {
|
||||||
|
ExpectedMultiplier float64 `json:"expected_multiplier"`
|
||||||
|
NominalRTPPercent float64 `json:"nominal_rtp_percent"`
|
||||||
|
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||||||
|
PassesTarget bool `json:"passes_target"`
|
||||||
|
Conclusion string `json:"conclusion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type scenarioResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
options := simulationOptions{}
|
||||||
|
jsonOut := ""
|
||||||
|
flag.Int64Var(&options.Seed, "seed", defaultSimulationSeed, "deterministic simulation seed")
|
||||||
|
flag.IntVar(&options.MonteCarloN, "monte-carlo-draws", 100_000, "draws for probability Monte Carlo")
|
||||||
|
flag.IntVar(&options.LongRunN, "long-run-draws", 100_000, "draws for funded RTP long run")
|
||||||
|
flag.IntVar(&options.GroupLongRunN, "group-long-run-draws", 30_000, "draws per recharge-stage group")
|
||||||
|
flag.StringVar(&jsonOut, "json-out", "", "optional path for the same JSON report printed to stdout")
|
||||||
|
flag.Parse()
|
||||||
|
if options.MonteCarloN <= 0 || options.LongRunN <= 0 || options.GroupLongRunN <= 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "all draw counts must be positive")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := runSimulation(options)
|
||||||
|
encoded, err := json.MarshalIndent(report, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "encode report: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
printHumanSummary(report)
|
||||||
|
fmt.Println("\n--- JSON REPORT ---")
|
||||||
|
fmt.Println(string(encoded))
|
||||||
|
if jsonOut != "" {
|
||||||
|
if err := os.WriteFile(jsonOut, append(encoded, '\n'), 0o644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "write JSON report: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !report.AllPassed {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSimulation(options simulationOptions) simulationReport {
|
||||||
|
// The runtime default remains a safe 98% table. Only this simulator opts into
|
||||||
|
// the supplied image's 2650% table so the report can expose why it is unsafe.
|
||||||
|
config := imageExampleConfig(domain.DefaultLuckyGiftStrategyConfig())
|
||||||
|
nominal := calculateNominalEV(config)
|
||||||
|
scenarios := []scenarioResult{
|
||||||
|
simulateImage800Redraw(config),
|
||||||
|
simulatePWBoundaries(config),
|
||||||
|
simulateFundSplitAndColdStart(config),
|
||||||
|
simulateBatchCounts(config, options.Seed),
|
||||||
|
simulateSettlementWagerWindow(),
|
||||||
|
simulateWaterBoundaries(config),
|
||||||
|
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),
|
||||||
|
simulateJackpotSet(config, options.Seed+50),
|
||||||
|
simulateDailyJackpotLimit(config, options.Seed+60),
|
||||||
|
simulateSixRiskCapacities(config),
|
||||||
|
simulateRechargeStages(config),
|
||||||
|
simulateCombinationPriority(config, options.Seed+70),
|
||||||
|
simulateConcurrencyAndIdempotency(config, options.Seed+80),
|
||||||
|
simulateLongRunGroups(config, options.Seed+90, options.GroupLongRunN),
|
||||||
|
}
|
||||||
|
allPassed := !nominal.PassesTarget // 2650% must be explicitly rejected, not relabelled as a 98% pass.
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
allPassed = allPassed && scenario.Passed
|
||||||
|
}
|
||||||
|
return simulationReport{
|
||||||
|
Seed: options.Seed, MonteCarloDraws: options.MonteCarloN, LongRunDraws: options.LongRunN,
|
||||||
|
GroupLongRunDraws: options.GroupLongRunN, Nominal: nominal, Scenarios: scenarios, AllPassed: allPassed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateNominalEV(config domain.StrategyConfig) nominalEVReport {
|
||||||
|
var expected float64
|
||||||
|
for _, tier := range config.Tiers {
|
||||||
|
if tier.Enabled {
|
||||||
|
expected += float64(tier.BaseWeightPPM) / float64(domain.StrategyPPMScale) * float64(tier.MultiplierPPM) / float64(domain.StrategyPPMScale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nominalEVReport{
|
||||||
|
ExpectedMultiplier: expected, NominalRTPPercent: expected * 100, TargetRTPPercent: 98,
|
||||||
|
PassesTarget: expected <= 0.98,
|
||||||
|
Conclusion: "图示固定概率表的名义EV为26.5x(2650%),不能作为98% RTP方案;只有P/W资金约束后的有资金长跑口径可验证不透支。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printHumanSummary(report simulationReport) {
|
||||||
|
fmt.Printf("幸运礼物策略模拟 seed=%d\n", report.Seed)
|
||||||
|
fmt.Printf("名义概率表: EV=%.2fx, RTP=%.2f%%, 98%%校验=%v(预期必须为false)\n", report.Nominal.ExpectedMultiplier, report.Nominal.NominalRTPPercent, report.Nominal.PassesTarget)
|
||||||
|
for _, scenario := range report.Scenarios {
|
||||||
|
status := "PASS"
|
||||||
|
if !scenario.Passed {
|
||||||
|
status = "FAIL"
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %s: %s\n", status, scenario.Name, scenario.Summary)
|
||||||
|
}
|
||||||
|
fmt.Printf("总结果: all_passed=%v\n", report.AllPassed)
|
||||||
|
}
|
||||||
774
services/lucky-gift-service/cmd/strategy-sim/scenarios.go
Normal file
774
services/lucky-gift-service/cmd/strategy-sim/scenarios.go
Normal file
@ -0,0 +1,774 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scriptedRandom struct {
|
||||||
|
indexes []int64
|
||||||
|
bounds []int64
|
||||||
|
position int
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageExampleConfig is deliberately local to strategy-sim. Its ordinary weights
|
||||||
|
// have 26.5x EV and therefore must never be exposed as the domain's runtime default.
|
||||||
|
func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig {
|
||||||
|
// 模拟显式给出金币分层边界,避免把生产 disabled 草稿的 0/0 sentinel 当成可运行默认值。
|
||||||
|
config.RechargeStages = []domain.StrategyRechargeStage{
|
||||||
|
{Name: domain.StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
|
||||||
|
{Name: domain.StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
|
||||||
|
{Name: domain.StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
|
||||||
|
}
|
||||||
|
config.Tiers = []domain.StrategyTier{
|
||||||
|
{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},
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *scriptedRandom) Int63n(n int64) (int64, error) {
|
||||||
|
if r.position >= len(r.indexes) {
|
||||||
|
return 0, fmt.Errorf("scripted random exhausted at bound %d", n)
|
||||||
|
}
|
||||||
|
if len(r.bounds) > r.position && r.bounds[r.position] != n {
|
||||||
|
return 0, fmt.Errorf("scripted random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
|
||||||
|
}
|
||||||
|
value := r.indexes[r.position]
|
||||||
|
r.position++
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseProbabilityConfig isolates the image's ordinary probability table. Each
|
||||||
|
// dynamic/special rule has a separate scenario, so scripted intervals remain exact.
|
||||||
|
func baseProbabilityConfig(config domain.StrategyConfig) domain.StrategyConfig {
|
||||||
|
config.LowWaterThresholdCoins = 0
|
||||||
|
config.HighWaterThresholdCoins = math.MaxInt64
|
||||||
|
config.LowWaterFactorPPM = domain.StrategyPPMScale
|
||||||
|
config.HighWaterFactorPPM = domain.StrategyPPMScale
|
||||||
|
config.RechargeFactorPPM = domain.StrategyPPMScale
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
config.DailyJackpotLimit = math.MaxInt64
|
||||||
|
config.MilestoneSpendCoins = 0
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func failedScenario(name string, err error) scenarioResult {
|
||||||
|
return scenarioResult{Name: name, Passed: false, Summary: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateImage800Redraw(config domain.StrategyConfig) scenarioResult {
|
||||||
|
const name = "图示800重抽序列"
|
||||||
|
config = baseProbabilityConfig(config)
|
||||||
|
random := &scriptedRandom{indexes: []int64{950_000, 359_999, 0}, bounds: []int64{1_000_000, 360_000, 350_000}}
|
||||||
|
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 800}, domain.StrategyInput{GiftPriceCoins: 10}, random)
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
sequence := make([]string, 0, len(decision.Trace.Draws))
|
||||||
|
for _, draw := range decision.Trace.Draws {
|
||||||
|
sequence = append(sequence, draw.TierID)
|
||||||
|
}
|
||||||
|
passed := decision.PayoutCoins == 50 && decision.PoolAfterCoins == 750 && fmt.Sprint(sequence) == "[200x 1000x 5x]"
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: passed,
|
||||||
|
Summary: fmt.Sprintf("P=800,固定脚本命中%s,最终赔付=%d,池后=%d", sequence, decision.PayoutCoins, decision.PoolAfterCoins),
|
||||||
|
Data: map[string]any{"sequence": sequence, "trace": decision.Trace},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulatePWBoundaries(config domain.StrategyConfig) scenarioResult {
|
||||||
|
const name = "P=W边界/候选穷尽"
|
||||||
|
config = baseProbabilityConfig(config)
|
||||||
|
equal, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 2_000}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}})
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
exhausted, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{999_999, 350_000, 349_999, 0}, bounds: []int64{1_000_000, 390_000, 350_000, 200_000}})
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
passed := equal.PayoutCoins == 2_000 && equal.PoolAfterCoins == 0 && exhausted.PayoutCoins == 0 && exhausted.Trace.FinalReason == domain.StrategyReasonNoPayableTier && exhausted.PoolAfterCoins == 49
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: passed,
|
||||||
|
Summary: fmt.Sprintf("W=P赔付%d且池归零;P=49时正奖候选穷尽安全回0,池仍%d", equal.PayoutCoins, exhausted.PoolAfterCoins),
|
||||||
|
Data: map[string]any{"equality": equal.Trace, "exhaustion": exhausted.Trace},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateFundSplitAndColdStart(config domain.StrategyConfig) scenarioResult {
|
||||||
|
const name = "拆账与冷启动"
|
||||||
|
split, err := domain.SplitLuckyGiftStrategyFunds(100, config)
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
initial := domain.InitialLuckyGiftStrategyState(config)
|
||||||
|
small, err := domain.SplitLuckyGiftStrategyFunds(10, config)
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
conserved := split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins == 100
|
||||||
|
passed := split.PublicPoolCoins == 98 && split.ProfitPoolCoins == 1 && split.AnchorReturnCoins == 1 && small.PublicPoolCoins == 9 && small.ProfitPoolCoins == 1 && small.AnchorReturnCoins == 0 && conserved && initial.PoolBalanceCoins == 0
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: passed,
|
||||||
|
Summary: fmt.Sprintf("100金币拆为%d/%d/%d;10金币按公共池floor、余数归盈利拆为%d/%d/%d;动态池冷启动=%d,启动资金只能审计注资", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins),
|
||||||
|
Data: map[string]any{
|
||||||
|
"split_100": split, "split_10_rounding": small,
|
||||||
|
"rounding_policy": "public=floor, anchor=floor, residue=profit",
|
||||||
|
"conserved": conserved, "cold_start_state": initial,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchSummary struct {
|
||||||
|
Requested int `json:"requested"`
|
||||||
|
Executed int `json:"executed"`
|
||||||
|
Positive int `json:"positive"`
|
||||||
|
PayoutCoins int64 `json:"payout_coins"`
|
||||||
|
PoolAfter int64 `json:"pool_after"`
|
||||||
|
TierCounts map[string]int `json:"tier_counts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateBatchCounts(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||||
|
const name = "1/5/6/99批量"
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
config.MilestoneSpendCoins = 0
|
||||||
|
config.DailyJackpotLimit = math.MaxInt64
|
||||||
|
counts := []int{1, 5, 6, 99}
|
||||||
|
results := make([]batchSummary, 0, len(counts))
|
||||||
|
passed := true
|
||||||
|
for _, count := range counts {
|
||||||
|
state := domain.StrategyState{}
|
||||||
|
random := domain.NewSeededStrategyRandom(seed + int64(count))
|
||||||
|
summary := batchSummary{Requested: count, TierCounts: map[string]int{}}
|
||||||
|
for index := 0; index < count; index++ {
|
||||||
|
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
state = decision.NextState
|
||||||
|
summary.Executed++
|
||||||
|
summary.TierCounts[decision.SelectedTier.ID]++
|
||||||
|
summary.PayoutCoins += decision.PayoutCoins
|
||||||
|
if decision.PayoutCoins > 0 {
|
||||||
|
summary.Positive++
|
||||||
|
}
|
||||||
|
passed = passed && decision.PoolAfterCoins >= 0
|
||||||
|
}
|
||||||
|
summary.PoolAfter = state.PoolBalanceCoins
|
||||||
|
passed = passed && summary.Executed == count
|
||||||
|
results = append(results, summary)
|
||||||
|
}
|
||||||
|
return scenarioResult{Name: name, Passed: passed, Summary: "每份礼物独立推进一次状态,1/5/6/99均完整执行且任一中间池余额不负", Data: results}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateSettlementWagerWindow() scenarioResult {
|
||||||
|
const name = "真实流水结算窗口"
|
||||||
|
const (
|
||||||
|
settlementWager = int64(99)
|
||||||
|
referencePrice = int64(10)
|
||||||
|
totalSpend = int64(100)
|
||||||
|
drawCount = int64(99)
|
||||||
|
)
|
||||||
|
type window struct {
|
||||||
|
Index int64 `json:"index"`
|
||||||
|
Paid int64 `json:"paid_draws"`
|
||||||
|
Wager int64 `json:"wager_coins"`
|
||||||
|
}
|
||||||
|
// 10 抽但只有 98 流水是专门构造的反例:ceil(99/10)=10 的旧抽数算法会提前关窗,真实流水算法仍允许下一抽进入窗口 1。
|
||||||
|
windows := []window{{Index: 1, Paid: 10, Wager: 98}}
|
||||||
|
drawWindows := make([]int64, 0, drawCount)
|
||||||
|
for index := int64(1); index <= drawCount; index++ {
|
||||||
|
current := &windows[len(windows)-1]
|
||||||
|
if current.Wager >= settlementWager {
|
||||||
|
windows = append(windows, window{Index: current.Index + 1})
|
||||||
|
current = &windows[len(windows)-1]
|
||||||
|
}
|
||||||
|
unitSpend := totalSpend / drawCount
|
||||||
|
if index <= totalSpend%drawCount {
|
||||||
|
unitSpend++
|
||||||
|
}
|
||||||
|
current.Paid++
|
||||||
|
current.Wager += unitSpend
|
||||||
|
drawWindows = append(drawWindows, current.Index)
|
||||||
|
}
|
||||||
|
firstCount, secondCount := 0, 0
|
||||||
|
for _, index := range drawWindows {
|
||||||
|
if index == 1 {
|
||||||
|
firstCount++
|
||||||
|
} else if index == 2 {
|
||||||
|
secondCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passed := len(windows) == 2 && windows[0].Paid == 11 && windows[0].Wager == 100 && windows[1].Paid == 98 && windows[1].Wager == 98 && firstCount == 1 && secondCount == 98
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: passed,
|
||||||
|
Summary: fmt.Sprintf("阈值99、参考价10、预置10抽/98流水;99份共100金币后窗口流水=%d/%d,子抽归属=%d/%d", windows[0].Wager, windows[1].Wager, firstCount, secondCount),
|
||||||
|
Data: map[string]any{
|
||||||
|
"settlement_window_wager": settlementWager,
|
||||||
|
"reference_price": referencePrice, "legacy_derived_draw_threshold": int64(math.Ceil(float64(settlementWager) / float64(referencePrice))),
|
||||||
|
"windows": windows, "draws_in_window_1": firstCount, "draws_in_window_2": secondCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateWaterBoundaries(config domain.StrategyConfig) scenarioResult {
|
||||||
|
const name = "低中高水位边界"
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
type waterPoint struct {
|
||||||
|
Pool int64 `json:"pool"`
|
||||||
|
FiveWeight int64 `json:"five_x_weight_ppm"`
|
||||||
|
Factors []string `json:"factors"`
|
||||||
|
}
|
||||||
|
points := make([]waterPoint, 0, 4)
|
||||||
|
for _, pool := range []int64{9_999_999, 10_000_000, 20_000_000, 20_000_001} {
|
||||||
|
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: pool}, domain.StrategyInput{GiftPriceCoins: 100})
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
trace := strategyWeightTrace(traces, "5x")
|
||||||
|
factors := make([]string, 0, len(trace.Factors))
|
||||||
|
for _, factor := range trace.Factors {
|
||||||
|
factors = append(factors, factor.Name)
|
||||||
|
}
|
||||||
|
points = append(points, waterPoint{Pool: pool, FiveWeight: trace.AdjustedWeightPPM, Factors: factors})
|
||||||
|
}
|
||||||
|
passed := points[0].FiveWeight == 140_000 && points[1].FiveWeight == 200_000 && points[2].FiveWeight == 200_000 && points[3].FiveWeight == 260_000
|
||||||
|
return scenarioResult{Name: name, Passed: passed, Summary: "<1000万应用0.7;等于1000万/2000万均为中水位;>2000万应用1.3", Data: points}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult {
|
||||||
|
const name = "充值0/299999/300000ms"
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
const now = int64(1_000_000)
|
||||||
|
type rechargePoint struct {
|
||||||
|
AgeMS int64 `json:"age_ms"`
|
||||||
|
FiveWeight int64 `json:"five_x_weight_ppm"`
|
||||||
|
Boosted bool `json:"boosted"`
|
||||||
|
}
|
||||||
|
points := make([]rechargePoint, 0, 3)
|
||||||
|
for _, age := range []int64{0, 299_999, 300_000} {
|
||||||
|
input := domain.StrategyInput{GiftPriceCoins: 100, NowMS: now, HasRechargeFact: true, LastRechargeAtMS: now - age}
|
||||||
|
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: 15_000_000}, input)
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
trace := strategyWeightTrace(traces, "5x")
|
||||||
|
boosted := false
|
||||||
|
for _, factor := range trace.Factors {
|
||||||
|
boosted = boosted || factor.Name == "recent_recharge"
|
||||||
|
}
|
||||||
|
points = append(points, rechargePoint{AgeMS: age, FiveWeight: trace.AdjustedWeightPPM, Boosted: boosted})
|
||||||
|
}
|
||||||
|
passed := points[0].Boosted && points[1].Boosted && !points[2].Boosted && points[0].FiveWeight == 220_000 && points[2].FiveWeight == 200_000
|
||||||
|
return scenarioResult{Name: name, Passed: passed, Summary: "充值加成严格使用[0,300000ms):0和299999生效,300000失效", Data: points}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateProbabilityMonteCarlo(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
|
||||||
|
const name = "概率Monte Carlo"
|
||||||
|
config = baseProbabilityConfig(config)
|
||||||
|
config.MissProtectionZeroDraws = math.MaxInt64
|
||||||
|
state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000_000}
|
||||||
|
random := domain.NewSeededStrategyRandom(seed)
|
||||||
|
counts := map[string]int{}
|
||||||
|
var multiplierSum float64
|
||||||
|
for index := 0; index < draws; index++ {
|
||||||
|
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, random)
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
state = decision.NextState
|
||||||
|
counts[decision.SelectedTier.ID]++
|
||||||
|
multiplierSum += float64(decision.SelectedTier.MultiplierPPM) / float64(domain.StrategyPPMScale)
|
||||||
|
}
|
||||||
|
expected := map[string]float64{"0x": 0.60, "5x": 0.20, "50x": 0.15, "200x": 0.04, "500x": 0, "1000x": 0.01}
|
||||||
|
empirical := map[string]float64{}
|
||||||
|
passed := true
|
||||||
|
for tier, probability := range expected {
|
||||||
|
empirical[tier] = float64(counts[tier]) / float64(draws)
|
||||||
|
// Five standard deviations makes the assertion scale with -draws instead
|
||||||
|
// of baking a tolerance that is too strict for a quick local smoke run.
|
||||||
|
tolerance := 5*math.Sqrt(probability*(1-probability)/float64(draws)) + 1/float64(draws)
|
||||||
|
passed = passed && math.Abs(empirical[tier]-probability) <= tolerance
|
||||||
|
}
|
||||||
|
empiricalEV := multiplierSum / float64(draws)
|
||||||
|
const multiplierVariance = 11_277.75 // E[X^2]=11980, E[X]^2=702.25.
|
||||||
|
evTolerance := 5 * math.Sqrt(multiplierVariance/float64(draws))
|
||||||
|
passed = passed && math.Abs(empiricalEV-26.5) <= evTolerance
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: passed,
|
||||||
|
Summary: fmt.Sprintf("%d抽经验EV=%.4fx(只验证图示概率采样,不声称通过98%%资金RTP)", draws, empiricalEV),
|
||||||
|
Data: map[string]any{"counts": counts, "expected_probability": expected, "empirical_probability": empirical, "empirical_ev_multiplier": empiricalEV},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateFundedRTPLongRun(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
|
||||||
|
const name = "RTP长跑"
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
config.DailyJackpotLimit = math.MaxInt64
|
||||||
|
config.MilestoneSpendCoins = 0
|
||||||
|
state := domain.StrategyState{}
|
||||||
|
random := domain.NewSeededStrategyRandom(seed)
|
||||||
|
var contributed, payout int64
|
||||||
|
for index := 0; index < draws; index++ {
|
||||||
|
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
state = decision.NextState
|
||||||
|
contributed += 98
|
||||||
|
payout += decision.PayoutCoins
|
||||||
|
if state.PoolBalanceCoins < 0 {
|
||||||
|
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d抽资金为负", index+1)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rtpPercent := float64(payout) / float64(draws*100) * 100
|
||||||
|
conserved := contributed-payout == state.PoolBalanceCoins
|
||||||
|
passed := conserved && payout <= contributed && rtpPercent <= 98
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: passed,
|
||||||
|
Summary: fmt.Sprintf("%d抽实付RTP=%.4f%%,投入=%d,赔付=%d,池余=%d", draws, rtpPercent, contributed, payout, state.PoolBalanceCoins),
|
||||||
|
Data: map[string]any{"contributed": contributed, "payout": payout, "pool_after": state.PoolBalanceCoins, "rtp_percent": rtpPercent, "fund_conserved": conserved},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) domain.StrategyWeightTrace {
|
||||||
|
for _, trace := range traces {
|
||||||
|
if trace.TierID == tierID {
|
||||||
|
return trace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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))
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
// 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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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里程碑/池不足"
|
||||||
|
config = baseProbabilityConfig(config)
|
||||||
|
config.JackpotMechanism2Enabled = true
|
||||||
|
// 模拟只用一个便于验证跨档的金币值;生产值必须来自当前不可变规则版本。
|
||||||
|
config.MilestoneSpendCoins = 50
|
||||||
|
config.DailyJackpotLimit = 5
|
||||||
|
input := domain.StrategyInput{GiftPriceCoins: 10}
|
||||||
|
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}, input, 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))
|
||||||
|
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
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||||
|
const name = "大奖集合"
|
||||||
|
config = baseProbabilityConfig(config)
|
||||||
|
config.JackpotMechanism2Enabled = true
|
||||||
|
config.DailyJackpotLimit = 5
|
||||||
|
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))
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
all[decision.SelectedTier.ID]++
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
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
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: passed,
|
||||||
|
Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%v;P=4999时只可能200x=%v", has200 && has500 && has1000, limited),
|
||||||
|
Data: map[string]any{"pool_10000_counts": all, "pool_4999_counts": limited},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
for index := 0; index < 5; index++ {
|
||||||
|
state.PendingMilestoneTokens = 1
|
||||||
|
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
state = decision.NextState
|
||||||
|
if !decision.Jackpot {
|
||||||
|
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}})
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && sixth.NextState.PendingMilestoneTokens == 1
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: passed,
|
||||||
|
Summary: fmt.Sprintf("前5次大奖计数=%d;第6次特殊与基础大奖均被硬阻断且资格保留", state.UserDailyJackpotWins),
|
||||||
|
Data: map[string]any{"sixth": sixth},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult {
|
||||||
|
const name = "六维risk capacity代表边界"
|
||||||
|
config = baseProbabilityConfig(config)
|
||||||
|
config.Tiers = []domain.StrategyTier{
|
||||||
|
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
|
||||||
|
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true},
|
||||||
|
}
|
||||||
|
type dimension struct {
|
||||||
|
name string
|
||||||
|
set func(*domain.StrategyRiskCapacity, int64)
|
||||||
|
}
|
||||||
|
dimensions := []dimension{
|
||||||
|
{name: "single_draw", 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 }},
|
||||||
|
}
|
||||||
|
rows := map[string]map[string]any{}
|
||||||
|
passed := true
|
||||||
|
for _, item := range dimensions {
|
||||||
|
capacity := domain.StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
|
||||||
|
item.set(&capacity, 50)
|
||||||
|
allowed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
item.set(&capacity, 49)
|
||||||
|
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
rowPass := allowed.PayoutCoins == 50 && blocked.PayoutCoins == 0 && blocked.PoolAfterCoins == 50
|
||||||
|
passed = passed && rowPass
|
||||||
|
rows[item.name] = map[string]any{"equal_capacity_payout": allowed.PayoutCoins, "below_capacity_payout": blocked.PayoutCoins, "pass": rowPass}
|
||||||
|
}
|
||||||
|
return scenarioResult{Name: name, Passed: passed, Summary: "单次/用户小时/用户日/设备日/房间小时/主播日六维均验证W=capacity可赔、W=capacity+1硬阻断", Data: rows}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
|
||||||
|
const name = "充值分层"
|
||||||
|
type row struct {
|
||||||
|
Recharge7D int64 `json:"recharge_7d"`
|
||||||
|
Recharge30D int64 `json:"recharge_30d"`
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
}
|
||||||
|
inputs := [][2]int64{
|
||||||
|
{99, 499}, // 非零但两维低于 normal。
|
||||||
|
{100, 499}, // 7 日等于 normal,30 日未达,仍为 novice。
|
||||||
|
{99, 500}, // 30 日等于 normal,7 日未达,仍为 novice。
|
||||||
|
{100, 500}, // 两维等于 normal,进入 normal。
|
||||||
|
{500, 2_000},
|
||||||
|
}
|
||||||
|
wants := []string{domain.StageNovice, domain.StageNovice, domain.StageNovice, domain.StageNormal, domain.StageAdvanced}
|
||||||
|
rows := make([]row, 0, len(inputs))
|
||||||
|
passed := true
|
||||||
|
for index, input := range inputs {
|
||||||
|
stage := domain.SelectLuckyGiftRechargeStage(config, input[0], input[1])
|
||||||
|
passed = passed && stage == wants[index]
|
||||||
|
rows = append(rows, row{Recharge7D: input[0], Recharge30D: input[1], Stage: stage})
|
||||||
|
}
|
||||||
|
return scenarioResult{Name: name, Passed: passed, Summary: "normal/advanced同时达到7日与30日下限才提档;任一维低于normal即使非零也是novice", Data: map[string]any{"thresholds": config.RechargeStages, "boundary_rows": rows}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||||
|
const name = "组合优先级"
|
||||||
|
config = baseProbabilityConfig(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
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
input := domain.StrategyInput{
|
||||||
|
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
|
||||||
|
LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000,
|
||||||
|
}
|
||||||
|
priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed))
|
||||||
|
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
|
||||||
|
|
||||||
|
blockedState := state
|
||||||
|
blockedState.PoolBalanceCoins = 50
|
||||||
|
blocked, err := domain.DecideLuckyGiftStrategy(config, blockedState, input, domain.NewSeededStrategyRandom(seed+1))
|
||||||
|
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
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: priorityPass && blockedPass,
|
||||||
|
Summary: fmt.Sprintf("优先级=持久token>%s>普通P/W/第6抽;特殊档池不足时资格保留并仅赔可支付%s", domain.StrategyJackpotMechanismRTPCompensation, blocked.SelectedTier.ID),
|
||||||
|
Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// verifies the state-machine invariants a row lock/CAS transaction must preserve.
|
||||||
|
type simulationStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
config domain.StrategyConfig
|
||||||
|
state domain.StrategyState
|
||||||
|
random domain.StrategyRandomSource
|
||||||
|
seen map[string]domain.StrategyDecision
|
||||||
|
transitions int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSimulationStore(config domain.StrategyConfig, state domain.StrategyState, seed int64) *simulationStore {
|
||||||
|
return &simulationStore{config: config, state: state, random: domain.NewSeededStrategyRandom(seed), seen: map[string]domain.StrategyDecision{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simulationStore) execute(commandID string, input domain.StrategyInput) (domain.StrategyDecision, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if existing, ok := s.seen[commandID]; ok {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
decision, err := domain.DecideLuckyGiftStrategy(s.config, s.state, input, s.random)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StrategyDecision{}, err
|
||||||
|
}
|
||||||
|
s.state = decision.NextState
|
||||||
|
s.seen[commandID] = decision
|
||||||
|
s.transitions++
|
||||||
|
return decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateConcurrencyAndIdempotency(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||||
|
const name = "并发/幂等模型"
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
config.DailyJackpotLimit = math.MaxInt64
|
||||||
|
config.MilestoneSpendCoins = 0
|
||||||
|
input := domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}
|
||||||
|
|
||||||
|
duplicateStore := newSimulationStore(config, domain.StrategyState{}, seed)
|
||||||
|
duplicateErrors := runConcurrentCommands(duplicateStore, 32, func(int) string { return "same-command" }, input)
|
||||||
|
duplicatePass := len(duplicateErrors) == 0 && duplicateStore.transitions == 1 && duplicateStore.state.Version == 1 && len(duplicateStore.seen) == 1 && duplicateStore.state.PoolBalanceCoins >= 0
|
||||||
|
|
||||||
|
uniqueStore := newSimulationStore(config, domain.StrategyState{}, seed+1)
|
||||||
|
uniqueErrors := runConcurrentCommands(uniqueStore, 100, func(index int) string { return fmt.Sprintf("command-%03d", index) }, input)
|
||||||
|
uniquePass := len(uniqueErrors) == 0 && uniqueStore.transitions == 100 && uniqueStore.state.Version == 100 && len(uniqueStore.seen) == 100 && uniqueStore.state.PoolBalanceCoins >= 0
|
||||||
|
return scenarioResult{
|
||||||
|
Name: name, Passed: duplicatePass && uniquePass,
|
||||||
|
Summary: fmt.Sprintf("32个同幂等键仅1次状态迁移;100个并发唯一键串行化为100个版本且池余=%d", uniqueStore.state.PoolBalanceCoins),
|
||||||
|
Data: map[string]any{
|
||||||
|
"duplicate": map[string]any{"requests": 32, "transitions": duplicateStore.transitions, "version": duplicateStore.state.Version, "errors": duplicateErrors},
|
||||||
|
"unique": map[string]any{"requests": 100, "transitions": uniqueStore.transitions, "version": uniqueStore.state.Version, "pool_after": uniqueStore.state.PoolBalanceCoins, "errors": uniqueErrors},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConcurrentCommands(store *simulationStore, count int, commandID func(int) string, input domain.StrategyInput) []string {
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
errorsCh := make(chan string, count)
|
||||||
|
for index := 0; index < count; index++ {
|
||||||
|
wait.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wait.Done()
|
||||||
|
if _, err := store.execute(commandID(index), input); err != nil {
|
||||||
|
errorsCh <- err.Error()
|
||||||
|
}
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
wait.Wait()
|
||||||
|
close(errorsCh)
|
||||||
|
errorsFound := make([]string, 0, len(errorsCh))
|
||||||
|
for err := range errorsCh {
|
||||||
|
errorsFound = append(errorsFound, err)
|
||||||
|
}
|
||||||
|
sort.Strings(errorsFound)
|
||||||
|
return errorsFound
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupLongRunSummary struct {
|
||||||
|
ExpectedStage string `json:"expected_stage"`
|
||||||
|
ObservedStage string `json:"observed_stage"`
|
||||||
|
Draws int `json:"draws"`
|
||||||
|
TierCounts map[string]int `json:"tier_counts"`
|
||||||
|
PayoutCoins int64 `json:"payout_coins"`
|
||||||
|
PoolAfter int64 `json:"pool_after"`
|
||||||
|
RTPPercent float64 `json:"rtp_percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateLongRunGroups(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
|
||||||
|
const name = "长跑分群"
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
config.DailyJackpotLimit = math.MaxInt64
|
||||||
|
config.MilestoneSpendCoins = 0
|
||||||
|
// This grouping run uses a production-safe table (stage EV 85%/97.5%/98%),
|
||||||
|
// not the image's 2650% probability snapshot. That keeps distribution changes
|
||||||
|
// visible instead of letting P/W drain every group into the same 5x-only cycle.
|
||||||
|
config.LowWaterThresholdCoins = 0
|
||||||
|
config.HighWaterThresholdCoins = math.MaxInt64
|
||||||
|
config.LowWaterFactorPPM = domain.StrategyPPMScale
|
||||||
|
config.HighWaterFactorPPM = domain.StrategyPPMScale
|
||||||
|
config.RechargeFactorPPM = domain.StrategyPPMScale
|
||||||
|
config.MissProtectionZeroDraws = math.MaxInt64
|
||||||
|
config.Tiers = []domain.StrategyTier{
|
||||||
|
{ID: "0x", MultiplierPPM: 0, Enabled: true},
|
||||||
|
{ID: "0.5x", MultiplierPPM: 500_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 100_000, domain.StageNormal: 50_000, domain.StageAdvanced: 40_000}},
|
||||||
|
{ID: "1x", MultiplierPPM: 1_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 800_000, domain.StageNormal: 850_000, domain.StageAdvanced: 840_000}},
|
||||||
|
{ID: "2x", MultiplierPPM: 2_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 0, domain.StageNormal: 50_000, domain.StageAdvanced: 60_000}},
|
||||||
|
}
|
||||||
|
groups := []struct {
|
||||||
|
stage string
|
||||||
|
recharge7D int64
|
||||||
|
recharge30D int64
|
||||||
|
}{
|
||||||
|
{stage: domain.StageNovice, recharge7D: 99, recharge30D: 499},
|
||||||
|
{stage: domain.StageNormal, recharge7D: 100, recharge30D: 500},
|
||||||
|
{stage: domain.StageAdvanced, recharge7D: 500, recharge30D: 2_000},
|
||||||
|
}
|
||||||
|
results := make([]groupLongRunSummary, 0, len(groups))
|
||||||
|
passed := true
|
||||||
|
for groupIndex, group := range groups {
|
||||||
|
state := domain.StrategyState{}
|
||||||
|
random := domain.NewSeededStrategyRandom(seed + int64(groupIndex))
|
||||||
|
summary := groupLongRunSummary{ExpectedStage: group.stage, Draws: draws, TierCounts: map[string]int{}}
|
||||||
|
for index := 0; index < draws; index++ {
|
||||||
|
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{
|
||||||
|
GiftPriceCoins: 100, PoolContributionCoins: 98,
|
||||||
|
Recharge7DCoins: group.recharge7D, Recharge30DCoins: group.recharge30D,
|
||||||
|
}, random)
|
||||||
|
if err != nil {
|
||||||
|
return failedScenario(name, err)
|
||||||
|
}
|
||||||
|
state = decision.NextState
|
||||||
|
summary.ObservedStage = decision.Stage
|
||||||
|
summary.TierCounts[decision.SelectedTier.ID]++
|
||||||
|
summary.PayoutCoins += decision.PayoutCoins
|
||||||
|
if state.PoolBalanceCoins < 0 {
|
||||||
|
passed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.PoolAfter = state.PoolBalanceCoins
|
||||||
|
summary.RTPPercent = float64(summary.PayoutCoins) / float64(draws*100) * 100
|
||||||
|
passed = passed && summary.ObservedStage == group.stage && summary.RTPPercent <= 98 && int64(draws*98)-summary.PayoutCoins == summary.PoolAfter
|
||||||
|
results = append(results, summary)
|
||||||
|
}
|
||||||
|
return scenarioResult{Name: name, Passed: passed, Summary: fmt.Sprintf("novice/normal/advanced各%d抽,分层权重不同但三组资金RTP均<=98%%且守恒", draws), Data: results}
|
||||||
|
}
|
||||||
@ -78,8 +78,11 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
|
|||||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
|
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
|
||||||
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
|
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
|
||||||
|
strategy_version VARCHAR(32) NOT NULL DEFAULT 'fixed_v2' COMMENT 'fixed_v2/dynamic_v3;旧版本固定按 fixed_v2 解释',
|
||||||
target_rtp_ppm BIGINT NOT NULL COMMENT '基础返奖目标,ppm',
|
target_rtp_ppm BIGINT NOT NULL COMMENT '基础返奖目标,ppm',
|
||||||
pool_rate_ppm BIGINT NOT NULL COMMENT '每笔扣费进入基础奖池比例,ppm',
|
pool_rate_ppm BIGINT NOT NULL COMMENT '每笔扣费进入基础奖池比例,ppm',
|
||||||
|
profit_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '每笔扣费计入平台盈利比例,ppm',
|
||||||
|
anchor_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '每笔扣费计入主播收益比例,ppm',
|
||||||
settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水,单位金币',
|
settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水,单位金币',
|
||||||
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带,ppm',
|
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带,ppm',
|
||||||
gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格',
|
gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格',
|
||||||
@ -88,6 +91,26 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
|
|||||||
effective_from_ms BIGINT NOT NULL COMMENT '生效时间,UTC epoch ms',
|
effective_from_ms BIGINT NOT NULL COMMENT '生效时间,UTC epoch ms',
|
||||||
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
|
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
initial_pool_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 奖池首次物化的初始注资金币',
|
||||||
|
loss_streak_guarantee BIGINT NOT NULL DEFAULT 0 COMMENT '连续未中奖后的保底触发抽数',
|
||||||
|
low_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '低水位阈值金币',
|
||||||
|
low_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '低水位非零奖档概率因子,ppm',
|
||||||
|
high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '高水位阈值金币',
|
||||||
|
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',
|
||||||
|
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_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 '单次返奖金币上限',
|
||||||
|
user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 小时返奖上限',
|
||||||
|
user_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日返奖上限',
|
||||||
|
device_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单设备 UTC 日返奖上限',
|
||||||
|
room_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单房间 UTC 小时返奖上限',
|
||||||
|
anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单主播 UTC 日返奖上限',
|
||||||
PRIMARY KEY (app_code, pool_id, rule_version),
|
PRIMARY KEY (app_code, pool_id, rule_version),
|
||||||
KEY idx_lucky_gift_rule_versions_latest (app_code, pool_id, rule_version),
|
KEY idx_lucky_gift_rule_versions_latest (app_code, pool_id, rule_version),
|
||||||
KEY idx_lucky_gift_rule_versions_enabled (app_code, enabled, created_at_ms)
|
KEY idx_lucky_gift_rule_versions_enabled (app_code, enabled, created_at_ms)
|
||||||
@ -98,6 +121,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
|
|||||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
rule_version BIGINT NOT NULL COMMENT '规则版本',
|
rule_version BIGINT NOT NULL COMMENT '规则版本',
|
||||||
stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced',
|
stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced',
|
||||||
|
min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'normal/advanced 最近 7 日充值下限;novice 的 0 仅为 sentinel',
|
||||||
|
min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'normal/advanced 最近 30 日充值下限;novice 的 0 仅为 sentinel',
|
||||||
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
|
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
|
||||||
multiplier_ppm BIGINT NOT NULL COMMENT '倍率,1x = 1000000',
|
multiplier_ppm BIGINT NOT NULL COMMENT '倍率,1x = 1000000',
|
||||||
base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率,ppm',
|
base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率,ppm',
|
||||||
@ -110,10 +135,10 @@ CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
|
|||||||
CREATE TABLE IF NOT EXISTS lucky_rtp_windows (
|
CREATE TABLE IF NOT EXISTS lucky_rtp_windows (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift',
|
scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift',
|
||||||
scope_id VARCHAR(96) NOT NULL COMMENT 'global 或规则作用域',
|
scope_id VARCHAR(128) NOT NULL COMMENT '奖池+不可变规则版本作用域',
|
||||||
window_index BIGINT NOT NULL COMMENT 'RTP 窗口序号',
|
window_index BIGINT NOT NULL COMMENT 'RTP 窗口序号',
|
||||||
target_rtp_ppm INT NOT NULL COMMENT '窗口目标 RTP',
|
target_rtp_ppm INT NOT NULL COMMENT '窗口目标 RTP',
|
||||||
control_window_draws BIGINT NOT NULL COMMENT '窗口抽数',
|
control_window_draws BIGINT NOT NULL COMMENT 'fixed_v2窗口抽数;dynamic_v3仅兼容展示,滚窗读取规则真实流水阈值',
|
||||||
paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '窗口已抽次数',
|
paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '窗口已抽次数',
|
||||||
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口消耗金币',
|
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口消耗金币',
|
||||||
target_payout_coins BIGINT NOT NULL COMMENT '窗口目标基础返奖',
|
target_payout_coins BIGINT NOT NULL COMMENT '窗口目标基础返奖',
|
||||||
@ -134,11 +159,93 @@ CREATE TABLE IF NOT EXISTS lucky_pools (
|
|||||||
reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位',
|
reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位',
|
||||||
total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池',
|
total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池',
|
||||||
total_out BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖支出',
|
total_out BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖支出',
|
||||||
|
manual_credit_total BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工注资金币,不计入业务 total_in',
|
||||||
|
manual_debit_total BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工扣资金币,不计入业务 total_out',
|
||||||
|
profit_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计平台盈利拆账金币',
|
||||||
|
anchor_income_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计主播收益拆账金币',
|
||||||
|
initial_seed_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计初始注资金币,只在首次物化时增加',
|
||||||
created_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',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, scope_type, scope_id)
|
PRIMARY KEY (app_code, scope_type, scope_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物单一基础奖池';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物单一基础奖池';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_pool_adjustments (
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||||
|
adjustment_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',
|
||||||
|
scope_type VARCHAR(32) NOT NULL COMMENT '实际资金账本 scope_type',
|
||||||
|
direction VARCHAR(16) NOT NULL COMMENT 'in/out',
|
||||||
|
amount_coins BIGINT NOT NULL COMMENT '本次人工调整金币',
|
||||||
|
reason VARCHAR(512) NOT NULL COMMENT '管理员填写的调整原因',
|
||||||
|
operator_admin_id BIGINT NOT NULL COMMENT '操作管理员 ID',
|
||||||
|
request_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链路 request_id,不作为幂等键',
|
||||||
|
insert_token VARCHAR(64) NOT NULL COMMENT '首次插入事务随机令牌,用于并发幂等判定',
|
||||||
|
balance_before BIGINT NOT NULL DEFAULT 0 COMMENT '首次应用前余额',
|
||||||
|
balance_after BIGINT NOT NULL DEFAULT 0 COMMENT '首次应用后余额',
|
||||||
|
pool_snapshot_json JSON NULL COMMENT '首次应用后的完整奖池快照,供幂等重放',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/applied',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, adjustment_id),
|
||||||
|
KEY idx_lucky_pool_adjustments_pool (app_code, pool_id, strategy_version, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物人工奖池调整事实';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_risk_counters (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
scope_type VARCHAR(32) NOT NULL COMMENT 'user/device/room/anchor',
|
||||||
|
scope_id VARCHAR(255) NOT NULL COMMENT 'pool_id+风控主体 ID;容纳96字符奖池与128字符设备',
|
||||||
|
window_type VARCHAR(32) NOT NULL COMMENT 'hour/day',
|
||||||
|
bucket_key VARCHAR(32) NOT NULL COMMENT 'UTC 窗口键',
|
||||||
|
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内已支出可见奖励',
|
||||||
|
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_type, bucket_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物多维风控窗口计数';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
bucket_hour_ms BIGINT NOT NULL COMMENT 'UTC 小时开始时间,epoch ms',
|
||||||
|
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内抽奖流水金币',
|
||||||
|
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内返奖金币',
|
||||||
|
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, 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完整小时聚合桶';
|
||||||
|
|
||||||
|
-- 小时桶负责完整小时,边界事件只用于精确补齐 [now-72h,now) 两端不足一小时的片段。
|
||||||
|
-- 这样热路径最多扫描 71 个小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
|
||||||
|
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',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
draw_id VARCHAR(128) NOT NULL COMMENT '单抽事实 ID,用于幂等',
|
||||||
|
occurred_at_ms BIGINT NOT NULL COMMENT '扣费事实时间,UTC epoch ms',
|
||||||
|
wager_coins BIGINT NOT NULL COMMENT '本抽流水金币',
|
||||||
|
payout_coins BIGINT NOT NULL COMMENT '本抽已承诺返奖负债',
|
||||||
|
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边界事件';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
stat_day DATE NOT NULL COMMENT 'UTC 自然日',
|
||||||
|
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日抽奖流水金币',
|
||||||
|
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日返奖金币',
|
||||||
|
spend_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日用于动态大奖资格的累计消费金币',
|
||||||
|
jackpot_hits BIGINT NOT NULL DEFAULT 0 COMMENT '当日动态大奖已命中次数',
|
||||||
|
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, user_id, stat_day),
|
||||||
|
KEY idx_lucky_user_strategy_days_retention (app_code, stat_day)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 dynamic_v3 UTC 日级消费、RTP 与大奖资格状态';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lucky_user_states (
|
CREATE TABLE IF NOT EXISTS lucky_user_states (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
@ -147,11 +254,21 @@ CREATE TABLE IF NOT EXISTS lucky_user_states (
|
|||||||
cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币',
|
cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币',
|
||||||
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
|
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
|
||||||
loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零',
|
loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零',
|
||||||
|
pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '未消费大奖机会;按UTC日消费产生,资金不足时允许跨日保留',
|
||||||
created_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',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, user_id, gift_id)
|
PRIMARY KEY (app_code, user_id, gift_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户体验池状态';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户体验池状态';
|
||||||
|
|
||||||
|
-- draw 事实不存在时 SELECT ... FOR UPDATE 没有可锁记录;该锁行保证同一钱包 command_id 的
|
||||||
|
-- 首次并发请求串行执行,等待者在首事务提交后只回读原始结果,不重新随机或重复推进奖池。
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_gift_command_locks (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '钱包已扣费命令幂等 ID',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, command_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部幸运礼物并发幂等锁行';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lucky_draw_records (
|
CREATE TABLE IF NOT EXISTS lucky_draw_records (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
draw_id VARCHAR(128) NOT NULL COMMENT '抽奖 ID',
|
draw_id VARCHAR(128) NOT NULL COMMENT '抽奖 ID',
|
||||||
@ -257,6 +374,13 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
|
|||||||
PRIMARY KEY (app_code, cursor_name)
|
PRIMARY KEY (app_code, cursor_name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
|
) 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',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, request_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部幸运礼物并发幂等锁行';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
|
CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
|
||||||
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
|
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
|
||||||
request_id VARCHAR(128) NOT NULL COMMENT '外部 App 业务幂等 ID',
|
request_id VARCHAR(128) NOT NULL COMMENT '外部 App 业务幂等 ID',
|
||||||
@ -282,3 +406,28 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
|
|||||||
KEY idx_external_lucky_pool (app_code, pool_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)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
|
||||||
|
request_id VARCHAR(128) NOT NULL COMMENT '聚合请求幂等 ID',
|
||||||
|
item_index INT NOT NULL COMMENT '从1开始的顺序子抽序号',
|
||||||
|
draw_id VARCHAR(128) NOT NULL COMMENT '子抽事实 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '稳定子命令 ID',
|
||||||
|
external_user_id VARCHAR(128) NOT NULL COMMENT '外部 App 用户 ID',
|
||||||
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
|
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
|
||||||
|
unit_amount BIGINT NOT NULL COMMENT '本抽实际金额',
|
||||||
|
reward_amount BIGINT NOT NULL COMMENT '本抽奖励金额',
|
||||||
|
selected_tier_id VARCHAR(96) NOT NULL COMMENT '本抽最终奖档',
|
||||||
|
multiplier_ppm BIGINT NOT NULL COMMENT '本抽倍率',
|
||||||
|
stage VARCHAR(32) NOT NULL COMMENT '充值分层;缺可信外部充值事实时为novice',
|
||||||
|
candidate_snapshot_json JSON NOT NULL COMMENT '动态权重、删除重抽及大奖条件快照',
|
||||||
|
pool_snapshot_json JSON NOT NULL COMMENT '本抽资金拆分和池快照',
|
||||||
|
rtp_snapshot_json JSON NOT NULL COMMENT '本抽RTP快照',
|
||||||
|
reward_status VARCHAR(32) NOT NULL DEFAULT 'granted' COMMENT '外部调用方自行结算',
|
||||||
|
paid_at_ms BIGINT NOT NULL COMMENT '外部扣费时间,UTC epoch ms',
|
||||||
|
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)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';
|
||||||
|
|||||||
@ -0,0 +1,226 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_lucky_gift;
|
||||||
|
|
||||||
|
-- 每列独立探测,兼容“曾运行旧 002、未运行旧 002、004 执行到一半”三种线上状态;
|
||||||
|
-- strategy_version 的默认值固定为 fixed_v2,确保已有不可变版本不会被动态规则重新解释。
|
||||||
|
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'strategy_version') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN strategy_version VARCHAR(32) NOT NULL DEFAULT ''fixed_v2'' COMMENT ''fixed_v2/dynamic_v3;旧版本固定按 fixed_v2 解释'' AFTER enabled', '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 = 'profit_rate_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN profit_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''平台盈利比例,ppm'' AFTER pool_rate_ppm', '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 = 'anchor_rate_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN anchor_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''主播收益比例,ppm'' AFTER profit_rate_ppm', '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 = 'initial_pool_coins') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN initial_pool_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''dynamic_v3 初始注资金币'' AFTER created_at_ms', '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 = 'loss_streak_guarantee') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN loss_streak_guarantee BIGINT NOT NULL DEFAULT 0 COMMENT ''连续未中奖保底抽数'' AFTER initial_pool_coins', '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 = 'low_watermark_coins') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN low_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''低水位阈值金币'' AFTER loss_streak_guarantee', '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 = 'low_water_nonzero_factor_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN low_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''低水位非零概率因子,ppm'' AFTER low_watermark_coins', '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 = 'high_watermark_coins') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''高水位阈值金币'' AFTER low_water_nonzero_factor_ppm', '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 = 'high_water_nonzero_factor_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''高水位非零概率因子,ppm'' AFTER high_watermark_coins', '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 = 'recharge_boost_window_ms') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''充值加权窗口毫秒'' AFTER high_water_nonzero_factor_ppm', '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 = 'recharge_boost_factor_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''充值加权因子,ppm'' AFTER recharge_boost_window_ms', '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 = 'jackpot_multiplier_ppms') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT ''动态大奖倍率列表'' AFTER recharge_boost_factor_ppm', '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 = 'jackpot_global_rtp_max_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''全局大奖 RTP 上限'' AFTER jackpot_multiplier_ppms', '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 = 'jackpot_user_day_rtp_max_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''用户当日大奖 RTP 上限'' AFTER jackpot_global_rtp_max_ppm', '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 = 'jackpot_user_72h_rtp_max_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''用户滚动72小时大奖 RTP 上限'' AFTER jackpot_user_day_rtp_max_ppm', '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 = 'jackpot_spend_threshold_coins') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''规则版本动态配置的用户 UTC 日累计消费大奖门槛金币'' AFTER jackpot_user_72h_rtp_max_ppm', '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 = 'max_jackpot_hits_per_user_day') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC日大奖命中上限'' AFTER jackpot_spend_threshold_coins', '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 = 'max_single_payout') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT ''单次返奖上限'' AFTER max_jackpot_hits_per_user_day', '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_hourly_payout_cap') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC小时返奖上限'' AFTER max_single_payout', '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_daily_payout_cap') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC日返奖上限'' AFTER user_hourly_payout_cap', '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 = 'device_daily_payout_cap') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN device_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''设备UTC日返奖上限'' AFTER user_daily_payout_cap', '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 = 'room_hourly_payout_cap') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN room_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''房间UTC小时返奖上限'' AFTER device_daily_payout_cap', '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 = 'anchor_daily_payout_cap') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''主播UTC日返奖上限'' AFTER room_hourly_payout_cap', '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_stage_tiers' AND COLUMN_NAME = 'min_recharge_7d_coins') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''normal/advanced 最近7日充值下限;novice 的0仅为sentinel'' AFTER stage', '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_stage_tiers' AND COLUMN_NAME = 'min_recharge_30d_coins') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''normal/advanced 最近30日充值下限;novice 的0仅为sentinel'' AFTER min_recharge_7d_coins', '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_pools' AND COLUMN_NAME = 'profit_total') = 0,
|
||||||
|
'ALTER TABLE lucky_pools ADD COLUMN profit_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计平台盈利拆账金币'' AFTER total_out', '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_pools' AND COLUMN_NAME = 'anchor_income_total') = 0,
|
||||||
|
'ALTER TABLE lucky_pools ADD COLUMN anchor_income_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计主播收益拆账金币'' AFTER profit_total', '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_pools' AND COLUMN_NAME = 'initial_seed_total') = 0,
|
||||||
|
'ALTER TABLE lucky_pools ADD COLUMN initial_seed_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计初始注资金币'' AFTER anchor_income_total', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 消费门槛按 UTC 日累计,但已经赚到的机会在池不足时必须跨日保留;因此 token 属于用户/奖池状态,不能放在日表。
|
||||||
|
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_states' AND COLUMN_NAME = 'pending_jackpot_tokens') = 0,
|
||||||
|
'ALTER TABLE lucky_user_states ADD COLUMN pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''未消费大奖机会;资金不足时允许跨日保留'' AFTER loss_streak', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- pool_id 最长 96;RTP 还拼接 :v<rule_version>,风控还拼接最长 128 的设备/房间主体,旧列宽会在合法输入上截断。
|
||||||
|
SET @ddl := IF((SELECT COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'scope_id') < 128,
|
||||||
|
'ALTER TABLE lucky_rtp_windows MODIFY COLUMN scope_id VARCHAR(128) NOT NULL COMMENT ''奖池+不可变规则版本作用域''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF((SELECT COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_risk_counters' AND COLUMN_NAME = 'scope_id') < 255,
|
||||||
|
'ALTER TABLE lucky_risk_counters MODIFY COLUMN scope_id VARCHAR(255) NOT NULL COMMENT ''pool_id+风控主体 ID''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 三张运行状态表都是 owner 库内聚合/计数事实;热路径只锁这些小表,禁止回扫开奖结果明细计算 72h RTP 或风控额度。
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_risk_counters (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
scope_type VARCHAR(32) NOT NULL COMMENT 'user/device/room/anchor',
|
||||||
|
scope_id VARCHAR(255) NOT NULL COMMENT '风控主体 ID,运行侧包含 pool 前缀',
|
||||||
|
window_type VARCHAR(32) NOT NULL COMMENT 'hour/day',
|
||||||
|
bucket_key VARCHAR(32) NOT NULL COMMENT 'UTC 窗口键',
|
||||||
|
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内已支出可见奖励',
|
||||||
|
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_type, bucket_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物多维风控窗口计数';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
bucket_hour_ms BIGINT NOT NULL COMMENT 'UTC 小时开始时间,epoch ms',
|
||||||
|
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内抽奖流水金币',
|
||||||
|
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内返奖金币',
|
||||||
|
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, 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小时桶';
|
||||||
|
|
||||||
|
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',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
draw_id VARCHAR(128) NOT NULL COMMENT '单抽事实 ID,用于幂等',
|
||||||
|
occurred_at_ms BIGINT NOT NULL COMMENT '扣费事实时间,UTC epoch ms',
|
||||||
|
wager_coins BIGINT NOT NULL COMMENT '本抽流水金币',
|
||||||
|
payout_coins BIGINT NOT NULL COMMENT '本抽已承诺返奖负债',
|
||||||
|
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边界事件';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
stat_day DATE NOT NULL COMMENT 'UTC 自然日',
|
||||||
|
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日抽奖流水金币',
|
||||||
|
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日返奖金币',
|
||||||
|
spend_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日大奖资格累计消费金币',
|
||||||
|
jackpot_hits BIGINT NOT NULL DEFAULT 0 COMMENT '当日大奖命中次数',
|
||||||
|
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, user_id, stat_day),
|
||||||
|
KEY idx_lucky_user_strategy_days_retention (app_code, stat_day)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户dynamic_v3 UTC日级状态';
|
||||||
|
|
||||||
|
-- 旧库只有 lucky_draw_records 唯一键,两个尚无结果的首次请求仍可能同时穿过空读并让后到者失败。
|
||||||
|
-- 独立锁行先串行化原始 command_id,后到请求再回读首次事务一次性提交的全部子抽事实。
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_gift_command_locks (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '钱包已扣费命令幂等 ID',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, command_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部幸运礼物并发幂等锁行';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
|
||||||
|
request_id VARCHAR(128) NOT NULL COMMENT '聚合请求幂等 ID',
|
||||||
|
item_index INT NOT NULL COMMENT '从1开始的顺序子抽序号',
|
||||||
|
draw_id VARCHAR(128) NOT NULL COMMENT '子抽事实 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '稳定子命令 ID',
|
||||||
|
external_user_id VARCHAR(128) NOT NULL COMMENT '外部 App 用户 ID',
|
||||||
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
|
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
|
||||||
|
unit_amount BIGINT NOT NULL COMMENT '本抽实际金额',
|
||||||
|
reward_amount BIGINT NOT NULL COMMENT '本抽奖励金额',
|
||||||
|
selected_tier_id VARCHAR(96) NOT NULL COMMENT '本抽最终奖档',
|
||||||
|
multiplier_ppm BIGINT NOT NULL COMMENT '本抽倍率',
|
||||||
|
stage VARCHAR(32) NOT NULL COMMENT '充值分层',
|
||||||
|
candidate_snapshot_json JSON NOT NULL COMMENT '动态权重、删除重抽及大奖条件快照',
|
||||||
|
pool_snapshot_json JSON NOT NULL COMMENT '本抽资金拆分和池快照',
|
||||||
|
rtp_snapshot_json JSON NOT NULL COMMENT '本抽RTP快照',
|
||||||
|
reward_status VARCHAR(32) NOT NULL DEFAULT 'granted' COMMENT '外部调用方自行结算',
|
||||||
|
paid_at_ms BIGINT NOT NULL COMMENT '外部扣费时间,UTC epoch ms',
|
||||||
|
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)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';
|
||||||
|
|
||||||
|
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',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, request_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部幸运礼物并发幂等锁行';
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_lucky_gift;
|
||||||
|
|
||||||
|
-- 每列独立探测,使迁移在“全新库、004 后旧库、曾执行一半的旧库”上都可重复运行。
|
||||||
|
-- 人工资金必须与正常送礼入池/返奖分账,不能污染 total_in/total_out 的 RTP 经营口径。
|
||||||
|
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools' AND COLUMN_NAME = 'manual_credit_total') = 0,
|
||||||
|
'ALTER TABLE lucky_pools ADD COLUMN manual_credit_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计人工注资金币,不计入业务 total_in'' AFTER total_out', '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_pools' AND COLUMN_NAME = 'manual_debit_total') = 0,
|
||||||
|
'ALTER TABLE lucky_pools ADD COLUMN manual_debit_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计人工扣资金币,不计入业务 total_out'' AFTER manual_credit_total', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_pool_adjustments (
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||||
|
adjustment_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',
|
||||||
|
scope_type VARCHAR(32) NOT NULL COMMENT '实际资金账本 scope_type',
|
||||||
|
direction VARCHAR(16) NOT NULL COMMENT 'in/out',
|
||||||
|
amount_coins BIGINT NOT NULL COMMENT '本次人工调整金币',
|
||||||
|
reason VARCHAR(512) NOT NULL COMMENT '管理员填写的调整原因',
|
||||||
|
operator_admin_id BIGINT NOT NULL COMMENT '操作管理员 ID',
|
||||||
|
request_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链路 request_id,不作为幂等键',
|
||||||
|
insert_token VARCHAR(64) NOT NULL COMMENT '首次插入事务随机令牌,用于并发幂等判定',
|
||||||
|
balance_before BIGINT NOT NULL DEFAULT 0 COMMENT '首次应用前余额',
|
||||||
|
balance_after BIGINT NOT NULL DEFAULT 0 COMMENT '首次应用后余额',
|
||||||
|
pool_snapshot_json JSON NULL COMMENT '首次应用后的完整奖池快照,供幂等重放',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/applied',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, adjustment_id),
|
||||||
|
KEY idx_lucky_pool_adjustments_pool (app_code, pool_id, strategy_version, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物人工奖池调整事实';
|
||||||
|
|
||||||
|
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pool_adjustments' AND COLUMN_NAME = 'insert_token') = 0,
|
||||||
|
'ALTER TABLE lucky_pool_adjustments ADD COLUMN insert_token VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''首次插入事务随机令牌,用于并发幂等判定'' AFTER request_id', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 只看每个 app+pool 当前最新不可变版本。若它已经切换到 dynamic_v3,就创建一口独立的零余额账本;
|
||||||
|
-- ON DUPLICATE 分支绝不更新余额、累计或时间,迁移重跑以及后续 V3 版本都不会重置真实资金。
|
||||||
|
INSERT INTO lucky_pools (
|
||||||
|
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
|
manual_credit_total, manual_debit_total, profit_total, anchor_income_total, initial_seed_total,
|
||||||
|
created_at_ms, updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
current_rule.app_code, 'pool_dynamic_v3', current_rule.pool_id, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, current_rule.created_at_ms, current_rule.created_at_ms
|
||||||
|
FROM lucky_gift_rule_versions current_rule
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT app_code, pool_id, MAX(rule_version) AS rule_version
|
||||||
|
FROM lucky_gift_rule_versions
|
||||||
|
GROUP BY app_code, pool_id
|
||||||
|
) latest
|
||||||
|
ON latest.app_code = current_rule.app_code
|
||||||
|
AND latest.pool_id = current_rule.pool_id
|
||||||
|
AND latest.rule_version = current_rule.rule_version
|
||||||
|
WHERE current_rule.strategy_version = 'dynamic_v3'
|
||||||
|
ON DUPLICATE KEY UPDATE scope_id = lucky_pools.scope_id;
|
||||||
@ -19,6 +19,13 @@ const (
|
|||||||
StageNormal = "normal"
|
StageNormal = "normal"
|
||||||
StageAdvanced = "advanced"
|
StageAdvanced = "advanced"
|
||||||
|
|
||||||
|
// StrategyFixedV2 保留现网不可变规则的原始语义;只有显式发布 StrategyDynamicV3 才启用动态水位、充值加权和多维风控。
|
||||||
|
StrategyFixedV2 = "fixed_v2"
|
||||||
|
StrategyDynamicV3 = "dynamic_v3"
|
||||||
|
|
||||||
|
PoolAdjustmentIn = "in"
|
||||||
|
PoolAdjustmentOut = "out"
|
||||||
|
|
||||||
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
|
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
|
||||||
EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement"
|
EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement"
|
||||||
EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn"
|
EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn"
|
||||||
@ -42,10 +49,15 @@ type Config struct {
|
|||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
RuleVersion int64 `json:"rule_version"`
|
RuleVersion int64 `json:"rule_version"`
|
||||||
|
StrategyVersion string `json:"strategy_version"`
|
||||||
GiftPrice int64 `json:"gift_price"`
|
GiftPrice int64 `json:"gift_price"`
|
||||||
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
||||||
PoolRatePPM int64 `json:"pool_rate_ppm"`
|
PoolRatePPM int64 `json:"pool_rate_ppm"`
|
||||||
|
ProfitRatePPM int64 `json:"profit_rate_ppm"`
|
||||||
|
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
|
||||||
ControlBandPPM int64 `json:"control_band_ppm"`
|
ControlBandPPM int64 `json:"control_band_ppm"`
|
||||||
|
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
|
||||||
|
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||||
GlobalWindowDraws int64 `json:"global_window_draws"`
|
GlobalWindowDraws int64 `json:"global_window_draws"`
|
||||||
GiftWindowDraws int64 `json:"gift_window_draws"`
|
GiftWindowDraws int64 `json:"gift_window_draws"`
|
||||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||||
@ -54,7 +66,29 @@ type Config struct {
|
|||||||
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
|
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
|
||||||
InitialBasePool int64 `json:"initial_base_pool"`
|
InitialBasePool int64 `json:"initial_base_pool"`
|
||||||
BasePoolReserve int64 `json:"base_pool_reserve"`
|
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"`
|
||||||
|
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"`
|
||||||
MultiplierPPMs []int64 `json:"multiplier_ppms"`
|
MultiplierPPMs []int64 `json:"multiplier_ppms"`
|
||||||
|
// Stages 保留 dynamic_v3 的充值门槛;Tiers 仍是 fixed_v2 候选选择使用的扁平运行结构。
|
||||||
|
Stages []RuleStage `json:"stages"`
|
||||||
Tiers []Tier `json:"tiers"`
|
Tiers []Tier `json:"tiers"`
|
||||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
@ -74,17 +108,24 @@ type RuleTier struct {
|
|||||||
// RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。
|
// RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。
|
||||||
type RuleStage struct {
|
type RuleStage struct {
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
|
// novice 的两个 0 仅为协议/存储 sentinel;normal/advanced 才用这两个下限提档。
|
||||||
|
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
|
||||||
|
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
|
||||||
Tiers []RuleTier `json:"tiers"`
|
Tiers []RuleTier `json:"tiers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuleConfig 是幸运礼物 v2 配置版本快照。比例和倍率均为 ppm,业务单位转换发生在发布入口。
|
// RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
|
||||||
|
// 比例和倍率统一使用 ppm,后台百分比/倍率业务单位只在发布入口转换。
|
||||||
type RuleConfig struct {
|
type RuleConfig struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
RuleVersion int64 `json:"rule_version"`
|
RuleVersion int64 `json:"rule_version"`
|
||||||
|
StrategyVersion string `json:"strategy_version"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
||||||
PoolRatePPM int64 `json:"pool_rate_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"`
|
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||||
ControlBandPPM int64 `json:"control_band_ppm"`
|
ControlBandPPM int64 `json:"control_band_ppm"`
|
||||||
GiftPriceReference int64 `json:"gift_price_reference"`
|
GiftPriceReference int64 `json:"gift_price_reference"`
|
||||||
@ -93,6 +134,26 @@ type RuleConfig struct {
|
|||||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
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"`
|
||||||
|
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 []RuleStage `json:"stages"`
|
Stages []RuleStage `json:"stages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,6 +178,10 @@ type DrawCommand struct {
|
|||||||
PaidAtMS int64
|
PaidAtMS int64
|
||||||
VisibleRegionID int64
|
VisibleRegionID int64
|
||||||
CountryID int64
|
CountryID int64
|
||||||
|
Recharge7DCoins int64
|
||||||
|
Recharge30DCoins int64
|
||||||
|
LastRechargedAtMS int64
|
||||||
|
GiftIncomeCoins int64
|
||||||
// Sender* 是扣费时刻的公开展示快照;owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
|
// Sender* 是扣费时刻的公开展示快照;owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
|
||||||
SenderName string
|
SenderName string
|
||||||
SenderAvatar string
|
SenderAvatar string
|
||||||
@ -131,6 +196,7 @@ type CheckResult struct {
|
|||||||
GiftID string
|
GiftID string
|
||||||
GiftPrice int64
|
GiftPrice int64
|
||||||
RuleVersion int64
|
RuleVersion int64
|
||||||
|
StrategyVersion string
|
||||||
TargetRTPPPM int64
|
TargetRTPPPM int64
|
||||||
ExperiencePool string
|
ExperiencePool string
|
||||||
}
|
}
|
||||||
@ -191,11 +257,14 @@ type DrawSummary struct {
|
|||||||
type PoolBalance struct {
|
type PoolBalance struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
PoolID string
|
PoolID string
|
||||||
|
StrategyVersion string
|
||||||
Balance int64
|
Balance int64
|
||||||
ReserveFloor int64
|
ReserveFloor int64
|
||||||
AvailableBalance int64
|
AvailableBalance int64
|
||||||
TotalIn int64
|
TotalIn int64
|
||||||
TotalOut int64
|
TotalOut int64
|
||||||
|
ManualCreditTotal int64
|
||||||
|
ManualDebitTotal int64
|
||||||
Materialized bool
|
Materialized bool
|
||||||
UpdatedAtMS int64
|
UpdatedAtMS int64
|
||||||
}
|
}
|
||||||
@ -203,6 +272,41 @@ type PoolBalance struct {
|
|||||||
type PoolBalanceQuery struct {
|
type PoolBalanceQuery struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
PoolID string
|
PoolID string
|
||||||
|
StrategyVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolAdjustmentCommand 是人工注资/扣资的 owner 命令。AdjustmentID 由调用方生成并在 App 内唯一,
|
||||||
|
// 同一个 ID 的重试只能复用完全相同的资金参数,避免网络重试演变成重复记账。
|
||||||
|
type PoolAdjustmentCommand struct {
|
||||||
|
AppCode string
|
||||||
|
PoolID string
|
||||||
|
StrategyVersion string
|
||||||
|
AdjustmentID string
|
||||||
|
Direction string
|
||||||
|
AmountCoins int64
|
||||||
|
Reason string
|
||||||
|
OperatorAdminID int64
|
||||||
|
RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PoolAdjustment struct {
|
||||||
|
AdjustmentID string
|
||||||
|
AppCode string
|
||||||
|
PoolID string
|
||||||
|
StrategyVersion string
|
||||||
|
Direction string
|
||||||
|
AmountCoins int64
|
||||||
|
Reason string
|
||||||
|
OperatorAdminID int64
|
||||||
|
BalanceBefore int64
|
||||||
|
BalanceAfter int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type PoolAdjustmentResult struct {
|
||||||
|
Adjustment PoolAdjustment
|
||||||
|
Pool PoolBalance
|
||||||
|
IdempotentReplay bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminConfigQuery struct {
|
type AdminConfigQuery struct {
|
||||||
@ -229,6 +333,8 @@ type DrawQuery struct {
|
|||||||
type ExternalDrawCommand struct {
|
type ExternalDrawCommand struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
ExternalUserID string
|
ExternalUserID string
|
||||||
|
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识;dynamic_v3 必填。
|
||||||
|
DeviceID string
|
||||||
RequestID string
|
RequestID string
|
||||||
GiftCount int64
|
GiftCount int64
|
||||||
UnitAmount int64
|
UnitAmount int64
|
||||||
|
|||||||
1058
services/lucky-gift-service/internal/domain/luckygift/strategy.go
Normal file
1058
services/lucky-gift-service/internal/domain/luckygift/strategy.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,454 @@
|
|||||||
|
package luckygift
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scriptedStrategyRandom struct {
|
||||||
|
t *testing.T
|
||||||
|
indexes []int64
|
||||||
|
bounds []int64
|
||||||
|
position int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *scriptedStrategyRandom) Int63n(n int64) (int64, error) {
|
||||||
|
r.t.Helper()
|
||||||
|
if r.position >= len(r.indexes) {
|
||||||
|
return 0, fmt.Errorf("scripted RNG exhausted at bound %d", n)
|
||||||
|
}
|
||||||
|
if len(r.bounds) > r.position && r.bounds[r.position] != n {
|
||||||
|
r.t.Fatalf("random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
|
||||||
|
}
|
||||||
|
value := r.indexes[r.position]
|
||||||
|
r.position++
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func strategyTestConfig() StrategyConfig {
|
||||||
|
config := DefaultLuckyGiftStrategyConfig()
|
||||||
|
// The 0/5/50/200/1000 table is the supplied image's deliberately unsafe
|
||||||
|
// 2650% example. It is a test fixture only; the exported default stays at 98%.
|
||||||
|
config.Tiers = []StrategyTier{
|
||||||
|
{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},
|
||||||
|
}
|
||||||
|
// Core P/W tests isolate the base distribution; dynamic factors have dedicated
|
||||||
|
// boundary tests below and must not silently move scripted intervals here.
|
||||||
|
config.LowWaterThresholdCoins = 0
|
||||||
|
config.HighWaterThresholdCoins = 1_000_000_000
|
||||||
|
config.LowWaterFactorPPM = StrategyPPMScale
|
||||||
|
config.HighWaterFactorPPM = StrategyPPMScale
|
||||||
|
config.RechargeFactorPPM = StrategyPPMScale
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
config.DailyJackpotLimit = 1_000_000
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func strategyTestInput(price int64) StrategyInput {
|
||||||
|
return StrategyInput{GiftPriceCoins: price}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyDeletesUnaffordableTierAndZeroBeforeRedraw(t *testing.T) {
|
||||||
|
config := strategyTestConfig()
|
||||||
|
random := &scriptedStrategyRandom{
|
||||||
|
t: t,
|
||||||
|
// 950000 selects 200x from the original 1,000,000 range. After deleting
|
||||||
|
// 200x+0x, 359999 selects 1000x; the last zero selects affordable 5x.
|
||||||
|
indexes: []int64{950_000, 359_999, 0},
|
||||||
|
bounds: []int64{1_000_000, 360_000, 350_000},
|
||||||
|
}
|
||||||
|
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 800}, strategyTestInput(10), random)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.PoolAfterCoins != 750 {
|
||||||
|
t.Fatalf("decision=%+v", decision)
|
||||||
|
}
|
||||||
|
if got, want := decision.Trace.OriginalTierID, "200x"; got != want {
|
||||||
|
t.Fatalf("original tier=%s want=%s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := decision.Trace.RedrawTierIDs, []string{"1000x", "5x"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("redraws=%v want=%v", got, want)
|
||||||
|
}
|
||||||
|
wantRemoved := []StrategyRemovalTrace{
|
||||||
|
{TierID: "200x", Reason: StrategyReasonPoolInsufficient},
|
||||||
|
{TierID: "0x", Reason: StrategyRemovalPWRedrawZero},
|
||||||
|
{TierID: "1000x", Reason: StrategyReasonPoolInsufficient},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(decision.Trace.Removed, wantRemoved) {
|
||||||
|
t.Fatalf("removed=%+v want=%+v", decision.Trace.Removed, wantRemoved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyPWEqualityAndCandidateExhaustion(t *testing.T) {
|
||||||
|
t.Run("W equals P is payable", func(t *testing.T) {
|
||||||
|
config := strategyTestConfig()
|
||||||
|
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 2_000}, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.PoolAfterCoins != 0 {
|
||||||
|
t.Fatalf("P=W decision=%+v", decision)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all positive tiers impossible falls back to zero", func(t *testing.T) {
|
||||||
|
config := strategyTestConfig()
|
||||||
|
random := &scriptedStrategyRandom{
|
||||||
|
t: t,
|
||||||
|
// Exhaust 1000x, 200x, 50x, then 5x. Zero is removed with the first
|
||||||
|
// failed P/W comparison, so no extra random call can hide exhaustion.
|
||||||
|
indexes: []int64{999_999, 350_000, 349_999, 0},
|
||||||
|
bounds: []int64{1_000_000, 390_000, 350_000, 200_000},
|
||||||
|
}
|
||||||
|
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49}, strategyTestInput(10), random)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decision.PayoutCoins != 0 || decision.SelectedTier.ID != "0x" || decision.Trace.FinalReason != StrategyReasonNoPayableTier {
|
||||||
|
t.Fatalf("exhausted decision=%+v", decision)
|
||||||
|
}
|
||||||
|
if decision.NextState.PoolBalanceCoins < 0 {
|
||||||
|
t.Fatalf("pool became negative: %d", decision.NextState.PoolBalanceCoins)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyMissProtectionSixthDraw(t *testing.T) {
|
||||||
|
config := strategyTestConfig()
|
||||||
|
state := StrategyState{PoolBalanceCoins: 800, ConsecutiveZeroDraws: 5}
|
||||||
|
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{400_000}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.NextState.ConsecutiveZeroDraws != 0 {
|
||||||
|
t.Fatalf("protected decision=%+v", decision)
|
||||||
|
}
|
||||||
|
if len(decision.Trace.Removed) == 0 || decision.Trace.Removed[0].Reason != StrategyRemovalMissProtection {
|
||||||
|
t.Fatalf("zero exclusion not traced: %+v", decision.Trace.Removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protection cannot manufacture money: when even 5x exceeds P, the strategy
|
||||||
|
// returns zero and leaves an explicit blocked reason instead of overdrawing.
|
||||||
|
blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49, ConsecutiveZeroDraws: 5}, strategyTestInput(10), &scriptedStrategyRandom{t: t})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonMissProtectionBlocked || blocked.NextState.PoolBalanceCoins != 49 {
|
||||||
|
t.Fatalf("blocked protection=%+v", blocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyDynamicWeightBoundariesAndMultiplication(t *testing.T) {
|
||||||
|
config := strategyTestConfig()
|
||||||
|
config.LowWaterThresholdCoins = 1_000
|
||||||
|
config.HighWaterThresholdCoins = 2_000
|
||||||
|
config.LowWaterFactorPPM = 700_000
|
||||||
|
config.HighWaterFactorPPM = 1_300_000
|
||||||
|
config.RechargeFactorPPM = 1_100_000
|
||||||
|
config.JackpotMechanism1Enabled = false
|
||||||
|
config.JackpotMechanism2Enabled = false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pool int64
|
||||||
|
rechargeAge int64
|
||||||
|
hasRecharge bool
|
||||||
|
want5x int64
|
||||||
|
wantFactorNames []string
|
||||||
|
}{
|
||||||
|
{name: "low below threshold", pool: 999, want5x: 140_000, wantFactorNames: []string{"low_water"}},
|
||||||
|
{name: "low equality is middle", pool: 1_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
|
||||||
|
{name: "high equality is middle", pool: 2_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
|
||||||
|
{name: "high above threshold", pool: 2_001, want5x: 260_000, wantFactorNames: []string{"high_water"}},
|
||||||
|
{name: "recharge starts inclusive and multiplies low", pool: 999, hasRecharge: true, rechargeAge: 0, want5x: 154_000, wantFactorNames: []string{"low_water", "recent_recharge"}},
|
||||||
|
{name: "recharge end minus one inclusive", pool: 1_500, hasRecharge: true, rechargeAge: 299_999, want5x: 220_000, wantFactorNames: []string{"recent_recharge"}},
|
||||||
|
{name: "recharge end exclusive", pool: 1_500, hasRecharge: true, rechargeAge: 300_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
input := strategyTestInput(10)
|
||||||
|
input.NowMS = 1_000_000
|
||||||
|
input.HasRechargeFact = tt.hasRecharge
|
||||||
|
input.LastRechargeAtMS = input.NowMS - tt.rechargeAge
|
||||||
|
_, traces, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{PoolBalanceCoins: tt.pool}, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trace := findWeightTrace(t, traces, "5x")
|
||||||
|
if trace.AdjustedWeightPPM != tt.want5x {
|
||||||
|
t.Fatalf("5x adjusted=%d want=%d full=%+v", trace.AdjustedWeightPPM, tt.want5x, trace)
|
||||||
|
}
|
||||||
|
factorNames := make([]string, 0, len(trace.Factors))
|
||||||
|
for _, factor := range trace.Factors {
|
||||||
|
factorNames = append(factorNames, factor.Name)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(factorNames, tt.wantFactorNames) {
|
||||||
|
t.Fatalf("factor names=%v want=%v", factorNames, tt.wantFactorNames)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, item := range traces {
|
||||||
|
total += item.AdjustedWeightPPM
|
||||||
|
}
|
||||||
|
if total != StrategyPPMScale {
|
||||||
|
t.Fatalf("weights sum=%d", total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyJackpotMechanismOneRequiresAllNonZeroWindows(t *testing.T) {
|
||||||
|
config := strategyTestConfig()
|
||||||
|
config.JackpotMechanism1Enabled = true
|
||||||
|
config.DailyJackpotLimit = 5
|
||||||
|
base := StrategyState{
|
||||||
|
PoolBalanceCoins: 10_000,
|
||||||
|
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||||||
|
UserDayRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||||||
|
User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*StrategyState)
|
||||||
|
wantPrize bool
|
||||||
|
}{
|
||||||
|
{name: "all inclusive boundaries pass", wantPrize: true},
|
||||||
|
{name: "global above 98", mutate: func(s *StrategyState) { s.GlobalRTP.PayoutCoins = 99 }},
|
||||||
|
{name: "day above 96", mutate: func(s *StrategyState) { s.UserDayRTP.PayoutCoins = 97 }},
|
||||||
|
{name: "72h above 96", mutate: func(s *StrategyState) { s.User72HourRTP.PayoutCoins = 97 }},
|
||||||
|
{name: "global denominator zero", mutate: func(s *StrategyState) { s.GlobalRTP = StrategyRTP{} }},
|
||||||
|
{name: "day denominator zero", mutate: func(s *StrategyState) { s.UserDayRTP = StrategyRTP{} }},
|
||||||
|
{name: "72h denominator zero", mutate: func(s *StrategyState) { s.User72HourRTP = StrategyRTP{} }},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
state := base
|
||||||
|
if tt.mutate != nil {
|
||||||
|
tt.mutate(&state)
|
||||||
|
}
|
||||||
|
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(7))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := decision.JackpotMechanism == StrategyJackpotMechanismRTPCompensation
|
||||||
|
if got != tt.wantPrize {
|
||||||
|
t.Fatalf("mechanism1=%v want=%v trace=%+v", got, tt.wantPrize, decision.Trace)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) {
|
||||||
|
if got := MilestoneTokensEarned(49, 101, 50); got != 2 {
|
||||||
|
t.Fatalf("crossed tokens=%d want=2", got)
|
||||||
|
}
|
||||||
|
config := strategyTestConfig()
|
||||||
|
config.JackpotMechanism2Enabled = true
|
||||||
|
config.DailyJackpotLimit = 5
|
||||||
|
config.MilestoneSpendCoins = 50
|
||||||
|
state := StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}
|
||||||
|
blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(11))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if blocked.ConsumedMilestoneToken || blocked.NextState.PendingMilestoneTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
|
||||||
|
t.Fatalf("blocked token decision=%+v", blocked)
|
||||||
|
}
|
||||||
|
if len(blocked.Trace.Removed) < 3 {
|
||||||
|
t.Fatalf("blocked jackpot removals=%+v want all 200/500/1000 tiers", blocked.Trace.Removed)
|
||||||
|
}
|
||||||
|
for _, removed := range blocked.Trace.Removed[:3] {
|
||||||
|
if removed.Reason != StrategyReasonPoolInsufficient {
|
||||||
|
t.Fatalf("blocked jackpot removal=%+v", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state = blocked.NextState
|
||||||
|
state.PoolBalanceCoins = 10_000
|
||||||
|
paid, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(12))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !paid.ConsumedMilestoneToken || paid.NextState.PendingMilestoneTokens != 0 || !paid.Jackpot || paid.PayoutCoins <= 0 {
|
||||||
|
t.Fatalf("consumed token decision=%+v", paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crossing happens during finalize, after this draw has already been selected.
|
||||||
|
// The token must therefore be persisted for the next request, not consumed now.
|
||||||
|
crossingState := StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}
|
||||||
|
crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if crossing.ConsumedMilestoneToken || crossing.NextState.PendingMilestoneTokens != 1 || crossing.Trace.MilestoneTokensEarned != 1 {
|
||||||
|
t.Fatalf("crossing decision=%+v", crossing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyDailyJackpotLimitAlsoConstrainsBaseDraw(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}}
|
||||||
|
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 {
|
||||||
|
t.Fatalf("daily-limit decision=%+v", decision)
|
||||||
|
}
|
||||||
|
if decision.Trace.Draws[0].RemovedReason != StrategyReasonDailyJackpotLimit {
|
||||||
|
t.Fatalf("daily-limit removal=%+v", decision.Trace.Draws)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategySixRiskCapacitiesAreHardInclusiveBounds(t *testing.T) {
|
||||||
|
config := strategyTestConfig()
|
||||||
|
config.Tiers = []StrategyTier{
|
||||||
|
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
|
||||||
|
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: StrategyPPMScale, Enabled: true},
|
||||||
|
}
|
||||||
|
setters := []struct {
|
||||||
|
name string
|
||||||
|
set func(*StrategyRiskCapacity, int64)
|
||||||
|
}{
|
||||||
|
{name: "single draw", 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 _, setter := range setters {
|
||||||
|
t.Run(setter.name, func(t *testing.T) {
|
||||||
|
capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
|
||||||
|
setter.set(&capacity, 50)
|
||||||
|
input := strategyTestInput(10)
|
||||||
|
input.RiskCapacity = capacity
|
||||||
|
allowed, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if allowed.PayoutCoins != 50 || allowed.PoolAfterCoins != 0 {
|
||||||
|
t.Fatalf("boundary must pass: %+v", allowed)
|
||||||
|
}
|
||||||
|
setter.set(&capacity, 49)
|
||||||
|
input.RiskCapacity = capacity
|
||||||
|
blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonNoPayableTier {
|
||||||
|
t.Fatalf("below boundary must block: %+v", blocked)
|
||||||
|
}
|
||||||
|
if len(blocked.Trace.Removed) != 1 || blocked.Trace.Removed[0].TierID != "5x" || blocked.Trace.Removed[0].Reason != StrategyReasonRiskCapacity {
|
||||||
|
t.Fatalf("risk retry must not remove zero: %+v", blocked.Trace.Removed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyRechargeStagesAndFundConservation(t *testing.T) {
|
||||||
|
config := DefaultLuckyGiftStrategyConfig()
|
||||||
|
config.RechargeStages = []StrategyRechargeStage{
|
||||||
|
{Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
|
||||||
|
{Name: StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
|
||||||
|
{Name: StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
|
||||||
|
}
|
||||||
|
stageCases := []struct {
|
||||||
|
seven, thirty int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{seven: 0, thirty: 0, want: StageNovice},
|
||||||
|
// 两个非零充值仍低于 normal,必须留在 novice;novice 不是“只有零充值”。
|
||||||
|
{seven: 99, thirty: 499, want: StageNovice},
|
||||||
|
{seven: 100, thirty: 499, want: StageNovice},
|
||||||
|
{seven: 99, thirty: 500, want: StageNovice},
|
||||||
|
// 两维等于门槛都算达到;任一维未达 advanced 时仍保持 normal。
|
||||||
|
{seven: 100, thirty: 500, want: StageNormal},
|
||||||
|
{seven: 500, thirty: 1_999, want: StageNormal},
|
||||||
|
{seven: 499, thirty: 2_000, want: StageNormal},
|
||||||
|
{seven: 500, thirty: 2_000, want: StageAdvanced},
|
||||||
|
}
|
||||||
|
for _, tt := range stageCases {
|
||||||
|
if got := SelectLuckyGiftRechargeStage(config, tt.seven, tt.thirty); got != tt.want {
|
||||||
|
t.Fatalf("stage(%d,%d)=%s want=%s", tt.seven, tt.thirty, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
split, err := SplitLuckyGiftStrategyFunds(100, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if split != (StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
|
||||||
|
t.Fatalf("split=%+v", split)
|
||||||
|
}
|
||||||
|
if split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins != 100 {
|
||||||
|
t.Fatal("fund split did not conserve money")
|
||||||
|
}
|
||||||
|
small, err := SplitLuckyGiftStrategyFunds(10, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if small != (StrategyFundSplit{PublicPoolCoins: 9, ProfitPoolCoins: 1, AnchorReturnCoins: 0}) {
|
||||||
|
t.Fatalf("small split=%+v want public floor with residue in profit", small)
|
||||||
|
}
|
||||||
|
if initial := InitialLuckyGiftStrategyState(config); initial.PoolBalanceCoins != config.ColdStartPoolCoins || initial.PoolBalanceCoins < 0 {
|
||||||
|
t.Fatalf("initial=%+v", initial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultLuckyGiftStrategyNominalEVIsExactlyNinetyEightPercent(t *testing.T) {
|
||||||
|
config := DefaultLuckyGiftStrategyConfig()
|
||||||
|
var weightedMultiplierMicros int64
|
||||||
|
for _, tier := range config.Tiers {
|
||||||
|
weightedMultiplierMicros += tier.BaseWeightPPM * tier.MultiplierPPM / StrategyPPMScale
|
||||||
|
}
|
||||||
|
if weightedMultiplierMicros != 980_000 {
|
||||||
|
t.Fatalf("default nominal EV=%dppm want=980000ppm", weightedMultiplierMicros)
|
||||||
|
}
|
||||||
|
for _, tier := range config.Tiers {
|
||||||
|
if tier.Jackpot && tier.BaseWeightPPM != 0 {
|
||||||
|
t.Fatalf("default jackpot tier %s leaked into ordinary probability", tier.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyGiftStrategyCombinationPriority(t *testing.T) {
|
||||||
|
config := strategyTestConfig()
|
||||||
|
config.JackpotMechanism1Enabled = true
|
||||||
|
config.JackpotMechanism2Enabled = true
|
||||||
|
config.DailyJackpotLimit = 5
|
||||||
|
state := StrategyState{
|
||||||
|
PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
|
||||||
|
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||||||
|
UserDayRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||||||
|
User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||||||
|
}
|
||||||
|
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(8))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decision.JackpotMechanism != StrategyJackpotMechanismMilestone || !decision.ConsumedMilestoneToken {
|
||||||
|
t.Fatalf("milestone must outrank mechanism1/miss protection: %+v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWeightTrace(t *testing.T, traces []StrategyWeightTrace, tierID string) StrategyWeightTrace {
|
||||||
|
t.Helper()
|
||||||
|
for _, trace := range traces {
|
||||||
|
if trace.TierID == tierID {
|
||||||
|
return trace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("weight trace %s not found", tierID)
|
||||||
|
return StrategyWeightTrace{}
|
||||||
|
}
|
||||||
@ -13,40 +13,45 @@ const (
|
|||||||
highTierMultiplierPPM int64 = 10_000_000
|
highTierMultiplierPPM int64 = 10_000_000
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultRuleConfig 返回 v2 配置草稿。草稿默认 disabled,只有后台发布后才写入不可变版本表。
|
// DefaultRuleConfig 返回 dynamic_v3 配置草稿。大奖累计消费门槛、充值分层和金额型风控上限都没有跨 App 通用值,
|
||||||
|
// 因而保留未配置 sentinel 且草稿默认 disabled;运营显式补齐这些值并启用后,才允许发布可运行版本。
|
||||||
func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
|
func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
|
||||||
poolID = normalizePoolID(poolID)
|
poolID = normalizePoolID(poolID)
|
||||||
return domain.RuleConfig{
|
return domain.RuleConfig{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
PoolID: poolID,
|
PoolID: poolID,
|
||||||
|
StrategyVersion: domain.StrategyDynamicV3,
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
TargetRTPPPM: 950_000,
|
TargetRTPPPM: 980_000,
|
||||||
PoolRatePPM: 960_000,
|
PoolRatePPM: 980_000,
|
||||||
|
ProfitRatePPM: 10_000,
|
||||||
|
AnchorRatePPM: 10_000,
|
||||||
SettlementWindowWager: 1_000_000,
|
SettlementWindowWager: 1_000_000,
|
||||||
ControlBandPPM: 10_000,
|
ControlBandPPM: 30_000,
|
||||||
GiftPriceReference: 100,
|
GiftPriceReference: 100,
|
||||||
|
// dynamic_v3 的账本从 0 开始,任何启动资金都必须经过带管理员和原因的人工注资事实。
|
||||||
|
InitialPoolCoins: 0,
|
||||||
|
LossStreakGuarantee: 5,
|
||||||
|
LowWatermarkCoins: 10_000_000,
|
||||||
|
LowWaterNonzeroFactorPPM: 700_000,
|
||||||
|
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,
|
||||||
|
JackpotUser72hRTPMaxPPM: 960_000,
|
||||||
|
MaxJackpotHitsPerUserDay: 5,
|
||||||
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
|
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
|
||||||
NoviceMaxEquivalentDraws: 2_000,
|
NoviceMaxEquivalentDraws: 2_000,
|
||||||
NormalMaxEquivalentDraws: 20_000,
|
NormalMaxEquivalentDraws: 20_000,
|
||||||
Stages: []domain.RuleStage{
|
Stages: []domain.RuleStage{
|
||||||
defaultRuleStage(domain.StageNovice, []ruleTierSeed{
|
// novice 的 0/0 只是协议和存储 sentinel;运行时未同时达到 normal 两个下限的用户都回落 novice。
|
||||||
{id: "novice_none", multiplierPPM: 0, weightPPM: 100_000},
|
defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)),
|
||||||
{id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
|
// disabled 草稿不猜测金币门槛;normal/advanced 必须由运营按 App 口径配置后才能启用。
|
||||||
{id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
|
defaultRuleStage(domain.StageNormal, 0, 0, defaultSafeTierSeeds(domain.StageNormal)),
|
||||||
{id: "novice_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
|
defaultRuleStage(domain.StageAdvanced, 0, 0, defaultSafeTierSeeds(domain.StageAdvanced)),
|
||||||
}),
|
|
||||||
defaultRuleStage(domain.StageNormal, []ruleTierSeed{
|
|
||||||
{id: "normal_none", multiplierPPM: 0, weightPPM: 100_000},
|
|
||||||
{id: "normal_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
|
|
||||||
{id: "normal_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
|
|
||||||
{id: "normal_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
|
|
||||||
}),
|
|
||||||
defaultRuleStage(domain.StageAdvanced, []ruleTierSeed{
|
|
||||||
{id: "advanced_none", multiplierPPM: 0, weightPPM: 270_000},
|
|
||||||
{id: "advanced_1x", multiplierPPM: 1_000_000, weightPPM: 600_000},
|
|
||||||
{id: "advanced_2x", multiplierPPM: 2_000_000, weightPPM: 100_000},
|
|
||||||
{id: "advanced_5x", multiplierPPM: 5_000_000, weightPPM: 30_000},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,8 +62,24 @@ type ruleTierSeed struct {
|
|||||||
weightPPM int64
|
weightPPM int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage {
|
// defaultSafeTierSeeds 保留明确的 0x 和少量 0.5x 小奖,并让每个阶段的静态期望精确为 98%。
|
||||||
out := domain.RuleStage{Stage: stage, Tiers: make([]domain.RuleTier, 0, len(tiers))}
|
// 200x/500x/1000x 只登记为 jackpot 倍率,不在缺少产品概率时照搬图片示例塞进基础 tier。
|
||||||
|
func defaultSafeTierSeeds(stage string) []ruleTierSeed {
|
||||||
|
return []ruleTierSeed{
|
||||||
|
{id: stage + "_none", multiplierPPM: 0, weightPPM: 50_000},
|
||||||
|
{id: stage + "_0_5x", multiplierPPM: 500_000, weightPPM: 40_000},
|
||||||
|
{id: stage + "_1x", multiplierPPM: 1_000_000, weightPPM: 860_000},
|
||||||
|
{id: stage + "_2x", multiplierPPM: 2_000_000, weightPPM: 50_000},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int64, tiers []ruleTierSeed) domain.RuleStage {
|
||||||
|
out := domain.RuleStage{
|
||||||
|
Stage: stage,
|
||||||
|
MinRecharge7DCoins: minRecharge7DCoins,
|
||||||
|
MinRecharge30DCoins: minRecharge30DCoins,
|
||||||
|
Tiers: make([]domain.RuleTier, 0, len(tiers)),
|
||||||
|
}
|
||||||
for _, tier := range tiers {
|
for _, tier := range tiers {
|
||||||
out.Tiers = append(out.Tiers, domain.RuleTier{
|
out.Tiers = append(out.Tiers, domain.RuleTier{
|
||||||
Stage: stage,
|
Stage: stage,
|
||||||
@ -74,6 +95,11 @@ func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage {
|
|||||||
|
|
||||||
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
|
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
|
||||||
config.PoolID = normalizePoolID(config.PoolID)
|
config.PoolID = normalizePoolID(config.PoolID)
|
||||||
|
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
||||||
|
if config.StrategyVersion == "" {
|
||||||
|
// 空值只可能来自升级前的客户端或旧持久行;归一为 fixed_v2 才不会让新动态校验改变历史发布语义。
|
||||||
|
config.StrategyVersion = domain.StrategyFixedV2
|
||||||
|
}
|
||||||
for stageIndex, stage := range config.Stages {
|
for stageIndex, stage := range config.Stages {
|
||||||
stage.Stage = strings.TrimSpace(stage.Stage)
|
stage.Stage = strings.TrimSpace(stage.Stage)
|
||||||
for tierIndex, tier := range stage.Tiers {
|
for tierIndex, tier := range stage.Tiers {
|
||||||
@ -111,6 +137,13 @@ func validateRuleConfig(config domain.RuleConfig) error {
|
|||||||
if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws {
|
if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws {
|
||||||
return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid")
|
return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid")
|
||||||
}
|
}
|
||||||
|
strategyVersion := strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
||||||
|
if strategyVersion == "" {
|
||||||
|
strategyVersion = domain.StrategyFixedV2
|
||||||
|
}
|
||||||
|
if strategyVersion != domain.StrategyFixedV2 && strategyVersion != domain.StrategyDynamicV3 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("unsupported lucky gift strategy version: %s", config.StrategyVersion))
|
||||||
|
}
|
||||||
stageMap := make(map[string]domain.RuleStage, len(config.Stages))
|
stageMap := make(map[string]domain.RuleStage, len(config.Stages))
|
||||||
for _, stage := range config.Stages {
|
for _, stage := range config.Stages {
|
||||||
if !validRuleStage(stage.Stage) {
|
if !validRuleStage(stage.Stage) {
|
||||||
@ -133,6 +166,128 @@ func validateRuleConfig(config domain.RuleConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
if err := validateDynamicRuleConfig(config, stageMap); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDynamicRuleConfig 只约束 dynamic_v3;fixed_v2 的历史不可变版本即使没有这些字段也必须可以继续读取和重发。
|
||||||
|
func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domain.RuleStage) error {
|
||||||
|
if config.ProfitRatePPM < 0 || config.AnchorRatePPM < 0 || config.PoolRatePPM+config.ProfitRatePPM+config.AnchorRatePPM != ppmScale {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 pool, profit and anchor rates must be non-negative and sum to 100%")
|
||||||
|
}
|
||||||
|
if config.InitialPoolCoins != 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero; fund the pool through an audited credit adjustment")
|
||||||
|
}
|
||||||
|
if config.LossStreakGuarantee <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 loss streak guarantee must be positive")
|
||||||
|
}
|
||||||
|
if config.LowWatermarkCoins <= 0 || config.HighWatermarkCoins <= config.LowWatermarkCoins {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 watermarks must be positive and high watermark must exceed low watermark")
|
||||||
|
}
|
||||||
|
if config.LowWaterNonzeroFactorPPM <= 0 || config.LowWaterNonzeroFactorPPM >= ppmScale {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 low-water nonzero factor must be between 0% and 100%")
|
||||||
|
}
|
||||||
|
if config.HighWaterNonzeroFactorPPM <= ppmScale {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 high-water nonzero factor must exceed 100%")
|
||||||
|
}
|
||||||
|
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 err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale ||
|
||||||
|
config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
|
||||||
|
config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > 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 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 daily jackpot hit cap must be positive")
|
||||||
|
}
|
||||||
|
if config.JackpotSpendThresholdCoins < 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins cannot be negative")
|
||||||
|
}
|
||||||
|
if err := validateDynamicPayoutCaps(config, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateDynamicStageThresholds(stages, config.Enabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !config.Enabled {
|
||||||
|
// disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if config.JackpotSpendThresholdCoins <= 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateJackpotMultipliers(multiplierPPMs []int64) error {
|
||||||
|
if len(multiplierPPMs) == 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 must configure at least one jackpot multiplier")
|
||||||
|
}
|
||||||
|
var previous int64
|
||||||
|
for _, multiplierPPM := range multiplierPPMs {
|
||||||
|
if multiplierPPM <= ppmScale || multiplierPPM <= previous {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot multipliers must exceed 1x and be strictly increasing")
|
||||||
|
}
|
||||||
|
previous = multiplierPPM
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDynamicStageThresholds(stages map[string]domain.RuleStage, requireConfigured bool) error {
|
||||||
|
novice := stages[domain.StageNovice]
|
||||||
|
normal := stages[domain.StageNormal]
|
||||||
|
advanced := stages[domain.StageAdvanced]
|
||||||
|
if novice.MinRecharge7DCoins != 0 || novice.MinRecharge30DCoins != 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 novice recharge thresholds must stay 0/0 as protocol and storage sentinels")
|
||||||
|
}
|
||||||
|
if normal.MinRecharge7DCoins < 0 || normal.MinRecharge30DCoins < 0 || advanced.MinRecharge7DCoins < 0 || advanced.MinRecharge30DCoins < 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge thresholds cannot be negative")
|
||||||
|
}
|
||||||
|
thresholdsUnset := normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 &&
|
||||||
|
advanced.MinRecharge7DCoins == 0 && advanced.MinRecharge30DCoins == 0
|
||||||
|
if !requireConfigured && thresholdsUnset {
|
||||||
|
// 未启用版本只在 normal/advanced 四个值全部为 0 时视为“纯未配置”草稿。
|
||||||
|
// 一旦运营开始填写任一门槛,即使仍 disabled 也必须保持完整单调边界,避免保存一份无法继续发布的半成品。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 normal stage must configure a positive 7-day or 30-day recharge threshold before enabling")
|
||||||
|
}
|
||||||
|
if advanced.MinRecharge7DCoins < normal.MinRecharge7DCoins || advanced.MinRecharge30DCoins < normal.MinRecharge30DCoins ||
|
||||||
|
(advanced.MinRecharge7DCoins == normal.MinRecharge7DCoins && advanced.MinRecharge30DCoins == normal.MinRecharge30DCoins) {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "dynamic_v3 advanced recharge thresholds must be component-wise monotonic and exceed normal in at least one dimension")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDynamicPayoutCaps(config domain.RuleConfig, requirePositive bool) error {
|
||||||
|
caps := []struct {
|
||||||
|
name string
|
||||||
|
value int64
|
||||||
|
}{
|
||||||
|
{name: "max_single_payout", value: config.MaxSinglePayout},
|
||||||
|
{name: "user_hourly_payout_cap", value: config.UserHourlyPayoutCap},
|
||||||
|
{name: "user_daily_payout_cap", value: config.UserDailyPayoutCap},
|
||||||
|
{name: "device_daily_payout_cap", value: config.DeviceDailyPayoutCap},
|
||||||
|
{name: "room_hourly_payout_cap", value: config.RoomHourlyPayoutCap},
|
||||||
|
{name: "anchor_daily_payout_cap", value: config.AnchorDailyPayoutCap},
|
||||||
|
}
|
||||||
|
for _, cap := range caps {
|
||||||
|
if cap.value < 0 || (requirePositive && cap.value == 0) {
|
||||||
|
if !requirePositive {
|
||||||
|
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s cannot be negative", cap.name))
|
||||||
|
}
|
||||||
|
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s must be positive before enabling", cap.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,136 @@
|
|||||||
|
package luckygift
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultRuleConfigUsesSafeDynamicV3Draft(t *testing.T) {
|
||||||
|
config := DefaultRuleConfig("lalu", "lucky")
|
||||||
|
if config.StrategyVersion != domain.StrategyDynamicV3 || config.Enabled {
|
||||||
|
t.Fatalf("default strategy=%q enabled=%v, want disabled dynamic_v3 draft", config.StrategyVersion, config.Enabled)
|
||||||
|
}
|
||||||
|
if config.TargetRTPPPM != 980_000 || config.PoolRatePPM != 980_000 || config.ProfitRatePPM != 10_000 || config.AnchorRatePPM != 10_000 {
|
||||||
|
t.Fatalf("default RTP/fund split mismatch: %+v", config)
|
||||||
|
}
|
||||||
|
if config.ControlBandPPM != 30_000 || config.InitialPoolCoins != 0 || config.LossStreakGuarantee != 5 {
|
||||||
|
t.Fatalf("default control values mismatch: %+v", config)
|
||||||
|
}
|
||||||
|
if config.LowWatermarkCoins != 10_000_000 || config.LowWaterNonzeroFactorPPM != 700_000 ||
|
||||||
|
config.HighWatermarkCoins != 20_000_000 || config.HighWaterNonzeroFactorPPM != 1_300_000 {
|
||||||
|
t.Fatalf("default water controls mismatch: %+v", config)
|
||||||
|
}
|
||||||
|
if config.RechargeBoostWindowMS != 300_000 || config.RechargeBoostFactorPPM != 1_100_000 {
|
||||||
|
t.Fatalf("default recharge boost mismatch: %+v", config)
|
||||||
|
}
|
||||||
|
if config.JackpotGlobalRTPMaxPPM != 980_000 || config.JackpotUserDayRTPMaxPPM != 960_000 ||
|
||||||
|
config.JackpotUser72hRTPMaxPPM != 960_000 || config.MaxJackpotHitsPerUserDay != 5 {
|
||||||
|
t.Fatalf("default jackpot controls mismatch: %+v", config)
|
||||||
|
}
|
||||||
|
if config.JackpotSpendThresholdCoins != 0 || config.MaxSinglePayout != 0 || config.AnchorDailyPayoutCap != 0 {
|
||||||
|
t.Fatalf("App-specific monetary limits must stay unset in disabled draft: %+v", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantThresholds := [][2]int64{{0, 0}, {0, 0}, {0, 0}}
|
||||||
|
for index, stage := range config.Stages {
|
||||||
|
if got := [2]int64{stage.MinRecharge7DCoins, stage.MinRecharge30DCoins}; got != wantThresholds[index] {
|
||||||
|
t.Fatalf("stage %s thresholds=%v, want %v", stage.Stage, got, wantThresholds[index])
|
||||||
|
}
|
||||||
|
var expectedRTPPPM int64
|
||||||
|
for _, tier := range stage.Tiers {
|
||||||
|
if tier.Enabled {
|
||||||
|
expectedRTPPPM += tier.MultiplierPPM * tier.BaseWeightPPM / ppmScale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if expectedRTPPPM != 980_000 {
|
||||||
|
t.Fatalf("stage %s static expected RTP=%d, want safe 980000", stage.Stage, expectedRTPPPM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateRuleConfig(config); err != nil {
|
||||||
|
t.Fatalf("disabled dynamic_v3 draft should validate before App-specific monetary limits are known: %v", err)
|
||||||
|
}
|
||||||
|
config.Enabled = true
|
||||||
|
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "normal stage") {
|
||||||
|
t.Fatalf("enabled draft error=%v, want explicit normal recharge threshold guidance", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDynamicV3RejectsImplicitInitialFunding(t *testing.T) {
|
||||||
|
config := DefaultRuleConfig("lalu", "lucky")
|
||||||
|
config.InitialPoolCoins = 1_000_000
|
||||||
|
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "audited credit adjustment") {
|
||||||
|
t.Fatalf("implicit V3 seed error=%v, want audited credit guidance", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDynamicV3RequiresExplicitMonetaryRiskLimitsWhenEnabled(t *testing.T) {
|
||||||
|
config := DefaultRuleConfig("lalu", "lucky")
|
||||||
|
config.Stages[1].MinRecharge7DCoins = 100
|
||||||
|
config.Stages[1].MinRecharge30DCoins = 500
|
||||||
|
config.Stages[2].MinRecharge7DCoins = 500
|
||||||
|
config.Stages[2].MinRecharge30DCoins = 2_000
|
||||||
|
config.Enabled = true
|
||||||
|
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "cumulative spend threshold") {
|
||||||
|
t.Fatalf("enabled draft error=%v, want explicit cumulative spend coin threshold guidance", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.JackpotSpendThresholdCoins = 5_000
|
||||||
|
config.MaxSinglePayout = 10_000
|
||||||
|
config.UserHourlyPayoutCap = 20_000
|
||||||
|
config.UserDailyPayoutCap = 30_000
|
||||||
|
config.DeviceDailyPayoutCap = 40_000
|
||||||
|
config.RoomHourlyPayoutCap = 50_000
|
||||||
|
config.AnchorDailyPayoutCap = 60_000
|
||||||
|
if err := validateRuleConfig(config); err != nil {
|
||||||
|
t.Fatalf("fully configured dynamic_v3 should validate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.RoomHourlyPayoutCap = 0
|
||||||
|
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "room_hourly_payout_cap") {
|
||||||
|
t.Fatalf("zero six-dimensional cap error=%v, want the missing cap name", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) {
|
||||||
|
config := DefaultRuleConfig("lalu", "lucky")
|
||||||
|
config.Stages[1].MinRecharge7DCoins = 100
|
||||||
|
config.Stages[1].MinRecharge30DCoins = 500
|
||||||
|
config.Stages[2].MinRecharge7DCoins = 500
|
||||||
|
config.Stages[2].MinRecharge30DCoins = 2_000
|
||||||
|
config.Stages[2].MinRecharge7DCoins = config.Stages[1].MinRecharge7DCoins
|
||||||
|
config.Stages[2].MinRecharge30DCoins = config.Stages[1].MinRecharge30DCoins
|
||||||
|
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "exceed normal") {
|
||||||
|
t.Fatalf("equal normal/advanced thresholds error=%v, want strict stage separation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) {
|
||||||
|
config := DefaultRuleConfig("lalu", "lucky")
|
||||||
|
config.StrategyVersion = ""
|
||||||
|
config.Enabled = true
|
||||||
|
// 升级前请求没有任何 dynamic_v3 字段;空 strategy_version 必须按 fixed_v2 解释,不能要求动态消费门槛或六维 cap。
|
||||||
|
config.ProfitRatePPM = 0
|
||||||
|
config.AnchorRatePPM = 0
|
||||||
|
config.InitialPoolCoins = 0
|
||||||
|
config.LossStreakGuarantee = 0
|
||||||
|
config.LowWatermarkCoins = 0
|
||||||
|
config.LowWaterNonzeroFactorPPM = 0
|
||||||
|
config.HighWatermarkCoins = 0
|
||||||
|
config.HighWaterNonzeroFactorPPM = 0
|
||||||
|
config.RechargeBoostWindowMS = 0
|
||||||
|
config.RechargeBoostFactorPPM = 0
|
||||||
|
config.JackpotMultiplierPPMs = nil
|
||||||
|
config.JackpotGlobalRTPMaxPPM = 0
|
||||||
|
config.JackpotUserDayRTPMaxPPM = 0
|
||||||
|
config.JackpotUser72hRTPMaxPPM = 0
|
||||||
|
config.MaxJackpotHitsPerUserDay = 0
|
||||||
|
if err := validateRuleConfig(config); err != nil {
|
||||||
|
t.Fatalf("legacy fixed_v2 config was broken by dynamic validation: %v", err)
|
||||||
|
}
|
||||||
|
normalized := normalizeRuleConfig(config)
|
||||||
|
if normalized.StrategyVersion != domain.StrategyFixedV2 {
|
||||||
|
t.Fatalf("legacy empty strategy normalized to %q, want fixed_v2", normalized.StrategyVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,51 @@ func TestNormalizeDrawCommandRejectsOversizedInternalGiftWork(t *testing.T) {
|
|||||||
if _, err := svc.normalizeDrawCommand(oversizedID); err == nil {
|
if _, err := svc.normalizeDrawCommand(oversizedID); err == nil {
|
||||||
t.Fatal("command_id above 128 bytes was accepted")
|
t.Fatal("command_id above 128 bytes was accepted")
|
||||||
}
|
}
|
||||||
|
normalized, err := svc.normalizeDrawCommand(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize valid command: %v", err)
|
||||||
|
}
|
||||||
|
if normalized.PaidAtMS != 0 {
|
||||||
|
t.Fatalf("service layer invented wallet paid_at_ms: %d", normalized.PaidAtMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDrawCommandKeepsLegacyMissingDeviceForRuleAwareValidation(t *testing.T) {
|
||||||
|
svc := &Service{}
|
||||||
|
normalized, err := svc.normalizeDrawCommand(domain.DrawCommand{
|
||||||
|
CommandID: "cmd-fixed-legacy-device", UserID: 1, TargetUserID: 2,
|
||||||
|
RoomID: "room-1", AnchorID: "2", GiftID: "rose", GiftCount: 1, CoinSpent: 10,
|
||||||
|
})
|
||||||
|
if err != nil || normalized.DeviceID != "" {
|
||||||
|
// normalize 尚未读到不可变规则;此处拒绝会破坏 fixed_v2 存量 JWT/command log 重放。
|
||||||
|
// 真正的 dynamic_v3 空设备拒绝在 MySQL 规则事务入口完成。
|
||||||
|
t.Fatalf("legacy missing device normalization got=%+v err=%v", normalized, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeExternalDrawCommandRejectsMoreThan999SequentialDraws(t *testing.T) {
|
||||||
|
svc := &Service{}
|
||||||
|
_, err := svc.normalizeExternalDrawCommand(domain.ExternalDrawCommand{
|
||||||
|
AppCode: "aslan", ExternalUserID: "u-1", RequestID: "req-1",
|
||||||
|
GiftCount: 1_000, UnitAmount: 1, TotalAmount: 1_000, Currency: "COIN", PaidAtMS: 1,
|
||||||
|
})
|
||||||
|
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||||
|
t.Fatalf("external gift_count=1000 should be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeExternalDrawCommandDoesNotInventPaidAt(t *testing.T) {
|
||||||
|
svc := &Service{}
|
||||||
|
normalized, err := svc.normalizeExternalDrawCommand(domain.ExternalDrawCommand{
|
||||||
|
AppCode: "aslan", ExternalUserID: "u-1", RequestID: "req-paid-at",
|
||||||
|
GiftCount: 1, UnitAmount: 10, TotalAmount: 10, Currency: "COIN",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize external draw: %v", err)
|
||||||
|
}
|
||||||
|
if normalized.PaidAtMS != 0 {
|
||||||
|
t.Fatalf("service layer invented external paid_at_ms: %d", normalized.PaidAtMS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDrawBatchRejectsAggregateWorkAcrossTargets(t *testing.T) {
|
func TestDrawBatchRejectsAggregateWorkAcrossTargets(t *testing.T) {
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
package luckygift
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
type poolAdjustmentRepositoryStub struct {
|
||||||
|
Repository
|
||||||
|
command domain.PoolAdjustmentCommand
|
||||||
|
nowMS int64
|
||||||
|
result domain.PoolAdjustmentResult
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *poolAdjustmentRepositoryStub) AdjustLuckyGiftPoolBalance(_ context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error) {
|
||||||
|
s.calls++
|
||||||
|
s.command = command
|
||||||
|
s.nowMS = nowMS
|
||||||
|
return s.result, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdjustPoolBalanceNormalizesAndForwardsOwnerCommand(t *testing.T) {
|
||||||
|
stub := &poolAdjustmentRepositoryStub{result: domain.PoolAdjustmentResult{Pool: domain.PoolBalance{Balance: 50}}}
|
||||||
|
service := New(stub)
|
||||||
|
fixedNow := time.Date(2026, 7, 15, 1, 2, 3, 0, time.UTC)
|
||||||
|
service.SetClock(func() time.Time { return fixedNow })
|
||||||
|
ctx := appcode.WithContext(context.Background(), " AsLaN ")
|
||||||
|
result, err := service.AdjustPoolBalance(ctx, domain.PoolAdjustmentCommand{
|
||||||
|
PoolID: " lucky ", StrategyVersion: " DYNAMIC_V3 ", AdjustmentID: " adjustment-1 ",
|
||||||
|
Direction: " IN ", AmountCoins: 50, Reason: " approved budget ", OperatorAdminID: 9,
|
||||||
|
RequestID: " request-1 ",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adjust pool balance: %v", err)
|
||||||
|
}
|
||||||
|
if result.Pool.Balance != 50 || stub.calls != 1 {
|
||||||
|
t.Fatalf("result=%+v calls=%d", result, stub.calls)
|
||||||
|
}
|
||||||
|
if stub.command.AppCode != "aslan" || stub.command.PoolID != "lucky" || stub.command.StrategyVersion != domain.StrategyDynamicV3 ||
|
||||||
|
stub.command.Direction != domain.PoolAdjustmentIn || stub.command.Reason != "approved budget" || stub.nowMS != fixedNow.UnixMilli() {
|
||||||
|
t.Fatalf("normalized command=%+v now=%d", stub.command, stub.nowMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdjustPoolBalanceRejectsInvalidMoneyCommandBeforeRepository(t *testing.T) {
|
||||||
|
stub := &poolAdjustmentRepositoryStub{}
|
||||||
|
service := New(stub)
|
||||||
|
tests := []domain.PoolAdjustmentCommand{
|
||||||
|
{StrategyVersion: domain.StrategyDynamicV3, AdjustmentID: "a", Direction: "in", AmountCoins: 1, Reason: "r", OperatorAdminID: 1},
|
||||||
|
{PoolID: "lucky", StrategyVersion: "v4", AdjustmentID: "a", Direction: "in", AmountCoins: 1, Reason: "r", OperatorAdminID: 1},
|
||||||
|
{PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, Direction: "in", AmountCoins: 1, Reason: "r", OperatorAdminID: 1},
|
||||||
|
{PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, AdjustmentID: "a", Direction: "sideways", AmountCoins: 1, Reason: "r", OperatorAdminID: 1},
|
||||||
|
{PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, AdjustmentID: "a", Direction: "out", AmountCoins: 0, Reason: "r", OperatorAdminID: 1},
|
||||||
|
{PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, AdjustmentID: "a", Direction: "out", AmountCoins: 1, OperatorAdminID: 1},
|
||||||
|
}
|
||||||
|
for index, command := range tests {
|
||||||
|
if _, err := service.AdjustPoolBalance(context.Background(), command); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||||
|
t.Fatalf("case %d error=%v, want invalid argument", index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stub.calls != 0 {
|
||||||
|
t.Fatalf("repository calls=%d, want 0 for invalid commands", stub.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/giftlimits"
|
"hyapp/pkg/giftlimits"
|
||||||
@ -37,6 +38,7 @@ type Repository interface {
|
|||||||
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
|
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
|
||||||
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
|
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
|
||||||
ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, 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)
|
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
|
||||||
ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error)
|
ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error)
|
||||||
DeleteDeliveredLuckyGiftOutboxBefore(ctx context.Context, cutoffMS int64, limit int) (int64, error)
|
DeleteDeliveredLuckyGiftOutboxBefore(ctx context.Context, cutoffMS int64, limit int) (int64, error)
|
||||||
@ -184,6 +186,7 @@ func (s *Service) ExecuteExternalDraw(ctx context.Context, cmd domain.ExternalDr
|
|||||||
func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) {
|
func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) {
|
||||||
cmd.AppCode = appcode.Normalize(cmd.AppCode)
|
cmd.AppCode = appcode.Normalize(cmd.AppCode)
|
||||||
cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID)
|
cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID)
|
||||||
|
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
|
||||||
cmd.RequestID = strings.TrimSpace(cmd.RequestID)
|
cmd.RequestID = strings.TrimSpace(cmd.RequestID)
|
||||||
cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency))
|
cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency))
|
||||||
cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON)
|
cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON)
|
||||||
@ -191,15 +194,17 @@ func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (
|
|||||||
if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" {
|
if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" {
|
||||||
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is incomplete")
|
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is incomplete")
|
||||||
}
|
}
|
||||||
if len(cmd.AppCode) > 32 || len(cmd.ExternalUserID) > 128 || len(cmd.RequestID) > 128 || len(cmd.PoolID) > 96 {
|
if len(cmd.AppCode) > 32 || len(cmd.ExternalUserID) > 128 || len(cmd.DeviceID) > 128 || len(cmd.RequestID) > 128 || len(cmd.PoolID) > 96 {
|
||||||
// 外部请求的 app 级业务键会进入唯一键和审计列;长度先在服务层失败,避免事务中途暴露 MySQL truncation/too long 错误。
|
// 外部请求的 app 级业务键会进入唯一键和审计列;长度先在服务层失败,避免事务中途暴露 MySQL truncation/too long 错误。
|
||||||
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long")
|
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long")
|
||||||
}
|
}
|
||||||
if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 {
|
if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 {
|
||||||
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive")
|
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive")
|
||||||
}
|
}
|
||||||
if cmd.GiftCount > int64(^uint32(0)>>1) {
|
// dynamic_v3 必须为每个礼物保存一条顺序子抽事实;与内部送礼统一限制 999,
|
||||||
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count is too large")
|
// 避免外部请求用一个超大 count 制造不可控事务和审计行数。
|
||||||
|
if cmd.GiftCount > 999 {
|
||||||
|
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count cannot exceed 999")
|
||||||
}
|
}
|
||||||
if cmd.GiftCount > (1<<63-1)/cmd.UnitAmount || cmd.GiftCount*cmd.UnitAmount != cmd.TotalAmount {
|
if cmd.GiftCount > (1<<63-1)/cmd.UnitAmount || cmd.GiftCount*cmd.UnitAmount != cmd.TotalAmount {
|
||||||
// 金额校验必须在 owner service 再做一遍;gateway 是外部入口,不能成为唯一可信校验点。
|
// 金额校验必须在 owner service 再做一遍;gateway 是外部入口,不能成为唯一可信校验点。
|
||||||
@ -218,10 +223,8 @@ func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (
|
|||||||
if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) {
|
if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) {
|
||||||
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid")
|
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid")
|
||||||
}
|
}
|
||||||
if cmd.PaidAtMS <= 0 {
|
// paid_at_ms 是否可兼容缺失取决于不可变规则版本;service 层保留原值,repository
|
||||||
// paid_at_ms 表示外部 App 扣费完成时间;缺失时用 UTC 服务端时间兜底,不能使用本地时区。
|
// 读取 fixed_v2 后才可使用 nowMS,dynamic_v3 内外调用都必须提供真实扣费时间。
|
||||||
cmd.PaidAtMS = s.now().UTC().UnixMilli()
|
|
||||||
}
|
|
||||||
return cmd, nil
|
return cmd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,9 +240,14 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
|
|||||||
cmd.SenderAvatar = strings.TrimSpace(cmd.SenderAvatar)
|
cmd.SenderAvatar = strings.TrimSpace(cmd.SenderAvatar)
|
||||||
cmd.SenderDisplayUserID = strings.TrimSpace(cmd.SenderDisplayUserID)
|
cmd.SenderDisplayUserID = strings.TrimSpace(cmd.SenderDisplayUserID)
|
||||||
cmd.SenderPrettyDisplayUserID = strings.TrimSpace(cmd.SenderPrettyDisplayUserID)
|
cmd.SenderPrettyDisplayUserID = strings.TrimSpace(cmd.SenderPrettyDisplayUserID)
|
||||||
if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
|
if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
|
||||||
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
|
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
|
||||||
}
|
}
|
||||||
|
if len(cmd.DeviceID) > 128 {
|
||||||
|
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw device_id is too long")
|
||||||
|
}
|
||||||
|
// 这一层不能把空 device_id 一概拒绝:存量 fixed_v2 请求/恢复命令没有该字段。
|
||||||
|
// 仓储读到不可变规则后才能对 dynamic_v3 做权威 fail-close,期间不用 session/command 兜底。
|
||||||
if cmd.TargetUserID < 0 {
|
if cmd.TargetUserID < 0 {
|
||||||
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
|
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
|
||||||
}
|
}
|
||||||
@ -248,10 +256,8 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
|
|||||||
// The cap prevents a direct internal caller from allocating unbounded per-unit RNG/write work.
|
// The cap prevents a direct internal caller from allocating unbounded per-unit RNG/write work.
|
||||||
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit")
|
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit")
|
||||||
}
|
}
|
||||||
if cmd.PaidAtMS <= 0 {
|
// 内部 wallet paid_at_ms 不能在 service 层用当前时钟补齐:repository 必须先读取策略,
|
||||||
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免本地时区污染 RTP 窗口。
|
// fixed_v2 才允许兼容降级,dynamic_v3 则严格拒绝缺失 owner 事实。
|
||||||
cmd.PaidAtMS = s.now().UTC().UnixMilli()
|
|
||||||
}
|
|
||||||
return cmd, nil
|
return cmd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -776,9 +782,57 @@ func (s *Service) ListPoolBalances(ctx context.Context, query domain.PoolBalance
|
|||||||
if strings.TrimSpace(query.PoolID) != "" {
|
if strings.TrimSpace(query.PoolID) != "" {
|
||||||
query.PoolID = normalizePoolID(query.PoolID)
|
query.PoolID = normalizePoolID(query.PoolID)
|
||||||
}
|
}
|
||||||
|
query.StrategyVersion = strings.ToLower(strings.TrimSpace(query.StrategyVersion))
|
||||||
|
if query.StrategyVersion != "" && query.StrategyVersion != domain.StrategyFixedV2 && query.StrategyVersion != domain.StrategyDynamicV3 {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "unsupported lucky gift strategy version")
|
||||||
|
}
|
||||||
return s.repository.ListLuckyGiftPoolBalances(ctx, query)
|
return s.repository.ListLuckyGiftPoolBalances(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdjustPoolBalance 校验管理员资金命令并交给 owner 仓储执行。request_id 只记录链路,
|
||||||
|
// adjustment_id 才是可跨超时重试的业务幂等键。
|
||||||
|
func (s *Service) AdjustPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand) (domain.PoolAdjustmentResult, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
command.AppCode = appcode.FromContext(ctx)
|
||||||
|
rawPoolID := strings.TrimSpace(command.PoolID)
|
||||||
|
if rawPoolID == "" {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment pool_id is required")
|
||||||
|
}
|
||||||
|
command.PoolID = normalizePoolID(rawPoolID)
|
||||||
|
command.StrategyVersion = strings.ToLower(strings.TrimSpace(command.StrategyVersion))
|
||||||
|
command.AdjustmentID = strings.TrimSpace(command.AdjustmentID)
|
||||||
|
command.Direction = strings.ToLower(strings.TrimSpace(command.Direction))
|
||||||
|
command.Reason = strings.TrimSpace(command.Reason)
|
||||||
|
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||||
|
if utf8.RuneCountInString(command.AppCode) > 32 || utf8.RuneCountInString(command.PoolID) > 96 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment app_code or pool_id is too long")
|
||||||
|
}
|
||||||
|
if command.StrategyVersion != domain.StrategyFixedV2 && command.StrategyVersion != domain.StrategyDynamicV3 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool strategy_version must be fixed_v2 or dynamic_v3")
|
||||||
|
}
|
||||||
|
if command.AdjustmentID == "" || utf8.RuneCountInString(command.AdjustmentID) > 128 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment_id is required and must not exceed 128 characters")
|
||||||
|
}
|
||||||
|
if command.Direction != domain.PoolAdjustmentIn && command.Direction != domain.PoolAdjustmentOut {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment direction must be in or out")
|
||||||
|
}
|
||||||
|
if command.AmountCoins <= 0 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment amount_coins must be positive")
|
||||||
|
}
|
||||||
|
if command.Reason == "" || utf8.RuneCountInString(command.Reason) > 512 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment reason is required and must not exceed 512 characters")
|
||||||
|
}
|
||||||
|
if command.OperatorAdminID <= 0 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment operator_admin_id must be positive")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(command.RequestID) > 128 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment request_id must not exceed 128 characters")
|
||||||
|
}
|
||||||
|
return s.repository.AdjustLuckyGiftPoolBalance(ctx, command, s.now().UTC().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) requireRepository() error {
|
func (s *Service) requireRepository() error {
|
||||||
if s == nil || s.repository == nil {
|
if s == nil || s.repository == nil {
|
||||||
return xerr.New(xerr.Unavailable, "lucky gift repository is not configured")
|
return xerr.New(xerr.Unavailable, "lucky gift repository is not configured")
|
||||||
|
|||||||
@ -48,9 +48,16 @@ func externalLuckyScopeID(appCode string) string {
|
|||||||
return externalLuckyHashScope("external", appcode.Normalize(appCode))
|
return externalLuckyHashScope("external", appcode.Normalize(appCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
func externalLuckyDeviceID(appCode string, externalUserID string) string {
|
func externalLuckyDeviceID(appCode string, deviceID string) string {
|
||||||
// 外部用户 ID 最长可到 128 字符;hash 后写入审计字段,避免长字符串碰到内部设备字段长度边界。
|
// 外部设备 ID 最长可到 128 字符;hash 后写入内部审计/风控字段,
|
||||||
return externalLuckyHashScope("external_device", appcode.Normalize(appCode), externalUserID)
|
// 既保持同 App+同设备的稳定 scope,也不把外部原始标识暴露到 draw 事实。
|
||||||
|
return externalLuckyHashScope("external_device", appcode.Normalize(appCode), strings.TrimSpace(deviceID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalLuckyFixedCompatibleDeviceID(cmd domain.ExternalDrawCommand) string {
|
||||||
|
// fixed_v2 不解释新 device_id,始终保留历史 app+external_user_id 作用域。
|
||||||
|
// 只有 dynamic_v3 才使用调用方绑定的设备,避免滚动升级改写 fixed 抽奖审计语义。
|
||||||
|
return externalLuckyDeviceID(cmd.AppCode, cmd.ExternalUserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func externalLuckyAnchorID(appCode string) string {
|
func externalLuckyAnchorID(appCode string) string {
|
||||||
@ -75,6 +82,9 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if err := r.lockExternalLuckyGiftRequest(ctx, tx, cmd.AppCode, cmd.RequestID, nowMS); err != nil {
|
||||||
|
return domain.ExternalDrawResult{}, err
|
||||||
|
}
|
||||||
if existing, ok, err := r.getExternalGiftDrawForUpdate(ctx, tx, cmd.AppCode, cmd.RequestID); err != nil || ok {
|
if existing, ok, err := r.getExternalGiftDrawForUpdate(ctx, tx, cmd.AppCode, cmd.RequestID); err != nil || ok {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ExternalDrawResult{}, err
|
return domain.ExternalDrawResult{}, err
|
||||||
@ -88,6 +98,16 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ExternalDrawResult{}, err
|
return domain.ExternalDrawResult{}, err
|
||||||
}
|
}
|
||||||
|
if exists && rule.StrategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
if cmd.PaidAtMS <= 0 {
|
||||||
|
// 外部 dynamic_v3 同样只能消费调用方已完成扣费的事实时间;请求到达 lucky 的时间
|
||||||
|
// 不是支付时间,不能用于充值短窗、UTC 日/小时或精确 72h 风控归属。
|
||||||
|
return domain.ExternalDrawResult{}, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift paid_at_ms is required")
|
||||||
|
}
|
||||||
|
} else if cmd.PaidAtMS <= 0 {
|
||||||
|
// 未配置规则和历史 fixed_v2 保留滚动发布兼容;dynamic_v3 永远不会进入这条降级。
|
||||||
|
cmd.PaidAtMS = nowMS
|
||||||
|
}
|
||||||
selected := externalDrawCandidate{TierID: "disabled", MultiplierPPM: 0}
|
selected := externalDrawCandidate{TierID: "disabled", MultiplierPPM: 0}
|
||||||
rewardAmount := int64(0)
|
rewardAmount := int64(0)
|
||||||
ruleVersion := rule.RuleVersion
|
ruleVersion := rule.RuleVersion
|
||||||
@ -159,7 +179,27 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
|
|||||||
return externalDrawResultFromRow(row), nil
|
return externalDrawResultFromRow(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lockExternalLuckyGiftRequest 为“尚不存在结果行”的并发幂等补上可锁主键。
|
||||||
|
// SELECT ... FOR UPDATE 无法锁住不存在的 external draw;先幂等建锁行后,第二个同 request_id 请求会等待首事务提交,再读回同一结果。
|
||||||
|
func (r *Repository) lockExternalLuckyGiftRequest(ctx context.Context, tx *sql.Tx, appCode, requestID string, nowMS int64) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO external_lucky_gift_request_locks (app_code, request_id, created_at_ms)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE created_at_ms = created_at_ms`, appCode, requestID, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var lockedRequestID string
|
||||||
|
return tx.QueryRowContext(ctx, `
|
||||||
|
SELECT request_id FROM external_lucky_gift_request_locks
|
||||||
|
WHERE app_code = ? AND request_id = ? FOR UPDATE`, appCode, requestID,
|
||||||
|
).Scan(&lockedRequestID)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) {
|
func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) {
|
||||||
|
if rule.StrategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
return r.executeExternalDynamicGiftEconomy(ctx, tx, cmd, rule, nowMS)
|
||||||
|
}
|
||||||
baseConfig, err := luckyRuntimeConfigFromRuleConfig(rule)
|
baseConfig, err := luckyRuntimeConfigFromRuleConfig(rule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return externalDrawCandidate{}, 0, err
|
return externalDrawCandidate{}, 0, err
|
||||||
@ -172,7 +212,7 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
|
|||||||
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
|
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
|
||||||
PoolID: cmd.PoolID,
|
PoolID: cmd.PoolID,
|
||||||
UserID: externalUserID,
|
UserID: externalUserID,
|
||||||
DeviceID: externalLuckyDeviceID(cmd.AppCode, cmd.ExternalUserID),
|
DeviceID: externalLuckyFixedCompatibleDeviceID(cmd),
|
||||||
RoomID: roomScopeID,
|
RoomID: roomScopeID,
|
||||||
AnchorID: externalLuckyAnchorID(cmd.AppCode),
|
AnchorID: externalLuckyAnchorID(cmd.AppCode),
|
||||||
GiftID: config.GiftID,
|
GiftID: config.GiftID,
|
||||||
@ -218,6 +258,66 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
|
|||||||
return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil
|
return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// executeExternalDynamicGiftEconomy 让外部 App 复用与房间送礼完全相同的 dynamic_v3 内核。
|
||||||
|
// 外部调用方自己负责余额和主播收益结算,因此本服务按配置拆账、返回聚合 reward,且只写 external 子抽审计,绝不生成 HyApp wallet outbox。
|
||||||
|
func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) {
|
||||||
|
trustedDeviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
// luck-gateway 目前无法独立验证外部设备;dynamic_v3 至少强制调用方提供经其认证/签名绑定的稳定值。
|
||||||
|
// 缺失时不得用 external_user_id/request_id 伪造设备 scope。
|
||||||
|
return externalDrawCandidate{}, 0, err
|
||||||
|
}
|
||||||
|
config, err := luckyRuntimeConfigFromRuleConfig(rule)
|
||||||
|
if err != nil {
|
||||||
|
return externalDrawCandidate{}, 0, err
|
||||||
|
}
|
||||||
|
strategyConfig := luckyDynamicStrategyConfig(config)
|
||||||
|
split, err := domain.SplitLuckyGiftStrategyFunds(cmd.TotalAmount, strategyConfig)
|
||||||
|
if err != nil {
|
||||||
|
return externalDrawCandidate{}, 0, err
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
|
||||||
|
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。
|
||||||
|
ctx = appcode.WithContext(ctx, cmd.AppCode)
|
||||||
|
results, err := r.executeDynamicLuckyGiftDrawBatch(ctx, tx, drawCommand, int32(cmd.GiftCount), rule, luckyDynamicPersistence{
|
||||||
|
ExternalRequestID: cmd.RequestID,
|
||||||
|
ExternalUserID: cmd.ExternalUserID,
|
||||||
|
}, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return externalDrawCandidate{}, 0, err
|
||||||
|
}
|
||||||
|
var reward int64
|
||||||
|
for _, result := range results {
|
||||||
|
if result.EffectiveRewardCoins < 0 || reward > int64(^uint64(0)>>1)-result.EffectiveRewardCoins {
|
||||||
|
return externalDrawCandidate{}, 0, xerr.New(xerr.Internal, "external dynamic lucky gift reward overflow")
|
||||||
|
}
|
||||||
|
reward += result.EffectiveRewardCoins
|
||||||
|
}
|
||||||
|
candidate := externalDrawCandidate{TierID: "dynamic_v3_batch"}
|
||||||
|
if len(results) == 1 {
|
||||||
|
candidate.TierID = results[0].SelectedTierID
|
||||||
|
candidate.MultiplierPPM = results[0].MultiplierPPM
|
||||||
|
} else if ratio, valid := (domain.StrategyRTP{WagerCoins: cmd.TotalAmount, PayoutCoins: reward}).RatioPPM(); valid {
|
||||||
|
// 批量可能命中多个不同倍率;聚合 multiplier 仅表达 reward/total,逐抽真实倍率保存在 item 表。
|
||||||
|
candidate.MultiplierPPM = ratio
|
||||||
|
}
|
||||||
|
return candidate, reward, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) applyExternalLuckyEconomy(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, candidate luckyCandidate, globalWindow luckyRTPWindow, giftWindow luckyRTPWindow, basePool luckyPool, nextCumulativeWager int64, nextEquivalentDraws int64, nowMS int64) error {
|
func (r *Repository) applyExternalLuckyEconomy(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, candidate luckyCandidate, globalWindow luckyRTPWindow, giftWindow luckyRTPWindow, basePool luckyPool, nextCumulativeWager int64, nextEquivalentDraws int64, nowMS int64) error {
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE lucky_rtp_windows
|
UPDATE lucky_rtp_windows
|
||||||
|
|||||||
@ -0,0 +1,456 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/idgen"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
type luckyDynamicBatchState struct {
|
||||||
|
PublicIn int64
|
||||||
|
ProfitIn int64
|
||||||
|
AnchorIn int64
|
||||||
|
Payout int64
|
||||||
|
DrawIDs []string
|
||||||
|
Records []luckyDrawRecordInput
|
||||||
|
Results []domain.DrawResult
|
||||||
|
BasePool luckyPool
|
||||||
|
GlobalWindow luckyRTPWindow
|
||||||
|
GiftWindow luckyRTPWindow
|
||||||
|
// *Windows 保存本批已经滚满的窗口;当前窗口单独保留,事务尾统一按绝对快照写回。
|
||||||
|
GlobalWindows []luckyRTPWindow
|
||||||
|
GiftWindows []luckyRTPWindow
|
||||||
|
UserState luckyUserState
|
||||||
|
RiskCounters map[string]luckyDynamicRiskCounter
|
||||||
|
DayState luckyUserStrategyDay
|
||||||
|
HourState luckyUserRTPHour
|
||||||
|
Strategy domain.StrategyState
|
||||||
|
}
|
||||||
|
|
||||||
|
// luckyDynamicPersistence 把内部钱包结算与外部 App 自行结算区分开。
|
||||||
|
// 外部模式仍推进同一套奖池/RTP/风控状态并保留 N 条子抽审计,但不能生成 HyApp wallet 返奖 outbox。
|
||||||
|
type luckyDynamicPersistence struct {
|
||||||
|
ExternalRequestID string
|
||||||
|
ExternalUserID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p luckyDynamicPersistence) external() bool {
|
||||||
|
return p.ExternalRequestID != "" || p.ExternalUserID != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeDynamicLuckyGiftDrawBatch 在已经锁定规则、完成 command_id 幂等探测后执行 dynamic_v3。
|
||||||
|
// 所有共享行只锁一次,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 的扣费事实时间。
|
||||||
|
// 外部调用也必须传它自己的扣费事实;缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required")
|
||||||
|
}
|
||||||
|
// 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。
|
||||||
|
// 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。
|
||||||
|
deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.DeviceID = deviceID
|
||||||
|
appCode := appcode.FromContext(ctx)
|
||||||
|
config, err := luckyRuntimeConfigFromRuleConfig(rule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !luckyUsesDynamicStrategy(config) {
|
||||||
|
return nil, xerr.New(xerr.Internal, "dynamic lucky gift path received a fixed strategy")
|
||||||
|
}
|
||||||
|
strategyConfig := luckyDynamicStrategyConfig(config)
|
||||||
|
referencePrice := config.GiftPrice
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// dynamic_v3 永远从独立的零余额账本启动;运营启动资金必须走人工 credit,不能由规则版本发布隐式注资。
|
||||||
|
basePool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, luckyDynamicPoolScopeType, luckyBasePoolScopeID(config), 0, 0, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
riskCounters, err := r.getLuckyDynamicRiskCounters(ctx, tx, appCode, config.PoolID, cmd, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dayState, err := r.getOrCreateLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, cmd.UserID, cmd, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hourState, err := r.getOrCreateLuckyUserRTPHour(ctx, tx, appCode, config.PoolID, cmd.UserID, cmd, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rolling72, err := r.getLuckyUserRolling72HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, luckyPaidTime(cmd, nowMS).UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.GiftIncomeCoins, strategyConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
unitSplits, err := luckyDynamicUnitFundSplits(cmd.CoinSpent, drawCount, fundSplit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state := luckyDynamicBatchState{
|
||||||
|
DrawIDs: make([]string, 0, drawCount),
|
||||||
|
Records: make([]luckyDrawRecordInput, 0, drawCount),
|
||||||
|
Results: make([]domain.DrawResult, 0, drawCount),
|
||||||
|
BasePool: basePool,
|
||||||
|
GlobalWindow: globalWindow,
|
||||||
|
GiftWindow: giftWindow,
|
||||||
|
GlobalWindows: make([]luckyRTPWindow, 0, 2),
|
||||||
|
GiftWindows: make([]luckyRTPWindow, 0, 2),
|
||||||
|
UserState: userState,
|
||||||
|
RiskCounters: riskCounters,
|
||||||
|
DayState: dayState,
|
||||||
|
HourState: hourState,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
unitSplit := unitSplits[index-1]
|
||||||
|
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
|
||||||
|
if unitCommand.CoinSpent <= 0 {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift unit spend must be positive")
|
||||||
|
}
|
||||||
|
if unitSplit.PublicPoolCoins+unitSplit.ProfitPoolCoins+unitSplit.AnchorReturnCoins != unitCommand.CoinSpent {
|
||||||
|
return nil, xerr.New(xerr.Internal, "dynamic lucky gift unit fund split is not conserved")
|
||||||
|
}
|
||||||
|
decision, err := domain.DecideLuckyGiftStrategy(strategyConfig, state.Strategy, domain.StrategyInput{
|
||||||
|
GiftPriceCoins: unitCommand.CoinSpent,
|
||||||
|
PoolContributionCoins: unitSplit.PublicPoolCoins,
|
||||||
|
NowMS: luckyPaidTime(unitCommand, nowMS).UnixMilli(),
|
||||||
|
HasRechargeFact: unitCommand.LastRechargedAtMS > 0,
|
||||||
|
LastRechargeAtMS: unitCommand.LastRechargedAtMS,
|
||||||
|
Recharge7DCoins: unitCommand.Recharge7DCoins,
|
||||||
|
Recharge30DCoins: unitCommand.Recharge30DCoins,
|
||||||
|
RiskCapacity: luckyDynamicRiskCapacity(config, state.RiskCounters),
|
||||||
|
}, luckyCryptoStrategyRandom{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.stageLuckyDynamicDecision(appCode, config, referencePrice, unitCommand, unitSplit, decision, nowMS, &state); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if persistence.external() {
|
||||||
|
if persistence.ExternalRequestID == "" || persistence.ExternalUserID == "" {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift persistence identity is incomplete")
|
||||||
|
}
|
||||||
|
for index := range state.Records {
|
||||||
|
state.Records[index].RewardStatus = domain.StatusGranted
|
||||||
|
state.Results[index].RewardStatus = domain.StatusGranted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := r.persistLuckyDynamicBatch(ctx, tx, appCode, cmd, config, state, persistence, nowMS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return state.Results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLuckyDynamicDeviceID(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", xerr.New(xerr.InvalidArgument, "dynamic lucky gift device_id is required")
|
||||||
|
}
|
||||||
|
if len(value) > 128 {
|
||||||
|
return "", xerr.New(xerr.InvalidArgument, "dynamic lucky gift device_id is too long")
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
|
||||||
|
}
|
||||||
|
// 图中“结算窗口流水”是金币阈值,不是抽数。整笔单抽归属进入时所在窗口;本抽把流水推过阈值后,下一抽才开启新窗口,避免拆分一次中奖事实。
|
||||||
|
if state.GlobalWindow.WagerCoins >= controlWager {
|
||||||
|
closed := state.GlobalWindow
|
||||||
|
closed.Status = "closed"
|
||||||
|
state.GlobalWindows = append(state.GlobalWindows, closed)
|
||||||
|
next, err := r.createLuckyRTPWindow(
|
||||||
|
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
|
||||||
|
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.GlobalWindow = next
|
||||||
|
// 大奖机制一只看当前结算窗口;窗口滚动后分母为 0,必须先积累新样本,不能沿用旧窗口资格。
|
||||||
|
state.Strategy.GlobalRTP = domain.StrategyRTP{
|
||||||
|
WagerCoins: next.WagerCoins,
|
||||||
|
PayoutCoins: next.ActualPayoutCoins,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state.GiftWindow.WagerCoins >= controlWager {
|
||||||
|
closed := state.GiftWindow
|
||||||
|
closed.Status = "closed"
|
||||||
|
state.GiftWindows = append(state.GiftWindows, closed)
|
||||||
|
next, err := r.createLuckyRTPWindow(
|
||||||
|
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
|
||||||
|
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.GiftWindow = next
|
||||||
|
}
|
||||||
|
return 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
|
||||||
|
state.AnchorIn += split.AnchorReturnCoins
|
||||||
|
state.Payout += decision.PayoutCoins
|
||||||
|
state.Strategy = decision.NextState
|
||||||
|
state.BasePool.Balance = decision.PoolAfterCoins
|
||||||
|
state.BasePool.TotalIn += split.PublicPoolCoins
|
||||||
|
state.BasePool.TotalOut += decision.PayoutCoins
|
||||||
|
state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent)
|
||||||
|
state.GlobalWindow.PaidDraws++
|
||||||
|
state.GlobalWindow.ActualPayoutCoins += decision.PayoutCoins
|
||||||
|
state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent)
|
||||||
|
state.GiftWindow.PaidDraws++
|
||||||
|
state.GiftWindow.ActualPayoutCoins += decision.PayoutCoins
|
||||||
|
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.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
|
||||||
|
state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins
|
||||||
|
state.HourState.WagerCoins += cmd.CoinSpent
|
||||||
|
state.HourState.PayoutCoins += decision.PayoutCoins
|
||||||
|
for key, counter := range state.RiskCounters {
|
||||||
|
counter.Payout += decision.PayoutCoins
|
||||||
|
state.RiskCounters[key] = counter
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := luckyCandidate{
|
||||||
|
TierID: decision.SelectedTier.ID,
|
||||||
|
Pool: decision.Stage,
|
||||||
|
Weight: luckyDynamicSelectedWeight(decision),
|
||||||
|
BaseReward: decision.PayoutCoins,
|
||||||
|
MultiplierPPM: decision.SelectedTier.MultiplierPPM,
|
||||||
|
HighMultiplier: decision.Jackpot || decision.SelectedTier.MultiplierPPM >= 10_000_000,
|
||||||
|
}
|
||||||
|
drawID := idgen.New("lucky_draw")
|
||||||
|
rewardStatus := luckyInitialRewardStatus(candidate, false)
|
||||||
|
limited := luckyDynamicLimitedReasons(decision.Trace)
|
||||||
|
decisionCopy := decision
|
||||||
|
poolSnapshot := state.BasePool
|
||||||
|
globalSnapshot := state.GlobalWindow
|
||||||
|
giftSnapshot := state.GiftWindow
|
||||||
|
record := luckyDrawRecordInput{
|
||||||
|
AppCode: appCode,
|
||||||
|
DrawID: drawID,
|
||||||
|
Command: cmd,
|
||||||
|
Config: config,
|
||||||
|
ExperiencePool: decision.Stage,
|
||||||
|
Candidate: candidate,
|
||||||
|
Limited: limited,
|
||||||
|
GlobalWindow: globalSnapshot,
|
||||||
|
GiftWindow: giftSnapshot,
|
||||||
|
BasePool: poolSnapshot,
|
||||||
|
StrategyDecision: &decisionCopy,
|
||||||
|
FundSplit: split,
|
||||||
|
UserDayRTP: decision.NextState.UserDayRTP,
|
||||||
|
User72HourRTP: decision.NextState.User72HourRTP,
|
||||||
|
RewardStatus: rewardStatus,
|
||||||
|
NowMS: nowMS,
|
||||||
|
}
|
||||||
|
state.DrawIDs = append(state.DrawIDs, drawID)
|
||||||
|
state.Records = append(state.Records, record)
|
||||||
|
globalRTP, _ := decision.NextState.GlobalRTP.RatioPPM()
|
||||||
|
giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins)
|
||||||
|
state.Results = append(state.Results, domain.DrawResult{
|
||||||
|
DrawID: drawID,
|
||||||
|
CommandID: cmd.CommandID,
|
||||||
|
PoolID: config.PoolID,
|
||||||
|
GiftID: cmd.GiftID,
|
||||||
|
RuleVersion: config.RuleVersion,
|
||||||
|
ExperiencePool: decision.Stage,
|
||||||
|
SelectedTierID: candidate.TierID,
|
||||||
|
MultiplierPPM: candidate.MultiplierPPM,
|
||||||
|
BaseRewardCoins: candidate.BaseReward,
|
||||||
|
EffectiveRewardCoins: candidate.effectiveReward(),
|
||||||
|
RewardStatus: rewardStatus,
|
||||||
|
RTPWindowIndex: globalSnapshot.WindowIndex,
|
||||||
|
GiftRTPWindowIndex: giftSnapshot.WindowIndex,
|
||||||
|
GlobalBaseRTPPPM: globalRTP,
|
||||||
|
GiftBaseRTPPPM: giftRTP,
|
||||||
|
StageFeedback: decision.Trace.FinalReason == domain.StrategyReasonPaid && decision.Trace.OriginalTierID == "",
|
||||||
|
HighMultiplier: candidate.HighMultiplier,
|
||||||
|
CreatedAtMS: nowMS,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
globalWindows := append(append([]luckyRTPWindow(nil), state.GlobalWindows...), state.GlobalWindow)
|
||||||
|
giftWindows := append(append([]luckyRTPWindow(nil), state.GiftWindows...), state.GiftWindow)
|
||||||
|
for _, window := range append(globalWindows, giftWindows...) {
|
||||||
|
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 = ?
|
||||||
|
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,
|
||||||
|
appCode, window.ScopeType, window.ScopeID, window.WindowIndex,
|
||||||
|
); 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 = ?
|
||||||
|
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,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.addLuckyDynamicRiskPayout(ctx, tx, appCode, state.RiskCounters, state.Payout, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.persistLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, cmd.UserID, state.DayState, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.persistLuckyUserRTPHour(ctx, tx, appCode, config.PoolID, cmd.UserID, state.HourState, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.insertLuckyUserRTPBoundaryEvents(ctx, tx, appCode, config.PoolID, cmd.UserID, state.Records, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if persistence.external() {
|
||||||
|
return r.insertExternalLuckyGiftDrawItems(ctx, tx, appCode, persistence, state.Records, nowMS)
|
||||||
|
}
|
||||||
|
if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state.Payout <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
aggregate := luckyAggregateDrawResults(cmd, state.Results)
|
||||||
|
legacyState := luckyBatchDrawState{DrawIDs: state.DrawIDs, AggregateResult: aggregate, NeedsRewardSettlement: true}
|
||||||
|
return r.insertLuckyAggregateRewardSettlementOutbox(ctx, tx, appCode, cmd, legacyState, nowMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func luckyDynamicSelectedWeight(decision domain.StrategyDecision) int64 {
|
||||||
|
for _, item := range decision.Trace.Weights {
|
||||||
|
if item.TierID == decision.SelectedTier.ID {
|
||||||
|
return item.AdjustedWeightPPM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decision.SelectedTier.JackpotWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func luckyDynamicLimitedReasons(trace domain.DecisionTrace) map[string]bool {
|
||||||
|
out := make(map[string]bool, len(trace.Removed)+1)
|
||||||
|
for _, removed := range trace.Removed {
|
||||||
|
out[removed.Reason] = true
|
||||||
|
}
|
||||||
|
if trace.BlockedReason != "" {
|
||||||
|
out[trace.BlockedReason] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLuckyDynamicBatchState(state luckyDynamicBatchState, expectedSpent int64) error {
|
||||||
|
if state.BasePool.Balance < 0 {
|
||||||
|
return fmt.Errorf("dynamic lucky gift pool became negative")
|
||||||
|
}
|
||||||
|
if state.PublicIn+state.ProfitIn+state.AnchorIn != expectedSpent {
|
||||||
|
return fmt.Errorf("dynamic lucky gift fund split does not conserve spend")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertExternalLuckyGiftDrawItems 给 external gift_count=N 保存 N 条独立决策;聚合响应仍是一行,
|
||||||
|
// 但 P/W 删除、token、六维风控和每抽池水位不能只藏在一个最终 multiplier 里。
|
||||||
|
func (r *Repository) insertExternalLuckyGiftDrawItems(ctx context.Context, tx *sql.Tx, appCode string, persistence luckyDynamicPersistence, records []luckyDrawRecordInput, nowMS int64) error {
|
||||||
|
const chunkSize = 200
|
||||||
|
for start := 0; start < len(records); start += chunkSize {
|
||||||
|
end := start + chunkSize
|
||||||
|
if end > len(records) {
|
||||||
|
end = len(records)
|
||||||
|
}
|
||||||
|
var query strings.Builder
|
||||||
|
query.WriteString(`
|
||||||
|
INSERT INTO external_lucky_gift_draw_items (
|
||||||
|
app_code, request_id, item_index, draw_id, command_id, external_user_id, pool_id,
|
||||||
|
rule_version, unit_amount, reward_amount, selected_tier_id, multiplier_ppm, stage,
|
||||||
|
candidate_snapshot_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
|
||||||
|
paid_at_ms, created_at_ms
|
||||||
|
) VALUES `)
|
||||||
|
args := make([]any, 0, (end-start)*19)
|
||||||
|
for offset, record := range records[start:end] {
|
||||||
|
if offset > 0 {
|
||||||
|
query.WriteByte(',')
|
||||||
|
}
|
||||||
|
query.WriteString("(" + luckySQLPlaceholders(19) + ")")
|
||||||
|
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(record)
|
||||||
|
args = append(args,
|
||||||
|
appCode, persistence.ExternalRequestID, start+offset+1, record.DrawID, record.Command.CommandID,
|
||||||
|
persistence.ExternalUserID, record.Config.PoolID, record.Config.RuleVersion, record.Command.CoinSpent,
|
||||||
|
record.Candidate.effectiveReward(), record.Candidate.TierID, record.Candidate.MultiplierPPM, record.ExperiencePool,
|
||||||
|
candidateSnapshot, poolSnapshot, rtpSnapshot, domain.StatusGranted,
|
||||||
|
luckyPaidTime(record.Command, nowMS).UnixMilli(), nowMS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,499 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/internal/testutil/mysqlschema"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
|
||||||
|
caller := mysqlschema.CallerFile(t, 1)
|
||||||
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||||
|
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||||
|
DatabasePrefix: "hy_lucky_dynamic",
|
||||||
|
})
|
||||||
|
repo := &Repository{db: schema.DB}
|
||||||
|
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||||
|
now := time.Date(2026, 7, 12, 12, 30, 0, 0, time.UTC).UnixMilli()
|
||||||
|
|
||||||
|
internalRule := dynamicMySQLTestRule("internal-dynamic")
|
||||||
|
internalRule.SettlementWindowWager = 99
|
||||||
|
// 参考价故意设为 10:旧算法会把 99 流水错误折成 10 抽;本用例预置 10 抽/98 流水,证明 V3 不会因抽数已满而提前滚窗。
|
||||||
|
internalRule.GiftPriceReference = 10
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, internalRule, now-1_000); err != nil {
|
||||||
|
t.Fatalf("publish internal dynamic rule: %v", err)
|
||||||
|
}
|
||||||
|
// 同名 fixed_v2 旧账本模拟策略切换前历史水位;dynamic draw 必须只碰 pool_dynamic_v3。
|
||||||
|
if _, err := schema.DB.Exec(`
|
||||||
|
INSERT INTO lucky_pools (
|
||||||
|
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
|
profit_total, anchor_income_total, initial_seed_total, created_at_ms, updated_at_ms
|
||||||
|
) VALUES ('lalu', 'pool', ?, 4321, 321, 4000, 100, 0, 0, 4321, ?, ?)`, internalRule.PoolID, now-2_000, now-2_000); err != nil {
|
||||||
|
t.Fatalf("seed fixed strategy ledger: %v", err)
|
||||||
|
}
|
||||||
|
checked, err := repo.CheckLuckyGift(ctx, domain.CheckCommand{PoolID: internalRule.PoolID, GiftID: "super-lucky", UserID: 1001, RoomID: "room-1"})
|
||||||
|
if err != nil || checked.StrategyVersion != domain.StrategyDynamicV3 {
|
||||||
|
t.Fatalf("check did not expose immutable strategy version: result=%+v err=%v", checked, err)
|
||||||
|
}
|
||||||
|
seedDynamicRTPWindowsBelowWagerThreshold(t, schema.DB, internalRule.PoolID, now-900)
|
||||||
|
command := domain.DrawCommand{
|
||||||
|
CommandID: "dynamic-99", PoolID: internalRule.PoolID, UserID: 1001, TargetUserID: 2001,
|
||||||
|
DeviceID: "device-1001", RoomID: "room-1", AnchorID: "anchor-1", GiftID: "super-lucky",
|
||||||
|
GiftCount: 99, CoinSpent: 100, GiftIncomeCoins: 1, PaidAtMS: now,
|
||||||
|
}
|
||||||
|
missingWalletPaidAt := command
|
||||||
|
missingWalletPaidAt.CommandID = "dynamic-missing-wallet-paid-at"
|
||||||
|
missingWalletPaidAt.PaidAtMS = 0
|
||||||
|
if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingWalletPaidAt, now+123_456); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||||
|
t.Fatalf("dynamic_v3 missing wallet paid_at_ms should fail closed, got %v", err)
|
||||||
|
}
|
||||||
|
var missingPaidAtDraws int64
|
||||||
|
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=?`, missingWalletPaidAt.CommandID).Scan(&missingPaidAtDraws); err != nil || missingPaidAtDraws != 0 {
|
||||||
|
t.Fatalf("missing wallet paid_at_ms draw facts=%d err=%v", missingPaidAtDraws, err)
|
||||||
|
}
|
||||||
|
missingDevice := command
|
||||||
|
missingDevice.CommandID = "dynamic-missing-device"
|
||||||
|
missingDevice.DeviceID = ""
|
||||||
|
if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingDevice, now); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||||
|
t.Fatalf("dynamic_v3 missing signed device_id should fail closed, got %v", err)
|
||||||
|
}
|
||||||
|
var missingDeviceDraws int64
|
||||||
|
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=?`, missingDevice.CommandID).Scan(&missingDeviceDraws); err != nil || missingDeviceDraws != 0 {
|
||||||
|
t.Fatalf("missing device_id draw facts=%d err=%v", missingDeviceDraws, err)
|
||||||
|
}
|
||||||
|
// 两个相同 command_id 的首次请求同时进入:两者都必须成功,后到者等待 command lock 后
|
||||||
|
// 从首次事务回读同一批 99 条事实,不能以唯一键冲突失败,更不能额外推进奖池/RTP/风控。
|
||||||
|
var internalResults [2]domain.DrawResult
|
||||||
|
var internalErrors [2]error
|
||||||
|
internalStart := make(chan struct{})
|
||||||
|
var internalWorkers sync.WaitGroup
|
||||||
|
for index := range internalResults {
|
||||||
|
internalWorkers.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer internalWorkers.Done()
|
||||||
|
<-internalStart
|
||||||
|
internalResults[index], internalErrors[index] = repo.ExecuteLuckyGiftDraw(ctx, command, now)
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
close(internalStart)
|
||||||
|
internalWorkers.Wait()
|
||||||
|
for index, workerErr := range internalErrors {
|
||||||
|
if workerErr != nil {
|
||||||
|
t.Fatalf("internal concurrent worker %d: %v", index, workerErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first := internalResults[0]
|
||||||
|
if len(first.DrawIDs) != 99 {
|
||||||
|
t.Fatalf("aggregate draw ids=%d want=99", len(first.DrawIDs))
|
||||||
|
}
|
||||||
|
if internalResults[1].DrawID != first.DrawID ||
|
||||||
|
internalResults[1].EffectiveRewardCoins != first.EffectiveRewardCoins ||
|
||||||
|
internalResults[1].MultiplierPPM != first.MultiplierPPM ||
|
||||||
|
internalResults[1].RewardStatus != first.RewardStatus ||
|
||||||
|
fmt.Sprint(internalResults[1].DrawIDs) != fmt.Sprint(first.DrawIDs) ||
|
||||||
|
fmt.Sprint(internalResults[1].Hits) != fmt.Sprint(first.Hits) {
|
||||||
|
t.Fatalf("internal concurrent aggregate mismatch: %+v / %+v", first, internalResults[1])
|
||||||
|
}
|
||||||
|
var internalLockCount int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM lucky_gift_command_locks
|
||||||
|
WHERE app_code='lalu' AND command_id=?`, command.CommandID,
|
||||||
|
).Scan(&internalLockCount); err != nil || internalLockCount != 1 {
|
||||||
|
t.Fatalf("internal command lock count=%d err=%v", internalLockCount, err)
|
||||||
|
}
|
||||||
|
assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
|
||||||
|
var fixedBalance, fixedTotalIn, fixedTotalOut int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT balance, total_in, total_out FROM lucky_pools
|
||||||
|
WHERE app_code='lalu' AND scope_type='pool' AND scope_id=?`, internalRule.PoolID,
|
||||||
|
).Scan(&fixedBalance, &fixedTotalIn, &fixedTotalOut); err != nil || fixedBalance != 4321 || fixedTotalIn != 4000 || fixedTotalOut != 100 {
|
||||||
|
t.Fatalf("dynamic draw changed fixed ledger balance/in/out=%d/%d/%d err=%v", fixedBalance, fixedTotalIn, fixedTotalOut, err)
|
||||||
|
}
|
||||||
|
assertDynamicRTPWindowRollover(t, schema.DB, internalRule.PoolID)
|
||||||
|
assertExactRolling72HourBoundary(t, repo, schema.DB, ctx, internalRule.PoolID, now)
|
||||||
|
|
||||||
|
// 顺序重放必须只读回首次 99 条事实,不能再次入池、推进风控或生成钱包返奖。
|
||||||
|
replayed, err := repo.ExecuteLuckyGiftDraw(ctx, command, now+1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replay dynamic 99 draw: %v", err)
|
||||||
|
}
|
||||||
|
if replayed.DrawID != first.DrawID || replayed.EffectiveRewardCoins != first.EffectiveRewardCoins {
|
||||||
|
t.Fatalf("replay changed aggregate: first=%+v replay=%+v", first, replayed)
|
||||||
|
}
|
||||||
|
assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
|
||||||
|
assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now)
|
||||||
|
|
||||||
|
externalRule := dynamicMySQLTestRule("external-dynamic")
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil {
|
||||||
|
t.Fatalf("publish external dynamic rule: %v", err)
|
||||||
|
}
|
||||||
|
missingExternalPaidAt := domain.ExternalDrawCommand{
|
||||||
|
AppCode: "lalu", ExternalUserID: "external-missing-paid-at", DeviceID: "trusted-device-missing-paid-at", RequestID: "external-dynamic-missing-paid-at",
|
||||||
|
GiftCount: 1, UnitAmount: 10, TotalAmount: 10, Currency: "COIN", PoolID: externalRule.PoolID,
|
||||||
|
}
|
||||||
|
if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalPaidAt, now+234_567); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||||
|
t.Fatalf("external dynamic_v3 missing paid_at_ms should fail closed, got %v", err)
|
||||||
|
}
|
||||||
|
var missingExternalDraws int64
|
||||||
|
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, missingExternalPaidAt.RequestID).Scan(&missingExternalDraws); err != nil || missingExternalDraws != 0 {
|
||||||
|
t.Fatalf("missing external paid_at_ms draw facts=%d err=%v", missingExternalDraws, err)
|
||||||
|
}
|
||||||
|
external := domain.ExternalDrawCommand{
|
||||||
|
AppCode: "lalu", ExternalUserID: "external-42", DeviceID: "external-device-attested-42", RequestID: "external-dynamic-99",
|
||||||
|
GiftCount: 99, UnitAmount: 10, TotalAmount: 990, Currency: "COIN", PaidAtMS: now, PoolID: externalRule.PoolID,
|
||||||
|
}
|
||||||
|
missingExternalDevice := external
|
||||||
|
missingExternalDevice.RequestID = "external-dynamic-missing-device"
|
||||||
|
missingExternalDevice.DeviceID = ""
|
||||||
|
if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalDevice, now); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||||
|
t.Fatalf("external dynamic_v3 missing caller-bound device_id should fail closed, got %v", err)
|
||||||
|
}
|
||||||
|
var missingExternalDeviceDraws int64
|
||||||
|
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, missingExternalDevice.RequestID).Scan(&missingExternalDeviceDraws); err != nil || missingExternalDeviceDraws != 0 {
|
||||||
|
t.Fatalf("missing external device draw facts=%d err=%v", missingExternalDeviceDraws, err)
|
||||||
|
}
|
||||||
|
var walletSettlementEventsBefore int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM lucky_gift_outbox
|
||||||
|
WHERE app_code='lalu' AND event_type=?`, domain.EventTypeLuckyGiftRewardSettlement,
|
||||||
|
).Scan(&walletSettlementEventsBefore); err != nil {
|
||||||
|
t.Fatalf("query wallet settlement outbox before external: %v", err)
|
||||||
|
}
|
||||||
|
// 两个同 request_id 的首次请求同时进入时,request lock 必须让后到者等待并读回同一结果。
|
||||||
|
var results [2]domain.ExternalDrawResult
|
||||||
|
var errorsByWorker [2]error
|
||||||
|
start := make(chan struct{})
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
for index := range results {
|
||||||
|
workers.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer workers.Done()
|
||||||
|
<-start
|
||||||
|
results[index], errorsByWorker[index] = repo.ExecuteExternalGiftDraw(ctx, external, now)
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
workers.Wait()
|
||||||
|
for index, workerErr := range errorsByWorker {
|
||||||
|
if workerErr != nil {
|
||||||
|
t.Fatalf("external concurrent worker %d: %v", index, workerErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if results[0].DrawID == "" || results[0].DrawID != results[1].DrawID || results[0].RewardAmount != results[1].RewardAmount {
|
||||||
|
t.Fatalf("external concurrent idempotency mismatch: %+v / %+v", results[0], results[1])
|
||||||
|
}
|
||||||
|
var aggregateCount, itemCount, itemSpend, itemReward int64
|
||||||
|
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, external.RequestID).Scan(&aggregateCount); err != nil {
|
||||||
|
t.Fatalf("query external aggregate: %v", err)
|
||||||
|
}
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(unit_amount),0), COALESCE(SUM(reward_amount),0)
|
||||||
|
FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, external.RequestID,
|
||||||
|
).Scan(&itemCount, &itemSpend, &itemReward); err != nil {
|
||||||
|
t.Fatalf("query external items: %v", err)
|
||||||
|
}
|
||||||
|
if aggregateCount != 1 || itemCount != 99 || itemSpend != 990 || itemReward != results[0].RewardAmount {
|
||||||
|
t.Fatalf("external facts aggregate=%d items=%d spend=%d reward=%d result=%d", aggregateCount, itemCount, itemSpend, itemReward, results[0].RewardAmount)
|
||||||
|
}
|
||||||
|
expectedExternalDeviceScope := luckyPoolScopedID(externalRule.PoolID, externalLuckyDeviceID(external.AppCode, external.DeviceID))
|
||||||
|
legacyExternalUserScope := luckyPoolScopedID(externalRule.PoolID, externalLuckyDeviceID(external.AppCode, external.ExternalUserID))
|
||||||
|
var expectedDeviceCounters, legacyUserCounters int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM lucky_risk_counters
|
||||||
|
WHERE app_code='lalu' AND scope_type='device' AND scope_id=?`, expectedExternalDeviceScope,
|
||||||
|
).Scan(&expectedDeviceCounters); err != nil {
|
||||||
|
t.Fatalf("query external device counter: %v", err)
|
||||||
|
}
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM lucky_risk_counters
|
||||||
|
WHERE app_code='lalu' AND scope_type='device' AND scope_id=?`, legacyExternalUserScope,
|
||||||
|
).Scan(&legacyUserCounters); err != nil {
|
||||||
|
t.Fatalf("query legacy external-user device counter: %v", err)
|
||||||
|
}
|
||||||
|
if expectedDeviceCounters != 1 || legacyUserCounters != 0 {
|
||||||
|
t.Fatalf("external dynamic device scope counters trusted=%d legacy_user=%d", expectedDeviceCounters, legacyUserCounters)
|
||||||
|
}
|
||||||
|
if _, err := repo.ExecuteExternalGiftDraw(ctx, external, now+1); err != nil {
|
||||||
|
t.Fatalf("replay external dynamic result: %v", err)
|
||||||
|
}
|
||||||
|
var walletSettlementEventsAfter int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM lucky_gift_outbox
|
||||||
|
WHERE app_code='lalu' AND event_type=?`, domain.EventTypeLuckyGiftRewardSettlement,
|
||||||
|
).Scan(&walletSettlementEventsAfter); err != nil {
|
||||||
|
t.Fatalf("query wallet settlement outbox after external: %v", err)
|
||||||
|
}
|
||||||
|
if walletSettlementEventsAfter != walletSettlementEventsBefore {
|
||||||
|
t.Fatalf("external dynamic draw created HyApp wallet settlements: before=%d after=%d", walletSettlementEventsBefore, walletSettlementEventsAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDynamicRTPWindowsBelowWagerThreshold(t *testing.T, db *sql.DB, poolID string, nowMS int64) {
|
||||||
|
t.Helper()
|
||||||
|
scopeID := poolID + ":v1"
|
||||||
|
for _, scopeType := range []string{"pool", "pool_gift"} {
|
||||||
|
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,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed %s RTP window: %v", scopeType, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertDynamicRTPWindowRollover(t *testing.T, db *sql.DB, poolID string) {
|
||||||
|
t.Helper()
|
||||||
|
scopeID := poolID + ":v1"
|
||||||
|
for _, scopeType := range []string{"pool", "pool_gift"} {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT window_index, paid_draws, wager_coins, status FROM lucky_rtp_windows
|
||||||
|
WHERE app_code='lalu' AND scope_type=? AND scope_id=? ORDER BY window_index`, scopeType, scopeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query %s rollover: %v", scopeType, err)
|
||||||
|
}
|
||||||
|
var indexes, paid, wagers []int64
|
||||||
|
var statuses []string
|
||||||
|
for rows.Next() {
|
||||||
|
var index, drawCount, wager int64
|
||||||
|
var status string
|
||||||
|
if err := rows.Scan(&index, &drawCount, &wager, &status); err != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
t.Fatalf("scan %s rollover: %v", scopeType, err)
|
||||||
|
}
|
||||||
|
indexes = append(indexes, index)
|
||||||
|
paid = append(paid, drawCount)
|
||||||
|
wagers = append(wagers, wager)
|
||||||
|
statuses = append(statuses, status)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
t.Fatalf("close %s rollover rows: %v", scopeType, err)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(indexes) != "[1 2]" || fmt.Sprint(paid) != "[11 98]" || fmt.Sprint(wagers) != "[100 98]" || fmt.Sprint(statuses) != "[closed open]" {
|
||||||
|
t.Fatalf("%s wager rollover indexes/paid/wager/status=%v/%v/%v/%v", scopeType, indexes, paid, wagers, statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var firstWindow, secondWindow, firstGiftWindow, secondGiftWindow int64
|
||||||
|
if err := db.QueryRow(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND rtp_window_index=1 THEN 1 ELSE 0 END),0),
|
||||||
|
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND rtp_window_index=2 THEN 1 ELSE 0 END),0),
|
||||||
|
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND gift_rtp_window_index=1 THEN 1 ELSE 0 END),0),
|
||||||
|
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND gift_rtp_window_index=2 THEN 1 ELSE 0 END),0)
|
||||||
|
FROM lucky_draw_records WHERE app_code='lalu' AND pool_id=?`, poolID,
|
||||||
|
).Scan(&firstWindow, &secondWindow, &firstGiftWindow, &secondGiftWindow); err != nil {
|
||||||
|
t.Fatalf("query draw rollover indexes: %v", err)
|
||||||
|
}
|
||||||
|
if firstWindow != 1 || secondWindow != 98 || firstGiftWindow != 1 || secondGiftWindow != 98 {
|
||||||
|
t.Fatalf("draw rollover global/gift=%d/%d %d/%d want=1/98 1/98", firstWindow, secondWindow, firstGiftWindow, secondGiftWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, dayOneMS int64) {
|
||||||
|
t.Helper()
|
||||||
|
rule := dynamicMySQLTestRule("token-cross-day")
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, dayOneMS-100); err != nil {
|
||||||
|
t.Fatalf("publish token rule: %v", err)
|
||||||
|
}
|
||||||
|
userID := int64(3030)
|
||||||
|
earn := domain.DrawCommand{
|
||||||
|
CommandID: "token-earn", PoolID: rule.PoolID, UserID: userID, TargetUserID: 1,
|
||||||
|
DeviceID: "device-token", RoomID: "room-token", AnchorID: "anchor-token", GiftID: "super-lucky",
|
||||||
|
GiftCount: 1, CoinSpent: 5_000, GiftIncomeCoins: 50, PaidAtMS: dayOneMS,
|
||||||
|
}
|
||||||
|
if _, err := repo.ExecuteLuckyGiftDraw(ctx, earn, dayOneMS); err != nil {
|
||||||
|
t.Fatalf("earn milestone token: %v", err)
|
||||||
|
}
|
||||||
|
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
|
||||||
|
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 {
|
||||||
|
t.Fatalf("drain token test pool affected=%d, want 1", rows)
|
||||||
|
}
|
||||||
|
dayTwoMS := time.UnixMilli(dayOneMS).UTC().Add(24 * time.Hour).UnixMilli()
|
||||||
|
retain := earn
|
||||||
|
retain.CommandID = "token-retain-next-day"
|
||||||
|
retain.CoinSpent = 1
|
||||||
|
retain.GiftIncomeCoins = 0
|
||||||
|
retain.PaidAtMS = dayTwoMS
|
||||||
|
if _, err := repo.ExecuteLuckyGiftDraw(ctx, retain, dayTwoMS); err != nil {
|
||||||
|
t.Fatalf("retain token across UTC day: %v", err)
|
||||||
|
}
|
||||||
|
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
consume := retain
|
||||||
|
consume.CommandID = "token-consume-next-day"
|
||||||
|
consume.PaidAtMS++
|
||||||
|
result, err := repo.ExecuteLuckyGiftDraw(ctx, consume, dayTwoMS+1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("consume retained token: %v", err)
|
||||||
|
}
|
||||||
|
if !result.HighMultiplier || result.EffectiveRewardCoins <= 0 {
|
||||||
|
t.Fatalf("retained token did not produce payable jackpot: %+v", result)
|
||||||
|
}
|
||||||
|
assertPendingTokenCount(t, db, rule.PoolID, userID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID,
|
||||||
|
).Scan(&got); err != nil {
|
||||||
|
t.Fatalf("query pending token: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("pending token=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertExactRolling72HourBoundary(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, poolID string, endMS int64) {
|
||||||
|
t.Helper()
|
||||||
|
const hourMS = int64(time.Hour / time.Millisecond)
|
||||||
|
startMS := endMS - 72*hourMS
|
||||||
|
fullStartMS := startMS + (hourMS - startMS%hourMS)
|
||||||
|
fullEndMS := endMS - endMS%hourMS
|
||||||
|
userID := int64(9090)
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO lucky_user_rtp_hour_buckets (
|
||||||
|
app_code,pool_id,user_id,bucket_hour_ms,wager_coins,payout_coins,created_at_ms,updated_at_ms
|
||||||
|
) VALUES
|
||||||
|
('lalu',?,?,?,10,1,1,1),
|
||||||
|
('lalu',?,?,?,999,999,1,1)`,
|
||||||
|
poolID, userID, fullStartMS,
|
||||||
|
poolID, userID, fullEndMS,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed exact 72h buckets: %v", err)
|
||||||
|
}
|
||||||
|
events := []struct {
|
||||||
|
id string
|
||||||
|
atMS int64
|
||||||
|
wager int64
|
||||||
|
payout int64
|
||||||
|
}{
|
||||||
|
{"before-start", startMS - 1, 100, 100},
|
||||||
|
{"at-start", startMS, 2, 1},
|
||||||
|
{"lower-end", fullStartMS - 1, 3, 1},
|
||||||
|
{"inside-full-hour", fullStartMS, 100, 100},
|
||||||
|
{"upper-start", fullEndMS, 4, 1},
|
||||||
|
{"before-end", endMS - 1, 5, 1},
|
||||||
|
{"at-end", endMS, 100, 100},
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO lucky_user_rtp_boundary_events (
|
||||||
|
app_code,pool_id,user_id,draw_id,occurred_at_ms,wager_coins,payout_coins,created_at_ms
|
||||||
|
) VALUES ('lalu',?,?,?,?,?,?,1)`,
|
||||||
|
poolID, userID, event.id, event.atMS, event.wager, event.payout,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed exact 72h event %s: %v", event.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin exact 72h query: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
rtp, err := repo.getLuckyUserRolling72HourRTP(ctx, tx, "lalu", poolID, userID, endMS)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query exact 72h: %v", err)
|
||||||
|
}
|
||||||
|
// 完整小时只取 bucket=10/1;边界严格包含 start、排除 end,并且不重复计算 fullStart 事件。
|
||||||
|
if rtp != (domain.StrategyRTP{WagerCoins: 24, PayoutCoins: 5}) {
|
||||||
|
t.Fatalf("exact 72h RTP=%+v want wager=24 payout=5", rtp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertDynamicMySQLBatchFacts(t *testing.T, db queryRower, appCode, poolID string, userID int64, commandPrefix string, draws, spent, public, profit, anchor int64) {
|
||||||
|
t.Helper()
|
||||||
|
var count, spentSum, publicSum, profitSum, anchorSum, invalidUnits int64
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(coin_spent),0),
|
||||||
|
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.public_pool_coins')) AS SIGNED)),0),
|
||||||
|
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.profit_pool_coins')) AS SIGNED)),0),
|
||||||
|
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.anchor_return_coins')) AS SIGNED)),0),
|
||||||
|
COALESCE(SUM(CASE WHEN coin_spent <>
|
||||||
|
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.public_pool_coins')) AS SIGNED) +
|
||||||
|
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.profit_pool_coins')) AS SIGNED) +
|
||||||
|
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.anchor_return_coins')) AS SIGNED)
|
||||||
|
THEN 1 ELSE 0 END),0)
|
||||||
|
FROM lucky_draw_records
|
||||||
|
WHERE app_code=? AND pool_id=? AND command_id LIKE ?`, appCode, poolID, commandPrefix+"#%",
|
||||||
|
).Scan(&count, &spentSum, &publicSum, &profitSum, &anchorSum, &invalidUnits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query dynamic batch facts: %v", err)
|
||||||
|
}
|
||||||
|
if count != draws || spentSum != spent || publicSum != public || profitSum != profit || anchorSum != anchor || invalidUnits != 0 {
|
||||||
|
t.Fatalf("batch facts count/spent/split/invalid=%d/%d/%d/%d/%d/%d want=%d/%d/%d/%d/%d/0",
|
||||||
|
count, spentSum, publicSum, profitSum, anchorSum, invalidUnits, draws, spent, public, profit, anchor)
|
||||||
|
}
|
||||||
|
var eventCount, eventWager int64
|
||||||
|
if err := db.QueryRow(`
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(wager_coins),0) FROM lucky_user_rtp_boundary_events
|
||||||
|
WHERE app_code=? AND pool_id=? AND user_id=?`, appCode, poolID, userID,
|
||||||
|
).Scan(&eventCount, &eventWager); err != nil {
|
||||||
|
t.Fatalf("query exact 72h events: %v", err)
|
||||||
|
}
|
||||||
|
if eventCount != draws || eventWager != spent {
|
||||||
|
t.Fatalf("72h events count/wager=%d/%d want=%d/%d", eventCount, eventWager, draws, spent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryRower interface {
|
||||||
|
QueryRow(query string, args ...any) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func dynamicMySQLTestRule(poolID string) domain.RuleConfig {
|
||||||
|
stages := make([]domain.RuleStage, 0, 3)
|
||||||
|
for index, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} {
|
||||||
|
stage := domain.RuleStage{Stage: stageName}
|
||||||
|
if index >= 1 {
|
||||||
|
stage.MinRecharge30DCoins = 1
|
||||||
|
}
|
||||||
|
if index >= 2 {
|
||||||
|
stage.MinRecharge7DCoins = 1
|
||||||
|
}
|
||||||
|
for _, tier := range []struct {
|
||||||
|
id string
|
||||||
|
multiplier int64
|
||||||
|
weight int64
|
||||||
|
}{{"0x", 0, 50_000}, {"0_5x", 500_000, 40_000}, {"1x", 1_000_000, 860_000}, {"2x", 2_000_000, 50_000}} {
|
||||||
|
stage.Tiers = append(stage.Tiers, domain.RuleTier{
|
||||||
|
Stage: stageName, TierID: fmt.Sprintf("%s_%s", stageName, tier.id),
|
||||||
|
MultiplierPPM: tier.multiplier, BaseWeightPPM: tier.weight, Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stages = append(stages, stage)
|
||||||
|
}
|
||||||
|
return domain.RuleConfig{
|
||||||
|
PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3, Enabled: true,
|
||||||
|
TargetRTPPPM: 980_000, PoolRatePPM: 980_000, ProfitRatePPM: 10_000, AnchorRatePPM: 10_000,
|
||||||
|
SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 1,
|
||||||
|
InitialPoolCoins: 0, LossStreakGuarantee: 5,
|
||||||
|
LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000,
|
||||||
|
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, JackpotUser72hRTPMaxPPM: 960_000,
|
||||||
|
JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
|
||||||
|
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 3_000_000,
|
||||||
|
DeviceDailyPayoutCap: 4_000_000, RoomHourlyPayoutCap: 5_000_000, AnchorDailyPayoutCap: 6_000_000,
|
||||||
|
Stages: stages,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,278 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
// luckyDynamicRiskCounter 是 dynamic_v3 在事务内锁定的单个风控窗口。
|
||||||
|
// scope_id 始终带 pool_id 前缀,防止同一用户在普通幸运与超级幸运池之间错误共享额度。
|
||||||
|
type luckyDynamicRiskCounter struct {
|
||||||
|
ScopeType string
|
||||||
|
ScopeID string
|
||||||
|
WindowType string
|
||||||
|
BucketKey string
|
||||||
|
Payout int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type luckyUserStrategyDay struct {
|
||||||
|
StatDay string
|
||||||
|
WagerCoins int64
|
||||||
|
PayoutCoins int64
|
||||||
|
SpendCoins int64
|
||||||
|
JackpotHits int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type luckyUserRTPHour struct {
|
||||||
|
BucketHourMS int64
|
||||||
|
WagerCoins int64
|
||||||
|
PayoutCoins int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func luckyPaidTime(cmd domain.DrawCommand, nowMS int64) time.Time {
|
||||||
|
ms := cmd.PaidAtMS
|
||||||
|
if ms <= 0 {
|
||||||
|
ms = nowMS
|
||||||
|
}
|
||||||
|
return time.UnixMilli(ms).UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getLuckyDynamicRiskCounters(ctx context.Context, tx *sql.Tx, appCode, poolID string, cmd domain.DrawCommand, nowMS int64) (map[string]luckyDynamicRiskCounter, error) {
|
||||||
|
paidAt := luckyPaidTime(cmd, nowMS)
|
||||||
|
hour := paidAt.Format("2006-01-02T15")
|
||||||
|
day := paidAt.Format("2006-01-02")
|
||||||
|
specs := []luckyDynamicRiskCounter{
|
||||||
|
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour},
|
||||||
|
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
|
||||||
|
{ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day},
|
||||||
|
{ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
|
||||||
|
{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
|
||||||
|
}
|
||||||
|
out := make(map[string]luckyDynamicRiskCounter, len(specs))
|
||||||
|
for _, spec := range specs {
|
||||||
|
counter, err := r.getOrCreateLuckyDynamicRiskCounter(ctx, tx, appCode, spec, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[luckyDynamicRiskCounterKey(spec.ScopeType, spec.WindowType)] = counter
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getOrCreateLuckyDynamicRiskCounter(ctx context.Context, tx *sql.Tx, appCode string, spec luckyDynamicRiskCounter, nowMS int64) (luckyDynamicRiskCounter, error) {
|
||||||
|
// 幂等建行先取得唯一键写锁,消除两个首次请求都读到 no rows 后其中一个 duplicate-key 失败的竞态。
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_risk_counters (
|
||||||
|
app_code, scope_type, scope_id, window_type, bucket_key, payout_coins, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
||||||
|
appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return luckyDynamicRiskCounter{}, err
|
||||||
|
}
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT payout_coins
|
||||||
|
FROM lucky_risk_counters
|
||||||
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?
|
||||||
|
FOR UPDATE`,
|
||||||
|
appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey,
|
||||||
|
).Scan(&spec.Payout)
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func luckyDynamicRiskCapacity(config domain.Config, counters map[string]luckyDynamicRiskCounter) domain.StrategyRiskCapacity {
|
||||||
|
// 每个值都是“本抽还能支付多少”,而不是配置总额;0 是真实硬锁,不能被解释为 unlimited。
|
||||||
|
return domain.StrategyRiskCapacity{
|
||||||
|
Enabled: true,
|
||||||
|
SingleDrawCoins: config.MaxSinglePayout,
|
||||||
|
UserHourCoins: maxInt64(0, config.UserHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "hour")].Payout),
|
||||||
|
UserDayCoins: maxInt64(0, config.UserDailyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "day")].Payout),
|
||||||
|
DeviceDayCoins: maxInt64(0, config.DeviceDailyPayoutCap-counters[luckyDynamicRiskCounterKey("device", "day")].Payout),
|
||||||
|
RoomHourCoins: maxInt64(0, config.RoomHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("room", "hour")].Payout),
|
||||||
|
AnchorDayCoins: maxInt64(0, config.AnchorDailyPayoutCap-counters[luckyDynamicRiskCounterKey("anchor", "day")].Payout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) addLuckyDynamicRiskPayout(ctx context.Context, tx *sql.Tx, appCode string, counters map[string]luckyDynamicRiskCounter, payout, nowMS int64) error {
|
||||||
|
if payout <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, counter := range counters {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_risk_counters
|
||||||
|
SET payout_coins = payout_coins + ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?`,
|
||||||
|
payout, nowMS, appCode, counter.ScopeType, counter.ScopeID, counter.WindowType, counter.BucketKey,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func luckyDynamicRiskCounterKey(scopeType, windowType string) string {
|
||||||
|
return scopeType + ":" + windowType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getOrCreateLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserStrategyDay, error) {
|
||||||
|
day := luckyPaidTime(cmd, nowMS).Format("2006-01-02")
|
||||||
|
state := luckyUserStrategyDay{StatDay: day}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_user_strategy_days (
|
||||||
|
app_code, pool_id, user_id, stat_day, wager_coins, payout_coins, spend_coins,
|
||||||
|
jackpot_hits, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, 0, 0, 0, 0, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
||||||
|
appCode, poolID, userID, day, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return luckyUserStrategyDay{}, err
|
||||||
|
}
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT wager_coins, payout_coins, spend_coins, jackpot_hits
|
||||||
|
FROM lucky_user_strategy_days
|
||||||
|
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?
|
||||||
|
FOR UPDATE`, appCode, poolID, userID, day,
|
||||||
|
).Scan(&state.WagerCoins, &state.PayoutCoins, &state.SpendCoins, &state.JackpotHits)
|
||||||
|
return state, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) persistLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserStrategyDay, nowMS int64) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_user_strategy_days
|
||||||
|
SET wager_coins = ?, payout_coins = ?, spend_coins = ?, jackpot_hits = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?`,
|
||||||
|
state.WagerCoins, state.PayoutCoins, state.SpendCoins, state.JackpotHits, nowMS,
|
||||||
|
appCode, poolID, userID, state.StatDay,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getOrCreateLuckyUserRTPHour(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserRTPHour, error) {
|
||||||
|
paidAt := luckyPaidTime(cmd, nowMS)
|
||||||
|
bucketHourMS := paidAt.Truncate(time.Hour).UnixMilli()
|
||||||
|
state := luckyUserRTPHour{BucketHourMS: bucketHourMS}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_user_rtp_hour_buckets (
|
||||||
|
app_code, pool_id, user_id, bucket_hour_ms, wager_coins, payout_coins, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, 0, 0, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
||||||
|
appCode, poolID, userID, bucketHourMS, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return luckyUserRTPHour{}, err
|
||||||
|
}
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT wager_coins, payout_coins
|
||||||
|
FROM lucky_user_rtp_hour_buckets
|
||||||
|
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND bucket_hour_ms = ?
|
||||||
|
FOR UPDATE`, appCode, poolID, userID, bucketHourMS,
|
||||||
|
).Scan(&state.WagerCoins, &state.PayoutCoins)
|
||||||
|
return state, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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。
|
||||||
|
// 完整小时从聚合桶读取;头尾两个不足一小时的片段从专用边界事件读取,避免回扫 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")
|
||||||
|
}
|
||||||
|
fullStartMS := startMS
|
||||||
|
if remainder := startMS % hourMS; remainder != 0 {
|
||||||
|
fullStartMS += hourMS - remainder
|
||||||
|
}
|
||||||
|
fullEndMS := endMS - endMS%hourMS
|
||||||
|
var state domain.StrategyRTP
|
||||||
|
if fullStartMS < fullEndMS {
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
|
||||||
|
FROM lucky_user_rtp_hour_buckets
|
||||||
|
WHERE app_code = ? AND pool_id = ? AND user_id = ?
|
||||||
|
AND bucket_hour_ms >= ? AND bucket_hour_ms < ?`,
|
||||||
|
appCode, poolID, userID, fullStartMS, fullEndMS,
|
||||||
|
).Scan(&state.WagerCoins, &state.PayoutCoins); err != nil {
|
||||||
|
return domain.StrategyRTP{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, boundary := range [][2]int64{{startMS, fullStartMS}, {fullEndMS, endMS}} {
|
||||||
|
if boundary[0] >= boundary[1] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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
|
||||||
|
WHERE app_code = ? AND pool_id = ? AND user_id = ?
|
||||||
|
AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
|
||||||
|
appCode, poolID, userID, boundary[0], boundary[1],
|
||||||
|
).Scan(&wager, &payout); err != nil {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
state.WagerCoins += wager
|
||||||
|
state.PayoutCoins += payout
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) insertLuckyUserRTPBoundaryEvents(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, records []luckyDrawRecordInput, nowMS int64) error {
|
||||||
|
const chunkSize = 200
|
||||||
|
for start := 0; start < len(records); start += chunkSize {
|
||||||
|
end := start + chunkSize
|
||||||
|
if end > len(records) {
|
||||||
|
end = len(records)
|
||||||
|
}
|
||||||
|
var query strings.Builder
|
||||||
|
query.WriteString(`
|
||||||
|
INSERT INTO lucky_user_rtp_boundary_events (
|
||||||
|
app_code, pool_id, user_id, draw_id, occurred_at_ms, wager_coins, payout_coins, created_at_ms
|
||||||
|
) VALUES `)
|
||||||
|
args := make([]any, 0, (end-start)*8)
|
||||||
|
for index, record := range records[start:end] {
|
||||||
|
if index > 0 {
|
||||||
|
query.WriteByte(',')
|
||||||
|
}
|
||||||
|
query.WriteString("(?,?,?,?,?,?,?,?)")
|
||||||
|
args = append(args,
|
||||||
|
appCode, poolID, userID, record.DrawID, luckyPaidTime(record.Command, nowMS).UnixMilli(),
|
||||||
|
record.Command.CoinSpent, record.Candidate.effectiveReward(), nowMS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) persistLuckyUserRTPHour(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserRTPHour, nowMS int64) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_user_rtp_hour_buckets
|
||||||
|
SET wager_coins = ?, payout_coins = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND bucket_hour_ms = ?`,
|
||||||
|
state.WagerCoins, state.PayoutCoins, nowMS, appCode, poolID, userID, state.BucketHourMS,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) updateLuckyDynamicPoolNet(ctx context.Context, tx *sql.Tx, appCode, scopeID string, publicIn, payout, profitIn, anchorIncome, nowMS int64) error {
|
||||||
|
if publicIn < 0 || payout < 0 || profitIn < 0 || anchorIncome < 0 {
|
||||||
|
return fmt.Errorf("dynamic lucky gift pool delta cannot be negative")
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_pools
|
||||||
|
SET balance = balance + ? - ?, total_in = total_in + ?, total_out = total_out + ?,
|
||||||
|
profit_total = profit_total + ?, anchor_income_total = anchor_income_total + ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
|
||||||
|
publicIn, payout, publicIn, payout, profitIn, anchorIncome, nowMS,
|
||||||
|
appCode, luckyDynamicPoolScopeType, scopeID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
crand "crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
type luckyCryptoStrategyRandom struct{}
|
||||||
|
|
||||||
|
func (luckyCryptoStrategyRandom) Int63n(n int64) (int64, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return 0, fmt.Errorf("lucky strategy random bound must be positive")
|
||||||
|
}
|
||||||
|
value, err := crand.Int(crand.Reader, big.NewInt(n))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return value.Int64(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func luckyUsesDynamicStrategy(config domain.Config) bool {
|
||||||
|
return config.StrategyVersion == domain.StrategyDynamicV3
|
||||||
|
}
|
||||||
|
|
||||||
|
// luckyDynamicStrategyConfig 把不可变规则快照转换为纯策略输入。
|
||||||
|
// 同一倍率在三个充值阶段可能使用不同 tier_id;运行内核按倍率合并并保存 stage->weight,
|
||||||
|
// 从而既只有一个 0x 候选,又不会把某阶段不存在的倍率错误继承到另一阶段。
|
||||||
|
func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
|
||||||
|
stageNames := make([]string, 0, len(config.Stages))
|
||||||
|
rechargeStages := make([]domain.StrategyRechargeStage, 0, len(config.Stages))
|
||||||
|
for _, stage := range config.Stages {
|
||||||
|
stageNames = append(stageNames, stage.Stage)
|
||||||
|
rechargeStages = append(rechargeStages, domain.StrategyRechargeStage{
|
||||||
|
Name: stage.Stage,
|
||||||
|
MinRecharge7DCoins: stage.MinRecharge7DCoins,
|
||||||
|
MinRecharge30DCoins: stage.MinRecharge30DCoins,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
jackpotSet := make(map[int64]struct{}, len(config.JackpotMultiplierPPMs))
|
||||||
|
for _, multiplier := range config.JackpotMultiplierPPMs {
|
||||||
|
jackpotSet[multiplier] = struct{}{}
|
||||||
|
}
|
||||||
|
tiersByMultiplier := make(map[int64]*domain.StrategyTier)
|
||||||
|
for _, stage := range config.Stages {
|
||||||
|
for _, configured := range stage.Tiers {
|
||||||
|
if !configured.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tier := tiersByMultiplier[configured.MultiplierPPM]
|
||||||
|
if tier == nil {
|
||||||
|
stageWeights := make(map[string]int64, len(stageNames))
|
||||||
|
for _, stageName := range stageNames {
|
||||||
|
stageWeights[stageName] = 0
|
||||||
|
}
|
||||||
|
tier = &domain.StrategyTier{
|
||||||
|
ID: fmt.Sprintf("multiplier_%d", configured.MultiplierPPM),
|
||||||
|
MultiplierPPM: configured.MultiplierPPM,
|
||||||
|
StageWeightPPM: stageWeights,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
tiersByMultiplier[configured.MultiplierPPM] = tier
|
||||||
|
}
|
||||||
|
tier.StageWeightPPM[stage.Stage] += configured.BaseWeightPPM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 大奖机制从显式倍率集合随机,不要求运营把 200x/500x/1000x 塞进普通概率表。
|
||||||
|
// 图片没有提供大奖之间的权重,因此统一使用 1;这一选择会完整进入 decision trace,不能被误认为基础概率。
|
||||||
|
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
|
||||||
|
}
|
||||||
|
tier.Jackpot = true
|
||||||
|
tier.JackpotWeight = 1
|
||||||
|
}
|
||||||
|
multipliers := make([]int64, 0, len(tiersByMultiplier))
|
||||||
|
for multiplier := range tiersByMultiplier {
|
||||||
|
multipliers = append(multipliers, multiplier)
|
||||||
|
}
|
||||||
|
sort.Slice(multipliers, func(i, j int) bool { return multipliers[i] < multipliers[j] })
|
||||||
|
tiers := make([]domain.StrategyTier, 0, len(multipliers))
|
||||||
|
for _, multiplier := range multipliers {
|
||||||
|
tiers = append(tiers, *tiersByMultiplier[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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// luckyDynamicFundSplit 以不可变规则做唯一拆账,并校验 wallet 已经发给收礼人的 COIN 与规则主播份额完全一致。
|
||||||
|
// dynamic_v3 不能把旧 room 缺字段解释成 0,也不能把 App 钱包配置的其他返币比例静默塞进盈利池;
|
||||||
|
// 启用新规则前必须先完成 wallet/room 契约滚动升级并让礼物返币配置与本规则一致。
|
||||||
|
func luckyDynamicFundSplit(totalSpent, actualAnchorIncome int64, config domain.StrategyConfig) (domain.StrategyFundSplit, error) {
|
||||||
|
configured, err := domain.SplitLuckyGiftStrategyFunds(totalSpent, config)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StrategyFundSplit{}, err
|
||||||
|
}
|
||||||
|
if actualAnchorIncome != configured.AnchorReturnCoins {
|
||||||
|
return domain.StrategyFundSplit{}, fmt.Errorf(
|
||||||
|
"dynamic lucky gift anchor income mismatch: wallet=%d configured=%d",
|
||||||
|
actualAnchorIncome,
|
||||||
|
configured.AnchorReturnCoins,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return configured, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// luckyDynamicUnitFundSplits 联合分配整批的三个资金桶:每一抽都先满足
|
||||||
|
// public_i+profit_i+anchor_i=unit_spent_i,同时整批三桶又精确等于总拆账。
|
||||||
|
// 不能分别对四个总量调用“余数落前几抽”,否则 100 金币/99 抽的首抽会出现 spent=2、三桶=3。
|
||||||
|
func luckyDynamicUnitFundSplits(totalSpent int64, drawCount int32, total domain.StrategyFundSplit) ([]domain.StrategyFundSplit, error) {
|
||||||
|
if totalSpent <= 0 || drawCount <= 0 || total.PublicPoolCoins < 0 || total.ProfitPoolCoins < 0 || total.AnchorReturnCoins < 0 ||
|
||||||
|
total.PublicPoolCoins+total.ProfitPoolCoins+total.AnchorReturnCoins != totalSpent {
|
||||||
|
return nil, fmt.Errorf("dynamic lucky gift batch split is invalid")
|
||||||
|
}
|
||||||
|
units := make([]int64, drawCount)
|
||||||
|
for index := int32(1); index <= drawCount; index++ {
|
||||||
|
units[index-1] = luckyDrawUnitSpend(totalSpent, drawCount, index)
|
||||||
|
if units[index-1] <= 0 {
|
||||||
|
return nil, fmt.Errorf("dynamic lucky gift unit spend must be positive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publicParts, err := luckyDynamicApportionByCapacity(units, total.PublicPoolCoins)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remaining := make([]int64, len(units))
|
||||||
|
for index := range units {
|
||||||
|
remaining[index] = units[index] - publicParts[index]
|
||||||
|
}
|
||||||
|
anchorParts, err := luckyDynamicApportionByCapacity(remaining, total.AnchorReturnCoins)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parts := make([]domain.StrategyFundSplit, len(units))
|
||||||
|
var publicSum, profitSum, anchorSum int64
|
||||||
|
for index := range units {
|
||||||
|
parts[index] = domain.StrategyFundSplit{
|
||||||
|
PublicPoolCoins: publicParts[index],
|
||||||
|
AnchorReturnCoins: anchorParts[index],
|
||||||
|
ProfitPoolCoins: units[index] - publicParts[index] - anchorParts[index],
|
||||||
|
}
|
||||||
|
publicSum += parts[index].PublicPoolCoins
|
||||||
|
profitSum += parts[index].ProfitPoolCoins
|
||||||
|
anchorSum += parts[index].AnchorReturnCoins
|
||||||
|
}
|
||||||
|
if publicSum != total.PublicPoolCoins || profitSum != total.ProfitPoolCoins || anchorSum != total.AnchorReturnCoins {
|
||||||
|
return nil, fmt.Errorf("dynamic lucky gift unit split does not conserve bucket totals")
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// luckyDynamicApportionByCapacity 按每抽金额占比做累计配额,既避免前置抽被贪心塞满,
|
||||||
|
// 又保证每份不超过该抽剩余容量。big.Int 只用于防止 total*part 在大额外部请求上溢出 int64。
|
||||||
|
func luckyDynamicApportionByCapacity(capacities []int64, partTotal int64) ([]int64, error) {
|
||||||
|
var capacityTotal int64
|
||||||
|
for _, capacity := range capacities {
|
||||||
|
if capacity < 0 || capacityTotal > int64(^uint64(0)>>1)-capacity {
|
||||||
|
return nil, fmt.Errorf("dynamic lucky gift unit capacity is invalid")
|
||||||
|
}
|
||||||
|
capacityTotal += capacity
|
||||||
|
}
|
||||||
|
if partTotal < 0 || partTotal > capacityTotal || capacityTotal <= 0 {
|
||||||
|
return nil, fmt.Errorf("dynamic lucky gift apportioned total exceeds capacity")
|
||||||
|
}
|
||||||
|
parts := make([]int64, len(capacities))
|
||||||
|
denominator := big.NewInt(capacityTotal)
|
||||||
|
var prefix, allocated int64
|
||||||
|
for index, capacity := range capacities {
|
||||||
|
prefix += capacity
|
||||||
|
cumulative := new(big.Int).Mul(big.NewInt(prefix), big.NewInt(partTotal))
|
||||||
|
cumulative.Quo(cumulative, denominator)
|
||||||
|
if !cumulative.IsInt64() {
|
||||||
|
return nil, fmt.Errorf("dynamic lucky gift unit apportion overflow")
|
||||||
|
}
|
||||||
|
parts[index] = cumulative.Int64() - allocated
|
||||||
|
if parts[index] < 0 || parts[index] > capacity {
|
||||||
|
return nil, fmt.Errorf("dynamic lucky gift unit apportion exceeded one draw")
|
||||||
|
}
|
||||||
|
allocated += parts[index]
|
||||||
|
}
|
||||||
|
if allocated != partTotal {
|
||||||
|
return nil, fmt.Errorf("dynamic lucky gift unit apportion lost funds")
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDynamicLuckyGiftRequiresExplicitStableDeviceScope(t *testing.T) {
|
||||||
|
for _, value := range []string{"", " ", strings.Repeat("d", 129)} {
|
||||||
|
if _, err := normalizeLuckyDynamicDeviceID(value); err == nil {
|
||||||
|
t.Fatalf("dynamic_v3 accepted invalid device_id length=%d", len(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, err := normalizeLuckyDynamicDeviceID(" device-auth-session-1 "); err != nil || got != "device-auth-session-1" {
|
||||||
|
t.Fatalf("normalize explicit dynamic device scope got=%q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalDeviceScopeUsesCallerBoundDeviceInsteadOfExternalUser(t *testing.T) {
|
||||||
|
firstUser := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-a", DeviceID: "shared-device"}
|
||||||
|
secondUser := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-b", DeviceID: "shared-device"}
|
||||||
|
otherDevice := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-a", DeviceID: "other-device"}
|
||||||
|
firstScope := externalLuckyDeviceID(firstUser.AppCode, firstUser.DeviceID)
|
||||||
|
if secondScope := externalLuckyDeviceID(secondUser.AppCode, secondUser.DeviceID); secondScope != firstScope {
|
||||||
|
// 同物理设备上的多账号必须共享设备日上限,不能继续按 external_user_id 分裂。
|
||||||
|
t.Fatalf("same external device produced different scopes: first=%q second=%q", firstScope, secondScope)
|
||||||
|
}
|
||||||
|
if otherScope := externalLuckyDeviceID(otherDevice.AppCode, otherDevice.DeviceID); otherScope == firstScope {
|
||||||
|
t.Fatalf("different external devices collapsed to one scope: %q", otherScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyA := externalLuckyFixedCompatibleDeviceID(domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "legacy-a"})
|
||||||
|
legacyB := externalLuckyFixedCompatibleDeviceID(domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "legacy-b"})
|
||||||
|
if legacyA == legacyB {
|
||||||
|
t.Fatalf("fixed_v2 legacy external users must retain their prior independent fallback scopes: %q", legacyA)
|
||||||
|
}
|
||||||
|
if historical := externalLuckyDeviceID("aslan", "legacy-a"); legacyA != historical {
|
||||||
|
t.Fatalf("fixed_v2 legacy device scope changed: got=%q historical=%q", legacyA, historical)
|
||||||
|
}
|
||||||
|
if fixedWithNewField := externalLuckyFixedCompatibleDeviceID(firstUser); fixedWithNewField != externalLuckyDeviceID("aslan", "user-a") {
|
||||||
|
t.Fatalf("fixed_v2 unexpectedly interpreted new device_id: %q", fixedWithNewField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyDynamicUnitFundSplitsConserveEveryDrawAndBatchBucket(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
spent int64
|
||||||
|
drawCount int32
|
||||||
|
totalSplit domain.StrategyFundSplit
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "image 100 coins across 99 draws",
|
||||||
|
spent: 100,
|
||||||
|
drawCount: 99,
|
||||||
|
totalSplit: domain.StrategyFundSplit{
|
||||||
|
PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "990 coins keeps configured floor rounding totals",
|
||||||
|
spent: 990,
|
||||||
|
drawCount: 99,
|
||||||
|
totalSplit: domain.StrategyFundSplit{
|
||||||
|
PublicPoolCoins: 970, ProfitPoolCoins: 11, AnchorReturnCoins: 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uneven unit prices and configured buckets",
|
||||||
|
spent: 101,
|
||||||
|
drawCount: 6,
|
||||||
|
totalSplit: domain.StrategyFundSplit{
|
||||||
|
PublicPoolCoins: 90, ProfitPoolCoins: 5, AnchorReturnCoins: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
parts, err := luckyDynamicUnitFundSplits(test.spent, test.drawCount, test.totalSplit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("split: %v", err)
|
||||||
|
}
|
||||||
|
if len(parts) != int(test.drawCount) {
|
||||||
|
t.Fatalf("parts=%d want=%d", len(parts), test.drawCount)
|
||||||
|
}
|
||||||
|
var publicTotal, profitTotal, anchorTotal int64
|
||||||
|
for index, part := range parts {
|
||||||
|
unitSpent := luckyDrawUnitSpend(test.spent, test.drawCount, int32(index+1))
|
||||||
|
if got := part.PublicPoolCoins + part.ProfitPoolCoins + part.AnchorReturnCoins; got != unitSpent {
|
||||||
|
t.Fatalf("draw %d buckets=%d want unit spend=%d: %+v", index+1, got, unitSpent, part)
|
||||||
|
}
|
||||||
|
publicTotal += part.PublicPoolCoins
|
||||||
|
profitTotal += part.ProfitPoolCoins
|
||||||
|
anchorTotal += part.AnchorReturnCoins
|
||||||
|
}
|
||||||
|
if publicTotal != test.totalSplit.PublicPoolCoins || profitTotal != test.totalSplit.ProfitPoolCoins || anchorTotal != test.totalSplit.AnchorReturnCoins {
|
||||||
|
t.Fatalf("batch totals=%d/%d/%d want=%+v", publicTotal, profitTotal, anchorTotal, test.totalSplit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyDynamicFundSplitRejectsWalletAnchorMismatch(t *testing.T) {
|
||||||
|
config := domain.DefaultLuckyGiftStrategyConfig()
|
||||||
|
if split, err := luckyDynamicFundSplit(100, 1, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
|
||||||
|
t.Fatalf("matching split=%+v err=%v", split, err)
|
||||||
|
}
|
||||||
|
if _, err := luckyDynamicFundSplit(100, 0, config); err == nil {
|
||||||
|
t.Fatal("missing wallet anchor snapshot was accepted")
|
||||||
|
}
|
||||||
|
if _, err := luckyDynamicFundSplit(100, 10, config); err == nil {
|
||||||
|
t.Fatal("wallet anchor ratio different from rule was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
luckyPPMScale int64 = 1_000_000
|
luckyPPMScale int64 = 1_000_000
|
||||||
luckyBasePoolScopeType = "pool"
|
luckyBasePoolScopeType = "pool"
|
||||||
|
// dynamic_v3 与 fixed_v2 的资金算法和启动规则不同,必须使用独立主键空间;
|
||||||
|
// 任何兼容读取都不能把两种策略重新折叠到同一口 pool 账本。
|
||||||
|
luckyDynamicPoolScopeType = "pool_dynamic_v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type luckyRTPWindow struct {
|
type luckyRTPWindow struct {
|
||||||
@ -53,6 +56,8 @@ type luckyPool struct {
|
|||||||
ReserveFloor int64
|
ReserveFloor int64
|
||||||
TotalIn int64
|
TotalIn int64
|
||||||
TotalOut int64
|
TotalOut int64
|
||||||
|
ManualCreditTotal int64
|
||||||
|
ManualDebitTotal int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p luckyPool) capacity() int64 {
|
func (p luckyPool) capacity() int64 {
|
||||||
@ -68,6 +73,9 @@ type luckyUserState struct {
|
|||||||
CumulativeWagerCoins int64
|
CumulativeWagerCoins int64
|
||||||
EquivalentDraws int64
|
EquivalentDraws int64
|
||||||
LossStreak int64
|
LossStreak int64
|
||||||
|
// PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。
|
||||||
|
// 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。
|
||||||
|
PendingJackpotTokens int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type luckyCandidate struct {
|
type luckyCandidate struct {
|
||||||
@ -99,6 +107,12 @@ type luckyDrawRecordInput struct {
|
|||||||
GlobalWindow luckyRTPWindow
|
GlobalWindow luckyRTPWindow
|
||||||
GiftWindow luckyRTPWindow
|
GiftWindow luckyRTPWindow
|
||||||
BasePool luckyPool
|
BasePool luckyPool
|
||||||
|
// StrategyDecision 只在 dynamic_v3 存在;完整权重、动态因子、P/W 重抽和大奖门槛会随 draw fact 固化,
|
||||||
|
// 后台以后修改规则版本也不会让历史结果失去可解释性。
|
||||||
|
StrategyDecision *domain.StrategyDecision
|
||||||
|
FundSplit domain.StrategyFundSplit
|
||||||
|
UserDayRTP domain.StrategyRTP
|
||||||
|
User72HourRTP domain.StrategyRTP
|
||||||
RewardStatus string
|
RewardStatus string
|
||||||
NowMS int64
|
NowMS int64
|
||||||
}
|
}
|
||||||
@ -159,6 +173,7 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand
|
|||||||
GiftID: cmd.GiftID,
|
GiftID: cmd.GiftID,
|
||||||
GiftPrice: config.GiftPrice,
|
GiftPrice: config.GiftPrice,
|
||||||
RuleVersion: config.RuleVersion,
|
RuleVersion: config.RuleVersion,
|
||||||
|
StrategyVersion: config.StrategyVersion,
|
||||||
TargetRTPPPM: config.TargetRTPPPM,
|
TargetRTPPPM: config.TargetRTPPPM,
|
||||||
ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config),
|
ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config),
|
||||||
}, nil
|
}, nil
|
||||||
@ -214,6 +229,13 @@ func (r *Repository) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domai
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
|
func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
|
||||||
|
appCode := appcode.FromContext(ctx)
|
||||||
|
// lucky_draw_records 的唯一键只能拒绝重复写,无法让两个“结果尚不存在”的首次请求都成功:
|
||||||
|
// 两个事务可能同时读到空结果,后到者最终只能撞唯一键回滚。原始 command_id 锁行把首次执行
|
||||||
|
// 串行化;等待者拿到锁后会重新走下方事实探测并完整回读首次结果,绝不重新随机或推进资金状态。
|
||||||
|
if err := r.lockLuckyGiftCommand(ctx, tx, appCode, cmd.CommandID, nowMS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// gift_count 是抽奖次数,coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。
|
// gift_count 是抽奖次数,coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。
|
||||||
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
|
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
|
||||||
drawCount := cmd.GiftCount
|
drawCount := cmd.GiftCount
|
||||||
@ -227,7 +249,6 @@ func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx,
|
|||||||
}
|
}
|
||||||
return []domain.DrawResult{result}, nil
|
return []domain.DrawResult{result}, nil
|
||||||
}
|
}
|
||||||
appCode := appcode.FromContext(ctx)
|
|
||||||
if drawCount > 1 {
|
if drawCount > 1 {
|
||||||
// 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库;批量拆分上线后遇到这类重试必须直接返回旧事实,不能追加子抽奖造成重复返奖。
|
// 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库;批量拆分上线后遇到这类重试必须直接返回旧事实,不能追加子抽奖造成重复返奖。
|
||||||
if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists {
|
if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists {
|
||||||
@ -249,6 +270,24 @@ func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx,
|
|||||||
return r.executeOptimizedLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, nowMS)
|
return r.executeOptimizedLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, nowMS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lockLuckyGiftCommand 为内部钱包已扣费命令创建稳定的事务互斥点。
|
||||||
|
// SELECT ... FOR UPDATE 不能锁住尚不存在的 draw record;幂等插入锁行后,InnoDB 会让同一
|
||||||
|
// app_code+command_id 的并发事务等待首事务提交或回滚,再由显式 FOR UPDATE 固定后续锁顺序。
|
||||||
|
func (r *Repository) lockLuckyGiftCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, nowMS int64) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_gift_command_locks (app_code, command_id, created_at_ms)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE created_at_ms = created_at_ms`, appCode, commandID, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var lockedCommandID string
|
||||||
|
return tx.QueryRowContext(ctx, `
|
||||||
|
SELECT command_id FROM lucky_gift_command_locks
|
||||||
|
WHERE app_code = ? AND command_id = ? FOR UPDATE`, appCode, commandID,
|
||||||
|
).Scan(&lockedCommandID)
|
||||||
|
}
|
||||||
|
|
||||||
// collectLuckyBatchDrawsByCommand 只服务批量命令重试:完整读回已提交的子抽奖,再由外层聚合成响应。
|
// collectLuckyBatchDrawsByCommand 只服务批量命令重试:完整读回已提交的子抽奖,再由外层聚合成响应。
|
||||||
// 这里不重新计算随机结果,避免同一个扣费命令因重试得到不同中奖结果。
|
// 这里不重新计算随机结果,避免同一个扣费命令因重试得到不同中奖结果。
|
||||||
func (r *Repository) collectLuckyBatchDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, drawCount int32) ([]domain.DrawResult, error) {
|
func (r *Repository) collectLuckyBatchDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, drawCount int32) ([]domain.DrawResult, error) {
|
||||||
@ -283,6 +322,16 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx
|
|||||||
if !exists || !ruleConfig.Enabled {
|
if !exists || !ruleConfig.Enabled {
|
||||||
return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
|
return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
|
||||||
}
|
}
|
||||||
|
if ruleConfig.StrategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
// dynamic_v3 必须在同一个事务里推进 P/W 重抽、充值层、大奖资格和六维风控;
|
||||||
|
// 不能先落入 fixed_v2 再在随机后修结果,否则审计和资金状态都会分叉。
|
||||||
|
return r.executeDynamicLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, ruleConfig, luckyDynamicPersistence{}, nowMS)
|
||||||
|
}
|
||||||
|
if cmd.PaidAtMS <= 0 {
|
||||||
|
// fixed_v2 在旧 room/wallet 滚动发布期间允许使用 owner 收到请求的 UTC 时间兼容;
|
||||||
|
// dynamic_v3 在上面的专用路径严格拒绝缺失 wallet paid_at,不能走这条降级。
|
||||||
|
cmd.PaidAtMS = nowMS
|
||||||
|
}
|
||||||
baseConfig, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
|
baseConfig, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -969,22 +1018,32 @@ func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
|
|||||||
// luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态,不要求成为完整候选空间。
|
// luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态,不要求成为完整候选空间。
|
||||||
// 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位,足够定位“为什么本抽是 0/1/高倍”。
|
// 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位,足够定位“为什么本抽是 0/1/高倍”。
|
||||||
func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) {
|
func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) {
|
||||||
candidateSnapshot, _ := json.Marshal(map[string]any{
|
candidateData := map[string]any{
|
||||||
"base_reward": input.Candidate.BaseReward,
|
"base_reward": input.Candidate.BaseReward,
|
||||||
"limited": input.Limited,
|
"limited": input.Limited,
|
||||||
"multiplier_ppm": input.Candidate.MultiplierPPM,
|
"multiplier_ppm": input.Candidate.MultiplierPPM,
|
||||||
"weight": input.Candidate.Weight,
|
"weight": input.Candidate.Weight,
|
||||||
})
|
}
|
||||||
poolSnapshot, _ := json.Marshal(map[string]any{
|
poolData := map[string]any{
|
||||||
"base_balance": input.BasePool.Balance,
|
"base_balance": input.BasePool.Balance,
|
||||||
"base_reserve_floor": input.BasePool.ReserveFloor,
|
"base_reserve_floor": input.BasePool.ReserveFloor,
|
||||||
"base_total_in": input.BasePool.TotalIn,
|
"base_total_in": input.BasePool.TotalIn,
|
||||||
"base_total_out": input.BasePool.TotalOut,
|
"base_total_out": input.BasePool.TotalOut,
|
||||||
})
|
}
|
||||||
rtpSnapshot, _ := json.Marshal(map[string]any{
|
rtpData := map[string]any{
|
||||||
"global_window": input.GlobalWindow.WindowIndex,
|
"global_window": input.GlobalWindow.WindowIndex,
|
||||||
"gift_window": input.GiftWindow.WindowIndex,
|
"gift_window": input.GiftWindow.WindowIndex,
|
||||||
})
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
candidateSnapshot, _ := json.Marshal(candidateData)
|
||||||
|
poolSnapshot, _ := json.Marshal(poolData)
|
||||||
|
rtpSnapshot, _ := json.Marshal(rtpData)
|
||||||
return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot)
|
return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1043,6 +1102,20 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx,
|
|||||||
if !exists || !ruleConfig.Enabled {
|
if !exists || !ruleConfig.Enabled {
|
||||||
return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
|
return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
|
||||||
}
|
}
|
||||||
|
if ruleConfig.StrategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
results, err := r.executeDynamicLuckyGiftDrawBatch(ctx, tx, cmd, 1, ruleConfig, luckyDynamicPersistence{}, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DrawResult{}, err
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
return domain.DrawResult{}, xerr.New(xerr.Internal, "dynamic lucky gift single draw returned an invalid result count")
|
||||||
|
}
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
|
if cmd.PaidAtMS <= 0 {
|
||||||
|
// 仅历史 fixed_v2 接受缺失 paid_at 的滚动发布兼容;新 dynamic_v3 已在专用路径 fail-close。
|
||||||
|
cmd.PaidAtMS = nowMS
|
||||||
|
}
|
||||||
config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
|
config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
@ -1870,15 +1943,29 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
|
|||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
}
|
}
|
||||||
appCode := appcode.Normalize(query.AppCode)
|
// 空 app_code 是后台“全部应用”筛选,不能经过 Normalize 变成默认 lalu;只有明确值才参与租户条件。
|
||||||
|
appCode := strings.ToLower(strings.TrimSpace(query.AppCode))
|
||||||
poolID := strings.TrimSpace(query.PoolID)
|
poolID := strings.TrimSpace(query.PoolID)
|
||||||
if poolID != "" {
|
if poolID != "" {
|
||||||
poolID = luckyPoolID(poolID)
|
poolID = luckyPoolID(poolID)
|
||||||
}
|
}
|
||||||
|
strategyVersion := strings.ToLower(strings.TrimSpace(query.StrategyVersion))
|
||||||
|
|
||||||
// 先读真实单池账本。这个查询不会 FOR UPDATE,也不会调用 getOrCreate,数据汇总刷新不能制造奖池行或改变余额。
|
// 先读两套真实账本。这个查询不会 FOR UPDATE,也不会调用 getOrCreate,后台列表不能制造奖池行或改变余额。
|
||||||
where := []string{"scope_type = ?"}
|
// strategy_version 由内部 scope_type 显式映射;固定与动态绝不能再按 app+pool 折叠覆盖。
|
||||||
args := []any{luckyBasePoolScopeType}
|
where := make([]string, 0, 3)
|
||||||
|
args := make([]any, 0, 4)
|
||||||
|
if strategyVersion != "" {
|
||||||
|
scopeType, err := luckyPoolScopeForStrategy(strategyVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
where = append(where, "scope_type = ?")
|
||||||
|
args = append(args, scopeType)
|
||||||
|
} else {
|
||||||
|
where = append(where, "scope_type IN (?, ?)")
|
||||||
|
args = append(args, luckyBasePoolScopeType, luckyDynamicPoolScopeType)
|
||||||
|
}
|
||||||
if appCode != "" {
|
if appCode != "" {
|
||||||
where = append(where, "app_code = ?")
|
where = append(where, "app_code = ?")
|
||||||
args = append(args, appCode)
|
args = append(args, appCode)
|
||||||
@ -1888,10 +1975,11 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
|
|||||||
args = append(args, poolID)
|
args = append(args, poolID)
|
||||||
}
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT app_code, scope_id, balance, reserve_floor, total_in, total_out, updated_at_ms
|
SELECT app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
|
manual_credit_total, manual_debit_total, updated_at_ms
|
||||||
FROM lucky_pools
|
FROM lucky_pools
|
||||||
WHERE `+strings.Join(where, " AND ")+`
|
WHERE `+strings.Join(where, " AND ")+`
|
||||||
ORDER BY app_code ASC, scope_id ASC`, args...)
|
ORDER BY app_code ASC, scope_id ASC, scope_type ASC`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -1900,12 +1988,21 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
|
|||||||
byKey := make(map[string]domain.PoolBalance)
|
byKey := make(map[string]domain.PoolBalance)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item domain.PoolBalance
|
var item domain.PoolBalance
|
||||||
if err := rows.Scan(&item.AppCode, &item.PoolID, &item.Balance, &item.ReserveFloor, &item.TotalIn, &item.TotalOut, &item.UpdatedAtMS); err != nil {
|
var scopeType string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.AppCode, &scopeType, &item.PoolID, &item.Balance, &item.ReserveFloor, &item.TotalIn, &item.TotalOut,
|
||||||
|
&item.ManualCreditTotal, &item.ManualDebitTotal, &item.UpdatedAtMS,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
mappedStrategy, ok := luckyStrategyForPoolScope(scopeType)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.StrategyVersion = mappedStrategy
|
||||||
item.Materialized = true
|
item.Materialized = true
|
||||||
item.AvailableBalance = luckyAvailableBalance(item.Balance, item.ReserveFloor)
|
item.AvailableBalance = luckyAvailableBalance(item.Balance, item.ReserveFloor)
|
||||||
byKey[luckyPoolBalanceKey(item.AppCode, item.PoolID)] = item
|
byKey[luckyPoolBalanceKey(item.AppCode, item.PoolID, item.StrategyVersion)] = item
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -1920,7 +2017,14 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
|
|||||||
if poolID != "" && luckyPoolID(config.PoolID) != poolID {
|
if poolID != "" && luckyPoolID(config.PoolID) != poolID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := luckyPoolBalanceKey(config.AppCode, config.PoolID)
|
configStrategy := strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
||||||
|
if configStrategy == "" {
|
||||||
|
configStrategy = domain.StrategyFixedV2
|
||||||
|
}
|
||||||
|
if strategyVersion != "" && configStrategy != strategyVersion {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := luckyPoolBalanceKey(config.AppCode, config.PoolID, configStrategy)
|
||||||
if _, exists := byKey[key]; exists {
|
if _, exists := byKey[key]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1928,12 +2032,20 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
initialBalance := runtimeConfig.InitialBasePool
|
||||||
|
reserveFloor := runtimeConfig.BasePoolReserve
|
||||||
|
if configStrategy == domain.StrategyDynamicV3 {
|
||||||
|
// V3 规则即使来自迁移前旧数据,也只能展示独立零余额冷启动;配置里的历史 initial_pool_coins 不再形成资金。
|
||||||
|
initialBalance = 0
|
||||||
|
reserveFloor = 0
|
||||||
|
}
|
||||||
byKey[key] = domain.PoolBalance{
|
byKey[key] = domain.PoolBalance{
|
||||||
AppCode: appcode.Normalize(config.AppCode),
|
AppCode: appcode.Normalize(config.AppCode),
|
||||||
PoolID: runtimeConfig.PoolID,
|
PoolID: runtimeConfig.PoolID,
|
||||||
Balance: runtimeConfig.InitialBasePool,
|
StrategyVersion: configStrategy,
|
||||||
ReserveFloor: runtimeConfig.BasePoolReserve,
|
Balance: initialBalance,
|
||||||
AvailableBalance: luckyAvailableBalance(runtimeConfig.InitialBasePool, runtimeConfig.BasePoolReserve),
|
ReserveFloor: reserveFloor,
|
||||||
|
AvailableBalance: luckyAvailableBalance(initialBalance, reserveFloor),
|
||||||
Materialized: false,
|
Materialized: false,
|
||||||
UpdatedAtMS: config.CreatedAtMS,
|
UpdatedAtMS: config.CreatedAtMS,
|
||||||
}
|
}
|
||||||
@ -1941,14 +2053,22 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
|
|||||||
|
|
||||||
// 没有任何配置的新应用仍然需要在 ops-center 看到 default 池。这里用当前默认规则推导初始水位,但不写库。
|
// 没有任何配置的新应用仍然需要在 ops-center 看到 default 池。这里用当前默认规则推导初始水位,但不写库。
|
||||||
if appCode != "" && (poolID == "" || poolID == domain.DefaultPoolID) {
|
if appCode != "" && (poolID == "" || poolID == domain.DefaultPoolID) {
|
||||||
key := luckyPoolBalanceKey(appCode, domain.DefaultPoolID)
|
defaultStrategy := strategyVersion
|
||||||
|
if defaultStrategy == "" {
|
||||||
|
defaultStrategy = domain.StrategyDynamicV3
|
||||||
|
}
|
||||||
|
key := luckyPoolBalanceKey(appCode, domain.DefaultPoolID, defaultStrategy)
|
||||||
if _, exists := byKey[key]; !exists {
|
if _, exists := byKey[key]; !exists {
|
||||||
|
var defaultBalance, defaultReserve int64
|
||||||
|
if defaultStrategy == domain.StrategyFixedV2 {
|
||||||
const defaultReferencePrice = int64(100)
|
const defaultReferencePrice = int64(100)
|
||||||
defaultBalance := defaultReferencePrice * (950 + 500 + 2_375)
|
defaultBalance = defaultReferencePrice * (950 + 500 + 2_375)
|
||||||
defaultReserve := defaultReferencePrice * (100 + 10 + 100)
|
defaultReserve = defaultReferencePrice * (100 + 10 + 100)
|
||||||
|
}
|
||||||
byKey[key] = domain.PoolBalance{
|
byKey[key] = domain.PoolBalance{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
PoolID: domain.DefaultPoolID,
|
PoolID: domain.DefaultPoolID,
|
||||||
|
StrategyVersion: defaultStrategy,
|
||||||
Balance: defaultBalance,
|
Balance: defaultBalance,
|
||||||
ReserveFloor: defaultReserve,
|
ReserveFloor: defaultReserve,
|
||||||
AvailableBalance: luckyAvailableBalance(defaultBalance, defaultReserve),
|
AvailableBalance: luckyAvailableBalance(defaultBalance, defaultReserve),
|
||||||
@ -1965,7 +2085,10 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
|
|||||||
if items[i].AppCode != items[j].AppCode {
|
if items[i].AppCode != items[j].AppCode {
|
||||||
return items[i].AppCode < items[j].AppCode
|
return items[i].AppCode < items[j].AppCode
|
||||||
}
|
}
|
||||||
|
if items[i].PoolID != items[j].PoolID {
|
||||||
return items[i].PoolID < items[j].PoolID
|
return items[i].PoolID < items[j].PoolID
|
||||||
|
}
|
||||||
|
return items[i].StrategyVersion < items[j].StrategyVersion
|
||||||
})
|
})
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@ -2009,8 +2132,8 @@ func luckyDrawSummaryCanUseStats(query domain.DrawQuery) bool {
|
|||||||
return query.UserID <= 0 && strings.TrimSpace(query.RoomID) == "" && strings.TrimSpace(query.Status) == ""
|
return query.UserID <= 0 && strings.TrimSpace(query.RoomID) == "" && strings.TrimSpace(query.Status) == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func luckyPoolBalanceKey(appCode string, poolID string) string {
|
func luckyPoolBalanceKey(appCode string, poolID string, strategyVersion string) string {
|
||||||
return appcode.Normalize(appCode) + "\x00" + luckyPoolID(poolID)
|
return appcode.Normalize(appCode) + "\x00" + luckyPoolID(poolID) + "\x00" + strings.ToLower(strings.TrimSpace(strategyVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
func luckyAvailableBalance(balance int64, reserveFloor int64) int64 {
|
func luckyAvailableBalance(balance int64, reserveFloor int64) int64 {
|
||||||
@ -2166,7 +2289,7 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
|
|||||||
if referencePrice <= 0 {
|
if referencePrice <= 0 {
|
||||||
return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift reference price must be positive")
|
return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift reference price must be positive")
|
||||||
}
|
}
|
||||||
// v2 用金币流水定义结算窗口,旧运行窗口仍以抽数推进;这里按参考价格折算成“等价抽数”,实际每抽会在 withCurrentWager 中继续按真实 coin_spent 累计。
|
// fixed_v2 历史窗口仍按参考价格折算成“等价抽数”;dynamic_v3 另行读取 SettlementWindowWager,按真实 coin_spent 滚动。
|
||||||
windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice)
|
windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice)
|
||||||
if windowDraws <= 0 {
|
if windowDraws <= 0 {
|
||||||
windowDraws = 1
|
windowDraws = 1
|
||||||
@ -2183,10 +2306,14 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
|
|||||||
PoolID: poolID,
|
PoolID: poolID,
|
||||||
Enabled: ruleConfig.Enabled,
|
Enabled: ruleConfig.Enabled,
|
||||||
RuleVersion: ruleConfig.RuleVersion,
|
RuleVersion: ruleConfig.RuleVersion,
|
||||||
|
StrategyVersion: ruleConfig.StrategyVersion,
|
||||||
GiftPrice: referencePrice,
|
GiftPrice: referencePrice,
|
||||||
TargetRTPPPM: ruleConfig.TargetRTPPPM,
|
TargetRTPPPM: ruleConfig.TargetRTPPPM,
|
||||||
PoolRatePPM: ruleConfig.PoolRatePPM,
|
PoolRatePPM: ruleConfig.PoolRatePPM,
|
||||||
|
ProfitRatePPM: ruleConfig.ProfitRatePPM,
|
||||||
|
AnchorRatePPM: ruleConfig.AnchorRatePPM,
|
||||||
ControlBandPPM: ruleConfig.ControlBandPPM,
|
ControlBandPPM: ruleConfig.ControlBandPPM,
|
||||||
|
SettlementWindowWager: ruleConfig.SettlementWindowWager,
|
||||||
GlobalWindowDraws: windowDraws,
|
GlobalWindowDraws: windowDraws,
|
||||||
GiftWindowDraws: windowDraws,
|
GiftWindowDraws: windowDraws,
|
||||||
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
|
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
|
||||||
@ -2195,11 +2322,41 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
|
|||||||
HighWaterPoolMultiple: 2,
|
HighWaterPoolMultiple: 2,
|
||||||
InitialBasePool: referencePrice * (950 + 500 + 2_375),
|
InitialBasePool: referencePrice * (950 + 500 + 2_375),
|
||||||
BasePoolReserve: referencePrice * (100 + 10 + 100),
|
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,
|
Tiers: tiers,
|
||||||
|
Stages: ruleConfig.Stages,
|
||||||
UpdatedByAdminID: ruleConfig.CreatedByAdminID,
|
UpdatedByAdminID: ruleConfig.CreatedByAdminID,
|
||||||
CreatedAtMS: ruleConfig.CreatedAtMS,
|
CreatedAtMS: ruleConfig.CreatedAtMS,
|
||||||
UpdatedAtMS: ruleConfig.CreatedAtMS,
|
UpdatedAtMS: ruleConfig.CreatedAtMS,
|
||||||
}
|
}
|
||||||
|
if config.StrategyVersion == "" {
|
||||||
|
config.StrategyVersion = domain.StrategyFixedV2
|
||||||
|
}
|
||||||
|
if config.StrategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
// V3 只能由人工 credit 形成启动资金;旧配置残留的 initial_pool_coins 既不能注入账本,也不能进入策略冷启动状态。
|
||||||
|
config.InitialPoolCoins = 0
|
||||||
|
config.InitialBasePool = 0
|
||||||
|
config.BasePoolReserve = 0
|
||||||
|
}
|
||||||
config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice)
|
config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice)
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
@ -2256,11 +2413,11 @@ func luckyCeilDiv(value, divisor int64) int64 {
|
|||||||
func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
|
func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
|
||||||
var state luckyUserState
|
var state luckyUserState
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak
|
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
|
||||||
FROM lucky_user_states
|
FROM lucky_user_states
|
||||||
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
|
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
|
||||||
appCode, userID, giftID,
|
appCode, userID, giftID,
|
||||||
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak)
|
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return luckyUserState{}, nil
|
return luckyUserState{}, nil
|
||||||
}
|
}
|
||||||
@ -2268,29 +2425,27 @@ func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (luckyUserState, error) {
|
func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (luckyUserState, error) {
|
||||||
var state luckyUserState
|
// 先用幂等 INSERT 建行再 SELECT FOR UPDATE;两个首次抽奖并发时,唯一键会让第二个事务等待,
|
||||||
err := tx.QueryRowContext(ctx, `
|
// 而不是两边都先读到 no rows 后由其中一个以 duplicate key 失败。
|
||||||
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak
|
|
||||||
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)
|
|
||||||
if err == nil {
|
|
||||||
return state, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return luckyUserState{}, err
|
|
||||||
}
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO lucky_user_states (
|
INSERT INTO lucky_user_states (
|
||||||
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, created_at_ms, updated_at_ms
|
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
|
||||||
) VALUES (?, ?, ?, 0, 0, 0, 0, ?, ?)`,
|
pending_jackpot_tokens, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
||||||
appCode, userID, giftID, nowMS, nowMS,
|
appCode, userID, giftID, nowMS, nowMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return luckyUserState{}, err
|
return luckyUserState{}, err
|
||||||
}
|
}
|
||||||
return luckyUserState{}, nil
|
var state luckyUserState
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_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)
|
||||||
|
return state, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
|
func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
|
||||||
@ -2317,6 +2472,35 @@ func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appC
|
|||||||
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, windowDraws, targetPPM, nowMS)
|
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, windowDraws, targetPPM, nowMS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if windowWager <= 0 {
|
||||||
|
return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift settlement window wager must be positive")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if window.Status == "open" && window.WagerCoins < windowWager {
|
||||||
|
return window, nil
|
||||||
|
}
|
||||||
|
if window.Status == "open" {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_rtp_windows
|
||||||
|
SET status = 'closed', updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) {
|
func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) {
|
||||||
lockSQL := ""
|
lockSQL := ""
|
||||||
if forUpdate {
|
if forUpdate {
|
||||||
@ -2343,51 +2527,53 @@ 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) {
|
func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
|
||||||
window := luckyRTPWindow{
|
// 同一 scope 的首次请求可能并发创建同一 window;幂等 INSERT 先以唯一键串行化,
|
||||||
ScopeType: scopeType,
|
// 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
|
||||||
ScopeID: scopeID,
|
|
||||||
WindowIndex: index,
|
|
||||||
TargetRTPPPM: targetPPM,
|
|
||||||
ControlDraws: windowDraws,
|
|
||||||
CarryPPM: carry,
|
|
||||||
Status: "open",
|
|
||||||
}
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO lucky_rtp_windows (
|
INSERT INTO lucky_rtp_windows (
|
||||||
app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
|
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
|
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', ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)
|
||||||
appCode, scopeType, scopeID, index, targetPPM, windowDraws, window.CarryPPM, nowMS, nowMS,
|
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
||||||
|
appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, nowMS, nowMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return luckyRTPWindow{}, err
|
return luckyRTPWindow{}, err
|
||||||
}
|
}
|
||||||
return window, nil
|
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
|
||||||
|
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)
|
||||||
|
return window, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
|
func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
|
||||||
row := tx.QueryRowContext(ctx, `
|
// 初始注资只能由唯一键胜出的事务写一次;第二个首次请求等待后读取真实余额,不能重复 seed。
|
||||||
SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_pools (
|
||||||
|
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
|
profit_total, anchor_income_total, initial_seed_total, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
||||||
|
appCode, scopeType, scopeID, initialBalance, reserveFloor, initialBalance, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return luckyPool{}, err
|
||||||
|
}
|
||||||
|
var pool luckyPool
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
|
manual_credit_total, manual_debit_total
|
||||||
FROM lucky_pools
|
FROM lucky_pools
|
||||||
WHERE app_code = ? AND scope_type = ? AND scope_id = ?
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ?
|
||||||
FOR UPDATE`,
|
FOR UPDATE`,
|
||||||
appCode, scopeType, scopeID,
|
appCode, scopeType, scopeID,
|
||||||
)
|
).Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut,
|
||||||
var pool luckyPool
|
&pool.ManualCreditTotal, &pool.ManualDebitTotal)
|
||||||
if err := row.Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut); err == nil {
|
return pool, err
|
||||||
return pool, nil
|
|
||||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return luckyPool{}, err
|
|
||||||
}
|
|
||||||
pool = luckyPool{ScopeType: scopeType, ScopeID: scopeID, Balance: initialBalance, ReserveFloor: reserveFloor}
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO lucky_pools (
|
|
||||||
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, created_at_ms, updated_at_ms
|
|
||||||
) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)`,
|
|
||||||
appCode, scopeType, scopeID, initialBalance, reserveFloor, nowMS, nowMS,
|
|
||||||
); err != nil {
|
|
||||||
return luckyPool{}, err
|
|
||||||
}
|
|
||||||
return pool, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) creditLuckyBasePool(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, pool *luckyPool, nowMS int64) error {
|
func (r *Repository) creditLuckyBasePool(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, pool *luckyPool, nowMS int64) error {
|
||||||
|
|||||||
@ -3,7 +3,9 @@ package mysql
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
@ -16,7 +18,7 @@ type luckyRuleQueryer interface {
|
|||||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLuckyGiftRuleConfig 读取指定奖池最新 v2 规则版本;旧抽奖运行表不会参与后台配置读取。
|
// GetLuckyGiftRuleConfig 读取指定奖池最新不可变规则版本;旧抽奖运行表不会参与后台配置读取。
|
||||||
func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) {
|
func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
@ -24,7 +26,7 @@ func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string)
|
|||||||
return r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), luckyPoolID(poolID), false)
|
return r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), luckyPoolID(poolID), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;后续 v2 抽奖引擎按版本读取快照。
|
// PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;运行引擎按 strategy_version 和 rule_version 读取完整快照。
|
||||||
func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
|
func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
@ -38,6 +40,22 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
|||||||
appCode := appcode.FromContext(ctx)
|
appCode := appcode.FromContext(ctx)
|
||||||
config.AppCode = appCode
|
config.AppCode = appCode
|
||||||
config.PoolID = luckyPoolID(config.PoolID)
|
config.PoolID = luckyPoolID(config.PoolID)
|
||||||
|
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
||||||
|
if config.StrategyVersion == "" {
|
||||||
|
// 升级前的发布请求没有 strategy_version;存储边界再次兜底为 fixed_v2,避免绕过 service 的调用写成动态规则。
|
||||||
|
config.StrategyVersion = domain.StrategyFixedV2
|
||||||
|
}
|
||||||
|
if config.StrategyVersion == domain.StrategyDynamicV3 && config.InitialPoolCoins != 0 {
|
||||||
|
// 仓储边界再次守住零冷启动,即使内部调用绕过 service 校验,也不能发布一个会暗示自动注资的 V3 快照。
|
||||||
|
return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero")
|
||||||
|
}
|
||||||
|
if config.JackpotMultiplierPPMs == nil {
|
||||||
|
config.JackpotMultiplierPPMs = []int64{}
|
||||||
|
}
|
||||||
|
jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs)
|
||||||
|
if err != nil {
|
||||||
|
return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err)
|
||||||
|
}
|
||||||
config.RuleVersion = 1
|
config.RuleVersion = 1
|
||||||
config.CreatedAtMS = nowMS
|
config.CreatedAtMS = nowMS
|
||||||
if config.EffectiveFromMS <= 0 {
|
if config.EffectiveFromMS <= 0 {
|
||||||
@ -61,15 +79,33 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
|||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO lucky_gift_rule_versions (
|
INSERT INTO lucky_gift_rule_versions (
|
||||||
app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
|
app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
|
||||||
|
profit_rate_ppm, anchor_rate_ppm,
|
||||||
settlement_window_wager, control_band_ppm, gift_price_reference,
|
settlement_window_wager, control_band_ppm, gift_price_reference,
|
||||||
novice_max_equivalent_draws, normal_max_equivalent_draws,
|
novice_max_equivalent_draws, normal_max_equivalent_draws,
|
||||||
effective_from_ms, created_by_admin_id, created_at_ms
|
effective_from_ms, created_by_admin_id, created_at_ms,
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
initial_pool_coins, loss_streak_guarantee,
|
||||||
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.TargetRTPPPM, config.PoolRatePPM,
|
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,
|
||||||
|
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||||
|
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
|
||||||
|
) 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,
|
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
|
||||||
config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws,
|
config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws,
|
||||||
config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS,
|
config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS,
|
||||||
|
config.InitialPoolCoins, config.LossStreakGuarantee,
|
||||||
|
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
|
||||||
|
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
|
||||||
|
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON,
|
||||||
|
config.JackpotGlobalRTPMaxPPM, config.JackpotUserDayRTPMaxPPM, config.JackpotUser72hRTPMaxPPM,
|
||||||
|
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
|
||||||
|
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
|
||||||
|
config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return domain.RuleConfig{}, err
|
return domain.RuleConfig{}, err
|
||||||
}
|
}
|
||||||
@ -77,23 +113,40 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
|||||||
for _, tier := range stage.Tiers {
|
for _, tier := range stage.Tiers {
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO lucky_gift_stage_tiers (
|
INSERT INTO lucky_gift_stage_tiers (
|
||||||
app_code, pool_id, rule_version, stage, tier_id, multiplier_ppm, base_weight_ppm,
|
app_code, pool_id, rule_version, stage, min_recharge_7d_coins, min_recharge_30d_coins,
|
||||||
|
tier_id, multiplier_ppm, base_weight_ppm,
|
||||||
high_water_only, enabled
|
high_water_only, enabled
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?)`,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM,
|
config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, stage.MinRecharge7DCoins, stage.MinRecharge30DCoins,
|
||||||
|
tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM,
|
||||||
tier.HighWaterOnly, tier.Enabled,
|
tier.HighWaterOnly, tier.Enabled,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return domain.RuleConfig{}, err
|
return domain.RuleConfig{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if config.StrategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
// 发布首个 V3 版本只物化一口 0 余额账本;后续 V3 版本命中唯一键后必须保持余额、累计和时间不变。
|
||||||
|
// 这也保证 fixed_v2 -> dynamic_v3 切换不会继承旧 scope_type=pool 的历史资金。
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_pools (
|
||||||
|
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
|
manual_credit_total, manual_debit_total, profit_total, anchor_income_total, initial_seed_total,
|
||||||
|
created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE scope_id = scope_id`,
|
||||||
|
config.AppCode, luckyDynamicPoolScopeType, config.PoolID, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.RuleConfig{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return domain.RuleConfig{}, err
|
return domain.RuleConfig{}, err
|
||||||
}
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListLuckyGiftRuleConfigs 返回每个奖池的最新 v2 规则版本,用于后台奖池列表。
|
// ListLuckyGiftRuleConfigs 返回每个奖池的最新规则版本,用于后台奖池列表。
|
||||||
func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) {
|
func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
@ -105,10 +158,19 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
|
|||||||
args = append(args, appcode.Normalize(appCode))
|
args = append(args, appcode.Normalize(appCode))
|
||||||
}
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.target_rtp_ppm, v.pool_rate_ppm,
|
SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.strategy_version, v.target_rtp_ppm, v.pool_rate_ppm,
|
||||||
|
v.profit_rate_ppm, v.anchor_rate_ppm,
|
||||||
v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference,
|
v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference,
|
||||||
v.novice_max_equivalent_draws, v.normal_max_equivalent_draws,
|
v.novice_max_equivalent_draws, v.normal_max_equivalent_draws,
|
||||||
v.effective_from_ms, v.created_by_admin_id, v.created_at_ms
|
v.effective_from_ms, v.created_by_admin_id, v.created_at_ms,
|
||||||
|
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.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
|
||||||
FROM lucky_gift_rule_versions v
|
FROM lucky_gift_rule_versions v
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT app_code, pool_id, MAX(rule_version) AS rule_version
|
SELECT app_code, pool_id, MAX(rule_version) AS rule_version
|
||||||
@ -148,10 +210,19 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
|
|||||||
lockSQL = " FOR UPDATE"
|
lockSQL = " FOR UPDATE"
|
||||||
}
|
}
|
||||||
row := q.QueryRowContext(ctx, `
|
row := q.QueryRowContext(ctx, `
|
||||||
SELECT app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
|
SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
|
||||||
|
profit_rate_ppm, anchor_rate_ppm,
|
||||||
settlement_window_wager, control_band_ppm, gift_price_reference,
|
settlement_window_wager, control_band_ppm, gift_price_reference,
|
||||||
novice_max_equivalent_draws, normal_max_equivalent_draws,
|
novice_max_equivalent_draws, normal_max_equivalent_draws,
|
||||||
effective_from_ms, created_by_admin_id, created_at_ms
|
effective_from_ms, created_by_admin_id, created_at_ms,
|
||||||
|
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,
|
||||||
|
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||||
|
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
|
||||||
FROM lucky_gift_rule_versions
|
FROM lucky_gift_rule_versions
|
||||||
WHERE app_code = ? AND pool_id = ?
|
WHERE app_code = ? AND pool_id = ?
|
||||||
ORDER BY rule_version DESC
|
ORDER BY rule_version DESC
|
||||||
@ -175,7 +246,8 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
|
|||||||
|
|
||||||
func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64) ([]domain.RuleStage, error) {
|
func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64) ([]domain.RuleStage, error) {
|
||||||
rows, err := q.QueryContext(ctx, `
|
rows, err := q.QueryContext(ctx, `
|
||||||
SELECT stage, tier_id, multiplier_ppm, base_weight_ppm, high_water_only, enabled
|
SELECT stage, min_recharge_7d_coins, min_recharge_30d_coins,
|
||||||
|
tier_id, multiplier_ppm, base_weight_ppm, high_water_only, enabled
|
||||||
FROM lucky_gift_stage_tiers
|
FROM lucky_gift_stage_tiers
|
||||||
WHERE app_code = ? AND pool_id = ? AND rule_version = ?
|
WHERE app_code = ? AND pool_id = ? AND rule_version = ?
|
||||||
ORDER BY FIELD(stage, 'novice', 'normal', 'advanced'), multiplier_ppm ASC, tier_id ASC`,
|
ORDER BY FIELD(stage, 'novice', 'normal', 'advanced'), multiplier_ppm ASC, tier_id ASC`,
|
||||||
@ -189,14 +261,31 @@ func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQuer
|
|||||||
stages := make([]domain.RuleStage, 0, 3)
|
stages := make([]domain.RuleStage, 0, 3)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var tier domain.RuleTier
|
var tier domain.RuleTier
|
||||||
if err := rows.Scan(&tier.Stage, &tier.TierID, &tier.MultiplierPPM, &tier.BaseWeightPPM, &tier.HighWaterOnly, &tier.Enabled); err != nil {
|
var minRecharge7DCoins, minRecharge30DCoins int64
|
||||||
|
if err := rows.Scan(
|
||||||
|
&tier.Stage,
|
||||||
|
&minRecharge7DCoins,
|
||||||
|
&minRecharge30DCoins,
|
||||||
|
&tier.TierID,
|
||||||
|
&tier.MultiplierPPM,
|
||||||
|
&tier.BaseWeightPPM,
|
||||||
|
&tier.HighWaterOnly,
|
||||||
|
&tier.Enabled,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
index, exists := stageIndex[tier.Stage]
|
index, exists := stageIndex[tier.Stage]
|
||||||
if !exists {
|
if !exists {
|
||||||
index = len(stages)
|
index = len(stages)
|
||||||
stageIndex[tier.Stage] = index
|
stageIndex[tier.Stage] = index
|
||||||
stages = append(stages, domain.RuleStage{Stage: tier.Stage})
|
stages = append(stages, domain.RuleStage{
|
||||||
|
Stage: tier.Stage,
|
||||||
|
MinRecharge7DCoins: minRecharge7DCoins,
|
||||||
|
MinRecharge30DCoins: minRecharge30DCoins,
|
||||||
|
})
|
||||||
|
} else if stages[index].MinRecharge7DCoins != minRecharge7DCoins || stages[index].MinRecharge30DCoins != minRecharge30DCoins {
|
||||||
|
// 阈值按 stage 定义但为兼容旧表结构随 tier 冗余保存;同阶段不一致表示数据已被绕过发布入口篡改,不能静默选第一行。
|
||||||
|
return nil, fmt.Errorf("inconsistent recharge thresholds for lucky gift stage %s", tier.Stage)
|
||||||
}
|
}
|
||||||
stages[index].Tiers = append(stages[index].Tiers, tier)
|
stages[index].Tiers = append(stages[index].Tiers, tier)
|
||||||
}
|
}
|
||||||
@ -205,13 +294,17 @@ func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQuer
|
|||||||
|
|
||||||
func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
|
func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
|
||||||
var config domain.RuleConfig
|
var config domain.RuleConfig
|
||||||
|
var jackpotMultiplierPPMsJSON []byte
|
||||||
if err := scanner.Scan(
|
if err := scanner.Scan(
|
||||||
&config.AppCode,
|
&config.AppCode,
|
||||||
&config.PoolID,
|
&config.PoolID,
|
||||||
&config.RuleVersion,
|
&config.RuleVersion,
|
||||||
&config.Enabled,
|
&config.Enabled,
|
||||||
|
&config.StrategyVersion,
|
||||||
&config.TargetRTPPPM,
|
&config.TargetRTPPPM,
|
||||||
&config.PoolRatePPM,
|
&config.PoolRatePPM,
|
||||||
|
&config.ProfitRatePPM,
|
||||||
|
&config.AnchorRatePPM,
|
||||||
&config.SettlementWindowWager,
|
&config.SettlementWindowWager,
|
||||||
&config.ControlBandPPM,
|
&config.ControlBandPPM,
|
||||||
&config.GiftPriceReference,
|
&config.GiftPriceReference,
|
||||||
@ -220,8 +313,40 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
|
|||||||
&config.EffectiveFromMS,
|
&config.EffectiveFromMS,
|
||||||
&config.CreatedByAdminID,
|
&config.CreatedByAdminID,
|
||||||
&config.CreatedAtMS,
|
&config.CreatedAtMS,
|
||||||
|
&config.InitialPoolCoins,
|
||||||
|
&config.LossStreakGuarantee,
|
||||||
|
&config.LowWatermarkCoins,
|
||||||
|
&config.LowWaterNonzeroFactorPPM,
|
||||||
|
&config.HighWatermarkCoins,
|
||||||
|
&config.HighWaterNonzeroFactorPPM,
|
||||||
|
&config.RechargeBoostWindowMS,
|
||||||
|
&config.RechargeBoostFactorPPM,
|
||||||
|
&jackpotMultiplierPPMsJSON,
|
||||||
|
&config.JackpotGlobalRTPMaxPPM,
|
||||||
|
&config.JackpotUserDayRTPMaxPPM,
|
||||||
|
&config.JackpotUser72hRTPMaxPPM,
|
||||||
|
&config.JackpotSpendThresholdCoins,
|
||||||
|
&config.MaxJackpotHitsPerUserDay,
|
||||||
|
&config.MaxSinglePayout,
|
||||||
|
&config.UserHourlyPayoutCap,
|
||||||
|
&config.UserDailyPayoutCap,
|
||||||
|
&config.DeviceDailyPayoutCap,
|
||||||
|
&config.RoomHourlyPayoutCap,
|
||||||
|
&config.AnchorDailyPayoutCap,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return domain.RuleConfig{}, err
|
return domain.RuleConfig{}, err
|
||||||
}
|
}
|
||||||
|
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
||||||
|
if config.StrategyVersion == "" {
|
||||||
|
config.StrategyVersion = domain.StrategyFixedV2
|
||||||
|
}
|
||||||
|
if len(jackpotMultiplierPPMsJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(jackpotMultiplierPPMsJSON, &config.JackpotMultiplierPPMs); err != nil {
|
||||||
|
return domain.RuleConfig{}, fmt.Errorf("decode lucky gift jackpot multipliers: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.JackpotMultiplierPPMs == nil {
|
||||||
|
config.JackpotMultiplierPPMs = []int64{}
|
||||||
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,9 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) {
|
|||||||
if published.RuleVersion != 1 {
|
if published.RuleVersion != 1 {
|
||||||
t.Fatalf("first publish rule_version = %d, want 1", published.RuleVersion)
|
t.Fatalf("first publish rule_version = %d, want 1", published.RuleVersion)
|
||||||
}
|
}
|
||||||
|
if published.StrategyVersion != domain.StrategyFixedV2 {
|
||||||
|
t.Fatalf("legacy request strategy = %q, want fixed_v2", published.StrategyVersion)
|
||||||
|
}
|
||||||
|
|
||||||
// 版本必须只增不改:同奖池再次发布要拿到 v2,且两版规则和奖档都能按最新版本读回。
|
// 版本必须只增不改:同奖池再次发布要拿到 v2,且两版规则和奖档都能按最新版本读回。
|
||||||
again, err := repo.PublishLuckyGiftRuleConfig(ctx, config, 1_700_000_100_000)
|
again, err := repo.PublishLuckyGiftRuleConfig(ctx, config, 1_700_000_100_000)
|
||||||
@ -69,4 +72,184 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) {
|
|||||||
if len(latest.Stages) != 1 || len(latest.Stages[0].Tiers) != 1 || latest.Stages[0].Tiers[0].TierID != "novice_1x" {
|
if len(latest.Stages) != 1 || len(latest.Stages[0].Tiers) != 1 || latest.Stages[0].Tiers[0].TierID != "novice_1x" {
|
||||||
t.Fatalf("latest stages = %+v, want single novice tier novice_1x", latest.Stages)
|
t.Fatalf("latest stages = %+v, want single novice tier novice_1x", latest.Stages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dynamic_v3 的 JSON 倍率、阶段充值阈值和金额风控必须随不可变版本完整往返;否则服务重启后会静默退回默认策略。
|
||||||
|
dynamic := config
|
||||||
|
dynamic.StrategyVersion = domain.StrategyDynamicV3
|
||||||
|
dynamic.ProfitRatePPM = 10_000
|
||||||
|
dynamic.AnchorRatePPM = 10_000
|
||||||
|
dynamic.InitialPoolCoins = 0
|
||||||
|
dynamic.LossStreakGuarantee = 5
|
||||||
|
dynamic.LowWatermarkCoins = 10_000_000
|
||||||
|
dynamic.LowWaterNonzeroFactorPPM = 700_000
|
||||||
|
dynamic.HighWatermarkCoins = 20_000_000
|
||||||
|
dynamic.HighWaterNonzeroFactorPPM = 1_300_000
|
||||||
|
dynamic.RechargeBoostWindowMS = 300_000
|
||||||
|
dynamic.RechargeBoostFactorPPM = 1_100_000
|
||||||
|
dynamic.JackpotMultiplierPPMs = []int64{200_000_000, 500_000_000, 1_000_000_000}
|
||||||
|
dynamic.JackpotGlobalRTPMaxPPM = 980_000
|
||||||
|
dynamic.JackpotUserDayRTPMaxPPM = 960_000
|
||||||
|
dynamic.JackpotUser72hRTPMaxPPM = 960_000
|
||||||
|
dynamic.JackpotSpendThresholdCoins = 5_000
|
||||||
|
dynamic.MaxJackpotHitsPerUserDay = 5
|
||||||
|
dynamic.MaxSinglePayout = 1_000_000
|
||||||
|
dynamic.UserHourlyPayoutCap = 2_000_000
|
||||||
|
dynamic.UserDailyPayoutCap = 3_000_000
|
||||||
|
dynamic.DeviceDailyPayoutCap = 4_000_000
|
||||||
|
dynamic.RoomHourlyPayoutCap = 5_000_000
|
||||||
|
dynamic.AnchorDailyPayoutCap = 6_000_000
|
||||||
|
dynamic.Stages[0].MinRecharge7DCoins = 1
|
||||||
|
dynamic.Stages[0].MinRecharge30DCoins = 2
|
||||||
|
|
||||||
|
third, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamic, 1_700_000_200_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish dynamic rule version: %v", err)
|
||||||
|
}
|
||||||
|
if third.RuleVersion != 3 {
|
||||||
|
t.Fatalf("dynamic rule_version = %d, want 3", third.RuleVersion)
|
||||||
|
}
|
||||||
|
loaded, exists, err := repo.GetLuckyGiftRuleConfig(ctx, "lucky")
|
||||||
|
if err != nil || !exists {
|
||||||
|
t.Fatalf("get dynamic rule: exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
if loaded.StrategyVersion != domain.StrategyDynamicV3 || loaded.InitialPoolCoins != 0 || loaded.AnchorDailyPayoutCap != 6_000_000 {
|
||||||
|
t.Fatalf("dynamic scalar fields were not persisted: %+v", loaded)
|
||||||
|
}
|
||||||
|
if len(loaded.JackpotMultiplierPPMs) != 3 || loaded.JackpotMultiplierPPMs[2] != 1_000_000_000 {
|
||||||
|
t.Fatalf("jackpot multipliers = %v, want [200x 500x 1000x] ppm", loaded.JackpotMultiplierPPMs)
|
||||||
|
}
|
||||||
|
if len(loaded.Stages) != 1 || loaded.Stages[0].MinRecharge7DCoins != 1 || loaded.Stages[0].MinRecharge30DCoins != 2 {
|
||||||
|
t.Fatalf("dynamic stage thresholds were not persisted: %+v", loaded.Stages)
|
||||||
|
}
|
||||||
|
var dynamicBalance int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT balance FROM lucky_pools
|
||||||
|
WHERE app_code='lalu' AND scope_type='pool_dynamic_v3' AND scope_id='lucky'`).Scan(&dynamicBalance); err != nil || dynamicBalance != 0 {
|
||||||
|
t.Fatalf("first dynamic publish balance=%d err=%v, want materialized zero ledger", dynamicBalance, err)
|
||||||
|
}
|
||||||
|
if _, err := schema.DB.Exec(`
|
||||||
|
UPDATE lucky_pools SET balance=777, manual_credit_total=777
|
||||||
|
WHERE app_code='lalu' AND scope_type='pool_dynamic_v3' AND scope_id='lucky'`); err != nil {
|
||||||
|
t.Fatalf("fund dynamic ledger before republish: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamic, 1_700_000_300_000); err != nil {
|
||||||
|
t.Fatalf("publish later dynamic version: %v", err)
|
||||||
|
}
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT balance FROM lucky_pools
|
||||||
|
WHERE app_code='lalu' AND scope_type='pool_dynamic_v3' AND scope_id='lucky'`).Scan(&dynamicBalance); err != nil || dynamicBalance != 777 {
|
||||||
|
t.Fatalf("later dynamic publish balance=%d err=%v, want existing 777 preserved", dynamicBalance, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) {
|
||||||
|
caller := mysqlschema.CallerFile(t, 1)
|
||||||
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||||
|
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||||
|
DatabasePrefix: "hy_lucky_gift",
|
||||||
|
})
|
||||||
|
migrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "004_dynamic_strategy.sql")
|
||||||
|
// initdb 已包含最终结构,004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。
|
||||||
|
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||||
|
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||||
|
|
||||||
|
for _, table := range []string{
|
||||||
|
"lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events",
|
||||||
|
"lucky_user_strategy_days", "lucky_gift_command_locks", "external_lucky_gift_draw_items", "external_lucky_gift_request_locks",
|
||||||
|
} {
|
||||||
|
var count int
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, table).Scan(&count); err != nil || count != 1 {
|
||||||
|
t.Fatalf("migration table %s: count=%d err=%v", table, count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, column := range []struct {
|
||||||
|
table string
|
||||||
|
name string
|
||||||
|
minLength int64
|
||||||
|
}{
|
||||||
|
{table: "lucky_user_states", name: "pending_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},
|
||||||
|
} {
|
||||||
|
var count, length int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*), COALESCE(MAX(CHARACTER_MAXIMUM_LENGTH), 0)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||||
|
column.table, column.name,
|
||||||
|
).Scan(&count, &length); err != nil || count != 1 || (column.minLength > 0 && length < column.minLength) {
|
||||||
|
t.Fatalf("migration column %s.%s count=%d length=%d err=%v", column.table, column.name, count, length, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 旧发布器只写 v2 原列;数据库默认值和扫描器必须共同把该行解释为 fixed_v2,而不是要求动态字段。
|
||||||
|
if _, err := schema.DB.Exec(`
|
||||||
|
INSERT INTO lucky_gift_rule_versions (
|
||||||
|
app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
|
||||||
|
settlement_window_wager, control_band_ppm, gift_price_reference,
|
||||||
|
novice_max_equivalent_draws, normal_max_equivalent_draws,
|
||||||
|
effective_from_ms, created_by_admin_id, created_at_ms
|
||||||
|
) VALUES ('legacy', 'lucky', 1, 1, 950000, 960000, 1000000, 10000, 100, 2000, 20000, 1, 7, 1)`); err != nil {
|
||||||
|
t.Fatalf("insert pre-dynamic rule row: %v", err)
|
||||||
|
}
|
||||||
|
repo := &Repository{db: schema.DB}
|
||||||
|
legacy, exists, err := repo.GetLuckyGiftRuleConfig(appcode.WithContext(context.Background(), "legacy"), "lucky")
|
||||||
|
if err != nil || !exists {
|
||||||
|
t.Fatalf("read pre-dynamic rule row: exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
if legacy.StrategyVersion != domain.StrategyFixedV2 || len(legacy.JackpotMultiplierPPMs) != 0 {
|
||||||
|
t.Fatalf("pre-dynamic row decoded as strategy=%q jackpot=%v, want fixed_v2 and empty jackpot", legacy.StrategyVersion, legacy.JackpotMultiplierPPMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyPoolBalanceMigrationMaterializesLatestDynamicV3AtZeroWithoutReset(t *testing.T) {
|
||||||
|
caller := mysqlschema.CallerFile(t, 1)
|
||||||
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||||
|
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||||
|
DatabasePrefix: "hy_lucky_gift",
|
||||||
|
})
|
||||||
|
repo := &Repository{db: schema.DB}
|
||||||
|
ctx := appcode.WithContext(context.Background(), "aslan")
|
||||||
|
rule := dynamicMySQLTestRule("migration-v3")
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, 1_700_001_000_000); err != nil {
|
||||||
|
t.Fatalf("publish dynamic config: %v", err)
|
||||||
|
}
|
||||||
|
// 模拟现网已经发布的旧 V3 快照:配置里仍残留 1,000,000,但 005 必须忽略它并创建零余额新 scope。
|
||||||
|
if _, err := schema.DB.Exec(`UPDATE lucky_gift_rule_versions SET initial_pool_coins=1000000 WHERE app_code='aslan' AND pool_id='migration-v3'`); err != nil {
|
||||||
|
t.Fatalf("seed legacy dynamic initial_pool_coins: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := schema.DB.Exec(`
|
||||||
|
INSERT INTO lucky_pools (
|
||||||
|
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
|
profit_total, anchor_income_total, initial_seed_total, created_at_ms, updated_at_ms
|
||||||
|
) VALUES ('aslan', 'pool', 'migration-v3', 888, 88, 1000, 112, 0, 0, 888, 1, 2)`); err != nil {
|
||||||
|
t.Fatalf("seed legacy fixed scope: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := schema.DB.Exec(`DELETE FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='migration-v3'`); err != nil {
|
||||||
|
t.Fatalf("simulate pre-005 database: %v", err)
|
||||||
|
}
|
||||||
|
migrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "005_lucky_pool_balance.sql")
|
||||||
|
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||||
|
var balance int64
|
||||||
|
if err := schema.DB.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='migration-v3'`).Scan(&balance); err != nil || balance != 0 {
|
||||||
|
t.Fatalf("005 materialized balance=%d err=%v, want 0", balance, err)
|
||||||
|
}
|
||||||
|
var legacyBalance int64
|
||||||
|
if err := schema.DB.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool' AND scope_id='migration-v3'`).Scan(&legacyBalance); err != nil || legacyBalance != 888 {
|
||||||
|
t.Fatalf("005 changed legacy fixed scope balance=%d err=%v, want 888", legacyBalance, err)
|
||||||
|
}
|
||||||
|
if _, err := schema.DB.Exec(`UPDATE lucky_pools SET balance=321, manual_credit_total=321 WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='migration-v3'`); err != nil {
|
||||||
|
t.Fatalf("fund migrated pool: %v", err)
|
||||||
|
}
|
||||||
|
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||||
|
if err := schema.DB.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='migration-v3'`).Scan(&balance); err != nil || balance != 321 {
|
||||||
|
t.Fatalf("rerun 005 balance=%d err=%v, want existing 321 preserved", balance, err)
|
||||||
|
}
|
||||||
|
if err := schema.DB.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool' AND scope_id='migration-v3'`).Scan(&legacyBalance); err != nil || legacyBalance != 888 {
|
||||||
|
t.Fatalf("rerun 005 changed legacy fixed scope balance=%d err=%v, want 888", legacyBalance, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,261 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
func luckyPoolScopeForStrategy(strategyVersion string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(strategyVersion)) {
|
||||||
|
case domain.StrategyFixedV2:
|
||||||
|
return luckyBasePoolScopeType, nil
|
||||||
|
case domain.StrategyDynamicV3:
|
||||||
|
return luckyDynamicPoolScopeType, nil
|
||||||
|
default:
|
||||||
|
return "", xerr.New(xerr.InvalidArgument, "unsupported lucky gift strategy version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func luckyStrategyForPoolScope(scopeType string) (string, bool) {
|
||||||
|
switch scopeType {
|
||||||
|
case luckyBasePoolScopeType:
|
||||||
|
return domain.StrategyFixedV2, true
|
||||||
|
case luckyDynamicPoolScopeType:
|
||||||
|
return domain.StrategyDynamicV3, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustLuckyGiftPoolBalance 是人工资金调整的唯一持久化入口。同一个事务先锁 adjustment_id,
|
||||||
|
// 再锁 app+strategy+pool 资金行;这样并发重试只能重放首次结果,不能在两个事务中重复增减余额。
|
||||||
|
func (r *Repository) AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
appCode := appcode.Normalize(command.AppCode)
|
||||||
|
poolID := luckyPoolID(command.PoolID)
|
||||||
|
scopeType, err := luckyPoolScopeForStrategy(command.StrategyVersion)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
var tokenEntropy [16]byte
|
||||||
|
if _, err := rand.Read(tokenEntropy[:]); err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.Unavailable, "cannot generate lucky gift pool adjustment insert token")
|
||||||
|
}
|
||||||
|
insertToken := hex.EncodeToString(tokenEntropy[:])
|
||||||
|
// 唯一键的 ON DUPLICATE 分支只做 no-op;随后比较库内首次随机令牌来判断本事务是否首写,
|
||||||
|
// 因而既不依赖 clientFoundRows,也避免多个 duplicate INSERT 错误事务互相持锁形成死锁。
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_pool_adjustments (
|
||||||
|
app_code, adjustment_id, pool_id, strategy_version, scope_type, direction,
|
||||||
|
amount_coins, reason, operator_admin_id, request_id, insert_token,
|
||||||
|
balance_before, balance_after, pool_snapshot_json, status, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, 'pending', ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE adjustment_id = adjustment_id`,
|
||||||
|
appCode, command.AdjustmentID, poolID, command.StrategyVersion, scopeType, command.Direction,
|
||||||
|
command.AmountCoins, command.Reason, command.OperatorAdminID, command.RequestID, insertToken, nowMS, nowMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored domain.PoolAdjustment
|
||||||
|
var storedScopeType string
|
||||||
|
var storedRequestID string
|
||||||
|
var storedInsertToken string
|
||||||
|
var storedStatus string
|
||||||
|
var storedSnapshot sql.NullString
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT adjustment_id, app_code, pool_id, strategy_version, scope_type, direction,
|
||||||
|
amount_coins, reason, operator_admin_id, request_id, insert_token,
|
||||||
|
balance_before, balance_after, pool_snapshot_json, status, created_at_ms
|
||||||
|
FROM lucky_pool_adjustments
|
||||||
|
WHERE app_code = ? AND adjustment_id = ?
|
||||||
|
FOR UPDATE`, appCode, command.AdjustmentID,
|
||||||
|
).Scan(
|
||||||
|
&stored.AdjustmentID, &stored.AppCode, &stored.PoolID, &stored.StrategyVersion, &storedScopeType, &stored.Direction,
|
||||||
|
&stored.AmountCoins, &stored.Reason, &stored.OperatorAdminID, &storedRequestID, &storedInsertToken,
|
||||||
|
&stored.BalanceBefore, &stored.BalanceAfter, &storedSnapshot, &storedStatus, &stored.CreatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
inserted := storedInsertToken == insertToken
|
||||||
|
|
||||||
|
// request_id 允许随网络重试变化;其余字段共同定义资金命令,任何不一致都属于幂等冲突。
|
||||||
|
if stored.PoolID != poolID || stored.StrategyVersion != command.StrategyVersion || storedScopeType != scopeType ||
|
||||||
|
stored.Direction != command.Direction || stored.AmountCoins != command.AmountCoins || stored.Reason != command.Reason ||
|
||||||
|
stored.OperatorAdminID != command.OperatorAdminID {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.IdempotencyConflict, "lucky gift pool adjustment payload does not match the existing adjustment_id")
|
||||||
|
}
|
||||||
|
if !inserted {
|
||||||
|
if storedStatus != "applied" || !storedSnapshot.Valid {
|
||||||
|
// 正常事务不会留下 pending 行;若人工修复或异常导入造成该状态,宁可阻断也不能猜测是否已经动账。
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool adjustment is not in an applied state")
|
||||||
|
}
|
||||||
|
var snapshot domain.PoolBalance
|
||||||
|
if err := json.Unmarshal([]byte(storedSnapshot.String), &snapshot); err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool adjustment snapshot is invalid")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
return domain.PoolAdjustmentResult{Adjustment: stored, Pool: snapshot, IdempotentReplay: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := r.getOrCreateAdjustmentPool(ctx, tx, appCode, poolID, command.StrategyVersion, scopeType, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
stored.BalanceBefore = pool.Balance
|
||||||
|
switch command.Direction {
|
||||||
|
case domain.PoolAdjustmentIn:
|
||||||
|
if pool.Balance > math.MaxInt64-command.AmountCoins || pool.ManualCreditTotal > math.MaxInt64-command.AmountCoins {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool credit would overflow the ledger")
|
||||||
|
}
|
||||||
|
pool.Balance += command.AmountCoins
|
||||||
|
pool.ManualCreditTotal += command.AmountCoins
|
||||||
|
case domain.PoolAdjustmentOut:
|
||||||
|
if pool.ManualDebitTotal > math.MaxInt64-command.AmountCoins || command.AmountCoins > pool.Balance || pool.Balance-command.AmountCoins < pool.ReserveFloor {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool debit would cross the reserve floor")
|
||||||
|
}
|
||||||
|
pool.Balance -= command.AmountCoins
|
||||||
|
pool.ManualDebitTotal += command.AmountCoins
|
||||||
|
default:
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment direction is invalid")
|
||||||
|
}
|
||||||
|
stored.BalanceAfter = pool.Balance
|
||||||
|
|
||||||
|
// 手工资金只更新 balance 和独立累计列;业务 total_in/total_out 仍只表达送礼入池与实际返奖。
|
||||||
|
updateResult, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_pools
|
||||||
|
SET balance = ?, manual_credit_total = ?, manual_debit_total = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
|
||||||
|
pool.Balance, pool.ManualCreditTotal, pool.ManualDebitTotal, nowMS, appCode, scopeType, poolID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
if rows, err := updateResult.RowsAffected(); err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
} else if rows != 1 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool changed during adjustment")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := domain.PoolBalance{
|
||||||
|
AppCode: appCode,
|
||||||
|
PoolID: poolID,
|
||||||
|
StrategyVersion: command.StrategyVersion,
|
||||||
|
Balance: pool.Balance,
|
||||||
|
ReserveFloor: pool.ReserveFloor,
|
||||||
|
AvailableBalance: luckyAvailableBalance(pool.Balance, pool.ReserveFloor),
|
||||||
|
TotalIn: pool.TotalIn,
|
||||||
|
TotalOut: pool.TotalOut,
|
||||||
|
ManualCreditTotal: pool.ManualCreditTotal,
|
||||||
|
ManualDebitTotal: pool.ManualDebitTotal,
|
||||||
|
Materialized: true,
|
||||||
|
UpdatedAtMS: nowMS,
|
||||||
|
}
|
||||||
|
snapshotJSON, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
finalizeResult, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_pool_adjustments
|
||||||
|
SET balance_before = ?, balance_after = ?, pool_snapshot_json = ?, status = 'applied', updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND adjustment_id = ? AND status = 'pending'`,
|
||||||
|
stored.BalanceBefore, stored.BalanceAfter, snapshotJSON, nowMS, appCode, command.AdjustmentID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
if rows, err := finalizeResult.RowsAffected(); err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
} else if rows != 1 {
|
||||||
|
return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool adjustment could not be finalized")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PoolAdjustmentResult{}, err
|
||||||
|
}
|
||||||
|
return domain.PoolAdjustmentResult{Adjustment: stored, Pool: snapshot}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getOrCreateAdjustmentPool(ctx context.Context, tx *sql.Tx, appCode, poolID, strategyVersion, scopeType string, nowMS int64) (luckyPool, error) {
|
||||||
|
// 人工资金不能凭任意 pool_id 创建孤儿账本;指定策略至少要有一个已发布不可变版本。
|
||||||
|
var configured int
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT 1
|
||||||
|
FROM lucky_gift_rule_versions
|
||||||
|
WHERE app_code = ? AND pool_id = ?
|
||||||
|
AND COALESCE(NULLIF(strategy_version, ''), 'fixed_v2') = ?
|
||||||
|
LIMIT 1`, appCode, poolID, strategyVersion,
|
||||||
|
).Scan(&configured)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return luckyPool{}, xerr.New(xerr.NotFound, "lucky gift pool strategy is not configured")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return luckyPool{}, err
|
||||||
|
}
|
||||||
|
if strategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
return r.getOrCreateLuckyPool(ctx, tx, appCode, scopeType, poolID, 0, 0, nowMS)
|
||||||
|
}
|
||||||
|
var existing luckyPool
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
|
manual_credit_total, manual_debit_total
|
||||||
|
FROM lucky_pools
|
||||||
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ?
|
||||||
|
FOR UPDATE`, appCode, scopeType, poolID,
|
||||||
|
).Scan(
|
||||||
|
&existing.ScopeType, &existing.ScopeID, &existing.Balance, &existing.ReserveFloor, &existing.TotalIn, &existing.TotalOut,
|
||||||
|
&existing.ManualCreditTotal, &existing.ManualDebitTotal,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return luckyPool{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixed_v2 首次人工调整仍需保持旧算法的启动水位。只读取最新 fixed_v2 规则的参考价格,
|
||||||
|
// 不使用当前最新 dynamic_v3 配置,也不把旧固定账本从 0 重新解释。
|
||||||
|
var referencePrice int64
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT gift_price_reference
|
||||||
|
FROM lucky_gift_rule_versions
|
||||||
|
WHERE app_code = ? AND pool_id = ?
|
||||||
|
AND COALESCE(NULLIF(strategy_version, ''), 'fixed_v2') = 'fixed_v2'
|
||||||
|
ORDER BY rule_version DESC
|
||||||
|
LIMIT 1`, appCode, poolID,
|
||||||
|
).Scan(&referencePrice)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return luckyPool{}, err
|
||||||
|
}
|
||||||
|
if errors.Is(err, sql.ErrNoRows) || referencePrice <= 0 {
|
||||||
|
return luckyPool{}, xerr.New(xerr.NotFound, "fixed_v2 lucky gift pool is not configured")
|
||||||
|
}
|
||||||
|
if referencePrice > math.MaxInt64/(950+500+2_375) {
|
||||||
|
return luckyPool{}, xerr.New(xerr.Conflict, "fixed_v2 lucky gift pool initial balance would overflow")
|
||||||
|
}
|
||||||
|
return r.getOrCreateLuckyPool(
|
||||||
|
ctx, tx, appCode, scopeType, poolID,
|
||||||
|
referencePrice*(950+500+2_375), referencePrice*(100+10+100), nowMS,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hyapp/internal/testutil/mysqlschema"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdjustLuckyGiftPoolBalanceSeparatesStrategiesAndReplaysIdempotently(t *testing.T) {
|
||||||
|
caller := mysqlschema.CallerFile(t, 1)
|
||||||
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||||
|
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||||
|
DatabasePrefix: "hy_lucky_gift",
|
||||||
|
})
|
||||||
|
repo := &Repository{db: schema.DB}
|
||||||
|
ctx := appcode.WithContext(context.Background(), "aslan")
|
||||||
|
|
||||||
|
fixed := dynamicMySQLTestRule("shared-pool")
|
||||||
|
fixed.StrategyVersion = domain.StrategyFixedV2
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, fixed, 1_700_010_000_000); err != nil {
|
||||||
|
t.Fatalf("publish fixed config: %v", err)
|
||||||
|
}
|
||||||
|
dynamic := dynamicMySQLTestRule("shared-pool")
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamic, 1_700_010_000_100); err != nil {
|
||||||
|
t.Fatalf("publish dynamic config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedResult, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{
|
||||||
|
AppCode: "aslan", PoolID: "shared-pool", StrategyVersion: domain.StrategyFixedV2,
|
||||||
|
AdjustmentID: "fixed-credit-1", Direction: domain.PoolAdjustmentIn, AmountCoins: 10,
|
||||||
|
Reason: "legacy reserve", OperatorAdminID: 7,
|
||||||
|
}, 1_700_010_001_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credit fixed pool: %v", err)
|
||||||
|
}
|
||||||
|
if fixedResult.Pool.Balance != 3_835 || fixedResult.Pool.ReserveFloor != 210 {
|
||||||
|
t.Fatalf("fixed pool snapshot=%+v, want old 3825/210 seed plus 10", fixedResult.Pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
credit := domain.PoolAdjustmentCommand{
|
||||||
|
AppCode: "aslan", PoolID: "shared-pool", StrategyVersion: domain.StrategyDynamicV3,
|
||||||
|
AdjustmentID: "dynamic-credit-1", Direction: domain.PoolAdjustmentIn, AmountCoins: 100,
|
||||||
|
Reason: "approved launch budget", OperatorAdminID: 7, RequestID: "request-1",
|
||||||
|
}
|
||||||
|
first, err := repo.AdjustLuckyGiftPoolBalance(ctx, credit, 1_700_010_002_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credit dynamic pool: %v", err)
|
||||||
|
}
|
||||||
|
if first.Adjustment.BalanceBefore != 0 || first.Adjustment.BalanceAfter != 100 || first.Pool.ManualCreditTotal != 100 || first.Pool.TotalIn != 0 {
|
||||||
|
t.Fatalf("dynamic credit result=%+v, want 0->100 isolated manual credit", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayCommand := credit
|
||||||
|
replayCommand.RequestID = "request-retry"
|
||||||
|
replay, err := repo.AdjustLuckyGiftPoolBalance(ctx, replayCommand, 1_700_010_003_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replay dynamic credit: %v", err)
|
||||||
|
}
|
||||||
|
if !replay.IdempotentReplay || replay.Adjustment.BalanceBefore != 0 || replay.Pool.Balance != 100 {
|
||||||
|
t.Fatalf("replay result=%+v, want original snapshot", replay)
|
||||||
|
}
|
||||||
|
mismatch := credit
|
||||||
|
mismatch.AmountCoins = 101
|
||||||
|
if _, err := repo.AdjustLuckyGiftPoolBalance(ctx, mismatch, 1_700_010_004_000); !xerr.IsCode(err, xerr.IdempotencyConflict) {
|
||||||
|
t.Fatalf("same ID with different payload error=%v, want idempotency conflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
debit := domain.PoolAdjustmentCommand{
|
||||||
|
AppCode: "aslan", PoolID: "shared-pool", StrategyVersion: domain.StrategyDynamicV3,
|
||||||
|
AdjustmentID: "dynamic-debit-1", Direction: domain.PoolAdjustmentOut, AmountCoins: 30,
|
||||||
|
Reason: "return unused budget", OperatorAdminID: 7,
|
||||||
|
}
|
||||||
|
debitResult, err := repo.AdjustLuckyGiftPoolBalance(ctx, debit, 1_700_010_005_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("debit dynamic pool: %v", err)
|
||||||
|
}
|
||||||
|
if debitResult.Pool.Balance != 70 || debitResult.Pool.ManualDebitTotal != 30 || debitResult.Pool.TotalOut != 0 {
|
||||||
|
t.Fatalf("dynamic debit result=%+v, want manual 30 without business total_out", debitResult)
|
||||||
|
}
|
||||||
|
overDebit := debit
|
||||||
|
overDebit.AdjustmentID = "dynamic-debit-over-reserve"
|
||||||
|
overDebit.AmountCoins = 71
|
||||||
|
if _, err := repo.AdjustLuckyGiftPoolBalance(ctx, overDebit, 1_700_010_006_000); !xerr.IsCode(err, xerr.Conflict) {
|
||||||
|
t.Fatalf("over-reserve debit error=%v, want conflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 八个并发重试共享一个 adjustment_id;唯一键和 FOR UPDATE 必须只让一次 10 金币入账。
|
||||||
|
concurrent := credit
|
||||||
|
concurrent.AdjustmentID = "dynamic-credit-concurrent"
|
||||||
|
concurrent.AmountCoins = 10
|
||||||
|
concurrent.Reason = "single concurrent grant"
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errorsByWorker := make([]error, 8)
|
||||||
|
for index := range errorsByWorker {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(worker int) {
|
||||||
|
defer wg.Done()
|
||||||
|
command := concurrent
|
||||||
|
command.RequestID = "concurrent-request"
|
||||||
|
_, errorsByWorker[worker] = repo.AdjustLuckyGiftPoolBalance(ctx, command, 1_700_010_007_000)
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for index, workerErr := range errorsByWorker {
|
||||||
|
if workerErr != nil {
|
||||||
|
t.Fatalf("concurrent worker %d: %v", index, workerErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dynamicBalance, dynamicManualCredit, dynamicManualDebit, dynamicTotalIn, dynamicTotalOut int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT balance, manual_credit_total, manual_debit_total, total_in, total_out
|
||||||
|
FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='shared-pool'`,
|
||||||
|
).Scan(&dynamicBalance, &dynamicManualCredit, &dynamicManualDebit, &dynamicTotalIn, &dynamicTotalOut); err != nil {
|
||||||
|
t.Fatalf("query dynamic pool: %v", err)
|
||||||
|
}
|
||||||
|
if dynamicBalance != 80 || dynamicManualCredit != 110 || dynamicManualDebit != 30 || dynamicTotalIn != 0 || dynamicTotalOut != 0 {
|
||||||
|
t.Fatalf("dynamic ledger balance/credit/debit/in/out=%d/%d/%d/%d/%d, want 80/110/30/0/0", dynamicBalance, dynamicManualCredit, dynamicManualDebit, dynamicTotalIn, dynamicTotalOut)
|
||||||
|
}
|
||||||
|
var fixedBalance int64
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool' AND scope_id='shared-pool'`,
|
||||||
|
).Scan(&fixedBalance); err != nil || fixedBalance != 3_835 {
|
||||||
|
t.Fatalf("fixed ledger balance=%d err=%v, want isolated 3835", fixedBalance, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := repo.ListLuckyGiftPoolBalances(ctx, domain.PoolBalanceQuery{AppCode: "aslan", PoolID: "shared-pool"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list both strategy ledgers: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 || items[0].StrategyVersion == items[1].StrategyVersion {
|
||||||
|
t.Fatalf("strategy-separated pool list=%+v, want fixed_v2 and dynamic_v3", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdjustLuckyGiftPoolBalanceRejectsUnconfiguredStrategy(t *testing.T) {
|
||||||
|
caller := mysqlschema.CallerFile(t, 1)
|
||||||
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||||
|
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||||
|
DatabasePrefix: "hy_lucky_gift",
|
||||||
|
})
|
||||||
|
repo := &Repository{db: schema.DB}
|
||||||
|
ctx := appcode.WithContext(context.Background(), "aslan")
|
||||||
|
_, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{
|
||||||
|
AppCode: "aslan", PoolID: "not-configured", StrategyVersion: domain.StrategyDynamicV3,
|
||||||
|
AdjustmentID: "orphan-credit", Direction: domain.PoolAdjustmentIn, AmountCoins: 1,
|
||||||
|
Reason: "must fail", OperatorAdminID: 7,
|
||||||
|
}, 1_700_020_000_000)
|
||||||
|
if !xerr.IsCode(err, xerr.NotFound) {
|
||||||
|
t.Fatalf("unconfigured strategy error=%v, want not found", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if scanErr := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_pools WHERE app_code='aslan' AND scope_id='not-configured'`).Scan(&count); scanErr != nil || count != 0 {
|
||||||
|
t.Fatalf("orphan pool count=%d err=%v, want 0", count, scanErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,6 +40,7 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *luckygiftv1.C
|
|||||||
GiftId: result.GiftID,
|
GiftId: result.GiftID,
|
||||||
GiftPrice: result.GiftPrice,
|
GiftPrice: result.GiftPrice,
|
||||||
RuleVersion: result.RuleVersion,
|
RuleVersion: result.RuleVersion,
|
||||||
|
StrategyVersion: result.StrategyVersion,
|
||||||
TargetRtpPpm: result.TargetRTPPPM,
|
TargetRtpPpm: result.TargetRTPPPM,
|
||||||
ExperiencePool: result.ExperiencePool,
|
ExperiencePool: result.ExperiencePool,
|
||||||
}, nil
|
}, nil
|
||||||
@ -88,6 +89,7 @@ func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luck
|
|||||||
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
|
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
|
||||||
AppCode: req.GetAppCode(),
|
AppCode: req.GetAppCode(),
|
||||||
ExternalUserID: req.GetExternalUserId(),
|
ExternalUserID: req.GetExternalUserId(),
|
||||||
|
DeviceID: req.GetDeviceId(),
|
||||||
RequestID: req.GetRequestId(),
|
RequestID: req.GetRequestId(),
|
||||||
GiftCount: req.GetGiftCount(),
|
GiftCount: req.GetGiftCount(),
|
||||||
UnitAmount: req.GetUnitAmount(),
|
UnitAmount: req.GetUnitAmount(),
|
||||||
@ -122,6 +124,12 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
|
|||||||
SenderAvatar: meta.GetSenderAvatar(),
|
SenderAvatar: meta.GetSenderAvatar(),
|
||||||
SenderDisplayUserID: meta.GetSenderDisplayUserId(),
|
SenderDisplayUserID: meta.GetSenderDisplayUserId(),
|
||||||
SenderPrettyDisplayUserID: meta.GetSenderPrettyDisplayUserId(),
|
SenderPrettyDisplayUserID: meta.GetSenderPrettyDisplayUserId(),
|
||||||
|
// 充值分层、短时加权与主播风控只消费 wallet-service 已落账的事实,
|
||||||
|
// lucky-gift-service 不跨 owner 数据库反查并重新解释同一笔交易。
|
||||||
|
Recharge7DCoins: meta.GetRecharge_7DCoins(),
|
||||||
|
Recharge30DCoins: meta.GetRecharge_30DCoins(),
|
||||||
|
LastRechargedAtMS: meta.GetLastRechargedAtMs(),
|
||||||
|
GiftIncomeCoins: meta.GetGiftIncomeCoins(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,6 +230,7 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re
|
|||||||
pools, err := s.svc.ListPoolBalances(ctx, domain.PoolBalanceQuery{
|
pools, err := s.svc.ListPoolBalances(ctx, domain.PoolBalanceQuery{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
PoolID: req.GetPoolId(),
|
PoolID: req.GetPoolId(),
|
||||||
|
StrategyVersion: req.GetStrategyVersion(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.ToGRPCError(err)
|
return nil, xerr.ToGRPCError(err)
|
||||||
@ -233,6 +242,32 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AdminLuckyGiftServer) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||||
|
if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" {
|
||||||
|
// 资金 RPC 必须 fail-closed;缺失 meta 不能像普通兼容查询一样静默落到默认 lalu 租户。
|
||||||
|
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment app_code is required"))
|
||||||
|
}
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
result, err := s.svc.AdjustPoolBalance(ctx, domain.PoolAdjustmentCommand{
|
||||||
|
PoolID: req.GetPoolId(),
|
||||||
|
StrategyVersion: req.GetStrategyVersion(),
|
||||||
|
AdjustmentID: req.GetAdjustmentId(),
|
||||||
|
Direction: req.GetDirection(),
|
||||||
|
AmountCoins: req.GetAmountCoins(),
|
||||||
|
Reason: req.GetReason(),
|
||||||
|
OperatorAdminID: req.GetOperatorAdminId(),
|
||||||
|
RequestID: req.GetMeta().GetRequestId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &luckygiftv1.AdjustLuckyGiftPoolBalanceResponse{
|
||||||
|
Adjustment: luckyPoolAdjustmentToProto(result.Adjustment),
|
||||||
|
Pool: luckyPoolBalanceToProto(result.Pool),
|
||||||
|
IdempotentReplay: result.IdempotentReplay,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.RuleConfig {
|
func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.RuleConfig {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
return domain.RuleConfig{}
|
return domain.RuleConfig{}
|
||||||
@ -252,6 +287,8 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
|
|||||||
}
|
}
|
||||||
stages = append(stages, domain.RuleStage{
|
stages = append(stages, domain.RuleStage{
|
||||||
Stage: stage.GetStage(),
|
Stage: stage.GetStage(),
|
||||||
|
MinRecharge7DCoins: stage.GetMinRecharge_7DCoins(),
|
||||||
|
MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -270,6 +307,29 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
|
|||||||
EffectiveFromMS: config.GetEffectiveFromMs(),
|
EffectiveFromMS: config.GetEffectiveFromMs(),
|
||||||
CreatedByAdminID: config.GetCreatedByAdminId(),
|
CreatedByAdminID: config.GetCreatedByAdminId(),
|
||||||
CreatedAtMS: config.GetCreatedAtMs(),
|
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,
|
Stages: stages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -290,6 +350,8 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
|
|||||||
}
|
}
|
||||||
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
|
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
|
||||||
Stage: stage.Stage,
|
Stage: stage.Stage,
|
||||||
|
MinRecharge_7DCoins: stage.MinRecharge7DCoins,
|
||||||
|
MinRecharge_30DCoins: stage.MinRecharge30DCoins,
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -308,6 +370,29 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
|
|||||||
EffectiveFromMs: config.EffectiveFromMS,
|
EffectiveFromMs: config.EffectiveFromMS,
|
||||||
CreatedByAdminId: config.CreatedByAdminID,
|
CreatedByAdminId: config.CreatedByAdminID,
|
||||||
CreatedAtMs: config.CreatedAtMS,
|
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,
|
Stages: stages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -384,12 +469,31 @@ func luckyPoolBalanceToProto(pool domain.PoolBalance) *luckygiftv1.LuckyGiftPool
|
|||||||
return &luckygiftv1.LuckyGiftPoolBalance{
|
return &luckygiftv1.LuckyGiftPoolBalance{
|
||||||
AppCode: pool.AppCode,
|
AppCode: pool.AppCode,
|
||||||
PoolId: pool.PoolID,
|
PoolId: pool.PoolID,
|
||||||
|
StrategyVersion: pool.StrategyVersion,
|
||||||
Balance: pool.Balance,
|
Balance: pool.Balance,
|
||||||
ReserveFloor: pool.ReserveFloor,
|
ReserveFloor: pool.ReserveFloor,
|
||||||
AvailableBalance: pool.AvailableBalance,
|
AvailableBalance: pool.AvailableBalance,
|
||||||
TotalIn: pool.TotalIn,
|
TotalIn: pool.TotalIn,
|
||||||
TotalOut: pool.TotalOut,
|
TotalOut: pool.TotalOut,
|
||||||
|
ManualCreditTotal: pool.ManualCreditTotal,
|
||||||
|
ManualDebitTotal: pool.ManualDebitTotal,
|
||||||
Materialized: pool.Materialized,
|
Materialized: pool.Materialized,
|
||||||
UpdatedAtMs: pool.UpdatedAtMS,
|
UpdatedAtMs: pool.UpdatedAtMS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func luckyPoolAdjustmentToProto(adjustment domain.PoolAdjustment) *luckygiftv1.LuckyGiftPoolAdjustment {
|
||||||
|
return &luckygiftv1.LuckyGiftPoolAdjustment{
|
||||||
|
AdjustmentId: adjustment.AdjustmentID,
|
||||||
|
AppCode: adjustment.AppCode,
|
||||||
|
PoolId: adjustment.PoolID,
|
||||||
|
StrategyVersion: adjustment.StrategyVersion,
|
||||||
|
Direction: adjustment.Direction,
|
||||||
|
AmountCoins: adjustment.AmountCoins,
|
||||||
|
Reason: adjustment.Reason,
|
||||||
|
OperatorAdminId: adjustment.OperatorAdminID,
|
||||||
|
BalanceBefore: adjustment.BalanceBefore,
|
||||||
|
BalanceAfter: adjustment.BalanceAfter,
|
||||||
|
CreatedAtMs: adjustment.CreatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,8 +5,24 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
service "hyapp/services/lucky-gift-service/internal/service/luckygift"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type poolAdjustmentGRPCRepository struct {
|
||||||
|
service.Repository
|
||||||
|
command domain.PoolAdjustmentCommand
|
||||||
|
result domain.PoolAdjustmentResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *poolAdjustmentGRPCRepository) AdjustLuckyGiftPoolBalance(_ context.Context, command domain.PoolAdjustmentCommand, _ int64) (domain.PoolAdjustmentResult, error) {
|
||||||
|
r.command = command
|
||||||
|
return r.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestBatchExecuteLuckyGiftDrawRejectsMixedAppCodes(t *testing.T) {
|
func TestBatchExecuteLuckyGiftDrawRejectsMixedAppCodes(t *testing.T) {
|
||||||
server := NewLuckyGiftServer(nil)
|
server := NewLuckyGiftServer(nil)
|
||||||
_, err := server.BatchExecuteLuckyGiftDraw(context.Background(), &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{
|
_, err := server.BatchExecuteLuckyGiftDraw(context.Background(), &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{
|
||||||
@ -19,3 +35,42 @@ func TestBatchExecuteLuckyGiftDrawRejectsMixedAppCodes(t *testing.T) {
|
|||||||
t.Fatal("expected mixed app_code batch to be rejected")
|
t.Fatal("expected mixed app_code batch to be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdjustLuckyGiftPoolBalanceMapsExplicitStrategyAndSnapshot(t *testing.T) {
|
||||||
|
repository := &poolAdjustmentGRPCRepository{result: domain.PoolAdjustmentResult{
|
||||||
|
Adjustment: domain.PoolAdjustment{
|
||||||
|
AdjustmentID: "adjustment-1", AppCode: "aslan", PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3,
|
||||||
|
Direction: domain.PoolAdjustmentIn, AmountCoins: 100, BalanceBefore: 0, BalanceAfter: 100,
|
||||||
|
},
|
||||||
|
Pool: domain.PoolBalance{
|
||||||
|
AppCode: "aslan", PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3,
|
||||||
|
Balance: 100, ManualCreditTotal: 100, Materialized: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
server := NewAdminLuckyGiftServer(service.New(repository))
|
||||||
|
resp, err := server.AdjustLuckyGiftPoolBalance(context.Background(), &luckygiftv1.AdjustLuckyGiftPoolBalanceRequest{
|
||||||
|
Meta: &luckygiftv1.RequestMeta{AppCode: "aslan", RequestId: "request-1"},
|
||||||
|
PoolId: "lucky", StrategyVersion: domain.StrategyDynamicV3, AdjustmentId: "adjustment-1",
|
||||||
|
Direction: domain.PoolAdjustmentIn, AmountCoins: 100, Reason: "launch", OperatorAdminId: 7,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adjust pool RPC: %v", err)
|
||||||
|
}
|
||||||
|
if repository.command.AppCode != "aslan" || repository.command.RequestID != "request-1" || repository.command.StrategyVersion != domain.StrategyDynamicV3 {
|
||||||
|
t.Fatalf("owner command=%+v", repository.command)
|
||||||
|
}
|
||||||
|
if resp.GetPool().GetStrategyVersion() != domain.StrategyDynamicV3 || resp.GetPool().GetManualCreditTotal() != 100 || resp.GetAdjustment().GetBalanceAfter() != 100 {
|
||||||
|
t.Fatalf("RPC response=%+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdjustLuckyGiftPoolBalanceRejectsMissingAppCodeBeforeOwnerCall(t *testing.T) {
|
||||||
|
server := NewAdminLuckyGiftServer(nil)
|
||||||
|
_, err := server.AdjustLuckyGiftPoolBalance(context.Background(), &luckygiftv1.AdjustLuckyGiftPoolBalanceRequest{
|
||||||
|
Meta: &luckygiftv1.RequestMeta{}, PoolId: "lucky", StrategyVersion: domain.StrategyDynamicV3,
|
||||||
|
AdjustmentId: "adjustment-1", Direction: domain.PoolAdjustmentIn, AmountCoins: 1, Reason: "test", OperatorAdminId: 7,
|
||||||
|
})
|
||||||
|
if status.Code(err) != codes.InvalidArgument {
|
||||||
|
t.Fatalf("missing app_code error=%v, want InvalidArgument", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -34,6 +34,9 @@ type Base struct {
|
|||||||
GatewayNodeID string `json:"gateway_node_id"`
|
GatewayNodeID string `json:"gateway_node_id"`
|
||||||
// SessionID 记录 gateway 看到的客户端会话,不等同于腾讯云 IM 连接。
|
// SessionID 记录 gateway 看到的客户端会话,不等同于腾讯云 IM 连接。
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
|
// DeviceID 是 user-service 绑定到 auth_session 并经 JWT 验签的设备 ID。
|
||||||
|
// 旧 command log 没有该字段时反序列化为空,fixed_v2 仍可重放,dynamic_v3 由抽奖 owner fail-close。
|
||||||
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
// SentAtMS 是客户端请求进入系统的时间,恢复回放时用于还原用户态时间字段。
|
// SentAtMS 是客户端请求进入系统的时间,恢复回放时用于还原用户态时间字段。
|
||||||
SentAtMS int64 `json:"sent_at_ms"`
|
SentAtMS int64 `json:"sent_at_ms"`
|
||||||
}
|
}
|
||||||
@ -439,6 +442,9 @@ type SendGift struct {
|
|||||||
GiftID string `json:"gift_id"`
|
GiftID string `json:"gift_id"`
|
||||||
// PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。
|
// PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。
|
||||||
PoolID string `json:"pool_id,omitempty"`
|
PoolID string `json:"pool_id,omitempty"`
|
||||||
|
// LuckyGiftStrategyVersion 是 lucky-gift Check 返回的 owner 规则快照;room saga 固化它只为
|
||||||
|
// 在滚动发布期间区分 fixed_v2 兼容与 dynamic_v3 必填 wallet 事实,不参与客户端幂等语义。
|
||||||
|
LuckyGiftStrategyVersion string `json:"lucky_gift_strategy_version,omitempty"`
|
||||||
// GiftCount 是礼物数量,必须为正数。
|
// GiftCount 是礼物数量,必须为正数。
|
||||||
GiftCount int32 `json:"gift_count"`
|
GiftCount int32 `json:"gift_count"`
|
||||||
// EntitlementID 是背包送礼要消耗的用户礼物权益 ID;普通金币送礼为空。
|
// EntitlementID 是背包送礼要消耗的用户礼物权益 ID;普通金币送礼为空。
|
||||||
@ -652,6 +658,9 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
|
|||||||
delete(values, "request_id")
|
delete(values, "request_id")
|
||||||
delete(values, "gateway_node_id")
|
delete(values, "gateway_node_id")
|
||||||
delete(values, "session_id")
|
delete(values, "session_id")
|
||||||
|
// device_id 是首次请求的可信风控快照,会在 gift operation/command log 中按原值恢复;
|
||||||
|
// 它不属于客户端业务载荷,token 旋转后的同 command_id 重试不应因此冲突。
|
||||||
|
delete(values, "device_id")
|
||||||
delete(values, "sent_at_ms")
|
delete(values, "sent_at_ms")
|
||||||
// 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。
|
// 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。
|
||||||
delete(values, "sender_display_profile")
|
delete(values, "sender_display_profile")
|
||||||
@ -685,6 +694,7 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
|
|||||||
delete(values, "gift_type_code")
|
delete(values, "gift_type_code")
|
||||||
delete(values, "host_period_diamond_added")
|
delete(values, "host_period_diamond_added")
|
||||||
delete(values, "host_period_cycle_key")
|
delete(values, "host_period_cycle_key")
|
||||||
|
delete(values, "lucky_gift_strategy_version")
|
||||||
delete(values, "rocket_touched")
|
delete(values, "rocket_touched")
|
||||||
delete(values, "rocket_added_fuel")
|
delete(values, "rocket_added_fuel")
|
||||||
delete(values, "rocket_effective_fuel")
|
delete(values, "rocket_effective_fuel")
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
|
func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
|
||||||
base := SendGift{
|
base := SendGift{
|
||||||
Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42},
|
Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42, DeviceID: "device-first"},
|
||||||
TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43,
|
TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43,
|
||||||
GiftID: "rose", GiftCount: 9, ComboSessionID: "combo-1", BatchSeq: 1,
|
GiftID: "rose", GiftCount: 9, ComboSessionID: "combo-1", BatchSeq: 1,
|
||||||
SenderRegionID: 1001, SenderCountryID: 840,
|
SenderRegionID: 1001, SenderCountryID: 840,
|
||||||
@ -19,6 +19,7 @@ func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
|
|||||||
retry.SenderCountryID = 356
|
retry.SenderCountryID = 356
|
||||||
retry.TargetHostScopes = []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: false}}
|
retry.TargetHostScopes = []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: false}}
|
||||||
retry.SenderDisplayProfile = GiftDisplayProfile{UserID: 42, Username: "after"}
|
retry.SenderDisplayProfile = GiftDisplayProfile{UserID: 42, Username: "after"}
|
||||||
|
retry.DeviceID = "device-after-token-rotation"
|
||||||
|
|
||||||
left, err := IdempotencyPayloadForCommand(base)
|
left, err := IdempotencyPayloadForCommand(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -33,6 +34,40 @@ func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendGiftCommandLogPreservesTrustedDeviceIDAndReadsLegacyPayload(t *testing.T) {
|
||||||
|
original := SendGift{
|
||||||
|
Base: Base{
|
||||||
|
AppCode: "lalu", CommandID: "cmd-device-recovery", Room: "room-1", ActorID: 42,
|
||||||
|
SessionID: "sess-not-a-device", DeviceID: "device-auth-session-1", SentAtMS: 1_000,
|
||||||
|
},
|
||||||
|
TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43,
|
||||||
|
GiftID: "rose", GiftCount: 1,
|
||||||
|
}
|
||||||
|
payload, err := Serialize(original)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("serialize send gift device snapshot: %v", err)
|
||||||
|
}
|
||||||
|
recovered, err := Deserialize(original.Type(), payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deserialize send gift device snapshot: %v", err)
|
||||||
|
}
|
||||||
|
got, ok := recovered.(*SendGift)
|
||||||
|
if !ok || got.DeviceID != "device-auth-session-1" || got.SessionID != "sess-not-a-device" {
|
||||||
|
t.Fatalf("recovered trusted device snapshot mismatch: %#v", recovered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动升级前的 command log 不包含 device_id;反序列化必须保持可恢复,
|
||||||
|
// 并留下空值让 fixed_v2 兼容、dynamic_v3 在 owner 处明确拒绝。
|
||||||
|
legacy, err := Deserialize(original.Type(), []byte(`{"app_code":"lalu","command_id":"cmd-legacy","actor_user_id":42,"room_id":"room-1","session_id":"sess-legacy","target_type":"user","target_user_id":43,"gift_id":"rose","gift_count":1}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deserialize legacy send gift: %v", err)
|
||||||
|
}
|
||||||
|
legacyGift := legacy.(*SendGift)
|
||||||
|
if legacyGift.DeviceID != "" || legacyGift.SessionID != "sess-legacy" {
|
||||||
|
t.Fatalf("legacy command must not derive device from session: %+v", legacyGift.Base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendGiftIdempotencyKeepsComboBatchIdentity(t *testing.T) {
|
func TestSendGiftIdempotencyKeepsComboBatchIdentity(t *testing.T) {
|
||||||
base := SendGift{Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, GiftID: "rose", GiftCount: 1, ComboSessionID: "combo-1", BatchSeq: 1}
|
base := SendGift{Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, GiftID: "rose", GiftCount: 1, ComboSessionID: "combo-1", BatchSeq: 1}
|
||||||
reused := base
|
reused := base
|
||||||
|
|||||||
@ -243,6 +243,15 @@ func (f *giftFlow) prepareRoomFacts() error {
|
|||||||
if checkResp == nil || !checkResp.GetEnabled() {
|
if checkResp == nil || !checkResp.GetEnabled() {
|
||||||
return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
|
return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
|
||||||
}
|
}
|
||||||
|
// 规则版本由 lucky-gift owner 判定并随 room saga 命令固化;恢复阶段不重新 Check,
|
||||||
|
// 防止后台切换策略后把一笔已扣费命令从 fixed_v2 兼容语义改成 dynamic_v3。
|
||||||
|
f.cmd.LuckyGiftStrategyVersion = normalizeLuckyGiftStrategyVersion(checkResp.GetStrategyVersion())
|
||||||
|
f.settledCommand.LuckyGiftStrategyVersion = f.cmd.LuckyGiftStrategyVersion
|
||||||
|
if f.cmd.LuckyGiftStrategyVersion == luckyGiftStrategyDynamicV3 && strings.TrimSpace(f.cmd.DeviceID) == "" {
|
||||||
|
// 新 dynamic_v3 请求在 wallet 扣费前拒绝旧 JWT,避免创建一笔因缺设备作用域而无法完成的扣费 saga。
|
||||||
|
// 已扣费恢复仍由 lucky-gift owner 的同一校验 fail-close,不得用 sid/command_id 补值。
|
||||||
|
return xerr.New(xerr.InvalidArgument, "device_id is required for dynamic lucky gift")
|
||||||
|
}
|
||||||
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
|
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
|
||||||
f.luckyEnabled = true
|
f.luckyEnabled = true
|
||||||
}
|
}
|
||||||
@ -289,7 +298,7 @@ func (f *giftFlow) debitAndSettle(now time.Time) error {
|
|||||||
f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode())
|
f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode())
|
||||||
}
|
}
|
||||||
if !f.cmd.SyntheticLuckyGift && f.luckyEnabled {
|
if !f.cmd.SyntheticLuckyGift && f.luckyEnabled {
|
||||||
f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings, now)
|
f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
|
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -15,28 +15,37 @@ import (
|
|||||||
"hyapp/services/room-service/internal/room/command"
|
"hyapp/services/room-service/internal/room/command"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) {
|
const (
|
||||||
|
luckyGiftStrategyFixedV2 = "fixed_v2"
|
||||||
|
luckyGiftStrategyDynamicV3 = "dynamic_v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
|
||||||
if len(targetBillings) == 0 {
|
if len(targetBillings) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if len(targetBillings) == 1 {
|
if len(targetBillings) == 1 {
|
||||||
result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0], now)
|
result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return []*roomv1.LuckyGiftDrawResult{result}, nil
|
return []*roomv1.LuckyGiftDrawResult{result}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings, now)
|
return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) (*roomv1.LuckyGiftDrawResult, error) {
|
func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*roomv1.LuckyGiftDrawResult, error) {
|
||||||
if targetBilling.Billing == nil {
|
if targetBilling.Billing == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
|
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
|
||||||
}
|
}
|
||||||
// 抽奖必须按每个收礼目标独立落事实;command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。
|
// 抽奖必须按每个收礼目标独立落事实;command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。
|
||||||
|
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{
|
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{
|
||||||
LuckyGift: luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now),
|
LuckyGift: luckyMeta,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -48,14 +57,18 @@ func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.Request
|
|||||||
return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil
|
return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) {
|
func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
|
||||||
req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))}
|
req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))}
|
||||||
for _, targetBilling := range targetBillings {
|
for _, targetBilling := range targetBillings {
|
||||||
if targetBilling.Billing == nil {
|
if targetBilling.Billing == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
|
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
|
||||||
}
|
}
|
||||||
// 多人送礼只跨服务调用一次;activity-service 在一个事务内按这个顺序推进锁和抽奖事实。
|
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
|
||||||
req.LuckyGifts = append(req.LuckyGifts, luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now))
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 多人送礼只跨服务调用一次;lucky-gift-service 在一个事务内按这个顺序推进锁和抽奖事实。
|
||||||
|
req.LuckyGifts = append(req.LuckyGifts, luckyMeta)
|
||||||
}
|
}
|
||||||
resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req)
|
resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -74,21 +87,29 @@ func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.Re
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) *luckygiftv1.LuckyGiftMeta {
|
func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*luckygiftv1.LuckyGiftMeta, error) {
|
||||||
|
paidAtMS := targetBilling.Billing.GetPaidAtMs()
|
||||||
|
if paidAtMS <= 0 && normalizeLuckyGiftStrategyVersion(cmd.LuckyGiftStrategyVersion) == luckyGiftStrategyDynamicV3 {
|
||||||
|
// dynamic_v3 所有时间窗口必须归属 wallet owner 的实际交易;缺字段通常表示 wallet 尚未滚动升级,
|
||||||
|
// 已扣费 saga 保持可恢复并等待 owner 回执补齐,绝不能拿 room mutate/recovery 时钟继续开奖。
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "wallet paid_at_ms is required for dynamic lucky gift")
|
||||||
|
}
|
||||||
return &luckygiftv1.LuckyGiftMeta{
|
return &luckygiftv1.LuckyGiftMeta{
|
||||||
Meta: luckyGiftMetaFromRoom(ctx, meta),
|
Meta: luckyGiftMetaFromRoom(ctx, meta),
|
||||||
CommandId: targetBilling.CommandID,
|
CommandId: targetBilling.CommandID,
|
||||||
UserId: cmd.ActorUserID(),
|
UserId: cmd.ActorUserID(),
|
||||||
TargetUserId: targetBilling.TargetUserID,
|
TargetUserId: targetBilling.TargetUserID,
|
||||||
// 目前没有独立设备 ID 字段,优先用 session_id;没有时退回目标子 command_id,保证每次抽奖 scope 不为空。
|
// DeviceID 已在首次请求进入 gift operation 时随 command.Base 持久化,恢复必须继续使用该值。
|
||||||
DeviceId: luckyGiftDeviceID(cmd, targetBilling.CommandID),
|
// 空值原样传给 owner:fixed_v2 兼容旧 JWT,dynamic_v3 拒绝;绝不用 session_id/command_id 伪造设备。
|
||||||
|
DeviceId: cmd.DeviceID,
|
||||||
RoomId: cmd.RoomID(),
|
RoomId: cmd.RoomID(),
|
||||||
AnchorId: luckyGiftAnchorID(roomMeta),
|
AnchorId: luckyGiftAnchorID(roomMeta),
|
||||||
GiftId: cmd.GiftID,
|
GiftId: cmd.GiftID,
|
||||||
GiftCount: cmd.GiftCount,
|
GiftCount: cmd.GiftCount,
|
||||||
CoinSpent: targetBilling.Billing.GetCoinSpent(),
|
CoinSpent: targetBilling.Billing.GetCoinSpent(),
|
||||||
// 房间链路统一使用 UTC epoch ms;不能把本地时区时间传给幸运礼物 RTP 窗口。
|
// PaidAtMs 只来自当前目标的 wallet transaction;batch 每个 target 也保留自己的 owner 回执,
|
||||||
PaidAtMs: now.UTC().UnixMilli(),
|
// 不使用聚合回执、Room Cell mutate 时钟或 gift_operation.created_at_ms。
|
||||||
|
PaidAtMs: paidAtMS,
|
||||||
PoolId: cmd.PoolID,
|
PoolId: cmd.PoolID,
|
||||||
VisibleRegionId: roomMeta.VisibleRegionID,
|
VisibleRegionId: roomMeta.VisibleRegionID,
|
||||||
CountryId: cmd.SenderCountryID,
|
CountryId: cmd.SenderCountryID,
|
||||||
@ -97,7 +118,21 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
|
|||||||
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
|
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
|
||||||
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
|
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
|
||||||
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
|
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
|
||||||
|
// 充值分层和短时加权只消费 wallet 扣费回执里固化的 owner 快照;room 不查询充值流水,也不按当前时间重新计算。
|
||||||
|
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
|
||||||
|
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
|
||||||
|
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
|
||||||
|
GiftIncomeCoins: targetBilling.Billing.GetGiftIncomeCoinAmount(),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeLuckyGiftStrategyVersion(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
// 旧 lucky-gift 版本没有返回 strategy_version;它发布时只有 fixed_v2,故仅该空值兼容成 fixed_v2。
|
||||||
|
return luckyGiftStrategyFixedV2
|
||||||
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
|
func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
|
||||||
@ -140,16 +175,6 @@ func luckyGiftMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *lucky
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func luckyGiftDeviceID(cmd command.SendGift, fallbackCommandID string) string {
|
|
||||||
if value := strings.TrimSpace(cmd.SessionID); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(fallbackCommandID); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return cmd.ID()
|
|
||||||
}
|
|
||||||
|
|
||||||
func luckyGiftAnchorID(roomMeta RoomMeta) string {
|
func luckyGiftAnchorID(roomMeta RoomMeta) string {
|
||||||
if roomMeta.OwnerUserID <= 0 {
|
if roomMeta.OwnerUserID <= 0 {
|
||||||
return roomMeta.RoomID
|
return roomMeta.RoomID
|
||||||
|
|||||||
@ -22,6 +22,8 @@ type concurrentV2Wallet struct {
|
|||||||
calls atomic.Int32
|
calls atomic.Int32
|
||||||
started chan struct{}
|
started chan struct{}
|
||||||
release chan struct{}
|
release chan struct{}
|
||||||
|
walletPaidAtMS int64
|
||||||
|
omitPaidAt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||||
@ -35,6 +37,10 @@ func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGif
|
|||||||
case <-w.release:
|
case <-w.release:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
paidAtMS := w.walletPaidAtMS
|
||||||
|
if paidAtMS <= 0 && !w.omitPaidAt {
|
||||||
|
paidAtMS = 1_700_000_000_000
|
||||||
|
}
|
||||||
return &walletv1.DebitGiftResponse{
|
return &walletv1.DebitGiftResponse{
|
||||||
BillingReceiptId: "receipt-v2-stable",
|
BillingReceiptId: "receipt-v2-stable",
|
||||||
TransactionId: "wallet-tx-v2-stable",
|
TransactionId: "wallet-tx-v2-stable",
|
||||||
@ -45,9 +51,38 @@ func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGif
|
|||||||
BalanceVersion: 17,
|
BalanceVersion: 17,
|
||||||
GiftIncomeBalanceAfter: 25,
|
GiftIncomeBalanceAfter: 25,
|
||||||
GiftTypeCode: "normal",
|
GiftTypeCode: "normal",
|
||||||
|
PaidAtMs: paidAtMS,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDynamicLuckyGiftRejectsMissingWalletPaidAtAfterDebit(t *testing.T) {
|
||||||
|
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
wallet := &concurrentV2Wallet{omitPaidAt: true}
|
||||||
|
lucky := &recoverableLuckyClient{}
|
||||||
|
svc := roomservice.New(roomservice.Config{
|
||||||
|
NodeID: "node-gift-v2-missing-paid-at", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1,
|
||||||
|
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
|
||||||
|
|
||||||
|
roomID := "room-gift-v2-missing-paid-at"
|
||||||
|
createRocketRoom(t, ctx, svc, roomID, 351, 9001)
|
||||||
|
joinRocketRoom(t, ctx, svc, roomID, 352)
|
||||||
|
request := &roomv1.SendGiftRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{CommandId: "cmd-v2-missing-paid-at", ActorUserId: 351, RoomId: roomID, AppCode: appcode.Default, DeviceId: "device-v2-missing-paid-at"},
|
||||||
|
TargetType: "user", TargetUserId: 352, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
|
||||||
|
}
|
||||||
|
if _, err := svc.SendGift(ctx, request); !xerr.IsCode(err, xerr.Unavailable) {
|
||||||
|
t.Fatalf("dynamic_v3 missing wallet paid_at_ms should fail closed, got %v", err)
|
||||||
|
}
|
||||||
|
if lucky.calls.Load() != 0 {
|
||||||
|
t.Fatalf("dynamic_v3 missing wallet paid_at_ms reached lucky random path %d times", lucky.calls.Load())
|
||||||
|
}
|
||||||
|
operation, exists, err := repository.GetGiftOperation(ctx, roomID, request.GetMeta().GetCommandId())
|
||||||
|
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
|
||||||
|
t.Fatalf("already debited saga must remain recoverable: exists=%v operation=%+v err=%v", exists, operation, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T) {
|
func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T) {
|
||||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
@ -120,17 +155,22 @@ func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T
|
|||||||
|
|
||||||
type recoverableLuckyClient struct {
|
type recoverableLuckyClient struct {
|
||||||
calls atomic.Int32
|
calls atomic.Int32
|
||||||
|
paidAtMS [2]atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
|
func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
|
||||||
return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool"}, nil
|
return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool", StrategyVersion: "dynamic_v3"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) {
|
func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) {
|
||||||
if c.calls.Add(1) == 1 {
|
meta := req.GetLuckyGift()
|
||||||
|
call := c.calls.Add(1)
|
||||||
|
if call <= 2 {
|
||||||
|
c.paidAtMS[call-1].Store(meta.GetPaidAtMs())
|
||||||
|
}
|
||||||
|
if call == 1 {
|
||||||
return nil, xerr.New(xerr.Unavailable, "temporary lucky outage")
|
return nil, xerr.New(xerr.Unavailable, "temporary lucky outage")
|
||||||
}
|
}
|
||||||
meta := req.GetLuckyGift()
|
|
||||||
return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{
|
return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{
|
||||||
DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(),
|
DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(),
|
||||||
MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", CoinBalanceAfter: 1100,
|
MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", CoinBalanceAfter: 1100,
|
||||||
@ -153,13 +193,16 @@ func (c *recoverableLuckyClient) BatchExecuteLuckyGiftDraw(ctx context.Context,
|
|||||||
func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.T) {
|
func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.T) {
|
||||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
wallet := &concurrentV2Wallet{}
|
walletPaidAtMS := time.Date(2026, 7, 12, 23, 59, 59, 700_000_000, time.UTC).UnixMilli()
|
||||||
|
wallet := &concurrentV2Wallet{walletPaidAtMS: walletPaidAtMS}
|
||||||
lucky := &recoverableLuckyClient{}
|
lucky := &recoverableLuckyClient{}
|
||||||
|
clock := &fixedRoomRocketClock{now: time.Date(2026, 7, 12, 23, 59, 59, 800_000_000, time.UTC)}
|
||||||
svc := roomservice.New(roomservice.Config{
|
svc := roomservice.New(roomservice.Config{
|
||||||
NodeID: "node-gift-v2-recovery",
|
NodeID: "node-gift-v2-recovery",
|
||||||
LeaseTTL: 10 * time.Second,
|
LeaseTTL: 48 * time.Hour,
|
||||||
RankLimit: 20,
|
RankLimit: 20,
|
||||||
SnapshotEveryN: 1,
|
SnapshotEveryN: 1,
|
||||||
|
Clock: clock,
|
||||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
|
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
|
||||||
|
|
||||||
roomID := "room-gift-v2-recovery"
|
roomID := "room-gift-v2-recovery"
|
||||||
@ -167,7 +210,7 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
|
|||||||
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
|
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
|
||||||
joinRocketRoom(t, ctx, svc, roomID, targetID)
|
joinRocketRoom(t, ctx, svc, roomID, targetID)
|
||||||
req := &roomv1.SendGiftRequest{
|
req := &roomv1.SendGiftRequest{
|
||||||
Meta: &roomv1.RequestMeta{RequestId: "req-v2-recovery", CommandId: "cmd-v2-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
|
Meta: &roomv1.RequestMeta{RequestId: "req-v2-recovery", CommandId: "cmd-v2-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default, DeviceId: "device-v2-recovery"},
|
||||||
TargetType: "user", TargetUserId: targetID, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
|
TargetType: "user", TargetUserId: targetID, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
|
||||||
}
|
}
|
||||||
if _, err := svc.SendGift(ctx, req); !xerr.IsCode(err, xerr.Unavailable) {
|
if _, err := svc.SendGift(ctx, req); !xerr.IsCode(err, xerr.Unavailable) {
|
||||||
@ -177,6 +220,12 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
|
|||||||
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
|
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
|
||||||
t.Fatalf("debited saga was not persisted: exists=%v operation=%+v err=%v", exists, operation, err)
|
t.Fatalf("debited saga was not persisted: exists=%v operation=%+v err=%v", exists, operation, err)
|
||||||
}
|
}
|
||||||
|
// 恢复跨过五分钟加权窗和 UTC 切日后,lucky-service 仍必须收到 wallet 首次交易时间;
|
||||||
|
// saga created_at 与 wallet paid_at 即使只差 100ms 也不是同一个 owner 事实,不能相互替代。
|
||||||
|
clock.now = clock.now.Add(6 * time.Minute)
|
||||||
|
if clock.Now().Sub(time.UnixMilli(operation.CreatedAtMS)) <= 5*time.Minute || clock.Now().UTC().Day() == time.UnixMilli(operation.CreatedAtMS).UTC().Day() {
|
||||||
|
t.Fatalf("test clock did not cross both recovery boundaries: created=%s recovery=%s", time.UnixMilli(operation.CreatedAtMS).UTC(), clock.Now())
|
||||||
|
}
|
||||||
if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
|
if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
|
||||||
Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
|
Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
|
||||||
}); !xerr.IsCode(err, xerr.Conflict) {
|
}); !xerr.IsCode(err, xerr.Conflict) {
|
||||||
@ -203,6 +252,12 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
|
|||||||
}
|
}
|
||||||
time.Sleep(20 * time.Millisecond)
|
time.Sleep(20 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
if operation.CreatedAtMS == walletPaidAtMS {
|
||||||
|
t.Fatalf("test must distinguish room saga time from wallet transaction time: %d", walletPaidAtMS)
|
||||||
|
}
|
||||||
|
if firstPaidAt, recoveredPaidAt := lucky.paidAtMS[0].Load(), lucky.paidAtMS[1].Load(); firstPaidAt != walletPaidAtMS || recoveredPaidAt != walletPaidAtMS {
|
||||||
|
t.Fatalf("lucky paid_at_ms drifted from wallet fact across recovery: wallet=%d operation=%d first=%d recovered=%d", walletPaidAtMS, operation.CreatedAtMS, firstPaidAt, recoveredPaidAt)
|
||||||
|
}
|
||||||
|
|
||||||
replayed, err := svc.SendGift(ctx, req)
|
replayed, err := svc.SendGift(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -214,6 +214,7 @@ func baseFromMeta(meta *roomv1.RequestMeta) command.Base {
|
|||||||
Room: meta.GetRoomId(),
|
Room: meta.GetRoomId(),
|
||||||
GatewayNodeID: meta.GetGatewayNodeId(),
|
GatewayNodeID: meta.GetGatewayNodeId(),
|
||||||
SessionID: meta.GetSessionId(),
|
SessionID: meta.GetSessionId(),
|
||||||
|
DeviceID: meta.GetDeviceId(),
|
||||||
SentAtMS: meta.GetSentAtMs(),
|
SentAtMS: meta.GetSentAtMs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -256,6 +257,7 @@ func requestMetaWithRoomID(meta *roomv1.RequestMeta, roomID string) *roomv1.Requ
|
|||||||
RoomId: roomID,
|
RoomId: roomID,
|
||||||
GatewayNodeId: meta.GetGatewayNodeId(),
|
GatewayNodeId: meta.GetGatewayNodeId(),
|
||||||
SessionId: meta.GetSessionId(),
|
SessionId: meta.GetSessionId(),
|
||||||
|
DeviceId: meta.GetDeviceId(),
|
||||||
SentAtMs: meta.GetSentAtMs(),
|
SentAtMs: meta.GetSentAtMs(),
|
||||||
AppCode: meta.GetAppCode(),
|
AppCode: meta.GetAppCode(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/room-service/internal/integration"
|
"hyapp/services/room-service/internal/integration"
|
||||||
roomservice "hyapp/services/room-service/internal/room/service"
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
"hyapp/services/room-service/internal/router"
|
"hyapp/services/room-service/internal/router"
|
||||||
@ -21,6 +22,8 @@ type luckyGiftTestClient struct {
|
|||||||
draws []*luckygiftv1.ExecuteLuckyGiftDrawRequest
|
draws []*luckygiftv1.ExecuteLuckyGiftDrawRequest
|
||||||
batchDraws []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest
|
batchDraws []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest
|
||||||
drawResults []*luckygiftv1.LuckyGiftDrawResult
|
drawResults []*luckygiftv1.LuckyGiftDrawResult
|
||||||
|
// checkStrategy 让 room 测试显式区分 fixed_v2 滚动兼容与 dynamic_v3 强设备事实。
|
||||||
|
checkStrategy string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
|
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
|
||||||
@ -32,6 +35,7 @@ func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1
|
|||||||
PoolId: req.GetPoolId(),
|
PoolId: req.GetPoolId(),
|
||||||
RuleVersion: 12,
|
RuleVersion: 12,
|
||||||
ExperiencePool: "novice",
|
ExperiencePool: "novice",
|
||||||
|
StrategyVersion: c.checkStrategy,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,13 +120,22 @@ func (c *luckyGiftTestClient) BatchExecuteLuckyGiftDraw(_ context.Context, req *
|
|||||||
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
|
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||||
|
{
|
||||||
BillingReceiptId: "receipt-lucky",
|
BillingReceiptId: "receipt-lucky",
|
||||||
CoinSpent: 100,
|
CoinSpent: 100,
|
||||||
GiftPointAdded: 100,
|
GiftPointAdded: 100,
|
||||||
HeatValue: 100,
|
HeatValue: 100,
|
||||||
GiftTypeCode: "super_lucky",
|
GiftTypeCode: "super_lucky",
|
||||||
}}}
|
},
|
||||||
|
{
|
||||||
|
BillingReceiptId: "receipt-lucky-legacy-token",
|
||||||
|
CoinSpent: 100,
|
||||||
|
GiftPointAdded: 100,
|
||||||
|
HeatValue: 100,
|
||||||
|
GiftTypeCode: "super_lucky",
|
||||||
|
},
|
||||||
|
}}
|
||||||
luckyGift := &luckyGiftTestClient{}
|
luckyGift := &luckyGiftTestClient{}
|
||||||
svc := roomservice.New(roomservice.Config{
|
svc := roomservice.New(roomservice.Config{
|
||||||
NodeID: "node-lucky-test",
|
NodeID: "node-lucky-test",
|
||||||
@ -143,7 +156,8 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
|||||||
CommandId: "cmd-lucky",
|
CommandId: "cmd-lucky",
|
||||||
ActorUserId: ownerID,
|
ActorUserId: ownerID,
|
||||||
RoomId: roomID,
|
RoomId: roomID,
|
||||||
SessionId: "device-session-1",
|
SessionId: "sess-must-not-be-device-1",
|
||||||
|
DeviceId: "device-auth-session-1",
|
||||||
AppCode: appcode.Default,
|
AppCode: appcode.Default,
|
||||||
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
||||||
},
|
},
|
||||||
@ -178,7 +192,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
|||||||
drawMeta := luckyGift.draws[0].GetLuckyGift()
|
drawMeta := luckyGift.draws[0].GetLuckyGift()
|
||||||
if drawMeta.GetCommandId() != "cmd-lucky" ||
|
if drawMeta.GetCommandId() != "cmd-lucky" ||
|
||||||
drawMeta.GetCoinSpent() != 100 ||
|
drawMeta.GetCoinSpent() != 100 ||
|
||||||
drawMeta.GetDeviceId() != "device-session-1" ||
|
drawMeta.GetDeviceId() != "device-auth-session-1" ||
|
||||||
drawMeta.GetAnchorId() != "101" ||
|
drawMeta.GetAnchorId() != "101" ||
|
||||||
drawMeta.GetVisibleRegionId() != 9001 ||
|
drawMeta.GetVisibleRegionId() != 9001 ||
|
||||||
drawMeta.GetSenderName() != "lucky sender" ||
|
drawMeta.GetSenderName() != "lucky sender" ||
|
||||||
@ -187,6 +201,40 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
|||||||
drawMeta.GetSenderPrettyDisplayUserId() != "HY-123456" {
|
drawMeta.GetSenderPrettyDisplayUserId() != "HY-123456" {
|
||||||
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
|
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 旧 access JWT 没有 device_id 时,room 必须原样传空以保留 fixed_v2 兼容性;
|
||||||
|
// 不能因为需要一个非空风控键就用 session_id 或 command_id 代替设备。
|
||||||
|
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{
|
||||||
|
RequestId: "req-lucky-legacy-token", CommandId: "cmd-lucky-legacy-token",
|
||||||
|
ActorUserId: ownerID, RoomId: roomID, SessionId: "sess-legacy-not-device",
|
||||||
|
AppCode: appcode.Default, SentAtMs: time.Date(2026, 5, 20, 12, 1, 0, 0, time.UTC).UnixMilli(),
|
||||||
|
},
|
||||||
|
TargetType: "user", TargetUserId: viewerID, GiftId: "rose", GiftCount: 1, PoolId: "super_lucky",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("legacy-token fixed-compatible lucky send failed: %v", err)
|
||||||
|
}
|
||||||
|
legacyDraw := luckyGift.draws[len(luckyGift.draws)-1].GetLuckyGift()
|
||||||
|
if legacyDraw.GetDeviceId() != "" {
|
||||||
|
t.Fatalf("legacy-token lucky device_id=%q, want empty instead of session/command fallback", legacyDraw.GetDeviceId())
|
||||||
|
}
|
||||||
|
|
||||||
|
luckyGift.checkStrategy = "dynamic_v3"
|
||||||
|
debitsBeforeDynamicReject := len(wallet.debitRequests)
|
||||||
|
_, err = svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{
|
||||||
|
RequestId: "req-lucky-dynamic-missing-device", CommandId: "cmd-lucky-dynamic-missing-device",
|
||||||
|
ActorUserId: ownerID, RoomId: roomID, SessionId: "sess-still-not-a-device",
|
||||||
|
AppCode: appcode.Default, SentAtMs: time.Date(2026, 5, 20, 12, 2, 0, 0, time.UTC).UnixMilli(),
|
||||||
|
},
|
||||||
|
TargetType: "user", TargetUserId: viewerID, GiftId: "rose", GiftCount: 1, PoolId: "super_lucky",
|
||||||
|
})
|
||||||
|
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||||
|
t.Fatalf("dynamic_v3 missing signed device_id should fail before debit, got %v", err)
|
||||||
|
}
|
||||||
|
if len(wallet.debitRequests) != debitsBeforeDynamicReject {
|
||||||
|
t.Fatalf("dynamic_v3 missing device reached wallet: before=%d after=%d", debitsBeforeDynamicReject, len(wallet.debitRequests))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
|
func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
|
||||||
@ -221,7 +269,8 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
|
|||||||
CommandId: "cmd-lucky-multi",
|
CommandId: "cmd-lucky-multi",
|
||||||
ActorUserId: ownerID,
|
ActorUserId: ownerID,
|
||||||
RoomId: roomID,
|
RoomId: roomID,
|
||||||
SessionId: "device-session-multi",
|
SessionId: "sess-must-not-be-device-multi",
|
||||||
|
DeviceId: "device-auth-session-multi",
|
||||||
AppCode: appcode.Default,
|
AppCode: appcode.Default,
|
||||||
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
||||||
},
|
},
|
||||||
@ -251,6 +300,9 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
|
|||||||
if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 {
|
if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 {
|
||||||
t.Fatalf("second target lucky draw mismatch: %+v", secondDraw)
|
t.Fatalf("second target lucky draw mismatch: %+v", secondDraw)
|
||||||
}
|
}
|
||||||
|
if firstDraw.GetDeviceId() != "device-auth-session-multi" || secondDraw.GetDeviceId() != "device-auth-session-multi" {
|
||||||
|
t.Fatalf("multi-target draws must share the signed JWT device scope: first=%q second=%q", firstDraw.GetDeviceId(), secondDraw.GetDeviceId())
|
||||||
|
}
|
||||||
if resp.GetLuckyGift().GetCommandId() != "cmd-lucky-multi" ||
|
if resp.GetLuckyGift().GetCommandId() != "cmd-lucky-multi" ||
|
||||||
resp.GetLuckyGift().GetTargetUserId() != 0 ||
|
resp.GetLuckyGift().GetTargetUserId() != 0 ||
|
||||||
resp.GetLuckyGift().GetSelectedTierId() != "batch" ||
|
resp.GetLuckyGift().GetSelectedTierId() != "batch" ||
|
||||||
|
|||||||
@ -69,7 +69,7 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
|
|||||||
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
|
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
|
||||||
// 登录成功即刷新设备档案;写入是 best-effort,不影响 token 返回。
|
// 登录成功即刷新设备档案;写入是 best-effort,不影响 token 返回。
|
||||||
s.recordUserDevice(ctx, meta, user.UserID, deviceID)
|
s.recordUserDevice(ctx, meta, user.UserID, deviceID)
|
||||||
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
token, err := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authdomain.Token{}, err
|
return authdomain.Token{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,7 +123,7 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
|
|||||||
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
|
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
|
||||||
// 快捷账号也是可登录主体,设备档案与普通注册保持一致;机器人分支在上方已提前返回。
|
// 快捷账号也是可登录主体,设备档案与普通注册保持一致;机器人分支在上方已提前返回。
|
||||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
||||||
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
|
||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
return authdomain.Token{}, tokenErr
|
return authdomain.Token{}, tokenErr
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,23 @@ func accessTokenExpUnix(t *testing.T, accessToken string) int64 {
|
|||||||
return exp.Unix()
|
return exp.Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func accessTokenStringClaim(t *testing.T, accessToken string, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
|
||||||
|
parsed, err := parser.Parse(accessToken, func(token *jwt.Token) (any, error) {
|
||||||
|
return []byte("test-secret"), nil
|
||||||
|
})
|
||||||
|
if err != nil || !parsed.Valid {
|
||||||
|
t.Fatalf("parse access token claim %s failed: token=%q err=%v", name, accessToken, err)
|
||||||
|
}
|
||||||
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("access token claim set has unexpected type %T", parsed.Claims)
|
||||||
|
}
|
||||||
|
value, _ := claims[name].(string)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
type memoryDecisionCache struct {
|
type memoryDecisionCache struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ip map[string]authservice.IPDecision
|
ip map[string]authservice.IPDecision
|
||||||
@ -363,6 +380,10 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
|
|||||||
if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
|
if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
|
||||||
t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want)
|
t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want)
|
||||||
}
|
}
|
||||||
|
if got := accessTokenStringClaim(t, thirdToken.AccessToken, "device_id"); got != "dev-ios" {
|
||||||
|
// 登录签发必须使用 auth_session 的设备绑定,不能让下游拿 sid 代替设备风控 scope。
|
||||||
|
t.Fatalf("third-party access token device_id=%q, want dev-ios", got)
|
||||||
|
}
|
||||||
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
||||||
t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
|
t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
|
||||||
}
|
}
|
||||||
@ -398,6 +419,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
|
|||||||
if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
|
if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
|
||||||
t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want)
|
t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want)
|
||||||
}
|
}
|
||||||
|
if got := accessTokenStringClaim(t, loginToken.AccessToken, "device_id"); got != "ios" {
|
||||||
|
t.Fatalf("password access token device_id=%q, want ios", got)
|
||||||
|
}
|
||||||
var auditedAppVersion string
|
var auditedAppVersion string
|
||||||
var auditedBuildNumber string
|
var auditedBuildNumber string
|
||||||
if err := repository.RawDB().QueryRowContext(ctx, `
|
if err := repository.RawDB().QueryRowContext(ctx, `
|
||||||
@ -435,6 +459,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
|
|||||||
if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
|
if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
|
||||||
t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want)
|
t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want)
|
||||||
}
|
}
|
||||||
|
if got := accessTokenStringClaim(t, refreshToken.AccessToken, "device_id"); got != "ios" {
|
||||||
|
t.Fatalf("refreshed access token device_id=%q, want ios", got)
|
||||||
|
}
|
||||||
if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" {
|
if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" {
|
||||||
t.Fatalf("refresh must denylist rotated access session, got %q", got)
|
t.Fatalf("refresh must denylist rotated access session, got %q", got)
|
||||||
}
|
}
|
||||||
@ -1961,6 +1988,10 @@ func TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithout
|
|||||||
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
|
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
|
||||||
t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
|
t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
|
||||||
}
|
}
|
||||||
|
if got := accessTokenStringClaim(t, reissued.AccessToken, "device_id"); got != "dev-ios" {
|
||||||
|
// access resign 不接受客户端 device_id,只能从已校验的 active session 回读原绑定值。
|
||||||
|
t.Fatalf("reissued access token device_id=%q, want dev-ios", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) {
|
func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) {
|
||||||
|
|||||||
@ -248,7 +248,7 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
|
|||||||
// 老用户换机三方登录也要立即出现在设备 tab;header 缺失时回退 body 设备字段。
|
// 老用户换机三方登录也要立即出现在设备 tab;header 缺失时回退 body 设备字段。
|
||||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
||||||
// isNewUser=false 表示本次只创建 session,没有创建用户。
|
// isNewUser=false 表示本次只创建 session,没有创建用户。
|
||||||
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
token, err := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authdomain.Token{}, false, err
|
return authdomain.Token{}, false, err
|
||||||
}
|
}
|
||||||
@ -300,7 +300,7 @@ func (s *Service) registerExistingThirdParty(ctx context.Context, identity authd
|
|||||||
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
|
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
|
||||||
// 补齐资料完成注册同样属于认证成功,设备档案与 session 创建保持同步。
|
// 补齐资料完成注册同样属于认证成功,设备档案与 session 创建保持同步。
|
||||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), updated.UserID, registration.DeviceID)
|
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), updated.UserID, registration.DeviceID)
|
||||||
token, err := s.issueToken(updated, session.SessionID, refreshToken, session.ExpiresAtMs)
|
token, err := s.issueToken(updated, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authdomain.Token{}, false, err
|
return authdomain.Token{}, false, err
|
||||||
}
|
}
|
||||||
@ -360,7 +360,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
|
|||||||
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
|
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
|
||||||
// 新注册用户的首个设备档案与注册事务同请求写入,后台无需等下次登录。
|
// 新注册用户的首个设备档案与注册事务同请求写入,后台无需等下次登录。
|
||||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
||||||
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
|
||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
return authdomain.Token{}, false, tokenErr
|
return authdomain.Token{}, false, tokenErr
|
||||||
}
|
}
|
||||||
@ -444,7 +444,7 @@ func (s *Service) createRegisteredThirdPartyUser(ctx context.Context, provider s
|
|||||||
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
|
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
|
||||||
// 注册专用入口创建的 completed 用户同样落首个设备档案。
|
// 注册专用入口创建的 completed 用户同样落首个设备档案。
|
||||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
||||||
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
|
||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
return authdomain.Token{}, false, tokenErr
|
return authdomain.Token{}, false, tokenErr
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,7 +86,7 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceI
|
|||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
return authdomain.Token{}, createErr
|
return authdomain.Token{}, createErr
|
||||||
}
|
}
|
||||||
candidateToken, issueErr := s.issueToken(user, newSession.SessionID, newRefreshToken, newSession.ExpiresAtMs)
|
candidateToken, issueErr := s.issueToken(user, newSession.SessionID, newSession.DeviceID, newRefreshToken, newSession.ExpiresAtMs)
|
||||||
if issueErr != nil {
|
if issueErr != nil {
|
||||||
return authdomain.Token{}, issueErr
|
return authdomain.Token{}, issueErr
|
||||||
}
|
}
|
||||||
@ -186,7 +186,7 @@ func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "")
|
s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "")
|
||||||
return s.issueToken(user, session.SessionID, "", session.ExpiresAtMs)
|
return s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminIssueUserAccessToken 供后台按用户当前最新 active session 重签 access token。
|
// AdminIssueUserAccessToken 供后台按用户当前最新 active session 重签 access token。
|
||||||
@ -221,7 +221,7 @@ func (s *Service) AdminIssueUserAccessToken(ctx context.Context, userID int64, m
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refreshToken 传空:后台没有轮换会话的理由,响应中也绝不携带 refresh 原文。
|
// refreshToken 传空:后台没有轮换会话的理由,响应中也绝不携带 refresh 原文。
|
||||||
token, err := s.issueToken(user, session.SessionID, "", session.ExpiresAtMs)
|
token, err := s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authdomain.Token{}, authdomain.Session{}, err
|
return authdomain.Token{}, authdomain.Session{}, err
|
||||||
}
|
}
|
||||||
@ -413,7 +413,7 @@ func refreshOutcomeAAD(appCode string, parentSessionID string, childSessionID st
|
|||||||
return []byte(strings.Join([]string{refreshReplayCipherVersion, appcode.Normalize(appCode), parentSessionID, childSessionID, refreshRequestID}, "\x00"))
|
return []byte(strings.Join([]string{refreshReplayCipherVersion, appcode.Normalize(appCode), parentSessionID, childSessionID, refreshRequestID}, "\x00"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) {
|
func (s *Service) issueToken(user userdomain.User, sessionID string, deviceID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) {
|
||||||
now := s.now()
|
now := s.now()
|
||||||
expiresInSec := s.cfg.AccessTokenTTLSec
|
expiresInSec := s.cfg.AccessTokenTTLSec
|
||||||
expiresAt := now.Add(time.Duration(expiresInSec) * time.Second)
|
expiresAt := now.Add(time.Duration(expiresInSec) * time.Second)
|
||||||
@ -434,6 +434,8 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
|
|||||||
onboardingStatus := tokenOnboardingStatus(user)
|
onboardingStatus := tokenOnboardingStatus(user)
|
||||||
// JWT 只携带内部 user_id;客户端展示和人工输入统一使用 display_user_id。
|
// JWT 只携带内部 user_id;客户端展示和人工输入统一使用 display_user_id。
|
||||||
// user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。
|
// user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。
|
||||||
|
// device_id 只能来自已落库 auth_session 的绑定值;心跳/资料完成重签也必须从同一 session 回读,
|
||||||
|
// 不允许 gateway 用 sid 或业务 command_id 伪造设备风控作用域。
|
||||||
claims := jwt.MapClaims{
|
claims := jwt.MapClaims{
|
||||||
"iss": s.cfg.Issuer,
|
"iss": s.cfg.Issuer,
|
||||||
"app_code": appcode.Normalize(user.AppCode),
|
"app_code": appcode.Normalize(user.AppCode),
|
||||||
@ -446,6 +448,7 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
|
|||||||
"profile_completed": user.ProfileCompleted,
|
"profile_completed": user.ProfileCompleted,
|
||||||
"onboarding_status": onboardingStatus,
|
"onboarding_status": onboardingStatus,
|
||||||
"sid": sessionID,
|
"sid": sessionID,
|
||||||
|
"device_id": strings.TrimSpace(deviceID),
|
||||||
"typ": "access",
|
"typ": "access",
|
||||||
"iat": now.Unix(),
|
"iat": now.Unix(),
|
||||||
"exp": expiresAt.Unix(),
|
"exp": expiresAt.Unix(),
|
||||||
|
|||||||
@ -26,3 +26,11 @@ func (s *Service) ResolveApp(ctx context.Context, code string, packageName strin
|
|||||||
|
|
||||||
return s.appRegistryRepository.ResolveApp(ctx, code, packageName, platform)
|
return s.appRegistryRepository.ResolveApp(ctx, code, packageName, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListApps 返回注册表中所有启用租户,供后台任务动态覆盖新 App。
|
||||||
|
func (s *Service) ListApps(ctx context.Context) ([]userdomain.App, error) {
|
||||||
|
if s.appRegistryRepository == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "app repository is not configured")
|
||||||
|
}
|
||||||
|
return s.appRegistryRepository.ListApps(ctx)
|
||||||
|
}
|
||||||
|
|||||||
@ -76,6 +76,7 @@ type UserRepository interface {
|
|||||||
// AppRegistryRepository 负责把客户端包名解析成内部 app_code。
|
// AppRegistryRepository 负责把客户端包名解析成内部 app_code。
|
||||||
type AppRegistryRepository interface {
|
type AppRegistryRepository interface {
|
||||||
ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error)
|
ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error)
|
||||||
|
ListApps(ctx context.Context) ([]userdomain.App, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountryRegionRepository 负责把用户选择的 country_code 解析成国家主数据和当前 active 区域。
|
// CountryRegionRepository 负责把用户选择的 country_code 解析成国家主数据和当前 active 区域。
|
||||||
|
|||||||
@ -77,3 +77,28 @@ func (r *Repository) ResolveApp(ctx context.Context, code string, packageName st
|
|||||||
}
|
}
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListApps 只返回 active 注册表事实;排序稳定,便于 cron 生成独立租户租约。
|
||||||
|
func (r *Repository) ListApps(ctx context.Context) ([]userdomain.App, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "app repository is not configured")
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT app_id, app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms
|
||||||
|
FROM apps
|
||||||
|
WHERE status = ?
|
||||||
|
ORDER BY app_code ASC`, userdomain.AppStatusActive)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
apps := make([]userdomain.App, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var app userdomain.App
|
||||||
|
if err := rows.Scan(&app.AppID, &app.AppCode, &app.AppName, &app.PackageName, &app.Platform, &app.Status, &app.CreatedAtMs, &app.UpdatedAtMs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apps = append(apps, app)
|
||||||
|
}
|
||||||
|
return apps, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@ -52,3 +52,23 @@ func TestResolveAppRejectsUnsupportedPackage(t *testing.T) {
|
|||||||
t.Fatalf("expected invalid argument for unsupported package, got %v", err)
|
t.Fatalf("expected invalid argument for unsupported package, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListAppsReturnsOnlyActiveAppsInStableOrder(t *testing.T) {
|
||||||
|
repo := mysqltest.NewRepository(t).Repository.AppRepository()
|
||||||
|
|
||||||
|
apps, err := repo.ListApps(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListApps failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(apps) == 0 {
|
||||||
|
t.Fatal("ListApps must return seeded active apps")
|
||||||
|
}
|
||||||
|
for index, app := range apps {
|
||||||
|
if app.Status != "active" {
|
||||||
|
t.Fatalf("inactive app leaked into registry: %+v", app)
|
||||||
|
}
|
||||||
|
if index > 0 && apps[index-1].AppCode > app.AppCode {
|
||||||
|
t.Fatalf("apps must be sorted by app_code: %+v", apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -103,6 +103,19 @@ func (s *Server) ResolveApp(ctx context.Context, req *userv1.ResolveAppRequest)
|
|||||||
return &userv1.ResolveAppResponse{App: toProtoApp(app)}, nil
|
return &userv1.ResolveAppResponse{App: toProtoApp(app)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListApps 给内部 scheduler 提供启用租户目录,避免每个后台任务在 YAML 重复维护 app_code。
|
||||||
|
func (s *Server) ListApps(ctx context.Context, _ *userv1.ListAppsRequest) (*userv1.ListAppsResponse, error) {
|
||||||
|
apps, err := s.userSvc.ListApps(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
items := make([]*userv1.App, 0, len(apps))
|
||||||
|
for _, app := range apps {
|
||||||
|
items = append(items, toProtoApp(app))
|
||||||
|
}
|
||||||
|
return &userv1.ListAppsResponse{Apps: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoginPassword 统一把短号不存在、未设置密码和密码错误留给 service 层映射为 AUTH_FAILED。
|
// LoginPassword 统一把短号不存在、未设置密码和密码错误留给 service 层映射为 AUTH_FAILED。
|
||||||
func (s *Server) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
|
func (s *Server) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
|
||||||
ctx = contextWithApp(ctx, req.GetMeta())
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
|
|||||||
@ -14,7 +14,6 @@ activity_service_addr: "activity-service:13006"
|
|||||||
mysql_auto_migrate: false
|
mysql_auto_migrate: false
|
||||||
red_packet_expiry_worker:
|
red_packet_expiry_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
app_code: "lalu"
|
|
||||||
poll_interval: "5s"
|
poll_interval: "5s"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
projection_worker:
|
projection_worker:
|
||||||
|
|||||||
@ -14,7 +14,6 @@ activity_service_addr: "ACTIVITY_SERVICE_HOST:13006"
|
|||||||
mysql_auto_migrate: false
|
mysql_auto_migrate: false
|
||||||
red_packet_expiry_worker:
|
red_packet_expiry_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
app_code: "lalu"
|
|
||||||
poll_interval: "5s"
|
poll_interval: "5s"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
projection_worker:
|
projection_worker:
|
||||||
|
|||||||
@ -14,7 +14,6 @@ activity_service_addr: "127.0.0.1:13006"
|
|||||||
mysql_auto_migrate: false
|
mysql_auto_migrate: false
|
||||||
red_packet_expiry_worker:
|
red_packet_expiry_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
app_code: "lalu"
|
|
||||||
poll_interval: "5s"
|
poll_interval: "5s"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
projection_worker:
|
projection_worker:
|
||||||
@ -148,7 +147,6 @@ external_recharge:
|
|||||||
v5pay_return_url: "http://127.0.0.1:7001/recharge/index.html"
|
v5pay_return_url: "http://127.0.0.1:7001/recharge/index.html"
|
||||||
external_recharge_reconcile_worker:
|
external_recharge_reconcile_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
app_code: "lalu"
|
|
||||||
poll_interval: "30s"
|
poll_interval: "30s"
|
||||||
batch_size: 50
|
batch_size: 50
|
||||||
outbox_worker:
|
outbox_worker:
|
||||||
|
|||||||
@ -18,7 +18,8 @@ func (a *App) startExternalRechargeReconcileWorker(ctx context.Context) {
|
|||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
// 三方回调是主链路;补偿 worker 只兜底 redirected 订单,真正入账仍由 service/storage 的幂等事务收敛。
|
// 三方回调是主链路;补偿 worker 只兜底 redirected 订单,真正入账仍由 service/storage 的幂等事务收敛。
|
||||||
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, a.externalRechargeReconcileWorkerCfg.AppCode, a.externalRechargeReconcileWorkerCfg.BatchSize)
|
// 自动补偿不绑定租户;每笔订单进入 provider 查询和入账前会恢复自己的 app_code context。
|
||||||
|
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, "", a.externalRechargeReconcileWorkerCfg.BatchSize)
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID))
|
logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,8 @@ func (a *App) startRedPacketExpiryWorker(ctx context.Context) {
|
|||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
// 过期退款必须进入 wallet service 用例,保证红包状态、退款流水和 outbox 同一事务提交。
|
// 过期退款必须进入 wallet service 用例,保证红包状态、退款流水和 outbox 同一事务提交。
|
||||||
result, err := a.walletSvc.ExpireRedPackets(ctx, a.redPacketExpiryWorkerCfg.AppCode, a.redPacketExpiryWorkerCfg.BatchSize)
|
// 空 app_code 是后台 worker 的“所有租户”语义;repository 会按候选记录的真实 app_code 分事务退款。
|
||||||
|
result, err := a.walletSvc.ExpireRedPackets(ctx, "", a.redPacketExpiryWorkerCfg.BatchSize)
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", workerID))
|
logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", workerID))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,6 @@ type Config struct {
|
|||||||
// RedPacketExpiryWorkerConfig 保存红包过期退款 worker 策略。
|
// RedPacketExpiryWorkerConfig 保存红包过期退款 worker 策略。
|
||||||
type RedPacketExpiryWorkerConfig struct {
|
type RedPacketExpiryWorkerConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
AppCode string `yaml:"app_code"`
|
|
||||||
PollInterval time.Duration `yaml:"poll_interval"`
|
PollInterval time.Duration `yaml:"poll_interval"`
|
||||||
BatchSize int32 `yaml:"batch_size"`
|
BatchSize int32 `yaml:"batch_size"`
|
||||||
}
|
}
|
||||||
@ -270,7 +269,6 @@ type ExternalRechargeConfig struct {
|
|||||||
// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
|
// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
|
||||||
type ExternalRechargeReconcileWorkerConfig struct {
|
type ExternalRechargeReconcileWorkerConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
AppCode string `yaml:"app_code"`
|
|
||||||
PollInterval time.Duration `yaml:"poll_interval"`
|
PollInterval time.Duration `yaml:"poll_interval"`
|
||||||
BatchSize int `yaml:"batch_size"`
|
BatchSize int `yaml:"batch_size"`
|
||||||
}
|
}
|
||||||
@ -287,7 +285,6 @@ func Default() Config {
|
|||||||
ActivityServiceAddr: "127.0.0.1:13006",
|
ActivityServiceAddr: "127.0.0.1:13006",
|
||||||
RedPacketExpiryWorker: RedPacketExpiryWorkerConfig{
|
RedPacketExpiryWorker: RedPacketExpiryWorkerConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
AppCode: "lalu",
|
|
||||||
PollInterval: 5 * time.Second,
|
PollInterval: 5 * time.Second,
|
||||||
BatchSize: 50,
|
BatchSize: 50,
|
||||||
},
|
},
|
||||||
@ -339,7 +336,6 @@ func Default() Config {
|
|||||||
},
|
},
|
||||||
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
|
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
AppCode: "lalu",
|
|
||||||
PollInterval: 30 * time.Second,
|
PollInterval: 30 * time.Second,
|
||||||
BatchSize: 50,
|
BatchSize: 50,
|
||||||
},
|
},
|
||||||
@ -434,10 +430,6 @@ func Load(path string) (Config, error) {
|
|||||||
if cfg.ActivityServiceAddr == "" {
|
if cfg.ActivityServiceAddr == "" {
|
||||||
cfg.ActivityServiceAddr = "127.0.0.1:13006"
|
cfg.ActivityServiceAddr = "127.0.0.1:13006"
|
||||||
}
|
}
|
||||||
cfg.RedPacketExpiryWorker.AppCode = strings.TrimSpace(cfg.RedPacketExpiryWorker.AppCode)
|
|
||||||
if cfg.RedPacketExpiryWorker.AppCode == "" {
|
|
||||||
cfg.RedPacketExpiryWorker.AppCode = Default().RedPacketExpiryWorker.AppCode
|
|
||||||
}
|
|
||||||
if cfg.RedPacketExpiryWorker.PollInterval <= 0 {
|
if cfg.RedPacketExpiryWorker.PollInterval <= 0 {
|
||||||
cfg.RedPacketExpiryWorker.PollInterval = Default().RedPacketExpiryWorker.PollInterval
|
cfg.RedPacketExpiryWorker.PollInterval = Default().RedPacketExpiryWorker.PollInterval
|
||||||
}
|
}
|
||||||
@ -604,9 +596,6 @@ func dotEnvCandidates(start string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig {
|
func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig {
|
||||||
if cfg.AppCode = strings.TrimSpace(cfg.AppCode); cfg.AppCode == "" {
|
|
||||||
cfg.AppCode = defaults.AppCode
|
|
||||||
}
|
|
||||||
if cfg.PollInterval <= 0 {
|
if cfg.PollInterval <= 0 {
|
||||||
cfg.PollInterval = defaults.PollInterval
|
cfg.PollInterval = defaults.PollInterval
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
|
|||||||
if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled {
|
if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled {
|
||||||
t.Fatalf("local config must enable wallet outbox workers: outbox=%+v realtime=%+v projection=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker, cfg.ProjectionWorker)
|
t.Fatalf("local config must enable wallet outbox workers: outbox=%+v realtime=%+v projection=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker, cfg.ProjectionWorker)
|
||||||
}
|
}
|
||||||
if !cfg.ExternalRechargeReconcileWorker.Enabled || cfg.ExternalRechargeReconcileWorker.BatchSize != 50 || cfg.ExternalRechargeReconcileWorker.AppCode != "lalu" {
|
if !cfg.ExternalRechargeReconcileWorker.Enabled || cfg.ExternalRechargeReconcileWorker.BatchSize != 50 {
|
||||||
t.Fatalf("local config must enable external recharge reconcile worker: %+v", cfg.ExternalRechargeReconcileWorker)
|
t.Fatalf("local config must enable external recharge reconcile worker: %+v", cfg.ExternalRechargeReconcileWorker)
|
||||||
}
|
}
|
||||||
if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 {
|
if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 {
|
||||||
|
|||||||
@ -106,6 +106,14 @@ type Receipt struct {
|
|||||||
GiftIncomeCoinAmount int64
|
GiftIncomeCoinAmount int64
|
||||||
// GiftIncomeBalanceAfter 是收礼人 COIN 入账后的余额;没有返币时为 0。
|
// GiftIncomeBalanceAfter 是收礼人 COIN 入账后的余额;没有返币时为 0。
|
||||||
GiftIncomeBalanceAfter int64
|
GiftIncomeBalanceAfter int64
|
||||||
|
// Recharge* 是扣费事务固化的 sender 充值画像。它们跟随 gift transaction metadata 幂等重放,
|
||||||
|
// 防止 lucky-gift 因重试时间不同进入另一概率层或丢失“充值后五分钟”加成。
|
||||||
|
RechargeSevenDayCoins int64
|
||||||
|
RechargeThirtyDayCoins int64
|
||||||
|
LastRechargedAtMS int64
|
||||||
|
// PaidAtMS 是 wallet owner 创建成功交易行的 UTC epoch ms。它跟 transaction_id 一起构成不可变扣费事实,
|
||||||
|
// room 恢复和 lucky 重放必须复用它,不能改用 saga 创建时间或恢复执行时间。
|
||||||
|
PaidAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。
|
// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。
|
||||||
|
|||||||
@ -15,19 +15,20 @@ func (s *Service) ReconcileExternalRechargeOrders(ctx context.Context, appCode s
|
|||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
|
appCode = strings.ToLower(strings.TrimSpace(appCode))
|
||||||
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit)
|
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appCode, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
processed := 0
|
processed := 0
|
||||||
for _, order := range orders {
|
for _, order := range orders {
|
||||||
|
// 跨租户扫描后必须恢复订单自己的 context,后续幂等流水和账户更新不能落到默认 lalu。
|
||||||
if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil {
|
orderCtx := appcode.WithContext(ctx, order.AppCode)
|
||||||
|
if _, err := s.refreshMifaPayRechargeOrder(orderCtx, order); err != nil {
|
||||||
processed++
|
processed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil {
|
if _, err := s.refreshV5PayRechargeOrder(orderCtx, order); err != nil {
|
||||||
processed++
|
processed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -127,8 +127,12 @@ func (s *Service) ExpireRedPackets(ctx context.Context, appCode string, batchSiz
|
|||||||
if s.repository == nil {
|
if s.repository == nil {
|
||||||
return ledger.RedPacketExpireResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
return ledger.RedPacketExpireResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||||
}
|
}
|
||||||
|
// 空 app_code 专用于内部 worker 的跨租户扫描;显式 RPC 仍归一化并保持单租户边界。
|
||||||
|
appCode = strings.ToLower(strings.TrimSpace(appCode))
|
||||||
|
if appCode != "" {
|
||||||
appCode = appcode.Normalize(appCode)
|
appCode = appcode.Normalize(appCode)
|
||||||
ctx = appcode.WithContext(ctx, appCode)
|
ctx = appcode.WithContext(ctx, appCode)
|
||||||
|
}
|
||||||
return s.repository.ExpireRedPackets(ctx, appCode, batchSize)
|
return s.repository.ExpireRedPackets(ctx, appCode, batchSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -399,6 +399,15 @@ func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) {
|
|||||||
if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 {
|
if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 {
|
||||||
t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again)
|
t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again)
|
||||||
}
|
}
|
||||||
|
if receipt.Aggregate.PaidAtMS <= 0 || again.Aggregate.PaidAtMS != receipt.Aggregate.PaidAtMS {
|
||||||
|
t.Fatalf("batch aggregate paid_at_ms drifted: first=%d replay=%d", receipt.Aggregate.PaidAtMS, again.Aggregate.PaidAtMS)
|
||||||
|
}
|
||||||
|
for index, target := range receipt.Targets {
|
||||||
|
walletCreatedAtMS := repository.TransactionCreatedAtMS(target.CommandID)
|
||||||
|
if target.Receipt.PaidAtMS != walletCreatedAtMS || again.Targets[index].Receipt.PaidAtMS != walletCreatedAtMS || target.Receipt.PaidAtMS != receipt.Aggregate.PaidAtMS {
|
||||||
|
t.Fatalf("batch target paid_at_ms is not stable wallet fact: target=%s created=%d first=%d replay=%d aggregate=%d", target.CommandID, walletCreatedAtMS, target.Receipt.PaidAtMS, again.Targets[index].Receipt.PaidAtMS, receipt.Aggregate.PaidAtMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, userID := range []int64{10002, 10003} {
|
for _, userID := range []int64{10002, 10003} {
|
||||||
balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
|
balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1576,6 +1585,8 @@ func TestDebitGiftIsIdempotent(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first debit failed: %v", err)
|
t.Fatalf("first debit failed: %v", err)
|
||||||
}
|
}
|
||||||
|
// 模拟旧版本 transaction metadata 未写 paid_at_ms;重放仍必须读取 wallet_transactions.created_at_ms。
|
||||||
|
repository.RemoveGiftPaidAtMetadata(command.CommandID)
|
||||||
second, err := svc.DebitGift(context.Background(), command)
|
second, err := svc.DebitGift(context.Background(), command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second debit failed: %v", err)
|
t.Fatalf("second debit failed: %v", err)
|
||||||
@ -1584,6 +1595,11 @@ func TestDebitGiftIsIdempotent(t *testing.T) {
|
|||||||
if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 {
|
if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 {
|
||||||
t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second)
|
t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second)
|
||||||
}
|
}
|
||||||
|
transactionCreatedAtMS := repository.TransactionCreatedAtMS(command.CommandID)
|
||||||
|
if first.PaidAtMS <= 0 || first.PaidAtMS != transactionCreatedAtMS || second.PaidAtMS != transactionCreatedAtMS {
|
||||||
|
// paid_at_ms 必须来自首次 wallet transaction,而不是每次 RPC 重放的服务时钟。
|
||||||
|
t.Fatalf("gift paid_at_ms is not stable wallet fact: created=%d first=%d second=%d", transactionCreatedAtMS, first.PaidAtMS, second.PaidAtMS)
|
||||||
|
}
|
||||||
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
|
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
|
||||||
t.Fatalf("idempotent command should write one transaction, got %d", got)
|
t.Fatalf("idempotent command should write one transaction, got %d", got)
|
||||||
}
|
}
|
||||||
@ -1907,6 +1923,46 @@ func TestRedPacketLifecycleUsesRealMySQL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExpireRedPacketsWithoutAppCodeRefundsEveryTenant(t *testing.T) {
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
repository.SetBalanceForApp("lalu", 19101, 100)
|
||||||
|
repository.SetBalanceForApp("huwaa", 19102, 100)
|
||||||
|
svc := walletservice.New(repository)
|
||||||
|
|
||||||
|
create := func(appCode string, commandID string, senderUserID int64) ledger.RedPacketCreateReceipt {
|
||||||
|
t.Helper()
|
||||||
|
receipt, err := svc.CreateRedPacket(context.Background(), ledger.RedPacketCreateCommand{
|
||||||
|
AppCode: appCode, CommandID: commandID, SenderUserID: senderUserID,
|
||||||
|
RoomID: appCode + "_room", RegionID: 1, PacketType: ledger.RedPacketTypeNormal,
|
||||||
|
TotalAmount: 10, PacketCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create %s red packet failed: %v", appCode, err)
|
||||||
|
}
|
||||||
|
repository.ExpireRedPacket(appCode, receipt.Packet.PacketID)
|
||||||
|
return receipt
|
||||||
|
}
|
||||||
|
lalu := create("lalu", "cmd-red-packet-all-lalu", 19101)
|
||||||
|
huwaa := create("huwaa", "cmd-red-packet-all-huwaa", 19102)
|
||||||
|
|
||||||
|
result, err := svc.ExpireRedPackets(context.Background(), "", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExpireRedPackets all tenants failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.ExpiredCount != 2 || result.RefundedAmount != 20 {
|
||||||
|
t.Fatalf("all-tenant expiry result mismatch: %+v", result)
|
||||||
|
}
|
||||||
|
for _, packet := range []struct {
|
||||||
|
appCode string
|
||||||
|
packetID string
|
||||||
|
}{{"lalu", lalu.Packet.PacketID}, {"huwaa", huwaa.Packet.PacketID}} {
|
||||||
|
got, err := svc.GetRedPacket(context.Background(), packet.appCode, packet.packetID, 0, false)
|
||||||
|
if err != nil || got.Status != ledger.RedPacketStatusRefunded {
|
||||||
|
t.Fatalf("%s packet must be refunded: packet=%+v err=%v", packet.appCode, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) {
|
func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
repository.SetBalance(19101, 1000)
|
repository.SetBalance(19101, 1000)
|
||||||
|
|||||||
@ -14,6 +14,20 @@ import (
|
|||||||
"hyapp/services/wallet-service/internal/testutil/mysqltest"
|
"hyapp/services/wallet-service/internal/testutil/mysqltest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestVipDailyCoinRebateMissingProgramIsNoOp(t *testing.T) {
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
service := walletservice.New(repository)
|
||||||
|
taskDay := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
result, err := service.ProcessVipDailyCoinRebateBatch(
|
||||||
|
appcode.WithContext(context.Background(), "huwaa"),
|
||||||
|
ledger.ProcessVipDailyCoinRebateBatchCommand{AppCode: "huwaa", TaskDay: taskDay, BatchSize: 100},
|
||||||
|
)
|
||||||
|
if err != nil || result.HasMore || result.CreatedCount != 0 || result.ScannedCount != 0 {
|
||||||
|
t.Fatalf("missing VIP program must be an empty successful batch: result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVipDailyCoinRebateUTCWindowSnapshotAndClaim 覆盖返现最关键的不可变边界:
|
// TestVipDailyCoinRebateUTCWindowSnapshotAndClaim 覆盖返现最关键的不可变边界:
|
||||||
// UTC 日切资格、run 配置快照、分页重放、App 隔离、available outbox 和并发领取仅入账一次。
|
// UTC 日切资格、run 配置快照、分页重放、App 隔离、available outbox 和并发领取仅入账一次。
|
||||||
func TestVipDailyCoinRebateUTCWindowSnapshotAndClaim(t *testing.T) {
|
func TestVipDailyCoinRebateUTCWindowSnapshotAndClaim(t *testing.T) {
|
||||||
|
|||||||
@ -362,22 +362,25 @@ func (r *Repository) ListExternalRechargeOrdersForReconcile(ctx context.Context,
|
|||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
}
|
}
|
||||||
ctx = contextWithCommandApp(ctx, appCode)
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if limit > 500 {
|
if limit > 500 {
|
||||||
limit = 500
|
limit = 500
|
||||||
}
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+`
|
appCode = strings.ToLower(strings.TrimSpace(appCode))
|
||||||
WHERE app_code = ?
|
where := `
|
||||||
AND provider_code IN (?, ?)
|
WHERE provider_code IN (?, ?)
|
||||||
AND status = ?
|
AND status = ?
|
||||||
ORDER BY updated_at_ms ASC, created_at_ms ASC
|
`
|
||||||
LIMIT ?`,
|
args := []any{ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay, ledger.ExternalRechargeStatusRedirected}
|
||||||
appcode.FromContext(ctx), ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay,
|
if appCode != "" {
|
||||||
ledger.ExternalRechargeStatusRedirected, limit,
|
where += ` AND app_code = ?`
|
||||||
)
|
args = append(args, appcode.Normalize(appCode))
|
||||||
|
}
|
||||||
|
where += ` ORDER BY updated_at_ms ASC, created_at_ms ASC, app_code ASC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+where, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user