Ctrl K
ComunidadGuías…

internal/domain/account_rating.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package domain

import (
	"errors"
	"time"
)

// Composite account rating.
//
// This is gramsrv's server-local account score. It deliberately uses its own
// inputs and thresholds (Stars, activity and moderation), rather than claiming
// to reproduce Telegram's private rating algorithm. The RPC edge exposes the
// stored level through userFull's existing rating fields so official clients can
// render it without a client patch.
const (
	// MaxAccountRatingLevel bounds the local rating policy. The TL schema
	// represents starsRating.level as int; 100 is this server's chosen ceiling,
	// not a protocol limit or a claim about Telegram's private rating policy.
	MaxAccountRatingLevel = 100
	// accountRatingLevelUnit is the score required for level 1. Thresholds grow
	// quadratically from it: level n needs accountRatingLevelUnit * n^2.
	//
	// The unit is chosen so the top rung costs the same score it used to:
	// accountRatingLevelUnit * MaxAccountRatingLevel^2 is 250k, exactly what the
	// previous 50-level ladder asked for. Doubling the level count therefore
	// doubles the resolution of the ladder instead of moving every stored score
	// further out of reach -- a score that used to be level n is now level 2n --
	// so the whole existing population spreads across all 100 levels rather than
	// piling up on the first few with a few whale accounts pinned to the ceiling.
	accountRatingLevelUnit = 25
	// MaxAccountRatingReasonLength matches the event ledger CHECK on reason.
	MaxAccountRatingReasonLength = 512
	// MaxAccountRatingActorLength matches the event ledger CHECK on actor.
	MaxAccountRatingActorLength = 128
	// MaxAccountRatingCommandKeyLength matches the idempotency CHECK.
	MaxAccountRatingCommandKeyLength = 128
)

var (
	// ErrAccountRatingNotFound reports a user with no rating row yet.
	ErrAccountRatingNotFound = errors.New("account rating not found")
	// ErrAccountRatingWeightsInvalid rejects a non-sensical weight set.
	ErrAccountRatingWeightsInvalid = errors.New("account rating weights invalid")
	// ErrAccountRatingAdjustmentInvalid rejects a malformed manual adjustment.
	ErrAccountRatingAdjustmentInvalid = errors.New("account rating adjustment invalid")
)

// AccountRatingEventKind is the contribution source of a ledger row. Only
// 'manual' rows survive a full recompute; the rest are audit trail.
type AccountRatingEventKind string

const (
	AccountRatingEventStars      AccountRatingEventKind = "stars"
	AccountRatingEventActivity   AccountRatingEventKind = "activity"
	AccountRatingEventModeration AccountRatingEventKind = "moderation"
	AccountRatingEventManual     AccountRatingEventKind = "manual"
	AccountRatingEventRecompute  AccountRatingEventKind = "recompute"
)

// Valid reports whether the kind is modelled.
func (k AccountRatingEventKind) Valid() bool {
	switch k {
	case AccountRatingEventStars, AccountRatingEventActivity, AccountRatingEventModeration,
		AccountRatingEventManual, AccountRatingEventRecompute:
		return true
	default:
		return false
	}
}

// AccountRating is the stored read model for one user.
type AccountRating struct {
	UserID            int64
	Level             int
	Stars             int64
	CurrentLevelStars int64
	// NextLevelStars is meaningful only when HasNextLevel is true.
	NextLevelStars int64
	HasNextLevel   bool
	// Components are the explainable breakdown. PenaltyComponent is a
	// non-negative magnitude that is subtracted.
	StarsComponent    int64
	ActivityComponent int64
	PenaltyComponent  int64
	ManualComponent   int64
	// PendingStars is a score delta not yet applied to the visible level, with
	// PendingDate reporting when it becomes effective.
	PendingStars int64
	PendingDate  time.Time
	ComputedAt   time.Time
	UpdatedAt    time.Time
	Version      int64
}

// AccountRatingLevel is the local client/admin-facing level snapshot.
type AccountRatingLevel struct {
	Level             int
	CurrentLevelStars int64
	Stars             int64
	NextLevelStars    int64
	HasNextLevelStars bool
}

// RatableAccount reports whether an account may carry a composite rating.
//
// The rating measures what an account did with Stars -- gifts bought, paid
// messages sent, activity, moderation history. Two kinds of account have no
// meaningful answer there and are excluded everywhere the rating is computed,
// seeded or projected:
//
//   - Bots. A bot does not buy gifts or send paid messages on its own behalf, so
//     its score would only ever be the flat account-age term.
//   - The built-in service accounts (the platform account, BotFather, @Stickers,
//     @ChatBot, the verification bots). They are infrastructure rather than
//     participants: a leaderboard entry for the platform account is noise, and a
//     level badge on it would claim something about transaction volume that means
//     nothing.
//
// Note that the platform account is not flagged is_bot, so the bot check alone
// does not cover it -- which is exactly how it ended up in the seeding pass.
func RatableAccount(userID int64, bot bool) bool {
	return userID > 0 && !bot && !IsSystemUserID(userID)
}

