owpengram-server/internal/store/postgres/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

557 lines
21 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// AccountRatingStore is the PostgreSQL implementation of the composite account
// rating read model and its contribution ledger.
//
// account_rating is derived state: it is always rebuildable from the contributing
// tables plus the 'manual' rows of account_rating_events, which is exactly what
// AccountRatingSignals gathers. Writes use optimistic concurrency on the stored
// version so a background recompute and an admin adjustment cannot silently
// overwrite each other.
type AccountRatingStore struct {
db sqlcgen.DBTX
}
// NewAccountRatingStore builds the store on a pgx pool or transaction.
func NewAccountRatingStore(db sqlcgen.DBTX) *AccountRatingStore {
return &AccountRatingStore{db: db}
}
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
const (
defaultAccountRatingListLimit = 50
maxAccountRatingListLimit = 200
)
const accountRatingColumns = `user_id, level, stars, current_level_stars, next_level_stars,
stars_component, activity_component, penalty_component, manual_component,
pending_stars, pending_date, computed_at, updated_at, version`
// accountRatingColumnsQualified is the same projection for queries that join, so
// the shared column names stay unambiguous.
const accountRatingColumnsQualified = `r.user_id, r.level, r.stars, r.current_level_stars, r.next_level_stars,
r.stars_component, r.activity_component, r.penalty_component, r.manual_component,
r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version`
// AccountRating returns the stored projection, distinguishing "never computed"
// from "computed as zero".
func (s *AccountRatingStore) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || s.db == nil {
return domain.AccountRating{}, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
rating, err := scanAccountRating(s.db.QueryRow(ctx, `
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = $1`, userID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
if err != nil {
return domain.AccountRating{}, fmt.Errorf("get account rating: %w", err)
}
return rating, nil
}
// AccountRatingBatch resolves several users in one round trip.
func (s *AccountRatingStore) AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
out := make(map[int64]domain.AccountRating, len(userIDs))
filtered := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if userID > 0 {
filtered = append(filtered, userID)
}
}
if len(filtered) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = ANY($1::bigint[])`, filtered)
if err != nil {
return nil, fmt.Errorf("list account ratings batch: %w", err)
}
defer rows.Close()
for rows.Next() {
rating, err := scanAccountRating(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating batch: %w", err)
}
out[rating.UserID] = rating
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ratings batch: %w", err)
}
return out, nil
}
// SaveAccountRating upserts the projection under optimistic concurrency: the
// caller submits the version it intends to write (prev.Version + 1, which is what
// domain.ResolveAccountRatingPending produces), and the update only lands when the
// stored row is still one version behind. A stale write reports changed=false and
// returns the row that won, so the caller can recompute instead of retrying blind.
func (s *AccountRatingStore) SaveAccountRating(ctx context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
if s == nil || s.db == nil {
return domain.AccountRating{}, false, fmt.Errorf("account rating store is not configured")
}
if rating.UserID <= 0 || rating.Level < 0 || rating.Level > domain.MaxAccountRatingLevel ||
rating.CurrentLevelStars < 0 || rating.StarsComponent < 0 ||
rating.ActivityComponent < 0 || rating.PenaltyComponent < 0 {
return domain.AccountRating{}, false, domain.ErrAccountRatingAdjustmentInvalid
}
if rating.Version <= 0 {
rating.Version = 1
}
now := time.Now().UTC()
if rating.ComputedAt.IsZero() {
rating.ComputedAt = now
}
if rating.UpdatedAt.IsZero() {
rating.UpdatedAt = now
}
// The schema pairs pending_stars with pending_date; a half-filled pending
// record is normalised away rather than rejected by the CHECK at runtime.
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
rating.PendingStars = 0
rating.PendingDate = time.Time{}
}
var nextLevelStars any
if rating.HasNextLevel && rating.NextLevelStars > rating.CurrentLevelStars {
nextLevelStars = rating.NextLevelStars
}
var pendingDate any
if rating.PendingStars != 0 {
pendingDate = rating.PendingDate.UTC()
}
stored, err := scanAccountRating(s.db.QueryRow(ctx, `
INSERT INTO account_rating (
user_id, level, stars, current_level_stars, next_level_stars,
stars_component, activity_component, penalty_component, manual_component,
pending_stars, pending_date, computed_at, updated_at, version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (user_id) DO UPDATE SET
level = EXCLUDED.level,
stars = EXCLUDED.stars,
current_level_stars = EXCLUDED.current_level_stars,
next_level_stars = EXCLUDED.next_level_stars,
stars_component = EXCLUDED.stars_component,
activity_component = EXCLUDED.activity_component,
penalty_component = EXCLUDED.penalty_component,
manual_component = EXCLUDED.manual_component,
pending_stars = EXCLUDED.pending_stars,
pending_date = EXCLUDED.pending_date,
computed_at = EXCLUDED.computed_at,
updated_at = EXCLUDED.updated_at,
version = EXCLUDED.version
WHERE account_rating.version = EXCLUDED.version - 1
RETURNING `+accountRatingColumns,
rating.UserID, rating.Level, rating.Stars, rating.CurrentLevelStars, nextLevelStars,
rating.StarsComponent, rating.ActivityComponent, rating.PenaltyComponent, rating.ManualComponent,
rating.PendingStars, pendingDate, rating.ComputedAt.UTC(), rating.UpdatedAt.UTC(), rating.Version,
))
if errors.Is(err, pgx.ErrNoRows) {
// The guard rejected the write; report the row that is actually stored.
current, getErr := s.AccountRating(ctx, rating.UserID)
if getErr != nil {
return domain.AccountRating{}, false, getErr
}
return current, false, nil
}
if err != nil {
return domain.AccountRating{}, false, fmt.Errorf("save account rating: %w", err)
}
return stored, true, nil
}
// AccountRatingSignals gathers the raw contribution snapshot for one user.
//
// Sources, and why each one:
//
// stars received / spent stars_transactions, split by the sign of amount: that
// ledger is the single authoritative record of Stars
// movement for a user, and stars_transactions_user_id_idx
// (user_id, id DESC) bounds the scan to the user's rows.
// gifts received peer_star_gifts for the user peer, restricted to
// lifecycle_status = 'active' -- the weight rewards gifts
// actually held, not ones converted, burned or exported
// away. peer_star_gifts_owner_profile_order_idx is the
// partial index on exactly that predicate and leads with
// (owner_peer_type, owner_peer_id).
// moderation cases moderation_cases against this user peer, restricted to
// the statuses that follow a *violation* decision:
// 'action_pending' (violation decided, actions running),
// 'action_failed' (violation decided, action delivery
// broke) and 'resolved' (violation decided and actions
// applied). 'dismissed' covers both no_violation and a
// granted appeal, and 'open'/'in_review'/'appeal_review'
// are undecided, so none of them penalise the account.
// scam / fake users.scam / users.fake, the peer flags 0136 added.
// account age users.created_at, floored to whole days.
// messages sent message_boxes, counted through
// message_boxes_private_sender_live_idx
// (message_sender_id, private_message_id) WHERE NOT
// deleted. This is the cheapest trustworthy source: the
// index leads with the sender and is coverable, so the
// count needs no heap access. Every private message
// materialises one box per participant, hence
// count(DISTINCT private_message_id) rather than
// count(*). Channel posts are deliberately excluded --
// channel_messages has no sender-leading index, so
// attributing them would cost a full table scan.
// manual sum of account_rating_events.amount where
// kind = 'manual', which is the part of the score that
// must survive a full recompute.
func (s *AccountRatingStore) AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error) {
if s == nil || s.db == nil {
return domain.AccountRatingSignals{}, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
}
signals := domain.AccountRatingSignals{UserID: userID}
err := s.db.QueryRow(ctx, `
SELECT
COALESCE((SELECT sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount > 0), 0),
COALESCE((SELECT -sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount < 0), 0),
COALESCE((
SELECT count(DISTINCT private_message_id) FROM message_boxes
WHERE message_sender_id = u.id AND NOT deleted
), 0),
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM ($2::timestamptz - u.created_at)) / 86400))::bigint,
COALESCE((
SELECT count(*) FROM peer_star_gifts
WHERE owner_peer_type = 'user' AND owner_peer_id = u.id AND lifecycle_status = 'active'
), 0),
COALESCE((
SELECT count(*) FROM moderation_cases
WHERE target_peer_type = 'user' AND target_peer_id = u.id
AND status IN ('action_pending', 'action_failed', 'resolved')
), 0),
u.scam,
u.fake,
COALESCE((
SELECT sum(amount) FROM account_rating_events
WHERE user_id = u.id AND kind = 'manual'
), 0)
FROM users u
WHERE u.id = $1`, userID, time.Now().UTC()).Scan(
&signals.StarsReceived, &signals.StarsSpent, &signals.MessagesSent,
&signals.AccountAgeDays, &signals.GiftsReceived, &signals.ModerationCases,
&signals.Scam, &signals.Fake, &signals.Manual,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
}
if err != nil {
return domain.AccountRatingSignals{}, fmt.Errorf("gather account rating signals: %w", err)
}
return signals, nil
}
// AdjustAccountRating appends a manual adjustment to the ledger. It does not
// recompute the projection: the caller pairs it with SaveAccountRating so the new
// manual total is folded in through the same formula as every other signal.
// Replaying the same CommandKey returns the recorded event and applied=false.
func (s *AccountRatingStore) AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if s == nil || s.db == nil {
return domain.AccountRatingEvent{}, false, fmt.Errorf("account rating store is not configured")
}
req.Reason = strings.TrimSpace(req.Reason)
req.Actor = strings.TrimSpace(req.Actor)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.AccountRatingEvent{}, false, err
}
var event domain.AccountRatingEvent
applied := false
err := withTx(ctx, s.db, "adjust account rating", func(tx pgx.Tx) error {
if existing, found, err := accountRatingEventByCommandKey(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
event = existing
return nil
}
event = domain.AccountRatingEvent{
UserID: req.UserID,
Kind: domain.AccountRatingEventManual,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: req.CommandKey,
CreatedAt: time.Now().UTC(),
}
// DO NOTHING on the partial command_key index closes the window between the
// replay lookup and the insert: a concurrent retry of the same command
// records nothing and falls back to reading the row that won.
err := tx.QueryRow(ctx, `
INSERT INTO account_rating_events (user_id, kind, amount, reason, actor, command_key, created_at)
VALUES ($1,'manual',$2,$3,$4,NULLIF($5,''),$6)
ON CONFLICT (command_key) WHERE command_key IS NOT NULL DO NOTHING
RETURNING id`, event.UserID, event.Amount, event.Reason, event.Actor, event.CommandKey, event.CreatedAt).
Scan(&event.ID)
if errors.Is(err, pgx.ErrNoRows) {
existing, found, lookupErr := accountRatingEventByCommandKey(ctx, tx, req.CommandKey)
if lookupErr != nil {
return lookupErr
}
if !found {
return fmt.Errorf("insert account rating adjustment: conflicting command %q vanished", req.CommandKey)
}
event = existing
return nil
}
if err != nil {
return fmt.Errorf("insert account rating adjustment: %w", err)
}
applied = true
return nil
})
if err != nil {
return domain.AccountRatingEvent{}, false, err
}
return event, applied, nil
}
// ListAccountRatings is the admin leaderboard query. The order matches
// account_rating_leaderboard_idx (level DESC, stars DESC, user_id) and BeforeID is
// a keyset cursor: the cursor row's own (level, stars) are read back so paging
// stays consistent across the compound order instead of only over user ids.
func (s *AccountRatingStore) ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if filter.MinLevel < 0 || filter.MinLevel > domain.MaxAccountRatingLevel {
return nil, domain.ErrAccountRatingAdjustmentInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
WITH cursor_row AS (
SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id
FROM account_rating WHERE $3 <> 0 AND user_id = $3
)
SELECT `+accountRatingColumnsQualified+`
FROM account_rating r
LEFT JOIN cursor_row c ON true
WHERE r.level >= $1
AND ($2 = 0 OR r.user_id = $2)
AND (
c.c_user_id IS NULL
OR r.level < c.c_level
OR (r.level = c.c_level AND r.stars < c.c_stars)
OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id)
)
ORDER BY r.level DESC, r.stars DESC, r.user_id
LIMIT $4`, filter.MinLevel, filter.UserID, filter.BeforeID, limit)
if err != nil {
return nil, fmt.Errorf("list account ratings: %w", err)
}
defer rows.Close()
out := make([]domain.AccountRating, 0, limit)
for rows.Next() {
rating, err := scanAccountRating(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating: %w", err)
}
out = append(out, rating)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ratings: %w", err)
}
return out, nil
}
// AccountRatingEvents returns the ledger for one user, newest first, over
// account_rating_events_user_idx (user_id, id DESC).
func (s *AccountRatingStore) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return nil, domain.ErrAccountRatingNotFound
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
FROM account_rating_events
WHERE user_id = $1
ORDER BY id DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list account rating events: %w", err)
}
defer rows.Close()
out := make([]domain.AccountRatingEvent, 0, limit)
for rows.Next() {
event, err := scanAccountRatingEvent(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating event: %w", err)
}
out = append(out, event)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account rating events: %w", err)
}
return out, nil
}
// StaleAccountRatings returns the users whose projection is older than the
// horizon, ordered so the walk follows account_rating_stale_idx
// (computed_at, user_id) exactly.
func (s *AccountRatingStore) StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if olderThanUnix <= 0 {
return nil, nil
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT user_id FROM account_rating
WHERE computed_at < to_timestamp($1)
ORDER BY computed_at, user_id
LIMIT $2`, olderThanUnix, limit)
if err != nil {
return nil, fmt.Errorf("list stale account ratings: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan stale account rating: %w", err)
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate stale account ratings: %w", err)
}
return out, nil
}
// UnratedAccounts returns accounts that have no projection yet, oldest account
// first so the walk is stable and every account is eventually reached.
//
// Three kinds of account are skipped, per domain.RatableAccount: bots, which do
// not transact on their own behalf; the built-in service accounts, which are
// infrastructure -- and note that the platform account is not flagged is_bot, so
// excluding bots alone would still have seeded it; and deleted accounts, which are
// tombstones whose every profile field has already been cleared.
func (s *AccountRatingStore) UnratedAccounts(ctx context.Context, limit int) ([]int64, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT u.id FROM users u
WHERE NOT u.is_bot
AND u.deleted_at IS NULL
AND u.id <> ALL($2::bigint[])
AND NOT EXISTS (SELECT 1 FROM account_rating r WHERE r.user_id = u.id)
ORDER BY u.created_at, u.id
LIMIT $1`, limit, domain.SystemUserIDs())
if err != nil {
return nil, fmt.Errorf("list unrated accounts: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan unrated account: %w", err)
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unrated accounts: %w", err)
}
return out, nil
}
func accountRatingEventByCommandKey(ctx context.Context, db sqlcgen.DBTX, commandKey string) (domain.AccountRatingEvent, bool, error) {
if commandKey == "" {
return domain.AccountRatingEvent{}, false, nil
}
event, err := scanAccountRatingEvent(db.QueryRow(ctx, `
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
FROM account_rating_events WHERE command_key = $1`, commandKey))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRatingEvent{}, false, nil
}
if err != nil {
return domain.AccountRatingEvent{}, false, fmt.Errorf("lookup account rating command: %w", err)
}
return event, true, nil
}
func scanAccountRating(row pgx.Row) (domain.AccountRating, error) {
var rating domain.AccountRating
var nextLevelStars pgtype.Int8
var pendingDate pgtype.Timestamptz
if err := row.Scan(&rating.UserID, &rating.Level, &rating.Stars, &rating.CurrentLevelStars,
&nextLevelStars, &rating.StarsComponent, &rating.ActivityComponent,
&rating.PenaltyComponent, &rating.ManualComponent, &rating.PendingStars,
&pendingDate, &rating.ComputedAt, &rating.UpdatedAt, &rating.Version); err != nil {
return domain.AccountRating{}, err
}
if nextLevelStars.Valid {
rating.NextLevelStars = nextLevelStars.Int64
rating.HasNextLevel = true
}
if pendingDate.Valid {
rating.PendingDate = pendingDate.Time.UTC()
}
rating.ComputedAt = rating.ComputedAt.UTC()
rating.UpdatedAt = rating.UpdatedAt.UTC()
return rating, nil
}
func scanAccountRatingEvent(row pgx.Row) (domain.AccountRatingEvent, error) {
var event domain.AccountRatingEvent
var kind string
if err := row.Scan(&event.ID, &event.UserID, &kind, &event.Amount, &event.Reason,
&event.Actor, &event.CommandKey, &event.CreatedAt); err != nil {
return domain.AccountRatingEvent{}, err
}
event.Kind = domain.AccountRatingEventKind(kind)
event.CreatedAt = event.CreatedAt.UTC()
return event, nil
}