lucky_gift_v2: update lucky gift activity
This commit is contained in:
parent
c93f46b191
commit
d089f4f282
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
@ -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 {
|
||||
|
||||
@ -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{}
|
||||
|
||||
107
server/admin/internal/modules/luckygift/handler_test.go
Normal file
107
server/admin/internal/modules/luckygift/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) == "" {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(¤tVersion)
|
||||
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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user