// LevelSnapshot returns the current visible local level.
func (r AccountRating) LevelSnapshot() AccountRatingLevel {
	return AccountRatingLevel{
		Level:             r.Level,
		CurrentLevelStars: r.CurrentLevelStars,
		Stars:             r.Stars,
		NextLevelStars:    r.NextLevelStars,
		HasNextLevelStars: r.HasNextLevel,
	}
}

// PendingLevel returns the local level after the pending score is applied and
// reports whether a pending record exists at all.
func (r AccountRating) PendingLevel() (AccountRatingLevel, bool) {
	if r.PendingStars == 0 || r.PendingDate.IsZero() {
		return AccountRatingLevel{}, false
	}
	total := r.Stars + r.PendingStars
	if total < 0 {
		total = 0
	}
	level, current, next, hasNext := AccountRatingLevelForStars(total)
	return AccountRatingLevel{
		Level:             level,
		CurrentLevelStars: current,
		Stars:             total,
		NextLevelStars:    next,
		HasNextLevelStars: hasNext,
	}, true
}

// AccountRatingWeights is the composite formula. All weights are integers so the
// score is exactly reproducible across a recompute and across store backends.
type AccountRatingWeights struct {
	// StarsReceivedPermille weighs Stars credited to the account (gifts,
	// reactions, paid messages received), in permille of the raw amount.
	StarsReceivedPermille int64
	// StarsSpentPermille weighs Stars the account spent. Spending is a weaker
	// signal than receiving, so the default is lower.
	StarsSpentPermille int64
	// PerMessageSent rewards sustained use.
	PerMessageSent int64
	// PerAccountAgeDay rewards account longevity.
	PerAccountAgeDay int64
	// PerGiftReceived rewards collectible gifts held.
	PerGiftReceived int64
	// PerModerationCase is the penalty for each upheld moderation case.
	PerModerationCase int64
	// ScamPenalty and FakePenalty are flat penalties for the peer flags.
	ScamPenalty int64
	FakePenalty int64
	// ActivityCap bounds the activity component so activity alone cannot
	// outweigh everything else. Zero means uncapped.
	ActivityCap int64
}

// DefaultAccountRatingWeights returns the shipped local policy. Stars dominate,
// activity contributes a bounded floor, and moderation subtracts.
func DefaultAccountRatingWeights() AccountRatingWeights {
	return AccountRatingWeights{
		StarsReceivedPermille: 1000,
		StarsSpentPermille:    250,
		PerMessageSent:        1,
		PerAccountAgeDay:      2,
		PerGiftReceived:       25,
		PerModerationCase:     150,
		ScamPenalty:           5000,
		FakePenalty:           5000,
		ActivityCap:           5000,
	}
}

// Validate rejects negative weights and an impossible cap.
func (w AccountRatingWeights) Validate() error {
	values := []int64{
		w.StarsReceivedPermille, w.StarsSpentPermille, w.PerMessageSent,
		w.PerAccountAgeDay, w.PerGiftReceived, w.PerModerationCase,
		w.ScamPenalty, w.FakePenalty, w.ActivityCap,
	}
	for _, v := range values {
		if v < 0 {
			return ErrAccountRatingWeightsInvalid
		}
	}
	return nil
}

// AccountRatingSignals is the raw snapshot gathered from the contributing
// sources for one user. It is deliberately a plain value: the same snapshot must
// produce the same score in a unit test and in production.
type AccountRatingSignals struct {
	UserID          int64
	StarsReceived   int64
	StarsSpent      int64
	MessagesSent    int64
	AccountAgeDays  int64
	GiftsReceived   int64
	ModerationCases int64
	Scam            bool
	Fake            bool
	// Manual is the sum of admin adjustments, carried across recomputes.
	Manual int64
}

// ComputeAccountRating turns a signal snapshot into the read model. The score is
// clamped at zero: penalties can erase this local score but never invert it.
func ComputeAccountRating(signals AccountRatingSignals, weights AccountRatingWeights, now time.Time) AccountRating {
	if err := weights.Validate(); err != nil {
		weights = DefaultAccountRatingWeights()
	}
	starsComponent := permille(max64(signals.StarsReceived, 0), weights.StarsReceivedPermille) +
		permille(max64(signals.StarsSpent, 0), weights.StarsSpentPermille)

	activityComponent := max64(signals.MessagesSent, 0)*weights.PerMessageSent +
		max64(signals.AccountAgeDays, 0)*weights.PerAccountAgeDay +
		max64(signals.GiftsReceived, 0)*weights.PerGiftReceived
	if weights.ActivityCap > 0 && activityComponent > weights.ActivityCap {
		activityComponent = weights.ActivityCap
	}

	penalty := max64(signals.ModerationCases, 0) * weights.PerModerationCase
	if signals.Scam {
		penalty += weights.ScamPenalty
	}
	if signals.Fake {
		penalty += weights.FakePenalty
	}

	total := starsComponent + activityComponent + signals.Manual - penalty
	if total < 0 {
		total = 0
	}
	level, current, next, hasNext := AccountRatingLevelForStars(total)

	return AccountRating{
		UserID:            signals.UserID,
		Level:             level,
		Stars:             total,
		CurrentLevelStars: current,
		NextLevelStars:    next,
		HasNextLevel:      hasNext,
		StarsComponent:    starsComponent,
		ActivityComponent: activityComponent,
		PenaltyComponent:  penalty,
		ManualComponent:   signals.Manual,
		ComputedAt:        now,
		UpdatedAt:         now,
		Version:           1,
	}
}

