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>
This commit is contained in:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
392
internal/store/memory/account_rating.go
Normal file
392
internal/store/memory/account_rating.go
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue