362 lines
15 KiB
Go
362 lines
15 KiB
Go
package luckygift
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"hyapp/pkg/xerr"
|
||
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||
)
|
||
|
||
const (
|
||
ppmScale int64 = 1_000_000
|
||
highTierMultiplierPPM int64 = 10_000_000
|
||
)
|
||
|
||
// DefaultRuleConfig 返回 dynamic_v3 配置草稿。金额型风控上限和 50 美元等值金币门槛没有跨 App 通用换算,
|
||
// 因而保留为 0 且草稿默认 disabled;运营显式补齐这些值并启用后,才允许发布可运行版本。
|
||
func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
|
||
poolID = normalizePoolID(poolID)
|
||
return domain.RuleConfig{
|
||
AppCode: appCode,
|
||
PoolID: poolID,
|
||
StrategyVersion: domain.StrategyDynamicV3,
|
||
Enabled: false,
|
||
TargetRTPPPM: 980_000,
|
||
PoolRatePPM: 980_000,
|
||
ProfitRatePPM: 10_000,
|
||
AnchorRatePPM: 10_000,
|
||
SettlementWindowWager: 1_000_000,
|
||
ControlBandPPM: 30_000,
|
||
GiftPriceReference: 100,
|
||
// dynamic_v3 的账本从 0 开始,任何启动资金都必须经过带管理员和原因的人工注资事实。
|
||
InitialPoolCoins: 0,
|
||
LossStreakGuarantee: 5,
|
||
LowWatermarkCoins: 10_000_000,
|
||
LowWaterNonzeroFactorPPM: 700_000,
|
||
HighWatermarkCoins: 20_000_000,
|
||
HighWaterNonzeroFactorPPM: 1_300_000,
|
||
RechargeBoostWindowMS: 300_000,
|
||
RechargeBoostFactorPPM: 1_100_000,
|
||
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
|
||
JackpotGlobalRTPMaxPPM: 980_000,
|
||
JackpotUserDayRTPMaxPPM: 960_000,
|
||
JackpotUser72hRTPMaxPPM: 960_000,
|
||
MaxJackpotHitsPerUserDay: 5,
|
||
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
|
||
NoviceMaxEquivalentDraws: 2_000,
|
||
NormalMaxEquivalentDraws: 20_000,
|
||
Stages: []domain.RuleStage{
|
||
defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)),
|
||
// 产品尚未给出具体充值金额时,用 (0,1) 与 (1,1) 保证三个阶段可执行且无重叠;运营可按 App 金币口径上调。
|
||
defaultRuleStage(domain.StageNormal, 0, 1, defaultSafeTierSeeds(domain.StageNormal)),
|
||
defaultRuleStage(domain.StageAdvanced, 1, 1, defaultSafeTierSeeds(domain.StageAdvanced)),
|
||
},
|
||
}
|
||
}
|
||
|
||
type ruleTierSeed struct {
|
||
id string
|
||
multiplierPPM int64
|
||
weightPPM int64
|
||
}
|
||
|
||
// defaultSafeTierSeeds 保留明确的 0x 和少量 0.5x 小奖,并让每个阶段的静态期望精确为 98%。
|
||
// 200x/500x/1000x 只登记为 jackpot 倍率,不在缺少产品概率时照搬图片示例塞进基础 tier。
|
||
func defaultSafeTierSeeds(stage string) []ruleTierSeed {
|
||
return []ruleTierSeed{
|
||
{id: stage + "_none", multiplierPPM: 0, weightPPM: 50_000},
|
||
{id: stage + "_0_5x", multiplierPPM: 500_000, weightPPM: 40_000},
|
||
{id: stage + "_1x", multiplierPPM: 1_000_000, weightPPM: 860_000},
|
||
{id: stage + "_2x", multiplierPPM: 2_000_000, weightPPM: 50_000},
|
||
}
|
||
}
|
||
|
||
func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int64, tiers []ruleTierSeed) domain.RuleStage {
|
||
out := domain.RuleStage{
|
||
Stage: stage,
|
||
MinRecharge7DCoins: minRecharge7DCoins,
|
||
MinRecharge30DCoins: minRecharge30DCoins,
|
||
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,
|
||
HighWaterOnly: tier.multiplierPPM >= highTierMultiplierPPM,
|
||
Enabled: true,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
|
||
config.PoolID = normalizePoolID(config.PoolID)
|
||
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
||
if config.StrategyVersion == "" {
|
||
// 空值只可能来自升级前的客户端或旧持久行;归一为 fixed_v2 才不会让新动态校验改变历史发布语义。
|
||
config.StrategyVersion = domain.StrategyFixedV2
|
||
}
|
||
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)
|
||
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")
|
||
}
|
||
strategyVersion := strings.ToLower(strings.TrimSpace(config.StrategyVersion))
|
||
if strategyVersion == "" {
|
||
strategyVersion = domain.StrategyFixedV2
|
||
}
|
||
if strategyVersion != domain.StrategyFixedV2 && strategyVersion != domain.StrategyDynamicV3 {
|
||
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("unsupported lucky gift strategy version: %s", config.StrategyVersion))
|
||
}
|
||
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
|
||
}
|
||
}
|
||
if strategyVersion == domain.StrategyDynamicV3 {
|
||
if err := validateDynamicRuleConfig(config, stageMap); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateDynamicRuleConfig 只约束 dynamic_v3;fixed_v2 的历史不可变版本即使没有这些字段也必须可以继续读取和重发。
|
||
func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domain.RuleStage) error {
|
||
if config.ProfitRatePPM < 0 || config.AnchorRatePPM < 0 || config.PoolRatePPM+config.ProfitRatePPM+config.AnchorRatePPM != ppmScale {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 pool, profit and anchor rates must be non-negative and sum to 100%")
|
||
}
|
||
if config.InitialPoolCoins != 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero; fund the pool through an audited credit adjustment")
|
||
}
|
||
if config.LossStreakGuarantee <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 loss streak guarantee must be positive")
|
||
}
|
||
if config.LowWatermarkCoins <= 0 || config.HighWatermarkCoins <= config.LowWatermarkCoins {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 watermarks must be positive and high watermark must exceed low watermark")
|
||
}
|
||
if config.LowWaterNonzeroFactorPPM <= 0 || config.LowWaterNonzeroFactorPPM >= ppmScale {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 low-water nonzero factor must be between 0% and 100%")
|
||
}
|
||
if config.HighWaterNonzeroFactorPPM <= ppmScale {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 high-water nonzero factor must exceed 100%")
|
||
}
|
||
if config.RechargeBoostWindowMS <= 0 || config.RechargeBoostFactorPPM <= ppmScale {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge boost window must be positive and factor must exceed 100%")
|
||
}
|
||
if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
|
||
return err
|
||
}
|
||
if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale ||
|
||
config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
|
||
config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot RTP limits must be positive; user limits cannot exceed the global limit")
|
||
}
|
||
if config.MaxJackpotHitsPerUserDay <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 daily jackpot hit cap must be positive")
|
||
}
|
||
if config.JackpotSpendThresholdCoins < 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins cannot be negative")
|
||
}
|
||
if err := validateDynamicPayoutCaps(config, false); err != nil {
|
||
return err
|
||
}
|
||
if err := validateDynamicStageThresholds(stages); err != nil {
|
||
return err
|
||
}
|
||
if !config.Enabled {
|
||
// disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身金币汇率和风控预算显式补齐。
|
||
return nil
|
||
}
|
||
if config.JackpotSpendThresholdCoins <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins must be configured to the App's USD 50 equivalent coin amount before enabling")
|
||
}
|
||
return validateDynamicPayoutCaps(config, true)
|
||
}
|
||
|
||
func validateJackpotMultipliers(multiplierPPMs []int64) error {
|
||
if len(multiplierPPMs) == 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 must configure at least one jackpot multiplier")
|
||
}
|
||
var previous int64
|
||
for _, multiplierPPM := range multiplierPPMs {
|
||
if multiplierPPM <= ppmScale || multiplierPPM <= previous {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot multipliers must exceed 1x and be strictly increasing")
|
||
}
|
||
previous = multiplierPPM
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateDynamicStageThresholds(stages map[string]domain.RuleStage) error {
|
||
novice := stages[domain.StageNovice]
|
||
normal := stages[domain.StageNormal]
|
||
advanced := stages[domain.StageAdvanced]
|
||
if novice.MinRecharge7DCoins != 0 || novice.MinRecharge30DCoins != 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 novice recharge thresholds must be 0/0")
|
||
}
|
||
if normal.MinRecharge7DCoins < 0 || normal.MinRecharge30DCoins < 0 || advanced.MinRecharge7DCoins < 0 || advanced.MinRecharge30DCoins < 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge thresholds cannot be negative")
|
||
}
|
||
if normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 normal stage must exceed novice in at least one recharge threshold")
|
||
}
|
||
if advanced.MinRecharge7DCoins < normal.MinRecharge7DCoins || advanced.MinRecharge30DCoins < normal.MinRecharge30DCoins ||
|
||
(advanced.MinRecharge7DCoins == normal.MinRecharge7DCoins && advanced.MinRecharge30DCoins == normal.MinRecharge30DCoins) {
|
||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 advanced recharge thresholds must be component-wise monotonic and exceed normal in at least one dimension")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateDynamicPayoutCaps(config domain.RuleConfig, requirePositive bool) error {
|
||
caps := []struct {
|
||
name string
|
||
value int64
|
||
}{
|
||
{name: "max_single_payout", value: config.MaxSinglePayout},
|
||
{name: "user_hourly_payout_cap", value: config.UserHourlyPayoutCap},
|
||
{name: "user_daily_payout_cap", value: config.UserDailyPayoutCap},
|
||
{name: "device_daily_payout_cap", value: config.DeviceDailyPayoutCap},
|
||
{name: "room_hourly_payout_cap", value: config.RoomHourlyPayoutCap},
|
||
{name: "anchor_daily_payout_cap", value: config.AnchorDailyPayoutCap},
|
||
}
|
||
for _, cap := range caps {
|
||
if cap.value < 0 || (requirePositive && cap.value == 0) {
|
||
if !requirePositive {
|
||
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s cannot be negative", cap.name))
|
||
}
|
||
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s must be positive before enabling", cap.name))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateRuleStage(stage domain.RuleStage) error {
|
||
var weightSum int64
|
||
hasBaseTier := false
|
||
hasZeroTier := false
|
||
tierIDs := make(map[string]struct{}, len(stage.Tiers))
|
||
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))
|
||
}
|
||
// 同阶段奖档 ID 是 lucky_gift_stage_tiers 的主键维度;发布前拦截重复值,避免运营配置错误落到数据库唯一键后变成 opaque internal error。
|
||
if _, exists := tierIDs[tier.TierID]; exists {
|
||
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("%s tier id is duplicated: %s", stage.Stage, tier.TierID))
|
||
}
|
||
tierIDs[tier.TierID] = struct{}{}
|
||
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 tier.Enabled {
|
||
weightSum += tier.BaseWeightPPM
|
||
hasBaseTier = true
|
||
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 {
|
||
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 normalizePoolID(poolID string) string {
|
||
poolID = strings.TrimSpace(poolID)
|
||
if poolID == "" {
|
||
return domain.DefaultPoolID
|
||
}
|
||
return poolID
|
||
}
|