362 lines
15 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/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_v3fixed_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
}