// AccountRatingLevelThreshold returns the score needed to reach the given level.
// Level 0 needs nothing; growth is quadratic so early levels arrive quickly and
// later ones stay meaningful.
func AccountRatingLevelThreshold(level int) int64 {
	if level <= 0 {
		return 0
	}
	if level > MaxAccountRatingLevel {
		level = MaxAccountRatingLevel
	}
	n := int64(level)
	return accountRatingLevelUnit * n * n
}

// AccountRatingLevelForStars maps a score onto the level and the surrounding
// thresholds. hasNext is false at MaxAccountRatingLevel.
func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64, nextLevelStars int64, hasNext bool) {
	if stars < 0 {
		stars = 0
	}
	level = 0
	for candidate := 1; candidate <= MaxAccountRatingLevel; candidate++ {
		if stars < AccountRatingLevelThreshold(candidate) {
			break
		}
		level = candidate
	}
	currentLevelStars = AccountRatingLevelThreshold(level)
	if level >= MaxAccountRatingLevel {
		return level, currentLevelStars, 0, false
	}
	return level, currentLevelStars, AccountRatingLevelThreshold(level + 1), true
}

// ResolveAccountRatingPending decides whether a freshly computed score becomes
// visible immediately or is parked as pending.
//
// A score that dropped is applied at once -- a penalty must not sit behind a
// delay. A score that grew is parked until delay has elapsed; once the parked
// window has passed the pending delta is folded into the visible local rating.
func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
	out := computed
	out.Version = prev.Version + 1
	if out.Version <= 0 {
		out.Version = 1
	}
	if delay <= 0 || prev.UserID == 0 {
		return out
	}
	if computed.Stars <= prev.Stars {
		return out
	}
	// A previously parked delta whose date has arrived is applied now.
	if prev.PendingStars != 0 && !prev.PendingDate.IsZero() && !now.Before(prev.PendingDate) {
		return out
	}
	pendingSince := prev.PendingDate
	if prev.PendingStars == 0 || pendingSince.IsZero() {
		pendingSince = now.Add(delay)
	}
	visible := prev
	visible.StarsComponent = computed.StarsComponent
	visible.ActivityComponent = computed.ActivityComponent
	visible.PenaltyComponent = computed.PenaltyComponent
	visible.ManualComponent = computed.ManualComponent
	visible.PendingStars = computed.Stars - prev.Stars
	visible.PendingDate = pendingSince
	visible.ComputedAt = now
	visible.UpdatedAt = now
	visible.Version = out.Version
	return visible
}

// AccountRatingEvent is one contribution ledger row.
type AccountRatingEvent struct {
	ID         int64
	UserID     int64
	Kind       AccountRatingEventKind
	Amount     int64
	Reason     string
	Actor      string
	CommandKey string
	CreatedAt  time.Time
}

// AdjustAccountRatingRequest is an operator adjustment to the manual component.
type AdjustAccountRatingRequest struct {
	UserID     int64
	Amount     int64
	Reason     string
	Actor      string
	CommandKey string
}

// Validate rejects a no-op or oversized adjustment.
func (r AdjustAccountRatingRequest) Validate() error {
	if r.UserID <= 0 || r.Amount == 0 {
		return ErrAccountRatingAdjustmentInvalid
	}
	if len(r.Reason) > MaxAccountRatingReasonLength {
		return ErrAccountRatingAdjustmentInvalid
	}
	if len(r.Actor) > MaxAccountRatingActorLength {
		return ErrAccountRatingAdjustmentInvalid
	}
	if len(r.CommandKey) > MaxAccountRatingCommandKeyLength {
		return ErrAccountRatingAdjustmentInvalid
	}
	return nil
}

// AccountRatingFilter bounds an admin listing query.
type AccountRatingFilter struct {
	MinLevel int
	UserID   int64
	BeforeID int64
	Limit    int
}

func permille(value, weight int64) int64 {
	if value <= 0 || weight <= 0 {
		return 0
	}
	return value * weight / 1000
}

func max64(a, b int64) int64 {
	if a > b {
		return a
	}
	return b
}