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. # Docker and database runtime data.
mysql-data/ mysql-data/
redis-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; 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 { message CheckLuckyGiftRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
int64 user_id = 2; int64 user_id = 2;
@ -1240,17 +1272,17 @@ message GetLuckyGiftConfigRequest {
} }
message GetLuckyGiftConfigResponse { message GetLuckyGiftConfigResponse {
LuckyGiftConfig config = 1; LuckyGiftRuleConfig config = 1;
} }
message UpsertLuckyGiftConfigRequest { message UpsertLuckyGiftConfigRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
LuckyGiftConfig config = 2; LuckyGiftRuleConfig config = 2;
int64 operator_admin_id = 3; int64 operator_admin_id = 3;
} }
message UpsertLuckyGiftConfigResponse { message UpsertLuckyGiftConfigResponse {
LuckyGiftConfig config = 1; LuckyGiftRuleConfig config = 1;
} }
message ListLuckyGiftConfigsRequest { message ListLuckyGiftConfigsRequest {
@ -1258,7 +1290,7 @@ message ListLuckyGiftConfigsRequest {
} }
message ListLuckyGiftConfigsResponse { message ListLuckyGiftConfigsResponse {
repeated LuckyGiftConfig configs = 1; repeated LuckyGiftRuleConfig configs = 1;
} }
message ListLuckyGiftDrawsRequest { message ListLuckyGiftDrawsRequest {

View File

@ -1,6 +1,7 @@
package luckygift package luckygift
import ( import (
"math"
"strings" "strings"
"time" "time"
@ -24,60 +25,38 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
} }
type configRequest struct { type configRequest struct {
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
GiftPrice int64 `json:"gift_price"` TargetRTPPercent float64 `json:"target_rtp_percent"`
TargetRTPPPM int64 `json:"target_rtp_ppm"` PoolRatePercent float64 `json:"pool_rate_percent"`
PoolRatePPM int64 `json:"pool_rate_ppm"` SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"` ControlBandPercent float64 `json:"control_band_percent"`
GiftWindowDraws int64 `json:"gift_window_draws"` GiftPriceReference int64 `json:"gift_price_reference"`
NoviceDrawLimit int64 `json:"novice_draw_limit"` EffectiveFromMS int64 `json:"effective_from_ms"`
IntermediateDrawLimit int64 `json:"intermediate_draw_limit"` Stages []stageDTO `json:"stages"`
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"`
} }
type configDTO struct { type configDTO struct {
configRequest configRequest
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
GiftID string `json:"gift_id"`
RuleVersion int64 `json:"rule_version"` 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"` 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 { type tierDTO struct {
Pool string `json:"pool"` TierID string `json:"tier_id"`
TierID string `json:"tier_id"` Multiplier float64 `json:"multiplier"`
RewardCoins int64 `json:"reward_coins"` ProbabilityPercent float64 `json:"probability_percent"`
MultiplierPPM int64 `json:"multiplier_ppm"` RewardSource string `json:"reward_source"`
Weight int64 `json:"weight"` HighWaterOnly bool `json:"high_water_only"`
HighWaterOnly bool `json:"high_water_only"` BroadcastLevel string `json:"broadcast_level"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
type drawDTO struct { type drawDTO struct {
@ -218,123 +197,99 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
} }
} }
func configToProto(req configRequest) *activityv1.LuckyGiftConfig { func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig {
tiers := make([]*activityv1.LuckyGiftTier, 0, len(req.Tiers)) stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages))
for _, tier := range req.Tiers { for _, stage := range req.Stages {
tiers = append(tiers, &activityv1.LuckyGiftTier{ tiers := make([]*activityv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
Pool: tier.Pool, for _, tier := range stage.Tiers {
TierId: tier.TierID, tiers = append(tiers, &activityv1.LuckyGiftRuleTier{
RewardCoins: tier.RewardCoins, Stage: strings.TrimSpace(stage.Stage),
MultiplierPpm: tier.MultiplierPPM, TierId: strings.TrimSpace(tier.TierID),
Weight: tier.Weight, MultiplierPpm: multiplierToPPM(tier.Multiplier),
HighWaterOnly: tier.HighWaterOnly, BaseWeightPpm: percentToPPM(tier.ProbabilityPercent),
Enabled: tier.Enabled, 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), PoolId: strings.TrimSpace(req.PoolID),
Enabled: req.Enabled, Enabled: req.Enabled,
GiftPrice: req.GiftPrice, TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
TargetRtpPpm: req.TargetRTPPPM, PoolRatePpm: percentToPPM(req.PoolRatePercent),
PoolRatePpm: req.PoolRatePPM, SettlementWindowWager: req.SettlementWindowWager,
GlobalWindowDraws: req.GlobalWindowDraws, ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftWindowDraws: req.GiftWindowDraws, GiftPriceReference: req.GiftPriceReference,
NoviceDrawLimit: req.NoviceDrawLimit, EffectiveFromMs: req.EffectiveFromMS,
IntermediateDrawLimit: req.IntermediateDrawLimit, Stages: stages,
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,
} }
} }
func configFromProto(config *activityv1.LuckyGiftConfig) configDTO { func configFromProto(config *activityv1.LuckyGiftRuleConfig) configDTO {
if config == nil { if config == nil {
return configDTO{} return configDTO{}
} }
tiers := make([]tierDTO, 0, len(config.GetTiers())) stages := make([]stageDTO, 0, len(config.GetStages()))
for _, tier := range config.GetTiers() { for _, stage := range config.GetStages() {
tiers = append(tiers, tierDTO{ tiers := make([]tierDTO, 0, len(stage.GetTiers()))
Pool: tier.GetPool(), for _, tier := range stage.GetTiers() {
TierID: tier.GetTierId(), tiers = append(tiers, tierDTO{
RewardCoins: tier.GetRewardCoins(), TierID: tier.GetTierId(),
MultiplierPPM: tier.GetMultiplierPpm(), Multiplier: ppmToMultiplier(tier.GetMultiplierPpm()),
Weight: tier.GetWeight(), ProbabilityPercent: ppmToPercent(tier.GetBaseWeightPpm()),
HighWaterOnly: tier.GetHighWaterOnly(), RewardSource: tier.GetRewardSource(),
Enabled: tier.GetEnabled(), HighWaterOnly: tier.GetHighWaterOnly(),
BroadcastLevel: tier.GetBroadcastLevel(),
Enabled: tier.GetEnabled(),
})
}
stages = append(stages, stageDTO{
Stage: stage.GetStage(),
Tiers: tiers,
}) })
} }
poolID := strings.TrimSpace(config.GetPoolId()) poolID := strings.TrimSpace(config.GetPoolId())
if poolID == "" {
poolID = strings.TrimSpace(config.GetGiftId())
}
return configDTO{ return configDTO{
AppCode: config.GetAppCode(), AppCode: config.GetAppCode(),
GiftID: config.GetGiftId(),
RuleVersion: config.GetRuleVersion(), RuleVersion: config.GetRuleVersion(),
UpdatedByAdminID: config.GetUpdatedByAdminId(), CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(), CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
configRequest: configRequest{ configRequest: configRequest{
PoolID: poolID, PoolID: poolID,
Enabled: config.GetEnabled(), Enabled: config.GetEnabled(),
GiftPrice: config.GetGiftPrice(), TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
TargetRTPPPM: config.GetTargetRtpPpm(), PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
PoolRatePPM: config.GetPoolRatePpm(), SettlementWindowWager: config.GetSettlementWindowWager(),
GlobalWindowDraws: config.GetGlobalWindowDraws(), ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftWindowDraws: config.GetGiftWindowDraws(), GiftPriceReference: config.GetGiftPriceReference(),
NoviceDrawLimit: config.GetNoviceDrawLimit(), EffectiveFromMS: config.GetEffectiveFromMs(),
IntermediateDrawLimit: config.GetIntermediateDrawLimit(), Stages: stages,
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,
}, },
} }
} }
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 { func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
if draw == nil { if draw == nil {
return drawDTO{} 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) KEY idx_lucky_gift_rules_enabled (app_code, enabled, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物规则表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物规则表';
CREATE TABLE IF NOT EXISTS lucky_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 ( CREATE TABLE IF NOT EXISTS lucky_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift', scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift',

View File

@ -15,10 +15,19 @@ const (
PoolIntermediate = "intermediate" PoolIntermediate = "intermediate"
PoolAdvanced = "advanced" PoolAdvanced = "advanced"
StageNovice = "novice"
StageNormal = "normal"
StageAdvanced = "advanced"
SourceBaseRTP = "base_rtp" SourceBaseRTP = "base_rtp"
SourceRoomAtmosphere = "room_atmosphere" SourceRoomAtmosphere = "room_atmosphere"
SourceActivity = "activity_subsidy" SourceActivity = "activity_subsidy"
SourcePresentation = "presentation_only" SourcePresentation = "presentation_only"
BroadcastNone = "none"
BroadcastRoom = "room"
BroadcastRegion = "region"
BroadcastGlobal = "global"
) )
// Tier 是单个体验池里的候选奖档;权重只塑造体验节奏,基础成本仍由 RTP 窗口兜住。 // Tier 是单个体验池里的候选奖档;权重只塑造体验节奏,基础成本仍由 RTP 窗口兜住。
@ -76,6 +85,41 @@ type Config struct {
UpdatedAtMS int64 `json:"updated_at_ms"` 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 { type CheckCommand struct {
PoolID string PoolID string
GiftID string GiftID string

View File

@ -9,7 +9,10 @@ import (
domain "hyapp/services/activity-service/internal/domain/luckygift" 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。 // DefaultConfig 给后台指定奖池提供规则草稿;真正生效必须由后台显式 enabled。
func DefaultConfig(appCode, poolID string) domain.Config { 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 只校验会破坏线上抽奖不变量的字段;展示类默认值留给后台表单处理。 // validateConfig 只校验会破坏线上抽奖不变量的字段;展示类默认值留给后台表单处理。
func validateConfig(config domain.Config) error { func validateConfig(config domain.Config) error {
if normalizePoolID(config.PoolID) == "" { 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") 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 事务内修改。 // Repository 是幸运礼物唯一持久化边界RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
type Repository interface { type Repository interface {
GetLuckyGiftConfig(ctx context.Context, poolID string) (domain.Config, bool, error) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error)
UpsertLuckyGiftConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
ListLuckyGiftConfigs(ctx context.Context) ([]domain.Config, error) ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error)
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, 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) 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 { if err := s.requireRepository(); err != nil {
return domain.Config{}, err return domain.RuleConfig{}, err
} }
poolID = normalizePoolID(poolID) poolID = normalizePoolID(poolID)
config, exists, err := s.repository.GetLuckyGiftConfig(ctx, poolID) config, exists, err := s.repository.GetLuckyGiftRuleConfig(ctx, poolID)
if err != nil { if err != nil {
return domain.Config{}, err return domain.RuleConfig{}, err
} }
if !exists { if !exists {
// 未配置时返回 disabled 草稿,后台可以直接编辑发布;不能在运行时把草稿当启用规则 // 未配置时返回 disabled v2 草稿,后台可以直接编辑发布;运行抽奖不会读取这个草稿
return DefaultConfig(appcode.FromContext(ctx), poolID), nil return DefaultRuleConfig(appcode.FromContext(ctx), poolID), nil
} }
return config, 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 { if err := s.requireRepository(); err != nil {
return domain.Config{}, err return domain.RuleConfig{}, err
} }
config.AppCode = appcode.FromContext(ctx) config.AppCode = appcode.FromContext(ctx)
config.PoolID = normalizePoolID(config.PoolID) config.PoolID = normalizePoolID(config.PoolID)
// 规则表历史上用 gift_id 承载作用域;当前以 pool_id 为真实业务键,发布时必须覆盖掉外部传入的 gift_id。 config = normalizeRuleConfig(config)
config.GiftID = config.PoolID if err := validateRuleConfig(config); err != nil {
// 后台现在配置倍率列表,服务端生成三档体验池候选;概率权重必须留给抽奖时的 RTP 控制器。 return domain.RuleConfig{}, err
config.Tiers, config.MultiplierPPMs = normalizeTiers(config.Tiers, config.MultiplierPPMs, config.GiftPrice, config.HighMultiplier)
if err := validateConfig(config); err != nil {
return domain.Config{}, 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 { if err := s.requireRepository(); err != nil {
return nil, err 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) { 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 { if repository.getConfigCalls != 1 {
t.Fatalf("expected repository to be called once, got %d", repository.getConfigCalls) t.Fatalf("expected repository to be called once, got %d", repository.getConfigCalls)
} }
if config.PoolID != "pool_95" || config.GiftID != "pool_95" { if config.PoolID != "pool_95" {
t.Fatalf("expected pool config scope, got pool=%q gift=%q", config.PoolID, config.GiftID) t.Fatalf("expected pool config scope, got pool=%q", config.PoolID)
} }
} }
func TestUpsertConfigPreservesPoolScope(t *testing.T) { func TestUpsertConfigPreservesPoolScope(t *testing.T) {
repository := &fakeLuckyGiftRepository{} repository := &fakeLuckyGiftRepository{}
service := New(repository) service := New(repository)
config := DefaultConfig("hyapp", "pool_98") config := DefaultRuleConfig("hyapp", "pool_98")
config.Enabled = true config.Enabled = true
if _, err := service.UpsertConfig(appcode.WithContext(context.Background(), "hyapp"), config); err != nil { if _, err := service.UpsertConfig(appcode.WithContext(context.Background(), "hyapp"), config); err != nil {
t.Fatalf("UpsertConfig returned error: %v", err) t.Fatalf("UpsertConfig returned error: %v", err)
} }
if repository.upserted.PoolID != "pool_98" || repository.upserted.GiftID != "pool_98" { if repository.upserted.PoolID != "pool_98" {
t.Fatalf("expected pool scope, got pool=%q gift=%q", repository.upserted.PoolID, repository.upserted.GiftID) t.Fatalf("expected pool scope, got pool=%q", repository.upserted.PoolID)
} }
} }
@ -110,24 +110,24 @@ func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomIM(t *testing.T) {
type fakeLuckyGiftRepository struct { type fakeLuckyGiftRepository struct {
getConfigCalls int getConfigCalls int
upserted domain.Config upserted domain.RuleConfig
outbox []domain.DrawOutbox outbox []domain.DrawOutbox
grantedDrawID string grantedDrawID string
deliveredOutboxID 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++ 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 r.upserted = config
return config, nil return config, nil
} }
func (r *fakeLuckyGiftRepository) ListLuckyGiftConfigs(context.Context) ([]domain.Config, error) { func (r *fakeLuckyGiftRepository) ListLuckyGiftRuleConfigs(context.Context) ([]domain.RuleConfig, error) {
return nil, nil return nil, nil
} }

View File

@ -122,154 +122,12 @@ func (c luckyCandidate) effectiveReward() int64 {
return c.BaseReward + c.RoomReward + c.ActivityReward 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) { 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) 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 { if err != nil {
return domain.CheckResult{}, err 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 { if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil {
return err return err
} }
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
return err
}
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil { if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
return err return err
} }
@ -148,6 +151,49 @@ func (r *Repository) ensureLuckyDrawRewardSettlementColumns(ctx context.Context)
return nil 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 { func (r *Repository) ensureDefaultLuckyGiftRules(ctx context.Context) error {
// 本地和 Docker 开发库通常保留 MySQL volume这里补齐缺省 lucky/super_lucky 契约,但不覆盖后台已发布规则。 // 本地和 Docker 开发库通常保留 MySQL volume这里补齐缺省 lucky/super_lucky 契约,但不覆盖后台已发布规则。
const seedNowMS int64 = 1779259000000 const seedNowMS int64 = 1779259000000

View File

@ -84,18 +84,18 @@ func (s *AdminLuckyGiftServer) GetLuckyGiftConfig(ctx context.Context, req *acti
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) 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) { func (s *AdminLuckyGiftServer) UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config := luckyConfigFromProto(req.GetConfig()) config := luckyRuleConfigFromProto(req.GetConfig())
config.UpdatedByAdminID = req.GetOperatorAdminId() config.CreatedByAdminID = req.GetOperatorAdminId()
updated, err := s.svc.UpsertConfig(ctx, config) updated, err := s.svc.UpsertConfig(ctx, config)
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) 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) { 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 { if err != nil {
return nil, xerr.ToGRPCError(err) 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 { for _, config := range configs {
resp.Configs = append(resp.Configs, luckyConfigToProto(config)) resp.Configs = append(resp.Configs, luckyRuleConfigToProto(config))
} }
return resp, nil return resp, nil
} }
@ -147,120 +147,82 @@ func (s *AdminLuckyGiftServer) GetLuckyGiftDrawSummary(ctx context.Context, req
return &activityv1.GetLuckyGiftDrawSummaryResponse{Summary: luckyDrawSummaryToProto(summary)}, nil return &activityv1.GetLuckyGiftDrawSummaryResponse{Summary: luckyDrawSummaryToProto(summary)}, nil
} }
func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config { func luckyRuleConfigFromProto(config *activityv1.LuckyGiftRuleConfig) domain.RuleConfig {
if config == nil { if config == nil {
return domain.Config{} return domain.RuleConfig{}
} }
tiers := make([]domain.Tier, 0, len(config.GetTiers())) stages := make([]domain.RuleStage, 0, len(config.GetStages()))
for _, tier := range config.GetTiers() { for _, stage := range config.GetStages() {
tiers = append(tiers, domain.Tier{ tiers := make([]domain.RuleTier, 0, len(stage.GetTiers()))
Pool: tier.GetPool(), for _, tier := range stage.GetTiers() {
TierID: tier.GetTierId(), tiers = append(tiers, domain.RuleTier{
RewardCoins: tier.GetRewardCoins(), Stage: tier.GetStage(),
MultiplierPPM: tier.GetMultiplierPpm(), TierID: tier.GetTierId(),
Weight: tier.GetWeight(), MultiplierPPM: tier.GetMultiplierPpm(),
HighWaterOnly: tier.GetHighWaterOnly(), BaseWeightPPM: tier.GetBaseWeightPpm(),
Enabled: tier.GetEnabled(), 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(), AppCode: config.GetAppCode(),
GiftID: config.GetGiftId(),
PoolID: config.GetPoolId(), PoolID: config.GetPoolId(),
Enabled: config.GetEnabled(),
RuleVersion: config.GetRuleVersion(), RuleVersion: config.GetRuleVersion(),
GiftPrice: config.GetGiftPrice(), Enabled: config.GetEnabled(),
TargetRTPPPM: config.GetTargetRtpPpm(), TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(), PoolRatePPM: config.GetPoolRatePpm(),
GlobalWindowDraws: config.GetGlobalWindowDraws(), SettlementWindowWager: config.GetSettlementWindowWager(),
GiftWindowDraws: config.GetGiftWindowDraws(), ControlBandPPM: config.GetControlBandPpm(),
NoviceDrawLimit: config.GetNoviceDrawLimit(), GiftPriceReference: config.GetGiftPriceReference(),
IntermediateDrawLimit: config.GetIntermediateDrawLimit(), EffectiveFromMS: config.GetEffectiveFromMs(),
HighMultiplier: config.GetHighMultiplier(), CreatedByAdminID: config.GetCreatedByAdminId(),
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(),
CreatedAtMS: config.GetCreatedAtMs(), CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(), Stages: stages,
} }
} }
func luckyConfigToProto(config domain.Config) *activityv1.LuckyGiftConfig { func luckyRuleConfigToProto(config domain.RuleConfig) *activityv1.LuckyGiftRuleConfig {
tiers := make([]*activityv1.LuckyGiftTier, 0, len(config.Tiers)) stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(config.Stages))
for _, tier := range config.Tiers { for _, stage := range config.Stages {
tiers = append(tiers, &activityv1.LuckyGiftTier{ tiers := make([]*activityv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
Pool: tier.Pool, for _, tier := range stage.Tiers {
TierId: tier.TierID, tiers = append(tiers, &activityv1.LuckyGiftRuleTier{
RewardCoins: tier.RewardCoins, Stage: tier.Stage,
MultiplierPpm: tier.MultiplierPPM, TierId: tier.TierID,
Weight: tier.Weight, MultiplierPpm: tier.MultiplierPPM,
HighWaterOnly: tier.HighWaterOnly, BaseWeightPpm: tier.BaseWeightPPM,
Enabled: tier.Enabled, 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, AppCode: config.AppCode,
GiftId: config.GiftID,
PoolId: config.PoolID, PoolId: config.PoolID,
Enabled: config.Enabled,
RuleVersion: config.RuleVersion, RuleVersion: config.RuleVersion,
GiftPrice: config.GiftPrice, Enabled: config.Enabled,
TargetRtpPpm: config.TargetRTPPPM, TargetRtpPpm: config.TargetRTPPPM,
PoolRatePpm: config.PoolRatePPM, PoolRatePpm: config.PoolRatePPM,
GlobalWindowDraws: config.GlobalWindowDraws, SettlementWindowWager: config.SettlementWindowWager,
GiftWindowDraws: config.GiftWindowDraws, ControlBandPpm: config.ControlBandPPM,
NoviceDrawLimit: config.NoviceDrawLimit, GiftPriceReference: config.GiftPriceReference,
IntermediateDrawLimit: config.IntermediateDrawLimit, EffectiveFromMs: config.EffectiveFromMS,
HighMultiplier: config.HighMultiplier, CreatedByAdminId: config.CreatedByAdminID,
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,
CreatedAtMs: config.CreatedAtMS, CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS, Stages: stages,
} }
} }