250 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package luckygift
import (
"fmt"
"strings"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/luckygift"
)
const (
ppmScale int64 = 1_000_000
highTierMultiplierPPM int64 = 10_000_000
)
// 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,
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
// v2 风控上限是绝对金币额,运行时只累计真实返奖;小额礼物不能把窗口上限按比例缩小。
MaxSinglePayout: 50_000,
UserHourlyPayoutCap: 34_200,
UserDailyPayoutCap: 615_600,
DeviceDailyPayoutCap: 1_026_000,
RoomHourlyPayoutCap: 684_000,
AnchorDailyPayoutCap: 12_312_000,
Stages: []domain.RuleStage{
defaultRuleStage(domain.StageNovice, []ruleTierSeed{
{id: "novice_none", multiplierPPM: 0, weightPPM: 100_000},
{id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
{id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
{id: "novice_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
}),
defaultRuleStage(domain.StageNormal, []ruleTierSeed{
{id: "normal_none", multiplierPPM: 0, weightPPM: 100_000},
{id: "normal_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
{id: "normal_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
{id: "normal_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
}),
defaultRuleStage(domain.StageAdvanced, []ruleTierSeed{
{id: "advanced_none", multiplierPPM: 0, weightPPM: 270_000},
{id: "advanced_1x", multiplierPPM: 1_000_000, weightPPM: 600_000},
{id: "advanced_2x", multiplierPPM: 2_000_000, weightPPM: 100_000},
{id: "advanced_5x", multiplierPPM: 5_000_000, weightPPM: 30_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")
}
if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws {
return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid")
}
// 风控上限是 v2 抽奖候选被 risk_cap 剪掉的直接来源,必须随规则版本固化,避免运行时继续吃代码硬编码。
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")
}
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
}
if err := validateRuleStageExpectedRTP(stage, config.TargetRTPPPM, config.ControlBandPPM); 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 validateRuleStageExpectedRTP(stage domain.RuleStage, targetRTPPPM int64, controlBandPPM int64) error {
var expectedPPM int64
for _, tier := range stage.Tiers {
if !tier.Enabled || tier.RewardSource != domain.SourceBaseRTP {
continue
}
expectedPPM += tier.MultiplierPPM * tier.BaseWeightPPM / ppmScale
}
lower := targetRTPPPM - controlBandPPM
upper := targetRTPPPM + controlBandPPM
if expectedPPM < lower || expectedPPM > upper {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s stage expected RTP %.2f%% must be within target band %.2f%%-%.2f%%",
stage.Stage,
float64(expectedPPM)/10_000,
float64(lower)/10_000,
float64(upper)/10_000,
))
}
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
}
func normalizePoolID(poolID string) string {
poolID = strings.TrimSpace(poolID)
if poolID == "" {
return domain.DefaultPoolID
}
return poolID
}