From 5da1421fae40aad3d70b5a632f35451cd2483560 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 17 Jul 2026 01:35:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B9=B8=E8=BF=90=E7=A4=BC=E7=89=A9=20fixed=5F?= =?UTF-8?q?v2=20=E9=AB=98=E6=B0=B4=E4=BD=8D=E8=BF=94=E5=A5=96=E5=8A=A0?= =?UTF-8?q?=E6=9D=83=EF=BC=9A=E9=98=88=E5=80=BC/=E7=B3=BB=E6=95=B0/?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=B0=B4=E4=BD=8D=E4=B8=89=E5=85=83=E7=BB=84?= =?UTF-8?q?=E4=B8=8E=E5=A5=96=E6=B1=A0=E6=BB=9E=E5=9B=9E=E6=A0=87=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/launch.json | 7 + api/proto/luckygift/v1/luckygift.pb.go | 35 ++- api/proto/luckygift/v1/luckygift.proto | 7 + .../internal/modules/luckygift/handler.go | 83 +++--- .../mysql/initdb/001_lucky_gift_service.sql | 4 + .../migrations/010_v2_high_water_boost.sql | 46 ++++ .../internal/domain/luckygift/lucky_gift.go | 17 +- .../internal/service/luckygift/config.go | 33 +++ .../internal/service/luckygift/config_test.go | 75 ++++++ .../internal/service/luckygift/experiment.go | 1 - .../storage/mysql/external_repository.go | 10 + .../storage/mysql/lucky_gift_repository.go | 100 ++++++- .../lucky_gift_rule_config_repository.go | 9 +- .../storage/mysql/lucky_gift_v2_boost_test.go | 244 ++++++++++++++++++ .../transport/grpc/lucky_gift_server.go | 86 +++--- 15 files changed, 667 insertions(+), 90 deletions(-) create mode 100644 services/lucky-gift-service/deploy/mysql/migrations/010_v2_high_water_boost.sql create mode 100644 services/lucky-gift-service/internal/storage/mysql/lucky_gift_v2_boost_test.go diff --git a/.claude/launch.json b/.claude/launch.json index 5c182e9d..f8e58e94 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -14,6 +14,13 @@ "runtimeArgs": ["run", "-C", "server/admin", "./cmd/server"], "port": 13100, "autoPort": false + }, + { + "name": "lucky-gift-service", + "runtimeExecutable": "go", + "runtimeArgs": ["run", "./services/lucky-gift-service/cmd/server"], + "port": 13013, + "autoPort": false } ] } diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index 85953cec..04692ccf 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -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"` // 触发用户 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"` + // 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 sizeCache protoimpl.SizeCache } @@ -839,6 +846,27 @@ func (x *LuckyGiftRuleConfig) GetUser_24HOrdinaryWinFactorPpm() int64 { 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 { state protoimpl.MessageState `protogen:"open.v1"` 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" + "\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\"\x8a\x10\n" + + "\x16min_recharge_30d_coins\x18\x04 \x01(\x03R\x13minRecharge30dCoins\"\xe3\x11\n" + "\x13LuckyGiftRuleConfig\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\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" + "\x17anchor_daily_payout_cap\x18& \x01(\x03R\x14anchorDailyPayoutCap\x12:\n" + "\x1auser_24h_rtp_threshold_ppm\x18' \x01(\x03R\x16user24hRtpThresholdPpm\x12E\n" + - " user_24h_ordinary_win_factor_ppm\x18( \x01(\x03R\x1buser24hOrdinaryWinFactorPpm\"\xb0\x01\n" + + " 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" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" + diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index 2f36e2e4..b3e5e183 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -109,6 +109,13 @@ message LuckyGiftRuleConfig { int64 user_24h_rtp_threshold_ppm = 39; // 触发用户 24 小时 RTP 降权后保留的普通中奖权重;1000000 表示保持原概率。 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 { diff --git a/server/admin/internal/modules/luckygift/handler.go b/server/admin/internal/modules/luckygift/handler.go index b02a1d4b..33a5557f 100644 --- a/server/admin/internal/modules/luckygift/handler.go +++ b/server/admin/internal/modules/luckygift/handler.go @@ -69,6 +69,9 @@ type configRequest struct { LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"` HighWatermarkCoins int64 `json:"high_watermark_coins"` 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"` RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"` User24HourRTPThresholdPercent float64 `json:"user_24h_rtp_threshold_percent"` @@ -510,43 +513,46 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent } return &luckygiftv1.LuckyGiftRuleConfig{ - AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), - PoolId: strings.TrimSpace(req.PoolID), - StrategyVersion: strategyVersion, - Enabled: req.Enabled, - TargetRtpPpm: percentToPPM(req.TargetRTPPercent), - PoolRatePpm: percentToPPM(req.PoolRatePercent), - ProfitRatePpm: percentToPPM(req.ProfitRatePercent), - AnchorRatePpm: percentToPPM(req.AnchorRatePercent), - SettlementWindowWager: req.SettlementWindowWager, - ControlBandPpm: percentToPPM(req.ControlBandPercent), - GiftPriceReference: req.GiftPriceReference, - NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, - NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, - EffectiveFromMs: req.EffectiveFromMS, - InitialPoolCoins: initialPoolCoins, - LossStreakGuarantee: req.LossStreakGuarantee, - LowWatermarkCoins: req.LowWatermarkCoins, - LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent), - HighWatermarkCoins: req.HighWatermarkCoins, - HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent), - RechargeBoostWindowMs: req.RechargeBoostWindowMS, - RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent), - User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent), - User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent), - JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers), - JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent), - JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent), - JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent), - JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins, - MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay, - MaxSinglePayout: req.MaxSinglePayout, - UserHourlyPayoutCap: req.UserHourlyPayoutCap, - UserDailyPayoutCap: req.UserDailyPayoutCap, - DeviceDailyPayoutCap: req.DeviceDailyPayoutCap, - RoomHourlyPayoutCap: req.RoomHourlyPayoutCap, - AnchorDailyPayoutCap: req.AnchorDailyPayoutCap, - Stages: stages, + AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), + PoolId: strings.TrimSpace(req.PoolID), + StrategyVersion: strategyVersion, + Enabled: req.Enabled, + TargetRtpPpm: percentToPPM(req.TargetRTPPercent), + PoolRatePpm: percentToPPM(req.PoolRatePercent), + ProfitRatePpm: percentToPPM(req.ProfitRatePercent), + AnchorRatePpm: percentToPPM(req.AnchorRatePercent), + SettlementWindowWager: req.SettlementWindowWager, + ControlBandPpm: percentToPPM(req.ControlBandPercent), + GiftPriceReference: req.GiftPriceReference, + NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, + NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, + EffectiveFromMs: req.EffectiveFromMS, + InitialPoolCoins: initialPoolCoins, + LossStreakGuarantee: req.LossStreakGuarantee, + LowWatermarkCoins: req.LowWatermarkCoins, + LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent), + HighWatermarkCoins: req.HighWatermarkCoins, + HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent), + V2HighWaterBoostThresholdCoins: req.V2HighWaterBoostThresholdCoins, + V2HighWaterBoostFactorPpm: percentToPPM(req.V2HighWaterBoostFactorPercent), + V2HighWaterBoostRecoverCoins: req.V2HighWaterBoostRecoverCoins, + RechargeBoostWindowMs: req.RechargeBoostWindowMS, + RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent), + User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent), + User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent), + JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers), + JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent), + JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent), + JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent), + JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins, + MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay, + MaxSinglePayout: req.MaxSinglePayout, + UserHourlyPayoutCap: req.UserHourlyPayoutCap, + UserDailyPayoutCap: req.UserDailyPayoutCap, + DeviceDailyPayoutCap: req.DeviceDailyPayoutCap, + RoomHourlyPayoutCap: req.RoomHourlyPayoutCap, + AnchorDailyPayoutCap: req.AnchorDailyPayoutCap, + Stages: stages, } } @@ -611,6 +617,9 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO { LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()), HighWatermarkCoins: config.GetHighWatermarkCoins(), HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()), + V2HighWaterBoostThresholdCoins: config.GetV2HighWaterBoostThresholdCoins(), + V2HighWaterBoostFactorPercent: ppmToPercent(config.GetV2HighWaterBoostFactorPpm()), + V2HighWaterBoostRecoverCoins: config.GetV2HighWaterBoostRecoverCoins(), RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(), RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()), User24HourRTPThresholdPercent: user24HourRTPThresholdPercent, diff --git a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql index ab697ff5..c53ab2ad 100644 --- a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql +++ b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql @@ -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', high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '高水位阈值金币', 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_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子,ppm', 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 '累计基础返奖支出', manual_credit_total BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工注资金币,不计入业务 total_in', 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 累计平台盈利拆账金币', anchor_income_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计主播收益拆账金币', initial_seed_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计初始注资金币,只在首次物化时增加', diff --git a/services/lucky-gift-service/deploy/mysql/migrations/010_v2_high_water_boost.sql b/services/lucky-gift-service/deploy/mysql/migrations/010_v2_high_water_boost.sql new file mode 100644 index 00000000..b8c03143 --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/010_v2_high_water_boost.sql @@ -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; diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index 279d5a13..f2437e3d 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -72,8 +72,13 @@ type Config struct { 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"` + // V2HighWaterBoost* 只由 fixed_v2 解释:奖池水位越过阈值后,中奖返奖金额按系数加权(RTP 等比放大), + // 滞回保持到水位跌破恢复线才退出;阈值为 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 int64 `json:"user_24h_rtp_threshold_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"` 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"` + // V2HighWaterBoost* 是 fixed_v2 专属的高水位返奖加权三元组;0/0/0 表示关闭(历史版本反序列化默认值)。 + 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 表示触发后保留的普通中奖权重。 User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"` User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"` diff --git a/services/lucky-gift-service/internal/service/luckygift/config.go b/services/lucky-gift-service/internal/service/luckygift/config.go index 211602c4..7e897fd3 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config.go +++ b/services/lucky-gift-service/internal/service/luckygift/config.go @@ -197,10 +197,43 @@ func validateRuleConfig(config domain.RuleConfig) error { } } 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 { 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 } diff --git a/services/lucky-gift-service/internal/service/luckygift/config_test.go b/services/lucky-gift-service/internal/service/luckygift/config_test.go index 940e7d3a..0870a0fa 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config_test.go +++ b/services/lucky-gift-service/internal/service/luckygift/config_test.go @@ -155,3 +155,78 @@ func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) { 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) + } +} diff --git a/services/lucky-gift-service/internal/service/luckygift/experiment.go b/services/lucky-gift-service/internal/service/luckygift/experiment.go index 04e3b439..604e6e50 100644 --- a/services/lucky-gift-service/internal/service/luckygift/experiment.go +++ b/services/lucky-gift-service/internal/service/luckygift/experiment.go @@ -10,7 +10,6 @@ import ( domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" ) - // CreateExperiment 原子完成 treatment 发布与实验登记。treatment 配置必须直接可运行(enabled), // 否则实验组用户在送礼预检查就会被 RuleNotActive 拦截,实验退化成按人关停功能。 func (s *Service) CreateExperiment(ctx context.Context, cmd domain.CreateExperimentCommand) (domain.Experiment, domain.RuleConfig, error) { diff --git a/services/lucky-gift-service/internal/storage/mysql/external_repository.go b/services/lucky-gift-service/internal/storage/mysql/external_repository.go index c76370fd..d77e0b40 100644 --- a/services/lucky-gift-service/internal/storage/mysql/external_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/external_repository.go @@ -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 { 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) if err != nil { return externalDrawCandidate{}, 0, err diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go index 50c9cf8d..f0e2ffb8 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go @@ -63,6 +63,9 @@ type luckyPool struct { TotalOut int64 ManualCreditTotal int64 ManualDebitTotal int64 + // V2BoostActive 是 fixed_v2 高水位返奖加权的滞回标志:水位越过阈值置位,跌破恢复线才清零。 + // 阈值区间内(恢复线~阈值)是否加权取决于历史轨迹,因此必须随奖池行持久化,不能按余额推导。 + V2BoostActive bool } func (p luckyPool) capacity() int64 { @@ -146,9 +149,11 @@ type luckyBatchDrawState struct { DrawIDs []string Records []luckyDrawRecordInput // Window/Pool 都是 FOR UPDATE 读出的当前行副本,循环内只改内存副本。 - GlobalWindow luckyRTPWindow - GiftWindow luckyRTPWindow - BasePool luckyPool + GlobalWindow luckyRTPWindow + GiftWindow luckyRTPWindow + BasePool luckyPool + // V2BoostLoaded 记录加锁读出的滞回标志原值;批量结束时与内存最终值比较,翻转才写回。 + V2BoostLoaded bool AggregateResult domain.DrawResult AggregateStatus string AggregateCommandID string @@ -372,6 +377,7 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx GlobalWindow: globalWindow, GiftWindow: giftWindow, BasePool: basePool, + V2BoostLoaded: basePool.V2BoostActive, FinalLossStreak: userState.LossStreak, FinalCumulative: userState.CumulativeWagerCoins, FinalEquivalent: userState.EquivalentDraws, @@ -429,11 +435,21 @@ func (r *Repository) stageLuckyBatchDraw(appCode string, baseConfig domain.Confi creditLuckyPoolInMemory(&state.BasePool, 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) if err != nil { return domain.DrawResult{}, err } + if boosted { + // 审计标记进入 candidate_tiers_json.limited,复盘时能区分“原始档位”与“高水位加权后的档位”。 + limited["v2_high_water_boost"] = true + } // 随机后只落事实,不再改候选;这里扣减内存容量,让后续子抽立即感知本抽成本。 debitLuckyPoolInMemory(&state.BasePool, 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 { 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 快照里。 if _, err := tx.ExecContext(ctx, ` UPDATE lucky_user_states @@ -1194,11 +1216,26 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx, 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,不能再改成另一个结果。 candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, basePool) if err != nil { return domain.DrawResult{}, err } + if boosted { + limited["v2_high_water_boost"] = true + } drawID := idgen.New("lucky_draw") rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback) @@ -2387,6 +2424,9 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM, HighWatermarkCoins: ruleConfig.HighWatermarkCoins, HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM, + V2HighWaterBoostThresholdCoins: ruleConfig.V2HighWaterBoostThresholdCoins, + V2HighWaterBoostFactorPPM: ruleConfig.V2HighWaterBoostFactorPPM, + V2HighWaterBoostRecoverCoins: ruleConfig.V2HighWaterBoostRecoverCoins, RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS, RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM, User24HourRTPThresholdPPM: ruleConfig.User24HourRTPThresholdPPM, @@ -2657,16 +2697,66 @@ func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCo 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 + manual_credit_total, manual_debit_total, v2_boost_active FROM lucky_pools WHERE app_code = ? AND scope_type = ? AND scope_id = ? FOR UPDATE`, appCode, scopeType, scopeID, ).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 } +// 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 { // 单池模型里 pool_rate 的全部入池金额都归属 app+pool_id 这一口基础池;不再为房间或礼物拆责任份额。 return r.creditLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), luckyBasePoolIn(config, spent), pool, nowMS) diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go index fc95d633..eb6e58da 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go @@ -122,13 +122,14 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T initial_pool_coins, loss_streak_guarantee, low_watermark_coins, low_water_nonzero_factor_ppm, high_watermark_coins, high_water_nonzero_factor_ppm, + 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, user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms, jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm, jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day, max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM, config.ProfitRatePPM, config.AnchorRatePPM, config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference, @@ -137,6 +138,7 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T config.InitialPoolCoins, config.LossStreakGuarantee, config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM, config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM, + config.V2HighWaterBoostThresholdCoins, config.V2HighWaterBoostFactorPPM, config.V2HighWaterBoostRecoverCoins, config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, config.User24HourRTPThresholdPPM, config.User24HourOrdinaryWinFactorPPM, jackpotMultiplierPPMsJSON, 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.low_watermark_coins, v.low_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.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, @@ -245,6 +248,7 @@ const luckyRuleConfigSelectSQL = ` initial_pool_coins, loss_streak_guarantee, low_watermark_coins, low_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, 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, @@ -377,6 +381,9 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) { &config.LowWaterNonzeroFactorPPM, &config.HighWatermarkCoins, &config.HighWaterNonzeroFactorPPM, + &config.V2HighWaterBoostThresholdCoins, + &config.V2HighWaterBoostFactorPPM, + &config.V2HighWaterBoostRecoverCoins, &config.RechargeBoostWindowMS, &config.RechargeBoostFactorPPM, &config.User24HourRTPThresholdPPM, diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_v2_boost_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_v2_boost_test.go new file mode 100644 index 00000000..21215120 --- /dev/null +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_v2_boost_test.go @@ -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") + } +} diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go index 30ee071b..47a7ac09 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go @@ -555,6 +555,9 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(), HighWatermarkCoins: config.GetHighWatermarkCoins(), HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(), + V2HighWaterBoostThresholdCoins: config.GetV2HighWaterBoostThresholdCoins(), + V2HighWaterBoostFactorPPM: config.GetV2HighWaterBoostFactorPpm(), + V2HighWaterBoostRecoverCoins: config.GetV2HighWaterBoostRecoverCoins(), RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(), RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(), User24HourRTPThresholdPPM: config.GetUser_24HRtpThresholdPpm(), @@ -605,46 +608,49 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule }) } return &luckygiftv1.LuckyGiftRuleConfig{ - AppCode: config.AppCode, - PoolId: config.PoolID, - RuleVersion: config.RuleVersion, - Enabled: config.Enabled, - TargetRtpPpm: config.TargetRTPPPM, - PoolRatePpm: config.PoolRatePPM, - SettlementWindowWager: config.SettlementWindowWager, - ControlBandPpm: config.ControlBandPPM, - GiftPriceReference: config.GiftPriceReference, - NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws, - NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws, - EffectiveFromMs: config.EffectiveFromMS, - CreatedByAdminId: config.CreatedByAdminID, - CreatedAtMs: config.CreatedAtMS, - StrategyVersion: config.StrategyVersion, - ProfitRatePpm: config.ProfitRatePPM, - AnchorRatePpm: config.AnchorRatePPM, - InitialPoolCoins: config.InitialPoolCoins, - LossStreakGuarantee: config.LossStreakGuarantee, - LowWatermarkCoins: config.LowWatermarkCoins, - LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM, - HighWatermarkCoins: config.HighWatermarkCoins, - HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM, - RechargeBoostWindowMs: config.RechargeBoostWindowMS, - RechargeBoostFactorPpm: config.RechargeBoostFactorPPM, - User_24HRtpThresholdPpm: config.User24HourRTPThresholdPPM, - User_24HOrdinaryWinFactorPpm: config.User24HourOrdinaryWinFactorPPM, - JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...), - JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM, - JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM, - JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM, - JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins, - MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay, - MaxSinglePayout: config.MaxSinglePayout, - UserHourlyPayoutCap: config.UserHourlyPayoutCap, - UserDailyPayoutCap: config.UserDailyPayoutCap, - DeviceDailyPayoutCap: config.DeviceDailyPayoutCap, - RoomHourlyPayoutCap: config.RoomHourlyPayoutCap, - AnchorDailyPayoutCap: config.AnchorDailyPayoutCap, - Stages: stages, + AppCode: config.AppCode, + PoolId: config.PoolID, + RuleVersion: config.RuleVersion, + Enabled: config.Enabled, + TargetRtpPpm: config.TargetRTPPPM, + PoolRatePpm: config.PoolRatePPM, + SettlementWindowWager: config.SettlementWindowWager, + ControlBandPpm: config.ControlBandPPM, + GiftPriceReference: config.GiftPriceReference, + NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws, + NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws, + EffectiveFromMs: config.EffectiveFromMS, + CreatedByAdminId: config.CreatedByAdminID, + CreatedAtMs: config.CreatedAtMS, + StrategyVersion: config.StrategyVersion, + ProfitRatePpm: config.ProfitRatePPM, + AnchorRatePpm: config.AnchorRatePPM, + InitialPoolCoins: config.InitialPoolCoins, + LossStreakGuarantee: config.LossStreakGuarantee, + LowWatermarkCoins: config.LowWatermarkCoins, + LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM, + HighWatermarkCoins: config.HighWatermarkCoins, + HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM, + V2HighWaterBoostThresholdCoins: config.V2HighWaterBoostThresholdCoins, + V2HighWaterBoostFactorPpm: config.V2HighWaterBoostFactorPPM, + V2HighWaterBoostRecoverCoins: config.V2HighWaterBoostRecoverCoins, + RechargeBoostWindowMs: config.RechargeBoostWindowMS, + RechargeBoostFactorPpm: config.RechargeBoostFactorPPM, + User_24HRtpThresholdPpm: config.User24HourRTPThresholdPPM, + User_24HOrdinaryWinFactorPpm: config.User24HourOrdinaryWinFactorPPM, + JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...), + JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM, + JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM, + JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM, + JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins, + MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay, + MaxSinglePayout: config.MaxSinglePayout, + UserHourlyPayoutCap: config.UserHourlyPayoutCap, + UserDailyPayoutCap: config.UserDailyPayoutCap, + DeviceDailyPayoutCap: config.DeviceDailyPayoutCap, + RoomHourlyPayoutCap: config.RoomHourlyPayoutCap, + AnchorDailyPayoutCap: config.AnchorDailyPayoutCap, + Stages: stages, } }