Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
392 lines
13 KiB
Go
392 lines
13 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
// Default page sizes for the rating reads, so an unset limit resolves to a
|
|
// finite page the way the PostgreSQL LIMIT does.
|
|
const (
|
|
defaultAccountRatingListLimit = 50
|
|
defaultAccountRatingEventLimit = 50
|
|
defaultAccountRatingStaleLimit = 50
|
|
)
|
|
|
|
// AccountRatingStore is the in-memory implementation of store.AccountRatingStore.
|
|
// It reproduces the invariants migration 0151 encodes:
|
|
//
|
|
// - account_rating is keyed by user_id, so one projection row per user.
|
|
// - the version CHECK plus optimistic concurrency: a write is applied only when
|
|
// it carries the successor of the stored version, which is exactly what
|
|
// domain.ResolveAccountRatingPending produces.
|
|
// - the pending pair CHECK: a pending delta and its date exist together or not
|
|
// at all.
|
|
// - the component CHECKs: stars/activity/penalty components and level are
|
|
// non-negative, and next_level_stars is either absent or above
|
|
// current_level_stars.
|
|
// - account_rating_events_command_idx: a replayed command key never appends a
|
|
// second adjustment.
|
|
type AccountRatingStore struct {
|
|
mu sync.Mutex
|
|
nextID int64
|
|
// ratings is the account_rating read model.
|
|
ratings map[int64]domain.AccountRating
|
|
// events is the append-only contribution ledger in insertion order.
|
|
events []domain.AccountRatingEvent
|
|
// commands maps an adjustment command key onto the ledger row it created.
|
|
commands map[string]int64
|
|
// signals holds the raw contribution snapshot per user.
|
|
//
|
|
// PostgreSQL aggregates it from stars_transactions, message counts, saved
|
|
// gifts and moderation cases. In memory those live in unrelated store types
|
|
// (StarsStore, MessageStore, StarGiftStore, ModerationReportStore) that this
|
|
// store has no handle on, and wiring them in would make the rating depend on
|
|
// which stores a test happens to construct. The snapshot is therefore
|
|
// injected -- deterministic, and identical for a unit test and for the
|
|
// recompute worker, which is what domain.AccountRatingSignals promises. Only
|
|
// the manual total is derived here, from the ledger, because the ledger is
|
|
// this store's own data.
|
|
signals map[int64]domain.AccountRatingSignals
|
|
// accounts is the account universe UnratedAccounts seeds from, in declaration
|
|
// order.
|
|
//
|
|
// PostgreSQL reads it from the users table. This store has no users table and
|
|
// inventing one from whichever ids happen to appear in the ledger would be
|
|
// circular -- an account with no rating and no adjustment is exactly the case
|
|
// seeding exists for. So the universe is declared, like signals above.
|
|
accounts []int64
|
|
}
|
|
|
|
// NewAccountRatingStore creates an empty rating store.
|
|
func NewAccountRatingStore() *AccountRatingStore {
|
|
return &AccountRatingStore{
|
|
nextID: 1,
|
|
ratings: make(map[int64]domain.AccountRating),
|
|
commands: make(map[string]int64),
|
|
signals: make(map[int64]domain.AccountRatingSignals),
|
|
}
|
|
}
|
|
|
|
// SeedAccountRatingSignals installs raw contribution snapshots for tests in other
|
|
// packages; see the signals field for why they are injected rather than derived.
|
|
func (s *AccountRatingStore) SeedAccountRatingSignals(signals ...domain.AccountRatingSignals) {
|
|
for _, item := range signals {
|
|
s.setAccountRatingSignals(item)
|
|
}
|
|
}
|
|
|
|
// setAccountRatingSignals is the same hook for this package's tests.
|
|
func (s *AccountRatingStore) setAccountRatingSignals(signals domain.AccountRatingSignals) {
|
|
if signals.UserID <= 0 {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.signals[signals.UserID] = signals
|
|
}
|
|
|
|
// AccountRating returns the stored projection, or domain.ErrAccountRatingNotFound
|
|
// when the user was never computed.
|
|
func (s *AccountRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
rating, ok := s.ratings[userID]
|
|
if !ok {
|
|
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
|
}
|
|
return rating, nil
|
|
}
|
|
|
|
// AccountRatingBatch resolves several users at once; users without a row are
|
|
// absent from the map.
|
|
func (s *AccountRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
|
out := make(map[int64]domain.AccountRating, len(userIDs))
|
|
if len(userIDs) == 0 {
|
|
return out, nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, userID := range userIDs {
|
|
if userID <= 0 {
|
|
continue
|
|
}
|
|
if rating, ok := s.ratings[userID]; ok {
|
|
out[userID] = rating
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SaveAccountRating upserts the projection under optimistic concurrency: the
|
|
// incoming Version must be the successor of the stored one, which is what
|
|
// domain.ResolveAccountRatingPending computes. A stale or missing version leaves
|
|
// the stored row untouched and reports changed=false, so a caller that lost a
|
|
// race can re-read and retry.
|
|
func (s *AccountRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
|
if rating.UserID <= 0 {
|
|
// account_rating.user_id references users(id): there is no row to write.
|
|
return domain.AccountRating{}, false, domain.ErrAccountRatingNotFound
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
stored, exists := s.ratings[rating.UserID]
|
|
if rating.Version != stored.Version+1 {
|
|
if !exists {
|
|
return domain.AccountRating{}, false, nil
|
|
}
|
|
return stored, false, nil
|
|
}
|
|
next := normalizeAccountRating(rating)
|
|
s.ratings[next.UserID] = next
|
|
return next, true, nil
|
|
}
|
|
|
|
// AccountRatingSignals returns the injected raw snapshot with the manual total
|
|
// taken from the ledger, mirroring the PostgreSQL aggregate. A user with no
|
|
// contributions reports zeros rather than an error, because the aggregate has no
|
|
// "missing row" state.
|
|
func (s *AccountRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
|
if userID <= 0 {
|
|
return domain.AccountRatingSignals{}, domain.ErrAccountRatingNotFound
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
signals := s.signals[userID]
|
|
signals.UserID = userID
|
|
signals.Manual += s.manualTotalLocked(userID)
|
|
return signals, nil
|
|
}
|
|
|
|
// AdjustAccountRating appends a manual adjustment. A replayed command key returns
|
|
// the recorded event with applied=false and appends nothing.
|
|
func (s *AccountRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
|
if err := req.Validate(); err != nil {
|
|
return domain.AccountRatingEvent{}, false, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if req.CommandKey != "" {
|
|
if id, ok := s.commands[req.CommandKey]; ok {
|
|
for _, event := range s.events {
|
|
if event.ID == id {
|
|
return event, false, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
event := domain.AccountRatingEvent{
|
|
ID: s.nextID,
|
|
UserID: req.UserID,
|
|
Kind: domain.AccountRatingEventManual,
|
|
Amount: req.Amount,
|
|
Reason: req.Reason,
|
|
Actor: req.Actor,
|
|
CommandKey: req.CommandKey,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
s.nextID++
|
|
s.events = append(s.events, event)
|
|
if event.CommandKey != "" {
|
|
s.commands[event.CommandKey] = event.ID
|
|
}
|
|
return event, true, nil
|
|
}
|
|
|
|
// ListAccountRatings is the admin leaderboard: level desc, stars desc, user id
|
|
// asc, matching account_rating_leaderboard_idx. BeforeID is the keyset cursor and
|
|
// names the last row of the previous page.
|
|
func (s *AccountRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
|
limit := filter.Limit
|
|
if limit <= 0 {
|
|
limit = defaultAccountRatingListLimit
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cursor, hasCursor := s.ratings[filter.BeforeID]
|
|
out := make([]domain.AccountRating, 0, len(s.ratings))
|
|
for _, rating := range s.ratings {
|
|
if filter.MinLevel > 0 && rating.Level < filter.MinLevel {
|
|
continue
|
|
}
|
|
if filter.UserID > 0 && rating.UserID != filter.UserID {
|
|
continue
|
|
}
|
|
switch {
|
|
case filter.BeforeID <= 0:
|
|
case hasCursor:
|
|
// Keyset paging over the leaderboard order.
|
|
if !accountRatingLess(cursor, rating) {
|
|
continue
|
|
}
|
|
default:
|
|
// The cursor row is gone; fall back to the id tiebreak alone so paging
|
|
// still terminates.
|
|
if rating.UserID <= filter.BeforeID {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, rating)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return accountRatingLess(out[i], out[j]) })
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AccountRatingEvents returns the ledger for one user, newest first.
|
|
func (s *AccountRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
|
if limit <= 0 {
|
|
limit = defaultAccountRatingEventLimit
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]domain.AccountRatingEvent, 0, limit)
|
|
for i := len(s.events) - 1; i >= 0 && len(out) < limit; i-- {
|
|
if s.events[i].UserID != userID {
|
|
continue
|
|
}
|
|
out = append(out, s.events[i])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// StaleAccountRatings returns the users whose projection predates the horizon,
|
|
// oldest first, which is the order account_rating_stale_idx serves.
|
|
func (s *AccountRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
|
if limit <= 0 {
|
|
limit = defaultAccountRatingStaleLimit
|
|
}
|
|
horizon := time.Unix(olderThanUnix, 0).UTC()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
stale := make([]domain.AccountRating, 0, len(s.ratings))
|
|
for _, rating := range s.ratings {
|
|
if rating.ComputedAt.Before(horizon) {
|
|
stale = append(stale, rating)
|
|
}
|
|
}
|
|
sort.Slice(stale, func(i, j int) bool {
|
|
if !stale[i].ComputedAt.Equal(stale[j].ComputedAt) {
|
|
return stale[i].ComputedAt.Before(stale[j].ComputedAt)
|
|
}
|
|
return stale[i].UserID < stale[j].UserID
|
|
})
|
|
if len(stale) > limit {
|
|
stale = stale[:limit]
|
|
}
|
|
out := make([]int64, 0, len(stale))
|
|
for _, rating := range stale {
|
|
out = append(out, rating.UserID)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SeedAccounts declares the account universe UnratedAccounts walks. Repeating an
|
|
// id is a no-op, so a test can declare accounts as it creates them.
|
|
func (s *AccountRatingStore) SeedAccounts(userIDs ...int64) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
known := make(map[int64]struct{}, len(s.accounts))
|
|
for _, id := range s.accounts {
|
|
known[id] = struct{}{}
|
|
}
|
|
for _, id := range userIDs {
|
|
if id <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := known[id]; ok {
|
|
continue
|
|
}
|
|
known[id] = struct{}{}
|
|
s.accounts = append(s.accounts, id)
|
|
}
|
|
}
|
|
|
|
// UnratedAccounts returns declared accounts that have no projection yet, in
|
|
// declaration order -- the memory stand-in for PostgreSQL's oldest-account-first
|
|
// walk. A store nobody seeded reports no candidates rather than erroring: the
|
|
// worker treats that as "nothing to seed", which is the truth.
|
|
func (s *AccountRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
|
|
if limit <= 0 {
|
|
limit = defaultAccountRatingStaleLimit
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]int64, 0, limit)
|
|
for _, id := range s.accounts {
|
|
if _, rated := s.ratings[id]; rated {
|
|
continue
|
|
}
|
|
out = append(out, id)
|
|
if len(out) == limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// manualTotalLocked sums the manual ledger rows, the only kind that survives a
|
|
// recompute.
|
|
func (s *AccountRatingStore) manualTotalLocked(userID int64) int64 {
|
|
var total int64
|
|
for _, event := range s.events {
|
|
if event.UserID == userID && event.Kind == domain.AccountRatingEventManual {
|
|
total += event.Amount
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// normalizeAccountRating makes the rows the table's CHECK constraints forbid
|
|
// unrepresentable. PostgreSQL raises an opaque constraint error there rather than
|
|
// a domain error, so the memory store folds the impossible shapes onto the
|
|
// closest representable one instead of inventing an error the RPC layer would
|
|
// then have to handle only in tests.
|
|
func normalizeAccountRating(rating domain.AccountRating) domain.AccountRating {
|
|
if rating.Level < 0 {
|
|
rating.Level = 0
|
|
}
|
|
if rating.Level > domain.MaxAccountRatingLevel {
|
|
rating.Level = domain.MaxAccountRatingLevel
|
|
}
|
|
if rating.CurrentLevelStars < 0 {
|
|
rating.CurrentLevelStars = 0
|
|
}
|
|
if rating.StarsComponent < 0 {
|
|
rating.StarsComponent = 0
|
|
}
|
|
if rating.ActivityComponent < 0 {
|
|
rating.ActivityComponent = 0
|
|
}
|
|
if rating.PenaltyComponent < 0 {
|
|
rating.PenaltyComponent = 0
|
|
}
|
|
if !rating.HasNextLevel || rating.NextLevelStars <= rating.CurrentLevelStars {
|
|
rating.HasNextLevel = false
|
|
rating.NextLevelStars = 0
|
|
}
|
|
// The pending delta and its date only exist together.
|
|
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
|
|
rating.PendingStars = 0
|
|
rating.PendingDate = time.Time{}
|
|
}
|
|
return rating
|
|
}
|
|
|
|
// accountRatingLess is the leaderboard order: highest level first, then the
|
|
// larger score, then the lower user id as a stable tiebreak.
|
|
func accountRatingLess(a, b domain.AccountRating) bool {
|
|
if a.Level != b.Level {
|
|
return a.Level > b.Level
|
|
}
|
|
if a.Stars != b.Stars {
|
|
return a.Stars > b.Stars
|
|
}
|
|
return a.UserID < b.UserID
|
|
}
|