211 lines
7.9 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"
"sort"
"strings"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/luckygift"
)
const ppmScale int64 = 1_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),
}
}
// 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
}