399 lines
15 KiB
Go
399 lines
15 KiB
Go
package luckygift
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
"hyapp/pkg/xerr"
|
||
domain "hyapp/services/activity-service/internal/domain/luckygift"
|
||
)
|
||
|
||
const (
|
||
ppmScale int64 = 1_000_000
|
||
highTierMultiplierPPM int64 = 10_000_000
|
||
)
|
||
|
||
// DefaultConfig 给后台指定奖池提供规则草稿;真正生效必须由后台显式 enabled。
|
||
func DefaultConfig(appCode, poolID string) domain.Config {
|
||
const giftPrice int64 = 10_000
|
||
poolID = normalizePoolID(poolID)
|
||
return domain.Config{
|
||
AppCode: appCode,
|
||
GiftID: poolID,
|
||
PoolID: poolID,
|
||
Enabled: false,
|
||
GiftPrice: giftPrice,
|
||
TargetRTPPPM: 950_000,
|
||
PoolRatePPM: 950_000,
|
||
GlobalWindowDraws: 100_000,
|
||
GiftWindowDraws: 100_000,
|
||
NoviceDrawLimit: 2_000,
|
||
IntermediateDrawLimit: 20_000,
|
||
HighMultiplier: 100,
|
||
HighWaterPoolMultiple: 2,
|
||
PlatformPoolWeightPPM: 200_000,
|
||
RoomPoolWeightPPM: 300_000,
|
||
GiftPoolWeightPPM: 500_000,
|
||
InitialPlatformPool: 9_500_000,
|
||
InitialGiftPool: 23_750_000,
|
||
InitialRoomPool: 5_000_000,
|
||
PlatformReserve: 1_000_000,
|
||
GiftReserve: 1_000_000,
|
||
RoomReserve: 100_000,
|
||
MaxSinglePayout: giftPrice * 500,
|
||
UserHourlyPayoutCap: 3_420_000,
|
||
UserDailyPayoutCap: 61_560_000,
|
||
DeviceDailyPayoutCap: 102_600_000,
|
||
RoomHourlyPayoutCap: 68_400_000,
|
||
AnchorDailyPayoutCap: 1_231_200_000,
|
||
RoomAtmosphereRatePPM: 10_000,
|
||
RoomAtmosphereInitial: 100_000,
|
||
RoomAtmosphereReserve: 10_000,
|
||
ActivityBudget: 0,
|
||
ActivityDailyLimit: 0,
|
||
LargeTierEnabled: true,
|
||
MultiplierPPMs: defaultMultiplierPPMs(),
|
||
Tiers: defaultTiers(giftPrice),
|
||
}
|
||
}
|
||
|
||
// 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) == "" {
|
||
return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
|
||
}
|
||
// 当前 DB 历史列名仍叫 gift_id,但业务语义已经是 pool_id;这里强制二者一致,避免后台提交真实礼物 ID 后把窗口和奖池切散。
|
||
config.GiftID = normalizePoolID(config.PoolID)
|
||
if config.GiftPrice <= 0 || config.TargetRTPPPM <= 0 || config.TargetRTPPPM > ppmScale {
|
||
return xerr.New(xerr.InvalidArgument, "gift price or target RTP is invalid")
|
||
}
|
||
// 入池比例低于目标 RTP 时,即使长期抽数足够也无法靠奖池支付基础返奖,必须在发布前拒绝。
|
||
if config.PoolRatePPM < config.TargetRTPPPM || config.PoolRatePPM > ppmScale {
|
||
return xerr.New(xerr.InvalidArgument, "pool rate must be between target RTP and 100%")
|
||
}
|
||
if config.GlobalWindowDraws <= 0 || config.GiftWindowDraws <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "RTP window draws must be positive")
|
||
}
|
||
if config.NoviceDrawLimit < 0 || config.IntermediateDrawLimit < config.NoviceDrawLimit {
|
||
return xerr.New(xerr.InvalidArgument, "experience pool draw limits are invalid")
|
||
}
|
||
if config.HighMultiplier <= 0 || config.HighWaterPoolMultiple <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "high multiplier config is invalid")
|
||
}
|
||
if config.PlatformPoolWeightPPM < 0 || config.RoomPoolWeightPPM < 0 || config.GiftPoolWeightPPM < 0 ||
|
||
config.PlatformPoolWeightPPM+config.RoomPoolWeightPPM+config.GiftPoolWeightPPM != ppmScale {
|
||
return xerr.New(xerr.InvalidArgument, "pool weights must sum to 1000000")
|
||
}
|
||
if config.MaxSinglePayout <= 0 || config.UserHourlyPayoutCap <= 0 || config.UserDailyPayoutCap <= 0 ||
|
||
config.DeviceDailyPayoutCap <= 0 || config.RoomHourlyPayoutCap <= 0 || config.AnchorDailyPayoutCap <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "risk payout caps must be positive")
|
||
}
|
||
// tiers 是倍率列表归一化后的运行时候选集合;即使后台只提交 multiplier_ppms,发布前也必须先生成 tiers。
|
||
if len(config.Tiers) == 0 {
|
||
return xerr.New(xerr.InvalidArgument, "reward tiers are required")
|
||
}
|
||
maxMultiplierPPM := int64(0)
|
||
for _, tier := range config.Tiers {
|
||
if tier.Pool == "" || tier.TierID == "" || tier.Weight < 0 || tier.RewardCoins < 0 || tier.MultiplierPPM < 0 {
|
||
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("invalid reward tier: %s", tier.TierID))
|
||
}
|
||
if tier.Enabled && tier.MultiplierPPM > maxMultiplierPPM {
|
||
maxMultiplierPPM = tier.MultiplierPPM
|
||
}
|
||
}
|
||
// 最大倍率低于目标 RTP 时,窗口最后几抽可能没有足够高的基础候选追平目标,只能提前拒绝这类配置。
|
||
if maxMultiplierPPM < config.TargetRTPPPM {
|
||
return xerr.New(xerr.InvalidArgument, "max lucky gift multiplier must not be lower than target RTP")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// normalizeTiers 把后台“可中倍率”转换成三档体验池候选;后台传入的权重不会成为线上概率。
|
||
func normalizeTiers(tiers []domain.Tier, multipliers []int64, giftPrice, highMultiplier int64) ([]domain.Tier, []int64) {
|
||
multiplierPPMs := normalizeMultiplierPPMs(multipliers, tiers, giftPrice)
|
||
return buildMultiplierTiers(giftPrice, highMultiplier, multiplierPPMs), multiplierPPMs
|
||
}
|
||
|
||
func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64) []int64 {
|
||
// 新后台直接提交 multiplier_ppms;旧调用方如果仍提交 tiers,则从 reward_coins 反推倍率以减少迁移断点。
|
||
values := append([]int64(nil), input...)
|
||
if len(values) == 0 {
|
||
for _, tier := range tiers {
|
||
if tier.MultiplierPPM > 0 || tier.RewardCoins == 0 {
|
||
values = append(values, tier.MultiplierPPM)
|
||
continue
|
||
}
|
||
if giftPrice > 0 && tier.RewardCoins > 0 {
|
||
values = append(values, tier.RewardCoins*ppmScale/giftPrice)
|
||
}
|
||
}
|
||
}
|
||
// 没有任何输入时使用保守默认倍率,保证默认草稿可被后台查看和复制,但仍默认 disabled。
|
||
if len(values) == 0 {
|
||
values = defaultMultiplierPPMs()
|
||
}
|
||
seen := map[int64]bool{}
|
||
out := make([]int64, 0, len(values))
|
||
for _, value := range values {
|
||
// 负倍率没有业务含义;重复倍率会造成同一返奖点被重复计权,因此在入口处去重。
|
||
if value < 0 || seen[value] {
|
||
continue
|
||
}
|
||
seen[value] = true
|
||
out = append(out, value)
|
||
}
|
||
// 全部被过滤时回退默认列表,避免构造出空 tiers 后在更深层事务里失败。
|
||
if len(out) == 0 {
|
||
out = defaultMultiplierPPMs()
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||
return out
|
||
}
|
||
|
||
func buildMultiplierTiers(cost, highMultiplier int64, multipliers []int64) []domain.Tier {
|
||
out := make([]domain.Tier, 0, len(multipliers)*3)
|
||
for _, pool := range []string{domain.PoolNovice, domain.PoolIntermediate, domain.PoolAdvanced} {
|
||
for _, multiplierPPM := range multipliers {
|
||
out = append(out, domain.Tier{
|
||
Pool: pool,
|
||
TierID: pool + "_" + multiplierTierID(multiplierPPM),
|
||
RewardCoins: cost * multiplierPPM / ppmScale,
|
||
MultiplierPPM: multiplierPPM,
|
||
// 权重固定为 0 是有意设计:线上概率由 RTP 缺口、奖池容量和风控容量运行时生成。
|
||
Weight: 0,
|
||
HighWaterOnly: highMultiplier > 0 && multiplierPPM >= highMultiplier*ppmScale,
|
||
Enabled: true,
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func defaultMultiplierPPMs() []int64 {
|
||
return []int64{
|
||
0,
|
||
200_000,
|
||
500_000,
|
||
1_000_000,
|
||
2_000_000,
|
||
5_000_000,
|
||
10_000_000,
|
||
20_000_000,
|
||
50_000_000,
|
||
100_000_000,
|
||
500_000_000,
|
||
}
|
||
}
|
||
|
||
func defaultTiers(cost int64) []domain.Tier {
|
||
return buildMultiplierTiers(cost, 100, defaultMultiplierPPMs())
|
||
}
|
||
|
||
func multiplierTierID(multiplierPPM int64) string {
|
||
if multiplierPPM == 0 {
|
||
return "none"
|
||
}
|
||
whole := multiplierPPM / ppmScale
|
||
fraction := multiplierPPM % ppmScale
|
||
if fraction == 0 {
|
||
return fmt.Sprintf("%dx", whole)
|
||
}
|
||
// 小数倍率用下划线表达,避免 tier_id 出现点号后和后台路径/筛选语法冲突。
|
||
text := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%d_%06dx", whole, fraction), "0"), "_")
|
||
return text
|
||
}
|
||
|
||
func normalizePoolID(poolID string) string {
|
||
poolID = strings.TrimSpace(poolID)
|
||
if poolID == "" {
|
||
return domain.DefaultPoolID
|
||
}
|
||
return poolID
|
||
}
|