lucky_gift_v2: update lucky gift activity

This commit is contained in:
ZuoZuo 2026-05-28 00:36:39 +08:00
parent c93f46b191
commit d089f4f282
15 changed files with 1444 additions and 644 deletions

2
.gitignore vendored
View File

@ -26,3 +26,5 @@ node_modules/
# Docker and database runtime data.
mysql-data/
redis-data/
.waylog/

File diff suppressed because it is too large Load Diff

View File

@ -1182,6 +1182,38 @@ message LuckyGiftConfig {
repeated int64 multiplier_ppms = 40;
}
message LuckyGiftRuleTier {
string stage = 1;
string tier_id = 2;
int64 multiplier_ppm = 3;
int64 base_weight_ppm = 4;
string reward_source = 5;
bool high_water_only = 6;
string broadcast_level = 7;
bool enabled = 8;
}
message LuckyGiftRuleStage {
string stage = 1;
repeated LuckyGiftRuleTier tiers = 2;
}
message LuckyGiftRuleConfig {
string app_code = 1;
string pool_id = 2;
int64 rule_version = 3;
bool enabled = 4;
int64 target_rtp_ppm = 5;
int64 pool_rate_ppm = 6;
int64 settlement_window_wager = 7;
int64 control_band_ppm = 8;
int64 gift_price_reference = 9;
int64 effective_from_ms = 10;
int64 created_by_admin_id = 11;
int64 created_at_ms = 12;
repeated LuckyGiftRuleStage stages = 13;
}
message CheckLuckyGiftRequest {
RequestMeta meta = 1;
int64 user_id = 2;
@ -1240,17 +1272,17 @@ message GetLuckyGiftConfigRequest {
}
message GetLuckyGiftConfigResponse {
LuckyGiftConfig config = 1;
LuckyGiftRuleConfig config = 1;
}
message UpsertLuckyGiftConfigRequest {
RequestMeta meta = 1;
LuckyGiftConfig config = 2;
LuckyGiftRuleConfig config = 2;
int64 operator_admin_id = 3;
}
message UpsertLuckyGiftConfigResponse {
LuckyGiftConfig config = 1;
LuckyGiftRuleConfig config = 1;
}
message ListLuckyGiftConfigsRequest {
@ -1258,7 +1290,7 @@ message ListLuckyGiftConfigsRequest {
}
message ListLuckyGiftConfigsResponse {
repeated LuckyGiftConfig configs = 1;
repeated LuckyGiftRuleConfig configs = 1;
}
message ListLuckyGiftDrawsRequest {

View File

@ -1,6 +1,7 @@
package luckygift
import (
"math"
"strings"
"time"
@ -24,60 +25,38 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
}
type configRequest struct {
PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"`
GiftPrice int64 `json:"gift_price"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceDrawLimit int64 `json:"novice_draw_limit"`
IntermediateDrawLimit int64 `json:"intermediate_draw_limit"`
HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
PlatformPoolWeightPPM int64 `json:"platform_pool_weight_ppm"`
RoomPoolWeightPPM int64 `json:"room_pool_weight_ppm"`
GiftPoolWeightPPM int64 `json:"gift_pool_weight_ppm"`
InitialPlatformPool int64 `json:"initial_platform_pool"`
InitialGiftPool int64 `json:"initial_gift_pool"`
InitialRoomPool int64 `json:"initial_room_pool"`
PlatformReserve int64 `json:"platform_reserve"`
GiftReserve int64 `json:"gift_reserve"`
RoomReserve int64 `json:"room_reserve"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
RoomAtmosphereRatePPM int64 `json:"room_atmosphere_rate_ppm"`
RoomAtmosphereInitial int64 `json:"room_atmosphere_initial"`
RoomAtmosphereReserve int64 `json:"room_atmosphere_reserve"`
ActivityBudget int64 `json:"activity_budget"`
ActivityDailyLimit int64 `json:"activity_daily_limit"`
LargeTierEnabled bool `json:"large_tier_enabled"`
MultiplierPPMs []int64 `json:"multiplier_ppms"`
Tiers []tierDTO `json:"tiers"`
PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
EffectiveFromMS int64 `json:"effective_from_ms"`
Stages []stageDTO `json:"stages"`
}
type configDTO struct {
configRequest
AppCode string `json:"app_code"`
GiftID string `json:"gift_id"`
RuleVersion int64 `json:"rule_version"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type stageDTO struct {
Stage string `json:"stage"`
Tiers []tierDTO `json:"tiers"`
}
type tierDTO struct {
Pool string `json:"pool"`
TierID string `json:"tier_id"`
RewardCoins int64 `json:"reward_coins"`
MultiplierPPM int64 `json:"multiplier_ppm"`
Weight int64 `json:"weight"`
HighWaterOnly bool `json:"high_water_only"`
Enabled bool `json:"enabled"`
TierID string `json:"tier_id"`
Multiplier float64 `json:"multiplier"`
ProbabilityPercent float64 `json:"probability_percent"`
RewardSource string `json:"reward_source"`
HighWaterOnly bool `json:"high_water_only"`
BroadcastLevel string `json:"broadcast_level"`
Enabled bool `json:"enabled"`
}
type drawDTO struct {
@ -218,123 +197,99 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
}
}
func configToProto(req configRequest) *activityv1.LuckyGiftConfig {
tiers := make([]*activityv1.LuckyGiftTier, 0, len(req.Tiers))
for _, tier := range req.Tiers {
tiers = append(tiers, &activityv1.LuckyGiftTier{
Pool: tier.Pool,
TierId: tier.TierID,
RewardCoins: tier.RewardCoins,
MultiplierPpm: tier.MultiplierPPM,
Weight: tier.Weight,
HighWaterOnly: tier.HighWaterOnly,
Enabled: tier.Enabled,
func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig {
stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages))
for _, stage := range req.Stages {
tiers := make([]*activityv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
for _, tier := range stage.Tiers {
tiers = append(tiers, &activityv1.LuckyGiftRuleTier{
Stage: strings.TrimSpace(stage.Stage),
TierId: strings.TrimSpace(tier.TierID),
MultiplierPpm: multiplierToPPM(tier.Multiplier),
BaseWeightPpm: percentToPPM(tier.ProbabilityPercent),
RewardSource: strings.TrimSpace(tier.RewardSource),
HighWaterOnly: tier.HighWaterOnly,
BroadcastLevel: strings.TrimSpace(tier.BroadcastLevel),
Enabled: tier.Enabled,
})
}
stages = append(stages, &activityv1.LuckyGiftRuleStage{
Stage: strings.TrimSpace(stage.Stage),
Tiers: tiers,
})
}
return &activityv1.LuckyGiftConfig{
return &activityv1.LuckyGiftRuleConfig{
PoolId: strings.TrimSpace(req.PoolID),
Enabled: req.Enabled,
GiftPrice: req.GiftPrice,
TargetRtpPpm: req.TargetRTPPPM,
PoolRatePpm: req.PoolRatePPM,
GlobalWindowDraws: req.GlobalWindowDraws,
GiftWindowDraws: req.GiftWindowDraws,
NoviceDrawLimit: req.NoviceDrawLimit,
IntermediateDrawLimit: req.IntermediateDrawLimit,
HighMultiplier: req.HighMultiplier,
HighWaterPoolMultiple: req.HighWaterPoolMultiple,
PlatformPoolWeightPpm: req.PlatformPoolWeightPPM,
RoomPoolWeightPpm: req.RoomPoolWeightPPM,
GiftPoolWeightPpm: req.GiftPoolWeightPPM,
InitialPlatformPool: req.InitialPlatformPool,
InitialGiftPool: req.InitialGiftPool,
InitialRoomPool: req.InitialRoomPool,
PlatformReserve: req.PlatformReserve,
GiftReserve: req.GiftReserve,
RoomReserve: req.RoomReserve,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
RoomAtmosphereRatePpm: req.RoomAtmosphereRatePPM,
RoomAtmosphereInitial: req.RoomAtmosphereInitial,
RoomAtmosphereReserve: req.RoomAtmosphereReserve,
ActivityBudget: req.ActivityBudget,
ActivityDailyLimit: req.ActivityDailyLimit,
LargeTierEnabled: req.LargeTierEnabled,
MultiplierPpms: append([]int64(nil), req.MultiplierPPMs...),
Tiers: tiers,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent),
SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference,
EffectiveFromMs: req.EffectiveFromMS,
Stages: stages,
}
}
func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
func configFromProto(config *activityv1.LuckyGiftRuleConfig) configDTO {
if config == nil {
return configDTO{}
}
tiers := make([]tierDTO, 0, len(config.GetTiers()))
for _, tier := range config.GetTiers() {
tiers = append(tiers, tierDTO{
Pool: tier.GetPool(),
TierID: tier.GetTierId(),
RewardCoins: tier.GetRewardCoins(),
MultiplierPPM: tier.GetMultiplierPpm(),
Weight: tier.GetWeight(),
HighWaterOnly: tier.GetHighWaterOnly(),
Enabled: tier.GetEnabled(),
stages := make([]stageDTO, 0, len(config.GetStages()))
for _, stage := range config.GetStages() {
tiers := make([]tierDTO, 0, len(stage.GetTiers()))
for _, tier := range stage.GetTiers() {
tiers = append(tiers, tierDTO{
TierID: tier.GetTierId(),
Multiplier: ppmToMultiplier(tier.GetMultiplierPpm()),
ProbabilityPercent: ppmToPercent(tier.GetBaseWeightPpm()),
RewardSource: tier.GetRewardSource(),
HighWaterOnly: tier.GetHighWaterOnly(),
BroadcastLevel: tier.GetBroadcastLevel(),
Enabled: tier.GetEnabled(),
})
}
stages = append(stages, stageDTO{
Stage: stage.GetStage(),
Tiers: tiers,
})
}
poolID := strings.TrimSpace(config.GetPoolId())
if poolID == "" {
poolID = strings.TrimSpace(config.GetGiftId())
}
return configDTO{
AppCode: config.GetAppCode(),
GiftID: config.GetGiftId(),
RuleVersion: config.GetRuleVersion(),
UpdatedByAdminID: config.GetUpdatedByAdminId(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
configRequest: configRequest{
PoolID: poolID,
Enabled: config.GetEnabled(),
GiftPrice: config.GetGiftPrice(),
TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(),
GlobalWindowDraws: config.GetGlobalWindowDraws(),
GiftWindowDraws: config.GetGiftWindowDraws(),
NoviceDrawLimit: config.GetNoviceDrawLimit(),
IntermediateDrawLimit: config.GetIntermediateDrawLimit(),
HighMultiplier: config.GetHighMultiplier(),
HighWaterPoolMultiple: config.GetHighWaterPoolMultiple(),
PlatformPoolWeightPPM: config.GetPlatformPoolWeightPpm(),
RoomPoolWeightPPM: config.GetRoomPoolWeightPpm(),
GiftPoolWeightPPM: config.GetGiftPoolWeightPpm(),
InitialPlatformPool: config.GetInitialPlatformPool(),
InitialGiftPool: config.GetInitialGiftPool(),
InitialRoomPool: config.GetInitialRoomPool(),
PlatformReserve: config.GetPlatformReserve(),
GiftReserve: config.GetGiftReserve(),
RoomReserve: config.GetRoomReserve(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
RoomAtmosphereRatePPM: config.GetRoomAtmosphereRatePpm(),
RoomAtmosphereInitial: config.GetRoomAtmosphereInitial(),
RoomAtmosphereReserve: config.GetRoomAtmosphereReserve(),
ActivityBudget: config.GetActivityBudget(),
ActivityDailyLimit: config.GetActivityDailyLimit(),
LargeTierEnabled: config.GetLargeTierEnabled(),
MultiplierPPMs: append([]int64(nil), config.GetMultiplierPpms()...),
Tiers: tiers,
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(),
EffectiveFromMS: config.GetEffectiveFromMs(),
Stages: stages,
},
}
}
func percentToPPM(value float64) int64 {
return int64(math.Round(value * 10_000))
}
func ppmToPercent(value int64) float64 {
return float64(value) / 10_000
}
func multiplierToPPM(value float64) int64 {
return int64(math.Round(value * 1_000_000))
}
func ppmToMultiplier(value int64) float64 {
return float64(value) / 1_000_000
}
func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
if draw == nil {
return drawDTO{}

View File

@ -0,0 +1,107 @@
package luckygift
import (
"testing"
activityv1 "hyapp.local/api/proto/activity/v1"
)
func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) {
config := configToProto(configRequest{
PoolID: " lucky_100 ",
Enabled: true,
TargetRTPPercent: 95,
PoolRatePercent: 96,
SettlementWindowWager: 1_000_000,
ControlBandPercent: 1,
GiftPriceReference: 100,
Stages: []stageDTO{
{
Stage: " novice ",
Tiers: []tierDTO{
{
TierID: " novice_0_3x ",
Multiplier: 0.3,
ProbabilityPercent: 22,
RewardSource: " base_rtp ",
BroadcastLevel: " room ",
Enabled: true,
},
},
},
},
})
if config.GetPoolId() != "lucky_100" {
t.Fatalf("pool id = %q, want lucky_100", config.GetPoolId())
}
if config.GetTargetRtpPpm() != 950_000 {
t.Fatalf("target rtp ppm = %d, want 950000", config.GetTargetRtpPpm())
}
if config.GetPoolRatePpm() != 960_000 {
t.Fatalf("pool rate ppm = %d, want 960000", config.GetPoolRatePpm())
}
if config.GetControlBandPpm() != 10_000 {
t.Fatalf("control band ppm = %d, want 10000", config.GetControlBandPpm())
}
tier := config.GetStages()[0].GetTiers()[0]
if tier.GetStage() != "novice" {
t.Fatalf("tier stage = %q, want novice", tier.GetStage())
}
if tier.GetTierId() != "novice_0_3x" {
t.Fatalf("tier id = %q, want novice_0_3x", tier.GetTierId())
}
if tier.GetMultiplierPpm() != 300_000 {
t.Fatalf("multiplier ppm = %d, want 300000", tier.GetMultiplierPpm())
}
if tier.GetBaseWeightPpm() != 220_000 {
t.Fatalf("base weight ppm = %d, want 220000", tier.GetBaseWeightPpm())
}
}
func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) {
config := configFromProto(&activityv1.LuckyGiftRuleConfig{
AppCode: "hyapp",
PoolId: "lucky_100",
RuleVersion: 7,
Enabled: true,
TargetRtpPpm: 950_000,
PoolRatePpm: 960_000,
SettlementWindowWager: 1_000_000,
ControlBandPpm: 10_000,
GiftPriceReference: 100,
CreatedByAdminId: 12,
CreatedAtMs: 1_700_000_000_000,
Stages: []*activityv1.LuckyGiftRuleStage{
{
Stage: "novice",
Tiers: []*activityv1.LuckyGiftRuleTier{
{
TierId: "novice_0_3x",
MultiplierPpm: 300_000,
BaseWeightPpm: 220_000,
RewardSource: "base_rtp",
BroadcastLevel: "room",
Enabled: true,
},
},
},
},
})
if config.TargetRTPPercent != 95 {
t.Fatalf("target rtp percent = %v, want 95", config.TargetRTPPercent)
}
if config.PoolRatePercent != 96 {
t.Fatalf("pool rate percent = %v, want 96", config.PoolRatePercent)
}
if config.ControlBandPercent != 1 {
t.Fatalf("control band percent = %v, want 1", config.ControlBandPercent)
}
if config.Stages[0].Tiers[0].Multiplier != 0.3 {
t.Fatalf("multiplier = %v, want 0.3", config.Stages[0].Tiers[0].Multiplier)
}
if config.Stages[0].Tiers[0].ProbabilityPercent != 22 {
t.Fatalf("probability percent = %v, want 22", config.Stages[0].Tiers[0].ProbabilityPercent)
}
}

View File

@ -90,6 +90,40 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rules (
KEY idx_lucky_gift_rules_enabled (app_code, enabled, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物规则表';
CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
target_rtp_ppm BIGINT NOT NULL COMMENT '基础返奖目标ppm',
pool_rate_ppm BIGINT NOT NULL COMMENT '每笔扣费进入基础奖池比例ppm',
settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水,单位金币',
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm',
gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格',
effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, rule_version),
KEY idx_lucky_gift_rule_versions_latest (app_code, pool_id, rule_version),
KEY idx_lucky_gift_rule_versions_enabled (app_code, enabled, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 v2 规则版本表';
CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '规则版本',
stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced',
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000',
base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率ppm',
reward_source VARCHAR(32) NOT NULL COMMENT 'base_rtp/activity_subsidy/presentation_only',
high_water_only TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否高水位才开放',
broadcast_level VARCHAR(32) NOT NULL DEFAULT 'none' COMMENT 'none/room/region/global',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
PRIMARY KEY (app_code, pool_id, rule_version, stage, tier_id),
KEY idx_lucky_gift_stage_tiers_stage (app_code, pool_id, rule_version, stage)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 v2 阶段奖档表';
CREATE TABLE IF NOT EXISTS lucky_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift',

View File

@ -15,10 +15,19 @@ const (
PoolIntermediate = "intermediate"
PoolAdvanced = "advanced"
StageNovice = "novice"
StageNormal = "normal"
StageAdvanced = "advanced"
SourceBaseRTP = "base_rtp"
SourceRoomAtmosphere = "room_atmosphere"
SourceActivity = "activity_subsidy"
SourcePresentation = "presentation_only"
BroadcastNone = "none"
BroadcastRoom = "room"
BroadcastRegion = "region"
BroadcastGlobal = "global"
)
// Tier 是单个体验池里的候选奖档;权重只塑造体验节奏,基础成本仍由 RTP 窗口兜住。
@ -76,6 +85,41 @@ type Config struct {
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// RuleTier 是 v2 配置契约里的阶段奖档。倍率和概率在服务端内部统一用 ppm 保存。
type RuleTier struct {
Stage string `json:"stage"`
TierID string `json:"tier_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
BaseWeightPPM int64 `json:"base_weight_ppm"`
RewardSource string `json:"reward_source"`
HighWaterOnly bool `json:"high_water_only"`
BroadcastLevel string `json:"broadcast_level"`
Enabled bool `json:"enabled"`
}
// RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。
type RuleStage struct {
Stage string `json:"stage"`
Tiers []RuleTier `json:"tiers"`
}
// RuleConfig 是幸运礼物 v2 配置版本快照。比例和倍率均为 ppm业务单位转换发生在发布入口。
type RuleConfig struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"`
EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
Stages []RuleStage `json:"stages"`
}
type CheckCommand struct {
PoolID string
GiftID string

View File

@ -9,7 +9,10 @@ import (
domain "hyapp/services/activity-service/internal/domain/luckygift"
)
const ppmScale int64 = 1_000_000
const (
ppmScale int64 = 1_000_000
highTierMultiplierPPM int64 = 10_000_000
)
// DefaultConfig 给后台指定奖池提供规则草稿;真正生效必须由后台显式 enabled。
func DefaultConfig(appCode, poolID string) domain.Config {
@ -55,6 +58,191 @@ func DefaultConfig(appCode, poolID string) domain.Config {
}
}
// DefaultRuleConfig 返回 v2 配置草稿。草稿默认 disabled只有后台发布后才写入不可变版本表。
func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
poolID = normalizePoolID(poolID)
return domain.RuleConfig{
AppCode: appCode,
PoolID: poolID,
Enabled: false,
TargetRTPPPM: 950_000,
PoolRatePPM: 960_000,
SettlementWindowWager: 1_000_000,
ControlBandPPM: 10_000,
GiftPriceReference: 100,
Stages: []domain.RuleStage{
defaultRuleStage(domain.StageNovice, []ruleTierSeed{
{id: "novice_none", multiplierPPM: 0, weightPPM: 300_000},
{id: "novice_0_3x", multiplierPPM: 300_000, weightPPM: 220_000},
{id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 180_000},
{id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 300_000},
}),
defaultRuleStage(domain.StageNormal, []ruleTierSeed{
{id: "normal_none", multiplierPPM: 0, weightPPM: 400_000},
{id: "normal_0_5x", multiplierPPM: 500_000, weightPPM: 250_000},
{id: "normal_1x", multiplierPPM: 1_000_000, weightPPM: 250_000},
{id: "normal_2x", multiplierPPM: 2_000_000, weightPPM: 100_000},
}),
defaultRuleStage(domain.StageAdvanced, []ruleTierSeed{
{id: "advanced_none", multiplierPPM: 0, weightPPM: 500_000},
{id: "advanced_1x", multiplierPPM: 1_000_000, weightPPM: 300_000},
{id: "advanced_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
{id: "advanced_5x", multiplierPPM: 5_000_000, weightPPM: 50_000},
}),
},
}
}
type ruleTierSeed struct {
id string
multiplierPPM int64
weightPPM int64
}
func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage {
out := domain.RuleStage{Stage: stage, Tiers: make([]domain.RuleTier, 0, len(tiers))}
for _, tier := range tiers {
out.Tiers = append(out.Tiers, domain.RuleTier{
Stage: stage,
TierID: tier.id,
MultiplierPPM: tier.multiplierPPM,
BaseWeightPPM: tier.weightPPM,
RewardSource: domain.SourceBaseRTP,
HighWaterOnly: tier.multiplierPPM >= highTierMultiplierPPM,
BroadcastLevel: domain.BroadcastNone,
Enabled: true,
})
}
return out
}
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
config.PoolID = normalizePoolID(config.PoolID)
for stageIndex, stage := range config.Stages {
stage.Stage = strings.TrimSpace(stage.Stage)
for tierIndex, tier := range stage.Tiers {
tier.Stage = strings.TrimSpace(tier.Stage)
if tier.Stage == "" {
tier.Stage = stage.Stage
}
tier.TierID = strings.TrimSpace(tier.TierID)
tier.RewardSource = strings.TrimSpace(tier.RewardSource)
if tier.RewardSource == "" {
tier.RewardSource = domain.SourceBaseRTP
}
tier.BroadcastLevel = strings.TrimSpace(tier.BroadcastLevel)
if tier.BroadcastLevel == "" {
tier.BroadcastLevel = domain.BroadcastNone
}
stage.Tiers[tierIndex] = tier
}
config.Stages[stageIndex] = stage
}
return config
}
func validateRuleConfig(config domain.RuleConfig) error {
if normalizePoolID(config.PoolID) == "" {
return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
}
if config.TargetRTPPPM < 100_000 || config.TargetRTPPPM > 990_000 {
return xerr.New(xerr.InvalidArgument, "target RTP must be between 10% and 99%")
}
if config.PoolRatePPM < config.TargetRTPPPM || config.PoolRatePPM > ppmScale {
return xerr.New(xerr.InvalidArgument, "pool rate must be between target RTP and 100%")
}
if config.SettlementWindowWager <= 0 {
return xerr.New(xerr.InvalidArgument, "settlement window wager must be positive")
}
if config.ControlBandPPM <= 0 || config.ControlBandPPM > 50_000 {
return xerr.New(xerr.InvalidArgument, "control band must be greater than 0 and no more than 5%")
}
if config.GiftPriceReference <= 0 {
return xerr.New(xerr.InvalidArgument, "gift price reference must be positive")
}
stageMap := make(map[string]domain.RuleStage, len(config.Stages))
for _, stage := range config.Stages {
if !validRuleStage(stage.Stage) {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("invalid lucky gift stage: %s", stage.Stage))
}
if _, exists := stageMap[stage.Stage]; exists {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("duplicate lucky gift stage: %s", stage.Stage))
}
stageMap[stage.Stage] = stage
}
for _, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} {
stage, exists := stageMap[stageName]
if !exists {
return xerr.New(xerr.InvalidArgument, "lucky gift stages must include novice, normal and advanced")
}
if err := validateRuleStage(stage); err != nil {
return err
}
}
return nil
}
func validateRuleStage(stage domain.RuleStage) error {
var weightSum int64
hasBaseTier := false
hasZeroTier := false
for _, tier := range stage.Tiers {
if tier.Stage != stage.Stage {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("tier %s stage does not match %s", tier.TierID, stage.Stage))
}
if tier.TierID == "" {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s tier id is required", stage.Stage))
}
if tier.MultiplierPPM < 0 {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s tier multiplier is invalid", tier.TierID))
}
if tier.BaseWeightPPM < 0 {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s tier probability is invalid", tier.TierID))
}
if !validRewardSource(tier.RewardSource) {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s reward source is invalid", tier.TierID))
}
if !validBroadcastLevel(tier.BroadcastLevel) {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s broadcast level is invalid", tier.TierID))
}
if tier.Enabled {
weightSum += tier.BaseWeightPPM
hasBaseTier = hasBaseTier || tier.RewardSource == domain.SourceBaseRTP
hasZeroTier = hasZeroTier || tier.MultiplierPPM == 0
}
if tier.Enabled && tier.MultiplierPPM >= highTierMultiplierPPM && !tier.HighWaterOnly {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s high multiplier tier must be high-water only", tier.TierID))
}
}
if weightSum != ppmScale {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage probability must sum to 100%%", stage.Stage))
}
if !hasBaseTier {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage must include at least one base RTP tier", stage.Stage))
}
if !hasZeroTier {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage must include explicit 0x tier", stage.Stage))
}
return nil
}
func validRuleStage(stage string) bool {
return stage == domain.StageNovice || stage == domain.StageNormal || stage == domain.StageAdvanced
}
func validRewardSource(source string) bool {
return source == domain.SourceBaseRTP ||
source == domain.SourceActivity ||
source == domain.SourcePresentation
}
func validBroadcastLevel(level string) bool {
return level == domain.BroadcastNone ||
level == domain.BroadcastRoom ||
level == domain.BroadcastRegion ||
level == domain.BroadcastGlobal
}
// validateConfig 只校验会破坏线上抽奖不变量的字段;展示类默认值留给后台表单处理。
func validateConfig(config domain.Config) error {
if normalizePoolID(config.PoolID) == "" {

View File

@ -63,3 +63,39 @@ func TestValidateConfigRejectsMultiplierListBelowTargetRTP(t *testing.T) {
t.Fatalf("expected max multiplier below RTP to be rejected")
}
}
func TestDefaultRuleConfigPassesValidation(t *testing.T) {
config := DefaultRuleConfig("hyapp", "pool_v2")
config.Enabled = true
if err := validateRuleConfig(config); err != nil {
t.Fatalf("default v2 lucky gift config should be publishable: %v", err)
}
}
func TestValidateRuleConfigRejectsPercentSubmittedAsPPM(t *testing.T) {
config := DefaultRuleConfig("hyapp", "pool_bad_rtp")
config.TargetRTPPPM = 95
if err := validateRuleConfig(config); err == nil {
t.Fatalf("expected raw percent value in ppm field to be rejected")
}
}
func TestValidateRuleConfigRejectsStageWeightMismatch(t *testing.T) {
config := DefaultRuleConfig("hyapp", "pool_bad_weight")
config.Stages[0].Tiers[0].BaseWeightPPM = 299_999
if err := validateRuleConfig(config); err == nil {
t.Fatalf("expected stage probability sum mismatch to be rejected")
}
}
func TestValidateRuleConfigRequiresExplicitZeroTier(t *testing.T) {
config := DefaultRuleConfig("hyapp", "pool_no_zero")
config.Stages[1].Tiers = config.Stages[1].Tiers[1:]
if err := validateRuleConfig(config); err == nil {
t.Fatalf("expected stage without 0x tier to be rejected")
}
}

View File

@ -21,9 +21,9 @@ import (
// Repository 是幸运礼物唯一持久化边界RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
type Repository interface {
GetLuckyGiftConfig(ctx context.Context, poolID string) (domain.Config, bool, error)
UpsertLuckyGiftConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
ListLuckyGiftConfigs(ctx context.Context) ([]domain.Config, error)
GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error)
PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error)
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
@ -285,43 +285,40 @@ func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox,
return s.repository.MarkLuckyGiftOutboxRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMS)
}
func (s *Service) GetConfig(ctx context.Context, poolID string) (domain.Config, error) {
func (s *Service) GetConfig(ctx context.Context, poolID string) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
return domain.RuleConfig{}, err
}
poolID = normalizePoolID(poolID)
config, exists, err := s.repository.GetLuckyGiftConfig(ctx, poolID)
config, exists, err := s.repository.GetLuckyGiftRuleConfig(ctx, poolID)
if err != nil {
return domain.Config{}, err
return domain.RuleConfig{}, err
}
if !exists {
// 未配置时返回 disabled 草稿,后台可以直接编辑发布;不能在运行时把草稿当启用规则
return DefaultConfig(appcode.FromContext(ctx), poolID), nil
// 未配置时返回 disabled v2 草稿,后台可以直接编辑发布;运行抽奖不会读取这个草稿
return DefaultRuleConfig(appcode.FromContext(ctx), poolID), nil
}
return config, nil
}
func (s *Service) UpsertConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
return domain.RuleConfig{}, err
}
config.AppCode = appcode.FromContext(ctx)
config.PoolID = normalizePoolID(config.PoolID)
// 规则表历史上用 gift_id 承载作用域;当前以 pool_id 为真实业务键,发布时必须覆盖掉外部传入的 gift_id。
config.GiftID = config.PoolID
// 后台现在配置倍率列表,服务端生成三档体验池候选;概率权重必须留给抽奖时的 RTP 控制器。
config.Tiers, config.MultiplierPPMs = normalizeTiers(config.Tiers, config.MultiplierPPMs, config.GiftPrice, config.HighMultiplier)
if err := validateConfig(config); err != nil {
return domain.Config{}, err
config = normalizeRuleConfig(config)
if err := validateRuleConfig(config); err != nil {
return domain.RuleConfig{}, err
}
return s.repository.UpsertLuckyGiftConfig(ctx, config, s.now().UTC().UnixMilli())
return s.repository.PublishLuckyGiftRuleConfig(ctx, config, s.now().UTC().UnixMilli())
}
func (s *Service) ListConfigs(ctx context.Context) ([]domain.Config, error) {
func (s *Service) ListConfigs(ctx context.Context) ([]domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
return s.repository.ListLuckyGiftConfigs(ctx)
return s.repository.ListLuckyGiftRuleConfigs(ctx)
}
func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {

View File

@ -26,22 +26,22 @@ func TestGetConfigReturnsPoolDefault(t *testing.T) {
if repository.getConfigCalls != 1 {
t.Fatalf("expected repository to be called once, got %d", repository.getConfigCalls)
}
if config.PoolID != "pool_95" || config.GiftID != "pool_95" {
t.Fatalf("expected pool config scope, got pool=%q gift=%q", config.PoolID, config.GiftID)
if config.PoolID != "pool_95" {
t.Fatalf("expected pool config scope, got pool=%q", config.PoolID)
}
}
func TestUpsertConfigPreservesPoolScope(t *testing.T) {
repository := &fakeLuckyGiftRepository{}
service := New(repository)
config := DefaultConfig("hyapp", "pool_98")
config := DefaultRuleConfig("hyapp", "pool_98")
config.Enabled = true
if _, err := service.UpsertConfig(appcode.WithContext(context.Background(), "hyapp"), config); err != nil {
t.Fatalf("UpsertConfig returned error: %v", err)
}
if repository.upserted.PoolID != "pool_98" || repository.upserted.GiftID != "pool_98" {
t.Fatalf("expected pool scope, got pool=%q gift=%q", repository.upserted.PoolID, repository.upserted.GiftID)
if repository.upserted.PoolID != "pool_98" {
t.Fatalf("expected pool scope, got pool=%q", repository.upserted.PoolID)
}
}
@ -110,24 +110,24 @@ func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomIM(t *testing.T) {
type fakeLuckyGiftRepository struct {
getConfigCalls int
upserted domain.Config
upserted domain.RuleConfig
outbox []domain.DrawOutbox
grantedDrawID string
deliveredOutboxID string
}
func (r *fakeLuckyGiftRepository) GetLuckyGiftConfig(context.Context, string) (domain.Config, bool, error) {
func (r *fakeLuckyGiftRepository) GetLuckyGiftRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
r.getConfigCalls++
return domain.Config{}, false, nil
return domain.RuleConfig{}, false, nil
}
func (r *fakeLuckyGiftRepository) UpsertLuckyGiftConfig(_ context.Context, config domain.Config, _ int64) (domain.Config, error) {
func (r *fakeLuckyGiftRepository) PublishLuckyGiftRuleConfig(_ context.Context, config domain.RuleConfig, _ int64) (domain.RuleConfig, error) {
r.upserted = config
return config, nil
}
func (r *fakeLuckyGiftRepository) ListLuckyGiftConfigs(context.Context) ([]domain.Config, error) {
func (r *fakeLuckyGiftRepository) ListLuckyGiftRuleConfigs(context.Context) ([]domain.RuleConfig, error) {
return nil, nil
}

View File

@ -122,154 +122,12 @@ func (c luckyCandidate) effectiveReward() int64 {
return c.BaseReward + c.RoomReward + c.ActivityReward
}
// GetLuckyGiftConfig 返回指定幸运礼物奖池规则;未配置由用例层转为默认草稿。
func (r *Repository) GetLuckyGiftConfig(ctx context.Context, poolID string) (domain.Config, bool, error) {
if r == nil || r.db == nil {
return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
poolID = luckyPoolID(poolID)
return r.getLuckyGiftConfig(ctx, r.db, appcode.FromContext(ctx), poolID, false)
}
// UpsertLuckyGiftConfig 发布新的不可变规则版本。运行中窗口不回退,新版本只影响后续抽奖。
func (r *Repository) UpsertLuckyGiftConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) {
if r == nil || r.db == nil {
return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Config{}, err
}
defer func() { _ = tx.Rollback() }()
appCode := appcode.FromContext(ctx)
config.PoolID = luckyPoolID(config.PoolID)
config.GiftID = config.PoolID
current, exists, err := r.getLuckyGiftConfig(ctx, tx, appCode, config.GiftID, true)
if err != nil {
return domain.Config{}, err
}
nextVersion := int64(1)
createdAt := nowMS
if exists {
nextVersion = current.RuleVersion + 1
createdAt = current.CreatedAtMS
}
config.AppCode = appCode
config.RuleVersion = nextVersion
config.CreatedAtMS = createdAt
config.UpdatedAtMS = nowMS
tiersJSON, err := json.Marshal(config.Tiers)
if err != nil {
return domain.Config{}, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_rules (
app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm,
global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit,
high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm,
gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool,
platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap,
user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap,
room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve,
activity_budget, activity_daily_limit, large_tier_enabled, tiers_json,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
rule_version = VALUES(rule_version),
gift_price = VALUES(gift_price),
target_rtp_ppm = VALUES(target_rtp_ppm),
pool_rate_ppm = VALUES(pool_rate_ppm),
global_window_draws = VALUES(global_window_draws),
gift_window_draws = VALUES(gift_window_draws),
novice_draw_limit = VALUES(novice_draw_limit),
intermediate_draw_limit = VALUES(intermediate_draw_limit),
high_multiplier = VALUES(high_multiplier),
high_water_pool_multiple = VALUES(high_water_pool_multiple),
platform_pool_weight_ppm = VALUES(platform_pool_weight_ppm),
room_pool_weight_ppm = VALUES(room_pool_weight_ppm),
gift_pool_weight_ppm = VALUES(gift_pool_weight_ppm),
initial_platform_pool = VALUES(initial_platform_pool),
initial_gift_pool = VALUES(initial_gift_pool),
initial_room_pool = VALUES(initial_room_pool),
platform_reserve = VALUES(platform_reserve),
gift_reserve = VALUES(gift_reserve),
room_reserve = VALUES(room_reserve),
max_single_payout = VALUES(max_single_payout),
user_hourly_payout_cap = VALUES(user_hourly_payout_cap),
user_daily_payout_cap = VALUES(user_daily_payout_cap),
device_daily_payout_cap = VALUES(device_daily_payout_cap),
room_hourly_payout_cap = VALUES(room_hourly_payout_cap),
anchor_daily_payout_cap = VALUES(anchor_daily_payout_cap),
room_atmosphere_rate_ppm = VALUES(room_atmosphere_rate_ppm),
room_atmosphere_initial = VALUES(room_atmosphere_initial),
room_atmosphere_reserve = VALUES(room_atmosphere_reserve),
activity_budget = VALUES(activity_budget),
activity_daily_limit = VALUES(activity_daily_limit),
large_tier_enabled = VALUES(large_tier_enabled),
tiers_json = VALUES(tiers_json),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
appCode, config.GiftID, config.Enabled, config.RuleVersion, config.GiftPrice, config.TargetRTPPPM, config.PoolRatePPM,
config.GlobalWindowDraws, config.GiftWindowDraws, config.NoviceDrawLimit, config.IntermediateDrawLimit,
config.HighMultiplier, config.HighWaterPoolMultiple, config.PlatformPoolWeightPPM, config.RoomPoolWeightPPM,
config.GiftPoolWeightPPM, config.InitialPlatformPool, config.InitialGiftPool, config.InitialRoomPool,
config.PlatformReserve, config.GiftReserve, config.RoomReserve, config.MaxSinglePayout, config.UserHourlyPayoutCap,
config.UserDailyPayoutCap, config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
config.RoomAtmosphereRatePPM, config.RoomAtmosphereInitial, config.RoomAtmosphereReserve,
config.ActivityBudget, config.ActivityDailyLimit, config.LargeTierEnabled, string(tiersJSON),
config.UpdatedByAdminID, config.CreatedAtMS, config.UpdatedAtMS,
); err != nil {
return domain.Config{}, err
}
if err := r.ensureLuckyActivityBudget(ctx, tx, appCode, config, nowMS); err != nil {
return domain.Config{}, err
}
if err := tx.Commit(); err != nil {
return domain.Config{}, err
}
return config, nil
}
func (r *Repository) ListLuckyGiftConfigs(ctx context.Context) ([]domain.Config, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm,
global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit,
high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm,
gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool,
platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap,
user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap,
room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve,
activity_budget, activity_daily_limit, large_tier_enabled,
COALESCE(CAST(tiers_json AS CHAR), '[]'), updated_by_admin_id, created_at_ms, updated_at_ms
FROM lucky_gift_rules
WHERE app_code = ?
ORDER BY updated_at_ms DESC, gift_id ASC`,
appCode,
)
if err != nil {
return nil, err
}
defer rows.Close()
configs := make([]domain.Config, 0)
for rows.Next() {
config, err := scanLuckyConfig(rows)
if err != nil {
return nil, err
}
configs = append(configs, config)
}
return configs, rows.Err()
}
func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) {
if r == nil || r.db == nil {
return domain.CheckResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
poolID := luckyPoolID(cmd.PoolID)
config, exists, err := r.GetLuckyGiftConfig(ctx, poolID)
config, exists, err := r.getLuckyGiftConfig(ctx, r.db, appcode.FromContext(ctx), poolID, false)
if err != nil {
return domain.CheckResult{}, err
}

View File

@ -0,0 +1,215 @@
package mysql
import (
"context"
"database/sql"
"errors"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/luckygift"
)
type luckyRuleQueryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
// GetLuckyGiftRuleConfig 读取指定奖池最新 v2 规则版本;旧抽奖运行表不会参与后台配置读取。
func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) {
if r == nil || r.db == nil {
return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
return r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), luckyPoolID(poolID), false)
}
// PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;后续 v2 抽奖引擎按版本读取快照。
func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
if r == nil || r.db == nil {
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.RuleConfig{}, err
}
defer func() { _ = tx.Rollback() }()
appCode := appcode.FromContext(ctx)
config.AppCode = appCode
config.PoolID = luckyPoolID(config.PoolID)
config.RuleVersion = 1
config.CreatedAtMS = nowMS
if config.EffectiveFromMS <= 0 {
config.EffectiveFromMS = nowMS
}
var currentVersion int64
err = tx.QueryRowContext(ctx, `
SELECT rule_version
FROM lucky_gift_rule_versions
WHERE app_code = ? AND pool_id = ?
ORDER BY rule_version DESC
LIMIT 1
FOR UPDATE`,
appCode, config.PoolID,
).Scan(&currentVersion)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return domain.RuleConfig{}, err
}
if err == nil {
config.RuleVersion = currentVersion + 1
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_rule_versions (
app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference,
effective_from_ms, created_by_admin_id, created_at_ms
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.TargetRTPPPM, config.PoolRatePPM,
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS,
); err != nil {
return domain.RuleConfig{}, err
}
for _, stage := range config.Stages {
for _, tier := range stage.Tiers {
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_stage_tiers (
app_code, pool_id, rule_version, stage, tier_id, multiplier_ppm, base_weight_ppm,
reward_source, high_water_only, broadcast_level, enabled
) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM,
tier.RewardSource, tier.HighWaterOnly, tier.BroadcastLevel, tier.Enabled,
); err != nil {
return domain.RuleConfig{}, err
}
}
}
if err := tx.Commit(); err != nil {
return domain.RuleConfig{}, err
}
return config, nil
}
// ListLuckyGiftRuleConfigs 返回每个奖池的最新 v2 规则版本,用于后台奖池列表。
func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
rows, err := r.db.QueryContext(ctx, `
SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.target_rtp_ppm, v.pool_rate_ppm,
v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference,
v.effective_from_ms, v.created_by_admin_id, v.created_at_ms
FROM lucky_gift_rule_versions v
INNER JOIN (
SELECT app_code, pool_id, MAX(rule_version) AS rule_version
FROM lucky_gift_rule_versions
WHERE app_code = ?
GROUP BY app_code, pool_id
) latest ON latest.app_code = v.app_code AND latest.pool_id = v.pool_id AND latest.rule_version = v.rule_version
ORDER BY v.created_at_ms DESC, v.pool_id ASC`,
appCode,
)
if err != nil {
return nil, err
}
defer rows.Close()
configs := make([]domain.RuleConfig, 0)
for rows.Next() {
config, err := scanLuckyGiftRuleConfig(rows)
if err != nil {
return nil, err
}
stages, err := r.getLuckyGiftRuleStages(ctx, r.db, config.AppCode, config.PoolID, config.RuleVersion)
if err != nil {
return nil, err
}
config.Stages = stages
configs = append(configs, config)
}
return configs, rows.Err()
}
func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, forUpdate bool) (domain.RuleConfig, bool, error) {
if poolID == "" {
return domain.RuleConfig{}, false, nil
}
lockSQL := ""
if forUpdate {
lockSQL = " FOR UPDATE"
}
row := q.QueryRowContext(ctx, `
SELECT app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference,
effective_from_ms, created_by_admin_id, created_at_ms
FROM lucky_gift_rule_versions
WHERE app_code = ? AND pool_id = ?
ORDER BY rule_version DESC
LIMIT 1`+lockSQL,
appCode, poolID,
)
config, err := scanLuckyGiftRuleConfig(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.RuleConfig{}, false, nil
}
return domain.RuleConfig{}, false, err
}
stages, err := r.getLuckyGiftRuleStages(ctx, q, appCode, poolID, config.RuleVersion)
if err != nil {
return domain.RuleConfig{}, false, err
}
config.Stages = stages
return config, true, nil
}
func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64) ([]domain.RuleStage, error) {
rows, err := q.QueryContext(ctx, `
SELECT stage, tier_id, multiplier_ppm, base_weight_ppm, reward_source, high_water_only, broadcast_level, enabled
FROM lucky_gift_stage_tiers
WHERE app_code = ? AND pool_id = ? AND rule_version = ?
ORDER BY FIELD(stage, 'novice', 'normal', 'advanced'), multiplier_ppm ASC, tier_id ASC`,
appCode, poolID, ruleVersion,
)
if err != nil {
return nil, err
}
defer rows.Close()
stageIndex := map[string]int{}
stages := make([]domain.RuleStage, 0, 3)
for rows.Next() {
var tier domain.RuleTier
if err := rows.Scan(&tier.Stage, &tier.TierID, &tier.MultiplierPPM, &tier.BaseWeightPPM, &tier.RewardSource, &tier.HighWaterOnly, &tier.BroadcastLevel, &tier.Enabled); err != nil {
return nil, err
}
index, exists := stageIndex[tier.Stage]
if !exists {
index = len(stages)
stageIndex[tier.Stage] = index
stages = append(stages, domain.RuleStage{Stage: tier.Stage})
}
stages[index].Tiers = append(stages[index].Tiers, tier)
}
return stages, rows.Err()
}
func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
var config domain.RuleConfig
if err := scanner.Scan(
&config.AppCode,
&config.PoolID,
&config.RuleVersion,
&config.Enabled,
&config.TargetRTPPPM,
&config.PoolRatePPM,
&config.SettlementWindowWager,
&config.ControlBandPPM,
&config.GiftPriceReference,
&config.EffectiveFromMS,
&config.CreatedByAdminID,
&config.CreatedAtMS,
); err != nil {
return domain.RuleConfig{}, err
}
return config, nil
}

View File

@ -51,6 +51,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil {
return err
}
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
return err
}
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
return err
}
@ -148,6 +151,49 @@ func (r *Repository) ensureLuckyDrawRewardSettlementColumns(ctx context.Context)
return nil
}
func (r *Repository) ensureLuckyGiftRuleVersionTables(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
target_rtp_ppm BIGINT NOT NULL COMMENT '基础返奖目标ppm',
pool_rate_ppm BIGINT NOT NULL COMMENT '每笔扣费进入基础奖池比例ppm',
settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水单位金币',
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm',
gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格',
effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, rule_version),
KEY idx_lucky_gift_rule_versions_latest (app_code, pool_id, rule_version),
KEY idx_lucky_gift_rule_versions_enabled (app_code, enabled, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 v2 规则版本表'`,
`CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '规则版本',
stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced',
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000',
base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率ppm',
reward_source VARCHAR(32) NOT NULL COMMENT 'base_rtp/activity_subsidy/presentation_only',
high_water_only TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否高水位才开放',
broadcast_level VARCHAR(32) NOT NULL DEFAULT 'none' COMMENT 'none/room/region/global',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
PRIMARY KEY (app_code, pool_id, rule_version, stage, tier_id),
KEY idx_lucky_gift_stage_tiers_stage (app_code, pool_id, rule_version, stage)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 v2 阶段奖档表'`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureDefaultLuckyGiftRules(ctx context.Context) error {
// 本地和 Docker 开发库通常保留 MySQL volume这里补齐缺省 lucky/super_lucky 契约,但不覆盖后台已发布规则。
const seedNowMS int64 = 1779259000000

View File

@ -84,18 +84,18 @@ func (s *AdminLuckyGiftServer) GetLuckyGiftConfig(ctx context.Context, req *acti
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetLuckyGiftConfigResponse{Config: luckyConfigToProto(config)}, nil
return &activityv1.GetLuckyGiftConfigResponse{Config: luckyRuleConfigToProto(config)}, nil
}
func (s *AdminLuckyGiftServer) UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config := luckyConfigFromProto(req.GetConfig())
config.UpdatedByAdminID = req.GetOperatorAdminId()
config := luckyRuleConfigFromProto(req.GetConfig())
config.CreatedByAdminID = req.GetOperatorAdminId()
updated, err := s.svc.UpsertConfig(ctx, config)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.UpsertLuckyGiftConfigResponse{Config: luckyConfigToProto(updated)}, nil
return &activityv1.UpsertLuckyGiftConfigResponse{Config: luckyRuleConfigToProto(updated)}, nil
}
func (s *AdminLuckyGiftServer) ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error) {
@ -104,9 +104,9 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftConfigs(ctx context.Context, req *ac
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.ListLuckyGiftConfigsResponse{Configs: make([]*activityv1.LuckyGiftConfig, 0, len(configs))}
resp := &activityv1.ListLuckyGiftConfigsResponse{Configs: make([]*activityv1.LuckyGiftRuleConfig, 0, len(configs))}
for _, config := range configs {
resp.Configs = append(resp.Configs, luckyConfigToProto(config))
resp.Configs = append(resp.Configs, luckyRuleConfigToProto(config))
}
return resp, nil
}
@ -147,120 +147,82 @@ func (s *AdminLuckyGiftServer) GetLuckyGiftDrawSummary(ctx context.Context, req
return &activityv1.GetLuckyGiftDrawSummaryResponse{Summary: luckyDrawSummaryToProto(summary)}, nil
}
func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config {
func luckyRuleConfigFromProto(config *activityv1.LuckyGiftRuleConfig) domain.RuleConfig {
if config == nil {
return domain.Config{}
return domain.RuleConfig{}
}
tiers := make([]domain.Tier, 0, len(config.GetTiers()))
for _, tier := range config.GetTiers() {
tiers = append(tiers, domain.Tier{
Pool: tier.GetPool(),
TierID: tier.GetTierId(),
RewardCoins: tier.GetRewardCoins(),
MultiplierPPM: tier.GetMultiplierPpm(),
Weight: tier.GetWeight(),
HighWaterOnly: tier.GetHighWaterOnly(),
Enabled: tier.GetEnabled(),
stages := make([]domain.RuleStage, 0, len(config.GetStages()))
for _, stage := range config.GetStages() {
tiers := make([]domain.RuleTier, 0, len(stage.GetTiers()))
for _, tier := range stage.GetTiers() {
tiers = append(tiers, domain.RuleTier{
Stage: tier.GetStage(),
TierID: tier.GetTierId(),
MultiplierPPM: tier.GetMultiplierPpm(),
BaseWeightPPM: tier.GetBaseWeightPpm(),
RewardSource: tier.GetRewardSource(),
HighWaterOnly: tier.GetHighWaterOnly(),
BroadcastLevel: tier.GetBroadcastLevel(),
Enabled: tier.GetEnabled(),
})
}
stages = append(stages, domain.RuleStage{
Stage: stage.GetStage(),
Tiers: tiers,
})
}
return domain.Config{
return domain.RuleConfig{
AppCode: config.GetAppCode(),
GiftID: config.GetGiftId(),
PoolID: config.GetPoolId(),
Enabled: config.GetEnabled(),
RuleVersion: config.GetRuleVersion(),
GiftPrice: config.GetGiftPrice(),
Enabled: config.GetEnabled(),
TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(),
GlobalWindowDraws: config.GetGlobalWindowDraws(),
GiftWindowDraws: config.GetGiftWindowDraws(),
NoviceDrawLimit: config.GetNoviceDrawLimit(),
IntermediateDrawLimit: config.GetIntermediateDrawLimit(),
HighMultiplier: config.GetHighMultiplier(),
HighWaterPoolMultiple: config.GetHighWaterPoolMultiple(),
PlatformPoolWeightPPM: config.GetPlatformPoolWeightPpm(),
RoomPoolWeightPPM: config.GetRoomPoolWeightPpm(),
GiftPoolWeightPPM: config.GetGiftPoolWeightPpm(),
InitialPlatformPool: config.GetInitialPlatformPool(),
InitialGiftPool: config.GetInitialGiftPool(),
InitialRoomPool: config.GetInitialRoomPool(),
PlatformReserve: config.GetPlatformReserve(),
GiftReserve: config.GetGiftReserve(),
RoomReserve: config.GetRoomReserve(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
RoomAtmosphereRatePPM: config.GetRoomAtmosphereRatePpm(),
RoomAtmosphereInitial: config.GetRoomAtmosphereInitial(),
RoomAtmosphereReserve: config.GetRoomAtmosphereReserve(),
ActivityBudget: config.GetActivityBudget(),
ActivityDailyLimit: config.GetActivityDailyLimit(),
LargeTierEnabled: config.GetLargeTierEnabled(),
MultiplierPPMs: append([]int64(nil), config.GetMultiplierPpms()...),
Tiers: tiers,
UpdatedByAdminID: config.GetUpdatedByAdminId(),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPPM: config.GetControlBandPpm(),
GiftPriceReference: config.GetGiftPriceReference(),
EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
Stages: stages,
}
}
func luckyConfigToProto(config domain.Config) *activityv1.LuckyGiftConfig {
tiers := make([]*activityv1.LuckyGiftTier, 0, len(config.Tiers))
for _, tier := range config.Tiers {
tiers = append(tiers, &activityv1.LuckyGiftTier{
Pool: tier.Pool,
TierId: tier.TierID,
RewardCoins: tier.RewardCoins,
MultiplierPpm: tier.MultiplierPPM,
Weight: tier.Weight,
HighWaterOnly: tier.HighWaterOnly,
Enabled: tier.Enabled,
func luckyRuleConfigToProto(config domain.RuleConfig) *activityv1.LuckyGiftRuleConfig {
stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(config.Stages))
for _, stage := range config.Stages {
tiers := make([]*activityv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
for _, tier := range stage.Tiers {
tiers = append(tiers, &activityv1.LuckyGiftRuleTier{
Stage: tier.Stage,
TierId: tier.TierID,
MultiplierPpm: tier.MultiplierPPM,
BaseWeightPpm: tier.BaseWeightPPM,
RewardSource: tier.RewardSource,
HighWaterOnly: tier.HighWaterOnly,
BroadcastLevel: tier.BroadcastLevel,
Enabled: tier.Enabled,
})
}
stages = append(stages, &activityv1.LuckyGiftRuleStage{
Stage: stage.Stage,
Tiers: tiers,
})
}
return &activityv1.LuckyGiftConfig{
return &activityv1.LuckyGiftRuleConfig{
AppCode: config.AppCode,
GiftId: config.GiftID,
PoolId: config.PoolID,
Enabled: config.Enabled,
RuleVersion: config.RuleVersion,
GiftPrice: config.GiftPrice,
Enabled: config.Enabled,
TargetRtpPpm: config.TargetRTPPPM,
PoolRatePpm: config.PoolRatePPM,
GlobalWindowDraws: config.GlobalWindowDraws,
GiftWindowDraws: config.GiftWindowDraws,
NoviceDrawLimit: config.NoviceDrawLimit,
IntermediateDrawLimit: config.IntermediateDrawLimit,
HighMultiplier: config.HighMultiplier,
HighWaterPoolMultiple: config.HighWaterPoolMultiple,
PlatformPoolWeightPpm: config.PlatformPoolWeightPPM,
RoomPoolWeightPpm: config.RoomPoolWeightPPM,
GiftPoolWeightPpm: config.GiftPoolWeightPPM,
InitialPlatformPool: config.InitialPlatformPool,
InitialGiftPool: config.InitialGiftPool,
InitialRoomPool: config.InitialRoomPool,
PlatformReserve: config.PlatformReserve,
GiftReserve: config.GiftReserve,
RoomReserve: config.RoomReserve,
MaxSinglePayout: config.MaxSinglePayout,
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
UserDailyPayoutCap: config.UserDailyPayoutCap,
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
RoomAtmosphereRatePpm: config.RoomAtmosphereRatePPM,
RoomAtmosphereInitial: config.RoomAtmosphereInitial,
RoomAtmosphereReserve: config.RoomAtmosphereReserve,
ActivityBudget: config.ActivityBudget,
ActivityDailyLimit: config.ActivityDailyLimit,
LargeTierEnabled: config.LargeTierEnabled,
MultiplierPpms: append([]int64(nil), config.MultiplierPPMs...),
Tiers: tiers,
UpdatedByAdminId: config.UpdatedByAdminID,
SettlementWindowWager: config.SettlementWindowWager,
ControlBandPpm: config.ControlBandPPM,
GiftPriceReference: config.GiftPriceReference,
EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
Stages: stages,
}
}