owpengram-server/internal/domain/boost.go

291 lines
8.4 KiB
Go

package domain
import (
"errors"
"fmt"
"sort"
)
const (
// DefaultPremiumBoostSlotID is the single base boost slot every active premium
// user owns in the current small-scale implementation.
DefaultPremiumBoostSlotID = 1
// MaxPremiumBoostSlotsPerApply bounds premium.applyBoost slots before storage.
MaxPremiumBoostSlotsPerApply = 16
// MaxPremiumBoostsListLimit bounds premium.getBoostsList pages.
MaxPremiumBoostsListLimit = 100
// MaxPremiumBoostsOffsetBytes bounds opaque boost pagination cursors.
MaxPremiumBoostsOffsetBytes = 128
// MaxChannelBoostsToUnblockRestrictions matches Layer 225 TDesktop UI bounds.
MaxChannelBoostsToUnblockRestrictions = 8
// MaxDefaultPremiumBoostLevel bounds the built-in level table so status replies
// never expose unbounded next-level values to clients.
MaxDefaultPremiumBoostLevel = 100
// DefaultPremiumBoostReassignCooldownSeconds keeps the current dev behavior of
// allowing immediate moves while preserving a single hook for official cooldowns.
DefaultPremiumBoostReassignCooldownSeconds = 0
)
var (
ErrBoostNotModified = errors.New("boost not modified")
defaultPremiumBoostLevelPolicy = PremiumBoostLevelPolicy{
Thresholds: linearPremiumBoostThresholds(MaxDefaultPremiumBoostLevel),
}
)
// PremiumBoostSlot describes one user-owned boost slot without TL types.
type PremiumBoostSlot struct {
UserID int64
Slot int
Peer Peer
Date int
Expires int
CooldownUntil int
Multiplier int
Gift bool
Giveaway bool
Unclaimed bool
GiveawayMsgID int
UsedGiftSlug string
Stars int64
}
// Active reports whether the slot can currently count for a peer.
func (s PremiumBoostSlot) Active(now int) bool {
if s.UserID == 0 || s.Slot <= 0 {
return false
}
return s.Expires == 0 || s.Expires > now
}
// Assigned reports whether the active slot is currently applied to a peer.
func (s PremiumBoostSlot) Assigned(now int) bool {
return s.Active(now) && s.Peer.ID != 0
}
// Weight returns the boost contribution of one active assigned slot.
func (s PremiumBoostSlot) Weight(now int) int {
if !s.Assigned(now) {
return 0
}
if s.Multiplier <= 0 {
return 1
}
return s.Multiplier
}
// PremiumBoostStatus is the domain projection behind premium.boostsStatus.
type PremiumBoostStatus struct {
Peer Peer
Level int
CurrentLevelBoosts int
Boosts int
GiftBoosts int
NextLevelBoosts int
HasNextLevelBoosts bool
PremiumAudiencePart int
PremiumAudienceTotal int
MyBoostSlots []PremiumBoostSlot
}
// PremiumBoostList is a bounded admin-visible boosts page.
type PremiumBoostList struct {
Count int
Boosts []PremiumBoostSlot
Users []User
NextOffset string
}
// PremiumMyBoosts is the current user's slot inventory.
type PremiumMyBoosts struct {
Slots []PremiumBoostSlot
Users []User
Channels []Channel
}
// PremiumBoostLevelPolicy maps cumulative active boosts to channel boost levels.
// Thresholds[n-1] is the minimum boost count required for level n.
type PremiumBoostLevelPolicy struct {
Thresholds []int
}
// DefaultPremiumBoostLevelPolicy returns the current bounded telesrv level table.
// It is intentionally policy-backed: production deployments can replace the table
// with official thresholds without touching store/RPC state transitions.
func DefaultPremiumBoostLevelPolicy() PremiumBoostLevelPolicy {
return PremiumBoostLevelPolicy{
Thresholds: append([]int(nil), defaultPremiumBoostLevelPolicy.Thresholds...),
}
}
// NewPremiumBoostLevelPolicy creates a monotonic threshold policy from raw values.
func NewPremiumBoostLevelPolicy(thresholds []int) PremiumBoostLevelPolicy {
return PremiumBoostLevelPolicy{Thresholds: normalizePremiumBoostThresholds(thresholds)}
}
// LevelForBoosts returns level bounds for the given active boost count.
func (p PremiumBoostLevelPolicy) LevelForBoosts(boosts int) (level, currentLevelBoosts, nextLevelBoosts int, hasNext bool) {
if boosts < 0 {
boosts = 0
}
thresholds := p.thresholds()
if len(thresholds) == 0 {
return 0, 0, 0, false
}
level = sort.Search(len(thresholds), func(i int) bool { return thresholds[i] > boosts })
if level > 0 {
currentLevelBoosts = thresholds[level-1]
}
if level < len(thresholds) {
return level, currentLevelBoosts, thresholds[level], true
}
return len(thresholds), thresholds[len(thresholds)-1], 0, false
}
func (p PremiumBoostLevelPolicy) thresholds() []int {
if len(p.Thresholds) == 0 {
return defaultPremiumBoostLevelPolicy.Thresholds
}
last := 0
for _, threshold := range p.Thresholds {
if threshold <= 0 || threshold <= last {
return normalizePremiumBoostThresholds(p.Thresholds)
}
last = threshold
}
return p.Thresholds
}
func normalizePremiumBoostThresholds(thresholds []int) []int {
if len(thresholds) == 0 {
return nil
}
sorted := make([]int, 0, len(thresholds))
for _, threshold := range thresholds {
if threshold > 0 {
sorted = append(sorted, threshold)
}
}
sort.Ints(sorted)
out := sorted[:0]
last := 0
for _, threshold := range sorted {
if threshold == last {
continue
}
out = append(out, threshold)
last = threshold
}
return append([]int(nil), out...)
}
func linearPremiumBoostThresholds(maxLevel int) []int {
if maxLevel <= 0 {
return nil
}
out := make([]int, maxLevel)
for i := range out {
out[i] = i + 1
}
return out
}
// PremiumBoostStatusForCount returns the bounded default level projection.
func PremiumBoostStatusForCount(peer Peer, boosts int, my []PremiumBoostSlot) PremiumBoostStatus {
return PremiumBoostStatusForPolicy(peer, boosts, my, defaultPremiumBoostLevelPolicy)
}
// PremiumBoostStatusForPolicy returns a status projection from an explicit level policy.
func PremiumBoostStatusForPolicy(peer Peer, boosts int, my []PremiumBoostSlot, policy PremiumBoostLevelPolicy) PremiumBoostStatus {
if boosts < 0 {
boosts = 0
}
level, current, next, hasNext := policy.LevelForBoosts(boosts)
return PremiumBoostStatus{
Peer: peer,
Level: level,
CurrentLevelBoosts: current,
Boosts: boosts,
NextLevelBoosts: next,
HasNextLevelBoosts: hasNext,
MyBoostSlots: append([]PremiumBoostSlot(nil), my...),
}
}
// ApplyPremiumBoostSlot applies one user-owned slot to a peer and enforces shared
// lifecycle rules for all store implementations.
func ApplyPremiumBoostSlot(slot PremiumBoostSlot, userID int64, slotID int, peer Peer, now, premiumUntil, cooldownSeconds int) (PremiumBoostSlot, bool, error) {
if userID == 0 || slotID <= 0 || peer.Type != PeerTypeChannel || peer.ID == 0 || now < 0 {
return PremiumBoostSlot{}, false, ErrChannelInvalid
}
if premiumUntil <= now {
return PremiumBoostSlot{}, false, ErrPremiumRequired
}
if cooldownSeconds < 0 {
cooldownSeconds = 0
}
if slot.UserID == 0 || slot.Slot == 0 || !slot.Active(now) {
slot = PremiumBoostSlot{
UserID: userID,
Slot: slotID,
Multiplier: 1,
}
}
if slot.UserID != userID || slot.Slot != slotID {
return PremiumBoostSlot{}, false, ErrChannelInvalid
}
if slot.Multiplier <= 0 {
slot.Multiplier = 1
}
if slot.Assigned(now) && slot.Peer == peer {
if slot.Expires != 0 && slot.Expires != premiumUntil {
slot.Expires = premiumUntil
return slot, true, nil
}
return slot, false, ErrBoostNotModified
}
if slot.Assigned(now) && slot.Peer != peer && slot.CooldownUntil > now {
return slot, false, NewPremiumBoostFloodWaitError(slot.CooldownUntil - now)
}
wasAssigned := slot.Assigned(now)
slot.Peer = peer
slot.Date = now
slot.Expires = premiumUntil
if wasAssigned && cooldownSeconds > 0 {
slot.CooldownUntil = now + cooldownSeconds
} else if slot.CooldownUntil <= now {
slot.CooldownUntil = 0
}
return slot, true, nil
}
// PremiumBoostFloodWaitError carries remaining seconds for boost reassign cooldowns.
type PremiumBoostFloodWaitError struct {
Seconds int
}
func (e PremiumBoostFloodWaitError) Error() string {
if e.Seconds <= 0 {
return "premium boost flood wait"
}
return fmt.Sprintf("premium boost flood wait %d seconds", e.Seconds)
}
func NewPremiumBoostFloodWaitError(seconds int) error {
if seconds <= 0 {
seconds = 1
}
return PremiumBoostFloodWaitError{Seconds: seconds}
}
func PremiumBoostFloodWaitSeconds(err error) (int, bool) {
var wait PremiumBoostFloodWaitError
if !errors.As(err, &wait) {
return 0, false
}
if wait.Seconds <= 0 {
return 1, true
}
return wait.Seconds, true
}