幸运礼物 fixed_v2 高水位返奖加权:阈值/系数/恢复水位三元组与奖池滞回标志
- lucky_gift_rule_versions 新增 v2_high_water_boost_{threshold,factor_ppm,recover}_coins,0 表示关闭
- lucky_pools 新增 v2_boost_active 滞回标志:触发阈值后加权,跌破恢复水位退出
- 迁移 010 全部 ALGORITHM=INSTANT,不回写历史行;proto/admin 透传三元组
- 真库回归 lucky_gift_v2_boost_test.go
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
43adaeddf1
commit
5da1421fae
@ -14,6 +14,13 @@
|
|||||||
"runtimeArgs": ["run", "-C", "server/admin", "./cmd/server"],
|
"runtimeArgs": ["run", "-C", "server/admin", "./cmd/server"],
|
||||||
"port": 13100,
|
"port": 13100,
|
||||||
"autoPort": false
|
"autoPort": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lucky-gift-service",
|
||||||
|
"runtimeExecutable": "go",
|
||||||
|
"runtimeArgs": ["run", "./services/lucky-gift-service/cmd/server"],
|
||||||
|
"port": 13013,
|
||||||
|
"autoPort": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -525,6 +525,13 @@ type LuckyGiftRuleConfig struct {
|
|||||||
User_24HRtpThresholdPpm int64 `protobuf:"varint,39,opt,name=user_24h_rtp_threshold_ppm,json=user24hRtpThresholdPpm,proto3" json:"user_24h_rtp_threshold_ppm,omitempty"`
|
User_24HRtpThresholdPpm int64 `protobuf:"varint,39,opt,name=user_24h_rtp_threshold_ppm,json=user24hRtpThresholdPpm,proto3" json:"user_24h_rtp_threshold_ppm,omitempty"`
|
||||||
// 触发用户 24 小时 RTP 降权后保留的普通中奖权重;1000000 表示保持原概率。
|
// 触发用户 24 小时 RTP 降权后保留的普通中奖权重;1000000 表示保持原概率。
|
||||||
User_24HOrdinaryWinFactorPpm int64 `protobuf:"varint,40,opt,name=user_24h_ordinary_win_factor_ppm,json=user24hOrdinaryWinFactorPpm,proto3" json:"user_24h_ordinary_win_factor_ppm,omitempty"`
|
User_24HOrdinaryWinFactorPpm int64 `protobuf:"varint,40,opt,name=user_24h_ordinary_win_factor_ppm,json=user24hOrdinaryWinFactorPpm,proto3" json:"user_24h_ordinary_win_factor_ppm,omitempty"`
|
||||||
|
// fixed_v2 高水位返奖加权:奖池水位越过阈值后中奖返奖按系数放大(RTP 等比放大),
|
||||||
|
// 滞回保持到水位跌破恢复线才退出。0 表示关闭;dynamic_v3 必须保持三个字段为 0。
|
||||||
|
V2HighWaterBoostThresholdCoins int64 `protobuf:"varint,41,opt,name=v2_high_water_boost_threshold_coins,json=v2HighWaterBoostThresholdCoins,proto3" json:"v2_high_water_boost_threshold_coins,omitempty"`
|
||||||
|
// 加权系数 ppm;1500000 表示 RTP 变为原值的 150%。启用时必须大于 1000000。
|
||||||
|
V2HighWaterBoostFactorPpm int64 `protobuf:"varint,42,opt,name=v2_high_water_boost_factor_ppm,json=v2HighWaterBoostFactorPpm,proto3" json:"v2_high_water_boost_factor_ppm,omitempty"`
|
||||||
|
// 恢复水位金币;水位低于该值后退出加权。启用时必须为正且不高于阈值。
|
||||||
|
V2HighWaterBoostRecoverCoins int64 `protobuf:"varint,43,opt,name=v2_high_water_boost_recover_coins,json=v2HighWaterBoostRecoverCoins,proto3" json:"v2_high_water_boost_recover_coins,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -839,6 +846,27 @@ func (x *LuckyGiftRuleConfig) GetUser_24HOrdinaryWinFactorPpm() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetV2HighWaterBoostThresholdCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.V2HighWaterBoostThresholdCoins
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetV2HighWaterBoostFactorPpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.V2HighWaterBoostFactorPpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftRuleConfig) GetV2HighWaterBoostRecoverCoins() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.V2HighWaterBoostRecoverCoins
|
||||||
|
}
|
||||||
|
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"`
|
||||||
@ -5192,7 +5220,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\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\x121\n" +
|
"\x05tiers\x18\x02 \x03(\v2%.hyapp.luckygift.v1.LuckyGiftRuleTierR\x05tiers\x121\n" +
|
||||||
"\x15min_recharge_7d_coins\x18\x03 \x01(\x03R\x12minRecharge7dCoins\x123\n" +
|
"\x15min_recharge_7d_coins\x18\x03 \x01(\x03R\x12minRecharge7dCoins\x123\n" +
|
||||||
"\x16min_recharge_30d_coins\x18\x04 \x01(\x03R\x13minRecharge30dCoins\"\x8a\x10\n" +
|
"\x16min_recharge_30d_coins\x18\x04 \x01(\x03R\x13minRecharge30dCoins\"\xe3\x11\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" +
|
||||||
@ -5234,7 +5262,10 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
"\x16room_hourly_payout_cap\x18% \x01(\x03R\x13roomHourlyPayoutCap\x125\n" +
|
"\x16room_hourly_payout_cap\x18% \x01(\x03R\x13roomHourlyPayoutCap\x125\n" +
|
||||||
"\x17anchor_daily_payout_cap\x18& \x01(\x03R\x14anchorDailyPayoutCap\x12:\n" +
|
"\x17anchor_daily_payout_cap\x18& \x01(\x03R\x14anchorDailyPayoutCap\x12:\n" +
|
||||||
"\x1auser_24h_rtp_threshold_ppm\x18' \x01(\x03R\x16user24hRtpThresholdPpm\x12E\n" +
|
"\x1auser_24h_rtp_threshold_ppm\x18' \x01(\x03R\x16user24hRtpThresholdPpm\x12E\n" +
|
||||||
" user_24h_ordinary_win_factor_ppm\x18( \x01(\x03R\x1buser24hOrdinaryWinFactorPpm\"\xb0\x01\n" +
|
" user_24h_ordinary_win_factor_ppm\x18( \x01(\x03R\x1buser24hOrdinaryWinFactorPpm\x12K\n" +
|
||||||
|
"#v2_high_water_boost_threshold_coins\x18) \x01(\x03R\x1ev2HighWaterBoostThresholdCoins\x12A\n" +
|
||||||
|
"\x1ev2_high_water_boost_factor_ppm\x18* \x01(\x03R\x19v2HighWaterBoostFactorPpm\x12G\n" +
|
||||||
|
"!v2_high_water_boost_recover_coins\x18+ \x01(\x03R\x1cv2HighWaterBoostRecoverCoins\"\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" +
|
||||||
|
|||||||
@ -109,6 +109,13 @@ message LuckyGiftRuleConfig {
|
|||||||
int64 user_24h_rtp_threshold_ppm = 39;
|
int64 user_24h_rtp_threshold_ppm = 39;
|
||||||
// 触发用户 24 小时 RTP 降权后保留的普通中奖权重;1000000 表示保持原概率。
|
// 触发用户 24 小时 RTP 降权后保留的普通中奖权重;1000000 表示保持原概率。
|
||||||
int64 user_24h_ordinary_win_factor_ppm = 40;
|
int64 user_24h_ordinary_win_factor_ppm = 40;
|
||||||
|
// fixed_v2 高水位返奖加权:奖池水位越过阈值后中奖返奖按系数放大(RTP 等比放大),
|
||||||
|
// 滞回保持到水位跌破恢复线才退出。0 表示关闭;dynamic_v3 必须保持三个字段为 0。
|
||||||
|
int64 v2_high_water_boost_threshold_coins = 41;
|
||||||
|
// 加权系数 ppm;1500000 表示 RTP 变为原值的 150%。启用时必须大于 1000000。
|
||||||
|
int64 v2_high_water_boost_factor_ppm = 42;
|
||||||
|
// 恢复水位金币;水位低于该值后退出加权。启用时必须为正且不高于阈值。
|
||||||
|
int64 v2_high_water_boost_recover_coins = 43;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CheckLuckyGiftRequest {
|
message CheckLuckyGiftRequest {
|
||||||
|
|||||||
@ -69,6 +69,9 @@ type configRequest struct {
|
|||||||
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
|
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
|
||||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||||
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
|
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
|
||||||
|
V2HighWaterBoostThresholdCoins int64 `json:"v2_high_water_boost_threshold_coins"`
|
||||||
|
V2HighWaterBoostFactorPercent float64 `json:"v2_high_water_boost_factor_percent"`
|
||||||
|
V2HighWaterBoostRecoverCoins int64 `json:"v2_high_water_boost_recover_coins"`
|
||||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||||
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
|
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
|
||||||
User24HourRTPThresholdPercent float64 `json:"user_24h_rtp_threshold_percent"`
|
User24HourRTPThresholdPercent float64 `json:"user_24h_rtp_threshold_percent"`
|
||||||
@ -510,43 +513,46 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
|
|||||||
rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
|
rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
|
||||||
}
|
}
|
||||||
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,
|
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),
|
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
|
||||||
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
|
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,
|
InitialPoolCoins: initialPoolCoins,
|
||||||
LossStreakGuarantee: req.LossStreakGuarantee,
|
LossStreakGuarantee: req.LossStreakGuarantee,
|
||||||
LowWatermarkCoins: req.LowWatermarkCoins,
|
LowWatermarkCoins: req.LowWatermarkCoins,
|
||||||
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
|
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
|
||||||
HighWatermarkCoins: req.HighWatermarkCoins,
|
HighWatermarkCoins: req.HighWatermarkCoins,
|
||||||
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
|
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
|
||||||
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
|
V2HighWaterBoostThresholdCoins: req.V2HighWaterBoostThresholdCoins,
|
||||||
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
|
V2HighWaterBoostFactorPpm: percentToPPM(req.V2HighWaterBoostFactorPercent),
|
||||||
User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent),
|
V2HighWaterBoostRecoverCoins: req.V2HighWaterBoostRecoverCoins,
|
||||||
User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent),
|
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
|
||||||
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
|
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
|
||||||
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
|
User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent),
|
||||||
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
|
User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent),
|
||||||
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
|
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
|
||||||
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
|
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
|
||||||
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
|
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
|
||||||
MaxSinglePayout: req.MaxSinglePayout,
|
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
|
||||||
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
|
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
|
||||||
UserDailyPayoutCap: req.UserDailyPayoutCap,
|
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
|
||||||
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
|
MaxSinglePayout: req.MaxSinglePayout,
|
||||||
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
|
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
|
||||||
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
|
UserDailyPayoutCap: req.UserDailyPayoutCap,
|
||||||
Stages: stages,
|
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
|
||||||
|
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
|
||||||
|
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
|
||||||
|
Stages: stages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -611,6 +617,9 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
|
|||||||
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
|
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
|
||||||
HighWatermarkCoins: config.GetHighWatermarkCoins(),
|
HighWatermarkCoins: config.GetHighWatermarkCoins(),
|
||||||
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
|
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
|
||||||
|
V2HighWaterBoostThresholdCoins: config.GetV2HighWaterBoostThresholdCoins(),
|
||||||
|
V2HighWaterBoostFactorPercent: ppmToPercent(config.GetV2HighWaterBoostFactorPpm()),
|
||||||
|
V2HighWaterBoostRecoverCoins: config.GetV2HighWaterBoostRecoverCoins(),
|
||||||
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
|
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
|
||||||
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
|
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
|
||||||
User24HourRTPThresholdPercent: user24HourRTPThresholdPercent,
|
User24HourRTPThresholdPercent: user24HourRTPThresholdPercent,
|
||||||
|
|||||||
@ -97,6 +97,9 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
|
|||||||
low_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '低水位非零奖档概率因子,ppm',
|
low_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '低水位非零奖档概率因子,ppm',
|
||||||
high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '高水位阈值金币',
|
high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '高水位阈值金币',
|
||||||
high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '高水位非零奖档概率因子,ppm',
|
high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '高水位非零奖档概率因子,ppm',
|
||||||
|
v2_high_water_boost_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'fixed_v2 高水位返奖加权触发阈值金币;0表示关闭',
|
||||||
|
v2_high_water_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'fixed_v2 高水位返奖加权系数,ppm;1500000表示RTP放大到150%',
|
||||||
|
v2_high_water_boost_recover_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'fixed_v2 高水位加权恢复水位金币;水位低于该值退出加权',
|
||||||
recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒',
|
recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒',
|
||||||
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子,ppm',
|
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子,ppm',
|
||||||
user_24h_rtp_threshold_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '用户滚动24小时RTP降权触发线,ppm;0表示关闭',
|
user_24h_rtp_threshold_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '用户滚动24小时RTP降权触发线,ppm;0表示关闭',
|
||||||
@ -188,6 +191,7 @@ CREATE TABLE IF NOT EXISTS lucky_pools (
|
|||||||
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_credit_total BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工注资金币,不计入业务 total_in',
|
||||||
manual_debit_total BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工扣资金币,不计入业务 total_out',
|
manual_debit_total BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工扣资金币,不计入业务 total_out',
|
||||||
|
v2_boost_active TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'fixed_v2 高水位返奖加权滞回标志;1表示当前处于加权区间',
|
||||||
profit_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计平台盈利拆账金币',
|
profit_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计平台盈利拆账金币',
|
||||||
anchor_income_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 累计初始注资金币,只在首次物化时增加',
|
initial_seed_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计初始注资金币,只在首次物化时增加',
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_lucky_gift;
|
||||||
|
|
||||||
|
-- 性能边界:两张表都只增加常量默认列,强制 ALGORITHM=INSTANT,不扫描或回写任何历史行。
|
||||||
|
-- lucky_gift_rule_versions 是发布次数级的小配置表;lucky_pools 是奖池数量级的小账本表。
|
||||||
|
-- 若目标 MySQL 不支持即时 DDL 则直接失败,不能退化为 COPY;执行前仍需确认无长事务占用元数据锁。
|
||||||
|
|
||||||
|
-- fixed_v2 高水位返奖加权三元组:阈值 0 表示关闭,历史版本三列均为默认 0,语义不变。
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions'
|
||||||
|
AND COLUMN_NAME = 'v2_high_water_boost_threshold_coins') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN v2_high_water_boost_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''fixed_v2 高水位返奖加权触发阈值金币;0表示关闭'' AFTER high_water_nonzero_factor_ppm, ALGORITHM=INSTANT',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions'
|
||||||
|
AND COLUMN_NAME = 'v2_high_water_boost_factor_ppm') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN v2_high_water_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''fixed_v2 高水位返奖加权系数,ppm;1500000表示RTP放大到150%'' AFTER v2_high_water_boost_threshold_coins, ALGORITHM=INSTANT',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions'
|
||||||
|
AND COLUMN_NAME = 'v2_high_water_boost_recover_coins') = 0,
|
||||||
|
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN v2_high_water_boost_recover_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''fixed_v2 高水位加权恢复水位金币;水位低于该值退出加权'' AFTER v2_high_water_boost_factor_ppm, ALGORITHM=INSTANT',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 滞回标志属于奖池账本行:加权是否生效取决于历史轨迹(200w 触发后要跌破 100w 才恢复),
|
||||||
|
-- 不能仅凭当前余额推导。默认 0 表示未处于加权区间,下一次开奖会按最新水位重新评估。
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools'
|
||||||
|
AND COLUMN_NAME = 'v2_boost_active') = 0,
|
||||||
|
'ALTER TABLE lucky_pools ADD COLUMN v2_boost_active TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''fixed_v2 高水位返奖加权滞回标志;1表示当前处于加权区间'' AFTER manual_debit_total, ALGORITHM=INSTANT',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
@ -72,8 +72,13 @@ type Config struct {
|
|||||||
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
||||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||||
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
||||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
// V2HighWaterBoost* 只由 fixed_v2 解释:奖池水位越过阈值后,中奖返奖金额按系数加权(RTP 等比放大),
|
||||||
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
// 滞回保持到水位跌破恢复线才退出;阈值为 0 表示关闭。dynamic_v3 有独立水位因子,不读这三个字段。
|
||||||
|
V2HighWaterBoostThresholdCoins int64 `json:"v2_high_water_boost_threshold_coins"`
|
||||||
|
V2HighWaterBoostFactorPPM int64 `json:"v2_high_water_boost_factor_ppm"`
|
||||||
|
V2HighWaterBoostRecoverCoins int64 `json:"v2_high_water_boost_recover_coins"`
|
||||||
|
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||||
|
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
||||||
// User24HourRTPThresholdPPM/Factor 只调整普通非零奖档;大奖资格和保底仍由各自策略独立决定。
|
// User24HourRTPThresholdPPM/Factor 只调整普通非零奖档;大奖资格和保底仍由各自策略独立决定。
|
||||||
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
|
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
|
||||||
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
|
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
|
||||||
@ -150,8 +155,12 @@ type RuleConfig struct {
|
|||||||
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
||||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||||
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
||||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
// V2HighWaterBoost* 是 fixed_v2 专属的高水位返奖加权三元组;0/0/0 表示关闭(历史版本反序列化默认值)。
|
||||||
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
V2HighWaterBoostThresholdCoins int64 `json:"v2_high_water_boost_threshold_coins"`
|
||||||
|
V2HighWaterBoostFactorPPM int64 `json:"v2_high_water_boost_factor_ppm"`
|
||||||
|
V2HighWaterBoostRecoverCoins int64 `json:"v2_high_water_boost_recover_coins"`
|
||||||
|
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||||
|
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
||||||
// 0/100% 是历史兼容 sentinel;阈值大于 0 时,factor 表示触发后保留的普通中奖权重。
|
// 0/100% 是历史兼容 sentinel;阈值大于 0 时,factor 表示触发后保留的普通中奖权重。
|
||||||
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
|
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
|
||||||
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
|
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
|
||||||
|
|||||||
@ -197,10 +197,43 @@ func validateRuleConfig(config domain.RuleConfig) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strategyVersion == domain.StrategyDynamicV3 {
|
if strategyVersion == domain.StrategyDynamicV3 {
|
||||||
|
if config.V2HighWaterBoostThresholdCoins != 0 || config.V2HighWaterBoostFactorPPM != 0 || config.V2HighWaterBoostRecoverCoins != 0 {
|
||||||
|
// dynamic_v3 有独立的水位因子体系;混填 v2 加权字段只会造成“看似配置了却不生效”的误解。
|
||||||
|
return xerr.New(xerr.InvalidArgument, "v2 high-water boost fields are fixed_v2 only and must stay zero for dynamic_v3")
|
||||||
|
}
|
||||||
if err := validateDynamicRuleConfig(config, stageMap); err != nil {
|
if err := validateDynamicRuleConfig(config, stageMap); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strategyVersion == domain.StrategyFixedV2 {
|
||||||
|
if err := validateV2HighWaterBoost(config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateV2HighWaterBoost 校验 fixed_v2 高水位返奖加权三元组。
|
||||||
|
// 阈值为 0 表示关闭,此时另外两个字段必须同为 0,避免半配置发布后与运行时的关闭兜底语义不一致。
|
||||||
|
func validateV2HighWaterBoost(config domain.RuleConfig) error {
|
||||||
|
threshold := config.V2HighWaterBoostThresholdCoins
|
||||||
|
factor := config.V2HighWaterBoostFactorPPM
|
||||||
|
recoverLine := config.V2HighWaterBoostRecoverCoins
|
||||||
|
if threshold == 0 {
|
||||||
|
if factor != 0 || recoverLine != 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "disabled v2 high-water boost must keep factor and recover watermark at zero")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if threshold < 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "v2 high-water boost threshold cannot be negative")
|
||||||
|
}
|
||||||
|
if factor <= ppmScale || factor > 10*ppmScale {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "v2 high-water boost factor must exceed 100% and stay within 1000%")
|
||||||
|
}
|
||||||
|
if recoverLine <= 0 || recoverLine > threshold {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "v2 high-water boost recover watermark must be positive and no higher than the threshold")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -155,3 +155,78 @@ func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) {
|
|||||||
t.Fatalf("legacy empty strategy normalized to %q, want fixed_v2", normalized.StrategyVersion)
|
t.Fatalf("legacy empty strategy normalized to %q, want fixed_v2", normalized.StrategyVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func legacyFixedV2Config() domain.RuleConfig {
|
||||||
|
config := DefaultRuleConfig("lalu", "lucky")
|
||||||
|
config.StrategyVersion = domain.StrategyFixedV2
|
||||||
|
config.Enabled = true
|
||||||
|
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.JackpotUserRoundRTPMaxPPM = 0
|
||||||
|
config.JackpotUser48hRTPMaxPPM = 0
|
||||||
|
config.MaxJackpotHitsPerUserDay = 0
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateV2HighWaterBoostAcceptsHysteresisTriple(t *testing.T) {
|
||||||
|
config := legacyFixedV2Config()
|
||||||
|
config.V2HighWaterBoostThresholdCoins = 2_000_000
|
||||||
|
config.V2HighWaterBoostFactorPPM = 1_500_000
|
||||||
|
config.V2HighWaterBoostRecoverCoins = 1_000_000
|
||||||
|
if err := validateRuleConfig(config); err != nil {
|
||||||
|
t.Fatalf("valid v2 high-water boost rejected: %v", err)
|
||||||
|
}
|
||||||
|
// 恢复线等于阈值表示无滞回的普通开关语义,同样合法。
|
||||||
|
config.V2HighWaterBoostRecoverCoins = config.V2HighWaterBoostThresholdCoins
|
||||||
|
if err := validateRuleConfig(config); err != nil {
|
||||||
|
t.Fatalf("recover==threshold should validate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateV2HighWaterBoostRejectsPartialOrInvalidTriples(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
threshold int64
|
||||||
|
factor int64
|
||||||
|
recover int64
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "disabled must keep factor zero", threshold: 0, factor: 1_500_000, recover: 0, wantErr: "disabled"},
|
||||||
|
{name: "disabled must keep recover zero", threshold: 0, factor: 0, recover: 1_000_000, wantErr: "disabled"},
|
||||||
|
{name: "factor at 100% is meaningless", threshold: 2_000_000, factor: 1_000_000, recover: 1_000_000, wantErr: "factor"},
|
||||||
|
{name: "factor above 1000% is a fat-finger guard", threshold: 2_000_000, factor: 10_000_001, recover: 1_000_000, wantErr: "factor"},
|
||||||
|
{name: "recover must be positive", threshold: 2_000_000, factor: 1_500_000, recover: 0, wantErr: "recover"},
|
||||||
|
{name: "recover cannot exceed threshold", threshold: 2_000_000, factor: 1_500_000, recover: 2_000_001, wantErr: "recover"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
config := legacyFixedV2Config()
|
||||||
|
config.V2HighWaterBoostThresholdCoins = tc.threshold
|
||||||
|
config.V2HighWaterBoostFactorPPM = tc.factor
|
||||||
|
config.V2HighWaterBoostRecoverCoins = tc.recover
|
||||||
|
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("%s: error=%v, want containing %q", tc.name, err, tc.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDynamicV3RejectsV2HighWaterBoostFields(t *testing.T) {
|
||||||
|
config := DefaultRuleConfig("lalu", "lucky")
|
||||||
|
config.V2HighWaterBoostThresholdCoins = 2_000_000
|
||||||
|
config.V2HighWaterBoostFactorPPM = 1_500_000
|
||||||
|
config.V2HighWaterBoostRecoverCoins = 1_000_000
|
||||||
|
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "fixed_v2 only") {
|
||||||
|
t.Fatalf("dynamic_v3 with v2 boost fields error=%v, want fixed_v2-only rejection", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import (
|
|||||||
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// CreateExperiment 原子完成 treatment 发布与实验登记。treatment 配置必须直接可运行(enabled),
|
// CreateExperiment 原子完成 treatment 发布与实验登记。treatment 配置必须直接可运行(enabled),
|
||||||
// 否则实验组用户在送礼预检查就会被 RuleNotActive 拦截,实验退化成按人关停功能。
|
// 否则实验组用户在送礼预检查就会被 RuleNotActive 拦截,实验退化成按人关停功能。
|
||||||
func (s *Service) CreateExperiment(ctx context.Context, cmd domain.CreateExperimentCommand) (domain.Experiment, domain.RuleConfig, error) {
|
func (s *Service) CreateExperiment(ctx context.Context, cmd domain.CreateExperimentCommand) (domain.Experiment, domain.RuleConfig, error) {
|
||||||
|
|||||||
@ -274,6 +274,16 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
|
|||||||
if err := r.creditLuckyBasePool(ctx, tx, cmd.AppCode, config, cmd.TotalAmount, &basePool, nowMS); err != nil {
|
if err := r.creditLuckyBasePool(ctx, tx, cmd.AppCode, config, cmd.TotalAmount, &basePool, nowMS); err != nil {
|
||||||
return externalDrawCandidate{}, 0, err
|
return externalDrawCandidate{}, 0, err
|
||||||
}
|
}
|
||||||
|
// 外部 fixed_v2 与内部路径共享同一口基础池,也必须走同一套高水位加权滞回,否则两侧 RTP 口径分叉。
|
||||||
|
boostBefore := basePool.V2BoostActive
|
||||||
|
if advanceLuckyV2HighWaterBoost(config, &basePool) {
|
||||||
|
config = luckyV2BoostedConfig(config)
|
||||||
|
}
|
||||||
|
if basePool.V2BoostActive != boostBefore {
|
||||||
|
if err := r.persistLuckyPoolV2BoostFlag(ctx, tx, cmd.AppCode, luckyBasePoolScopeType, basePool.ScopeID, basePool.V2BoostActive, nowMS); err != nil {
|
||||||
|
return externalDrawCandidate{}, 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
candidate, _, err := r.selectLuckyCandidate(config, experiencePool, basePool)
|
candidate, _, err := r.selectLuckyCandidate(config, experiencePool, basePool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return externalDrawCandidate{}, 0, err
|
return externalDrawCandidate{}, 0, err
|
||||||
|
|||||||
@ -63,6 +63,9 @@ type luckyPool struct {
|
|||||||
TotalOut int64
|
TotalOut int64
|
||||||
ManualCreditTotal int64
|
ManualCreditTotal int64
|
||||||
ManualDebitTotal int64
|
ManualDebitTotal int64
|
||||||
|
// V2BoostActive 是 fixed_v2 高水位返奖加权的滞回标志:水位越过阈值置位,跌破恢复线才清零。
|
||||||
|
// 阈值区间内(恢复线~阈值)是否加权取决于历史轨迹,因此必须随奖池行持久化,不能按余额推导。
|
||||||
|
V2BoostActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p luckyPool) capacity() int64 {
|
func (p luckyPool) capacity() int64 {
|
||||||
@ -146,9 +149,11 @@ type luckyBatchDrawState struct {
|
|||||||
DrawIDs []string
|
DrawIDs []string
|
||||||
Records []luckyDrawRecordInput
|
Records []luckyDrawRecordInput
|
||||||
// Window/Pool 都是 FOR UPDATE 读出的当前行副本,循环内只改内存副本。
|
// Window/Pool 都是 FOR UPDATE 读出的当前行副本,循环内只改内存副本。
|
||||||
GlobalWindow luckyRTPWindow
|
GlobalWindow luckyRTPWindow
|
||||||
GiftWindow luckyRTPWindow
|
GiftWindow luckyRTPWindow
|
||||||
BasePool luckyPool
|
BasePool luckyPool
|
||||||
|
// V2BoostLoaded 记录加锁读出的滞回标志原值;批量结束时与内存最终值比较,翻转才写回。
|
||||||
|
V2BoostLoaded bool
|
||||||
AggregateResult domain.DrawResult
|
AggregateResult domain.DrawResult
|
||||||
AggregateStatus string
|
AggregateStatus string
|
||||||
AggregateCommandID string
|
AggregateCommandID string
|
||||||
@ -372,6 +377,7 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx
|
|||||||
GlobalWindow: globalWindow,
|
GlobalWindow: globalWindow,
|
||||||
GiftWindow: giftWindow,
|
GiftWindow: giftWindow,
|
||||||
BasePool: basePool,
|
BasePool: basePool,
|
||||||
|
V2BoostLoaded: basePool.V2BoostActive,
|
||||||
FinalLossStreak: userState.LossStreak,
|
FinalLossStreak: userState.LossStreak,
|
||||||
FinalCumulative: userState.CumulativeWagerCoins,
|
FinalCumulative: userState.CumulativeWagerCoins,
|
||||||
FinalEquivalent: userState.EquivalentDraws,
|
FinalEquivalent: userState.EquivalentDraws,
|
||||||
@ -429,11 +435,21 @@ func (r *Repository) stageLuckyBatchDraw(appCode string, baseConfig domain.Confi
|
|||||||
creditLuckyPoolInMemory(&state.BasePool, baseIn)
|
creditLuckyPoolInMemory(&state.BasePool, baseIn)
|
||||||
state.BaseIn += baseIn
|
state.BaseIn += baseIn
|
||||||
|
|
||||||
|
// fixed_v2 高水位加权按子抽粒度评估:入池后的最新内存水位推进滞回状态,命中后放大本抽中奖返奖。
|
||||||
|
boosted := advanceLuckyV2HighWaterBoost(config, &state.BasePool)
|
||||||
|
if boosted {
|
||||||
|
config = luckyV2BoostedConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
// 候选过滤使用已经被本批前序子抽消耗过的内存池,避免批量同时命中超过容量的高倍奖。
|
// 候选过滤使用已经被本批前序子抽消耗过的内存池,避免批量同时命中超过容量的高倍奖。
|
||||||
candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, state.BasePool)
|
candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, state.BasePool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
|
if boosted {
|
||||||
|
// 审计标记进入 candidate_tiers_json.limited,复盘时能区分“原始档位”与“高水位加权后的档位”。
|
||||||
|
limited["v2_high_water_boost"] = true
|
||||||
|
}
|
||||||
// 随机后只落事实,不再改候选;这里扣减内存容量,让后续子抽立即感知本抽成本。
|
// 随机后只落事实,不再改候选;这里扣减内存容量,让后续子抽立即感知本抽成本。
|
||||||
debitLuckyPoolInMemory(&state.BasePool, candidate.BaseReward)
|
debitLuckyPoolInMemory(&state.BasePool, candidate.BaseReward)
|
||||||
state.BaseOut += candidate.BaseReward
|
state.BaseOut += candidate.BaseReward
|
||||||
@ -528,6 +544,12 @@ func (r *Repository) persistLuckyBatchDraw(ctx context.Context, tx *sql.Tx, appC
|
|||||||
if err := r.updateLuckyPoolNet(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), state.BaseIn, state.BaseOut, nowMS); err != nil {
|
if err := r.updateLuckyPoolNet(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), state.BaseIn, state.BaseOut, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if state.BasePool.V2BoostActive != state.V2BoostLoaded {
|
||||||
|
// 滞回标志只在翻转时写回;批量内多次翻转只需要最终值,中间轨迹已固化在每条 draw record 的审计快照里。
|
||||||
|
if err := r.persistLuckyPoolV2BoostFlag(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), state.BasePool.V2BoostActive, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
// 用户体验状态只取本批最终值;中间值已经体现在每条 draw record 的 experience_pool 快照里。
|
// 用户体验状态只取本批最终值;中间值已经体现在每条 draw record 的 experience_pool 快照里。
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE lucky_user_states
|
UPDATE lucky_user_states
|
||||||
@ -1194,11 +1216,26 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx,
|
|||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fixed_v2 高水位加权:入池后的最新水位推进滞回状态,命中后放大本抽中奖返奖;标志翻转立即落库。
|
||||||
|
boostBefore := basePool.V2BoostActive
|
||||||
|
boosted := advanceLuckyV2HighWaterBoost(config, &basePool)
|
||||||
|
if boosted {
|
||||||
|
config = luckyV2BoostedConfig(config)
|
||||||
|
}
|
||||||
|
if basePool.V2BoostActive != boostBefore {
|
||||||
|
if err := r.persistLuckyPoolV2BoostFlag(ctx, tx, appCode, luckyBasePoolScopeType, basePool.ScopeID, basePool.V2BoostActive, nowMS); err != nil {
|
||||||
|
return domain.DrawResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 所有过滤都发生在随机前;随机后只能落事实和副作用 outbox,不能再改成另一个结果。
|
// 所有过滤都发生在随机前;随机后只能落事实和副作用 outbox,不能再改成另一个结果。
|
||||||
candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, basePool)
|
candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, basePool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
|
if boosted {
|
||||||
|
limited["v2_high_water_boost"] = true
|
||||||
|
}
|
||||||
|
|
||||||
drawID := idgen.New("lucky_draw")
|
drawID := idgen.New("lucky_draw")
|
||||||
rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback)
|
rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback)
|
||||||
@ -2387,6 +2424,9 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
|
|||||||
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
|
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
|
||||||
HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
|
HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
|
||||||
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
|
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
|
||||||
|
V2HighWaterBoostThresholdCoins: ruleConfig.V2HighWaterBoostThresholdCoins,
|
||||||
|
V2HighWaterBoostFactorPPM: ruleConfig.V2HighWaterBoostFactorPPM,
|
||||||
|
V2HighWaterBoostRecoverCoins: ruleConfig.V2HighWaterBoostRecoverCoins,
|
||||||
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
|
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
|
||||||
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
|
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
|
||||||
User24HourRTPThresholdPPM: ruleConfig.User24HourRTPThresholdPPM,
|
User24HourRTPThresholdPPM: ruleConfig.User24HourRTPThresholdPPM,
|
||||||
@ -2657,16 +2697,66 @@ func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCo
|
|||||||
var pool luckyPool
|
var pool luckyPool
|
||||||
err := tx.QueryRowContext(ctx, `
|
err := tx.QueryRowContext(ctx, `
|
||||||
SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out,
|
||||||
manual_credit_total, manual_debit_total
|
manual_credit_total, manual_debit_total, v2_boost_active
|
||||||
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,
|
).Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut,
|
||||||
&pool.ManualCreditTotal, &pool.ManualDebitTotal)
|
&pool.ManualCreditTotal, &pool.ManualDebitTotal, &pool.V2BoostActive)
|
||||||
return pool, err
|
return pool, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// advanceLuckyV2HighWaterBoost 推进 fixed_v2 高水位返奖加权的滞回状态并返回本抽是否加权。
|
||||||
|
// 语义:水位(含本抽入池)越过阈值进入加权;进入后保持,直到水位跌破恢复线才退出。
|
||||||
|
// 配置不合法(阈值/系数/恢复线任一无效)时视为关闭且强制清零滞回标志,发布校验是第一道防线,这里兜底。
|
||||||
|
func advanceLuckyV2HighWaterBoost(config domain.Config, pool *luckyPool) bool {
|
||||||
|
threshold := config.V2HighWaterBoostThresholdCoins
|
||||||
|
factor := config.V2HighWaterBoostFactorPPM
|
||||||
|
recoverLine := config.V2HighWaterBoostRecoverCoins
|
||||||
|
if threshold <= 0 || factor <= luckyPPMScale || recoverLine <= 0 || recoverLine > threshold {
|
||||||
|
pool.V2BoostActive = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if pool.V2BoostActive {
|
||||||
|
if pool.Balance < recoverLine {
|
||||||
|
pool.V2BoostActive = false
|
||||||
|
}
|
||||||
|
} else if pool.Balance > threshold {
|
||||||
|
pool.V2BoostActive = true
|
||||||
|
}
|
||||||
|
return pool.V2BoostActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// luckyV2BoostedConfig 返回中奖档返奖金额与倍率都乘以加权系数的配置副本,实现 RTP 等比放大。
|
||||||
|
// 只放大金额、不改概率:概率加权在非零概率接近 100% 的表上会立即饱和,无法达到 150% 的目标倍数。
|
||||||
|
// 零倍档金额本就是 0,乘法天然跳过;候选的奖池支付能力过滤发生在放大之后,加权奖同样不能透支。
|
||||||
|
func luckyV2BoostedConfig(config domain.Config) domain.Config {
|
||||||
|
factor := config.V2HighWaterBoostFactorPPM
|
||||||
|
if factor <= luckyPPMScale {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
tiers := make([]domain.Tier, 0, len(config.Tiers))
|
||||||
|
for _, tier := range config.Tiers {
|
||||||
|
tier.RewardCoins = tier.RewardCoins * factor / luckyPPMScale
|
||||||
|
tier.MultiplierPPM = tier.MultiplierPPM * factor / luckyPPMScale
|
||||||
|
tiers = append(tiers, tier)
|
||||||
|
}
|
||||||
|
config.Tiers = tiers
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistLuckyPoolV2BoostFlag 只在滞回标志翻转时调用;奖池行已被本事务 FOR UPDATE 锁定。
|
||||||
|
func (r *Repository) persistLuckyPoolV2BoostFlag(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, active bool, nowMS int64) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_pools
|
||||||
|
SET v2_boost_active = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
|
||||||
|
active, nowMS, appCode, scopeType, scopeID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
// 单池模型里 pool_rate 的全部入池金额都归属 app+pool_id 这一口基础池;不再为房间或礼物拆责任份额。
|
// 单池模型里 pool_rate 的全部入池金额都归属 app+pool_id 这一口基础池;不再为房间或礼物拆责任份额。
|
||||||
return r.creditLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), luckyBasePoolIn(config, spent), pool, nowMS)
|
return r.creditLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), luckyBasePoolIn(config, spent), pool, nowMS)
|
||||||
|
|||||||
@ -122,13 +122,14 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
|
|||||||
initial_pool_coins, loss_streak_guarantee,
|
initial_pool_coins, loss_streak_guarantee,
|
||||||
low_watermark_coins, low_water_nonzero_factor_ppm,
|
low_watermark_coins, low_water_nonzero_factor_ppm,
|
||||||
high_watermark_coins, high_water_nonzero_factor_ppm,
|
high_watermark_coins, high_water_nonzero_factor_ppm,
|
||||||
|
v2_high_water_boost_threshold_coins, v2_high_water_boost_factor_ppm, v2_high_water_boost_recover_coins,
|
||||||
recharge_boost_window_ms, recharge_boost_factor_ppm,
|
recharge_boost_window_ms, recharge_boost_factor_ppm,
|
||||||
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
|
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
|
||||||
jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
|
jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
|
||||||
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
|
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
|
||||||
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||||
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
|
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
|
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
|
||||||
config.ProfitRatePPM, config.AnchorRatePPM,
|
config.ProfitRatePPM, config.AnchorRatePPM,
|
||||||
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
|
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
|
||||||
@ -137,6 +138,7 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
|
|||||||
config.InitialPoolCoins, config.LossStreakGuarantee,
|
config.InitialPoolCoins, config.LossStreakGuarantee,
|
||||||
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
|
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
|
||||||
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
|
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
|
||||||
|
config.V2HighWaterBoostThresholdCoins, config.V2HighWaterBoostFactorPPM, config.V2HighWaterBoostRecoverCoins,
|
||||||
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM,
|
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM,
|
||||||
config.User24HourRTPThresholdPPM, config.User24HourOrdinaryWinFactorPPM, jackpotMultiplierPPMsJSON,
|
config.User24HourRTPThresholdPPM, config.User24HourOrdinaryWinFactorPPM, jackpotMultiplierPPMsJSON,
|
||||||
config.JackpotGlobalRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
|
config.JackpotGlobalRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
|
||||||
@ -200,6 +202,7 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
|
|||||||
v.initial_pool_coins, v.loss_streak_guarantee,
|
v.initial_pool_coins, v.loss_streak_guarantee,
|
||||||
v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
|
v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
|
||||||
v.high_watermark_coins, v.high_water_nonzero_factor_ppm,
|
v.high_watermark_coins, v.high_water_nonzero_factor_ppm,
|
||||||
|
v.v2_high_water_boost_threshold_coins, v.v2_high_water_boost_factor_ppm, v.v2_high_water_boost_recover_coins,
|
||||||
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm,
|
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm,
|
||||||
v.user_24h_rtp_threshold_ppm, v.user_24h_ordinary_win_factor_ppm, v.jackpot_multiplier_ppms,
|
v.user_24h_rtp_threshold_ppm, v.user_24h_ordinary_win_factor_ppm, v.jackpot_multiplier_ppms,
|
||||||
v.jackpot_global_rtp_max_ppm, v.jackpot_user_round_rtp_max_ppm, v.jackpot_user_48h_rtp_max_ppm,
|
v.jackpot_global_rtp_max_ppm, v.jackpot_user_round_rtp_max_ppm, v.jackpot_user_48h_rtp_max_ppm,
|
||||||
@ -245,6 +248,7 @@ const luckyRuleConfigSelectSQL = `
|
|||||||
initial_pool_coins, loss_streak_guarantee,
|
initial_pool_coins, loss_streak_guarantee,
|
||||||
low_watermark_coins, low_water_nonzero_factor_ppm,
|
low_watermark_coins, low_water_nonzero_factor_ppm,
|
||||||
high_watermark_coins, high_water_nonzero_factor_ppm,
|
high_watermark_coins, high_water_nonzero_factor_ppm,
|
||||||
|
v2_high_water_boost_threshold_coins, v2_high_water_boost_factor_ppm, v2_high_water_boost_recover_coins,
|
||||||
recharge_boost_window_ms, recharge_boost_factor_ppm,
|
recharge_boost_window_ms, recharge_boost_factor_ppm,
|
||||||
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
|
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
|
||||||
jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
|
jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
|
||||||
@ -377,6 +381,9 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
|
|||||||
&config.LowWaterNonzeroFactorPPM,
|
&config.LowWaterNonzeroFactorPPM,
|
||||||
&config.HighWatermarkCoins,
|
&config.HighWatermarkCoins,
|
||||||
&config.HighWaterNonzeroFactorPPM,
|
&config.HighWaterNonzeroFactorPPM,
|
||||||
|
&config.V2HighWaterBoostThresholdCoins,
|
||||||
|
&config.V2HighWaterBoostFactorPPM,
|
||||||
|
&config.V2HighWaterBoostRecoverCoins,
|
||||||
&config.RechargeBoostWindowMS,
|
&config.RechargeBoostWindowMS,
|
||||||
&config.RechargeBoostFactorPPM,
|
&config.RechargeBoostFactorPPM,
|
||||||
&config.User24HourRTPThresholdPPM,
|
&config.User24HourRTPThresholdPPM,
|
||||||
|
|||||||
@ -0,0 +1,244 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hyapp/internal/testutil/mysqlschema"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
func v2BoostConfig(threshold, factor, recoverLine int64) domain.Config {
|
||||||
|
return domain.Config{
|
||||||
|
V2HighWaterBoostThresholdCoins: threshold,
|
||||||
|
V2HighWaterBoostFactorPPM: factor,
|
||||||
|
V2HighWaterBoostRecoverCoins: recoverLine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvanceLuckyV2HighWaterBoostHysteresis(t *testing.T) {
|
||||||
|
config := v2BoostConfig(2_000_000, 1_500_000, 1_000_000)
|
||||||
|
pool := luckyPool{Balance: 1_500_000}
|
||||||
|
|
||||||
|
// 阈值以下且未触发:不加权。
|
||||||
|
if advanceLuckyV2HighWaterBoost(config, &pool) {
|
||||||
|
t.Fatalf("balance below threshold must not boost")
|
||||||
|
}
|
||||||
|
// 越过阈值:进入加权。
|
||||||
|
pool.Balance = 2_000_001
|
||||||
|
if !advanceLuckyV2HighWaterBoost(config, &pool) {
|
||||||
|
t.Fatalf("balance above threshold must boost")
|
||||||
|
}
|
||||||
|
// 恰好等于阈值不触发(严格大于语义),但已触发状态在恢复线之上必须保持。
|
||||||
|
pool.Balance = 1_200_000
|
||||||
|
if !advanceLuckyV2HighWaterBoost(config, &pool) {
|
||||||
|
t.Fatalf("hysteresis must keep boosting between recover line and threshold")
|
||||||
|
}
|
||||||
|
// 恰好等于恢复线仍保持(严格小于才退出)。
|
||||||
|
pool.Balance = 1_000_000
|
||||||
|
if !advanceLuckyV2HighWaterBoost(config, &pool) {
|
||||||
|
t.Fatalf("balance at recover line must keep boosting; exit is strictly below")
|
||||||
|
}
|
||||||
|
// 跌破恢复线:退出加权。
|
||||||
|
pool.Balance = 999_999
|
||||||
|
if advanceLuckyV2HighWaterBoost(config, &pool) {
|
||||||
|
t.Fatalf("balance below recover line must stop boosting")
|
||||||
|
}
|
||||||
|
// 退出后回到中间区间不重新触发,必须再次越过阈值。
|
||||||
|
pool.Balance = 1_800_000
|
||||||
|
if advanceLuckyV2HighWaterBoost(config, &pool) {
|
||||||
|
t.Fatalf("after exit the middle band must stay normal until threshold is crossed again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvanceLuckyV2HighWaterBoostDisabledOrInvalidConfigClearsFlag(t *testing.T) {
|
||||||
|
// 高水位余额 + 脏滞回标志:关闭或非法配置都必须清零标志并停止加权,避免规则回滚后残留放大。
|
||||||
|
cases := []domain.Config{
|
||||||
|
v2BoostConfig(0, 0, 0),
|
||||||
|
v2BoostConfig(2_000_000, 1_000_000, 1_000_000), // 系数不足 100% 视为非法
|
||||||
|
v2BoostConfig(2_000_000, 1_500_000, 0), // 恢复线缺失
|
||||||
|
v2BoostConfig(2_000_000, 1_500_000, 3_000_000), // 恢复线高于阈值
|
||||||
|
}
|
||||||
|
for index, config := range cases {
|
||||||
|
pool := luckyPool{Balance: 5_000_000, V2BoostActive: true}
|
||||||
|
if advanceLuckyV2HighWaterBoost(config, &pool) || pool.V2BoostActive {
|
||||||
|
t.Fatalf("case %d: invalid/disabled config must clear the sticky flag", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuckyV2BoostedConfigScalesRewardAndMultiplierOnly(t *testing.T) {
|
||||||
|
config := domain.Config{
|
||||||
|
GiftPrice: 100,
|
||||||
|
V2HighWaterBoostFactorPPM: 1_500_000,
|
||||||
|
Tiers: []domain.Tier{
|
||||||
|
{TierID: "zero", MultiplierPPM: 0, RewardCoins: 0, Weight: 13_308, Enabled: true},
|
||||||
|
{TierID: "1_2x", MultiplierPPM: 1_200_000, RewardCoins: 120, Weight: 66_664, Enabled: true},
|
||||||
|
{TierID: "5x", MultiplierPPM: 5_000_000, RewardCoins: 500, Weight: 15_999, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
boosted := luckyV2BoostedConfig(config)
|
||||||
|
if boosted.Tiers[0].RewardCoins != 0 || boosted.Tiers[0].MultiplierPPM != 0 {
|
||||||
|
t.Fatalf("zero tier must stay zero: %+v", boosted.Tiers[0])
|
||||||
|
}
|
||||||
|
if boosted.Tiers[1].RewardCoins != 180 || boosted.Tiers[1].MultiplierPPM != 1_800_000 {
|
||||||
|
t.Fatalf("1.2x tier should scale to 1.8x/180: %+v", boosted.Tiers[1])
|
||||||
|
}
|
||||||
|
if boosted.Tiers[2].RewardCoins != 750 || boosted.Tiers[2].MultiplierPPM != 7_500_000 {
|
||||||
|
t.Fatalf("5x tier should scale to 7.5x/750: %+v", boosted.Tiers[2])
|
||||||
|
}
|
||||||
|
// 概率权重与原配置保持一致:加权只放大返奖金额,不改变中奖概率。
|
||||||
|
for index := range config.Tiers {
|
||||||
|
if boosted.Tiers[index].Weight != config.Tiers[index].Weight {
|
||||||
|
t.Fatalf("tier %d weight changed: %d -> %d", index, config.Tiers[index].Weight, boosted.Tiers[index].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 原配置不能被原地修改。
|
||||||
|
if config.Tiers[1].RewardCoins != 120 {
|
||||||
|
t.Fatalf("input config was mutated: %+v", config.Tiers[1])
|
||||||
|
}
|
||||||
|
// 未启用系数时原样返回。
|
||||||
|
config.V2HighWaterBoostFactorPPM = 0
|
||||||
|
if unchanged := luckyV2BoostedConfig(config); unchanged.Tiers[1].RewardCoins != 120 {
|
||||||
|
t.Fatalf("disabled factor must return config unchanged: %+v", unchanged.Tiers[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真库端到端:发布带高水位加权的 fixed_v2 规则,抽奖路径必须按水位推进滞回并放大返奖,
|
||||||
|
// 滞回标志随 lucky_pools 行持久化,跨请求可见。
|
||||||
|
func TestExecuteLuckyGiftDrawAppliesV2HighWaterBoostWithHysteresis(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_v2boost",
|
||||||
|
})
|
||||||
|
|
||||||
|
repo := &Repository{db: schema.DB}
|
||||||
|
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||||
|
// 零档权重 0 + 单一 2x 档权重 100%:抽奖结果确定为 2x,测试只观察加权对金额的影响。
|
||||||
|
rule := domain.RuleConfig{
|
||||||
|
PoolID: "lucky",
|
||||||
|
StrategyVersion: domain.StrategyFixedV2,
|
||||||
|
Enabled: true,
|
||||||
|
TargetRTPPPM: 950_000,
|
||||||
|
PoolRatePPM: 980_000,
|
||||||
|
SettlementWindowWager: 1_000_000,
|
||||||
|
ControlBandPPM: 10_000,
|
||||||
|
GiftPriceReference: 100,
|
||||||
|
NoviceMaxEquivalentDraws: 2_000,
|
||||||
|
NormalMaxEquivalentDraws: 20_000,
|
||||||
|
V2HighWaterBoostThresholdCoins: 500_000,
|
||||||
|
V2HighWaterBoostFactorPPM: 1_500_000,
|
||||||
|
V2HighWaterBoostRecoverCoins: 400_000,
|
||||||
|
Stages: []domain.RuleStage{
|
||||||
|
{Stage: domain.StageNovice, Tiers: []domain.RuleTier{
|
||||||
|
{Stage: domain.StageNovice, TierID: "novice_none", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
|
||||||
|
{Stage: domain.StageNovice, TierID: "novice_2x", MultiplierPPM: 2_000_000, BaseWeightPPM: 1_000_000, Enabled: true},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, 1_700_000_000_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish v2 boost rule: %v", err)
|
||||||
|
}
|
||||||
|
loaded, exists, err := repo.GetLuckyGiftRuleConfig(ctx, "lucky")
|
||||||
|
if err != nil || !exists {
|
||||||
|
t.Fatalf("read back rule: exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
if loaded.V2HighWaterBoostThresholdCoins != 500_000 || loaded.V2HighWaterBoostFactorPPM != 1_500_000 || loaded.V2HighWaterBoostRecoverCoins != 400_000 {
|
||||||
|
t.Fatalf("v2 boost triple did not round-trip: %+v", loaded)
|
||||||
|
}
|
||||||
|
if published.RuleVersion != loaded.RuleVersion {
|
||||||
|
t.Fatalf("latest rule version mismatch: published=%d loaded=%d", published.RuleVersion, loaded.RuleVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
setBalance := func(balance int64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := schema.DB.Exec(`
|
||||||
|
UPDATE lucky_pools SET balance = ?
|
||||||
|
WHERE app_code='lalu' AND scope_type=? AND scope_id='lucky'`, balance, luckyBasePoolScopeType); err != nil {
|
||||||
|
t.Fatalf("set pool balance: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boostFlag := func() bool {
|
||||||
|
t.Helper()
|
||||||
|
var active bool
|
||||||
|
if err := schema.DB.QueryRow(`
|
||||||
|
SELECT v2_boost_active FROM lucky_pools
|
||||||
|
WHERE app_code='lalu' AND scope_type=? AND scope_id='lucky'`, luckyBasePoolScopeType).Scan(&active); err != nil {
|
||||||
|
t.Fatalf("read boost flag: %v", err)
|
||||||
|
}
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
draw := func(commandID string) domain.DrawResult {
|
||||||
|
t.Helper()
|
||||||
|
result, err := repo.ExecuteLuckyGiftDraw(ctx, domain.DrawCommand{
|
||||||
|
CommandID: commandID, PoolID: "lucky", UserID: 9001, RoomID: "room-1",
|
||||||
|
GiftID: "lucky", GiftCount: 1, CoinSpent: 100, PaidAtMS: 1_700_000_000_000,
|
||||||
|
}, 1_700_000_000_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("draw %s: %v", commandID, err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首抽物化奖池;默认初始水位 382,500 在阈值之上,先压回阈值下方再验证基线。
|
||||||
|
first := draw("cmd-materialize")
|
||||||
|
if first.SelectedTierID != "novice_2x" {
|
||||||
|
t.Fatalf("deterministic table must select novice_2x, got %+v", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基线:水位低于阈值,2x 原价返奖。
|
||||||
|
setBalance(100_000)
|
||||||
|
if result := draw("cmd-normal"); result.EffectiveRewardCoins != 200 || result.MultiplierPPM != 2_000_000 {
|
||||||
|
t.Fatalf("below threshold draw = %d coins %d ppm, want 200/2000000", result.EffectiveRewardCoins, result.MultiplierPPM)
|
||||||
|
}
|
||||||
|
if boostFlag() {
|
||||||
|
t.Fatalf("boost flag must stay clear below threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 越过阈值:返奖放大 150%(2x -> 3x),滞回标志落库。
|
||||||
|
setBalance(600_000)
|
||||||
|
if result := draw("cmd-boosted"); result.EffectiveRewardCoins != 300 || result.MultiplierPPM != 3_000_000 {
|
||||||
|
t.Fatalf("boosted draw = %d coins %d ppm, want 300/3000000", result.EffectiveRewardCoins, result.MultiplierPPM)
|
||||||
|
}
|
||||||
|
if !boostFlag() {
|
||||||
|
t.Fatalf("boost flag must persist after crossing threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阈值与恢复线之间:滞回保持加权。
|
||||||
|
setBalance(450_000)
|
||||||
|
if result := draw("cmd-hysteresis"); result.EffectiveRewardCoins != 300 {
|
||||||
|
t.Fatalf("hysteresis draw = %d coins, want 300", result.EffectiveRewardCoins)
|
||||||
|
}
|
||||||
|
if !boostFlag() {
|
||||||
|
t.Fatalf("boost flag must survive between recover line and threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跌破恢复线:退出加权并清零标志。
|
||||||
|
setBalance(300_000)
|
||||||
|
if result := draw("cmd-recovered"); result.EffectiveRewardCoins != 200 || result.MultiplierPPM != 2_000_000 {
|
||||||
|
t.Fatalf("recovered draw = %d coins %d ppm, want 200/2000000", result.EffectiveRewardCoins, result.MultiplierPPM)
|
||||||
|
}
|
||||||
|
if boostFlag() {
|
||||||
|
t.Fatalf("boost flag must clear after falling below recover line")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量路径共享同一套滞回与放大逻辑:高水位下 3 连抽每抽都是 3x。
|
||||||
|
setBalance(600_000)
|
||||||
|
batch, err := repo.ExecuteLuckyGiftDraw(ctx, domain.DrawCommand{
|
||||||
|
CommandID: "cmd-batch", PoolID: "lucky", UserID: 9001, RoomID: "room-1",
|
||||||
|
GiftID: "lucky", GiftCount: 3, CoinSpent: 300, PaidAtMS: 1_700_000_000_000,
|
||||||
|
}, 1_700_000_000_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch draw: %v", err)
|
||||||
|
}
|
||||||
|
if batch.EffectiveRewardCoins != 900 {
|
||||||
|
t.Fatalf("boosted batch total = %d coins, want 900 (3 draws x 100 x 3x)", batch.EffectiveRewardCoins)
|
||||||
|
}
|
||||||
|
if !boostFlag() {
|
||||||
|
t.Fatalf("boost flag must persist after the batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -555,6 +555,9 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
|
|||||||
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
|
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
|
||||||
HighWatermarkCoins: config.GetHighWatermarkCoins(),
|
HighWatermarkCoins: config.GetHighWatermarkCoins(),
|
||||||
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
|
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
|
||||||
|
V2HighWaterBoostThresholdCoins: config.GetV2HighWaterBoostThresholdCoins(),
|
||||||
|
V2HighWaterBoostFactorPPM: config.GetV2HighWaterBoostFactorPpm(),
|
||||||
|
V2HighWaterBoostRecoverCoins: config.GetV2HighWaterBoostRecoverCoins(),
|
||||||
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
|
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
|
||||||
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
|
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
|
||||||
User24HourRTPThresholdPPM: config.GetUser_24HRtpThresholdPpm(),
|
User24HourRTPThresholdPPM: config.GetUser_24HRtpThresholdPpm(),
|
||||||
@ -605,46 +608,49 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return &luckygiftv1.LuckyGiftRuleConfig{
|
return &luckygiftv1.LuckyGiftRuleConfig{
|
||||||
AppCode: config.AppCode,
|
AppCode: config.AppCode,
|
||||||
PoolId: config.PoolID,
|
PoolId: config.PoolID,
|
||||||
RuleVersion: config.RuleVersion,
|
RuleVersion: config.RuleVersion,
|
||||||
Enabled: config.Enabled,
|
Enabled: config.Enabled,
|
||||||
TargetRtpPpm: config.TargetRTPPPM,
|
TargetRtpPpm: config.TargetRTPPPM,
|
||||||
PoolRatePpm: config.PoolRatePPM,
|
PoolRatePpm: config.PoolRatePPM,
|
||||||
SettlementWindowWager: config.SettlementWindowWager,
|
SettlementWindowWager: config.SettlementWindowWager,
|
||||||
ControlBandPpm: config.ControlBandPPM,
|
ControlBandPpm: config.ControlBandPPM,
|
||||||
GiftPriceReference: config.GiftPriceReference,
|
GiftPriceReference: config.GiftPriceReference,
|
||||||
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
|
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
|
||||||
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
|
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
|
||||||
EffectiveFromMs: config.EffectiveFromMS,
|
EffectiveFromMs: config.EffectiveFromMS,
|
||||||
CreatedByAdminId: config.CreatedByAdminID,
|
CreatedByAdminId: config.CreatedByAdminID,
|
||||||
CreatedAtMs: config.CreatedAtMS,
|
CreatedAtMs: config.CreatedAtMS,
|
||||||
StrategyVersion: config.StrategyVersion,
|
StrategyVersion: config.StrategyVersion,
|
||||||
ProfitRatePpm: config.ProfitRatePPM,
|
ProfitRatePpm: config.ProfitRatePPM,
|
||||||
AnchorRatePpm: config.AnchorRatePPM,
|
AnchorRatePpm: config.AnchorRatePPM,
|
||||||
InitialPoolCoins: config.InitialPoolCoins,
|
InitialPoolCoins: config.InitialPoolCoins,
|
||||||
LossStreakGuarantee: config.LossStreakGuarantee,
|
LossStreakGuarantee: config.LossStreakGuarantee,
|
||||||
LowWatermarkCoins: config.LowWatermarkCoins,
|
LowWatermarkCoins: config.LowWatermarkCoins,
|
||||||
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
|
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
|
||||||
HighWatermarkCoins: config.HighWatermarkCoins,
|
HighWatermarkCoins: config.HighWatermarkCoins,
|
||||||
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
|
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
|
||||||
RechargeBoostWindowMs: config.RechargeBoostWindowMS,
|
V2HighWaterBoostThresholdCoins: config.V2HighWaterBoostThresholdCoins,
|
||||||
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
|
V2HighWaterBoostFactorPpm: config.V2HighWaterBoostFactorPPM,
|
||||||
User_24HRtpThresholdPpm: config.User24HourRTPThresholdPPM,
|
V2HighWaterBoostRecoverCoins: config.V2HighWaterBoostRecoverCoins,
|
||||||
User_24HOrdinaryWinFactorPpm: config.User24HourOrdinaryWinFactorPPM,
|
RechargeBoostWindowMs: config.RechargeBoostWindowMS,
|
||||||
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
|
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
|
||||||
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
|
User_24HRtpThresholdPpm: config.User24HourRTPThresholdPPM,
|
||||||
JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM,
|
User_24HOrdinaryWinFactorPpm: config.User24HourOrdinaryWinFactorPPM,
|
||||||
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
|
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
|
||||||
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
|
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
|
||||||
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
|
JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM,
|
||||||
MaxSinglePayout: config.MaxSinglePayout,
|
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
|
||||||
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
|
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
|
||||||
UserDailyPayoutCap: config.UserDailyPayoutCap,
|
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
|
||||||
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
|
MaxSinglePayout: config.MaxSinglePayout,
|
||||||
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
|
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
|
||||||
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
|
UserDailyPayoutCap: config.UserDailyPayoutCap,
|
||||||
Stages: stages,
|
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
|
||||||
|
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
|
||||||
|
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
|
||||||
|
Stages: stages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user