owpengram-server/internal/store/memory/account_rating.go
Egor Egorov fff8de783a
feat: add NFT usernames and bot verification (#22)
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>
2026-07-28 01:18:00 +08:00

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
}