Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
@ -36,18 +35,17 @@ SELECT
email_unconfirmed_pattern, login_email_pattern, secure_random,
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
srp_id, srp_verifier, srp_b_secret, srp_b,
recovery_email, recovery_code, recovery_code_expires_at, login_email
recovery_email, login_email
FROM account_passwords
WHERE user_id = $1`, userID)
var settings domain.PasswordSettings
var salt1, salt2, p []byte
var recoveryExpires sql.NullTime
if err := row.Scan(
&settings.HasRecovery, &settings.HasSecureValues, &settings.HasPassword, &settings.Hint,
&settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom,
&salt1, &salt2, &settings.NewAlgo.G, &p,
&settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB,
&settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires, &settings.LoginEmail,
&settings.RecoveryEmail, &settings.LoginEmail,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PasswordSettings{}, false, nil
@ -65,9 +63,6 @@ WHERE user_id = $1`, userID)
settings.NewAlgo.Salt1 = append([]byte(nil), salt1...)
settings.NewAlgo.Salt2 = append([]byte(nil), salt2...)
settings.NewAlgo.P = append([]byte(nil), p...)
if recoveryExpires.Valid {
settings.RecoveryCodeExpiresAt = recoveryExpires.Time.Unix()
}
settings.SecureRandom = append([]byte(nil), settings.SecureRandom...)
settings.SRPVerifier = append([]byte(nil), settings.SRPVerifier...)
settings.SRPBSecret = append([]byte(nil), settings.SRPBSecret...)
@ -102,19 +97,15 @@ func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.
if settings.CurrentAlgo != nil {
algo = *settings.CurrentAlgo
}
var recoveryExpires any
if settings.RecoveryCodeExpiresAt > 0 {
recoveryExpires = time.Unix(settings.RecoveryCodeExpiresAt, 0)
}
_, err := s.db.Exec(ctx, `
INSERT INTO account_passwords (
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random,
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
srp_id, srp_verifier, srp_b_secret, srp_b,
recovery_email, recovery_code, recovery_code_expires_at, login_email
recovery_email, login_email
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (user_id) DO UPDATE SET
has_recovery = EXCLUDED.has_recovery,
has_secure_values = EXCLUDED.has_secure_values,
@ -132,8 +123,6 @@ ON CONFLICT (user_id) DO UPDATE SET
srp_b_secret = EXCLUDED.srp_b_secret,
srp_b = EXCLUDED.srp_b,
recovery_email = EXCLUDED.recovery_email,
recovery_code = EXCLUDED.recovery_code,
recovery_code_expires_at = EXCLUDED.recovery_code_expires_at,
login_email = EXCLUDED.login_email,
updated_at = now()`,
userID,
@ -141,7 +130,7 @@ ON CONFLICT (user_id) DO UPDATE SET
settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, nonNilBytea(settings.SecureRandom),
nonNilBytea(algo.Salt1), nonNilBytea(algo.Salt2), algo.G, nonNilBytea(algo.P),
settings.SRPID, nonNilBytea(settings.SRPVerifier), nonNilBytea(settings.SRPBSecret), nonNilBytea(settings.SRPB),
settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, settings.LoginEmail,
settings.RecoveryEmail, settings.LoginEmail,
)
if err != nil {
if isAccountPasswordLoginEmailUnique(err) {
@ -261,6 +250,41 @@ WHERE user_id = $1`, userID)
return settings, true, nil
}
func (s *PasswordStore) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = ANY($1::bigint[])`, userIDs)
if err != nil {
return nil, fmt.Errorf("get account settings batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var userID int64
settings := domain.DefaultAccountSettings()
gp := &settings.GlobalPrivacy
if err := rows.Scan(
&userID,
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
); err != nil {
return nil, fmt.Errorf("scan account settings batch: %w", err)
}
out[userID] = settings
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account settings batch: %w", err)
}
return out, nil
}
func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error {
gp := settings.GlobalPrivacy
if _, err := s.db.Exec(ctx, `

View file

@ -170,7 +170,7 @@ func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, user
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, "", ""); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
}
reason = strings.TrimSpace(reason)
@ -505,7 +505,10 @@ FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, use
if !found {
return nil, nil
}
if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil {
// Cancelling a pending account deletion deliberately retires the requester
// protocol identity; unlike remote device revocation, this path does not need
// to preserve the key for a client-visible RPC 401 transition.
if err := deleteProtocolAuthIdentitiesTx(ctx, tx, []int64{id}); err != nil {
return nil, err
}
return []domain.Authorization{a}, nil

View file

@ -0,0 +1,557 @@
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
}

View file

@ -0,0 +1,462 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// ratingTestUser inserts a user row with an explicit creation date so the account
// age signal is deterministic.
func ratingTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, createdAt time.Time) int64 {
t.Helper()
ctx := context.Background()
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, created_at, updated_at)
VALUES ($1, $2, 'rating test', $3, $3)
RETURNING id`, seed, fmt.Sprintf("%d", seed), createdAt).Scan(&id); err != nil {
t.Fatalf("insert rating test user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// ratingTestCatalogRevision publishes a throwaway star gift so peer_star_gifts
// rows can satisfy their catalog revision foreign key.
func ratingTestCatalogRevision(t *testing.T, pool *pgxpool.Pool) (revisionID, giftID int64) {
t.Helper()
ctx := context.Background()
suffix := randomSuffix(t)
docID := time.Now().UnixNano() & 0x7fffffffffffffff
entry, err := NewStarGiftStore(pool).CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 50, Enabled: true,
Document: domain.Document{
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
Blob: domain.FileBlob{
LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS,
ObjectKey: "rating-star-gift", Size: 4, SHA256: make([]byte, 32),
MimeType: "application/x-tgsticker",
},
Animation: domain.StarGiftAnimation{
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512,
},
Actor: "test", CommandID: "rating-star-gift-" + suffix,
})
if err != nil {
t.Fatalf("create catalog revision: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT id FROM star_gift_catalog_revisions WHERE gift_id = $1 ORDER BY revision DESC LIMIT 1`,
entry.Gift.ID).Scan(&revisionID); err != nil {
t.Fatalf("read catalog revision id: %v", err)
}
t.Cleanup(func() {
cleanupCtx := context.Background()
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog WHERE gift_id = $1`, entry.Gift.ID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog_revisions WHERE gift_id = $1`, entry.Gift.ID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM file_blobs WHERE location_key = $1`, "doc:"+fmt.Sprint(docID))
_, _ = pool.Exec(cleanupCtx, `DELETE FROM documents WHERE id = $1`, docID)
})
return revisionID, entry.Gift.ID
}
// TestAccountRatingSaveVersionConflict covers the optimistic write: a first save
// creates the row, a stale version is refused without an error, and the next
// version wins.
func TestAccountRatingSaveVersionConflict(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
userID := ratingTestUser(t, pool, 3_100_000_000+seed, time.Now().UTC().AddDate(0, 0, -30))
if _, err := store.AccountRating(ctx, userID); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("missing rating err = %v, want ErrAccountRatingNotFound", err)
}
now := time.Now().UTC().Truncate(time.Millisecond)
signals := domain.AccountRatingSignals{UserID: userID, StarsReceived: 900, MessagesSent: 10, AccountAgeDays: 30}
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
stored, changed, err := store.SaveAccountRating(ctx, computed)
if err != nil || !changed {
t.Fatalf("first save changed=%v err=%v", changed, err)
}
if stored.Version != 1 || stored.Stars != computed.Stars || stored.Level != computed.Level ||
stored.HasNextLevel != computed.HasNextLevel || stored.NextLevelStars != computed.NextLevelStars {
t.Fatalf("stored = %+v want %+v", stored, computed)
}
read, err := store.AccountRating(ctx, userID)
if err != nil || read != stored {
t.Fatalf("read = %+v stored = %+v err=%v", read, stored, err)
}
// A second writer that still believes version 0 is stale: no error, no write.
stale := computed
stale.Stars = 999_999
conflicted, changed, err := store.SaveAccountRating(ctx, stale)
if err != nil || changed {
t.Fatalf("stale save changed=%v err=%v", changed, err)
}
if conflicted.Stars != stored.Stars || conflicted.Version != 1 {
t.Fatalf("conflicted = %+v, stored row must win", conflicted)
}
// The recompute that carries the right version applies, including a pending
// delta that must round-trip through the paired pending_stars/pending_date.
next := domain.ResolveAccountRatingPending(stored,
domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, StarsReceived: 40_000}, domain.DefaultAccountRatingWeights(), now),
time.Hour, now)
if next.PendingStars == 0 || next.PendingDate.IsZero() {
t.Fatalf("expected a parked pending delta, got %+v", next)
}
applied, changed, err := store.SaveAccountRating(ctx, next)
if err != nil || !changed {
t.Fatalf("versioned save changed=%v err=%v", changed, err)
}
if applied.Version != 2 || applied.PendingStars != next.PendingStars ||
!applied.PendingDate.Equal(next.PendingDate.UTC()) {
t.Fatalf("applied = %+v want pending %d at %v", applied, next.PendingStars, next.PendingDate)
}
pending, ok := applied.PendingLevel()
if !ok || pending.Stars <= applied.Stars {
t.Fatalf("pending projection = %+v ok=%v", pending, ok)
}
batch, err := store.AccountRatingBatch(ctx, []int64{userID, userID + 1})
if err != nil || len(batch) != 1 || batch[userID].Version != 2 {
t.Fatalf("batch = %+v err=%v", batch, err)
}
list, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: userID, Limit: 10})
if err != nil || len(list) != 1 || list[0].UserID != userID {
t.Fatalf("list = %+v err=%v", list, err)
}
stale2, err := store.StaleAccountRatings(ctx, now.Add(time.Minute).Unix(), 100)
if err != nil {
t.Fatalf("stale: %v", err)
}
if !containsInt64(stale2, userID) {
t.Fatalf("stale ratings %v must contain %d", stale2, userID)
}
fresh, err := store.StaleAccountRatings(ctx, now.Add(-time.Hour).Unix(), 100)
if err != nil {
t.Fatalf("stale fresh: %v", err)
}
if containsInt64(fresh, userID) {
t.Fatalf("rating computed at %v must not be stale before it", applied.ComputedAt)
}
}
// TestAccountRatingAdjustmentIdempotency covers the manual ledger: a replayed
// command key records nothing new and the manual total feeds the recompute.
func TestAccountRatingAdjustmentIdempotency(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
userID := ratingTestUser(t, pool, 3_200_000_000+seed, time.Now().UTC().AddDate(0, 0, -10))
key := fmt.Sprintf("adjust-%d", seed)
req := domain.AdjustAccountRatingRequest{
UserID: userID, Amount: 750, Reason: "community award", Actor: "ops", CommandKey: key,
}
event, applied, err := store.AdjustAccountRating(ctx, req)
if err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
event.CommandKey != key {
t.Fatalf("event = %+v", event)
}
replay, applied, err := store.AdjustAccountRating(ctx, req)
if err != nil || applied || replay.ID != event.ID {
t.Fatalf("replay applied=%v event=%+v err=%v", applied, replay, err)
}
// A second, distinct adjustment accumulates rather than replacing.
if _, applied, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: userID, Amount: -250, Reason: "partial revoke", Actor: "ops",
CommandKey: key + "-b",
}); err != nil || !applied {
t.Fatalf("second adjust applied=%v err=%v", applied, err)
}
events, err := store.AccountRatingEvents(ctx, userID, 10)
if err != nil || len(events) != 2 || events[0].Amount != -250 || events[1].Amount != 750 {
t.Fatalf("events = %+v err=%v", events, err)
}
if _, _, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: userID, Amount: 0, CommandKey: key + "-c",
}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("zero adjustment err = %v, want ErrAccountRatingAdjustmentInvalid", err)
}
signals, err := store.AccountRatingSignals(ctx, userID)
if err != nil {
t.Fatalf("signals: %v", err)
}
if signals.Manual != 500 {
t.Fatalf("manual signal = %d, want 500", signals.Manual)
}
}
// TestAccountRatingSignalsSources pins where each contribution comes from: the
// Stars ledger sign split, saved gifts, upheld moderation cases, the peer flags,
// account age and the private-message count.
func TestAccountRatingSignalsSources(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
createdAt := time.Now().UTC().AddDate(0, 0, -45)
userID := ratingTestUser(t, pool, 3_300_000_000+seed, createdAt)
if _, err := pool.Exec(ctx, `
INSERT INTO stars_transactions (user_id, amount, reason, date)
VALUES ($1, 1500, 'gift', 0), ($1, 500, 'reaction', 0), ($1, -400, 'purchase', 0)`, userID); err != nil {
t.Fatalf("seed stars: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM stars_transactions WHERE user_id = $1`, userID)
})
revisionID, giftID := ratingTestCatalogRevision(t, pool)
if _, err := pool.Exec(ctx, `
INSERT INTO peer_star_gifts (owner_peer_type, owner_peer_id, msg_id, gift_id, gift_date, catalog_revision_id, lifecycle_status, converted)
VALUES ('user', $1, 1, $2, 0, $3, 'active', false),
('user', $1, 2, $2, 0, $3, 'active', false),
('user', $1, 3, $2, 0, $3, 'converted', true)`, userID, giftID, revisionID); err != nil {
t.Fatalf("seed gifts: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID)
})
// Only statuses that follow a violation decision count; 'dismissed' (which
// covers no_violation and a granted appeal) and undecided states do not.
now := time.Now().UTC()
for _, status := range []string{"resolved", "action_failed", "dismissed", "open"} {
if _, err := pool.Exec(ctx, `
INSERT INTO moderation_cases (
target_peer_type, target_peer_id, status, severity, report_count,
distinct_reporter_count, first_report_at, last_report_at, created_at, updated_at
) VALUES ('user', $1, $2, 1, 1, 1, $3, $3, $3, $3)`, userID, status, now); err != nil {
t.Fatalf("seed moderation case %s: %v", status, err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = $1`, userID)
})
if _, err := pool.Exec(ctx, `UPDATE users SET scam = true WHERE id = $1`, userID); err != nil {
t.Fatalf("set scam: %v", err)
}
// Two private messages, each materialised as the sender's and the recipient's
// box: the distinct count must report two, not four.
peerID := ratingTestUser(t, pool, 3_350_000_000+seed, createdAt)
for i := 1; i <= 2; i++ {
var messageID int64
if err := pool.QueryRow(ctx, `
INSERT INTO private_messages (sender_user_id, recipient_user_id, message_date, body)
VALUES ($1, $2, 0, 'hi')
RETURNING id`, userID, peerID).Scan(&messageID); err != nil {
t.Fatalf("seed private message: %v", err)
}
for _, box := range []struct {
owner int64
peer int64
outgoing bool
}{{userID, peerID, true}, {peerID, userID, false}} {
if _, err := pool.Exec(ctx, `
INSERT INTO message_boxes (
owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id,
from_user_id, message_date, outgoing, body
) VALUES ($1, $2, $3, $4, 'user', $5, $4, 0, $6, 'hi')`,
box.owner, i, messageID, userID, box.peer, box.outgoing); err != nil {
t.Fatalf("seed message box: %v", err)
}
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM message_boxes WHERE message_sender_id = $1`, userID)
_, _ = pool.Exec(context.Background(), `DELETE FROM private_messages WHERE sender_user_id = $1`, userID)
})
signals, err := store.AccountRatingSignals(ctx, userID)
if err != nil {
t.Fatalf("signals: %v", err)
}
if signals.UserID != userID || signals.StarsReceived != 2000 || signals.StarsSpent != 400 ||
signals.GiftsReceived != 2 || signals.ModerationCases != 2 || !signals.Scam || signals.Fake ||
signals.MessagesSent != 2 || signals.AccountAgeDays != 45 || signals.Manual != 0 {
t.Fatalf("signals = %+v", signals)
}
rating := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
if rating.PenaltyComponent == 0 || rating.StarsComponent != 2000+100 {
t.Fatalf("computed rating = %+v", rating)
}
if _, err := store.AccountRatingSignals(ctx, userID+7_000_000); !errors.Is(err, domain.ErrUserNotFound) {
t.Fatalf("signals for unknown user err = %v, want ErrUserNotFound", err)
}
}
// TestAccountRatingLeaderboardPaging covers the keyset walk over
// (level DESC, stars DESC, user_id).
func TestAccountRatingLeaderboardPaging(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
now := time.Now().UTC()
scores := []int64{100, 400, 900, 1600}
ids := make([]int64, 0, len(scores))
for i, score := range scores {
userID := ratingTestUser(t, pool, 3_400_000_000+seed+int64(i), now.AddDate(0, 0, -1))
ids = append(ids, userID)
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, Manual: score},
domain.DefaultAccountRatingWeights(), now)
if _, changed, err := store.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("save rating %d: changed=%v err=%v", userID, changed, err)
}
}
page, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 2, Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("first page = %+v err=%v", page, err)
}
if page[0].Level < page[1].Level {
t.Fatalf("leaderboard must be level-descending: %+v", page)
}
next, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{
MinLevel: 2, BeforeID: page[len(page)-1].UserID, Limit: 10,
})
if err != nil {
t.Fatalf("second page: %v", err)
}
for _, item := range next {
if item.UserID == page[0].UserID || item.UserID == page[1].UserID {
t.Fatalf("keyset page repeated user %d", item.UserID)
}
if item.Level > page[len(page)-1].Level {
t.Fatalf("keyset page went backwards: %+v after %+v", item, page)
}
}
if _, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: -1}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("negative level filter must be rejected")
}
_ = ids
}
// TestAccountRatingUnratedAccountsSeedsTheReadModel proves the SQL behind the
// bootstrap pass: only accounts with no projection are returned, bots and deleted
// tombstones are excluded, the walk is oldest-account-first, and a user drops out of
// the candidate set the moment a projection exists.
//
// Without this query the read model can never populate itself -- StaleAccountRatings
// walks account_rating and so cannot return a user who is not in it.
func TestAccountRatingUnratedAccountsSeedsTheReadModel(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
base := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
oldest := ratingTestUser(t, pool, 4_100_000_000+seed, base)
middle := ratingTestUser(t, pool, 4_200_000_000+seed, base.Add(time.Hour))
newest := ratingTestUser(t, pool, 4_300_000_000+seed, base.Add(2*time.Hour))
bot := ratingTestUser(t, pool, 4_400_000_000+seed, base.Add(3*time.Hour))
deleted := ratingTestUser(t, pool, 4_500_000_000+seed, base.Add(4*time.Hour))
if _, err := pool.Exec(ctx, `UPDATE users SET is_bot = true WHERE id = $1`, bot); err != nil {
t.Fatalf("mark bot: %v", err)
}
// A deleted account is a tombstone: every profile field is already cleared, so
// there is no rating to show and no reason to compute one.
if _, err := pool.Exec(ctx, `
UPDATE users SET deleted_at = now(), deletion_source = 'manual', deletion_reason = 'test',
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, premium_expires_at = NULL,
emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0,
personal_channel_id = 0, last_seen_at = 0, account_delete_at = NULL
WHERE id = $1`, deleted); err != nil {
t.Fatalf("mark deleted: %v", err)
}
// The table is shared with every other account in the test database, so assert
// on relative order and membership rather than on an exact page.
positions := func(t *testing.T) (map[int64]int, []int64) {
t.Helper()
ids, err := store.UnratedAccounts(ctx, maxAccountRatingListLimit)
if err != nil {
t.Fatalf("UnratedAccounts: %v", err)
}
index := make(map[int64]int, len(ids))
for i, id := range ids {
index[id] = i
}
return index, ids
}
index, ids := positions(t)
for _, id := range []int64{oldest, middle, newest} {
if _, ok := index[id]; !ok {
t.Fatalf("account %d with no projection is absent from %d candidates", id, len(ids))
}
}
if _, ok := index[bot]; ok {
t.Fatalf("bot %d was offered as a rating candidate", bot)
}
if _, ok := index[deleted]; ok {
t.Fatalf("deleted account %d was offered as a rating candidate", deleted)
}
// The service accounts are infrastructure. The platform account in particular is
// NOT flagged is_bot, so excluding bots alone would still have seeded it -- which
// is exactly how it got a rating.
for _, serviceID := range domain.SystemUserIDs() {
if _, ok := index[serviceID]; ok {
t.Fatalf("service account %d was offered as a rating candidate", serviceID)
}
}
if !(index[oldest] < index[middle] && index[middle] < index[newest]) {
t.Fatalf("candidate order = oldest %d, middle %d, newest %d; want oldest first",
index[oldest], index[middle], index[newest])
}
// Seeding one account removes it from the candidate set, so the pass converges
// instead of offering the same user every cycle.
if _, changed, err := store.SaveAccountRating(ctx, domain.AccountRating{
UserID: middle, Level: 1, Stars: 150,
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
ComputedAt: time.Now().UTC(), Version: 1,
}); err != nil || !changed {
t.Fatalf("seed projection = %v changed=%v", err, changed)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM account_rating WHERE user_id = $1`, middle)
})
index, _ = positions(t)
if _, ok := index[middle]; ok {
t.Fatalf("account %d is still a candidate after being seeded", middle)
}
if _, ok := index[oldest]; !ok {
t.Fatalf("seeding %d removed unrelated candidate %d", middle, oldest)
}
// The limit is honoured, so one cycle can never walk the whole users table.
capped, err := store.UnratedAccounts(ctx, 2)
if err != nil {
t.Fatalf("UnratedAccounts with a limit: %v", err)
}
if len(capped) != 2 {
t.Fatalf("limited candidates = %d, want 2", len(capped))
}
}

View file

@ -0,0 +1,152 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type AuthDeliveryReportStore struct {
db sqlcgen.DBTX
}
func NewAuthDeliveryReportStore(db sqlcgen.DBTX) *AuthDeliveryReportStore {
return &AuthDeliveryReportStore{db: db}
}
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(ctx context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
if s == nil || s.db == nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store is not configured")
}
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, err
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("begin auth delivery report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('auth-delivery:' || encode($1::bytea, 'hex'), 0)
)`, report.AuthKeyID[:]); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("lock auth delivery reporter: %w", err)
}
existing, found, err := getAuthDeliveryReportByFingerprint(ctx, tx, report.AuthKeyID, report.Fingerprint)
if err != nil {
return domain.AuthDeliveryReport{}, false, err
}
if found {
return existing, false, nil
}
var hourly, phoneDaily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (
WHERE auth_key_id = $1 AND created_at >= $3::timestamptz - interval '1 hour'
),
count(*) FILTER (
WHERE phone_hash = $2 AND created_at >= $3::timestamptz - interval '24 hours'
)
FROM auth_delivery_reports
WHERE created_at <= $3::timestamptz
AND (auth_key_id = $1 OR phone_hash = $2)`,
report.AuthKeyID[:], report.PhoneHash[:], report.CreatedAt,
).Scan(&hourly, &phoneDaily); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("count auth delivery reports: %w", err)
}
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
}
err = tx.QueryRow(ctx, `
INSERT INTO auth_delivery_reports (
auth_key_id, session_id, client_type, phone_hash, code_hash,
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id`,
report.AuthKeyID[:], report.SessionID, report.ClientType,
report.PhoneHash[:], report.CodeHash[:], report.IssuedUserID,
report.DeliveryID, string(report.Channel), report.MNC,
report.Fingerprint[:], report.CreatedAt,
).Scan(&report.ID)
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("insert auth delivery report: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("commit auth delivery report: %w", err)
}
return report, true, nil
}
func getAuthDeliveryReportByFingerprint(ctx context.Context, db sqlcgen.DBTX, authKeyID [8]byte, fingerprint [32]byte) (domain.AuthDeliveryReport, bool, error) {
var report domain.AuthDeliveryReport
var storedAuthKey, phoneHash, codeHash, storedFingerprint []byte
var channel string
err := db.QueryRow(ctx, `
SELECT id, auth_key_id, session_id, client_type, phone_hash, code_hash,
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
FROM auth_delivery_reports
WHERE auth_key_id = $1 AND fingerprint = $2`,
authKeyID[:], fingerprint[:],
).Scan(
&report.ID, &storedAuthKey, &report.SessionID, &report.ClientType,
&phoneHash, &codeHash, &report.IssuedUserID, &report.DeliveryID,
&channel, &report.MNC, &storedFingerprint, &report.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AuthDeliveryReport{}, false, nil
}
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: %w", err)
}
if len(storedAuthKey) != len(report.AuthKeyID) ||
len(phoneHash) != len(report.PhoneHash) ||
len(codeHash) != len(report.CodeHash) ||
len(storedFingerprint) != len(report.Fingerprint) {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: invalid hash shape")
}
copy(report.AuthKeyID[:], storedAuthKey)
copy(report.PhoneHash[:], phoneHash)
copy(report.CodeHash[:], codeHash)
copy(report.Fingerprint[:], storedFingerprint)
report.Channel = domain.AuthCodeDeliveryKind(channel)
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("validate auth delivery report: %w", err)
}
return report, true, nil
}
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("auth delivery report store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrAuthDeliveryReportInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM auth_delivery_reports
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)
DELETE FROM auth_delivery_reports r
USING doomed d
WHERE r.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired auth delivery reports: %w", err)
}
return int(tag.RowsAffected()), nil
}

View file

@ -0,0 +1,53 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestAuthDeliveryReportPostgresIsIdempotentAndRetainedSeparately(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
createdAt := time.Unix(123_456, 0).UTC()
report, err := domain.NewAuthDeliveryReport(
[8]byte{1, 2, 3, 4, 5, 6, 7, byte(time.Now().UnixNano())},
time.Now().UnixNano(), "tdesktop", "15550004444",
"phone-code-hash", 77, "delivery-77",
domain.AuthCodeDeliverySMS, "46000", createdAt,
)
if err != nil {
t.Fatal(err)
}
store := NewAuthDeliveryReportStore(pool)
stored, created, err := store.CreateAuthDeliveryReport(ctx, report)
if err != nil || !created || stored.ID <= 0 {
t.Fatalf("stored=%+v created=%v err=%v", stored, created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_delivery_reports WHERE id = $1", stored.ID)
})
retry, created, err := store.CreateAuthDeliveryReport(ctx, report)
if err != nil || created || retry.ID != stored.ID ||
retry.PhoneHash != stored.PhoneHash || retry.CodeHash != stored.CodeHash {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
deleted, err := store.DeleteExpiredAuthDeliveryReports(
ctx, createdAt.Add(time.Second), 10,
)
if err != nil || deleted < 1 {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
var moderationRows int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM moderation_reports
WHERE reporter_user_id = $1`, report.IssuedUserID).Scan(&moderationRows); err != nil {
t.Fatal(err)
}
if moderationRows != 0 {
t.Fatalf("auth diagnostic leaked into moderation reports: %d", moderationRows)
}
}

View file

@ -195,8 +195,9 @@ WHERE auth_key_id = $1
//
// 同时清理把本 key 当作 perm key 的 temp auth key 行temp_auth_key_bindings.temp_auth_key_id
// 侧有外键 ON DELETE CASCADE删除 temp key 会自动清绑定perm_auth_key_id 侧由
// RESTRICT FK 防止悬空,因此被踢/销毁 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED而不是连接层 404。
// RESTRICT FK 防止悬空,因此显式销毁 perm key 时必须先把关联 temp key 一并删掉。
// 远程撤销 authorization 不得调用本方法:被踢客户端必须保留协议 key重连进入 RPC
// 层后取得 AUTH_KEY_UNREGISTERED而不是只收到连接层 -404。
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
return withAuthIdentityTx(ctx, s.db, "delete auth key", func(tx pgx.Tx) error {
return deleteAuthKeyTx(ctx, tx, authKeyIDToInt64(id))

View file

@ -252,9 +252,10 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
return a, true, nil
}
// RevokeByHash 删除协议 auth_key 作为远程踢设备的持久化事实入口。
// authorizations 通过 FK cascade 删除update_states 没有 auth_keys FK必须显式清理
// 关联 temp auth key 也显式删除,避免 raw temp key 重连。
// RevokeByHash 是远程踢设备的持久化事实入口:删除业务 authorization 与
// device update state但保留 permanent/temp 协议 key 和 binding。这样被踢客户端
// 重连后仍可完成 MTProto 解密,并由 RPC gate 返回 AUTH_KEY_UNREGISTERED若先删除
// 协议 key客户端只能收到 transport -404无法可靠清理本地登录态。
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
var (
a domain.Authorization
@ -315,7 +316,7 @@ FOR UPDATE`, candidate, userID, hash))
if !found {
return domain.Authorization{}, false, nil
}
if err := deleteRevocationTargetsTx(ctx, tx, []int64{candidate}); err != nil {
if err := deleteRevokedAuthorizationStateTx(ctx, tx, []int64{candidate}); err != nil {
return domain.Authorization{}, false, err
}
return a, true, nil
@ -349,7 +350,8 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
return out, nil
}
// RevokeByUserExcept 批量删除协议 auth_key保留 keepAuthKeyID 对应的当前设备。
// RevokeByUserExcept 批量删除业务 authorization保留 keepAuthKeyID 对应的当前设备;
// 被撤销设备的协议 key/binding 保留,以便重连后取得 RPC 401 并完成客户端退出。
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
var out []domain.Authorization
err := withAuthIdentityTx(ctx, s.db, "revoke authorizations by user", func(tx pgx.Tx) error {
@ -449,13 +451,38 @@ FOR UPDATE`, userID, keepAuthKeyID, candidates)
for i := range out {
targets[i] = authKeyIDToInt64(out[i].AuthKeyID)
}
if err := deleteRevocationTargetsTx(ctx, tx, targets); err != nil {
if err := deleteRevokedAuthorizationStateTx(ctx, tx, targets); err != nil {
return nil, err
}
return out, nil
}
func deleteRevocationTargetsTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
func deleteRevokedAuthorizationStateTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM authorizations
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete revoked authorizations: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete revoked authorizations: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}
// deleteProtocolAuthIdentitiesTx permanently removes permanent identities and
// their derived temp keys. Remote account.resetAuthorization/
// auth.resetAuthorizations must not use this helper: those clients need the
// protocol key long enough to reconnect and receive an RPC-level 401.
func deleteProtocolAuthIdentitiesTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
@ -466,21 +493,21 @@ WHERE auth_key_id IN (
FROM temp_auth_key_bindings
WHERE perm_auth_key_id = ANY($1::bigint[])
)`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked temporary auth keys: %w", err)
return fmt.Errorf("delete temporary auth keys: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked update states: %w", err)
return fmt.Errorf("delete protocol identity update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete revoked permanent auth keys: %w", err)
return fmt.Errorf("delete permanent auth keys: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete revoked permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
return fmt.Errorf("delete permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}

View file

@ -2,7 +2,6 @@ package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
@ -13,7 +12,7 @@ import (
"telesrv/internal/store"
)
func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByHashKeepsProtocolIdentityPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "hash")
@ -43,14 +42,15 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
if err := NewUpdateStateStore(pool).Save(ctx, perm, userID, domain.UpdateState{Pts: 11, Date: int(time.Now().Unix())}); err != nil {
t.Fatalf("save update state: %v", err)
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: authKeyIDToInt64(perm),
Nonce: 1,
TempSessionID: 2,
ExpiresAt: tempExpiry,
EncryptedMessage: []byte{1, 2, 3, 4},
}); err != nil {
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, binding); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -61,14 +61,14 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
if deleted.AuthKeyID != perm || !deleted.PasswordPending {
t.Fatalf("deleted authorization = %+v, want perm key and password_pending", deleted)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
assertRevokeTestTableCount(t, ctx, pool, "update_states", "auth_key_id", authKeyIDToInt64(perm), 0)
assertRevokeTestTableCount(t, ctx, pool, "temp_auth_key_bindings", "temp_auth_key_id", authKeyIDToInt64(temp), 0)
assertTempIdentityBinding(t, ctx, NewTempAuthKeyBindingStore(pool), binding)
}
func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByUserExceptKeepsRevokedProtocolIdentitiesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "bulk")
@ -87,14 +87,15 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
}
tempExpiry := int(time.Now().Add(time.Hour).Unix())
saveRevokeTestAuthKey(t, ctx, keys, tempForTwo, tempExpiry)
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: tempForTwo,
PermAuthKeyID: authKeyIDToInt64(revokedTwo),
Nonce: 3,
TempSessionID: 4,
ExpiresAt: tempExpiry,
EncryptedMessage: []byte{5, 6, 7, 8},
}); err != nil {
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, binding); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -107,9 +108,12 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
}
assertRevokeTestPresentAuthKey(t, ctx, keys, keep)
assertRevokeTestPresentAuthorization(t, ctx, auths, keep)
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedOne)
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedTwo)
assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo)
assertRevokeTestPresentAuthKey(t, ctx, keys, revokedOne)
assertRevokeTestPresentAuthKey(t, ctx, keys, revokedTwo)
assertRevokeTestPresentAuthKey(t, ctx, keys, tempForTwo)
assertRevokeTestNoAuthorization(t, ctx, auths, revokedOne)
assertRevokeTestNoAuthorization(t, ctx, auths, revokedTwo)
assertTempIdentityBinding(t, ctx, NewTempAuthKeyBindingStore(pool), binding)
}
func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
@ -155,7 +159,7 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
}
}
func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByHashConcurrentTempBindKeepsProtocolIdentityPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "bind-revoke-race")
@ -203,28 +207,17 @@ func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePo
close(start)
bindErr := <-bindResult
if bindErr != nil && !errors.Is(bindErr, store.ErrAuthKeyBindingInvalid) {
if bindErr != nil {
t.Fatalf("attempt %d bind/revoke race bind error = %v", attempt, bindErr)
}
revoked := <-revokeResults
if revoked.err != nil || !revoked.found {
t.Fatalf("attempt %d bind/revoke race found=%v err=%v", attempt, revoked.found, revoked.err)
}
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
t.Fatalf("attempt %d dangling binding found=%v err=%v", attempt, found, err)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
assertTempIdentityBinding(t, ctx, bindings, candidate)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
if bindErr == nil {
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
} else {
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, tempExpiry)
assertRevokeTestNoAuthorization(t, ctx, auths, temp)
if err := keys.Delete(ctx, temp); err != nil {
t.Fatalf("attempt %d clean unbound loser temp: %v", attempt, err)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
}
}
}
@ -520,12 +513,10 @@ func TestAuthorizationStoreRevokeByUserExceptPartiallySkipsTransferredCandidateP
t.Fatalf("old A state for transferred key found=%v err=%v, want absent", found, err)
}
assertRevokeTestMissingAuthKey(t, testCtx, keys, revoked)
assertRevokeTestMissingAuthKey(t, testCtx, keys, revokedTemp)
assertRevokeTestPresentAuthKey(t, testCtx, keys, revoked)
assertRevokeTestPresentAuthKey(t, testCtx, keys, revokedTemp)
assertRevokeTestNoAuthorization(t, testCtx, auths, revoked)
if _, found, err := bindings.GetByTemp(testCtx, revokedTemp); err != nil || found {
t.Fatalf("revoked temp binding found=%v err=%v, want absent", found, err)
}
assertTempIdentityBinding(t, testCtx, bindings, revokedBinding)
if _, found, err := states.Get(testCtx, revoked, userA); err != nil || found {
t.Fatalf("revoked A state found=%v err=%v, want absent", found, err)
}

View file

@ -70,7 +70,7 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert user: %w", err)
}
if usernameLower := strings.ToLower(row.Username); usernameLower != "" {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil {
return domain.User{}, domain.BotProfile{}, err
}
}
@ -139,7 +139,7 @@ func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domai
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
return domain.User{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, "", ""); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
}
// Drop the bots row so the token can no longer authenticate a login.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -317,6 +317,7 @@ func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, chan
SELECT `+channelColumns+`,
m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
m.history_clear_anchor_id, m.history_clear_anchor_date,
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date
FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
@ -395,6 +396,10 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
if err != nil {
return nil, err
}
publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, s.db, peerUsernameTypeChannel, remaining)
if err != nil {
return nil, err
}
for _, channel := range channels {
if member, ok := linkedGuests[channel.ID]; ok {
views[channel.ID] = domain.ChannelView{
@ -427,7 +432,8 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
continue
}
}
if !publicPreviewableChannel(channel) {
_, hasActiveUsername := publicUsernameIDs[channel.ID]
if !publicPreviewableChannel(channel, hasActiveUsername) {
continue
}
existing, found := previewMembers[channel.ID]
@ -514,17 +520,18 @@ func finishChannelScan(ch *domain.Channel, rights, reactionPolicy string, wallpa
}
}
func publicPreviewableChannel(channel domain.Channel) bool {
func publicPreviewableChannel(channel domain.Channel, hasActiveUsername bool) bool {
return !channel.Deleted &&
(channel.Broadcast || channel.Megagroup) &&
strings.TrimSpace(channel.Username) != ""
(strings.TrimSpace(channel.Username) != "" || hasActiveUsername)
}
func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) {
var participants, admins, kicked, banned int
rows, err := tx.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1`, channel.ID)
if err != nil {

View file

@ -18,6 +18,26 @@ type channelDialogListItem struct {
defaultSendAs *domain.Peer
}
func channelDialogVisibleTopIDSQL() string {
return `CASE
WHEN c.top_message_id > m.available_min_id THEN c.top_message_id
WHEN m.history_clear_anchor_id > 0
AND m.history_clear_anchor_id = m.available_min_id
THEN m.history_clear_anchor_id
ELSE 0
END`
}
func channelDialogVisibleTopDateSQL(globalTopDate, emptyFallback string) string {
return `CASE
WHEN c.top_message_id > m.available_min_id THEN ` + globalTopDate + `
WHEN m.history_clear_anchor_id > 0
AND m.history_clear_anchor_id = m.available_min_id
THEN m.history_clear_anchor_date
ELSE ` + emptyFallback + `
END`
}
func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil
@ -33,8 +53,8 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
args := []any{viewerUserID, channelIDs}
@ -139,7 +159,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -232,6 +254,7 @@ LIMIT `+limitArg, args...)
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
@ -255,7 +278,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
0,
0
FROM user_channel_member_index i
JOIN channel_members pm ON pm.user_id = i.user_id AND pm.channel_id = i.channel_id
JOIN channels parent ON parent.id = i.channel_id AND parent.id = ANY($2::bigint[]) AND parent.broadcast AND parent.linked_monoforum_id <> 0 AND NOT parent.deleted
@ -357,6 +382,20 @@ WHERE `+where.String(), args...)
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan channel dialog top messages: %w", err)
}
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel ||
dialog.TopMessage <= 0 ||
dialog.TopMessage != dialog.HistoryClearAnchorID {
continue
}
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
out[key] = domain.ProjectChannelHistoryClearMessage(
out[key],
dialog.Peer.ID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
return out, nil
}
@ -413,6 +452,14 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
}
}
msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID)
if dialog.TopMessageID > 0 && dialog.TopMessageID == dialog.HistoryClearAnchorID {
msg = domain.ProjectChannelHistoryClearMessage(
msg,
channelID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
if msg.ID != 0 {
dialog.TopMessageDate = msg.Date
out.Messages = append(out.Messages, msg)
@ -425,9 +472,34 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
func projectChannelDialogHistoryClearMessages(dialogs []domain.Dialog, messages []domain.ChannelMessage) {
anchors := make(map[channelMessageLookupKey]domain.Dialog)
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel ||
dialog.TopMessage <= 0 ||
dialog.TopMessage != dialog.HistoryClearAnchorID {
continue
}
anchors[channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}] = dialog
}
for i := range messages {
dialog, ok := anchors[channelMessageLookupKey{channelID: messages[i].ChannelID, id: messages[i].ID}]
if !ok {
continue
}
messages[i] = domain.ProjectChannelHistoryClearMessage(
messages[i],
dialog.Peer.ID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
}
func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) {
if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 {
return domain.CommonChannelsResult{}, domain.ErrChannelInvalid
@ -553,6 +625,7 @@ func (s *ChannelStore) leftChannelsByIDs(ctx context.Context, userID int64, ids
rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE user_id = $1
@ -603,8 +676,8 @@ func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, l
if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "GREATEST(c.date, m.joined_at)")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
rows, err := s.db.Query(ctx, `
@ -619,7 +692,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -856,6 +931,7 @@ func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64,
// 归档必须 ensure-INSERT从未读过/置顶过的频道还没有 dialog 行,
// 只 UPDATE 会让归档静默丢失;新行同时带上 member 真实水位,
// 避免 0 水位缓存行遮蔽未读/已读状态。
visibleTopID := channelDialogVisibleTopIDSQL()
if _, err := s.db.Exec(ctx, `
WITH requested AS (
SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id
@ -870,7 +946,7 @@ deduped AS (
)
INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count, updated_at)
SELECT $1, m.channel_id, deduped.folder_id,
CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END,
`+visibleTopID+`,
m.read_inbox_max_id, m.read_outbox_max_id,
(
SELECT COUNT(*)::int
@ -904,7 +980,7 @@ func (s *ChannelStore) CountChannelArchiveUnread(ctx context.Context, userID int
// JOIN active member退群残留的 channel_dialogs 行不计入归档徽章。
// unread 读时动态派生H4a不再消费 channel_dialogs.unread_count 缓存列;归档集合
// 有界(需显式归档建行),每行动态 COUNT 已被 cap 钳制。
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
if err := s.db.QueryRow(ctx, `
SELECT
@ -937,7 +1013,7 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID}
var defaultSendAsType sql.NullString
var defaultSendAsID sql.NullInt64
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
// 单频道 TopMessageDate 用 LEFT JOIN top_msg 直接取,替代此前对每个频道再单查一次
@ -946,7 +1022,10 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
// message_date(不过滤 deleted,与原 getChannelMessage 一致),否则回退
// COALESCE(d.top_message_date, c.date)。注意:与批量版 getChannelDialogs 的隐藏态
// (ELSE 0)刻意保持各自既有差异,本改动只去往返、不改单频道输出。
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id AND top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END"
visibleTopDate := channelDialogVisibleTopDateSQL(
"CASE WHEN top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END",
"COALESCE(d.top_message_date, c.date)",
)
err := db.QueryRow(ctx, `
SELECT `+visibleTopID+`,
`+visibleTopDate+`,
@ -962,7 +1041,9 @@ SELECT `+visibleTopID+`,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id
@ -983,6 +1064,8 @@ WHERE c.id = $2`, userID, channel.ID).Scan(
&dialog.HasScheduled,
&defaultSendAsType,
&defaultSendAsID,
&dialog.HistoryClearAnchorID,
&dialog.HistoryClearAnchorDate,
)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err)
@ -1037,8 +1120,8 @@ func (s *ChannelStore) getChannelDialogsUncached(ctx context.Context, db sqlcgen
if userID == 0 || len(channelIDs) == 0 {
return nil, nil
}
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
rows, err := db.Query(ctx, `
@ -1057,7 +1140,9 @@ SELECT c.id,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -1089,6 +1174,8 @@ WHERE c.id = ANY($2::bigint[])`, userID, channelIDs)
&dialog.HasScheduled,
&defaultSendAsType,
&defaultSendAsID,
&dialog.HistoryClearAnchorID,
&dialog.HistoryClearAnchorDate,
); err != nil {
return nil, err
}
@ -1197,6 +1284,7 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
var rights, reactionPolicy string
var wallpaper *string
var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int
var historyClearAnchorID, historyClearAnchorDate int
var pinned, unreadMark, viewForumAsMessages, hasScheduled bool
var defaultSendAsType sql.NullString
var defaultSendAsID sql.NullInt64
@ -1204,27 +1292,30 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
&topID, &topDate,
&folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, &hasScheduled,
&defaultSendAsType, &defaultSendAsID,
&historyClearAnchorID, &historyClearAnchorDate,
)
if err := row.Scan(dest...); err != nil {
return domain.Channel{}, domain.Dialog{}, nil, err
}
finishChannelScan(&ch, rights, reactionPolicy, wallpaper)
dialog := domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
FolderID: folderID,
TopMessage: topID,
TopMessageDate: topDate,
ReadInboxMaxID: readInbox,
ReadOutboxMaxID: readOutbox,
UnreadCount: unreadCount,
UnreadMentions: unreadMentions,
UnreadReactions: unreadReactions,
Pinned: pinned,
PinnedOrder: pinnedOrder,
UnreadMark: unreadMark,
ViewForumAsMessages: viewForumAsMessages,
HasScheduled: hasScheduled,
Pts: ch.Pts,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
FolderID: folderID,
TopMessage: topID,
TopMessageDate: topDate,
HistoryClearAnchorID: historyClearAnchorID,
HistoryClearAnchorDate: historyClearAnchorDate,
ReadInboxMaxID: readInbox,
ReadOutboxMaxID: readOutbox,
UnreadCount: unreadCount,
UnreadMentions: unreadMentions,
UnreadReactions: unreadReactions,
Pinned: pinned,
PinnedOrder: pinnedOrder,
UnreadMark: unreadMark,
ViewForumAsMessages: viewForumAsMessages,
HasScheduled: hasScheduled,
Pts: ch.Pts,
}
var defaultSendAs *domain.Peer
if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 {
@ -1236,41 +1327,45 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.Dialog {
return domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: channelPts,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: channelPts,
}
}
func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelDialog {
return domain.ChannelDialog{
UserID: userID,
ChannelID: dialog.Peer.ID,
FolderID: dialog.FolderID,
TopMessageID: dialog.TopMessage,
TopMessageDate: dialog.TopMessageDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
UserID: userID,
ChannelID: dialog.Peer.ID,
FolderID: dialog.FolderID,
TopMessageID: dialog.TopMessage,
TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
}
}
@ -1392,14 +1487,23 @@ func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.Di
func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID {
topMessageID = 0
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0
}
}
topMessageDate := channel.Date
if topMessageID > 0 && topMessageID == member.HistoryClearAnchorID {
topMessageDate = member.HistoryClearAnchorDate
}
return domain.ChannelDialog{
UserID: userID,
ChannelID: channel.ID,
TopMessageID: topMessageID,
TopMessageDate: channel.Date,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
UserID: userID,
ChannelID: channel.ID,
TopMessageID: topMessageID,
TopMessageDate: topMessageDate,
HistoryClearAnchorID: member.HistoryClearAnchorID,
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
}
}

View file

@ -116,7 +116,7 @@ func TestChannelStoreDifferenceStartsAtMemberAvailableMinPts(t *testing.T) {
}
}
func TestChannelStorePublicPreviewDifferenceSkipsNonMemberMessages(t *testing.T) {
func TestChannelStorePublicPreviewDifferenceReplaysVisibleMessages(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
@ -183,12 +183,57 @@ func TestChannelStorePublicPreviewDifferenceSkipsNonMemberMessages(t *testing.T)
if err != nil {
t.Fatalf("list public preview difference: %v", err)
}
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 0 || len(diff.NewMessages) != 0 || len(diff.OtherUpdates) != 0 {
t.Fatalf("preview diff = %+v, want empty public preview difference at current pts", diff)
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 1 || len(diff.NewMessages) != 1 || len(diff.OtherUpdates) != 0 {
t.Fatalf("preview diff = %+v, want one durable public preview message at pts %d", diff, sent.Event.Pts)
}
if diff.NewMessages[0].ID != sent.Message.ID || diff.NewMessages[0].Body != sent.Message.Body {
t.Fatalf("preview diff message = %+v, want sent message %+v", diff.NewMessages[0], sent.Message)
}
if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID {
t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog)
}
edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, ID: sent.Message.ID,
Message: "public preview edited", EditDate: 1700000372,
})
if err != nil {
t.Fatalf("edit public preview message: %v", err)
}
pinned, err := channels.UpdatePinnedMessage(ctx, domain.UpdateChannelPinnedMessageRequest{
UserID: owner.ID, ChannelID: channelID, MessageID: sent.Message.ID,
Pinned: true, Date: 1700000373,
})
if err != nil {
t.Fatalf("pin public preview message: %v", err)
}
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
UserID: owner.ID, ChannelID: channelID, IDs: []int{sent.Message.ID}, Date: 1700000374,
})
if err != nil {
t.Fatalf("delete public preview message: %v", err)
}
mutations, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: viewer.ID, ChannelID: channelID, Pts: sent.Event.Pts, Limit: 10,
})
if err != nil {
t.Fatalf("list public preview mutations: %v", err)
}
if !mutations.Final || mutations.Pts != deleted.Event.Pts || len(mutations.NewMessages) != 0 || len(mutations.OtherUpdates) != 3 {
t.Fatalf("public preview mutations = %+v, want edit/pin/delete through pts %d", mutations, deleted.Event.Pts)
}
wantTypes := []domain.ChannelUpdateEventType{
domain.ChannelUpdateEditMessage,
domain.ChannelUpdatePinnedMessages,
domain.ChannelUpdateDeleteMessages,
}
for i, want := range wantTypes {
if mutations.OtherUpdates[i].Type != want {
t.Fatalf("public preview mutation[%d] = %+v, want %s", i, mutations.OtherUpdates[i], want)
}
}
if edited.Event.Pts >= pinned.Event.Pts || pinned.Event.Pts >= deleted.Event.Pts {
t.Fatalf("public preview mutation pts = edit %d pin %d delete %d, want strictly increasing", edited.Event.Pts, pinned.Event.Pts, deleted.Event.Pts)
}
}
func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) {

View file

@ -31,7 +31,10 @@ func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.
}
topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID {
topMessageID = 0
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0
}
}
if _, err := s.db.Exec(ctx, `
INSERT INTO channel_dialogs (
@ -150,11 +153,28 @@ func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID in
queryPrefix := escapeLike(queryLower) + "%"
queryLike := "%" + escapeLike(queryLower) + "%"
rows, err := s.db.Query(ctx, `
WITH username_matches AS (
SELECT
peer_id,
MIN(CASE
WHEN username_lower = $2 THEN 0
ELSE 1
END) AS rank
FROM peer_usernames
WHERE peer_type = 'channel'
AND active
AND collectible_id IS NOT NULL
AND (
username_lower = $2
OR username_lower LIKE $3 ESCAPE '\'
)
GROUP BY peer_id
)
SELECT `+channelColumns+`
FROM channels c
LEFT JOIN username_matches um ON um.peer_id = c.id
WHERE NOT c.deleted
AND (c.broadcast OR c.megagroup)
AND COALESCE(c.username, '') <> ''
AND NOT EXISTS (
SELECT 1
FROM channel_members m
@ -163,15 +183,16 @@ WHERE NOT c.deleted
AND m.status = 'active'
)
AND (
lower(c.username) = $2
um.peer_id IS NOT NULL
OR lower(c.username) = $2
OR lower(c.username) LIKE $3 ESCAPE '\'
OR lower(c.title) LIKE $3 ESCAPE '\'
OR lower(c.username) LIKE $4 ESCAPE '\'
OR lower(c.title) LIKE $4 ESCAPE '\'
)
ORDER BY CASE
WHEN lower(c.username) = $2 THEN 0
WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1
WHEN um.rank = 0 OR lower(c.username) = $2 THEN 0
WHEN um.rank = 1 OR lower(c.username) LIKE $3 ESCAPE '\' THEN 1
WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2
WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3
ELSE 4
@ -375,15 +396,59 @@ LIMIT $4`, userID, sinceDate, afterChannelID, limit)
return nil, fmt.Errorf("list dirty active channels for user: %w", err)
}
defer rows.Close()
out := make([]domain.DirtyChannel, 0, limit)
byChannelID := make(map[int64]domain.DirtyChannel, limit*2)
for rows.Next() {
var item domain.DirtyChannel
if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil {
return nil, err
}
item.ChannelUpdatesDirty = true
byChannelID[item.ChannelID] = item
}
if err := rows.Err(); err != nil {
return nil, err
}
clearRows, err := s.db.Query(ctx, `
SELECT i.channel_id, c.pts, i.available_min_id, i.history_clear_updated_at
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
AND i.history_clear_anchor_id > 0
AND i.history_clear_anchor_id = i.available_min_id
AND i.history_clear_updated_at >= $2
ORDER BY i.channel_id ASC
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
if err != nil {
return nil, fmt.Errorf("list owner-local channel history clears for user: %w", err)
}
defer clearRows.Close()
for clearRows.Next() {
var item domain.DirtyChannel
if err := clearRows.Scan(&item.ChannelID, &item.Pts, &item.AvailableMinID, &item.HistoryClearDate); err != nil {
return nil, err
}
if existing, ok := byChannelID[item.ChannelID]; ok {
item.ChannelUpdatesDirty = existing.ChannelUpdatesDirty
}
byChannelID[item.ChannelID] = item
}
if err := clearRows.Err(); err != nil {
return nil, err
}
out := make([]domain.DirtyChannel, 0, len(byChannelID))
for _, item := range byChannelID {
out = append(out, item)
}
return out, rows.Err()
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
type rowScanner interface {
@ -421,7 +486,12 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil
}
}
if !publicPreviewableChannel(ch) {
publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, db, peerUsernameTypeChannel, []int64{ch.ID})
if err != nil {
return domain.Channel{}, domain.ChannelMember{}, false, err
}
_, hasActiveUsername := publicUsernameIDs[ch.ID]
if !publicPreviewableChannel(ch, hasActiveUsername) {
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
}
member, err = s.getPublicPreviewMember(ctx, db, viewerUserID, ch)
@ -431,6 +501,19 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
return ch, member, true, nil
}
// channelMessageVisibleToViewer applies the message-level half of synthetic monoforum access.
// Subscribers do not have channel_members rows and may only address saved_peer=self; a synthetic
// manager view may address every subscriber sub-dialog.
func channelMessageVisibleToViewer(channel domain.Channel, member domain.ChannelMember, viewerUserID int64, msg domain.ChannelMessage) bool {
if !channel.Monoforum {
return true
}
if member.CanManageDirectMessages() {
return true
}
return msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID})
}
func getChannelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) {
ch, err := scanChannel(db.QueryRow(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = $1 AND NOT c.deleted`, channelID))
if errors.Is(err, pgx.ErrNoRows) {

View file

@ -207,6 +207,45 @@ func TestReadModelChangeListenerInvalidatesPrivateMediaCountCache(t *testing.T)
}
}
func TestReadModelChangeListenerInvalidatesAccountSettingsCache(t *testing.T) {
settings := &fakeAccountSettingsReadModelCache{}
listener := NewReadModelChangeListener("", ReadModelCacheSet{
AccountSettings: settings,
}, nil)
listener.handlePayload(`{"model":"account_settings","owner_user_id":100,"peer_type":"user","peer_id":100,"version":2}`)
if len(settings.invalidated) != 1 || settings.invalidated[0] != 100 {
t.Fatalf("account_settings invalidations = %v, want [100]", settings.invalidated)
}
if len(settings.warmed) != 1 || settings.warmed[0] != 100 {
t.Fatalf("account_settings warmups = %v, want [100]", settings.warmed)
}
listener.flush("test")
if settings.flushes != 1 {
t.Fatalf("account_settings flushes = %d, want 1", settings.flushes)
}
}
type fakeAccountSettingsReadModelCache struct {
invalidated []int64
warmed []int64
flushes int
}
func (f *fakeAccountSettingsReadModelCache) InvalidateAccountSettingsReadModel(userID int64) {
f.invalidated = append(f.invalidated, userID)
}
func (f *fakeAccountSettingsReadModelCache) FlushAccountSettingsReadModel() {
f.flushes++
}
func (f *fakeAccountSettingsReadModelCache) WarmAccountSettingsReadModel(_ context.Context, userID int64) error {
f.warmed = append(f.warmed, userID)
return nil
}
// TestReadModelChangeListenerBotFullFlushesChannelFullBots 回归: bot 改资料(bot_full 事件,
// 迁移 0013)须 flush channelFullBotInfoCache(否则群信息页 bot 简介/命令跨实例陈旧至 TTL);
// 普通用户的 user_base 事件不得 flush(否则该缓存形同虚设)。
@ -419,6 +458,7 @@ func (f *fakeDialogReadModelCache) flushCount() int {
type fakePrivacyReadModelCache struct {
mu sync.Mutex
ids []int64
warmed []int64
flushes int
}
@ -428,6 +468,13 @@ func (f *fakePrivacyReadModelCache) InvalidateOwners(ids ...int64) {
f.ids = append(f.ids, ids...)
}
func (f *fakePrivacyReadModelCache) WarmOwners(_ context.Context, ids ...int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.warmed = append(f.warmed, ids...)
return nil
}
func (f *fakePrivacyReadModelCache) FlushReadModelCache() {
f.mu.Lock()
defer f.mu.Unlock()
@ -440,6 +487,12 @@ func (f *fakePrivacyReadModelCache) idsSnapshot() []int64 {
return append([]int64(nil), f.ids...)
}
func (f *fakePrivacyReadModelCache) warmedSnapshot() []int64 {
f.mu.Lock()
defer f.mu.Unlock()
return append([]int64(nil), f.warmed...)
}
func (f *fakePrivacyReadModelCache) flushCount() int {
f.mu.Lock()
defer f.mu.Unlock()
@ -509,6 +562,9 @@ func TestReadModelChangeListenerInvalidatesAccountCaches(t *testing.T) {
if len(privacy.ids) != 1 || privacy.ids[0] != 21 {
t.Fatalf("privacy invalidations = %v, want [21]", privacy.ids)
}
if warmed := privacy.warmedSnapshot(); len(warmed) != 1 || warmed[0] != 21 {
t.Fatalf("privacy warms = %v, want [21]", warmed)
}
listener.handlePayload(`{"model":"dialog_light","owner_user_id":22,"peer_type":"user","peer_id":32,"version":4}`)
if len(dialogs.owners) != 1 || dialogs.owners[0] != 22 || dialogs.keys[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 32}) {

View file

@ -189,6 +189,7 @@ func getChannelMemberByID(ctx context.Context, db sqlcgen.DBTX, channelID, userI
row := db.QueryRow(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1 AND user_id = $2`, channelID, userID)
@ -214,8 +215,9 @@ func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channe
if _, err := tx.Exec(ctx, `
INSERT INTO channel_members (
channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (channel_id, user_id) DO UPDATE SET
inviter_user_id = EXCLUDED.inviter_user_id,
role = EXCLUDED.role,
@ -231,7 +233,8 @@ ON CONFLICT (channel_id, user_id) DO UPDATE SET
updated_at = now()`,
member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status),
member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID,
member.AvailableMinPts, member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil {
member.AvailableMinPts, member.HistoryClearAnchorID, member.HistoryClearAnchorDate,
member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil {
return fmt.Errorf("upsert channel member: %w", err)
}
return upsertUserChannelMemberIndexTx(ctx, tx, channel, member)
@ -316,7 +319,8 @@ func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember
dest := append(channelScanDest(&ch, &defaultRights, &reactionPolicy, &wallpaper),
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
)
if err := row.Scan(dest...); err != nil {
return domain.Channel{}, domain.ChannelMember{}, err
@ -351,7 +355,8 @@ func scanChannelMember(row rowScanner) (domain.ChannelMember, error) {
if err := row.Scan(
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
); err != nil {
return domain.ChannelMember{}, err
}
@ -370,7 +375,8 @@ func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, erro
if err := row.Scan(
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&count,
); err != nil {
return domain.ChannelMember{}, 0, err
@ -471,6 +477,8 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
}
member.AvailableMinID = 0
member.AvailableMinPts = 0
member.HistoryClearAnchorID = 0
member.HistoryClearAnchorDate = 0
member.ReadInboxMaxID = mono.TopMessageID
member.ReadOutboxMaxID = mono.TopMessageID
member.UnreadMark = false

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
@ -123,7 +124,12 @@ SELECT peer_id
FROM peer_usernames
WHERE username_lower = $1 AND peer_type = 'channel'
`, publicUsername)
requirePlanContains(t, usernameLookupPlan, "peer_usernames_pkey")
// 0150 adds peer_usernames_peer_order_idx, which covers this lookup as an
// index-only scan; either index is an acceptable plan, a partition scan is not.
if !strings.Contains(usernameLookupPlan, "peer_usernames_pkey") &&
!strings.Contains(usernameLookupPlan, "peer_usernames_peer_order_idx") {
t.Fatalf("username lookup plan = %s, want a peer_usernames index scan", usernameLookupPlan)
}
requirePlanNotMatches(t, usernameLookupPlan, `channels_p\d+`)
usernameChannelDetailPlan := explainText(t, ctx, tx, `

View file

@ -267,6 +267,7 @@ func (s *ChannelStore) futureCreatorAfterLeave(ctx context.Context, db sqlcgen.D
member, err := scanChannelMember(db.QueryRow(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1

View file

@ -128,7 +128,8 @@ func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channe
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
`+from+`
WHERE `+strings.Join(where, " AND ")+`
ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id
@ -221,7 +222,8 @@ func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserI
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1 AND status = 'active'
ORDER BY user_id
@ -264,6 +266,7 @@ func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUs
rows, err := s.db.Query(ctx, `
SELECT m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
m.history_clear_anchor_id, m.history_clear_anchor_date,
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date,
COUNT(*) OVER()::int
FROM bots b
@ -360,3 +363,52 @@ ORDER BY user_id`, channelID, candidates[start:end])
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out, nil
}
func (s *ChannelStore) FilterChannelMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
if channelID == 0 || len(userIDs) == 0 {
return nil, nil
}
candidates := uniqueChannelUserIDs(userIDs, 0)
if len(candidates) == 0 {
return nil, nil
}
out := make([]int64, 0, len(candidates))
for start := 0; start < len(candidates); start += channelMemberFilterBatch {
end := start + channelMemberFilterBatch
if end > len(candidates) {
end = len(candidates)
}
rows, err := s.db.Query(ctx, `
SELECT candidate.user_id
FROM channels c
CROSS JOIN unnest($2::bigint[]) AS candidate(user_id)
LEFT JOIN channel_members m
ON m.channel_id = c.id AND m.user_id = candidate.user_id
WHERE c.id = $1
AND NOT c.deleted
AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false)
AND COALESCE(m.status, '') NOT IN ('kicked', 'banned')
AND (
m.status = 'active'
OR (COALESCE(c.username, '') <> '' AND COALESCE(m.status, 'left') = 'left')
)
ORDER BY candidate.user_id`, channelID, candidates[start:end])
if err != nil {
return nil, fmt.Errorf("filter channel message audience: %w", err)
}
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
rows.Close()
return nil, err
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
}
return out, nil
}

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"sort"
@ -60,6 +61,68 @@ func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.Del
return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, DiscussionDeletes: cascades}, nil
}
func (s *ChannelStore) ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
if channelID <= 0 || len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
if date == 0 {
date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("moderation delete channel messages: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin moderation delete channel messages: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, err := getChannelByID(ctx, tx, channelID)
if err != nil || channel.Deleted {
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
refs, err := s.discussionRefsForMessages(ctx, tx, channel.ID, ids)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
systemMember := domain.ChannelMember{
ChannelID: channel.ID, UserID: domain.OfficialSystemUserID,
Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive,
}
deleted, event, channel, err := s.deleteChannelMessagesTx(
ctx, tx, channel, systemMember, ids, domain.OfficialSystemUserID, date,
)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
cascades, err := s.cascadeDeleteDiscussionRootsTx(
ctx, tx, refs, deleted, domain.OfficialSystemUserID, date,
)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit moderation delete channel messages: %w", err)
}
committed = true
recipients, _ := s.ListActiveChannelMemberIDs(ctx, 0, channel.ID, 0)
for i := range cascades {
cascades[i].Recipients, _ = s.ListActiveChannelMemberIDs(ctx, 0, cascades[i].Channel.ID, 0)
}
return domain.DeleteChannelMessagesResult{
Channel: channel, Event: event, DeletedIDs: deleted,
Recipients: recipients, DiscussionDeletes: cascades,
}, nil
}
// discussionRefsForMessages 取待删消息携带的讨论组转发根引用。
func (s *ChannelStore) discussionRefsForMessages(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) (map[int]domain.ChannelDiscussionRef, error) {
id32, _, err := validUniqueChannelMessageIDs(ids)
@ -161,19 +224,68 @@ func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.Dele
}
if !req.ForEveryone {
appliedMinID := maxInt(member.AvailableMinID, maxID)
changed := appliedMinID > member.AvailableMinID
anchorID := member.HistoryClearAnchorID
anchorDate := member.HistoryClearAnchorDate
if changed {
anchorID = appliedMinID
anchorDate = req.Date
var messageDate int
err := tx.QueryRow(ctx, `
SELECT message_date
FROM channel_messages
WHERE channel_id = $1 AND id = $2`, req.ChannelID, appliedMinID).Scan(&messageDate)
switch {
case err == nil && messageDate > 0:
anchorDate = messageDate
case errors.Is(err, pgx.ErrNoRows):
// max_id may name an already-pruned/hole id. The owner-local
// marker is still a valid monotonic boundary; retain request
// time so its dialog projection remains loadable.
case err != nil:
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("select channel clear anchor date: %w", err)
}
if anchorDate <= 0 {
anchorDate = channel.Date
}
}
topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date)
if err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if topID == 0 && anchorID > 0 && anchorID == appliedMinID {
topID = anchorID
topDate = anchorDate
}
if _, err := tx.Exec(ctx, `
UPDATE channel_members
SET available_min_id = GREATEST(available_min_id, $3),
history_clear_anchor_id = CASE WHEN available_min_id < $3 THEN $3 ELSE history_clear_anchor_id END,
history_clear_anchor_date = CASE WHEN available_min_id < $3 THEN $4 ELSE history_clear_anchor_date END,
read_inbox_max_id = GREATEST(read_inbox_max_id, $3),
unread_mark = false,
updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil {
WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID, anchorDate); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err)
}
if changed {
member.AvailableMinID = appliedMinID
member.HistoryClearAnchorID = anchorID
member.HistoryClearAnchorDate = anchorDate
if err := upsertUserChannelMemberIndexTx(ctx, tx, channel, member); err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if _, err := tx.Exec(ctx, `
UPDATE user_channel_member_index
SET available_min_id = $3,
history_clear_anchor_id = $3,
history_clear_updated_at = $4,
updated_at = now()
WHERE user_id = $1 AND channel_id = $2`,
req.UserID, req.ChannelID, appliedMinID, req.Date); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear recovery index: %w", err)
}
}
if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
@ -197,7 +309,17 @@ ON CONFLICT (user_id, channel_id) DO UPDATE SET
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err)
}
committed = true
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil
if s.memberCacheActive(s.db) {
s.memberCache.delete(req.ChannelID, req.UserID)
}
if s.dialogCacheActive(s.db) {
s.dialogCache.delete(req.UserID, req.ChannelID)
}
return domain.DeleteChannelHistoryResult{
Channel: channel,
AvailableMinID: appliedMinID,
AvailableMinChanged: changed,
}, nil
}
if !canDeleteAnyChannelMessage(member) {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired

View file

@ -79,6 +79,31 @@ AND EXISTS (
baseArgs = append(baseArgs, filter.MinID)
base += fmt.Sprintf(" AND id > $%d", len(baseArgs))
}
historyClearAnchor, hasHistoryClearAnchor, err := s.channelHistoryClearAnchor(ctx, channel, member, filter)
if err != nil {
return domain.ChannelHistory{}, err
}
out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels}
needExactTotal := filter.NeedTotalCount || filter.CountOnly
exactTotal := 0
if needExactTotal {
// Exact totals are opt-in. messages.search first/count-only pages and
// messages.getSearchCounters need protocol-exact Count, while ordinary
// getHistory pages must stay on the single bounded page query.
if err := s.db.QueryRow(ctx,
"SELECT count(*)::int FROM channel_messages WHERE "+base,
baseArgs...,
).Scan(&exactTotal); err != nil {
return domain.ChannelHistory{}, fmt.Errorf("count channel history: %w", err)
}
if hasHistoryClearAnchor {
exactTotal++
}
out.Count = exactTotal
}
if filter.CountOnly {
return out, nil
}
scanList := func(sql string, queryArgs []any) ([]domain.ChannelMessage, error) {
rows, err := s.db.Query(ctx, sql, queryArgs...)
if err != nil {
@ -102,7 +127,6 @@ AND EXISTS (
// store 层二次钳制 add_offset 到 [-100,100](与私聊 ListMessagesByUser 对齐):
// 即便某个 caller 漏在 RPC 层钳制,也不会把客户端巨大值变成大 SQL OFFSET 跳扫。
addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset)
out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels}
hasMoreOlder := false
// 锚点条件offset_date 优先按日期、否则按消息 id对齐私聊
// 二者皆空时向更新方向退化为空、向更旧方向退化为全部(取最新)。
@ -117,6 +141,18 @@ AND EXISTS (
}
return "false"
}
anchorMatchesForward := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date >= filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID > filter.OffsetID
}
return false
}
aroundOlderCond := func(args *[]any) string {
if filter.OffsetDate > 0 {
*args = append(*args, filter.OffsetDate)
@ -128,6 +164,30 @@ AND EXISTS (
}
return "true"
}
anchorMatchesAroundOlder := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date < filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID <= filter.OffsetID
}
return true
}
anchorMatchesBackward := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date < filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID < filter.OffsetID
}
return true
}
switch {
case addOffset < 0 && addOffset+limit > 0:
// around以锚点为中心向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
@ -140,6 +200,12 @@ AND EXISTS (
if err != nil {
return domain.ChannelHistory{}, err
}
if anchorMatchesForward() {
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
if len(newer) > fwdLimit {
newer = newer[:fwdLimit]
}
}
bwdArgs := append([]any{}, baseArgs...)
bwdWhere := aroundOlderCond(&bwdArgs)
bwdArgs = append(bwdArgs, bwdLimit+1)
@ -147,6 +213,9 @@ AND EXISTS (
if err != nil {
return domain.ChannelHistory{}, err
}
if anchorMatchesAroundOlder() {
older = append(older, historyClearAnchor)
}
if len(older) > bwdLimit {
older = older[:bwdLimit]
hasMoreOlder = true
@ -164,6 +233,9 @@ AND EXISTS (
if err != nil {
return domain.ChannelHistory{}, err
}
if anchorMatchesForward() {
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
}
if len(newer) > limit {
newer = newer[:limit]
}
@ -181,27 +253,37 @@ AND EXISTS (
args = append(args, filter.OffsetID)
where += fmt.Sprintf(" AND id < $%d", len(args))
}
args = append(args, limit+1)
// Fetch the bounded add_offset window and slice it in memory. This keeps
// the shared-history branch on its ordered (channel_id,id) seek index;
// the owner-local anchor is one separate PK lookup and never adds an OR
// that would force BitmapOr + Sort for large channels.
args = append(args, addOffset+limit+1)
limIdx := len(args)
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC"
if addOffset > 0 {
args = append(args, addOffset)
sql += fmt.Sprintf(" OFFSET $%d", len(args))
}
sql += fmt.Sprintf(" LIMIT $%d", limIdx)
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where +
fmt.Sprintf(" ORDER BY id DESC LIMIT $%d", limIdx)
older, err := scanList(sql, args)
if err != nil {
return domain.ChannelHistory{}, err
}
if anchorMatchesBackward() {
older = append(older, historyClearAnchor)
}
if addOffset >= len(older) {
older = nil
} else if addOffset > 0 {
older = older[addOffset:]
}
if len(older) > limit {
older = older[:limit]
hasMoreOlder = true
}
out.Messages = older
}
out.Count = len(out.Messages)
if hasMoreOlder {
out.Count = len(out.Messages) + 1
if !needExactTotal {
out.Count = len(out.Messages)
if hasMoreOlder {
out.Count = len(out.Messages) + 1
}
}
if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil {
return domain.ChannelHistory{}, err
@ -209,9 +291,63 @@ AND EXISTS (
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
return domain.ChannelHistory{}, err
}
if hasHistoryClearAnchor {
for i := range out.Messages {
if out.Messages[i].ID == historyClearAnchor.ID {
out.Messages[i] = domain.ProjectChannelHistoryClearMessage(
out.Messages[i],
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
}
}
}
return out, nil
}
func (s *ChannelStore) channelHistoryClearAnchor(
ctx context.Context,
channel domain.Channel,
member domain.ChannelMember,
filter domain.ChannelHistoryFilter,
) (domain.ChannelMessage, bool, error) {
if !filter.IncludeHistoryClearAnchor ||
member.HistoryClearAnchorID <= 0 ||
member.HistoryClearAnchorID != member.AvailableMinID ||
filter.PinnedOnly ||
filter.MusicOnly ||
filter.Query != "" {
return domain.ChannelMessage{}, false, nil
}
source, err := s.getChannelMessage(ctx, s.db, channel.ID, member.HistoryClearAnchorID)
if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) {
return domain.ChannelMessage{}, false, fmt.Errorf("load channel history-clear anchor: %w", err)
}
anchor := domain.ProjectChannelHistoryClearMessage(
source,
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
if filter.SenderUserID != 0 && anchor.SenderUserID != filter.SenderUserID {
return domain.ChannelMessage{}, false, nil
}
if filter.MinDate > 0 && anchor.Date <= filter.MinDate {
return domain.ChannelMessage{}, false, nil
}
if filter.MaxDate > 0 && anchor.Date >= filter.MaxDate {
return domain.ChannelMessage{}, false, nil
}
if filter.MaxID > 0 && anchor.ID > filter.MaxID {
return domain.ChannelMessage{}, false, nil
}
if filter.MinID > 0 && anchor.ID <= filter.MinID {
return domain.ChannelMessage{}, false, nil
}
return anchor, true, nil
}
func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) {
query := strings.TrimSpace(req.Query)
if viewerUserID == 0 || (query == "" && !req.MusicOnly) {
@ -371,8 +507,19 @@ func (s *ChannelStore) getChannelMessagesForMember(ctx context.Context, viewerUs
// 执行不变。注意:这种"OR 哨兵"只对【非排序锚点】的残余过滤安全;ListChannelHistory
// 的方向/anchor 条件若同样哨兵化会让规划器无法用索引顺序做 LIMIT、退化为全表扫+排序
// (实测 0.06ms→23ms),故那里【刻意保留】动态 SQL。
args := []any{channel.ID, id32, member.AvailableMinID}
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted AND ($3 <= 0 OR id > $3)"
anchorID := 0
if member.HistoryClearAnchorID > 0 && member.HistoryClearAnchorID == member.AvailableMinID {
anchorID = member.HistoryClearAnchorID
}
args := []any{channel.ID, id32, member.AvailableMinID, anchorID}
where := `channel_id = $1
AND id = ANY($2::int[])
AND (NOT deleted OR ($4 > 0 AND id = $4))
AND (($3 <= 0 OR id > $3) OR ($4 > 0 AND id = $4))`
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, string(domain.PeerTypeUser), viewerUserID)
where += fmt.Sprintf("\nAND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
}
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
@ -400,6 +547,36 @@ ORDER BY id DESC`, args...)
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
return domain.ChannelHistory{}, err
}
if anchorID > 0 {
anchorFound := false
for i := range out.Messages {
if out.Messages[i].ID != anchorID {
continue
}
out.Messages[i] = domain.ProjectChannelHistoryClearMessage(
out.Messages[i],
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
anchorFound = true
}
if !anchorFound {
for _, id := range ids {
if id != anchorID {
continue
}
out.Messages = append(out.Messages, domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
out.Count = len(out.Messages)
break
}
}
}
return out, nil
}
@ -737,7 +914,7 @@ WHERE (
}
func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, _, readOnly, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ReadChannelHistoryResult{}, err
}
@ -745,6 +922,15 @@ func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.Re
if maxID <= 0 || maxID > channel.TopMessageID {
maxID = channel.TopMessageID
}
if readOnly {
return domain.ReadChannelHistoryResult{
ChannelID: req.ChannelID,
MaxID: maxID,
ReadOnly: true,
Pts: channel.Pts,
Forum: channel.Forum,
}, nil
}
previous, unreadMark, err := s.channelReadHistoryState(ctx, req.ChannelID, req.UserID)
if err != nil {
return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err)

View file

@ -189,6 +189,17 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
t.Fatalf("read participants = %+v, want friend read date", readers.Participants)
}
var channelPtsBeforeClear, channelEventsBeforeClear int
if err := pool.QueryRow(ctx, `
SELECT c.pts, (
SELECT count(*)::int
FROM channel_update_events e
WHERE e.channel_id = c.id
)
FROM channels c
WHERE c.id = $1`, channelID).Scan(&channelPtsBeforeClear, &channelEventsBeforeClear); err != nil {
t.Fatalf("query channel state before local clear: %v", err)
}
cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: friend.ID,
ChannelID: channelID,
@ -201,6 +212,70 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if cleared.AvailableMinID != sent.Message.ID {
t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID)
}
if !cleared.AvailableMinChanged {
t.Fatal("local clear did not report an advanced owner-local boundary")
}
var channelPtsAfterClear, channelEventsAfterClear int
if err := pool.QueryRow(ctx, `
SELECT c.pts, (
SELECT count(*)::int
FROM channel_update_events e
WHERE e.channel_id = c.id
)
FROM channels c
WHERE c.id = $1`, channelID).Scan(&channelPtsAfterClear, &channelEventsAfterClear); err != nil {
t.Fatalf("query channel state after local clear: %v", err)
}
if channelPtsAfterClear != channelPtsBeforeClear || channelEventsAfterClear != channelEventsBeforeClear {
t.Fatalf("local clear changed channel sequence: pts %d->%d events %d->%d",
channelPtsBeforeClear, channelPtsAfterClear, channelEventsBeforeClear, channelEventsAfterClear)
}
var recoveryMinID, recoveryAnchorID, recoveryDate int
if err := pool.QueryRow(ctx, `
SELECT available_min_id, history_clear_anchor_id, history_clear_updated_at
FROM user_channel_member_index
WHERE user_id = $1 AND channel_id = $2`, friend.ID, channelID).Scan(
&recoveryMinID, &recoveryAnchorID, &recoveryDate,
); err != nil {
t.Fatalf("query owner-local clear recovery index: %v", err)
}
if recoveryMinID != sent.Message.ID || recoveryAnchorID != sent.Message.ID || recoveryDate != 1700000302 {
t.Fatalf("recovery index = min %d anchor %d date %d, want %d/%d/1700000302",
recoveryMinID, recoveryAnchorID, recoveryDate, sent.Message.ID, sent.Message.ID)
}
dirtyAfterClear, err := channels.ListDirtyActiveChannelsForUser(ctx, friend.ID, 1700000302, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after local clear: %v", err)
}
if len(dirtyAfterClear) != 1 ||
dirtyAfterClear[0].ChannelID != channelID ||
dirtyAfterClear[0].AvailableMinID != sent.Message.ID ||
dirtyAfterClear[0].HistoryClearDate != 1700000302 {
t.Fatalf("dirty channel recovery = %+v, want channel %d boundary %d", dirtyAfterClear, channelID, sent.Message.ID)
}
planTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin history-clear recovery plan transaction: %v", err)
}
defer func() { _ = planTx.Rollback(ctx) }()
if _, err := planTx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
t.Fatalf("disable seqscan for history-clear recovery plan: %v", err)
}
clearPlan := explainText(t, ctx, planTx, `
SELECT i.channel_id, c.pts, i.available_min_id, i.history_clear_updated_at
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
AND i.history_clear_anchor_id > 0
AND i.history_clear_anchor_id = i.available_min_id
AND i.history_clear_updated_at >= $2
ORDER BY i.channel_id ASC
LIMIT $4`, friend.ID, 1700000302, int64(0), 10)
requirePlanContains(t, clearPlan, "user_channel_member_index_history_clear_idx")
_ = planTx.Rollback(ctx)
staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: friend.ID,
ChannelID: channelID,
@ -213,12 +288,60 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if staleClear.AvailableMinID != sent.Message.ID {
t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID)
}
if staleClear.AvailableMinChanged {
t.Fatal("stale local clear unexpectedly replaced the owner-local anchor")
}
if err := pool.QueryRow(ctx, `
SELECT history_clear_updated_at
FROM user_channel_member_index
WHERE user_id = $1 AND channel_id = $2`, friend.ID, channelID).Scan(&recoveryDate); err != nil {
t.Fatalf("query recovery timestamp after stale clear: %v", err)
}
if recoveryDate != 1700000302 {
t.Fatalf("stale clear recovery date = %d, want unchanged 1700000302", recoveryDate)
}
afterClear, err := channels.GetChannel(ctx, friend.ID, channelID)
if err != nil {
t.Fatalf("get channel after clear: %v", err)
}
if afterClear.Dialog.TopMessageID != 0 {
t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog)
if afterClear.Dialog.TopMessageID != sent.Message.ID ||
afterClear.Dialog.HistoryClearAnchorID != sent.Message.ID ||
afterClear.Dialog.UnreadCount != 0 {
t.Fatalf("dialog after clear = %+v, want owner-local anchored top %d", afterClear.Dialog, sent.Message.ID)
}
dialogsAfterClear, err := channels.GetChannelDialogs(ctx, friend.ID, []int64{channelID})
if err != nil {
t.Fatalf("get channel dialogs after clear: %v", err)
}
if len(dialogsAfterClear.Dialogs) != 1 ||
len(dialogsAfterClear.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogsAfterClear.Messages[0]) ||
dialogsAfterClear.Messages[0].ID != sent.Message.ID ||
dialogsAfterClear.Messages[0].Body != "" {
t.Fatalf("dialog projection after clear = dialogs=%+v messages=%+v", dialogsAfterClear.Dialogs, dialogsAfterClear.Messages)
}
projectedHistory, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("project history-clear anchor: %v", err)
}
if len(projectedHistory.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(projectedHistory.Messages[0]) ||
projectedHistory.Messages[0].ID != sent.Message.ID {
t.Fatalf("projected history after clear = %+v, want anchor %d", projectedHistory.Messages, sent.Message.ID)
}
ownerDialogsAfterClear, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
if err != nil {
t.Fatalf("get unaffected owner dialog after friend clear: %v", err)
}
if len(ownerDialogsAfterClear.Messages) != 1 ||
ownerDialogsAfterClear.Messages[0].ID != sent.Message.ID ||
ownerDialogsAfterClear.Messages[0].Body != "first visible channel text" ||
ownerDialogsAfterClear.Messages[0].Action != nil {
t.Fatalf("friend clear changed shared owner projection: %+v", ownerDialogsAfterClear.Messages)
}
next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
@ -238,6 +361,59 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 {
t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID)
}
firstPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
Limit: 1,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list first page after next message: %v", err)
}
if len(firstPage.Messages) != 1 ||
firstPage.Messages[0].ID != next.Message.ID ||
firstPage.Count != 2 {
t.Fatalf("first page after next = %+v count=%d, want next message and bounded has-more count", firstPage.Messages, firstPage.Count)
}
offsetPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
AddOffset: 1,
Limit: 1,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list add-offset page after next message: %v", err)
}
if len(offsetPage.Messages) != 1 ||
offsetPage.Messages[0].ID != sent.Message.ID ||
!domain.IsChannelHistoryClearMessage(offsetPage.Messages[0]) {
t.Fatalf("add-offset page after next = %+v, want retained clear anchor %d", offsetPage.Messages, sent.Message.ID)
}
exactCount, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
CountOnly: true,
NeedTotalCount: true,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("count history after next message: %v", err)
}
if exactCount.Count != 2 {
t.Fatalf("exact history count after next = %d, want shared message plus clear anchor", exactCount.Count)
}
olderPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
OffsetID: next.Message.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list older page after next message: %v", err)
}
if len(olderPage.Messages) != 1 ||
olderPage.Messages[0].ID != sent.Message.ID ||
!domain.IsChannelHistoryClearMessage(olderPage.Messages[0]) {
t.Fatalf("older page after next = %+v, want retained clear anchor %d", olderPage.Messages, sent.Message.ID)
}
}
func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollbackPostgres(t *testing.T) {

View file

@ -12,6 +12,18 @@ import (
)
func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
return s.sendChannelMessageWithHooks(ctx, req, channelSendTxHooks{})
}
type channelSendTxHooks struct {
before func(context.Context, pgx.Tx, *domain.SendChannelMessageRequest) error
after func(context.Context, pgx.Tx, domain.SendChannelMessageResult) error
}
// sendChannelMessageWithHooks lets a tightly coupled domain command join the
// channel message/event/PTS transaction. It is deliberately package-private:
// ordinary callers must use SendChannelMessage and may not inject SQL work.
func (s *ChannelStore) sendChannelMessageWithHooks(ctx context.Context, req domain.SendChannelMessageRequest, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
@ -27,7 +39,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
}
var lastErr error
for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ {
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint)
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint, hooks)
if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil {
return res, err
}
@ -36,7 +48,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
return domain.SendChannelMessageResult{}, lastErr
}
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte) (domain.SendChannelMessageResult, error) {
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) {
if req.RandomID != 0 && !req.IdempotencyPreflighted {
if dup, found, err := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.ChannelID,
@ -125,6 +137,11 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
p := *req.SendAs
sendAs = &p
}
if hooks.before != nil {
if err := hooks.before(ctx, tx, &req); err != nil {
return domain.SendChannelMessageResult{}, err
}
}
msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err)
@ -135,7 +152,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
}
var discussion *domain.SendChannelDiscussionResult
var discussionRef *domain.ChannelDiscussionRef
if channel.Broadcast && channel.LinkedChatID != 0 {
if channel.Broadcast && channel.LinkedChatID != 0 && req.Action == nil {
linked, err := getChannelByID(ctx, tx, channel.LinkedChatID)
if err == nil && !linked.Deleted && linked.Megagroup {
discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID)
@ -330,6 +347,16 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User
return domain.SendChannelMessageResult{}, err
}
}
txResult := domain.SendChannelMessageResult{
Channel: channel, Message: msg, Event: event, Discussion: discussion,
MentionUserIDs: append([]int64(nil), req.MentionUserIDs...),
SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...),
}
if hooks.after != nil {
if err := hooks.after(ctx, tx, txResult); err != nil {
return domain.SendChannelMessageResult{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err)
}
@ -342,7 +369,8 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User
discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0)
}
}
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion, MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...)}, nil
txResult.Recipients = recipients
return txResult, nil
}
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {

View file

@ -24,7 +24,7 @@ func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.Ch
if req.UserID == 0 || req.ChannelID == 0 {
return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageViewsResult{}, err
}
@ -43,6 +43,16 @@ func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.Ch
if date <= 0 {
date = nowUnix()
}
args := []any{req.ChannelID, id32, req.UserID, date, member.AvailableMinID}
visibility := ""
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, string(domain.PeerTypeUser), req.UserID)
visibility = fmt.Sprintf(
" AND m.saved_peer_type = $%d AND m.saved_peer_id = $%d",
len(args)-1,
len(args),
)
}
rows, err := s.db.Query(ctx, `
WITH inserted AS (
INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at)
@ -52,6 +62,7 @@ WITH inserted AS (
AND m.id = ANY($2::int[])
AND NOT m.deleted
AND m.id > $5
`+visibility+`
ON CONFLICT DO NOTHING
RETURNING message_id
), updated AS (
@ -65,7 +76,7 @@ WITH inserted AS (
)
SELECT i.message_id
FROM inserted i
LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, date, member.AvailableMinID)
LEFT JOIN updated u ON u.id = i.message_id`, args...)
if err != nil {
return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err)
}
@ -82,7 +93,7 @@ LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, da
}
rows.Close()
}
summaries, err := s.listChannelMessageViewSummaries(ctx, req.ChannelID, id32, member.AvailableMinID)
summaries, err := s.listChannelMessageViewSummaries(ctx, req.UserID, channel, member, id32)
if err != nil {
return domain.ChannelMessageViewsResult{}, err
}
@ -110,13 +121,17 @@ LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, da
}, nil
}
func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, channelID int64, ids []int32, availableMinID int) ([]channelMessageViewSummary, error) {
args := []any{channelID, ids}
func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, viewerUserID int64, channel domain.Channel, member domain.ChannelMember, ids []int32) ([]channelMessageViewSummary, error) {
args := []any{channel.ID, ids}
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted"
if availableMinID > 0 {
args = append(args, availableMinID)
if member.AvailableMinID > 0 {
args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args))
}
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, string(domain.PeerTypeUser), viewerUserID)
where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
}
rows, err := s.db.Query(ctx, `
SELECT id, views_count, post, discussion_channel_id, discussion_message_id, sender_user_id, from_peer_type, from_peer_id
FROM channel_messages

View file

@ -189,6 +189,7 @@ SELECT EXISTS (
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media,
ReplyTo: req.ReplyTo,
Forward: req.Forward,
Pts: pts,
}
event := domain.ChannelUpdateEvent{

View file

@ -71,6 +71,28 @@ func TestChannelStoreEnablingDirectMessagesCreatesMonoforum(t *testing.T) {
if mfTop == 0 || mfPts == 0 {
t.Fatalf("monoforum top/pts = %d/%d, want paid-messages service top", mfTop, mfPts)
}
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: owner.ID, ChannelID: monoID, MaxID: mfTop, Date: 1700000901,
})
if err != nil {
t.Fatalf("read synthetic monoforum history: %v", err)
}
if !read.ReadOnly || read.Changed || read.MaxID != mfTop {
t.Fatalf("synthetic monoforum read = %+v, want read-only no-op at %d", read, mfTop)
}
var memberExists, dialogExists bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM channel_members WHERE channel_id=$1 AND user_id=$2
),
EXISTS (
SELECT 1 FROM channel_dialogs WHERE channel_id=$1 AND user_id=$2
)`, monoID, owner.ID).Scan(&memberExists, &dialogExists); err != nil {
t.Fatalf("check synthetic monoforum read footprint: %v", err)
}
if memberExists || dialogExists {
t.Fatalf("synthetic monoforum read persisted member/dialog = %v/%v", memberExists, dialogExists)
}
// 同批下发母广播频道(TDesktop 据此 resolve linked_monoforum_id 并派生 MonoforumAdmin
// 渲染 Direct-Messages 容器):GetChannelDialogs([mono]) 的 chats[] 必须同时带 mono 与母频道。
coDelivery, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{monoID})

View file

@ -79,9 +79,10 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
}
suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000}
forward := &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, Date: 1700000999}
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001,
SuggestedPost: suggestedPost,
SuggestedPost: suggestedPost, Forward: forward,
})
if err != nil {
t.Fatalf("subscriber send 1: %v", err)
@ -128,7 +129,7 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
}
// 幂等。
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004})
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Forward: forward, Date: 1700001004})
if err != nil {
t.Fatalf("dup send: %v", err)
}
@ -166,6 +167,9 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 {
t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost)
}
if oldest.Forward == nil || oldest.Forward.From.ID != owner.ID || oldest.Forward.Date != 1700000999 {
t.Fatalf("persisted monoforum forward = %+v, want source user %d/date 1700000999", oldest.Forward, owner.ID)
}
if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID {
t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID)
}
@ -175,7 +179,8 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
// 另一个订阅者不串会话。
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005}); err != nil {
otherMessage, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005})
if err != nil {
t.Fatalf("other subscriber send: %v", err)
}
subHist, _ := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: subPeer, Limit: 10})
@ -191,6 +196,126 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
t.Fatalf("subscriber channel history leaked message %+v", message)
}
}
exactMessages, err := channels.GetChannelMessages(ctx, sub.ID, monoID, []int{m1.Message.ID, otherMessage.Message.ID})
if err != nil {
t.Fatalf("subscriber exact monoforum messages: %v", err)
}
if len(exactMessages.Messages) != 1 || exactMessages.Messages[0].ID != m1.Message.ID {
t.Fatalf("subscriber exact monoforum messages = %+v, want only own message %d", exactMessages.Messages, m1.Message.ID)
}
monoBeforeViews, err := channels.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum before views: %v", err)
}
subViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
Increment: true, Date: 1700001006,
})
if err != nil {
t.Fatalf("subscriber get monoforum message views: %v", err)
}
if len(subViews.Views) != 1 || subViews.Views[m1.Message.ID] != 1 {
t.Fatalf("subscriber monoforum views = %+v, want own message %d at 1", subViews.Views, m1.Message.ID)
}
if _, ok := subViews.Views[otherMessage.Message.ID]; ok {
t.Fatalf("subscriber monoforum views leaked other saved_peer message %d", otherMessage.Message.ID)
}
var hiddenViews int
var hiddenViewer bool
if err := pool.QueryRow(ctx, `
SELECT m.views_count,
EXISTS (
SELECT 1
FROM channel_message_viewers v
WHERE v.channel_id = m.channel_id
AND v.message_id = m.id
AND v.viewer_user_id = $3
)
FROM channel_messages m
WHERE m.channel_id = $1 AND m.id = $2`, monoID, otherMessage.Message.ID, sub.ID).Scan(&hiddenViews, &hiddenViewer); err != nil {
t.Fatalf("load hidden monoforum view state: %v", err)
}
if hiddenViews != 0 || hiddenViewer {
t.Fatalf("hidden monoforum view state = count %d viewer %v, want 0/false", hiddenViews, hiddenViewer)
}
repeatedViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID},
Increment: true, Date: 1700001007,
})
if err != nil || repeatedViews.Views[m1.Message.ID] != 1 {
t.Fatalf("repeated subscriber monoforum views = %+v, %v; want idempotent 1", repeatedViews.Views, err)
}
adminViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: owner.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
Increment: true, Date: 1700001008,
})
if err != nil {
t.Fatalf("admin get monoforum message views: %v", err)
}
if len(adminViews.Views) != 2 || adminViews.Views[m1.Message.ID] != 2 || adminViews.Views[otherMessage.Message.ID] != 1 {
t.Fatalf("admin monoforum views = %+v, want both saved peers at 2/1", adminViews.Views)
}
monoAfterViews, err := channels.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum after views: %v", err)
}
if monoAfterViews.Pts != monoBeforeViews.Pts {
t.Fatalf("message views advanced monoforum pts = %d, want unchanged %d", monoAfterViews.Pts, monoBeforeViews.Pts)
}
if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}},
Date: 1700001006,
}); err != nil {
t.Fatalf("subscriber react to own monoforum message: %v", err)
}
if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f525"}},
Date: 1700001006,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber react to another saved_peer err = %v, want ErrMessageIDInvalid", err)
}
subReactions, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
})
if err != nil {
t.Fatalf("subscriber get monoforum reactions: %v", err)
}
if len(subReactions.Messages) != 1 || subReactions.Messages[0].ID != m1.Message.ID {
t.Fatalf("subscriber monoforum reactions = %+v, want only own message %d", subReactions.Messages, m1.Message.ID)
}
adminReactions, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: owner.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
})
if err != nil {
t.Fatalf("admin get monoforum reactions: %v", err)
}
if len(adminReactions.Messages) != 2 {
t.Fatalf("admin monoforum reactions = %+v, want both subscriber messages", adminReactions.Messages)
}
reactionList, err := channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
UserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, Limit: 10,
})
if err != nil || reactionList.Count != 1 || len(reactionList.Reactions) != 1 {
t.Fatalf("subscriber monoforum reaction list = %+v, %v; want one", reactionList, err)
}
if _, err := channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
UserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID, Limit: 10,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber list another saved_peer reactions err = %v, want ErrMessageIDInvalid", err)
}
reactionLookup, found, err := channels.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, ReactorUserID: sub.ID,
})
if err != nil || !found || len(reactionLookup.Reactions) != 1 {
t.Fatalf("subscriber monoforum reaction lookup = %+v, %v, %v; want one", reactionLookup, found, err)
}
if _, _, err := channels.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID, ReactorUserID: other.ID,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber lookup another saved_peer reaction err = %v, want ErrMessageIDInvalid", err)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100})
if err != nil {
t.Fatalf("subscriber channel difference: %v", err)

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"errors"
"strings"
"testing"
"telesrv/internal/domain"
@ -98,6 +99,70 @@ func TestChannelMultiPin(t *testing.T) {
t.Fatalf("filterPinned message %d lacks pinned flag", msg.ID)
}
}
// 普通历史热路径保留 bounded has-more hint不为每页额外 COUNT。
hint, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
ChannelID: channelID, PinnedOnly: true, Limit: 1,
})
if err != nil {
t.Fatalf("filterPinned hint page: %v", err)
}
if len(hint.Messages) != 1 || hint.Count != 2 {
t.Fatalf("filterPinned hint messages/count = %d/%d, want 1/2", len(hint.Messages), hint.Count)
}
// messages.search 首屏显式请求精确总数;页大小不能污染 Count。
exact, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
ChannelID: channelID, PinnedOnly: true, Limit: 1, NeedTotalCount: true,
})
if err != nil {
t.Fatalf("filterPinned exact page: %v", err)
}
if len(exact.Messages) != 1 || exact.Count != 3 {
t.Fatalf("filterPinned exact messages/count = %d/%d, want 1/3", len(exact.Messages), exact.Count)
}
// messages.getSearchCounters/limit=0 只计数,不加载消息及 reply/reaction companion。
countOnly, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
ChannelID: channelID, PinnedOnly: true, NeedTotalCount: true, CountOnly: true,
})
if err != nil {
t.Fatalf("filterPinned count-only: %v", err)
}
if len(countOnly.Messages) != 0 || countOnly.Count != 3 {
t.Fatalf("filterPinned count-only messages/count = %d/%d, want 0/3", len(countOnly.Messages), countOnly.Count)
}
// 精确计数只扫描该频道的 pinned 部分索引,不能退化为全频道消息扫描。
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin pinned count explain: %v", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
t.Fatalf("disable seqscan for pinned count explain: %v", err)
}
rows, err := tx.Query(ctx, `EXPLAIN (COSTS OFF)
SELECT count(*)::int
FROM channel_messages
WHERE channel_id = $1 AND NOT deleted AND pinned`, channelID)
if err != nil {
t.Fatalf("explain pinned count: %v", err)
}
var plan strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
rows.Close()
t.Fatalf("scan pinned count plan: %v", err)
}
plan.WriteString(line)
plan.WriteByte('\n')
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatalf("read pinned count plan: %v", err)
}
rows.Close()
if !strings.Contains(plan.String(), "channel_messages_live_pinned_idx") {
t.Fatalf("pinned count plan misses partial index:\n%s", plan.String())
}
// 普通历史页的消息行直接携带 pinned 标志(多置顶都标,不只最新)。
page, err := channels.ListChannelHistory(ctx, member.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 50})
if err != nil {

View file

@ -75,20 +75,61 @@ func TestPublicChannelAndMegagroupPreviewPostgres(t *testing.T) {
if !found || history.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("preview history = %+v self=%+v", history.Messages, history.Self)
}
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: viewer.ID, ChannelID: public.ID, MaxID: sent.Message.ID, Date: 1700009411 + i,
})
if err != nil {
t.Fatalf("read public preview history: %v", err)
}
if !read.ReadOnly || read.Changed || read.MaxID != sent.Message.ID {
t.Fatalf("public preview read = %+v, want read-only no-op at %d", read, sent.Message.ID)
}
audience, err := channels.FilterChannelMessageAudienceIDs(ctx, public.ID, []int64{viewer.ID, owner.ID, viewer.ID})
if err != nil {
t.Fatalf("filter public message audience: %v", err)
}
if len(audience) != 2 || audience[0] != owner.ID || audience[1] != viewer.ID {
t.Fatalf("public message audience = %v, want owner/member and viewer/subscriber", audience)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: viewer.ID, ChannelID: public.ID, Pts: created.Event.Pts, Limit: 20,
})
if err != nil {
t.Fatalf("public preview difference: %v", err)
}
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].ID != sent.Message.ID {
t.Fatalf("public preview difference = %+v, want sent message through pts %d", diff, sent.Event.Pts)
}
if _, err := channels.GetParticipants(ctx, viewer.ID, public.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 20); err != nil {
t.Fatalf("public preview participants: %v", err)
}
if _, err := channels.GetParticipant(ctx, viewer.ID, public.ID, viewer.ID); !errors.Is(err, domain.ErrUserNotParticipant) {
t.Fatalf("public preview self participant err = %v, want ErrUserNotParticipant", err)
}
var memberExists bool
dialogs := appdialogs.NewService(nil, channels)
peerDialogs, err := dialogs.GetPeerDialogs(ctx, viewer.ID, []domain.Peer{{Type: domain.PeerTypeChannel, ID: public.ID}})
if err != nil {
t.Fatalf("public preview peer dialogs: %v", err)
}
if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.ChannelMessages) != 0 || len(peerDialogs.Channels) != 1 {
t.Fatalf("public preview peer dialogs = %+v, want one zero-top bootstrap", peerDialogs)
}
previewDialog := peerDialogs.Dialogs[0]
if previewDialog.TopMessage != 0 || !previewDialog.ChannelLeft ||
previewDialog.ReadInboxMaxID != 0 || previewDialog.ReadOutboxMaxID != 0 ||
previewDialog.Pts != sent.Event.Pts {
t.Fatalf("public preview bootstrap dialog = %+v", previewDialog)
}
var memberExists, dialogExists bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
)`, public.ID, viewer.ID).Scan(&memberExists); err != nil {
), EXISTS (
SELECT 1 FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2
)`, public.ID, viewer.ID).Scan(&memberExists, &dialogExists); err != nil {
t.Fatalf("check preview member row: %v", err)
}
if memberExists {
t.Fatal("public preview persisted a channel member row")
if memberExists || dialogExists {
t.Fatalf("public preview persisted member/dialog = %v/%v", memberExists, dialogExists)
}
if _, err := channels.JoinChannel(ctx, public.ID, viewer.ID, 1700009420+i); err != nil {
t.Fatalf("join public peer: %v", err)
@ -96,6 +137,28 @@ SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
if _, err := channels.LeaveChannel(ctx, public.ID, viewer.ID, 1700009430+i); err != nil {
t.Fatalf("leave public peer: %v", err)
}
filtered, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: viewer.ID, ChannelID: public.ID, Pts: sent.Event.Pts, Limit: 20,
})
if err != nil {
t.Fatalf("public difference across participant events: %v", err)
}
if tc.broadcast {
if !filtered.Final || filtered.Pts != sent.Event.Pts || len(filtered.Events) != 0 ||
len(filtered.NewMessages) != 0 || len(filtered.OtherUpdates) != 0 {
t.Fatalf("broadcast difference after transient participant changes = %+v, want unchanged PTS", filtered)
}
} else {
if !filtered.Final || filtered.Pts <= sent.Event.Pts || len(filtered.NewMessages) != 2 ||
len(filtered.OtherUpdates) != 0 {
t.Fatalf("megagroup join/leave difference = %+v, want two real service messages", filtered)
}
for _, message := range filtered.NewMessages {
if message.Action == nil {
t.Fatalf("megagroup join/leave difference message = %+v, want service action", message)
}
}
}
if _, err := channels.GetParticipant(ctx, viewer.ID, public.ID, viewer.ID); !errors.Is(err, domain.ErrUserNotParticipant) {
t.Fatalf("left self participant err = %v, want ErrUserNotParticipant", err)
}
@ -112,6 +175,9 @@ SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
t.Fatalf("create private group: %v", err)
}
channelIDs = append(channelIDs, private.Channel.ID)
if audience, err := channels.FilterChannelMessageAudienceIDs(ctx, private.Channel.ID, []int64{viewer.ID}); err != nil || len(audience) != 0 {
t.Fatalf("private message audience = %v err %v, want empty", audience, err)
}
if _, err := channels.ListChannelHistory(ctx, viewer.ID, domain.ChannelHistoryFilter{ChannelID: private.Channel.ID, Limit: 20}); !errors.Is(err, domain.ErrChannelPrivate) {
t.Fatalf("private preview history err = %v, want ErrChannelPrivate", err)
}

View file

@ -121,70 +121,3 @@ func (s *ChannelStore) ClearRecentMessageReactions(ctx context.Context, userID i
}
return nil
}
func (s *ChannelStore) ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) {
if userID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 {
return []domain.SavedReactionTag{}, nil
}
if limit > domain.MaxSavedReactionTags {
limit = domain.MaxSavedReactionTags
}
rows, err := s.db.Query(ctx, `
SELECT reaction_type, reaction_value, title, reaction_count
FROM user_saved_reaction_tags
WHERE user_id = $1
ORDER BY reaction_count DESC, updated_at DESC, reaction_type ASC, reaction_value ASC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list saved reaction tags: %w", err)
}
defer rows.Close()
out := make([]domain.SavedReactionTag, 0, limit)
for rows.Next() {
var reactionType, reactionValue, title string
var count int
if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil {
return nil, err
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
continue
}
out = append(out, domain.SavedReactionTag{
UserID: userID,
Reaction: reaction,
Title: title,
Count: count,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *ChannelStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji {
return domain.ErrChannelInvalid
}
reactionValue := strings.TrimSpace(tag.Reaction.Emoticon)
if reactionValue == "" {
return domain.ErrChannelInvalid
}
if tag.Count < 0 {
tag.Count = 0
}
if _, err := s.db.Exec(ctx, `
INSERT INTO user_saved_reaction_tags (user_id, reaction_type, reaction_value, title, reaction_count)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET
title = EXCLUDED.title,
reaction_count = GREATEST(user_saved_reaction_tags.reaction_count, EXCLUDED.reaction_count),
updated_at = now()`, tag.UserID, string(tag.Reaction.Type), reactionValue, tag.Title, tag.Count); err != nil {
return fmt.Errorf("upsert saved reaction tag: %w", err)
}
return nil
}

View file

@ -37,7 +37,7 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai
_ = tx.Rollback(ctx)
}
}()
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
channel, member, _, err := s.getChannelForViewer(ctx, tx, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
@ -57,7 +57,8 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
// 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。
@ -474,7 +475,7 @@ func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domai
if len(req.IDs) > domain.MaxGetMessageIDs {
return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
@ -491,6 +492,10 @@ func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domai
args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args))
}
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, string(domain.PeerTypeUser), req.UserID)
where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
}
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
@ -532,7 +537,7 @@ func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req doma
if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit {
req.Limit = domain.MaxChannelMessageReactionListLimit
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsList{}, err
}
@ -543,7 +548,8 @@ func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req doma
if err != nil {
return domain.ChannelMessageReactionsList{}, err
}
if msg.Deleted || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid
}
baseWhere := []string{"channel_id = $1", "message_id = $2"}
@ -612,3 +618,49 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
NextOffset: next,
}, nil
}
func (s *ChannelStore) FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
message, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
if message.Deleted || message.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewer(channel, member, req.ViewerUserID, message) {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, message_id, reacted_user_id, sender_user_id,
reaction_type, reaction_value, big, unread, chosen_order, reaction_date
FROM channel_message_reactions
WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3
ORDER BY chosen_order, reaction_type, reaction_value
LIMIT $4`,
req.ChannelID, req.MessageID, req.ReactorUserID,
domain.MaxChannelMessageReactionsPerUser)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, fmt.Errorf("find channel message reaction: %w", err)
}
defer rows.Close()
reactions := make([]domain.ChannelMessagePeerReaction, 0, domain.MaxChannelMessageReactionsPerUser)
for rows.Next() {
reaction, err := scanChannelMessagePeerReaction(rows, req.ViewerUserID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
reactions = append(reactions, reaction)
}
if err := rows.Err(); err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
return domain.ChannelMessageReactionLookup{
Channel: channel, Message: message, Reactions: reactions,
}, len(reactions) > 0, nil
}

View file

@ -574,8 +574,10 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop,
if err != nil {
t.Fatalf("get large channel after local clear: %v", err)
}
if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 {
t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog)
if afterClear.Dialog.TopMessageID != sent.Message.ID ||
afterClear.Dialog.HistoryClearAnchorID != sent.Message.ID ||
afterClear.Dialog.UnreadCount != 0 {
t.Fatalf("large dialog after local clear = %+v, want anchored top %d with no unread", afterClear.Dialog, sent.Message.ID)
}
}

View file

@ -6,6 +6,9 @@ import (
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
@ -235,7 +238,7 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
if strings.EqualFold(channel.Username, username) {
return domain.Channel{}, domain.ErrChannelNotModified
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, username, usernameLower); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil {
@ -292,16 +295,52 @@ func (s *ChannelStore) SetChannelScamFake(ctx context.Context, channelID int64,
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.Channel{}, fmt.Errorf("set channel scam/fake: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.Channel{}, fmt.Errorf("begin set channel scam/fake: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
var currentScam, currentFake bool
if err := tx.QueryRow(ctx, `
SELECT scam, fake
FROM channels
WHERE id = $1 AND NOT deleted
FOR UPDATE`, channelID).Scan(&currentScam, &currentFake); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Channel{}, domain.ErrChannelInvalid
}
return domain.Channel{}, fmt.Errorf("lock channel scam/fake: %w", err)
}
channel, err := s.channelByID(ctx, tx, channelID)
if err != nil {
return domain.Channel{}, err
}
if channel.Scam == scam && channel.Fake == fake {
if err := tx.Commit(ctx); err != nil {
return domain.Channel{}, fmt.Errorf("commit unchanged channel scam/fake: %w", err)
}
committed = true
return channel, nil
}
if _, err := s.db.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
if currentScam != channel.Scam || currentFake != channel.Fake {
return domain.Channel{}, fmt.Errorf("channel scam/fake snapshot changed while locked")
}
if _, err := tx.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.Channel{}, fmt.Errorf("commit channel scam/fake: %w", err)
}
committed = true
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
@ -387,7 +426,7 @@ func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID in
if strings.EqualFold(channel.Username, username) {
return channel, nil
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, username, usernameLower); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, channelID, username); err != nil {
@ -471,6 +510,9 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
if !found || owner.peerType != peerUsernameTypeChannel {
return domain.Channel{}, false, nil
}
if !owner.active {
return domain.Channel{}, false, nil
}
ch, err := getChannelByID(ctx, s.db, owner.peerID)
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) {
@ -478,7 +520,14 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
}
return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err)
}
if !publicPreviewableChannel(ch) || !strings.EqualFold(ch.Username, usernameLower) {
if !publicPreviewableChannel(ch, true) {
return domain.Channel{}, false, nil
}
// A collectible row is authoritative for its own name: the channel's scalar
// username column only ever mirrors the editable slot, so re-checking it here
// would make collectible names unresolvable. The scalar comparison stays for
// the editable slot, where it guards against a stale registry row.
if !owner.collectible && !strings.EqualFold(ch.Username, usernameLower) {
return domain.Channel{}, false, nil
}
return ch, true, nil

View file

@ -118,8 +118,12 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
if req.ScheduleDate > 0 {
scheduleDate = req.ScheduleDate
}
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
if !req.Reject {
effectiveDate, scheduleErr := domain.EffectiveSuggestedPostPublishDate(scheduleDate, req.Date)
if scheduleErr != nil {
return domain.ToggleSuggestedPostApprovalResult{}, scheduleErr
}
scheduleDate = effectiveDate
}
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, original.SavedPeer.ID)
if err != nil {
@ -173,11 +177,6 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
}
} else {
effectivePublishDate := scheduleDate
if effectivePublishDate == 0 {
// TDesktop's "Publish Now" request has no schedule_date flag, but
// its approval service renderer always expects an absolute date.
effectivePublishDate = req.Date
}
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected, original.SuggestedPost.ScheduleDate = true, false, effectivePublishDate
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
if err != nil {

View file

@ -133,3 +133,122 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) {
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
}
}
func TestSuggestedPostApprovalAcceptsDelayedSchedulePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 211, Phone: "+1889" + suffix + "01", FirstName: "DelayedOwner"})
if err != nil {
t.Fatal(err)
}
subscriber, err := users.Create(ctx, domain.User{AccessHash: 212, Phone: "+1889" + suffix + "02", FirstName: "DelayedSubscriber"})
if err != nil {
t.Fatal(err)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Delayed Suggested " + suffix, Broadcast: true, Date: 1_700_020_000,
})
if err != nil {
t.Fatal(err)
}
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatal(err)
}
monoID := enabled.Channel.LinkedMonoforumID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM suggested_post_approvals WHERE monoforum_id=$1`, monoID)
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id=ANY($1::bigint[])`, []int64{monoID, created.Channel.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{owner.ID, subscriber.ID})
})
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
const now = 1_700_020_100
near, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved,
RandomID: 81, Message: "postgres near schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now - 10,
})
if err != nil {
t.Fatal(err)
}
nearDate := now + 2*60
accepted, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: owner.ID, MonoforumID: monoID, MessageID: near.Message.ID,
ScheduleDate: nearDate, Date: now,
})
if err != nil || accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil {
t.Fatalf("near schedule approval=%+v err=%v", accepted, err)
}
var monoPts, parentPts, monoEvents, parentEvents int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, monoID).Scan(&monoPts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&parentPts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, monoID).Scan(&monoEvents); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, created.Channel.ID).Scan(&parentEvents); err != nil {
t.Fatal(err)
}
replay, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: owner.ID, MonoforumID: monoID, MessageID: near.Message.ID,
ScheduleDate: nearDate, Date: now + 10*60,
})
if err != nil || !replay.Duplicate {
t.Fatalf("late replay=%+v err=%v", replay, err)
}
var gotMonoPts, gotParentPts, gotMonoEvents, gotParentEvents int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, monoID).Scan(&gotMonoPts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&gotParentPts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, monoID).Scan(&gotMonoEvents); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, created.Channel.ID).Scan(&gotParentEvents); err != nil {
t.Fatal(err)
}
if gotMonoPts != monoPts || gotParentPts != parentPts || gotMonoEvents != monoEvents || gotParentEvents != parentEvents {
t.Fatalf("late replay changed pts/events mono=%d/%d→%d/%d parent=%d/%d→%d/%d",
monoPts, monoEvents, gotMonoPts, gotMonoEvents, parentPts, parentEvents, gotParentPts, gotParentEvents)
}
due, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved,
RandomID: 82, Message: "postgres due schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now + 20,
})
if err != nil {
t.Fatal(err)
}
approvedAt := now + 30
dueResult, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: owner.ID, MonoforumID: monoID, MessageID: due.Message.ID,
ScheduleDate: now - 1, Date: approvedAt,
})
if err != nil || dueResult.State != domain.SuggestedPostStateCompleted || dueResult.Published == nil {
t.Fatalf("due approval=%+v err=%v", dueResult, err)
}
if dueResult.OriginalMessage.SuggestedPost.ScheduleDate != approvedAt ||
dueResult.ServiceMessage.Action == nil ||
dueResult.ServiceMessage.Action.SuggestedPostScheduleDate != approvedAt {
t.Fatalf("due effective dates original/action=%d/%+v, want %d",
dueResult.OriginalMessage.SuggestedPost.ScheduleDate, dueResult.ServiceMessage.Action, approvedAt)
}
var persistedDate int
if err := pool.QueryRow(ctx, `
SELECT schedule_date
FROM suggested_post_approvals
WHERE monoforum_id=$1 AND suggestion_message_id=$2`, monoID, due.Message.ID).Scan(&persistedDate); err != nil {
t.Fatal(err)
}
if persistedDate != approvedAt {
t.Fatalf("persisted due schedule=%d, want %d", persistedDate, approvedAt)
}
}

View file

@ -27,16 +27,6 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
limit = domain.MaxChannelDifferenceLimit
}
if preview && member.Status != domain.ChannelMemberActive {
return domain.ChannelDifference{
Channel: channel,
Self: member,
Pts: channel.Pts,
Final: true,
Timeout: 30,
Dialog: previewChannelDialog(req.UserID, channel, member),
}, nil
}
checkpoint, err := getChannelUpdateCheckpoint(ctx, s.db, req.ChannelID)
if err != nil {
return domain.ChannelDifference{}, err

View file

@ -71,8 +71,8 @@ func TestChannelStoreResolvePublicUsernameRejectsStaleIndex(t *testing.T) {
t.Fatalf("clear username: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
VALUES ($1,'channel',$2)
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
VALUES ($1,$1,'channel',$2,true,true,0)
ON CONFLICT (username_lower) DO UPDATE SET peer_type = EXCLUDED.peer_type, peer_id = EXCLUDED.peer_id, updated_at = now()
`, strings.ToLower(publicUsername), publicChannel.ID); err != nil {
t.Fatalf("insert stale username index: %v", err)

View file

@ -0,0 +1,149 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type ClientTelemetryStore struct {
db sqlcgen.DBTX
}
func NewClientTelemetryStore(db sqlcgen.DBTX) *ClientTelemetryStore {
return &ClientTelemetryStore{db: db}
}
func (s *ClientTelemetryStore) CreateClientTelemetry(ctx context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error) {
if s == nil || s.db == nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store is not configured")
}
if err := event.Validate(); err != nil || event.ID != 0 {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("begin client telemetry: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('client-telemetry:' || $1::bigint::text, 0)
)`, event.UserID); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("lock client telemetry user: %w", err)
}
existing, found, err := getClientTelemetryByFingerprint(
ctx, tx, event.UserID, event.Fingerprint,
)
if err != nil {
return domain.ClientTelemetryEvent{}, false, err
}
if found {
return existing, false, nil
}
var hourly, daily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '1 hour'),
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '24 hours')
FROM client_telemetry_events
WHERE user_id = $1 AND created_at <= $2::timestamptz`,
event.UserID, event.CreatedAt,
).Scan(&hourly, &daily); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("count client telemetry: %w", err)
}
if hourly >= domain.MaxClientTelemetryEventsPerHour ||
daily >= domain.MaxClientTelemetryEventsPerDay {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryRateLimited
}
if err := tx.QueryRow(ctx, `
INSERT INTO client_telemetry_events (
user_id, kind, peer_type, peer_id, subject_ids, payload,
fingerprint, created_at
) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8)
RETURNING id`,
event.UserID, string(event.Kind), string(event.Peer.Type),
event.Peer.ID, event.SubjectIDs, []byte(event.Payload),
event.Fingerprint[:], event.CreatedAt,
).Scan(&event.ID); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("insert client telemetry: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("commit client telemetry: %w", err)
}
return event, true, nil
}
func (s *ClientTelemetryStore) DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("client telemetry store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrClientTelemetryInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM client_telemetry_events
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)
DELETE FROM client_telemetry_events e
USING doomed d
WHERE e.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired client telemetry: %w", err)
}
return int(tag.RowsAffected()), nil
}
func getClientTelemetryByFingerprint(ctx context.Context, db sqlcgen.DBTX, userID int64, fingerprint [32]byte) (domain.ClientTelemetryEvent, bool, error) {
var event domain.ClientTelemetryEvent
var kind, peerType string
var payload, storedFingerprint []byte
if err := db.QueryRow(ctx, `
SELECT id, user_id, kind, peer_type, peer_id, subject_ids, payload,
fingerprint, created_at
FROM client_telemetry_events
WHERE user_id = $1 AND fingerprint = $2`,
userID, fingerprint[:],
).Scan(
&event.ID, &event.UserID, &kind, &peerType, &event.Peer.ID,
&event.SubjectIDs, &payload, &storedFingerprint, &event.CreatedAt,
); errors.Is(err, pgx.ErrNoRows) {
return domain.ClientTelemetryEvent{}, false, nil
} else if err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("get client telemetry: %w", err)
}
event.Kind = domain.ClientTelemetryKind(kind)
event.Peer.Type = domain.PeerType(peerType)
var canonicalPayload map[string]any
if err := json.Unmarshal(payload, &canonicalPayload); err != nil || canonicalPayload == nil {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
canonicalRaw, marshalErr := json.Marshal(canonicalPayload)
if marshalErr != nil {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
event.Payload = canonicalRaw
if len(storedFingerprint) != len(event.Fingerprint) {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
copy(event.Fingerprint[:], storedFingerprint)
if err := event.Validate(); err != nil {
return domain.ClientTelemetryEvent{}, false, err
}
return event, true, nil
}

View file

@ -0,0 +1,750 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// CollectibleUsernameStore is the PostgreSQL implementation of the collectible
// username registry and the asset lifecycle behind it.
//
// The asset (collectible_usernames) and its registry projection (peer_usernames)
// are always written in one transaction: an asset can never be owned without
// being resolvable, and a resolvable collectible row always has a live owner.
// The asset row is locked with SELECT ... FOR UPDATE before any mutation, and the
// name itself is locked through the registry row, so two concurrent commands on
// the same name or the same asset serialise instead of interleaving.
type CollectibleUsernameStore struct {
db sqlcgen.DBTX
}
// NewCollectibleUsernameStore builds the store on a pgx pool or transaction.
func NewCollectibleUsernameStore(db sqlcgen.DBTX) *CollectibleUsernameStore {
return &CollectibleUsernameStore{db: db}
}
var (
_ store.UsernameRegistryStore = (*CollectibleUsernameStore)(nil)
_ store.CollectibleUsernameStore = (*CollectibleUsernameStore)(nil)
)
const (
defaultCollectibleUsernameListLimit = 50
maxCollectibleUsernameListLimit = 200
)
// collectibleUsernameColumns is the asset projection shared by every reader.
const collectibleUsernameColumns = `id, username, status, owner_peer_type, owner_peer_id,
purchase_date, currency, amount, crypto_currency, crypto_amount, url,
original_owner_peer_type, original_owner_peer_id, transfer_count, version,
created_at, updated_at`
// PeerUsernames returns the peer's registry rows in projection order.
func (s *CollectibleUsernameStore) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
return listPeerUsernames(ctx, s.db, peer)
}
// PeerUsernamesBatch resolves several peers in one round trip.
func (s *CollectibleUsernameStore) PeerUsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
return listPeerUsernamesBatch(ctx, s.db, peers)
}
// SetUsernameActive toggles one collectible row. The editable slot is rejected:
// the client owns it through account.updateUsername, not through this path.
func (s *CollectibleUsernameStore) SetUsernameActive(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("collectible username store is not configured")
}
username = domain.NormalizeUsername(username)
if peer.Type == "" || peer.ID <= 0 || username == "" {
return false, domain.ErrUsernameInvalid
}
usernameLower := strings.ToLower(username)
changed := false
err := withTx(ctx, s.db, "set collectible username active", func(tx pgx.Tx) error {
current, err := lockPeerUsernamesTx(ctx, tx, peer)
if err != nil {
return err
}
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
UPDATE peer_usernames SET active = $4, updated_at = now()
WHERE username_lower = $1 AND peer_type = $2 AND peer_id = $3
AND collectible_id IS NOT NULL AND active <> $4`,
usernameLower, string(peer.Type), peer.ID, active)
if err != nil {
return fmt.Errorf("update collectible username active: %w", err)
}
changed = tag.RowsAffected() > 0
return nil
})
if err != nil {
return false, err
}
return changed, nil
}
// ReorderUsernames rewrites the peer's username sort order. order carries every
// active username the peer has, the editable slot included -- see
// domain.ValidateUsernameReorder -- so the editable row is repositioned like any
// other and a collectible may end up first.
func (s *CollectibleUsernameStore) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("collectible username store is not configured")
}
if peer.Type == "" || peer.ID <= 0 {
return false, domain.ErrUsernameInvalid
}
changed := false
err := withTx(ctx, s.db, "reorder collectible usernames", func(tx pgx.Tx) error {
current, err := lockPeerUsernamesTx(ctx, tx, peer)
if err != nil {
return err
}
next, err := domain.ApplyUsernameReorder(current, order)
if err != nil {
return err
}
previous := make(map[string]int, len(current))
for _, item := range current {
previous[strings.ToLower(item.Username)] = item.SortOrder
}
// Renumbering always happens; "changed" is about what a client can see.
changed = !domain.SameUsernameOrder(current, next)
for _, item := range next {
key := strings.ToLower(item.Username)
if key == "" || previous[key] == item.SortOrder {
continue
}
if _, err := tx.Exec(ctx, `
UPDATE peer_usernames SET sort_order = $4, updated_at = now()
WHERE username_lower = $1 AND peer_type = $2 AND peer_id = $3`,
key, string(peer.Type), peer.ID, item.SortOrder); err != nil {
return fmt.Errorf("update username sort order: %w", err)
}
}
return nil
})
if err != nil {
return false, err
}
return changed, nil
}
// DeactivateAllUsernames clears the active flag on every collectible row, which
// is what losing a public surface does to the peer's collectible names. The
// editable slot keeps its own flag.
func (s *CollectibleUsernameStore) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("collectible username store is not configured")
}
if peer.Type == "" || peer.ID <= 0 {
return false, domain.ErrUsernameInvalid
}
tag, err := s.db.Exec(ctx, `
UPDATE peer_usernames SET active = false, updated_at = now()
WHERE peer_type = $1 AND peer_id = $2 AND collectible_id IS NOT NULL AND active`,
string(peer.Type), peer.ID)
if err != nil {
return false, fmt.Errorf("deactivate collectible usernames: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// MintCollectibleUsername creates the asset, optionally assigning it in the same
// transaction. A non-empty CommandKey makes the mint replay-safe: the recorded
// provenance row carries the key, so a retry returns the original asset.
func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
usernameLower := strings.ToLower(req.Username)
var asset domain.CollectibleUsername
created := false
err := withTx(ctx, s.db, "mint collectible username", func(tx pgx.Tx) error {
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
asset = replayed
return nil
}
now := time.Now().UTC()
purchaseDate := req.PurchaseDate.UTC()
if req.PurchaseDate.IsZero() {
purchaseDate = now
}
// The registry row locks the name against editable usernames. Occupancy of
// the asset itself is decided by the live rows only: 0152 narrowed
// uniqueness to status <> 'burned', so a retired name can be issued again
// while its burned rows stay as provenance.
if _, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true); err != nil {
return err
} else if found {
return domain.ErrUsernameOccupied
}
var existing int64
switch err := tx.QueryRow(ctx, `
SELECT id FROM collectible_usernames
WHERE username_lower = $1 AND status <> 'burned' FOR UPDATE`, usernameLower).Scan(&existing); {
case err == nil:
return domain.ErrUsernameOccupied
case errors.Is(err, pgx.ErrNoRows):
default:
return fmt.Errorf("lock collectible username: %w", err)
}
owner := req.Owner
status := domain.CollectibleUsernameStatusVault
if owner.Type != "" {
status = domain.CollectibleUsernameStatusOwned
count, err := countPeerCollectibleUsernamesTx(ctx, tx, string(owner.Type), owner.ID)
if err != nil {
return err
}
if count >= domain.MaxPeerCollectibleUsernames {
return domain.ErrCollectibleUsernameLimit
}
}
var collectibleID int64
if err := tx.QueryRow(ctx, `
INSERT INTO collectible_usernames
(username, username_lower, status, owner_peer_type, owner_peer_id, purchase_date,
currency, amount, crypto_currency, crypto_amount, url,
original_owner_peer_type, original_owner_peer_id, transfer_count, version,
created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$4,$5,0,1,$12,$12)
RETURNING id`,
req.Username, usernameLower, string(status), string(owner.Type), owner.ID, purchaseDate,
req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount, req.URL, now,
).Scan(&collectibleID); err != nil {
if isUniqueViolation(err) {
return domain.ErrUsernameOccupied
}
return fmt.Errorf("insert collectible username: %w", err)
}
if owner.Type != "" {
if err := insertCollectiblePeerUsernameTx(ctx, tx, string(owner.Type), owner.ID,
req.Username, usernameLower, collectibleID); err != nil {
return err
}
}
// A mint that assigns an owner records a single 'mint' row carrying the
// recipient: the command key is unique, so one command owns one row.
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
collectibleID: collectibleID,
kind: domain.CollectibleUsernameKindMint,
to: owner,
currency: req.Currency,
amount: req.Amount,
actor: req.Actor,
reason: req.Reason,
commandKey: req.CommandKey,
createdAt: now,
}); err != nil {
return err
}
loaded, err := collectibleUsernameByIDTx(ctx, tx, collectibleID)
if err != nil {
return err
}
asset = loaded
created = true
return nil
})
if err != nil {
return domain.CollectibleUsername{}, false, err
}
return asset, created, nil
}
// TransferCollectibleUsername moves the asset to req.To, out of the vault or from
// the current holder. The old registry row is removed and the new one inserted in
// the same transaction, so the name never resolves to the wrong peer.
func (s *CollectibleUsernameStore) TransferCollectibleUsername(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
usernameLower := strings.ToLower(req.Username)
var asset domain.CollectibleUsername
changed := false
err := withTx(ctx, s.db, "transfer collectible username", func(tx pgx.Tx) error {
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
asset = replayed
return nil
}
current, err := lockCollectibleUsernameTx(ctx, tx, usernameLower)
if err != nil {
return err
}
if current.Status == domain.CollectibleUsernameStatusBurned {
return domain.ErrCollectibleUsernameBurned
}
if current.Owned() && current.Owner == req.To {
asset = current
return nil
}
now := time.Now().UTC()
if err := deleteCollectiblePeerUsernameTx(ctx, tx, current.ID); err != nil {
return err
}
count, err := countPeerCollectibleUsernamesTx(ctx, tx, string(req.To.Type), req.To.ID)
if err != nil {
return err
}
if count >= domain.MaxPeerCollectibleUsernames {
return domain.ErrCollectibleUsernameLimit
}
if err := insertCollectiblePeerUsernameTx(ctx, tx, string(req.To.Type), req.To.ID,
current.Username, usernameLower, current.ID); err != nil {
return err
}
// The original owner is the first holder and is recorded once: a name that
// left the vault keeps its provenance across every later move and the burn.
if _, err := tx.Exec(ctx, `
UPDATE collectible_usernames
SET status = 'owned',
owner_peer_type = $2,
owner_peer_id = $3,
original_owner_peer_type = CASE WHEN original_owner_peer_type = '' THEN $2 ELSE original_owner_peer_type END,
original_owner_peer_id = CASE WHEN original_owner_peer_type = '' THEN $3 ELSE original_owner_peer_id END,
transfer_count = transfer_count + 1,
version = version + 1,
updated_at = $4
WHERE id = $1`, current.ID, string(req.To.Type), req.To.ID, now); err != nil {
return fmt.Errorf("update transferred collectible username: %w", err)
}
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
collectibleID: current.ID,
kind: domain.CollectibleUsernameKindTransfer,
from: current.Owner,
to: req.To,
actor: req.Actor,
reason: req.Reason,
commandKey: req.CommandKey,
createdAt: now,
}); err != nil {
return err
}
loaded, err := collectibleUsernameByIDTx(ctx, tx, current.ID)
if err != nil {
return err
}
asset = loaded
changed = true
return nil
})
if err != nil {
return domain.CollectibleUsername{}, false, err
}
return asset, changed, nil
}
// RevokeCollectibleUsername returns the asset to the vault, or burns it when
// req.Burn is set. Either way the registry row goes away, so the name stops
// resolving to the former holder; a burn additionally retires the asset.
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
usernameLower := strings.ToLower(req.Username)
var asset domain.CollectibleUsername
changed := false
err := withTx(ctx, s.db, "revoke collectible username", func(tx pgx.Tx) error {
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
asset = replayed
return nil
}
current, err := lockCollectibleUsernameTx(ctx, tx, usernameLower)
if err != nil {
return err
}
if current.Status == domain.CollectibleUsernameStatusBurned {
return domain.ErrCollectibleUsernameBurned
}
if !req.Burn && !current.Owned() {
// Already in the vault: nothing to release, nothing to record.
asset = current
return nil
}
now := time.Now().UTC()
if err := deleteCollectiblePeerUsernameTx(ctx, tx, current.ID); err != nil {
return err
}
status := domain.CollectibleUsernameStatusVault
kind := domain.CollectibleUsernameKindRevoke
if req.Burn {
status = domain.CollectibleUsernameStatusBurned
kind = domain.CollectibleUsernameKindBurn
}
if _, err := tx.Exec(ctx, `
UPDATE collectible_usernames
SET status = $2,
owner_peer_type = '',
owner_peer_id = 0,
version = version + 1,
updated_at = $3
WHERE id = $1`, current.ID, string(status), now); err != nil {
return fmt.Errorf("update revoked collectible username: %w", err)
}
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
collectibleID: current.ID,
kind: kind,
from: current.Owner,
actor: req.Actor,
reason: req.Reason,
commandKey: req.CommandKey,
createdAt: now,
}); err != nil {
return err
}
loaded, err := collectibleUsernameByIDTx(ctx, tx, current.ID)
if err != nil {
return err
}
asset = loaded
changed = true
return nil
})
if err != nil {
return domain.CollectibleUsername{}, false, err
}
return asset, changed, nil
}
// DeleteCollectibleUsername removes the live asset for a name outright: the
// registry row, the asset and its provenance log all go away, and the name
// becomes free for any use. This is the operator's escape hatch for a mistaken
// issue, as opposed to Revoke+Burn, which retires an asset but keeps its history.
//
// The command key cannot make this idempotent -- a replay has no record left to
// return -- so a second call simply reports deleted=false once no live asset
// remains.
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("collectible username store is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return false, err
}
usernameLower := strings.ToLower(req.Username)
deleted := false
err := withTx(ctx, s.db, "delete collectible username", func(tx pgx.Tx) error {
var id int64
switch err := tx.QueryRow(ctx, `
SELECT id FROM collectible_usernames
WHERE username_lower = $1 AND status <> 'burned'
ORDER BY id DESC
LIMIT 1
FOR UPDATE`, usernameLower).Scan(&id); {
case err == nil:
case errors.Is(err, pgx.ErrNoRows):
// Either the name was never issued, or only burned history remains.
// Both are "nothing live to delete" rather than an error, so a repeated
// command stays safe.
return nil
default:
return fmt.Errorf("lock collectible username for delete: %w", err)
}
if err := deleteCollectiblePeerUsernameTx(ctx, tx, id); err != nil {
return err
}
// collectible_username_transfers references the asset with ON DELETE
// CASCADE, so the provenance rows go with it.
if _, err := tx.Exec(ctx, `DELETE FROM collectible_usernames WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete collectible username: %w", err)
}
deleted = true
return nil
})
if err != nil {
return false, err
}
return deleted, nil
}
// CollectibleUsername looks the asset up by name. A live asset wins; when the
// name only has burned rows the newest one is returned, because the provenance of
// a retired name still has to be inspectable.
func (s *CollectibleUsernameStore) CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, fmt.Errorf("collectible username store is not configured")
}
usernameLower := strings.ToLower(domain.NormalizeUsername(username))
if usernameLower == "" {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
asset, err := scanCollectibleUsername(s.db.QueryRow(ctx, `
SELECT `+collectibleUsernameColumns+`
FROM collectible_usernames
WHERE username_lower = $1
ORDER BY (status <> 'burned') DESC, id DESC
LIMIT 1`, usernameLower))
if errors.Is(err, pgx.ErrNoRows) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
if err != nil {
return domain.CollectibleUsername{}, fmt.Errorf("get collectible username: %w", err)
}
return asset, nil
}
// CollectibleUsernameByID looks the asset up by identity.
func (s *CollectibleUsernameStore) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, fmt.Errorf("collectible username store is not configured")
}
if id <= 0 {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
asset, err := collectibleUsernameByIDTx(ctx, s.db, id)
if err != nil {
return domain.CollectibleUsername{}, err
}
return asset, nil
}
// ListCollectibleUsernames is the admin listing query with keyset paging on the
// asset id, which matches the (status, id DESC) and (owner, id DESC) indexes.
func (s *CollectibleUsernameStore) ListCollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
if filter.Status != "" && !filter.Status.Valid() {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
if filter.Owner.Type != "" && filter.Owner.ID <= 0 {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultCollectibleUsernameListLimit
}
if limit > maxCollectibleUsernameListLimit {
limit = maxCollectibleUsernameListLimit
}
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
rows, err := s.db.Query(ctx, `
SELECT `+collectibleUsernameColumns+`
FROM collectible_usernames
WHERE ($1 = '' OR status = $1)
AND ($2 = '' OR (owner_peer_type = $2 AND owner_peer_id = $3))
AND ($4 = '' OR username_lower LIKE $5 || '%')
AND ($6 = 0 OR id < $6)
ORDER BY id DESC
LIMIT $7`,
string(filter.Status), string(filter.Owner.Type), filter.Owner.ID,
query, escapeLike(query), filter.BeforeID, limit)
if err != nil {
return nil, fmt.Errorf("list collectible usernames: %w", err)
}
defer rows.Close()
out := make([]domain.CollectibleUsername, 0, limit)
for rows.Next() {
asset, err := scanCollectibleUsername(rows)
if err != nil {
return nil, fmt.Errorf("scan collectible username: %w", err)
}
out = append(out, asset)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate collectible usernames: %w", err)
}
return out, nil
}
// CollectibleUsernameTransfers returns the provenance log, newest first.
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
if collectibleID <= 0 {
return nil, domain.ErrCollectibleUsernameNotFound
}
if limit <= 0 {
limit = defaultCollectibleUsernameListLimit
}
if limit > maxCollectibleUsernameListLimit {
limit = maxCollectibleUsernameListLimit
}
rows, err := s.db.Query(ctx, `
SELECT id, collectible_id, kind, from_peer_type, from_peer_id, to_peer_type, to_peer_id,
currency, amount, actor, reason, COALESCE(command_key, ''), created_at
FROM collectible_username_transfers
WHERE collectible_id = $1
ORDER BY id DESC
LIMIT $2`, collectibleID, limit)
if err != nil {
return nil, fmt.Errorf("list collectible username transfers: %w", err)
}
defer rows.Close()
out := make([]domain.CollectibleUsernameTransfer, 0, limit)
for rows.Next() {
var item domain.CollectibleUsernameTransfer
var kind, fromType, toType string
if err := rows.Scan(&item.ID, &item.CollectibleID, &kind, &fromType, &item.From.ID,
&toType, &item.To.ID, &item.Currency, &item.Amount, &item.Actor, &item.Reason,
&item.CommandKey, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("scan collectible username transfer: %w", err)
}
item.Kind = domain.CollectibleUsernameTransferKind(kind)
item.From.Type = domain.PeerType(fromType)
item.To.Type = domain.PeerType(toType)
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate collectible username transfers: %w", err)
}
return out, nil
}
type collectibleUsernameTransfer struct {
collectibleID int64
kind domain.CollectibleUsernameTransferKind
from domain.Peer
to domain.Peer
currency string
amount int64
actor string
reason string
commandKey string
createdAt time.Time
}
func insertCollectibleUsernameTransferTx(ctx context.Context, tx pgx.Tx, entry collectibleUsernameTransfer) error {
if _, err := tx.Exec(ctx, `
INSERT INTO collectible_username_transfers
(collectible_id, kind, from_peer_type, from_peer_id, to_peer_type, to_peer_id,
currency, amount, actor, reason, command_key, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NULLIF($11,''),$12)`,
entry.collectibleID, string(entry.kind), string(entry.from.Type), entry.from.ID,
string(entry.to.Type), entry.to.ID, entry.currency, entry.amount,
entry.actor, entry.reason, entry.commandKey, entry.createdAt); err != nil {
return fmt.Errorf("insert collectible username transfer: %w", err)
}
return nil
}
// replayCollectibleUsernameCommand resolves an already-recorded command key to
// the asset it touched, which is what makes mint/transfer/revoke retry-safe.
//
// The transaction-scoped advisory lock serialises commands sharing a key, so two
// concurrent retries cannot both pass the lookup and race on the unique
// command_key index -- the second one waits and then observes the recorded row.
func replayCollectibleUsernameCommand(ctx context.Context, tx pgx.Tx, commandKey string) (domain.CollectibleUsername, bool, error) {
if commandKey == "" {
return domain.CollectibleUsername{}, false, nil
}
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(hashtextextended('collectible-username:' || $1::text, 0))`, commandKey); err != nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("lock collectible username command: %w", err)
}
var collectibleID int64
err := tx.QueryRow(ctx, `
SELECT collectible_id FROM collectible_username_transfers WHERE command_key = $1`, commandKey).Scan(&collectibleID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.CollectibleUsername{}, false, nil
}
if err != nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("lookup collectible username command: %w", err)
}
asset, err := collectibleUsernameByIDTx(ctx, tx, collectibleID)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
return asset, true, nil
}
// lockCollectibleUsernameTx locks the row a name currently resolves to. After
// 0151 one name can carry several burned rows plus at most one live row, so the
// live row wins and the newest burned row is the fallback. That keeps a mutation
// of a retired name reporting ErrCollectibleUsernameBurned instead of degrading
// to a not-found.
func lockCollectibleUsernameTx(ctx context.Context, tx pgx.Tx, usernameLower string) (domain.CollectibleUsername, error) {
asset, err := scanCollectibleUsername(tx.QueryRow(ctx, `
SELECT `+collectibleUsernameColumns+`
FROM collectible_usernames
WHERE username_lower = $1
ORDER BY (status <> 'burned') DESC, id DESC
LIMIT 1
FOR UPDATE`, usernameLower))
if errors.Is(err, pgx.ErrNoRows) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
if err != nil {
return domain.CollectibleUsername{}, fmt.Errorf("lock collectible username: %w", err)
}
return asset, nil
}
func collectibleUsernameByIDTx(ctx context.Context, db sqlcgen.DBTX, id int64) (domain.CollectibleUsername, error) {
asset, err := scanCollectibleUsername(db.QueryRow(ctx, `
SELECT `+collectibleUsernameColumns+`
FROM collectible_usernames WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
if err != nil {
return domain.CollectibleUsername{}, fmt.Errorf("get collectible username by id: %w", err)
}
return asset, nil
}
func scanCollectibleUsername(row pgx.Row) (domain.CollectibleUsername, error) {
var asset domain.CollectibleUsername
var status, ownerType, originalOwnerType string
if err := row.Scan(&asset.ID, &asset.Username, &status, &ownerType, &asset.Owner.ID,
&asset.PurchaseDate, &asset.Currency, &asset.Amount, &asset.CryptoCurrency,
&asset.CryptoAmount, &asset.URL, &originalOwnerType, &asset.OriginalOwner.ID,
&asset.TransferCount, &asset.Version, &asset.CreatedAt, &asset.UpdatedAt); err != nil {
return domain.CollectibleUsername{}, err
}
asset.Status = domain.CollectibleUsernameStatus(status)
asset.Owner.Type = domain.PeerType(ownerType)
asset.OriginalOwner.Type = domain.PeerType(originalOwnerType)
return asset, nil
}

View file

@ -0,0 +1,773 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// collectibleTestUser inserts a bare user row and returns its user peer. The
// registry rows for collectibles reference no peer table, but the editable-slot
// assertions and the peer-deletion trigger both need a real user.
func collectibleTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, username string) domain.Peer {
t.Helper()
ctx := context.Background()
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, username)
VALUES ($1, $2, 'collectible test', $3)
RETURNING id`, seed, fmt.Sprintf("%d", seed), username).Scan(&id); err != nil {
t.Fatalf("insert collectible test user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return domain.Peer{Type: domain.PeerTypeUser, ID: id}
}
// setEditableUsername installs an editable registry row through the same helper
// the client-driven username path uses.
func setEditableUsername(t *testing.T, pool *pgxpool.Pool, peer domain.Peer, username string) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin editable username: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, peer.ID, username, lowerASCII(username)); err != nil {
t.Fatalf("set editable username %q: %v", username, err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit editable username: %v", err)
}
}
func lowerASCII(s string) string {
out := []byte(s)
for i := range out {
if out[i] >= 'A' && out[i] <= 'Z' {
out[i] += 'a' - 'A'
}
}
return string(out)
}
func cleanupCollectible(t *testing.T, pool *pgxpool.Pool, usernameLower string) {
t.Helper()
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM collectible_usernames WHERE username_lower = $1`, usernameLower)
})
}
func mintRequest(username string, owner domain.Peer, commandKey string) domain.MintCollectibleUsernameRequest {
return domain.MintCollectibleUsernameRequest{
Username: username,
Owner: owner,
PurchaseDate: time.Now().UTC().Truncate(time.Second),
Currency: domain.CollectibleCurrencyStars,
Amount: 5000,
URL: "https://fragment.example/" + username,
Actor: "ops",
Reason: "integration test",
CommandKey: commandKey,
}
}
func registryRows(t *testing.T, pool *pgxpool.Pool, peer domain.Peer) []domain.Username {
t.Helper()
list, err := listPeerUsernames(context.Background(), pool, peer)
if err != nil {
t.Fatalf("list peer usernames: %v", err)
}
return list
}
func TestCollectibleUsernameResolveAndSearchUseActiveRegistry(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
seed := time.Now().UnixNano() % 1_000_000
viewer := collectibleTestUser(t, pool, 3_100_000_000+seed, "")
userPeer := collectibleTestUser(t, pool, 3_200_000_000+seed, "")
userEditable := fmt.Sprintf("uedit%d", seed)
userCollectible := fmt.Sprintf("unft%d", seed)
setEditableUsername(t, pool, userPeer, userEditable)
cleanupCollectible(t, pool, lowerASCII(userCollectible))
registry := NewCollectibleUsernameStore(pool)
if _, created, err := registry.MintCollectibleUsername(ctx, mintRequest(userCollectible, userPeer, "")); err != nil || !created {
t.Fatalf("mint user collectible: created=%v err=%v", created, err)
}
users := NewUserStore(pool)
resolvedUser, found, err := users.ByUsername(ctx, userCollectible)
if err != nil || !found || resolvedUser.ID != userPeer.ID {
t.Fatalf("resolve user collectible = %+v found=%v err=%v", resolvedUser, found, err)
}
userSearch, err := users.Search(ctx, viewer.ID, userCollectible, "", 10)
if err != nil || len(userSearch.Results) != 1 || userSearch.Results[0].ID != userPeer.ID {
t.Fatalf("search user collectible = %+v err=%v", userSearch, err)
}
if changed, err := registry.SetUsernameActive(ctx, userPeer, userCollectible, false); err != nil || !changed {
t.Fatalf("deactivate user collectible: changed=%v err=%v", changed, err)
}
if _, found, err := users.ByUsername(ctx, userCollectible); err != nil || found {
t.Fatalf("resolve inactive user collectible found=%v err=%v", found, err)
}
if hidden, err := users.Search(ctx, viewer.ID, userCollectible, "", 10); err != nil || len(hidden.Results)+len(hidden.MyResults) != 0 {
t.Fatalf("search inactive user collectible = %+v err=%v", hidden, err)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: userPeer.ID,
Title: "Unrelated collectible channel",
Broadcast: true,
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM channels WHERE id = $1`, channelPeer.ID)
})
channelEditable := fmt.Sprintf("cedit%d", seed)
if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
UserID: userPeer.ID,
ChannelID: channelPeer.ID,
Username: channelEditable,
}); err != nil {
t.Fatalf("set channel editable username: %v", err)
}
channelCollectible := fmt.Sprintf("cnft%d", seed)
cleanupCollectible(t, pool, lowerASCII(channelCollectible))
if _, created, err := registry.MintCollectibleUsername(ctx, mintRequest(channelCollectible, channelPeer, "")); err != nil || !created {
t.Fatalf("mint channel collectible: created=%v err=%v", created, err)
}
resolvedChannel, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible)
if err != nil || !found || resolvedChannel.ID != channelPeer.ID {
t.Fatalf("resolve channel collectible = %+v found=%v err=%v", resolvedChannel, found, err)
}
channelSearch, err := channels.SearchPublicChannels(ctx, viewer.ID, channelCollectible, 10)
if err != nil || len(channelSearch.Results) != 1 || channelSearch.Results[0].ID != channelPeer.ID {
t.Fatalf("search channel collectible = %+v err=%v", channelSearch, err)
}
if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
UserID: userPeer.ID,
ChannelID: channelPeer.ID,
Username: "",
}); err != nil {
t.Fatalf("clear channel editable username: %v", err)
}
if nftOnly, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible); err != nil || !found || nftOnly.ID != channelPeer.ID {
t.Fatalf("resolve NFT-only channel = %+v found=%v err=%v", nftOnly, found, err)
}
if view, err := channels.GetChannel(ctx, viewer.ID, channelPeer.ID); err != nil || view.Channel.ID != channelPeer.ID {
t.Fatalf("preview NFT-only channel = %+v err=%v", view, err)
}
if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
UserID: userPeer.ID,
ChannelID: channelPeer.ID,
Username: channelEditable,
}); err != nil {
t.Fatalf("restore channel editable username: %v", err)
}
if changed, err := registry.SetUsernameActive(ctx, channelPeer, channelCollectible, false); err != nil || !changed {
t.Fatalf("deactivate channel collectible: changed=%v err=%v", changed, err)
}
if _, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible); err != nil || found {
t.Fatalf("resolve inactive channel collectible found=%v err=%v", found, err)
}
if hidden, err := channels.SearchPublicChannels(ctx, viewer.ID, channelCollectible, 10); err != nil || len(hidden.Results) != 0 {
t.Fatalf("search inactive channel collectible = %+v err=%v", hidden, err)
}
}
func TestCollectibleUsernameSearchPrefixUsesActiveRegistryIndex(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
WITH names AS (
SELECT
CASE WHEN n = 1 THEN 'nftplanfixture' ELSE 'otherplanfixture' || n::text END AS username,
9100000000 + n AS owner_peer_id
FROM generate_series(1, 5000) AS n
),
assets AS (
INSERT INTO collectible_usernames (
username, username_lower, status, owner_peer_type, owner_peer_id,
purchase_date, currency, amount,
original_owner_peer_type, original_owner_peer_id,
created_at, updated_at
)
SELECT
username, username, 'owned', 'user', owner_peer_id,
now(), 'XTR', 1,
'user', owner_peer_id,
now(), now()
FROM names
RETURNING id, username, username_lower, owner_peer_id
)
INSERT INTO peer_usernames (
username_lower, username, peer_type, peer_id,
active, editable, sort_order, collectible_id
)
SELECT
username_lower, username, 'user', owner_peer_id,
true, false, 0, id
FROM assets`); err != nil {
t.Fatalf("seed username plan fixture: %v", err)
}
if _, err := tx.Exec(ctx, "ANALYZE peer_usernames"); err != nil {
t.Fatalf("analyze username plan fixture: %v", err)
}
if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
t.Fatalf("disable seqscan: %v", err)
}
plan := explainText(t, ctx, tx, `
SELECT peer_id
FROM peer_usernames
WHERE peer_type = 'user'
AND active
AND collectible_id IS NOT NULL
AND username_lower LIKE $1 || '%' ESCAPE '\'`, "nft")
if !strings.Contains(plan, "peer_usernames_active_search_idx") {
t.Fatalf("active username prefix plan = %s, want peer_usernames_active_search_idx", plan)
}
}
// TestCollectibleUsernameMintIntoVault covers a vault mint: the asset exists, the
// name is not projected into any peer's registry, and the provenance log records
// the mint.
func TestCollectibleUsernameMintIntoVault(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
name := fmt.Sprintf("vault%d", time.Now().UnixNano()%1_000_000)
cleanupCollectible(t, pool, lowerASCII(name))
asset, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, ""))
if err != nil || !created {
t.Fatalf("mint into vault created=%v err=%v", created, err)
}
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() ||
asset.Version != 1 || asset.TransferCount != 0 || asset.Username != name {
t.Fatalf("vault asset = %+v", asset)
}
if err := asset.Validate(); err != nil {
t.Fatalf("vault asset invariants: %v", err)
}
var registry int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM peer_usernames WHERE collectible_id = $1`, asset.ID).Scan(&registry); err != nil {
t.Fatal(err)
}
if registry != 0 {
t.Fatalf("vault asset must not be projected, got %d registry rows", registry)
}
transfers, err := store.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(transfers) != 1 || transfers[0].Kind != domain.CollectibleUsernameKindMint {
t.Fatalf("transfers=%+v err=%v", transfers, err)
}
// A vault revoke has nothing to release and must stay a no-op.
same, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{Username: name})
if err != nil || changed || same.Version != asset.Version {
t.Fatalf("vault revoke changed=%v asset=%+v err=%v", changed, same, err)
}
}
// TestCollectibleUsernameMintWithOwnerAndReplay covers a mint that assigns the
// asset immediately, and the command-key replay that must return the recorded
// state instead of minting twice.
func TestCollectibleUsernameMintWithOwnerAndReplay(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
owner := collectibleTestUser(t, pool, 2_100_000_000+seed, "")
name := fmt.Sprintf("owned%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
key := fmt.Sprintf("mint-%d", seed)
asset, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, owner, key))
if err != nil || !created {
t.Fatalf("mint with owner created=%v err=%v", created, err)
}
if asset.Status != domain.CollectibleUsernameStatusOwned || asset.Owner != owner ||
asset.OriginalOwner != owner || asset.TransferCount != 0 {
t.Fatalf("owned asset = %+v", asset)
}
if err := asset.Validate(); err != nil {
t.Fatalf("owned asset invariants: %v", err)
}
list := registryRows(t, pool, owner)
if len(list) != 1 || list[0].Username != name || list[0].Editable ||
!list[0].Active || list[0].CollectibleID != asset.ID {
t.Fatalf("registry = %+v", list)
}
replay, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, owner, key))
if err != nil || created || replay.ID != asset.ID || replay.Version != asset.Version {
t.Fatalf("replay created=%v asset=%+v err=%v", created, replay, err)
}
var assets int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM collectible_usernames WHERE username_lower = $1`, lowerASCII(name)).Scan(&assets); err != nil {
t.Fatal(err)
}
if assets != 1 {
t.Fatalf("replay must not mint again, got %d assets", assets)
}
// The same name cannot be minted twice, even under a different command key.
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, key+"-again")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("duplicate mint err = %v, want ErrUsernameOccupied", err)
}
}
// TestCollectibleUsernameMintRejectsOccupiedEditableName proves the collectible
// registry and the editable slot share one occupancy namespace.
func TestCollectibleUsernameMintRejectsOccupiedEditableName(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_200_000_000+seed, "")
name := fmt.Sprintf("taken%d", seed)
setEditableUsername(t, pool, holder, name)
cleanupCollectible(t, pool, lowerASCII(name))
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, "")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("mint over editable name err = %v, want ErrUsernameOccupied", err)
}
if _, err := store.CollectibleUsername(ctx, name); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("rejected mint must leave no asset, err = %v", err)
}
}
// TestCollectibleUsernameTransferPreservesRecipientEditableSlot is the core
// regression: moving an asset must not disturb either peer's editable username.
func TestCollectibleUsernameTransferPreservesRecipientEditableSlot(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
from := collectibleTestUser(t, pool, 2_300_000_000+seed, "")
to := collectibleTestUser(t, pool, 2_400_000_000+seed, "")
fromEditable := fmt.Sprintf("sender%d", seed)
toEditable := fmt.Sprintf("recip%d", seed)
setEditableUsername(t, pool, from, fromEditable)
setEditableUsername(t, pool, to, toEditable)
name := fmt.Sprintf("moved%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, from, ""))
if err != nil {
t.Fatalf("mint: %v", err)
}
key := fmt.Sprintf("transfer-%d", seed)
moved, changed, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: name, To: to, Actor: "ops", Reason: "sold", CommandKey: key,
})
if err != nil || !changed {
t.Fatalf("transfer changed=%v err=%v", changed, err)
}
if moved.Owner != to || moved.OriginalOwner != from || moved.TransferCount != 1 ||
moved.Version != asset.Version+1 {
t.Fatalf("transferred asset = %+v", moved)
}
fromList := registryRows(t, pool, from)
if len(fromList) != 1 || fromList[0].Username != fromEditable || !fromList[0].Editable {
t.Fatalf("sender registry = %+v, editable slot must survive", fromList)
}
toList := registryRows(t, pool, to)
if len(toList) != 2 || toList[0].Username != toEditable || !toList[0].Editable ||
toList[1].Username != name || !toList[1].Collectible() {
t.Fatalf("recipient registry = %+v", toList)
}
replay, changed, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: name, To: to, CommandKey: key,
})
if err != nil || changed || replay.Version != moved.Version {
t.Fatalf("transfer replay changed=%v asset=%+v err=%v", changed, replay, err)
}
// Revoking back to the vault releases the recipient's registry row only.
vaulted, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Actor: "ops", Reason: "recalled", CommandKey: key + "-revoke",
})
if err != nil || !changed {
t.Fatalf("revoke changed=%v err=%v", changed, err)
}
if vaulted.Status != domain.CollectibleUsernameStatusVault || vaulted.Owned() ||
vaulted.OriginalOwner != from {
t.Fatalf("revoked asset = %+v", vaulted)
}
toList = registryRows(t, pool, to)
if len(toList) != 1 || toList[0].Username != toEditable {
t.Fatalf("recipient registry after revoke = %+v", toList)
}
}
// TestCollectibleUsernameBurnReleasesName covers a burn: the asset is retired and
// the name stops resolving, so an ordinary peer can claim it as its editable slot.
func TestCollectibleUsernameBurnReleasesName(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_500_000_000+seed, "")
claimer := collectibleTestUser(t, pool, 2_600_000_000+seed, "")
name := fmt.Sprintf("burned%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
t.Fatalf("mint: %v", err)
}
burned, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Burn: true, Actor: "ops", Reason: "abuse",
})
if err != nil || !changed {
t.Fatalf("burn changed=%v err=%v", changed, err)
}
if burned.Status != domain.CollectibleUsernameStatusBurned || burned.Owned() {
t.Fatalf("burned asset = %+v", burned)
}
if list := registryRows(t, pool, holder); len(list) != 0 {
t.Fatalf("holder registry after burn = %+v", list)
}
// The freed name is claimable as an ordinary editable username.
setEditableUsername(t, pool, claimer, name)
if list := registryRows(t, pool, claimer); len(list) != 1 || !list[0].Editable || list[0].Username != name {
t.Fatalf("claimer registry = %+v", list)
}
// Every further mutation of a burned asset is refused.
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: name, To: holder,
}); !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
t.Fatalf("transfer of burned asset err = %v, want ErrCollectibleUsernameBurned", err)
}
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Burn: true,
}); !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
t.Fatalf("re-burn err = %v, want ErrCollectibleUsernameBurned", err)
}
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: fmt.Sprintf("ghost%d", seed), To: holder,
}); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("transfer of unknown asset err = %v, want ErrCollectibleUsernameNotFound", err)
}
}
// TestCollectibleUsernamePeerLimit covers the per-peer bound on both entry paths:
// minting straight to the holder and transferring into it.
func TestCollectibleUsernamePeerLimit(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_700_000_000+seed, "")
prefix := fmt.Sprintf("lim%d", seed)
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM collectible_usernames WHERE username_lower LIKE $1 || '%'`, lowerASCII(prefix))
})
for i := 0; i < domain.MaxPeerCollectibleUsernames; i++ {
name := fmt.Sprintf("%sn%02d", prefix, i)
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
t.Fatalf("mint %s: %v", name, err)
}
}
if list := registryRows(t, pool, holder); len(list) != domain.MaxPeerCollectibleUsernames {
t.Fatalf("registry size = %d", len(list))
}
overflow := prefix + "over"
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(overflow, holder, "")); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("mint over limit err = %v, want ErrCollectibleUsernameLimit", err)
}
if _, err := store.CollectibleUsername(ctx, overflow); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("rejected mint must not leave an asset: %v", err)
}
vaulted, _, err := store.MintCollectibleUsername(ctx, mintRequest(prefix+"vault", domain.Peer{}, ""))
if err != nil {
t.Fatalf("mint vault asset: %v", err)
}
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: vaulted.Username, To: holder,
}); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("transfer over limit err = %v, want ErrCollectibleUsernameLimit", err)
}
// The refused transfer must not have released the asset from the vault.
after, err := store.CollectibleUsername(ctx, vaulted.Username)
if err != nil || after.Status != domain.CollectibleUsernameStatusVault {
t.Fatalf("vault asset after refused transfer = %+v err=%v", after, err)
}
owned, err := store.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Owner: holder, Status: domain.CollectibleUsernameStatusOwned, Limit: 100,
})
if err != nil || len(owned) != domain.MaxPeerCollectibleUsernames {
t.Fatalf("list owned = %d err=%v", len(owned), err)
}
prefixed, err := store.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Query: prefix + "n0", Limit: 100,
})
if err != nil || len(prefixed) != 10 {
t.Fatalf("list by prefix = %d err=%v", len(prefixed), err)
}
}
// TestCollectibleUsernameRegistryToggleAndReorder covers the registry-only
// surface: activation, ordering and the bulk deactivation, none of which may
// touch the editable slot.
func TestCollectibleUsernameRegistryToggleAndReorder(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_800_000_000+seed, "")
editable := fmt.Sprintf("edit%d", seed)
setEditableUsername(t, pool, holder, editable)
first := fmt.Sprintf("alpha%d", seed)
second := fmt.Sprintf("beta%d", seed)
cleanupCollectible(t, pool, lowerASCII(first))
cleanupCollectible(t, pool, lowerASCII(second))
for _, name := range []string{first, second} {
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
t.Fatalf("mint %s: %v", name, err)
}
}
changed, err := store.SetUsernameActive(ctx, holder, first, false)
if err != nil || !changed {
t.Fatalf("deactivate collectible changed=%v err=%v", changed, err)
}
if _, err := store.SetUsernameActive(ctx, holder, editable, false); !errors.Is(err, domain.ErrUsernameNotCollectible) {
t.Fatalf("toggling the editable slot err = %v, want ErrUsernameNotCollectible", err)
}
// first is inactive now, so a client would send only the active names; the
// editable slot is one of them and listing it is required, not rejected.
changed, err = store.ReorderUsernames(ctx, holder, []string{editable, second})
if err != nil || !changed {
t.Fatalf("reorder changed=%v err=%v", changed, err)
}
list := registryRows(t, pool, holder)
if len(list) != 3 || list[0].Username != editable || list[1].Username != second || list[2].Username != first {
t.Fatalf("registry order = %+v", list)
}
// Re-sending the order the peer already has is a no-op a client can repeat.
if changed, err := store.ReorderUsernames(ctx, holder, []string{editable, second}); err != nil || changed {
t.Fatalf("repeat reorder changed=%v err=%v", changed, err)
}
// A collectible may be promoted above the editable slot, which is what makes
// it the peer's primary username for clients.
changed, err = store.ReorderUsernames(ctx, holder, []string{second, editable})
if err != nil || !changed {
t.Fatalf("promote collectible changed=%v err=%v", changed, err)
}
list = registryRows(t, pool, holder)
if len(list) != 3 || list[0].Username != second || list[1].Username != editable {
t.Fatalf("registry order after promoting a collectible = %+v", list)
}
if domain.ActiveUsername(list) != second {
t.Fatalf("active username after promoting a collectible = %q, want %q", domain.ActiveUsername(list), second)
}
changed, err = store.ReorderUsernames(ctx, holder, []string{editable, second})
if err != nil || !changed {
t.Fatalf("restore order changed=%v err=%v", changed, err)
}
// An order that omits an active username is still rejected, and so is one
// naming something the peer does not own.
if _, err := store.ReorderUsernames(ctx, holder, []string{second}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
t.Fatalf("partial reorder err = %v, want ErrUsernameOrderInvalid", err)
}
if _, err := store.ReorderUsernames(ctx, holder, []string{editable, second, "nobodyowns" + editable}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
t.Fatalf("reorder with a foreign name err = %v, want ErrUsernameOrderInvalid", err)
}
changed, err = store.DeactivateAllUsernames(ctx, holder)
if err != nil || !changed {
t.Fatalf("deactivate all changed=%v err=%v", changed, err)
}
list = registryRows(t, pool, holder)
if len(list) != 3 || !list[0].Active || list[1].Active || list[2].Active {
t.Fatalf("registry after deactivate all = %+v", list)
}
batch, err := store.PeerUsernamesBatch(ctx, []domain.Peer{holder, {Type: domain.PeerTypeUser, ID: holder.ID + 1}})
if err != nil || len(batch[holder]) != 3 {
t.Fatalf("batch = %+v err=%v", batch, err)
}
if domain.ActiveUsername(batch[holder]) != editable {
t.Fatalf("active username = %q", domain.ActiveUsername(batch[holder]))
}
}
// TestCollectibleUsernameEditableEditKeepsCollectibles pins the peer_username.go
// surgery: rewriting or clearing the editable slot must leave collectible rows
// and their assets untouched.
func TestCollectibleUsernameEditableEditKeepsCollectibles(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_900_000_000+seed, "")
name := fmt.Sprintf("keep%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
if err != nil {
t.Fatalf("mint: %v", err)
}
setEditableUsername(t, pool, holder, fmt.Sprintf("one%d", seed))
setEditableUsername(t, pool, holder, fmt.Sprintf("two%d", seed))
setEditableUsername(t, pool, holder, "")
list := registryRows(t, pool, holder)
if len(list) != 1 || list[0].CollectibleID != asset.ID {
t.Fatalf("registry after editable churn = %+v", list)
}
stored, err := store.CollectibleUsernameByID(ctx, asset.ID)
if err != nil || stored.Owner != holder {
t.Fatalf("asset after editable churn = %+v err=%v", stored, err)
}
// The editable slot may not duplicate a name the peer holds as a collectible.
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, holder.ID, name, lowerASCII(name)); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("editable slot over own collectible err = %v, want ErrUsernameOccupied", err)
}
}
// TestCollectibleUsernameReissueAfterBurn covers migration 0152: uniqueness now
// spans live assets only, so a burned name can be issued again while its burned
// rows remain as provenance.
func TestCollectibleUsernameReissueAfterBurn(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 3_500_000_000+seed, "")
name := fmt.Sprintf("reissue%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
first, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
if err != nil {
t.Fatalf("first mint: %v", err)
}
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Burn: true, Actor: "ops", Reason: "retire",
}); err != nil {
t.Fatalf("burn: %v", err)
}
second, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
if err != nil || !created {
t.Fatalf("reissue: created=%v err=%v", created, err)
}
if second.ID == first.ID {
t.Fatalf("reissue reused asset id %d", second.ID)
}
// Both rows coexist: the live one is what the name resolves to.
live, err := store.CollectibleUsername(ctx, name)
if err != nil || live.ID != second.ID {
t.Fatalf("lookup after reissue = %+v err=%v, want live asset %d", live, err, second.ID)
}
burned, err := store.CollectibleUsernameByID(ctx, first.ID)
if err != nil || burned.Status != domain.CollectibleUsernameStatusBurned {
t.Fatalf("burned provenance row = %+v err=%v", burned, err)
}
var rowCount int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM collectible_usernames WHERE username_lower = $1`,
lowerASCII(name)).Scan(&rowCount); err != nil {
t.Fatalf("count rows: %v", err)
}
if rowCount != 2 {
t.Fatalf("rows for reissued name = %d, want 2", rowCount)
}
// The live asset still blocks another mint.
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("mint over live asset err = %v, want ErrUsernameOccupied", err)
}
if list := registryRows(t, pool, holder); len(list) != 1 || list[0].CollectibleID != second.ID {
t.Fatalf("holder registry after reissue = %+v", list)
}
}
// TestCollectibleUsernameDelete covers the hard delete: asset, registry row and
// provenance all disappear and the name becomes fully free.
func TestCollectibleUsernameDelete(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 3_600_000_000+seed, "")
name := fmt.Sprintf("mistake%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
editable := fmt.Sprintf("keep%d", seed)
setEditableUsername(t, pool, holder, editable)
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
if err != nil {
t.Fatalf("mint: %v", err)
}
deleted, err := store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "@" + name, Actor: "ops", Reason: "issued by mistake",
})
if err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
if _, err := store.CollectibleUsernameByID(ctx, asset.ID); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("asset after delete err = %v, want not found", err)
}
// ON DELETE CASCADE took the provenance rows with the asset.
var transfers int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM collectible_username_transfers WHERE collectible_id = $1`,
asset.ID).Scan(&transfers); err != nil {
t.Fatalf("count transfers: %v", err)
}
if transfers != 0 {
t.Fatalf("provenance rows after delete = %d, want 0", transfers)
}
// The holder keeps its editable slot and loses only the collectible row.
list := registryRows(t, pool, holder)
if len(list) != 1 || !list[0].Editable || list[0].Username != editable {
t.Fatalf("holder registry after delete = %+v", list)
}
// The name is free again, with no burned history left behind.
if _, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil || !created {
t.Fatalf("mint after delete: created=%v err=%v", created, err)
}
// Deleting a name that has no live asset is a no-op, not an error.
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Burn: true, Actor: "ops", Reason: "retire",
}); err != nil {
t.Fatalf("burn before repeat delete: %v", err)
}
deleted, err = store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: name, Actor: "ops", Reason: "again",
})
if err != nil || deleted {
t.Fatalf("delete of burned-only name = %v err=%v, want (false, nil)", deleted, err)
}
deleted, err = store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: fmt.Sprintf("absent%d", seed), Actor: "ops", Reason: "again",
})
if err != nil || deleted {
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
}
}

View file

@ -8,6 +8,88 @@ import (
"telesrv/internal/domain"
)
func TestReserveUserPtsRejectsZeroBeforeQuery(t *testing.T) {
if _, err := reserveUserPts(context.Background(), nil, 0, 1); err == nil {
t.Fatal("reserveUserPts user=0 succeeded, want fail-fast before DB access")
}
}
// TestAppendAllocatedFirstPtsRangeAndRollback covers both branches of the
// single-statement watermark upsert through a legal durable update. A rolled
// back first allocation must leave no watermark; the committed retry must
// create one range ending at pts=3 with pts_count=3.
func TestAppendAllocatedFirstPtsRangeAndRollback(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 4,
Phone: "+1555" + suffix + "01",
FirstName: "FirstPtsRange",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
event := domain.UpdateEvent{
Type: domain.UpdateEventDeleteMessages,
PtsCount: 3,
Date: 1700000003,
MessageIDs: []int{101, 102, 103},
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin rollback allocation: %v", err)
}
allocated, err := NewUpdateEventStore(tx).AppendAllocated(ctx, owner.ID, event)
if err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("append allocated before rollback: %v", err)
}
if allocated.Pts != 3 || allocated.PtsCount != 3 {
_ = tx.Rollback(ctx)
t.Fatalf("allocated before rollback = pts %d count %d, want 3/3", allocated.Pts, allocated.PtsCount)
}
if err := tx.Rollback(ctx); err != nil {
t.Fatalf("rollback first allocation: %v", err)
}
var rows int
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM user_update_watermarks WHERE user_id=$1`, owner.ID).Scan(&rows); err != nil {
t.Fatalf("count watermark after rollback: %v", err)
}
if rows != 0 {
t.Fatalf("watermark rows after rollback = %d, want 0", rows)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM user_update_events WHERE user_id=$1`, owner.ID).Scan(&rows); err != nil {
t.Fatalf("count events after rollback: %v", err)
}
if rows != 0 {
t.Fatalf("event rows after rollback = %d, want 0", rows)
}
allocated, err = NewUpdateEventStore(pool).AppendAllocated(ctx, owner.ID, event)
if err != nil {
t.Fatalf("append allocated after rollback: %v", err)
}
if allocated.Pts != 3 || allocated.PtsCount != 3 {
t.Fatalf("committed allocation = pts %d count %d, want 3/3", allocated.Pts, allocated.PtsCount)
}
if pts, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, owner.ID); err != nil || pts != 3 {
t.Fatalf("MaxContiguousPts = %d err=%v, want 3", pts, err)
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, 0, 10)
if err != nil {
t.Fatalf("ListAfter: %v", err)
}
if len(events) != 1 || events[0].Pts != 3 || events[0].PtsCount != 3 || len(events[0].MessageIDs) != 3 {
t.Fatalf("events = %+v, want one delete range ending at 3", events)
}
}
// TestAppendRejectsPtsHole 用真实 PG 验证显式 pts 写入不能制造空洞。
func TestAppendRejectsPtsHole(t *testing.T) {
pool := testPool(t)

View file

@ -1,11 +1,13 @@
package postgres
import (
"bytes"
"context"
"encoding/json"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
@ -47,3 +49,69 @@ ON CONFLICT (
}
return tag.RowsAffected() == 1, nil
}
func (s *EphemeralReportStore) ListUnmigratedEphemeralReports(ctx context.Context, limit int) ([]store.LegacyEphemeralReport, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("ephemeral report store is not configured")
}
if limit <= 0 || limit > 1000 {
return nil, fmt.Errorf("legacy ephemeral report batch limit out of range")
}
rows, err := s.db.Query(ctx, `
SELECT r.id, r.reporter_user_id, r.channel_id, r.ephemeral_message_id,
r.sender_user_id, r.receiver_user_id, r.report_option,
r.report_comment, r.comment_hash, r.payload_hash, r.evidence,
r.created_at
FROM ephemeral_abuse_reports r
LEFT JOIN moderation_legacy_ephemeral_migrations m
ON m.legacy_report_id = r.id
WHERE m.legacy_report_id IS NULL
ORDER BY r.id
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("list unmigrated ephemeral reports: %w", err)
}
defer rows.Close()
out := make([]store.LegacyEphemeralReport, 0, limit)
for rows.Next() {
var (
legacy store.LegacyEphemeralReport
channelID, senderUserID, receiverUserID int64
messageID int
commentHash, payloadHash, evidenceRaw []byte
)
if err := rows.Scan(
&legacy.ID, &legacy.Report.ReporterUserID, &channelID,
&messageID, &senderUserID, &receiverUserID,
&legacy.Report.Option, &legacy.Report.Comment, &commentHash,
&payloadHash, &evidenceRaw, &legacy.Report.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan legacy ephemeral report: %w", err)
}
if legacy.ID <= 0 || len(commentHash) != len(legacy.Report.CommentHash) ||
len(payloadHash) != len(legacy.Report.Evidence.PayloadHash) {
return nil, fmt.Errorf("legacy ephemeral report %d has invalid persisted identity", legacy.ID)
}
copy(legacy.Report.CommentHash[:], commentHash)
if err := json.Unmarshal(evidenceRaw, &legacy.Report.Evidence); err != nil {
return nil, fmt.Errorf("decode legacy ephemeral report %d evidence: %w", legacy.ID, err)
}
if legacy.Report.Evidence.Peer.Type != domain.PeerTypeChannel ||
legacy.Report.Evidence.Peer.ID != channelID ||
legacy.Report.Evidence.MessageID != messageID ||
legacy.Report.Evidence.SenderUserID != senderUserID ||
legacy.Report.Evidence.ReceiverUserID != receiverUserID ||
!bytes.Equal(legacy.Report.Evidence.PayloadHash[:], payloadHash) {
return nil, fmt.Errorf("legacy ephemeral report %d evidence disagrees with indexed columns", legacy.ID)
}
if err := legacy.Report.Validate(); err != nil {
return nil, fmt.Errorf("validate legacy ephemeral report %d: %w", legacy.ID, err)
}
out = append(out, legacy)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate legacy ephemeral reports: %w", err)
}
return out, nil
}

View file

@ -45,6 +45,16 @@ func TestSendPrivateTextConcurrentNoPtsGap(t *testing.T) {
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
})
var initialWatermarks int
if err := pool.QueryRow(ctx, `
SELECT count(*)::int
FROM user_update_watermarks
WHERE user_id = ANY($1::bigint[])`, ids).Scan(&initialWatermarks); err != nil {
t.Fatalf("count initial watermarks: %v", err)
}
if initialWatermarks != 0 {
t.Fatalf("initial watermarks = %d, want 0 so concurrency covers first upsert", initialWatermarks)
}
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}))

View file

@ -71,7 +71,7 @@ func (s *MessageStore) DeleteMessages(ctx context.Context, req domain.DeleteMess
}
deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...)
}
res, err = s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, false)
res, err = s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, nil)
if err != nil {
return res, err
}
@ -118,9 +118,53 @@ type deletedOwnerPeerKey struct {
peer domain.Peer
}
func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, ownerUserID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, date int, rows []deletedBox, preserveEmptyDialogs bool) (domain.DeleteMessagesResult, error) {
type historyClearAnchor struct {
userID int64
peer domain.Peer
boxID int
uid int64
messageDate int
materialized bool
}
func (s *MessageStore) loadHistoryClearAnchor(ctx context.Context, q *sqlcgen.Queries, userID int64, peer domain.Peer) (historyClearAnchor, bool, error) {
top, err := q.TopVisibleMessageBoxByPeer(ctx, sqlcgen.TopVisibleMessageBoxByPeerParams{
OwnerUserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if errors.Is(err, pgx.ErrNoRows) {
return historyClearAnchor{}, false, nil
}
if err != nil {
return historyClearAnchor{}, false, fmt.Errorf("load history clear top: %w", err)
}
row, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: userID,
BoxID: top.BoxID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
return historyClearAnchor{}, false, fmt.Errorf("lock history clear top: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return historyClearAnchor{}, false, fmt.Errorf("decode history clear top media: %w", err)
}
return historyClearAnchor{
userID: userID,
peer: peer,
boxID: int(row.BoxID),
uid: row.PrivateMessageID,
messageDate: int(row.MessageDate),
materialized: domain.IsHistoryClearServiceMessage(domain.Message{Media: media}),
}, true, nil
}
func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, ownerUserID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, date int, rows []deletedBox, anchors map[int64]historyClearAnchor) (domain.DeleteMessagesResult, error) {
res := domain.DeleteMessagesResult{OwnerUserID: ownerUserID}
if len(rows) == 0 {
if len(rows) == 0 && len(anchors) == 0 {
return res, nil
}
peersByOwner := make(map[int64]map[domain.Peer]struct{})
@ -145,6 +189,20 @@ func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DB
incomingDeletedByPeer[key][row.boxID] = struct{}{}
}
}
for userID, anchor := range anchors {
if anchor.boxID <= 0 || anchor.peer.ID == 0 {
continue
}
if peersByOwner[userID] == nil {
peersByOwner[userID] = make(map[domain.Peer]struct{})
}
peersByOwner[userID][anchor.peer] = struct{}{}
if !anchor.materialized {
// 首次物化锚点会用一条 max_id=anchor 的真实 read update 覆盖该
// peer 的全部已读校正;不能再为本批删除的 incoming prefix 重复推进。
delete(incomingDeletedByPeer, deletedOwnerPeerKey{userID: userID, peer: anchor.peer})
}
}
// 按 owner 升序重建 dialog使两个反向 deleteX 删与 Y 的会话 / Y 删与 X 的会话)以一致顺序
// 获取 dialog 行锁,配合下方 watermark 的升序推进,彻底避免 delete-delete 之间的 AB-BA 死锁。
rebuildOwners := make([]int64, 0, len(peersByOwner))
@ -154,11 +212,12 @@ func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DB
sort.Slice(rebuildOwners, func(i, j int) bool { return rebuildOwners[i] < rebuildOwners[j] })
for _, userID := range rebuildOwners {
for peer := range peersByOwner[userID] {
// just_clearpreserveEmptyDialogs是请求者的本端语义"清空但保留我这侧
// 空会话"。revoke 反查出的对端并未选择 just_clear其空会话应按普通删除
// 处理(无存活消息则移除 dialog不能也被保留成空会话。
preserve := preserveEmptyDialogs && userID == ownerUserID
if err := rebuildDialogAfterMessageDelete(ctx, q, userID, peer, preserve); err != nil {
if anchor, ok := anchors[userID]; ok && anchor.peer == peer && !anchor.materialized {
// 先分配 edit PTS 并原位转换锚点,再按转换后的 outgoing
// 状态重算 dialog否则会短暂把 incoming anchor 计为未读。
continue
}
if err := rebuildDialogAfterMessageDelete(ctx, q, userID, peer); err != nil {
return res, err
}
}
@ -168,8 +227,17 @@ func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DB
return res, err
}
ownerIDs := make([]int64, 0, len(idsByOwner))
ownerSet := make(map[int64]struct{}, len(idsByOwner)+len(anchors))
for userID := range idsByOwner {
ownerSet[userID] = struct{}{}
}
for userID, anchor := range anchors {
if !anchor.materialized {
ownerSet[userID] = struct{}{}
}
}
ownerIDs := make([]int64, 0, len(ownerSet))
for userID := range ownerSet {
ownerIDs = append(ownerIDs, userID)
}
sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] })
@ -177,36 +245,56 @@ func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DB
res.Deleted = make([]domain.DeletedMessagesForUser, 0, len(ownerIDs))
for _, userID := range ownerIDs {
ids := normalizeMessageIDs(idsByOwner[userID])
if len(ids) == 0 {
corrections := readCorrectionsByOwner[userID]
anchor, hasAnchor := anchors[userID]
materializeAnchor := hasAnchor && !anchor.materialized
totalPtsCount := len(ids) + len(corrections)
if materializeAnchor {
totalPtsCount += 2 // updateReadHistoryInbox + updateEditMessage
}
if totalPtsCount == 0 {
continue
}
corrections := readCorrectionsByOwner[userID]
totalPtsCount := len(ids) + len(corrections)
pts, err := s.reservePtsN(ctx, db, userID, totalPtsCount)
if err != nil {
return res, fmt.Errorf("allocate delete messages pts: %w", err)
}
deletePts := pts - len(corrections)
event := domain.UpdateEvent{
cursor := pts - totalPtsCount
item := domain.DeletedMessagesForUser{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
Pts: deletePts,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
Pts: pts,
PtsCount: totalPtsCount,
Events: make([]domain.UpdateEvent, 0, 1+len(corrections)+2),
}
deleteIDsJSON, err := encodeEventMessageIDs(event.MessageIDs)
if err != nil {
return res, fmt.Errorf("encode sender delete receipt ids: %w", err)
dispatchAuthKeyID := [8]byte{}
dispatchSessionID := int64(0)
if userID == ownerUserID {
dispatchAuthKeyID = excludeAuthKeyID
dispatchSessionID = excludeSessionID
}
senderPrivateIDs := make([]int64, 0, len(rows))
for _, row := range rows {
if row.ownerUserID == userID && row.messageSenderID == userID && row.privateMessageID != 0 {
senderPrivateIDs = append(senderPrivateIDs, row.privateMessageID)
if len(ids) > 0 {
cursor += len(ids)
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
Pts: cursor,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
}
}
if len(senderPrivateIDs) > 0 {
if _, err := db.Exec(ctx, `
deleteIDsJSON, err := encodeEventMessageIDs(event.MessageIDs)
if err != nil {
return res, fmt.Errorf("encode sender delete receipt ids: %w", err)
}
senderPrivateIDs := make([]int64, 0, len(rows))
for _, row := range rows {
if row.ownerUserID == userID && row.messageSenderID == userID && row.privateMessageID != 0 {
senderPrivateIDs = append(senderPrivateIDs, row.privateMessageID)
}
}
if len(senderPrivateIDs) > 0 {
if _, err := db.Exec(ctx, `
UPDATE private_messages
SET sender_delete_pts = $3,
sender_delete_pts_count = $4,
@ -215,34 +303,27 @@ SET sender_delete_pts = $3,
WHERE sender_user_id = $1
AND id = ANY($2::bigint[])
AND sender_box_id > 0`, userID, senderPrivateIDs, event.Pts, event.PtsCount, event.Date, deleteIDsJSON); err != nil {
return res, fmt.Errorf("save sender delete replay receipt: %w", err)
return res, fmt.Errorf("save sender delete replay receipt: %w", err)
}
}
if err := appendDeleteMessagesEvent(ctx, q, event); err != nil {
return res, err
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(event.Pts),
EventType: string(domain.UpdateEventDeleteMessages),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID),
ExcludeSessionID: dispatchSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue delete messages dispatch: %w", err)
}
item.Event = event
item.Events = append(item.Events, event)
}
if err := appendDeleteMessagesEvent(ctx, q, event); err != nil {
return res, err
}
dispatchAuthKeyID := [8]byte{}
dispatchSessionID := int64(0)
if userID == ownerUserID {
dispatchAuthKeyID = excludeAuthKeyID
dispatchSessionID = excludeSessionID
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(deletePts),
EventType: string(domain.UpdateEventDeleteMessages),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID),
ExcludeSessionID: dispatchSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue delete messages dispatch: %w", err)
}
res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{
UserID: userID,
MessageIDs: ids,
Event: event,
})
for i, correction := range corrections {
correction.Pts = deletePts + i + 1
for _, correction := range corrections {
cursor++
correction.Pts = cursor
if err := appendUserUpdateEvent(ctx, db, q, userID, correction); err != nil {
return res, fmt.Errorf("append delete unread correction event: %w", err)
}
@ -263,11 +344,142 @@ WHERE sender_user_id = $1
}); err != nil {
return res, fmt.Errorf("enqueue delete unread correction dispatch: %w", err)
}
item.Events = append(item.Events, correction)
}
if materializeAnchor {
readPts := cursor + 1
editPts := readPts + 1
msg, err := materializeHistoryClearAnchorTx(ctx, db, anchor, editPts)
if err != nil {
return res, err
}
if err := rebuildDialogAfterMessageDelete(ctx, q, userID, anchor.peer); err != nil {
return res, err
}
readEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventReadHistoryInbox,
Pts: readPts,
PtsCount: 1,
Date: date,
Peer: anchor.peer,
MaxID: anchor.boxID,
StillUnreadCount: 0,
}
if err := appendUserUpdateEvent(ctx, db, q, userID, readEvent); err != nil {
return res, fmt.Errorf("append history clear read event: %w", err)
}
if err := q.AdvanceDialogReadInboxFloor(ctx, sqlcgen.AdvanceDialogReadInboxFloorParams{
UserID: userID,
PeerType: string(anchor.peer.Type),
PeerID: anchor.peer.ID,
ReadInboxMaxID: int32(anchor.boxID),
}); err != nil {
return res, fmt.Errorf("advance history clear read inbox: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(readPts),
EventType: string(domain.UpdateEventReadHistoryInbox),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID),
ExcludeSessionID: dispatchSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue history clear read dispatch: %w", err)
}
editEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventEditMessage,
Pts: editPts,
PtsCount: 1,
Date: date,
Message: msg,
}
if err := appendUserUpdateEvent(ctx, db, q, userID, editEvent); err != nil {
return res, fmt.Errorf("append history clear edit event: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(editPts),
EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID),
ExcludeSessionID: dispatchSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue history clear edit dispatch: %w", err)
}
item.Events = append(item.Events, readEvent, editEvent)
cursor = editPts
}
if cursor != pts {
return res, fmt.Errorf("delete history pts cursor %d does not reach reserved pts %d", cursor, pts)
}
res.Deleted = append(res.Deleted, item)
}
return res, nil
}
func materializeHistoryClearAnchorTx(ctx context.Context, db sqlcgen.DBTX, anchor historyClearAnchor, pts int) (domain.Message, error) {
msg := domain.NewHistoryClearMessage(anchor.userID, anchor.peer, anchor.boxID, anchor.uid, anchor.messageDate, pts)
mediaJSON, err := encodeMessageMedia(msg.Media)
if err != nil {
return domain.Message{}, fmt.Errorf("encode history clear media: %w", err)
}
tag, err := db.Exec(ctx, `
UPDATE message_boxes
SET from_user_id = $3,
ttl_period = 0,
expires_at = 0,
edit_date = 0,
hide_edited = false,
outgoing = true,
body = '',
entities = '[]'::jsonb,
silent = false,
noforwards = false,
reply_to_msg_id = 0,
reply_to_peer_type = '',
reply_to_peer_id = 0,
reply_to_top_id = 0,
reply_to_story_id = 0,
quote_text = '',
quote_entities = '[]'::jsonb,
quote_offset = 0,
fwd_from_peer_type = '',
fwd_from_peer_id = 0,
fwd_from_name = '',
fwd_date = 0,
fwd_saved_from_peer_type = '',
fwd_saved_from_peer_id = 0,
fwd_saved_from_msg_id = 0,
saved_peer_type = '',
saved_peer_id = 0,
pts = $4,
media = $5::jsonb,
media_unread = false,
reaction_unread = false,
pinned = false,
via_bot_id = 0,
grouped_id = 0,
effect = 0,
reply_markup = '{}'::jsonb,
rich_message = '{}'::jsonb
WHERE owner_user_id = $1
AND box_id = $2
AND NOT deleted`, anchor.userID, int32(anchor.boxID), anchor.userID, int32(pts), mediaJSON)
if err != nil {
return domain.Message{}, fmt.Errorf("materialize history clear anchor: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.Message{}, fmt.Errorf("materialize history clear anchor: box %d disappeared", anchor.boxID)
}
if _, err := db.Exec(ctx, `DELETE FROM message_box_media WHERE owner_user_id = $1 AND box_id = $2`, anchor.userID, int32(anchor.boxID)); err != nil {
return domain.Message{}, fmt.Errorf("delete history clear media index: %w", err)
}
if _, err := db.Exec(ctx, `DELETE FROM saved_message_reaction_tags WHERE user_id = $1 AND message_box_id = $2`, anchor.userID, int32(anchor.boxID)); err != nil {
return domain.Message{}, fmt.Errorf("delete history clear saved tags: %w", err)
}
return msg, nil
}
func maxDeletedMessageID(ids map[int]struct{}) int {
maxID := 0
for id := range ids {
@ -278,23 +490,13 @@ func maxDeletedMessageID(ids map[int]struct{}) int {
return maxID
}
func rebuildDialogAfterMessageDelete(ctx context.Context, q *sqlcgen.Queries, userID int64, peer domain.Peer, preserveEmpty bool) error {
func rebuildDialogAfterMessageDelete(ctx context.Context, q *sqlcgen.Queries, userID int64, peer domain.Peer) error {
top, err := q.TopVisibleMessageBoxByPeer(ctx, sqlcgen.TopVisibleMessageBoxByPeerParams{
OwnerUserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if errors.Is(err, pgx.ErrNoRows) {
if preserveEmpty {
if err := q.ClearDialogAfterHistoryDelete(ctx, sqlcgen.ClearDialogAfterHistoryDeleteParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
}); err != nil {
return fmt.Errorf("clear empty dialog after history delete: %w", err)
}
return nil
}
if err := q.DeleteDialogByPeer(ctx, sqlcgen.DeleteDialogByPeerParams{
UserID: userID,
PeerType: string(peer.Type),

View file

@ -421,7 +421,7 @@ func TestMessageStoreDeleteHistoryRebuildsDialogAndEmitsDeleteUpdates(t *testing
}
}
func TestMessageStoreDeleteHistoryJustClearPreservesEmptyDialog(t *testing.T) {
func TestMessageStoreDeleteHistoryJustClearPreservesHistoryClearMessage(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
@ -444,31 +444,147 @@ func TestMessageStoreDeleteHistoryJustClearPreservesEmptyDialog(t *testing.T) {
t.Fatalf("seed send: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
JustClear: true,
Date: 1700001200,
}); err != nil {
clearResult, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
JustClear: true,
Date: 1700001200,
OriginAuthKeyID: [8]byte{7},
OriginSessionID: 99,
})
if err != nil {
t.Fatalf("DeleteHistory just_clear: %v", err)
}
self := clearResult.Self()
if self.PtsCount != 2 || len(self.MessageIDs) != 0 || len(self.Events) != 2 ||
self.Events[0].Type != domain.UpdateEventReadHistoryInbox ||
self.Events[1].Type != domain.UpdateEventEditMessage {
t.Fatalf("clear result = %+v, want read+edit", self)
}
dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("dialogs after just_clear: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer != peer || dialogs.Dialogs[0].TopMessage != 0 || len(dialogs.Messages) != 0 {
t.Fatalf("dialogs = %+v messages=%+v, want empty dialog preserved after just_clear", dialogs.Dialogs, dialogs.Messages)
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer != peer ||
dialogs.Dialogs[0].TopMessage == 0 || len(dialogs.Messages) != 1 ||
!domain.IsHistoryClearServiceMessage(dialogs.Messages[0]) {
t.Fatalf("dialogs = %+v messages=%+v, want real history-clear top", dialogs.Dialogs, dialogs.Messages)
}
history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true})
if err != nil {
t.Fatalf("history after just_clear: %v", err)
}
if len(history.Messages) != 0 {
t.Fatalf("history = %+v, want cleared", history.Messages)
if len(history.Messages) != 1 || history.Messages[0].ID != dialogs.Dialogs[0].TopMessage ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) ||
!history.Messages[0].Out || history.Messages[0].From.ID != owner.ID ||
history.Messages[0].Body != "" || history.Messages[0].MediaUnread ||
history.Messages[0].ReactionUnread || history.Messages[0].Pinned {
t.Fatalf("history = %+v, want clean owner-local history-clear anchor", history.Messages)
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, self.Pts-2, 10)
if err != nil {
t.Fatalf("list clear events: %v", err)
}
if len(events) != 2 || events[0].Type != domain.UpdateEventReadHistoryInbox ||
events[1].Type != domain.UpdateEventEditMessage ||
!domain.IsHistoryClearServiceMessage(events[1].Message) {
t.Fatalf("durable clear events = %+v, want read/edit with service message", events)
}
var outboxRows int
if err := pool.QueryRow(ctx, `
SELECT count(*)::int
FROM dispatch_outbox
WHERE target_user_id = $1
AND pts = ANY($2::int[])
AND exclude_auth_key_id = $3
AND exclude_session_id = $4`,
owner.ID, []int32{int32(self.Pts - 1), int32(self.Pts)}, int64(7), int64(99)).Scan(&outboxRows); err != nil {
t.Fatalf("count clear outbox: %v", err)
}
if outboxRows != 2 {
t.Fatalf("clear outbox rows = %d, want read/edit excluding origin session", outboxRows)
}
repeated, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID, Peer: peer, JustClear: true, Date: 1700001201,
})
if err != nil {
t.Fatalf("repeat just_clear: %v", err)
}
if repeated.Changed() || len(repeated.Deleted) != 0 {
t.Fatalf("repeat just_clear = %+v, want idempotent no-op", repeated)
}
}
func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) {
func TestMessageStoreDeleteHistoryJustClearRevokeKeepsPerOwnerAnchors(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
alice := createTestUser(t, ctx, users, "+1994"+suffix+"01", "ClearAlice", "")
bob := createTestUser(t, ctx, users, "+1994"+suffix+"02", "ClearBob", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID})
})
messages := NewMessageStore(pool)
var last domain.SendPrivateTextResult
for i := 0; i < 2; i++ {
var err error
last, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice.ID, RecipientUserID: bob.ID, RandomID: int64(9200 + i),
Message: "clear for both", Date: 1700001300 + i,
})
if err != nil {
t.Fatalf("seed send %d: %v", i, err)
}
}
res, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: alice.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
JustClear: true,
Revoke: true,
Date: 1700001400,
})
if err != nil {
t.Fatalf("revoke just_clear: %v", err)
}
if len(res.Deleted) != 2 {
t.Fatalf("deleted owners = %+v, want alice and bob", res.Deleted)
}
for _, tc := range []struct {
userID int64
peerID int64
topID int
}{
{alice.ID, bob.ID, last.SenderMessage.ID},
{bob.ID, alice.ID, last.RecipientMessage.ID},
} {
history, err := messages.ListByUser(ctx, tc.userID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: tc.peerID}, Limit: 10,
})
if err != nil {
t.Fatalf("history user %d: %v", tc.userID, err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != tc.topID ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) ||
!history.Messages[0].Out || history.Messages[0].From.ID != tc.userID {
t.Fatalf("history user %d = %+v, want owner-local anchor %d", tc.userID, history.Messages, tc.topID)
}
var ownerResult domain.DeletedMessagesForUser
for _, item := range res.Deleted {
if item.UserID == tc.userID {
ownerResult = item
break
}
}
if ownerResult.PtsCount != 3 || len(ownerResult.MessageIDs) != 1 ||
len(ownerResult.Events) != 3 {
t.Fatalf("owner %d result = %+v, want delete/read/edit", tc.userID, ownerResult)
}
}
}
func TestMessageStoreDeleteHistoryJustClearKeepsAnchorAcrossBatches(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
@ -566,34 +682,47 @@ func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) {
first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
MaxID: domain.MaxMessageBoxID,
JustClear: true,
Date: 1700003000,
})
if err != nil {
t.Fatalf("DeleteHistory first batch: %v", err)
}
self := first.Self()
if first.Offset != 1 || self.Event.Pts != domain.MaxDeleteHistoryBatch || self.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(self.MessageIDs) != domain.MaxDeleteHistoryBatch {
t.Fatalf("first batch = %+v self=%+v, want offset=1 and exactly %d deleted ids", first, self, domain.MaxDeleteHistoryBatch)
if first.Offset != 1 || self.Event.Pts != domain.MaxDeleteHistoryBatch ||
self.Event.PtsCount != domain.MaxDeleteHistoryBatch ||
self.Pts != domain.MaxDeleteHistoryBatch+2 || self.PtsCount != domain.MaxDeleteHistoryBatch+2 ||
len(self.MessageIDs) != domain.MaxDeleteHistoryBatch || len(self.Events) != 3 {
t.Fatalf("first batch = %+v self=%+v, want delete batch plus read/edit", first, self)
}
history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true})
if err != nil {
t.Fatalf("history after first batch: %v", err)
}
if history.Count != 2 || len(history.Messages) != 2 || history.Messages[0].ID != 2 {
t.Fatalf("history after first batch = %+v, want only two oldest messages left", history)
if history.Count != 2 || len(history.Messages) != 2 || history.Messages[0].ID != total ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) || history.Messages[1].ID != 1 {
t.Fatalf("history after first batch = %+v, want stable top anchor plus oldest message", history)
}
second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
MaxID: domain.MaxMessageBoxID,
JustClear: true,
Date: 1700003001,
})
if err != nil {
t.Fatalf("DeleteHistory second batch: %v", err)
}
if second.Offset != 0 || second.Self().Event.PtsCount != 2 {
t.Fatalf("second batch = %+v, want final offset=0 pts_count=2", second)
if second.Offset != 0 || second.Self().Event.PtsCount != 1 ||
second.Self().PtsCount != 1 || len(second.Self().Events) != 1 {
t.Fatalf("second batch = %+v, want final old-message delete only", second)
}
history, err = messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true})
if err != nil {
t.Fatalf("history after second batch: %v", err)
}
if history.Count != 1 || len(history.Messages) != 1 || history.Messages[0].ID != total ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) {
t.Fatalf("history after second batch = %+v, want only stable history-clear anchor", history)
}
}

View file

@ -33,6 +33,13 @@ func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.Fo
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
protected, err := s.privateNoForwardsEnabled(ctx, req.OwnerUserID, req.FromPeer.ID)
if err != nil {
return res, err
}
if protected {
return res, domain.ErrChatForwardsRestricted
}
boxIDs := make([]int32, 0, len(req.MessageIDs))
for i, id := range req.MessageIDs {
if id <= 0 || id > domain.MaxMessageBoxID || req.RandomIDs[i] == 0 {

View file

@ -4,10 +4,12 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
"time"
)
func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) {
@ -92,6 +94,7 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
savedPeerType = string(filter.SavedPeer.Type)
savedPeerID = filter.SavedPeer.ID
}
savedReactionKeys := postgresSavedReactionKeys(filter.SavedReactions)
// add_offset>=0 是 backward 热路径(初始加载/上滑翻页,占 getHistory 绝大多数)。
// 走扁平静态查询 ListMessagesBackward:规划仅单 index scan + 2 LEFT JOIN,避免
// ListMessagesByUser 大 CTE 把 4 个分支+total 全树规划(6.7ms→~1ms)。与 CTE
@ -100,23 +103,26 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
var rows []sqlcgen.ListMessagesByUserRow
if addOffset >= 0 {
bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
OffsetID: pgInt32NonNegative(filter.OffsetID),
RowOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MinDate: pgInt32NonNegative(filter.MinDate),
MaxDate: pgInt32NonNegative(filter.MaxDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
SavedReactionKeys: savedReactionKeys,
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
OffsetID: pgInt32NonNegative(filter.OffsetID),
RowOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("list messages (backward): %w", err)
@ -127,19 +133,22 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
}
if filter.NeedTotalCount {
total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MinDate: pgInt32NonNegative(filter.MinDate),
MaxDate: pgInt32NonNegative(filter.MaxDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
SavedReactionKeys: savedReactionKeys,
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("count messages: %w", err)
@ -153,24 +162,27 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
} else {
var err error
rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
AddOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
NeedTotalCount: filter.NeedTotalCount,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MinDate: pgInt32NonNegative(filter.MinDate),
MaxDate: pgInt32NonNegative(filter.MaxDate),
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
AddOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
NeedTotalCount: filter.NeedTotalCount,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
SavedReactionKeys: savedReactionKeys,
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("list messages: %w", err)
@ -273,6 +285,16 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
return out, nil
}
func postgresSavedReactionKeys(reactions []domain.MessageReaction) []string {
out := make([]string, 0, len(reactions))
for _, reaction := range reactions {
if reaction.Valid() {
out = append(out, string(reaction.Type)+":"+reaction.Value())
}
}
return out
}
func (s *MessageStore) ReadHistory(ctx context.Context, req domain.ReadHistoryRequest) (res domain.ReadHistoryResult, err error) {
res = domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID}
if req.OwnerUserID == 0 {
@ -498,10 +520,36 @@ func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHisto
maxID := pgInt32NonNegative(req.MaxID)
minDate := pgInt32NonNegative(req.MinDate)
maxDate := pgInt32NonNegative(req.MaxDate)
var anchors map[int64]historyClearAnchor
// TDesktop 的完整 Clear history 形态固定为 just_clear + max_id=0 且
// 不带日期范围;按日期删除只销毁选中区间,不在本地生成 history-clear
// 服务消息。只有完整清空才跨批保留 top 锚点。
fullJustClear := req.JustClear && req.MaxID <= 0 && req.MinDate <= 0 && req.MaxDate <= 0
if fullJustClear {
anchors = make(map[int64]historyClearAnchor, 2)
if anchor, found, err := s.loadHistoryClearAnchor(ctx, qtx, req.OwnerUserID, req.Peer); err != nil {
return res, err
} else if found {
anchors[req.OwnerUserID] = anchor
}
if req.Revoke && req.Peer.ID != req.OwnerUserID {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}
if anchor, found, err := s.loadHistoryClearAnchor(ctx, qtx, req.Peer.ID, peer); err != nil {
return res, err
} else if found {
anchors[req.Peer.ID] = anchor
}
}
}
ownerKeepBoxID := int32(0)
if anchor, ok := anchors[req.OwnerUserID]; ok {
ownerKeepBoxID = int32(anchor.boxID)
}
rows, err := qtx.DeleteMessageBoxesByPeerBatch(ctx, sqlcgen.DeleteMessageBoxesByPeerBatchParams{
OwnerUserID: req.OwnerUserID,
PeerType: string(req.Peer.Type),
PeerID: req.Peer.ID,
KeepBoxID: ownerKeepBoxID,
MaxID: maxID,
MinDate: minDate,
MaxDate: maxDate,
@ -512,7 +560,10 @@ func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHisto
}
deleted := deletedRowsFromPeerBatchRows(rows)
if req.Revoke {
if len(deleted) > 0 {
// max_id>0 是 owner-local box 边界,只能通过逻辑 private-message
// 映射删除对端副本。max_id=0 则双方按同一日期范围各扫一批,避免
// linked delete 与双方保留锚点互相删除。
if req.MaxID > 0 && len(deleted) > 0 {
peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted))
if err != nil {
return res, fmt.Errorf("delete revoked private history boxes: %w", err)
@ -525,10 +576,15 @@ func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHisto
// 适用同一区间box_id 上限则是 owner 私有序无法映射,部分
// max_id 清史保持反查模型(官方 UI 无此入口)。
if req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID {
peerKeepBoxID := int32(0)
if anchor, ok := anchors[req.Peer.ID]; ok {
peerKeepBoxID = int32(anchor.boxID)
}
peerSideRows, err := qtx.DeleteMessageBoxesByPeerBatch(ctx, sqlcgen.DeleteMessageBoxesByPeerBatchParams{
OwnerUserID: req.Peer.ID,
PeerType: string(domain.PeerTypeUser),
PeerID: req.OwnerUserID,
KeepBoxID: peerKeepBoxID,
MaxID: 0,
MinDate: minDate,
MaxDate: maxDate,
@ -540,7 +596,7 @@ func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHisto
deleted = append(deleted, deletedRowsFromPeerBatchRows(peerSideRows)...)
}
}
res, err = s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, req.JustClear)
res, err = s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, anchors)
if err != nil {
return res, err
}
@ -548,6 +604,7 @@ func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHisto
OwnerUserID: req.OwnerUserID,
PeerType: string(req.Peer.Type),
PeerID: req.Peer.ID,
KeepBoxID: ownerKeepBoxID,
MaxID: maxID,
MinDate: minDate,
MaxDate: maxDate,
@ -556,10 +613,15 @@ func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHisto
return res, fmt.Errorf("check remaining history after delete: %w", err)
}
if !more && req.Revoke && req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID {
peerKeepBoxID := int32(0)
if anchor, ok := anchors[req.Peer.ID]; ok {
peerKeepBoxID = int32(anchor.boxID)
}
more, err = qtx.HasDeletableMessageBoxByPeer(ctx, sqlcgen.HasDeletableMessageBoxByPeerParams{
OwnerUserID: req.Peer.ID,
PeerType: string(domain.PeerTypeUser),
PeerID: req.OwnerUserID,
KeepBoxID: peerKeepBoxID,
MaxID: 0,
MinDate: minDate,
MaxDate: maxDate,

View file

@ -0,0 +1,248 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
var errPrivateNoForwardsNoop = errors.New("private no forwards no-op")
func pgNoForwardsPair(a, b int64) (low, high int64, ok bool) {
if a <= 0 || b <= 0 || a == b {
return 0, 0, false
}
if a > b {
a, b = b, a
}
return a, b, true
}
func (s *MessageStore) GetPrivateNoForwards(ctx context.Context, viewerUserID, peerUserID int64) (domain.PrivateNoForwardsState, error) {
low, high, ok := pgNoForwardsPair(viewerUserID, peerUserID)
if !ok {
return domain.PrivateNoForwardsState{}, domain.ErrMessageIDInvalid
}
state := domain.PrivateNoForwardsState{UserLowID: low, UserHighID: high}
err := s.db.QueryRow(ctx, `
SELECT COALESCE(enabled_by_user_id, 0)
FROM private_no_forwards_chats
WHERE user_low_id = $1 AND user_high_id = $2`, low, high).Scan(&state.EnabledByUserID)
if errors.Is(err, pgx.ErrNoRows) {
return state, nil
}
if err != nil {
return domain.PrivateNoForwardsState{}, fmt.Errorf("get private no forwards: %w", err)
}
return state, nil
}
func (s *MessageStore) TogglePrivateNoForwards(ctx context.Context, req domain.TogglePrivateNoForwardsRequest) (domain.TogglePrivateNoForwardsResult, error) {
low, high, ok := pgNoForwardsPair(req.ActorUserID, req.PeerUserID)
if !ok || req.RequestMsgID < 0 || req.RequestMsgID > domain.MaxMessageBoxID {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if req.RandomID == 0 {
req.RandomID = time.Now().UnixNano()
if req.RandomID == 0 {
req.RandomID = 1
}
}
state := domain.PrivateNoForwardsState{UserLowID: low, UserHighID: high}
actionKind := domain.MessageServiceActionNoForwardsToggle
action := domain.MessageNoForwardsAction{}
var answeredRequestSenderID, answeredRequestMessageID int64
sendReq := domain.SendPrivateTextRequest{
SenderUserID: req.ActorUserID,
RecipientUserID: req.PeerUserID,
RandomID: req.RandomID,
Silent: true,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
// A non-empty placeholder is required before the send transaction starts.
// The pair-locked before-hook replaces it with the authoritative action.
Media: noForwardsServiceMedia(actionKind, action),
}
if req.RequestMsgID != 0 {
sendReq.ReplyTo = &domain.MessageReply{
MessageID: req.RequestMsgID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.PeerUserID},
}
}
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
if _, err := tx.Exec(ctx, `
INSERT INTO private_no_forwards_chats (user_low_id, user_high_id)
VALUES ($1, $2)
ON CONFLICT (user_low_id, user_high_id) DO NOTHING`, low, high); err != nil {
return fmt.Errorf("ensure private no forwards state: %w", err)
}
if err := tx.QueryRow(ctx, `
SELECT COALESCE(enabled_by_user_id, 0)
FROM private_no_forwards_chats
WHERE user_low_id = $1 AND user_high_id = $2
FOR UPDATE`, low, high).Scan(&state.EnabledByUserID); err != nil {
return fmt.Errorf("lock private no forwards state: %w", err)
}
previousEnabled := state.Enabled()
actionKind = domain.MessageServiceActionNoForwardsToggle
action = domain.MessageNoForwardsAction{}
if req.RequestMsgID != 0 {
var expiresAt, handledAt int
err := tx.QueryRow(ctx, `
SELECT r.private_message_sender_user_id, r.private_message_id, r.expires_at, r.handled_at
FROM message_boxes AS b
JOIN private_no_forwards_requests AS r
ON r.private_message_sender_user_id = b.message_sender_id
AND r.private_message_id = b.private_message_id
WHERE b.owner_user_id = $1
AND b.box_id = $2
AND b.peer_type = 'user'
AND b.peer_id = $3
AND r.requester_user_id = $3
AND r.responder_user_id = $1
FOR UPDATE OF r`,
req.ActorUserID, req.RequestMsgID, req.PeerUserID,
).Scan(&answeredRequestSenderID, &answeredRequestMessageID, &expiresAt, &handledAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrNoForwardsRequestExpired
}
if err != nil {
return fmt.Errorf("lock private no forwards request: %w", err)
}
if handledAt != 0 || expiresAt <= req.Date {
return domain.ErrNoForwardsRequestExpired
}
action = domain.MessageNoForwardsAction{PrevValue: previousEnabled, NewValue: req.Enabled}
if req.Enabled {
state.EnabledByUserID = req.ActorUserID
} else {
state.EnabledByUserID = 0
}
if _, err := tx.Exec(ctx, `
UPDATE private_no_forwards_requests
SET handled_at = $3
WHERE private_message_sender_user_id = $1
AND private_message_id = $2
AND handled_at = 0`,
answeredRequestSenderID, answeredRequestMessageID, req.Date,
); err != nil {
return fmt.Errorf("handle private no forwards request: %w", err)
}
if err := expirePGNoForwardsRequest(ctx, tx, answeredRequestSenderID, answeredRequestMessageID); err != nil {
return err
}
} else if req.Enabled {
if state.EnabledByUserID != 0 {
return errPrivateNoForwardsNoop
}
action = domain.MessageNoForwardsAction{PrevValue: false, NewValue: true}
state.EnabledByUserID = req.ActorUserID
} else {
switch state.EnabledByUserID {
case 0:
return errPrivateNoForwardsNoop
case req.ActorUserID:
action = domain.MessageNoForwardsAction{PrevValue: true, NewValue: false}
state.EnabledByUserID = 0
default:
actionKind = domain.MessageServiceActionNoForwardsRequest
action = domain.MessageNoForwardsAction{
PrevValue: true,
NewValue: false,
ExpiresAt: req.Date + domain.PrivateNoForwardsRequestExpirePeriod,
}
}
}
var enabledBy any
if state.EnabledByUserID != 0 {
enabledBy = state.EnabledByUserID
}
if _, err := tx.Exec(ctx, `
UPDATE private_no_forwards_chats
SET enabled_by_user_id = $3, updated_at = now()
WHERE user_low_id = $1 AND user_high_id = $2`, low, high, enabledBy); err != nil {
return fmt.Errorf("update private no forwards state: %w", err)
}
send.Media = noForwardsServiceMedia(actionKind, action)
return nil
},
after: func(ctx context.Context, tx pgx.Tx, result domain.SendPrivateTextResult) error {
if actionKind != domain.MessageServiceActionNoForwardsRequest {
return nil
}
if _, err := tx.Exec(ctx, `
INSERT INTO private_no_forwards_requests (
private_message_sender_user_id,
private_message_id,
requester_user_id,
responder_user_id,
expires_at
) VALUES ($1, $2, $3, $4, $5)`,
req.ActorUserID, result.SenderMessage.UID, req.ActorUserID, req.PeerUserID, action.ExpiresAt,
); err != nil {
return fmt.Errorf("create private no forwards request: %w", err)
}
return nil
},
}
send, err := s.sendPrivateTextWithHooks(ctx, sendReq, hooks)
if errors.Is(err, errPrivateNoForwardsNoop) {
return domain.TogglePrivateNoForwardsResult{State: state}, nil
}
if errors.Is(err, domain.ErrReplyMessageIDInvalid) {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrNoForwardsRequestExpired
}
if err != nil {
return domain.TogglePrivateNoForwardsResult{}, err
}
return domain.TogglePrivateNoForwardsResult{State: state, Changed: true, Send: send}, nil
}
func noForwardsServiceMedia(kind domain.MessageServiceActionKind, action domain.MessageNoForwardsAction) *domain.MessageMedia {
return &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: kind,
NoForwards: &action,
},
}
}
func expirePGNoForwardsRequest(ctx context.Context, tx pgx.Tx, senderUserID, privateMessageID int64) error {
for _, statement := range []string{
`UPDATE private_messages
SET media = jsonb_set(media, '{service_action,no_forwards,expired}', 'true'::jsonb, true)
WHERE sender_user_id = $1 AND id = $2`,
`UPDATE message_boxes
SET media = jsonb_set(media, '{service_action,no_forwards,expired}', 'true'::jsonb, true)
WHERE message_sender_id = $1 AND private_message_id = $2`,
} {
tag, err := tx.Exec(ctx, statement, senderUserID, privateMessageID)
if err != nil {
return fmt.Errorf("expire private no forwards request: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("expire private no forwards request: message disappeared")
}
}
return nil
}
func (s *MessageStore) privateNoForwardsEnabled(ctx context.Context, a, b int64) (bool, error) {
state, err := s.GetPrivateNoForwards(ctx, a, b)
return state.Enabled(), err
}

View file

@ -0,0 +1,229 @@
package postgres
import (
"context"
"errors"
"sync"
"testing"
"time"
"telesrv/internal/domain"
)
func TestPostgresPrivateNoForwardsAtomicStateAndDifference(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
alice, err := users.Create(ctx, domain.User{AccessHash: 6101, Phone: "+1668" + suffix + "01", FirstName: "Alice"})
if err != nil {
t.Fatal(err)
}
bob, err := users.Create(ctx, domain.User{AccessHash: 6102, Phone: "+1668" + suffix + "02", FirstName: "Bob"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID})
})
messages := NewMessageStore(pool)
baseRandom := time.Now().UnixNano()
enable, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice.ID, PeerUserID: bob.ID, Enabled: true, RandomID: baseRandom, Date: 1700100000,
})
if err != nil {
t.Fatalf("enable: %v", err)
}
request, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: bob.ID, PeerUserID: alice.ID, RandomID: baseRandom + 1, Date: 1700100001,
})
if err != nil {
t.Fatalf("request: %v", err)
}
answer, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice.ID, PeerUserID: bob.ID, RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: baseRandom + 2, Date: 1700100002,
})
if err != nil {
t.Fatalf("answer: %v", err)
}
if enable.Send.SenderMessage.Pts != 1 || request.Send.SenderMessage.Pts != 2 ||
answer.Send.SenderMessage.Pts != 3 || answer.Send.SenderMessage.ReplyTo == nil ||
answer.Send.SenderMessage.ReplyTo.MessageID != request.Send.RecipientMessage.ID ||
answer.Send.RecipientMessage.ReplyTo == nil ||
answer.Send.RecipientMessage.ReplyTo.MessageID != request.Send.SenderMessage.ID {
t.Fatalf("pts/reply mapping enable=%+v request=%+v answer=%+v", enable.Send, request.Send, answer.Send)
}
state, err := messages.GetPrivateNoForwards(ctx, alice.ID, bob.ID)
if err != nil || state.Enabled() {
t.Fatalf("final state=%+v err=%v, want disabled", state, err)
}
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice.ID, PeerUserID: bob.ID, RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: baseRandom + 3, Date: 1700100003,
}); !errors.Is(err, domain.ErrNoForwardsRequestExpired) {
t.Fatalf("repeat answer err=%v", err)
}
for _, userID := range []int64{alice.ID, bob.ID} {
events, err := NewUpdateEventStore(pool).ListAfter(ctx, userID, 0, 10)
if err != nil {
t.Fatalf("events user %d: %v", userID, err)
}
if len(events) != 3 || events[0].Pts != 1 || events[1].Pts != 2 || events[2].Pts != 3 {
t.Fatalf("events user %d = %+v, want continuous 1..3", userID, events)
}
}
var eventCount, outboxCount int
if err := pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM user_update_events WHERE user_id = ANY($1::bigint[])),
(SELECT count(*) FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[]))`,
[]int64{alice.ID, bob.ID},
).Scan(&eventCount, &outboxCount); err != nil {
t.Fatal(err)
}
if eventCount != 6 || outboxCount != 6 {
t.Fatalf("event/outbox count=%d/%d, want 6/6", eventCount, outboxCount)
}
var handledAt int
var logicalExpired bool
var expiredBoxes int
if err := pool.QueryRow(ctx, `
SELECT r.handled_at,
COALESCE((pm.media #>> '{service_action,no_forwards,expired}')::boolean, false),
(SELECT count(*)
FROM message_boxes b
WHERE b.message_sender_id = r.private_message_sender_user_id
AND b.private_message_id = r.private_message_id
AND COALESCE((b.media #>> '{service_action,no_forwards,expired}')::boolean, false))
FROM private_no_forwards_requests r
JOIN private_messages pm
ON pm.sender_user_id = r.private_message_sender_user_id
AND pm.id = r.private_message_id
WHERE r.private_message_sender_user_id = $1
AND r.private_message_id = $2`, bob.ID, request.Send.SenderMessage.UID,
).Scan(&handledAt, &logicalExpired, &expiredBoxes); err != nil {
t.Fatal(err)
}
if handledAt != 1700100002 || !logicalExpired || expiredBoxes != 2 {
t.Fatalf("handled request handled_at=%d logical_expired=%v boxes=%d", handledAt, logicalExpired, expiredBoxes)
}
}
func TestPostgresPrivateNoForwardsConcurrentOwnershipAndOneShotAnswer(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
alice, err := users.Create(ctx, domain.User{AccessHash: 6201, Phone: "+1669" + suffix + "01", FirstName: "Alice"})
if err != nil {
t.Fatal(err)
}
bob, err := users.Create(ctx, domain.User{AccessHash: 6202, Phone: "+1669" + suffix + "02", FirstName: "Bob"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID})
})
messages := NewMessageStore(pool)
baseRandom := time.Now().UnixNano()
enableResults := make([]domain.TogglePrivateNoForwardsResult, 2)
enableErrors := make([]error, 2)
actors := []int64{alice.ID, bob.ID}
peers := []int64{bob.ID, alice.ID}
var wg sync.WaitGroup
for i := range actors {
wg.Add(1)
go func(i int) {
defer wg.Done()
enableResults[i], enableErrors[i] = messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: actors[i],
PeerUserID: peers[i],
Enabled: true,
RandomID: baseRandom + int64(i),
Date: 1700200000,
})
}(i)
}
wg.Wait()
changed := 0
for i, err := range enableErrors {
if err != nil {
t.Fatalf("concurrent enable %d: %v", i, err)
}
if enableResults[i].Changed {
changed++
}
}
if changed != 1 {
t.Fatalf("concurrent enable changed=%d, want exactly one service message", changed)
}
state, err := messages.GetPrivateNoForwards(ctx, alice.ID, bob.ID)
if err != nil || (state.EnabledByUserID != alice.ID && state.EnabledByUserID != bob.ID) {
t.Fatalf("concurrent enable state=%+v err=%v", state, err)
}
ownerID := state.EnabledByUserID
requesterID := alice.ID
if ownerID == alice.ID {
requesterID = bob.ID
}
request, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: requesterID,
PeerUserID: ownerID,
RandomID: baseRandom + 10,
Date: 1700200001,
})
if err != nil {
t.Fatalf("create disable request: %v", err)
}
answerResults := make([]domain.TogglePrivateNoForwardsResult, 2)
answerErrors := make([]error, 2)
for i := range answerResults {
wg.Add(1)
go func(i int) {
defer wg.Done()
answerResults[i], answerErrors[i] = messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: ownerID,
PeerUserID: requesterID,
Enabled: false,
RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: baseRandom + 20 + int64(i),
Date: 1700200002,
})
}(i)
}
wg.Wait()
successes, expired := 0, 0
for i, err := range answerErrors {
switch {
case err == nil && answerResults[i].Changed:
successes++
case errors.Is(err, domain.ErrNoForwardsRequestExpired):
expired++
default:
t.Fatalf("concurrent answer %d result=%+v err=%v", i, answerResults[i], err)
}
}
if successes != 1 || expired != 1 {
t.Fatalf("concurrent answers successes=%d expired=%d, want 1/1", successes, expired)
}
state, err = messages.GetPrivateNoForwards(ctx, alice.ID, bob.ID)
if err != nil || state.Enabled() {
t.Fatalf("state after concurrent answer=%+v err=%v, want disabled", state, err)
}
for _, userID := range []int64{alice.ID, bob.ID} {
events, err := NewUpdateEventStore(pool).ListAfter(ctx, userID, 0, 10)
if err != nil {
t.Fatalf("events user %d: %v", userID, err)
}
if len(events) != 3 || events[0].Pts != 1 || events[1].Pts != 2 || events[2].Pts != 3 {
t.Fatalf("events user %d = %+v, want one enable/request/answer sequence", userID, events)
}
}
}

View file

@ -29,6 +29,9 @@ func (s *MessageStore) SetMessageReactions(ctx context.Context, req domain.SetPr
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
}
if req.Peer.ID == req.UserID {
return s.setSavedMessageTags(ctx, req)
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("set message reactions: db does not support transactions")
@ -240,10 +243,17 @@ func (s *MessageStore) enrichPrivateMessageReactions(ctx context.Context, db sql
if err := s.enrichPrivateMessagePolls(ctx, db, viewerUserID, messages); err != nil {
return err
}
if err := s.enrichSavedMessageTags(ctx, db, messages); err != nil {
return err
}
keySet := make(map[privateMessageReactionKey]struct{}, len(messages))
senderIDs := make([]int64, 0, len(messages))
privateIDs := make([]int64, 0, len(messages))
for _, msg := range messages {
if msg.OwnerUserID != 0 &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: msg.OwnerUserID}) {
continue
}
if msg.UID == 0 || msg.From.ID == 0 {
continue
}
@ -420,6 +430,11 @@ func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageRe
return
}
var buf [16]byte
if reactions.AsTags {
_, _ = h.Write([]byte{1})
} else {
_, _ = h.Write([]byte{0})
}
for _, item := range reactions.Results {
_, _ = h.Write([]byte(item.Reaction.Type))
_, _ = h.Write([]byte{0})

View file

@ -0,0 +1,308 @@
package postgres
import (
"context"
"errors"
"fmt"
"sort"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func (s *MessageStore) setSavedMessageTags(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("set saved message tags: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("begin set saved message tags tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := lockUsersForUpdate(ctx, tx, req.UserID); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("lock saved message tag owner: %w", err)
}
var boxID int32
if err := tx.QueryRow(ctx, `
SELECT box_id
FROM message_boxes
WHERE owner_user_id = $1
AND box_id = $2
AND peer_type = 'user'
AND peer_id = $1
AND NOT deleted
LIMIT 1
FOR UPDATE`, req.UserID, int32(req.MessageID)).Scan(&boxID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("get saved message for tags: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM saved_message_reaction_tags
WHERE user_id = $1 AND message_box_id = $2`, req.UserID, boxID); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("delete old saved message tags: %w", err)
}
for i, reaction := range req.Reactions {
if !reaction.Valid() {
return domain.PrivateMessageReactionsResult{}, domain.ErrReactionInvalid
}
if _, err := tx.Exec(ctx, `
INSERT INTO saved_message_reaction_tags (
user_id, message_box_id, reaction_type, reaction_value, chosen_order
) VALUES ($1, $2, $3, $4, $5)`,
req.UserID, boxID, string(reaction.Type), reaction.Value(), int32(i+1)); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("insert saved message tag: %w", err)
}
}
rows, err := sqlcgen.New(tx).GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{
OwnerUserID: req.UserID,
BoxIds: []int32{boxID},
})
if err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("reload saved message tags box: %w", err)
}
if len(rows) != 1 {
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
msg, err := messageFromIDRow(rows[0])
if err != nil {
return domain.PrivateMessageReactionsResult{}, err
}
messages := []domain.Message{msg}
if err := s.enrichPrivateMessageReactions(ctx, tx, req.UserID, messages); err != nil {
return domain.PrivateMessageReactionsResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("commit saved message tags tx: %w", err)
}
committed = true
reactions := domain.ChannelMessageReactions{AsTags: true}
if messages[0].Reactions != nil {
reactions = *messages[0].Reactions
}
return domain.PrivateMessageReactionsResult{
Messages: messages,
Reactions: reactions,
}, nil
}
func (s *MessageStore) enrichSavedMessageTags(ctx context.Context, db sqlcgen.DBTX, messages []domain.Message) error {
ownerIDs := make([]int64, 0, len(messages))
boxIDs := make([]int32, 0, len(messages))
indexes := make(map[[2]int64]int, len(messages))
for i := range messages {
msg := messages[i]
if msg.OwnerUserID == 0 ||
msg.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: msg.OwnerUserID}) {
continue
}
ownerIDs = append(ownerIDs, msg.OwnerUserID)
boxIDs = append(boxIDs, int32(msg.ID))
indexes[[2]int64{msg.OwnerUserID, int64(msg.ID)}] = i
}
if len(ownerIDs) == 0 {
return nil
}
rows, err := db.Query(ctx, `
WITH wanted AS (
SELECT user_id, message_box_id
FROM unnest($1::bigint[], $2::int[]) AS w(user_id, message_box_id)
)
SELECT t.user_id, t.message_box_id, t.reaction_type, t.reaction_value, t.chosen_order
FROM saved_message_reaction_tags t
JOIN wanted w
ON w.user_id = t.user_id
AND w.message_box_id = t.message_box_id
ORDER BY t.user_id, t.message_box_id, t.chosen_order, t.reaction_type, t.reaction_value`,
ownerIDs, boxIDs)
if err != nil {
return fmt.Errorf("load saved message tags: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
userID int64
messageBoxID int32
reactionType string
reactionValue string
chosenOrder int32
)
if err := rows.Scan(&userID, &messageBoxID, &reactionType, &reactionValue, &chosenOrder); err != nil {
return fmt.Errorf("scan saved message tag: %w", err)
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
continue
}
index, ok := indexes[[2]int64{userID, int64(messageBoxID)}]
if !ok {
continue
}
if messages[index].Reactions == nil {
messages[index].Reactions = &domain.ChannelMessageReactions{
AsTags: true,
Results: []domain.ChannelMessageReactionCount{},
Recent: []domain.ChannelMessagePeerReaction{},
}
}
messages[index].Reactions.Results = append(messages[index].Reactions.Results, domain.ChannelMessageReactionCount{
Reaction: reaction,
Count: 1,
ChosenOrder: int(chosenOrder),
})
}
if err := rows.Err(); err != nil {
return fmt.Errorf("saved message tag rows: %w", err)
}
return nil
}
func (s *MessageStore) ListSavedReactionTags(ctx context.Context, req domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error) {
if req.UserID == 0 {
return nil, domain.ErrReactionInvalid
}
if req.Limit <= 0 || req.Limit > domain.MaxSavedReactionTags {
req.Limit = domain.MaxSavedReactionTags
}
savedPeerType := ""
var savedPeerID int64
if req.SavedPeer.ID != 0 {
savedPeerType = string(req.SavedPeer.Type)
savedPeerID = req.SavedPeer.ID
}
rows, err := s.db.Query(ctx, `
SELECT
a.reaction_type,
a.reaction_value,
CASE WHEN $2 = '' THEN COALESCE(t.title, '') ELSE '' END AS title,
COUNT(*)::int AS reaction_count
FROM saved_message_reaction_tags a
JOIN message_boxes m
ON m.owner_user_id = a.user_id
AND m.box_id = a.message_box_id
AND NOT m.deleted
AND m.peer_type = 'user'
AND m.peer_id = a.user_id
LEFT JOIN user_saved_reaction_tags t
ON t.user_id = a.user_id
AND t.reaction_type = a.reaction_type
AND t.reaction_value = a.reaction_value
WHERE a.user_id = $1
AND ($2 = '' OR (m.saved_peer_type = $2 AND m.saved_peer_id = $3))
GROUP BY a.reaction_type, a.reaction_value, title
ORDER BY
reaction_count DESC,
CASE
WHEN a.reaction_type = 'custom_emoji'
THEN lpad(to_hex(a.reaction_value::bigint), 16, '0')
ELSE substr(md5(replace(a.reaction_value, U&'\FE0F', '')), 1, 16)
END DESC
LIMIT $4`, req.UserID, savedPeerType, savedPeerID, int32(req.Limit))
if err != nil {
return nil, fmt.Errorf("list saved reaction tags: %w", err)
}
defer rows.Close()
out := make([]domain.SavedReactionTag, 0, req.Limit)
for rows.Next() {
var reactionType, reactionValue, title string
var count int32
if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil {
return nil, fmt.Errorf("scan saved reaction tag: %w", err)
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok || count <= 0 {
continue
}
out = append(out, domain.SavedReactionTag{
UserID: req.UserID,
Reaction: reaction,
Title: title,
Count: int(count),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("saved reaction tag rows: %w", err)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Reaction.Key() > out[j].Reaction.Key()
})
return out, nil
}
func (s *MessageStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || !tag.Reaction.Valid() || utf8.RuneCountInString(tag.Title) > 12 {
return domain.ErrReactionInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return fmt.Errorf("update saved reaction tag title: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin update saved reaction tag title tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := lockUsersForUpdate(ctx, tx, tag.UserID); err != nil {
return fmt.Errorf("lock saved reaction tag owner: %w", err)
}
var exists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM saved_message_reaction_tags a
JOIN message_boxes m
ON m.owner_user_id = a.user_id
AND m.box_id = a.message_box_id
AND NOT m.deleted
AND m.peer_type = 'user'
AND m.peer_id = a.user_id
WHERE a.user_id = $1
AND a.reaction_type = $2
AND a.reaction_value = $3
)`, tag.UserID, string(tag.Reaction.Type), tag.Reaction.Value()).Scan(&exists); err != nil {
return fmt.Errorf("check saved reaction tag assignment: %w", err)
}
if !exists {
return domain.ErrReactionInvalid
}
if tag.Title == "" {
if _, err := tx.Exec(ctx, `
DELETE FROM user_saved_reaction_tags
WHERE user_id = $1 AND reaction_type = $2 AND reaction_value = $3`,
tag.UserID, string(tag.Reaction.Type), tag.Reaction.Value()); err != nil {
return fmt.Errorf("delete saved reaction tag title: %w", err)
}
} else if _, err := tx.Exec(ctx, `
INSERT INTO user_saved_reaction_tags (
user_id, reaction_type, reaction_value, title, reaction_count
) VALUES ($1, $2, $3, $4, 0)
ON CONFLICT (user_id, reaction_type, reaction_value)
DO UPDATE SET title = EXCLUDED.title, reaction_count = 0, updated_at = now()`,
tag.UserID, string(tag.Reaction.Type), tag.Reaction.Value(), tag.Title); err != nil {
return fmt.Errorf("upsert saved reaction tag title: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit saved reaction tag title tx: %w", err)
}
committed = true
return nil
}

View file

@ -0,0 +1,177 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestSavedMessageTagsPostgresAssignmentCountsSearchAndDelete(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
user, err := users.Create(ctx, domain.User{
AccessHash: 1,
Phone: "+1777" + suffix + "01",
FirstName: "SavedTags",
})
if err != nil {
t.Fatalf("create saved-tag user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM saved_message_reaction_tags WHERE user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM user_saved_reaction_tags WHERE user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = $1 OR recipient_user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID)
})
messages := NewMessageStore(pool)
self := domain.Peer{Type: domain.PeerTypeUser, ID: user.ID}
peerA := domain.Peer{Type: domain.PeerTypeUser, ID: user.ID}
peerB := domain.Peer{Type: domain.PeerTypeChannel, ID: 90001}
create := func(body string, savedPeer domain.Peer) domain.Message {
msg, err := messages.Create(ctx, domain.Message{
OwnerUserID: user.ID,
Peer: self,
From: self,
Date: int(time.Now().Unix()),
Body: body,
})
if err != nil {
t.Fatalf("create saved message: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE message_boxes
SET saved_peer_type = $3, saved_peer_id = $4
WHERE owner_user_id = $1 AND box_id = $2`,
user.ID, msg.ID, string(savedPeer.Type), savedPeer.ID); err != nil {
t.Fatalf("set saved peer: %v", err)
}
msg.SavedPeer = savedPeer
return msg
}
first := create("first", peerA)
second := create("second", peerA)
third := create("third", peerB)
thumb := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"}
custom := domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 70001}
set := func(msg domain.Message, reactions ...domain.MessageReaction) {
t.Helper()
result, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
UserID: user.ID,
Peer: self,
MessageID: msg.ID,
Reactions: reactions,
ReactionsPerUserMax: 3,
})
if err != nil {
t.Fatalf("set saved tags on %d: %v", msg.ID, err)
}
if len(result.Messages) != 1 || result.Messages[0].Reactions == nil ||
!result.Messages[0].Reactions.AsTags {
t.Fatalf("set saved tags result = %+v", result)
}
}
set(first, thumb)
set(second, thumb, custom)
set(third, custom)
if err := messages.UpsertSavedReactionTag(ctx, domain.SavedReactionTag{
UserID: user.ID, Reaction: custom, Title: "Custom",
}); err != nil {
t.Fatalf("rename custom saved tag: %v", err)
}
global, err := messages.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: user.ID, Limit: 100,
})
if err != nil {
t.Fatalf("list global saved tags: %v", err)
}
assertPostgresSavedTag(t, global, thumb, 2, "")
assertPostgresSavedTag(t, global, custom, 2, "Custom")
perPeer, err := messages.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: user.ID, SavedPeer: peerA, Limit: 100,
})
if err != nil {
t.Fatalf("list per-peer saved tags: %v", err)
}
assertPostgresSavedTag(t, perPeer, thumb, 2, "")
assertPostgresSavedTag(t, perPeer, custom, 1, "")
search, err := messages.ListByUser(ctx, user.ID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedPeer: peerA,
SavedReactions: []domain.MessageReaction{custom},
NeedTotalCount: true,
Limit: 10,
})
if err != nil {
t.Fatalf("search saved tag: %v", err)
}
if len(search.Messages) != 1 || search.Messages[0].ID != second.ID ||
search.Messages[0].Reactions == nil || !search.Messages[0].Reactions.AsTags {
t.Fatalf("saved tag search = %+v, want second message", search.Messages)
}
searchAny, err := messages.ListByUser(ctx, user.ID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedReactions: []domain.MessageReaction{thumb, custom},
NeedTotalCount: true,
Limit: 10,
})
if err != nil {
t.Fatalf("search any saved tag: %v", err)
}
if len(searchAny.Messages) != 3 || searchAny.Count != 3 {
t.Fatalf("saved tag OR search = count %d messages %+v, want all three", searchAny.Count, searchAny.Messages)
}
var reactionEvents int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM user_update_events
WHERE user_id = $1 AND event_type = 'message_reactions'`, user.ID).Scan(&reactionEvents); err != nil {
t.Fatalf("count reaction events: %v", err)
}
if reactionEvents != 0 {
t.Fatalf("reaction durable events = %d, want 0", reactionEvents)
}
if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: user.ID,
IDs: []int{second.ID},
Date: int(time.Now().Unix()),
}); err != nil {
t.Fatalf("delete tagged saved message: %v", err)
}
global, err = messages.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: user.ID, Limit: 100,
})
if err != nil {
t.Fatalf("list tags after delete: %v", err)
}
assertPostgresSavedTag(t, global, thumb, 1, "")
assertPostgresSavedTag(t, global, custom, 1, "Custom")
}
func assertPostgresSavedTag(t *testing.T, tags []domain.SavedReactionTag, reaction domain.MessageReaction, count int, title string) {
t.Helper()
for _, tag := range tags {
if tag.Reaction.Key() == reaction.Key() {
if tag.Count != count || tag.Title != title {
t.Fatalf("tag %s = %+v, want count=%d title=%q", reaction.Key(), tag, count, title)
}
return
}
}
t.Fatalf("tag %s not found in %+v", reaction.Key(), tags)
}

View file

@ -99,13 +99,15 @@ WITH desired (
EXCLUDED.about, EXCLUDED.is_bot, EXCLUDED.bot_info_version
)
)
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
SELECT lower(username), 'user', id
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
SELECT lower(username), username, 'user', id, true, true, 0
FROM desired
ON CONFLICT (peer_type, peer_id) DO UPDATE SET
ON CONFLICT (peer_type, peer_id) WHERE editable DO UPDATE SET
username_lower = EXCLUDED.username_lower,
username = EXCLUDED.username,
updated_at = now()
WHERE peer_usernames.username_lower IS DISTINCT FROM EXCLUDED.username_lower
WHERE (peer_usernames.username_lower, peer_usernames.username)
IS DISTINCT FROM (EXCLUDED.username_lower, EXCLUDED.username)
`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil {
return fmt.Errorf("ensure official system user: %w", err)
}

View file

@ -0,0 +1,441 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type ModerationReportStore struct {
db sqlcgen.DBTX
}
func NewModerationReportStore(db sqlcgen.DBTX) *ModerationReportStore {
return &ModerationReportStore{db: db}
}
func (s *ModerationReportStore) CreateModerationReport(ctx context.Context, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, err
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin moderation report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
stored, created, err := createModerationReportTx(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit moderation report: %w", err)
}
return stored, created, nil
}
func createModerationReportTx(ctx context.Context, tx pgx.Tx, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
var (
reportID int64
err error
)
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-report:' || $1::bigint::text, 0)
)`, report.ReporterUserID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock moderation reporter: %w", err)
}
err = tx.QueryRow(ctx, `
SELECT id
FROM moderation_reports
WHERE reporter_user_id = $1 AND fingerprint = $2`,
report.ReporterUserID, report.Fingerprint[:]).Scan(&reportID)
if err == nil {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("duplicate moderation report disappeared")
}
return existing, false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, fmt.Errorf("lookup moderation report fingerprint: %w", err)
}
var hourly, daily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '1 hour'),
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '24 hours')
FROM moderation_reports
WHERE reporter_user_id = $1
AND created_at <= $2::timestamptz`,
report.ReporterUserID, report.CreatedAt).Scan(&hourly, &daily); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("count moderation reporter submissions: %w", err)
}
if hourly >= domain.MaxModerationReportsPerHour || daily >= domain.MaxModerationReportsPerDay {
return domain.ModerationReport{}, false, domain.ErrModerationRateLimited
}
reportID, created, err := insertModerationReport(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !created {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("duplicate moderation report disappeared")
}
return existing, false, nil
}
report.ID = reportID
return domain.CloneModerationReport(report), true, nil
}
func (s *ModerationReportStore) ImportLegacyEphemeralReport(ctx context.Context, legacyReportID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if legacyReportID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, err
}
if report.Source != domain.ModerationSourceEphemeral {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin legacy ephemeral report import: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-legacy-ephemeral:' || $1::bigint::text, 0)
)`, legacyReportID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock legacy ephemeral report: %w", err)
}
var reportID int64
err = tx.QueryRow(ctx, `
SELECT moderation_report_id
FROM moderation_legacy_ephemeral_migrations
WHERE legacy_report_id = $1`, legacyReportID).Scan(&reportID)
if err == nil {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("legacy ephemeral report mapping points to missing moderation report")
}
return existing, false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, fmt.Errorf("lookup legacy ephemeral report mapping: %w", err)
}
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-report:' || $1::bigint::text, 0)
)`, report.ReporterUserID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock moderation reporter: %w", err)
}
reportID, created, err := insertModerationReport(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_legacy_ephemeral_migrations (
legacy_report_id, moderation_report_id, migrated_at
) VALUES ($1,$2,clock_timestamp())`,
legacyReportID, reportID,
); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("insert legacy ephemeral report mapping: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit legacy ephemeral report import: %w", err)
}
if created {
report.ID = reportID
return domain.CloneModerationReport(report), true, nil
}
existing, found, err := getModerationReport(ctx, s.db, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("imported duplicate moderation report disappeared")
}
return existing, false, nil
}
func insertModerationReport(ctx context.Context, tx pgx.Tx, report domain.ModerationReport) (int64, bool, error) {
var reportID int64
err := tx.QueryRow(ctx, `
INSERT INTO moderation_reports (
reporter_user_id, source, target_peer_type, target_peer_id, reason,
report_option, report_comment, comment_hash, fingerprint,
taxonomy_version, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (reporter_user_id, fingerprint) DO NOTHING
RETURNING id`,
report.ReporterUserID, string(report.Source), string(report.Target.Type),
report.Target.ID, string(report.Reason), report.Option, report.Comment,
report.CommentHash[:], report.Fingerprint[:], report.TaxonomyVersion,
report.CreatedAt,
).Scan(&reportID)
if errors.Is(err, pgx.ErrNoRows) {
if err := tx.QueryRow(ctx, `
SELECT id
FROM moderation_reports
WHERE reporter_user_id = $1 AND fingerprint = $2`,
report.ReporterUserID, report.Fingerprint[:]).Scan(&reportID); err != nil {
return 0, false, fmt.Errorf("lookup duplicate moderation report: %w", err)
}
return reportID, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("insert moderation report: %w", err)
}
for ordinal, item := range report.Items {
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_report_items (
report_id, ordinal, item_kind, peer_type, peer_id, item_id,
secondary_id, author_user_id, evidence_schema_version, evidence,
evidence_hash
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11)`,
reportID, ordinal, string(item.Kind), string(item.Peer.Type),
item.Peer.ID, item.ItemID, item.SecondaryID, item.AuthorUserID,
item.EvidenceSchemaVersion, []byte(item.Evidence), item.EvidenceHash[:],
); err != nil {
return 0, false, fmt.Errorf("insert moderation report item %d: %w", ordinal, err)
}
}
for _, hold := range report.MediaHolds {
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_media_holds (
report_id, item_ordinal, media_kind, storage_key, created_at
) VALUES ($1,$2,$3,$4,$5)`,
reportID, hold.ItemIndex, string(hold.Kind), hold.StorageKey,
report.CreatedAt,
); err != nil {
return 0, false, fmt.Errorf("insert moderation media hold: %w", err)
}
}
if err := attachModerationReportToCase(ctx, tx, reportID, report); err != nil {
return 0, false, err
}
return reportID, true, nil
}
func attachModerationReportToCase(ctx context.Context, tx pgx.Tx, reportID int64, report domain.ModerationReport) error {
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended(
'moderation-case:' || $1::text || ':' || $2::bigint::text,
0
)
)`, string(report.Target.Type), report.Target.ID); err != nil {
return fmt.Errorf("lock moderation case target: %w", err)
}
var caseID int64
err := tx.QueryRow(ctx, `
SELECT id
FROM moderation_cases
WHERE target_peer_type = $1
AND target_peer_id = $2
AND status IN ('open', 'in_review')
FOR UPDATE`,
string(report.Target.Type), report.Target.ID,
).Scan(&caseID)
if errors.Is(err, pgx.ErrNoRows) {
err = tx.QueryRow(ctx, `
INSERT INTO moderation_cases (
target_peer_type, target_peer_id, status, severity, assigned_to,
version, report_count, distinct_reporter_count, first_report_at,
last_report_at, created_at, updated_at
) VALUES ($1,$2,'open',$3,'',1,1,1,$4,$4,$4,$4)
RETURNING id`,
string(report.Target.Type), report.Target.ID,
int16(domain.ModerationSeverityForReason(report.Reason)),
report.CreatedAt,
).Scan(&caseID)
if err != nil {
return fmt.Errorf("create moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_case_reports (case_id, report_id, attached_at)
VALUES ($1,$2,$3)`, caseID, reportID, report.CreatedAt); err != nil {
return fmt.Errorf("attach report to new moderation case: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("find active moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_case_reports (case_id, report_id, attached_at)
VALUES ($1,$2,$3)`, caseID, reportID, report.CreatedAt); err != nil {
return fmt.Errorf("attach report to moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE moderation_cases c
SET severity = greatest(c.severity, $2),
version = c.version + 1,
report_count = (
SELECT count(*)::integer
FROM moderation_case_reports cr
WHERE cr.case_id = c.id
),
distinct_reporter_count = (
SELECT count(DISTINCT r.reporter_user_id)::integer
FROM moderation_case_reports cr
JOIN moderation_reports r ON r.id = cr.report_id
WHERE cr.case_id = c.id
),
first_report_at = least(c.first_report_at, $3),
last_report_at = greatest(c.last_report_at, $3),
updated_at = greatest(c.updated_at, $3)
WHERE c.id = $1`,
caseID, int16(domain.ModerationSeverityForReason(report.Reason)),
report.CreatedAt,
); err != nil {
return fmt.Errorf("update moderation case aggregates: %w", err)
}
return nil
}
func (s *ModerationReportStore) GetModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if reportID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
return getModerationReport(ctx, s.db, reportID)
}
func getModerationReport(ctx context.Context, db sqlcgen.DBTX, reportID int64) (domain.ModerationReport, bool, error) {
var (
report domain.ModerationReport
source, target, reason string
commentHash []byte
fingerprint []byte
)
err := db.QueryRow(ctx, `
SELECT id, reporter_user_id, source, target_peer_type, target_peer_id,
reason, report_option, report_comment, comment_hash, fingerprint,
taxonomy_version, created_at
FROM moderation_reports
WHERE id = $1`, reportID).Scan(
&report.ID, &report.ReporterUserID, &source, &target,
&report.Target.ID, &reason, &report.Option, &report.Comment,
&commentHash, &fingerprint, &report.TaxonomyVersion,
&report.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, nil
}
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("get moderation report: %w", err)
}
if len(commentHash) != len(report.CommentHash) || len(fingerprint) != len(report.Fingerprint) {
return domain.ModerationReport{}, false, fmt.Errorf("get moderation report: invalid persisted hash length")
}
copy(report.CommentHash[:], commentHash)
copy(report.Fingerprint[:], fingerprint)
report.Source = domain.ModerationReportSource(source)
report.Target.Type = domain.PeerType(target)
report.Reason = domain.ModerationReason(reason)
rows, err := db.Query(ctx, `
SELECT ordinal, item_kind, peer_type, peer_id, item_id, secondary_id,
author_user_id, evidence_schema_version, evidence, evidence_hash
FROM moderation_report_items
WHERE report_id = $1
ORDER BY ordinal`, reportID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("list moderation report items: %w", err)
}
for rows.Next() {
var (
ordinal int
item domain.ModerationReportItem
kind, peerType string
evidence, evidenceHash []byte
)
if err := rows.Scan(
&ordinal, &kind, &peerType, &item.Peer.ID, &item.ItemID,
&item.SecondaryID, &item.AuthorUserID,
&item.EvidenceSchemaVersion, &evidence, &evidenceHash,
); err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item: %w", err)
}
if ordinal != len(report.Items) || len(evidenceHash) != len(item.EvidenceHash) {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item: invalid persisted ordering or hash")
}
canonical, err := domain.CanonicalModerationEvidence(evidence)
if err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item evidence: %w", err)
}
item.Kind = domain.ModerationReportItemKind(kind)
item.Peer.Type = domain.PeerType(peerType)
item.Evidence = canonical
copy(item.EvidenceHash[:], evidenceHash)
report.Items = append(report.Items, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("iterate moderation report items: %w", err)
}
rows.Close()
holdRows, err := db.Query(ctx, `
SELECT item_ordinal, media_kind, storage_key
FROM moderation_media_holds
WHERE report_id = $1
ORDER BY item_ordinal, media_kind, storage_key`, reportID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("list moderation media holds: %w", err)
}
defer holdRows.Close()
for holdRows.Next() {
var hold domain.ModerationMediaHold
var kind string
if err := holdRows.Scan(&hold.ItemIndex, &kind, &hold.StorageKey); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation media hold: %w", err)
}
hold.Kind = domain.ModerationMediaKind(kind)
report.MediaHolds = append(report.MediaHolds, hold)
}
if err := holdRows.Err(); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("iterate moderation media holds: %w", err)
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("validate persisted moderation report: %w", err)
}
return report, true, nil
}

File diff suppressed because it is too large Load diff

View file

@ -48,3 +48,171 @@ func TestModerationFlagsRejectImpossibleStateAtPostgresBoundary(t *testing.T) {
t.Fatalf("channel after rejected writes=%+v err=%v", gotChannel, err)
}
}
func TestUserModerationFlagsDoNotAdvanceAccountPts(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
contacts := NewContactStore(pool)
events := NewUpdateEventStore(pool)
target := createTestUser(t, ctx, users, "+1782"+suffix+"71", "FlagTarget", "")
savedTargetViewer := createTestUser(t, ctx, users, "+1782"+suffix+"72", "SavedTarget", "")
savedByTargetViewer := createTestUser(t, ctx, users, "+1782"+suffix+"73", "SavedByTarget", "")
unrelated := createTestUser(t, ctx, users, "+1782"+suffix+"74", "Unrelated", "")
if _, err := contacts.Upsert(ctx, savedTargetViewer.ID, domain.ContactInput{
ContactUserID: target.ID, FirstName: target.FirstName,
}); err != nil {
t.Fatalf("save target contact: %v", err)
}
if _, err := contacts.Upsert(ctx, target.ID, domain.ContactInput{
ContactUserID: savedByTargetViewer.ID, FirstName: savedByTargetViewer.FirstName,
}); err != nil {
t.Fatalf("save reverse contact: %v", err)
}
viewers := []domain.User{target, savedTargetViewer, savedByTargetViewer, unrelated}
baseline := make(map[int64]int, len(viewers))
for _, viewer := range viewers {
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil {
t.Fatalf("viewer %d baseline pts: %v", viewer.ID, err)
}
baseline[viewer.ID] = pts
}
updated, err := users.SetScamFake(ctx, target.ID, true, false)
if err != nil {
t.Fatalf("set scam: %v", err)
}
if !updated.Scam || updated.Fake {
t.Fatalf("updated flags = scam:%v fake:%v", updated.Scam, updated.Fake)
}
for _, viewer := range viewers {
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil || pts != baseline[viewer.ID] {
t.Fatalf("viewer %d pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
}
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
if err != nil || len(got) != 0 {
t.Fatalf("viewer %d moderation events=%+v err=%v", viewer.ID, got, err)
}
}
var outboxCount int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM dispatch_outbox
WHERE target_user_id = ANY($1::bigint[])
AND event_type = 'user_profile'`,
[]int64{target.ID, savedTargetViewer.ID, savedByTargetViewer.ID, unrelated.ID},
).Scan(&outboxCount); err != nil || outboxCount != 0 {
t.Fatalf("profile outbox count=%d err=%v", outboxCount, err)
}
audience, err := users.ModerationFlagAudience(ctx, target.ID, 4096)
if err != nil {
t.Fatalf("moderation audience: %v", err)
}
audienceSet := make(map[int64]struct{}, len(audience))
for _, userID := range audience {
audienceSet[userID] = struct{}{}
}
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} {
if _, ok := audienceSet[viewer.ID]; !ok {
t.Fatalf("viewer %d missing from audience %v", viewer.ID, audience)
}
}
if _, ok := audienceSet[unrelated.ID]; ok {
t.Fatalf("unrelated viewer included in audience %v", audience)
}
if _, err := users.SetScamFake(ctx, target.ID, true, false); err != nil {
t.Fatalf("repeat same flags: %v", err)
}
if _, err := users.SetScamFake(ctx, target.ID, false, true); err != nil {
t.Fatalf("switch to fake: %v", err)
}
for _, viewer := range viewers {
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil || pts != baseline[viewer.ID] {
t.Fatalf("viewer %d final pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
}
}
}
func TestChannelModerationFlagsDoNotAdvanceMemberAccountPts(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
channels := NewChannelStore(pool)
events := NewUpdateEventStore(pool)
owner := createTestUser(t, ctx, users, "+1783"+suffix+"71", "FlagOwner", "")
member := createTestUser(t, ctx, users, "+1783"+suffix+"72", "FlagMember", "")
unrelated := createTestUser(t, ctx, users, "+1783"+suffix+"73", "FlagUnrelated", "")
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
MemberUserIDs: []int64{member.ID},
Title: "Flagged Channel " + suffix,
Megagroup: true,
Date: 1700003000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
baseline := make(map[int64]int)
for _, viewer := range []domain.User{owner, member, unrelated} {
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil {
t.Fatalf("viewer %d baseline pts: %v", viewer.ID, err)
}
baseline[viewer.ID] = pts
}
updated, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, false)
if err != nil {
t.Fatalf("set channel scam: %v", err)
}
if !updated.Scam || updated.Fake {
t.Fatalf("updated flags = scam:%v fake:%v", updated.Scam, updated.Fake)
}
for _, viewer := range []domain.User{owner, member, unrelated} {
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil || pts != baseline[viewer.ID] {
t.Fatalf("viewer %d pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
}
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
if err != nil || len(got) != 0 {
t.Fatalf("viewer %d moderation events=%+v err=%v", viewer.ID, got, err)
}
}
var outboxCount int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM dispatch_outbox
WHERE target_user_id = ANY($1::bigint[])
AND event_type = 'channel_state'
AND pts > 0`,
[]int64{owner.ID, member.ID, unrelated.ID},
).Scan(&outboxCount); err != nil || outboxCount != 0 {
t.Fatalf("channel state outbox count=%d err=%v", outboxCount, err)
}
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, false); err != nil {
t.Fatalf("repeat same channel flags: %v", err)
}
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, false, true); err != nil {
t.Fatalf("switch channel to fake: %v", err)
}
for _, viewer := range []domain.User{owner, member, unrelated} {
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
if err != nil || pts != baseline[viewer.ID] {
t.Fatalf("viewer %d final pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
}
}
}

View file

@ -0,0 +1,471 @@
package postgres
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
moderationapp "telesrv/internal/app/moderation"
"telesrv/internal/domain"
)
func TestModerationReportStoreAtomicEvidenceAndIdempotency(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC()
reporter := now.UnixNano()&0x3fffffff + 5_000
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceProfilePhoto,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: reporter + 1},
Reason: domain.ModerationReasonFake, Option: "v1/fake",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemProfilePhoto,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: reporter + 1},
ItemID: reporter + 2, AuthorUserID: reporter + 1,
EvidenceSchemaVersion: 1,
Evidence: []byte(`{"photo_id":2,"owner_id":1}`),
}},
MediaHolds: []domain.ModerationMediaHold{{
ItemIndex: 0, Kind: domain.ModerationMediaPhoto,
StorageKey: "profile/photo/test",
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
store := NewModerationReportStore(pool)
stored, created, err := store.CreateModerationReport(ctx, report)
if err != nil || !created {
t.Fatalf("create=%v err=%v", created, err)
}
t.Cleanup(func() {
cleanupModerationReport(t, pool, stored.ID)
})
retry, created, err := store.CreateModerationReport(ctx, report)
if err != nil || created || retry.ID != stored.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
got, found, err := store.GetModerationReport(ctx, stored.ID)
if err != nil || !found {
t.Fatalf("get found=%v err=%v", found, err)
}
if got.Fingerprint != report.Fingerprint || len(got.Items) != 1 ||
len(got.MediaHolds) != 1 || got.MediaHolds[0].StorageKey != "profile/photo/test" {
t.Fatalf("stored report = %+v", got)
}
var reports, items, holds int
if err := pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM moderation_reports WHERE id = $1),
(SELECT count(*) FROM moderation_report_items WHERE report_id = $1),
(SELECT count(*) FROM moderation_media_holds WHERE report_id = $1)`,
stored.ID).Scan(&reports, &items, &holds); err != nil {
t.Fatal(err)
}
if reports != 1 || items != 1 || holds != 1 {
t.Fatalf("rows reports=%d items=%d holds=%d", reports, items, holds)
}
}
func TestModerationSponsoredReportIsAtomicUnderConcurrentFinalOptions(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC()
userID := now.UnixNano()&0x3fffffff + 8_000
randomID := []byte("postgres-sponsored-random-id")
store := NewModerationReportStore(pool)
impression, err := domain.NewSponsoredMessageImpression(
userID, randomID,
domain.Peer{Type: domain.PeerTypeChannel, ID: userID + 1},
userID+2, []byte(`{"creative_id":"pg-creative","schema_version":1}`),
now, now.Add(time.Hour),
)
if err != nil {
t.Fatal(err)
}
impression, created, err := store.CreateSponsoredMessageImpression(ctx, impression)
if err != nil || !created {
t.Fatalf("impression=%+v created=%v err=%v", impression, created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM sponsored_message_impressions WHERE id = $1", impression.ID)
})
service := moderationapp.NewService(store)
type result struct {
report domain.ModerationReport
created bool
err error
}
start := make(chan struct{})
results := make(chan result, 2)
var wg sync.WaitGroup
for _, option := range []struct {
reason domain.ModerationReason
option string
}{
{domain.ModerationReasonSpam, "spam"},
{domain.ModerationReasonFake, "fake"},
} {
wg.Add(1)
go func(reason domain.ModerationReason, option string) {
defer wg.Done()
<-start
report, created, err := service.ReportSponsored(
ctx, userID, randomID, reason, option, now.Add(time.Second),
)
results <- result{report: report, created: created, err: err}
}(option.reason, option.option)
}
close(start)
wg.Wait()
close(results)
var reportID int64
var createdCount int
for got := range results {
if got.err != nil || got.report.ID <= 0 {
t.Fatalf("concurrent result=%+v", got)
}
if reportID == 0 {
reportID = got.report.ID
} else if got.report.ID != reportID {
t.Fatalf("report ids differ: %d vs %d", reportID, got.report.ID)
}
if got.created {
createdCount++
}
}
if createdCount != 1 {
t.Fatalf("created count=%d, want 1", createdCount)
}
t.Cleanup(func() { cleanupModerationReport(t, pool, reportID) })
var reportCount int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM moderation_reports
WHERE reporter_user_id = $1 AND source = 'sponsored'`,
userID,
).Scan(&reportCount); err != nil {
t.Fatal(err)
}
if reportCount != 1 {
t.Fatalf("sponsored reports=%d, want 1", reportCount)
}
}
func TestModerationCaseActionAppealLinkAndTelemetryPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC()
reporter := now.UnixNano()&0x3fffffff + 12_000
target := domain.Peer{Type: domain.PeerTypeUser, ID: reporter + 1}
store := NewModerationReportStore(pool)
service := moderationapp.NewService(store)
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
stored, created, err := store.CreateModerationReport(ctx, report)
if err != nil || !created {
t.Fatalf("report=%+v created=%v err=%v", stored, created, err)
}
t.Cleanup(func() { cleanupModerationReport(t, pool, stored.ID) })
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{
Target: target, Limit: 10,
})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
claimed, err := store.ClaimModerationCase(
ctx, cases[0].ID, cases[0].Version, "pg-reviewer", now.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
decision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "pg-reviewer", Reason: "confirmed fake",
CommandID: "pg-moderation-decision-" + time.Unix(0, reporter).Format("150405.000000000"),
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(2 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.DecideModerationCase(ctx, decision); err != nil || !created {
t.Fatalf("decision created=%v err=%v", created, err)
}
actions, err := store.ClaimModerationActions(
ctx, now.Add(3*time.Second), 10, time.Minute,
)
if err != nil || len(actions) != 1 {
t.Fatalf("actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(4*time.Second),
); err != nil {
t.Fatal(err)
}
token, err := service.IssueAppealLink(
ctx, cases[0].ID, target.ID, now.Add(time.Hour), now.Add(5*time.Second),
)
if err != nil {
t.Fatal(err)
}
appeal, created, err := service.SubmitAppealLink(
ctx, token, "Postgres appeal.", now.Add(6*time.Second),
)
if err != nil || !created || appeal.ID <= 0 {
t.Fatalf("appeal=%+v created=%v err=%v", appeal, created, err)
}
retry, created, err := service.SubmitAppealLink(
ctx, token, "retry body", now.Add(7*time.Second),
)
if err != nil || created || retry.ID != appeal.ID ||
retry.Text != appeal.Text {
t.Fatalf("appeal retry=%+v created=%v err=%v", retry, created, err)
}
telemetryStore := NewClientTelemetryStore(pool)
telemetryAt := time.Unix(reporter%1_000_000+1, 0).UTC()
event, err := domain.NewClientTelemetryEvent(
reporter, domain.ClientTelemetryMessageDelivery, target,
[]int64{3, 1, 2}, map[string]any{"push": true}, telemetryAt,
)
if err != nil {
t.Fatal(err)
}
telemetry, created, err := telemetryStore.CreateClientTelemetry(ctx, event)
if err != nil || !created || telemetry.ID <= 0 {
t.Fatalf("telemetry=%+v created=%v err=%v", telemetry, created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM client_telemetry_events WHERE id = $1", telemetry.ID)
})
retryTelemetry, created, err := telemetryStore.CreateClientTelemetry(ctx, event)
if err != nil || created || retryTelemetry.ID != telemetry.ID {
t.Fatalf("telemetry retry=%+v created=%v err=%v", retryTelemetry, created, err)
}
deleted, err := telemetryStore.DeleteExpiredClientTelemetry(
ctx, telemetryAt.Add(time.Second), 10,
)
if err != nil || deleted < 1 {
t.Fatalf("telemetry retention deleted=%d err=%v", deleted, err)
}
}
func TestModerationSanctionSupersessionAndAppealOwnershipPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC()
store := NewModerationReportStore(pool)
service := moderationapp.NewService(store)
base := now.UnixNano()&0x3fffffff + 40_000
createDecision := func(target domain.Peer, reporter int64, option, command string, at time.Time) (int64, int64) {
t.Helper()
report, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake, Option: option,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: at,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cleanupModerationReport(t, pool, report.ID) })
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
Target: target, Limit: 10,
})
if err != nil || len(cases) != 1 {
t.Fatalf("open cases=%+v err=%v", cases, err)
}
claimed, err := service.ClaimCase(
ctx, cases[0].ID, cases[0].Version, "pg-owner", at.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
detail, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "pg-owner", Reason: "confirmed", CommandID: command,
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: at.Add(2 * time.Second),
})
if err != nil || len(detail.Actions) != 1 {
t.Fatalf("decision=%+v err=%v", detail, err)
}
return claimed.ID, detail.Actions[0].ID
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: base + 1}
oldCaseID, oldActionID := createDecision(target, base+2, "old", "pg-old", now)
newCaseID, newActionID := createDecision(target, base+3, "new", "pg-new", now.Add(3*time.Second))
claimedActions, err := store.ClaimModerationActions(ctx, now.Add(6*time.Second), 10, time.Minute)
if err != nil {
t.Fatal(err)
}
claimedByID := make(map[int64]domain.ModerationAction, len(claimedActions))
for _, action := range claimedActions {
claimedByID[action.ID] = action
}
oldAction, oldFound := claimedByID[oldActionID]
newAction, newFound := claimedByID[newActionID]
if !oldFound || !newFound {
t.Fatalf("claimed actions=%+v", claimedActions)
}
if current, err := store.IsModerationActionCurrent(ctx, oldAction); err != nil || current {
t.Fatalf("old current=%v err=%v", current, err)
}
if current, err := store.IsModerationActionCurrent(ctx, newAction); err != nil || !current {
t.Fatalf("new current=%v err=%v", current, err)
}
if err := store.SupersedeModerationAction(
ctx, oldAction.ID, oldAction.Attempts, now.Add(7*time.Second),
); err != nil {
t.Fatal(err)
}
if err := store.CompleteModerationAction(
ctx, newAction.ID, newAction.Attempts, true, "", time.Time{},
now.Add(8*time.Second),
); err != nil {
t.Fatal(err)
}
oldDetail, _, err := service.Case(ctx, oldCaseID)
if err != nil || oldDetail.Case.Status != domain.ModerationCaseResolved ||
oldDetail.Actions[0].Status != domain.ModerationActionSuperseded {
t.Fatalf("old detail=%+v err=%v", oldDetail, err)
}
newDetail, _, err := service.Case(ctx, newCaseID)
if err != nil || newDetail.Case.Status != domain.ModerationCaseResolved ||
newDetail.Actions[0].Status != domain.ModerationActionSucceeded {
t.Fatalf("new detail=%+v err=%v", newDetail, err)
}
appealTarget := domain.Peer{Type: domain.PeerTypeUser, ID: base + 10}
appealedCaseID, appealedActionID := createDecision(
appealTarget, base+11, "appealed", "pg-appealed", now.Add(10*time.Second),
)
actions, err := store.ClaimModerationActions(ctx, now.Add(13*time.Second), 10, time.Minute)
if err != nil || len(actions) != 1 || actions[0].ID != appealedActionID {
t.Fatalf("appealed action=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "", time.Time{},
now.Add(14*time.Second),
); err != nil {
t.Fatal(err)
}
appeal, _, err := service.SubmitAppeal(
ctx, appealedCaseID, appealTarget.ID, "please review", now.Add(15*time.Second),
)
if err != nil {
t.Fatal(err)
}
_, _ = createDecision(
appealTarget, base+12, "newer", "pg-newer-owner", now.Add(16*time.Second),
)
appealedDetail, _, err := service.Case(ctx, appealedCaseID)
if err != nil {
t.Fatal(err)
}
appealClaim, err := service.ClaimCase(
ctx, appealedCaseID, appealedDetail.Case.Version, "pg-owner", now.Add(19*time.Second),
)
if err != nil {
t.Fatal(err)
}
_, _, err = service.ReviewAppeal(ctx, domain.ModerationDecisionRequest{
CaseID: appealedCaseID, AppealID: appeal.ID,
ExpectedVersion: appealClaim.Version, Actor: "pg-owner",
Reason: "grant", CommandID: "pg-stale-appeal",
Kind: domain.ModerationDecisionAppealGrant,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(20 * time.Second),
})
if !errors.Is(err, domain.ErrModerationActionConflict) {
t.Fatalf("ReviewAppeal error=%v", err)
}
}
func cleanupModerationReport(t *testing.T, pool *pgxpool.Pool, reportID int64) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Errorf("begin moderation cleanup: %v", err)
return
}
defer func() { _ = tx.Rollback(ctx) }()
var caseID int64
err = tx.QueryRow(ctx, `
SELECT case_id FROM moderation_case_reports WHERE report_id = $1`,
reportID,
).Scan(&caseID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("find moderation cleanup case: %v", err)
return
}
if _, err := tx.Exec(ctx, "DELETE FROM sponsored_message_impressions WHERE report_id = $1", reportID); err != nil {
t.Errorf("cleanup sponsored impression: %v", err)
return
}
if _, err := tx.Exec(ctx, "DELETE FROM channel_antispam_decisions WHERE report_id = $1", reportID); err != nil {
t.Errorf("cleanup anti-spam decision: %v", err)
return
}
if caseID > 0 {
for _, statement := range []string{
"DELETE FROM moderation_actions WHERE case_id = $1",
"DELETE FROM moderation_decisions WHERE case_id = $1",
"DELETE FROM moderation_appeal_links WHERE case_id = $1",
"DELETE FROM moderation_appeals WHERE case_id = $1",
"DELETE FROM moderation_case_reports WHERE case_id = $1",
"DELETE FROM moderation_cases WHERE id = $1",
} {
if _, err := tx.Exec(ctx, statement, caseID); err != nil {
t.Errorf("moderation cleanup %q: %v", statement, err)
return
}
}
}
if _, err := tx.Exec(ctx, "DELETE FROM moderation_legacy_ephemeral_migrations WHERE moderation_report_id = $1", reportID); err != nil {
t.Errorf("cleanup legacy moderation mapping: %v", err)
return
}
if _, err := tx.Exec(ctx, "DELETE FROM moderation_reports WHERE id = $1", reportID); err != nil {
t.Errorf("cleanup moderation report: %v", err)
return
}
if err := tx.Commit(ctx); err != nil {
t.Errorf("commit moderation cleanup: %v", err)
}
}

View file

@ -0,0 +1,353 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func (s *ModerationReportStore) CreateSponsoredMessageImpression(ctx context.Context, impression domain.SponsoredMessageImpression) (domain.SponsoredMessageImpression, bool, error) {
if s == nil || s.db == nil {
return domain.SponsoredMessageImpression{}, false, fmt.Errorf("moderation report store is not configured")
}
if err := impression.Validate(); err != nil || impression.ID != 0 ||
impression.ReportID != 0 {
return domain.SponsoredMessageImpression{}, false, domain.ErrModerationReportInvalid
}
err := s.db.QueryRow(ctx, `
INSERT INTO sponsored_message_impressions (
user_id, random_id_hash, target_peer_type, target_peer_id,
author_user_id, evidence_schema_version, evidence, evidence_hash,
created_at, expires_at
) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10)
ON CONFLICT (user_id, random_id_hash) DO NOTHING
RETURNING id`,
impression.UserID, impression.RandomIDHash[:],
string(impression.Target.Type), impression.Target.ID,
impression.AuthorUserID, impression.EvidenceSchemaVersion,
[]byte(impression.Evidence), impression.EvidenceHash[:],
impression.CreatedAt, impression.ExpiresAt,
).Scan(&impression.ID)
if err == nil {
return impression, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.SponsoredMessageImpression{}, false, fmt.Errorf("insert sponsored impression: %w", err)
}
existing, found, err := s.GetSponsoredMessageImpression(
ctx, impression.UserID, impression.RandomIDHash, impression.CreatedAt,
)
if err != nil {
return domain.SponsoredMessageImpression{}, false, err
}
if !found || existing.Target != impression.Target ||
existing.AuthorUserID != impression.AuthorUserID ||
existing.EvidenceHash != impression.EvidenceHash ||
!existing.ExpiresAt.Equal(impression.ExpiresAt) {
return domain.SponsoredMessageImpression{}, false, domain.ErrModerationActionConflict
}
return existing, false, nil
}
func (s *ModerationReportStore) GetSponsoredMessageImpression(ctx context.Context, userID int64, randomIDHash [32]byte, now time.Time) (domain.SponsoredMessageImpression, bool, error) {
if s == nil || s.db == nil {
return domain.SponsoredMessageImpression{}, false, fmt.Errorf("moderation report store is not configured")
}
if userID <= 0 || randomIDHash == ([32]byte{}) || now.IsZero() {
return domain.SponsoredMessageImpression{}, false, domain.ErrModerationReportInvalid
}
impression, err := scanSponsoredMessageImpression(s.db.QueryRow(ctx, `
SELECT id, user_id, random_id_hash, target_peer_type, target_peer_id,
author_user_id, evidence_schema_version, evidence, evidence_hash,
report_id, created_at, expires_at
FROM sponsored_message_impressions
WHERE user_id = $1 AND random_id_hash = $2 AND expires_at > $3`,
userID, randomIDHash[:], now,
))
if errors.Is(err, pgx.ErrNoRows) {
return domain.SponsoredMessageImpression{}, false, nil
}
if err != nil {
return domain.SponsoredMessageImpression{}, false, fmt.Errorf("get sponsored impression: %w", err)
}
return impression, true, nil
}
func (s *ModerationReportStore) CreateSponsoredModerationReport(ctx context.Context, impressionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if impressionID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin sponsored moderation report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
impression, err := scanSponsoredMessageImpression(tx.QueryRow(ctx, `
SELECT id, user_id, random_id_hash, target_peer_type, target_peer_id,
author_user_id, evidence_schema_version, evidence, evidence_hash,
report_id, created_at, expires_at
FROM sponsored_message_impressions
WHERE id = $1
FOR UPDATE`, impressionID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock sponsored impression: %w", err)
}
if !report.CreatedAt.Before(impression.ExpiresAt) {
return domain.ModerationReport{}, false, domain.ErrModerationImpressionExpired
}
if err := domain.ValidateSponsoredModerationReport(impression, report); err != nil {
return domain.ModerationReport{}, false, err
}
if impression.ReportID > 0 {
existing, found, err := getModerationReport(ctx, tx, impression.ReportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
}
return existing, false, nil
}
stored, created, err := createModerationReportTx(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
tag, err := tx.Exec(ctx, `
UPDATE sponsored_message_impressions
SET report_id = $2
WHERE id = $1 AND report_id IS NULL`, impressionID, stored.ID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("link sponsored report: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.ModerationReport{}, false, domain.ErrModerationActionConflict
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit sponsored moderation report: %w", err)
}
return stored, created, nil
}
func (s *ModerationReportStore) CreateChannelAntiSpamDecision(ctx context.Context, decision domain.ChannelAntiSpamDecision) (domain.ChannelAntiSpamDecision, bool, error) {
if s == nil || s.db == nil {
return domain.ChannelAntiSpamDecision{}, false, fmt.Errorf("moderation report store is not configured")
}
if err := decision.Validate(); err != nil || decision.ID != 0 ||
decision.ReportID != 0 {
return domain.ChannelAntiSpamDecision{}, false, domain.ErrModerationReportInvalid
}
err := s.db.QueryRow(ctx, `
INSERT INTO channel_antispam_decisions (
channel_id, message_id, author_user_id, evidence_schema_version,
evidence, evidence_hash, created_at
) VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7)
ON CONFLICT (channel_id, message_id) DO NOTHING
RETURNING id`,
decision.ChannelID, decision.MessageID, decision.AuthorUserID,
decision.EvidenceSchemaVersion, []byte(decision.Evidence),
decision.EvidenceHash[:], decision.CreatedAt,
).Scan(&decision.ID)
if err == nil {
return decision, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelAntiSpamDecision{}, false, fmt.Errorf("insert anti-spam decision: %w", err)
}
existing, found, err := s.GetChannelAntiSpamDecision(
ctx, decision.ChannelID, decision.MessageID,
)
if err != nil {
return domain.ChannelAntiSpamDecision{}, false, err
}
if !found || existing.AuthorUserID != decision.AuthorUserID ||
existing.EvidenceHash != decision.EvidenceHash {
return domain.ChannelAntiSpamDecision{}, false, domain.ErrModerationActionConflict
}
return existing, false, nil
}
func (s *ModerationReportStore) GetChannelAntiSpamDecision(ctx context.Context, channelID int64, messageID int) (domain.ChannelAntiSpamDecision, bool, error) {
if s == nil || s.db == nil {
return domain.ChannelAntiSpamDecision{}, false, fmt.Errorf("moderation report store is not configured")
}
if channelID <= 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID {
return domain.ChannelAntiSpamDecision{}, false, domain.ErrModerationReportInvalid
}
decision, err := scanChannelAntiSpamDecision(s.db.QueryRow(ctx, `
SELECT id, channel_id, message_id, author_user_id,
evidence_schema_version, evidence, evidence_hash, report_id,
created_at
FROM channel_antispam_decisions
WHERE channel_id = $1 AND message_id = $2`, channelID, messageID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelAntiSpamDecision{}, false, nil
}
if err != nil {
return domain.ChannelAntiSpamDecision{}, false, fmt.Errorf("get anti-spam decision: %w", err)
}
return decision, true, nil
}
func (s *ModerationReportStore) CreateAntiSpamFalsePositiveReport(ctx context.Context, decisionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if decisionID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin anti-spam false-positive report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
decision, err := scanChannelAntiSpamDecision(tx.QueryRow(ctx, `
SELECT id, channel_id, message_id, author_user_id,
evidence_schema_version, evidence, evidence_hash, report_id,
created_at
FROM channel_antispam_decisions
WHERE id = $1
FOR UPDATE`, decisionID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock anti-spam decision: %w", err)
}
if err := domain.ValidateAntiSpamFalsePositiveReport(decision, report); err != nil {
return domain.ModerationReport{}, false, err
}
if decision.ReportID > 0 {
existing, found, err := getModerationReport(ctx, tx, decision.ReportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
}
return existing, false, nil
}
stored, created, err := createModerationReportTx(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
tag, err := tx.Exec(ctx, `
UPDATE channel_antispam_decisions
SET report_id = $2
WHERE id = $1 AND report_id IS NULL`, decisionID, stored.ID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("link anti-spam report: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.ModerationReport{}, false, domain.ErrModerationActionConflict
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit anti-spam false-positive report: %w", err)
}
return stored, created, nil
}
func (s *ModerationReportStore) DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("moderation report store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrModerationReportInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM sponsored_message_impressions
WHERE expires_at < $1
ORDER BY expires_at, id
LIMIT $2
)
DELETE FROM sponsored_message_impressions i
USING doomed d
WHERE i.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired sponsored impressions: %w", err)
}
return int(tag.RowsAffected()), nil
}
func scanSponsoredMessageImpression(row moderationCaseScanner) (domain.SponsoredMessageImpression, error) {
var impression domain.SponsoredMessageImpression
var randomIDHash, evidence, evidenceHash []byte
var peerType string
var reportID *int64
if err := row.Scan(
&impression.ID, &impression.UserID, &randomIDHash, &peerType,
&impression.Target.ID, &impression.AuthorUserID,
&impression.EvidenceSchemaVersion, &evidence, &evidenceHash,
&reportID, &impression.CreatedAt, &impression.ExpiresAt,
); err != nil {
return domain.SponsoredMessageImpression{}, err
}
if len(randomIDHash) != len(impression.RandomIDHash) ||
len(evidenceHash) != len(impression.EvidenceHash) {
return domain.SponsoredMessageImpression{}, domain.ErrModerationReportInvalid
}
copy(impression.RandomIDHash[:], randomIDHash)
copy(impression.EvidenceHash[:], evidenceHash)
impression.Target.Type = domain.PeerType(peerType)
canonical, err := domain.CanonicalModerationEvidence(evidence)
if err != nil {
return domain.SponsoredMessageImpression{}, err
}
impression.Evidence = canonical
if reportID != nil {
impression.ReportID = *reportID
}
if err := impression.Validate(); err != nil {
return domain.SponsoredMessageImpression{}, err
}
return impression, nil
}
func scanChannelAntiSpamDecision(row moderationCaseScanner) (domain.ChannelAntiSpamDecision, error) {
var decision domain.ChannelAntiSpamDecision
var evidence, evidenceHash []byte
var reportID *int64
if err := row.Scan(
&decision.ID, &decision.ChannelID, &decision.MessageID,
&decision.AuthorUserID, &decision.EvidenceSchemaVersion,
&evidence, &evidenceHash, &reportID, &decision.CreatedAt,
); err != nil {
return domain.ChannelAntiSpamDecision{}, err
}
if len(evidenceHash) != len(decision.EvidenceHash) {
return domain.ChannelAntiSpamDecision{}, domain.ErrModerationReportInvalid
}
copy(decision.EvidenceHash[:], evidenceHash)
canonical, err := domain.CanonicalModerationEvidence(evidence)
if err != nil {
return domain.ChannelAntiSpamDecision{}, err
}
decision.Evidence = canonical
if reportID != nil {
decision.ReportID = *reportID
}
if err := decision.Validate(); err != nil {
return domain.ChannelAntiSpamDecision{}, err
}
return decision, nil
}

View file

@ -18,25 +18,40 @@ const (
peerUsernameTypeChannel = "channel"
)
// peerUsernameColumns is the registry projection shared by every reader. The
// collectible id is coalesced so domain.Username keeps a plain int64 zero for
// the editable slot.
const peerUsernameColumns = `username, active, editable, sort_order, COALESCE(collectible_id, 0)`
type peerUsernameOwner struct {
peerType string
peerID int64
// collectible marks a row backed by a collectible asset. Such a row is never
// editable, so the client-driven username path must not reuse or delete it.
collectible bool
// active mirrors the registry flag. An inactive name stays occupied for
// uniqueness purposes but must not resolve to its holder.
active bool
}
func (o peerUsernameOwner) matches(peerType string, peerID int64) bool {
return o.peerType == peerType && o.peerID == peerID
}
// getPeerUsernameOwner resolves the holder of a name across the whole registry:
// username uniqueness is global and covers collectible rows as well as editable
// ones, so occupancy checks and username resolution keep a single source of
// truth.
func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower string, forUpdate bool) (peerUsernameOwner, bool, error) {
if usernameLower == "" {
return peerUsernameOwner{}, false, nil
}
query := `SELECT peer_type, peer_id FROM peer_usernames WHERE username_lower = $1`
query := `SELECT peer_type, peer_id, collectible_id IS NOT NULL, active FROM peer_usernames WHERE username_lower = $1`
if forUpdate {
query += ` FOR UPDATE`
}
var owner peerUsernameOwner
err := db.QueryRow(ctx, query, usernameLower).Scan(&owner.peerType, &owner.peerID)
err := db.QueryRow(ctx, query, usernameLower).Scan(&owner.peerType, &owner.peerID, &owner.collectible, &owner.active)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return peerUsernameOwner{}, false, nil
@ -54,25 +69,68 @@ func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower,
return owner.matches(peerType, peerID), nil
}
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, usernameLower string) error {
// activeCollectibleUsernamePeerIDs returns the requested peers that own at
// least one active collectible username. Editable registry rows are excluded:
// their scalar users.username/channels.username value is the cross-check that
// prevents a stale registry row from making a private peer public.
func activeCollectibleUsernamePeerIDs(ctx context.Context, db sqlcgen.DBTX, peerType string, peerIDs []int64) (map[int64]struct{}, error) {
out := make(map[int64]struct{})
if len(peerIDs) == 0 {
return out, nil
}
rows, err := db.Query(ctx, `
SELECT DISTINCT peer_id
FROM peer_usernames
WHERE peer_type = $1
AND active
AND collectible_id IS NOT NULL
AND peer_id = ANY($2::bigint[])`, peerType, peerIDs)
if err != nil {
return nil, fmt.Errorf("list peers with active usernames: %w", err)
}
defer rows.Close()
for rows.Next() {
var peerID int64
if err := rows.Scan(&peerID); err != nil {
return nil, fmt.Errorf("scan peer with active username: %w", err)
}
out[peerID] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list peers with active usernames: %w", err)
}
return out, nil
}
// replacePeerUsernameTx rewrites the peer's editable username slot. username is
// the display form (original case) and usernameLower its registry key; an empty
// pair clears the slot.
//
// Only the editable row is replaced. Collectible rows belong to assets in
// collectible_usernames and must survive every client-driven username edit,
// otherwise account.updateUsername would silently release a minted asset.
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
if usernameLower != "" {
owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
if err != nil {
return err
}
if found && !owner.matches(peerType, peerID) {
// A collectible row occupies the name even for its own holder: the
// editable slot cannot duplicate a name the peer already holds as an asset.
if found && (!owner.matches(peerType, peerID) || owner.collectible) {
return domain.ErrUsernameOccupied
}
}
if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2`, peerType, peerID); err != nil {
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2 AND editable`, peerType, peerID); err != nil {
return fmt.Errorf("delete peer username: %w", err)
}
if usernameLower == "" {
return nil
}
if _, err := tx.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
VALUES ($1, $2, $3)`, usernameLower, peerType, peerID); err != nil {
INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id)
VALUES ($1, $2, $3, $4, true, true, 0, NULL)`, usernameLower, peerType, peerID, username); err != nil {
if isUniqueViolation(err) {
return domain.ErrUsernameOccupied
}
@ -81,13 +139,164 @@ VALUES ($1, $2, $3)`, usernameLower, peerType, peerID); err != nil {
return nil
}
// deletePeerUsernameTx clears the peer's editable slot only. Collectible rows are
// released through the asset lifecycle (revoke/burn) or by the peer-deletion
// trigger, never by an editable-slot edit.
func deletePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) error {
if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2`, peerType, peerID); err != nil {
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2 AND editable`, peerType, peerID); err != nil {
return fmt.Errorf("delete peer username: %w", err)
}
return nil
}
// listPeerUsernames returns the peer's registry rows in projection order:
// editable slot first, then collectibles by stored sort order.
func listPeerUsernames(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer) ([]domain.Username, error) {
if peer.Type == "" || peer.ID <= 0 {
return nil, nil
}
rows, err := db.Query(ctx, `
SELECT `+peerUsernameColumns+`
FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2
ORDER BY editable DESC, sort_order, username_lower`, string(peer.Type), peer.ID)
if err != nil {
return nil, fmt.Errorf("list peer usernames: %w", err)
}
defer rows.Close()
out := make([]domain.Username, 0, 4)
for rows.Next() {
var item domain.Username
if err := rows.Scan(&item.Username, &item.Active, &item.Editable, &item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan peer username: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate peer usernames: %w", err)
}
return domain.SortUsernames(out), nil
}
// listPeerUsernamesBatch resolves several peers in one round trip.
func listPeerUsernamesBatch(ctx context.Context, db sqlcgen.DBTX, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
out := make(map[domain.Peer][]domain.Username, len(peers))
types := make([]string, 0, len(peers))
ids := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID <= 0 {
continue
}
if _, dup := seen[peer]; dup {
continue
}
seen[peer] = struct{}{}
types = append(types, string(peer.Type))
ids = append(ids, peer.ID)
}
if len(types) == 0 {
return out, nil
}
rows, err := db.Query(ctx, `
SELECT peer_type, peer_id, `+peerUsernameColumns+`
FROM peer_usernames
WHERE (peer_type, peer_id) IN (SELECT t, i FROM unnest($1::text[], $2::bigint[]) AS s(t, i))
ORDER BY peer_type, peer_id, editable DESC, sort_order, username_lower`, types, ids)
if err != nil {
return nil, fmt.Errorf("list peer usernames batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var peer domain.Peer
var peerType string
var item domain.Username
if err := rows.Scan(&peerType, &peer.ID, &item.Username, &item.Active, &item.Editable,
&item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan peer username batch: %w", err)
}
peer.Type = domain.PeerType(peerType)
out[peer] = append(out[peer], item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate peer usernames batch: %w", err)
}
for peer, list := range out {
out[peer] = domain.SortUsernames(list)
}
return out, nil
}
// lockPeerUsernamesTx reads the peer's registry rows with row locks so a toggle
// or reorder validated against the list cannot race a concurrent asset move.
func lockPeerUsernamesTx(ctx context.Context, tx pgx.Tx, peer domain.Peer) ([]domain.Username, error) {
rows, err := tx.Query(ctx, `
SELECT `+peerUsernameColumns+`
FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2
ORDER BY username_lower
FOR UPDATE`, string(peer.Type), peer.ID)
if err != nil {
return nil, fmt.Errorf("lock peer usernames: %w", err)
}
defer rows.Close()
out := make([]domain.Username, 0, 4)
for rows.Next() {
var item domain.Username
if err := rows.Scan(&item.Username, &item.Active, &item.Editable, &item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan locked peer username: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate locked peer usernames: %w", err)
}
return domain.SortUsernames(out), nil
}
// countPeerCollectibleUsernamesTx bounds the collectible rows a peer may hold.
func countPeerCollectibleUsernamesTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) (int, error) {
var count int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2 AND collectible_id IS NOT NULL`, peerType, peerID).Scan(&count); err != nil {
return 0, fmt.Errorf("count peer collectible usernames: %w", err)
}
return count, nil
}
// insertCollectiblePeerUsernameTx projects an owned asset into the registry. The
// row sorts after every collectible the peer already holds and always carries
// collectible_id, which the schema forbids on an editable row.
func insertCollectiblePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string, collectibleID int64) error {
if _, err := tx.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id, updated_at)
SELECT $1, $2, $3, $4, true, false, LEAST(
COALESCE((
SELECT max(sort_order) + 1 FROM peer_usernames
WHERE peer_type = $2 AND peer_id = $3 AND collectible_id IS NOT NULL
), 0), $6::int
), $5, now()`,
usernameLower, peerType, peerID, username, collectibleID, domain.MaxUsernameSortOrder); err != nil {
if isUniqueViolation(err) {
return domain.ErrUsernameOccupied
}
return fmt.Errorf("insert collectible peer username: %w", err)
}
return nil
}
// deleteCollectiblePeerUsernameTx removes the registry projection of an asset,
// releasing the name for anyone else the moment the asset stops being owned.
func deleteCollectiblePeerUsernameTx(ctx context.Context, tx pgx.Tx, collectibleID int64) error {
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE collectible_id = $1`, collectibleID); err != nil {
return fmt.Errorf("delete collectible peer username: %w", err)
}
return nil
}
func isUniqueConstraint(err error, constraintName string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == constraintName

View file

@ -46,11 +46,15 @@ WHERE owner_user_id = $1
}
func (s *PrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error {
return setPrivacyRules(ctx, s.db, rules)
}
func setPrivacyRules(ctx context.Context, db sqlcgen.DBTX, rules domain.PrivacyRules) error {
raw, err := json.Marshal(rules.Rules)
if err != nil {
return err
}
_, err = s.db.Exec(ctx, `
_, err = db.Exec(ctx, `
INSERT INTO account_privacy_rules (owner_user_id, privacy_key, rules, updated_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (owner_user_id, privacy_key) DO UPDATE SET

View file

@ -0,0 +1,103 @@
package postgres
import (
"context"
"errors"
"testing"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
)
// TestPrivacyRulesDoNotAllocateAccountPts protects the protocol boundary:
// account privacy is authoritative absolute state, not a message-box event.
func TestPrivacyRulesDoNotAllocateAccountPts(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
user, err := users.Create(ctx, domain.User{
AccessHash: 9201,
Phone: "+1665" + suffix + "01",
FirstName: "PrivacyPts",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID)
})
var privacyPayloadTableAbsent bool
if err := pool.QueryRow(ctx, `
SELECT to_regclass('public.user_update_privacy_payloads') IS NULL`).Scan(&privacyPayloadTableAbsent); err != nil {
t.Fatalf("inspect privacy payload schema: %v", err)
}
if !privacyPayloadTableAbsent {
t.Fatal("development-only user_update_privacy_payloads table still exists")
}
if _, err := pool.Exec(ctx, `
INSERT INTO user_update_events (user_id, pts, pts_count, date, event_type)
VALUES ($1, 1, 1, 1700000000, 'privacy')`, user.ID); err == nil {
t.Fatal("development-only privacy update event type is still accepted")
} else {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != pgerrcode.CheckViolation {
t.Fatalf("insert privacy update event error=%v, want check violation", err)
}
}
type updateFootprint struct {
eventCount int
maxPts int
outboxCount int
watermarkRow int
watermarkPts int
}
readFootprint := func() updateFootprint {
t.Helper()
var got updateFootprint
if err := pool.QueryRow(ctx, `
SELECT count(*), COALESCE(max(pts), 0)
FROM user_update_events
WHERE user_id = $1`, user.ID).Scan(&got.eventCount, &got.maxPts); err != nil {
t.Fatalf("read update events footprint: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM dispatch_outbox
WHERE target_user_id = $1`, user.ID).Scan(&got.outboxCount); err != nil {
t.Fatalf("read outbox footprint: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*), COALESCE(max(contiguous_pts), 0)
FROM user_update_watermarks
WHERE user_id = $1`, user.ID).Scan(&got.watermarkRow, &got.watermarkPts); err != nil {
t.Fatalf("read update watermark footprint: %v", err)
}
return got
}
before := readFootprint()
store := NewPrivacyStore(pool)
want := domain.PrivacyRules{
OwnerUserID: user.ID,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}
if err := store.SetPrivacyRules(ctx, want); err != nil {
t.Fatalf("set privacy rules: %v", err)
}
got, found, err := store.GetPrivacyRules(ctx, user.ID, want.Key)
if err != nil || !found {
t.Fatalf("get privacy rules: found=%v err=%v", found, err)
}
if len(got.Rules) != 1 || got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("stored privacy rules=%+v, want disallow_all", got)
}
after := readFootprint()
if after != before {
t.Fatalf("privacy write changed PTS footprint: before=%+v after=%+v", before, after)
}
}

View file

@ -880,30 +880,6 @@ WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint;
-- name: ClearDialogAfterHistoryDelete :exec
UPDATE dialogs d
SET
top_message_id = 0,
top_message_date = 0,
read_inbox_max_id = GREATEST(d.read_inbox_max_id, d.top_message_id),
read_outbox_max_id = GREATEST(d.read_outbox_max_id, d.top_message_id),
unread_count = 0,
unread_mark = false,
unread_mentions_count = 0,
unread_reactions_count = (
SELECT COUNT(*)::int
FROM message_boxes m2
WHERE m2.owner_user_id = d.user_id
AND m2.peer_type = d.peer_type
AND m2.peer_id = d.peer_id
AND NOT m2.deleted
AND m2.reaction_unread
),
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint;
-- name: DeleteDialogByPeer :exec
WITH dropped_drafts AS (
-- 删除会话同时丢弃该 peer 的云草稿,避免对端重建会话后旧草稿复活。

View file

@ -545,6 +545,8 @@ base AS NOT MATERIALIZED (
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
)
AND (sqlc.arg(min_date)::int <= 0 OR m.message_date > sqlc.arg(min_date)::int)
AND (sqlc.arg(max_date)::int <= 0 OR m.message_date < sqlc.arg(max_date)::int)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int)
AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int)
AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned)
@ -564,6 +566,17 @@ base AS NOT MATERIALIZED (
sqlc.arg(saved_peer_type)::text = ''
OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint)
)
AND (
cardinality(sqlc.arg(saved_reaction_keys)::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY(sqlc.arg(saved_reaction_keys)::text[])
)
)
),
total AS (
SELECT count(*)::int AS total_count
@ -810,6 +823,8 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
)
AND (sqlc.arg(min_date)::int <= 0 OR m.message_date > sqlc.arg(min_date)::int)
AND (sqlc.arg(max_date)::int <= 0 OR m.message_date < sqlc.arg(max_date)::int)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int)
AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int)
AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned)
@ -829,6 +844,17 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
sqlc.arg(saved_peer_type)::text = ''
OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint)
)
AND (
cardinality(sqlc.arg(saved_reaction_keys)::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY(sqlc.arg(saved_reaction_keys)::text[])
)
)
AND (
(sqlc.arg(offset_date)::int > 0 AND m.message_date < sqlc.arg(offset_date)::int)
OR (sqlc.arg(offset_date)::int <= 0 AND (sqlc.arg(offset_id)::int <= 0 OR m.box_id < sqlc.arg(offset_id)::int))
@ -856,6 +882,8 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
)
AND (sqlc.arg(min_date)::int <= 0 OR m.message_date > sqlc.arg(min_date)::int)
AND (sqlc.arg(max_date)::int <= 0 OR m.message_date < sqlc.arg(max_date)::int)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int)
AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int)
AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned)
@ -874,6 +902,17 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND (
sqlc.arg(saved_peer_type)::text = ''
OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint)
)
AND (
cardinality(sqlc.arg(saved_reaction_keys)::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY(sqlc.arg(saved_reaction_keys)::text[])
)
);
-- name: GetMessageBoxesByIDs :many
@ -1304,6 +1343,7 @@ WITH target AS (
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND (sqlc.arg(keep_box_id)::int <= 0 OR m.box_id <> sqlc.arg(keep_box_id)::int)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int)
AND (sqlc.arg(min_date)::int <= 0 OR m.message_date >= sqlc.arg(min_date)::int)
AND (sqlc.arg(max_date)::int <= 0 OR m.message_date <= sqlc.arg(max_date)::int)
@ -1343,6 +1383,7 @@ SELECT EXISTS (
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND (sqlc.arg(keep_box_id)::int <= 0 OR m.box_id <> sqlc.arg(keep_box_id)::int)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int)
AND (sqlc.arg(min_date)::int <= 0 OR m.message_date >= sqlc.arg(min_date)::int)
AND (sqlc.arg(max_date)::int <= 0 OR m.message_date <= sqlc.arg(max_date)::int)

View file

@ -23,7 +23,18 @@ ORDER BY id;
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL;
-- name: SearchUsers :many
WITH matched AS (
WITH username_matches AS (
SELECT
peer_id,
bool_or(username_lower = sqlc.arg(query_lower)::text) AS exact
FROM peer_usernames
WHERE peer_type = 'user'
AND active
AND collectible_id IS NOT NULL
AND username_lower LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\'
GROUP BY peer_id
),
matched AS (
SELECT
u.id,
u.access_hash,
@ -54,7 +65,7 @@ WITH matched AS (
COALESCE(c.mutual, false)::boolean AS mutual,
CASE
WHEN sqlc.arg(phone_query)::text <> '' AND u.phone = sqlc.arg(phone_query)::text THEN 0
WHEN lower(u.username) = sqlc.arg(query_lower)::text THEN 1
WHEN COALESCE(um.exact, false) OR lower(u.username) = sqlc.arg(query_lower)::text THEN 1
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = sqlc.arg(query_lower)::text THEN 2
WHEN lower(u.first_name) = sqlc.arg(query_lower)::text THEN 3
WHEN c.contact_user_id IS NOT NULL THEN 4
@ -62,11 +73,13 @@ WITH matched AS (
END AS rank
FROM users u
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
LEFT JOIN username_matches um ON um.peer_id = u.id
WHERE u.id <> sqlc.arg(current_user_id)::bigint
AND u.deleted_at IS NULL
AND sqlc.arg(query_lower)::text <> ''
AND (
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
OR um.peer_id IS NOT NULL
OR lower(u.username) LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(u.first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(u.last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
@ -13,6 +14,8 @@ import (
const readModelChangeNotifyChannel = "telesrv_read_model_changed"
const privacyReadModelWarmTimeout = 5 * time.Second
// ReadModelCacheSet 是 read_model_versions 通知可失效的进程内投影缓存集合。
// 后续新增 read model 时,把缓存接到这里即可复用同一条 LISTEN 连接。
type ReadModelCacheSet struct {
@ -34,6 +37,7 @@ type ReadModelCacheSet struct {
BaseUsers BaseUserCache
BotProfiles BotProfileReadModelCache
StarGifts StarGiftCatalogCache
AccountSettings AccountSettingsReadModelCache
}
type StarGiftCatalogCache interface {
@ -41,6 +45,15 @@ type StarGiftCatalogCache interface {
FlushStarGiftCatalog()
}
type AccountSettingsReadModelCache interface {
InvalidateAccountSettingsReadModel(userID int64)
FlushAccountSettingsReadModel()
}
type AccountSettingsReadModelWarmer interface {
WarmAccountSettingsReadModel(context.Context, int64) error
}
// BaseUserCache 是跨进程共享的 user:base 缓存(Redis)。user_base read-model 事件必须删除
// 对应 user 键,否则 RPC 投影失效后会从陈旧的 user:base 重建(失效被未失效的源自我抵消)。
// 不参与重连 flushRedis 是跨实例共享的,整库清空是错的,漏掉的通知靠其自身 TTL 兜底。
@ -90,6 +103,23 @@ type PrivacyReadModelCache interface {
FlushReadModelCache()
}
// PrivacyReadModelWarmer lets the low-frequency change stream rebuild owner
// snapshots after invalidation, so the next user projection does not own a
// synchronous database miss. It is optional; caches without it remain
// cache-aside and only receive invalidation.
type PrivacyReadModelWarmer interface {
WarmOwners(context.Context, ...int64) error
}
type PrivacyViewerFactsReadModelCache interface {
InvalidateViewerFacts(...int64)
}
type PrivacyMembershipReadModelCache interface {
InvalidateMembership(channelID, userID int64)
InvalidateChannelMemberships(channelID int64)
}
type ProfilePhotoReadModelCache interface {
InvalidateOwner(domain.PeerType, int64)
FlushReadModelCache()
@ -221,7 +251,8 @@ func (l *ReadModelChangeListener) empty() bool {
l.caches.RPCProjections == nil &&
l.caches.BaseUsers == nil &&
l.caches.BotProfiles == nil &&
l.caches.StarGifts == nil
l.caches.StarGifts == nil &&
l.caches.AccountSettings == nil
}
func (l *ReadModelChangeListener) flush(reasons ...string) {
@ -298,6 +329,10 @@ func (l *ReadModelChangeListener) flush(reasons ...string) {
l.caches.StarGifts.FlushStarGiftCatalog()
flushed = append(flushed, "star_gifts")
}
if l.caches.AccountSettings != nil {
l.caches.AccountSettings.FlushAccountSettingsReadModel()
flushed = append(flushed, "account_settings")
}
// 注意BaseUsers(Redis) 刻意不在重连时 flush——它是跨实例共享缓存整库清空会误伤
// 其它实例;漏掉的通知由其 5min TTL 兜底。
l.log.Info("read model caches flushed",
@ -326,6 +361,19 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
}
}
switch evt.Model {
case "account_settings":
if evt.OwnerUserID != 0 && l.caches.AccountSettings != nil {
l.caches.AccountSettings.InvalidateAccountSettingsReadModel(evt.OwnerUserID)
if warmer, ok := l.caches.AccountSettings.(AccountSettingsReadModelWarmer); ok {
ctx, cancel := context.WithTimeout(context.Background(), privacyReadModelWarmTimeout)
err := warmer.WarmAccountSettingsReadModel(ctx, evt.OwnerUserID)
cancel()
if err != nil {
l.log.Warn("warm account settings read model after change",
zap.Int64("owner_user_id", evt.OwnerUserID), zap.Error(err))
}
}
}
case "star_gift_catalog":
if l.caches.StarGifts != nil {
l.caches.StarGifts.InvalidateStarGiftCatalog()
@ -347,6 +395,9 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
if l.caches.BotProfiles != nil {
l.caches.BotProfiles.InvalidateBotProfileReadModel(evt.PeerID)
}
if cache, ok := l.caches.Privacy.(PrivacyViewerFactsReadModelCache); ok {
cache.InvalidateViewerFacts(evt.PeerID)
}
}
case "user_visibility":
if evt.PeerType == "user" && evt.PeerID != 0 {
@ -393,6 +444,15 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID)
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForViewer(evt.OwnerUserID)
}
if warmer, ok := l.caches.Privacy.(PrivacyReadModelWarmer); ok && evt.OwnerUserID != 0 {
ctx, cancel := context.WithTimeout(context.Background(), privacyReadModelWarmTimeout)
err := warmer.WarmOwners(ctx, evt.OwnerUserID)
cancel()
if err != nil {
l.log.Warn("warm privacy read model after change",
zap.Int64("owner_user_id", evt.OwnerUserID), zap.Error(err))
}
}
case "dialog_light":
if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.OwnerUserID != 0 && evt.PeerID != 0 {
peer := domain.Peer{Type: peerType, ID: evt.PeerID}
@ -437,6 +497,9 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
if l.caches.RPCProjections != nil {
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForChannel(evt.PeerID)
}
if cache, ok := l.caches.Privacy.(PrivacyMembershipReadModelCache); ok {
cache.InvalidateChannelMemberships(evt.PeerID)
}
}
case "channel_media_counts":
if evt.PeerType == "channel" && evt.PeerID != 0 && l.caches.ChannelMediaCounts != nil {
@ -467,6 +530,9 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForPeer(evt.OwnerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: evt.PeerID})
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID)
}
if cache, ok := l.caches.Privacy.(PrivacyMembershipReadModelCache); ok {
cache.InvalidateMembership(evt.PeerID, evt.OwnerUserID)
}
}
case "channel_self_boosts":
if evt.PeerType == "channel" && evt.PeerID != 0 && l.caches.ChannelBoosts != nil {

View file

@ -299,7 +299,7 @@ func (s *MessageStore) DeleteSavedHistory(ctx context.Context, req domain.Delete
peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
})
}
delRes, err := s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, false)
delRes, err := s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, nil)
if err != nil {
return res, err
}

View file

@ -35,42 +35,6 @@ func (q *Queries) AdvanceDialogReadInboxFloor(ctx context.Context, arg AdvanceDi
return err
}
const clearDialogAfterHistoryDelete = `-- name: ClearDialogAfterHistoryDelete :exec
UPDATE dialogs d
SET
top_message_id = 0,
top_message_date = 0,
read_inbox_max_id = GREATEST(d.read_inbox_max_id, d.top_message_id),
read_outbox_max_id = GREATEST(d.read_outbox_max_id, d.top_message_id),
unread_count = 0,
unread_mark = false,
unread_mentions_count = 0,
unread_reactions_count = (
SELECT COUNT(*)::int
FROM message_boxes m2
WHERE m2.owner_user_id = d.user_id
AND m2.peer_type = d.peer_type
AND m2.peer_id = d.peer_id
AND NOT m2.deleted
AND m2.reaction_unread
),
updated_at = now()
WHERE d.user_id = $1::bigint
AND d.peer_type = $2::text
AND d.peer_id = $3::bigint
`
type ClearDialogAfterHistoryDeleteParams struct {
UserID int64
PeerType string
PeerID int64
}
func (q *Queries) ClearDialogAfterHistoryDelete(ctx context.Context, arg ClearDialogAfterHistoryDeleteParams) error {
_, err := q.db.Exec(ctx, clearDialogAfterHistoryDelete, arg.UserID, arg.PeerType, arg.PeerID)
return err
}
const clearDialogDrafts = `-- name: ClearDialogDrafts :many
WITH doomed AS (
SELECT d.user_id, d.peer_type, d.peer_id, d.top_message_id

View file

@ -26,11 +26,13 @@ WHERE m.owner_user_id = $1::bigint
$7::text = ''
OR m.body ILIKE ('%' || $7::text || '%')
)
AND ($8::int <= 0 OR m.box_id < $8::int)
AND ($9::int <= 0 OR m.box_id > $9::int)
AND (NOT $10::boolean OR m.pinned)
AND ($8::int <= 0 OR m.message_date > $8::int)
AND ($9::int <= 0 OR m.message_date < $9::int)
AND ($10::int <= 0 OR m.box_id < $10::int)
AND ($11::int <= 0 OR m.box_id > $11::int)
AND (NOT $12::boolean OR m.pinned)
AND (
NOT $11::boolean
NOT $13::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -42,25 +44,39 @@ WHERE m.owner_user_id = $1::bigint
)
)
AND (
$12::text = ''
OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint)
$14::text = ''
OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint)
)
AND (
cardinality($16::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY($16::text[])
)
)
`
type CountMessagesByUserParams struct {
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MinDate int32
MaxDate int32
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
SavedReactionKeys []string
}
// ListMessagesByUser total CTE 的独立化:相同 base 过滤(不含分页 anchor),
@ -74,12 +90,15 @@ func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUs
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MinDate,
arg.MaxDate,
arg.MaxID,
arg.MinID,
arg.PinnedOnly,
arg.MusicOnly,
arg.SavedPeerType,
arg.SavedPeerID,
arg.SavedReactionKeys,
)
var total_count int32
err := row.Scan(&total_count)
@ -867,12 +886,13 @@ WITH target AS (
WHERE m.owner_user_id = $1::bigint
AND m.peer_type = $2::text
AND m.peer_id = $3::bigint
AND ($4::int <= 0 OR m.box_id <= $4::int)
AND ($5::int <= 0 OR m.message_date >= $5::int)
AND ($6::int <= 0 OR m.message_date <= $6::int)
AND ($4::int <= 0 OR m.box_id <> $4::int)
AND ($5::int <= 0 OR m.box_id <= $5::int)
AND ($6::int <= 0 OR m.message_date >= $6::int)
AND ($7::int <= 0 OR m.message_date <= $7::int)
AND NOT m.deleted
ORDER BY m.box_id DESC
LIMIT $7::int
LIMIT $8::int
FOR UPDATE SKIP LOCKED
),
updated AS (
@ -904,6 +924,7 @@ type DeleteMessageBoxesByPeerBatchParams struct {
OwnerUserID int64
PeerType string
PeerID int64
KeepBoxID int32
MaxID int32
MinDate int32
MaxDate int32
@ -924,6 +945,7 @@ func (q *Queries) DeleteMessageBoxesByPeerBatch(ctx context.Context, arg DeleteM
arg.OwnerUserID,
arg.PeerType,
arg.PeerID,
arg.KeepBoxID,
arg.MaxID,
arg.MinDate,
arg.MaxDate,
@ -2089,9 +2111,10 @@ SELECT EXISTS (
WHERE m.owner_user_id = $1::bigint
AND m.peer_type = $2::text
AND m.peer_id = $3::bigint
AND ($4::int <= 0 OR m.box_id <= $4::int)
AND ($5::int <= 0 OR m.message_date >= $5::int)
AND ($6::int <= 0 OR m.message_date <= $6::int)
AND ($4::int <= 0 OR m.box_id <> $4::int)
AND ($5::int <= 0 OR m.box_id <= $5::int)
AND ($6::int <= 0 OR m.message_date >= $6::int)
AND ($7::int <= 0 OR m.message_date <= $7::int)
AND NOT m.deleted
LIMIT 1
)::boolean AS more
@ -2101,6 +2124,7 @@ type HasDeletableMessageBoxByPeerParams struct {
OwnerUserID int64
PeerType string
PeerID int64
KeepBoxID int32
MaxID int32
MinDate int32
MaxDate int32
@ -2111,6 +2135,7 @@ func (q *Queries) HasDeletableMessageBoxByPeer(ctx context.Context, arg HasDelet
arg.OwnerUserID,
arg.PeerType,
arg.PeerID,
arg.KeepBoxID,
arg.MaxID,
arg.MinDate,
arg.MaxDate,
@ -2295,11 +2320,13 @@ WHERE m.owner_user_id = $1::bigint
$7::text = ''
OR m.body ILIKE ('%' || $7::text || '%')
)
AND ($8::int <= 0 OR m.box_id < $8::int)
AND ($9::int <= 0 OR m.box_id > $9::int)
AND (NOT $10::boolean OR m.pinned)
AND ($8::int <= 0 OR m.message_date > $8::int)
AND ($9::int <= 0 OR m.message_date < $9::int)
AND ($10::int <= 0 OR m.box_id < $10::int)
AND ($11::int <= 0 OR m.box_id > $11::int)
AND (NOT $12::boolean OR m.pinned)
AND (
NOT $11::boolean
NOT $13::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -2311,36 +2338,50 @@ WHERE m.owner_user_id = $1::bigint
)
)
AND (
$12::text = ''
OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint)
$14::text = ''
OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint)
)
AND (
($14::int > 0 AND m.message_date < $14::int)
OR ($14::int <= 0 AND ($15::int <= 0 OR m.box_id < $15::int))
cardinality($16::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY($16::text[])
)
)
AND (
($17::int > 0 AND m.message_date < $17::int)
OR ($17::int <= 0 AND ($18::int <= 0 OR m.box_id < $18::int))
)
ORDER BY m.box_id DESC
OFFSET GREATEST($16::int, 0)
LIMIT $17::int
OFFSET GREATEST($19::int, 0)
LIMIT $20::int
`
type ListMessagesBackwardParams struct {
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
OffsetDate int32
OffsetID int32
RowOffset int32
LimitCount int32
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MinDate int32
MaxDate int32
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
SavedReactionKeys []string
OffsetDate int32
OffsetID int32
RowOffset int32
LimitCount int32
}
type ListMessagesBackwardRow struct {
@ -2433,12 +2474,15 @@ func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBack
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MinDate,
arg.MaxDate,
arg.MaxID,
arg.MinID,
arg.PinnedOnly,
arg.MusicOnly,
arg.SavedPeerType,
arg.SavedPeerID,
arg.SavedReactionKeys,
arg.OffsetDate,
arg.OffsetID,
arg.RowOffset,
@ -2641,11 +2685,13 @@ base AS NOT MATERIALIZED (
$11::text = ''
OR m.body ILIKE ('%' || $11::text || '%')
)
AND ($12::int <= 0 OR m.box_id < $12::int)
AND ($13::int <= 0 OR m.box_id > $13::int)
AND (NOT $14::boolean OR m.pinned)
AND ($12::int <= 0 OR m.message_date > $12::int)
AND ($13::int <= 0 OR m.message_date < $13::int)
AND ($14::int <= 0 OR m.box_id < $14::int)
AND ($15::int <= 0 OR m.box_id > $15::int)
AND (NOT $16::boolean OR m.pinned)
AND (
NOT $15::boolean
NOT $17::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -2657,14 +2703,25 @@ base AS NOT MATERIALIZED (
)
)
AND (
$16::text = ''
OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint)
$18::text = ''
OR (m.saved_peer_type = $18::text AND m.saved_peer_id = $19::bigint)
)
AND (
cardinality($20::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY($20::text[])
)
)
),
total AS (
SELECT count(*)::int AS total_count
FROM base
WHERE $18::boolean
WHERE $21::boolean
),
backward AS (
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
@ -2811,24 +2868,27 @@ ORDER BY box_id DESC
`
type ListMessagesByUserParams struct {
OwnerUserID int64
OffsetID int32
OffsetDate int32
AddOffset int32
LimitCount int32
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
NeedTotalCount bool
OwnerUserID int64
OffsetID int32
OffsetDate int32
AddOffset int32
LimitCount int32
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MinDate int32
MaxDate int32
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
SavedReactionKeys []string
NeedTotalCount bool
}
type ListMessagesByUserRow struct {
@ -2921,12 +2981,15 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MinDate,
arg.MaxDate,
arg.MaxID,
arg.MinID,
arg.PinnedOnly,
arg.MusicOnly,
arg.SavedPeerType,
arg.SavedPeerID,
arg.SavedReactionKeys,
arg.NeedTotalCount,
)
if err != nil {

View file

@ -68,8 +68,6 @@ type AccountPassword struct {
SrpBSecret []byte
SrpB []byte
RecoveryEmail string
RecoveryCode string
RecoveryCodeExpiresAt pgtype.Timestamptz
LoginEmail string
PasswordChangedAt pgtype.Timestamptz
}

View file

@ -423,7 +423,18 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
}
const searchUsers = `-- name: SearchUsers :many
WITH matched AS (
WITH username_matches AS (
SELECT
peer_id,
bool_or(username_lower = $2::text) AS exact
FROM peer_usernames
WHERE peer_type = 'user'
AND active
AND collectible_id IS NOT NULL
AND username_lower LIKE $3::text || '%' ESCAPE '\'
GROUP BY peer_id
),
matched AS (
SELECT
u.id,
u.access_hash,
@ -453,27 +464,29 @@ WITH matched AS (
(c.contact_user_id IS NOT NULL)::boolean AS contact,
COALESCE(c.mutual, false)::boolean AS mutual,
CASE
WHEN $2::text <> '' AND u.phone = $2::text THEN 0
WHEN lower(u.username) = $3::text THEN 1
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $3::text THEN 2
WHEN lower(u.first_name) = $3::text THEN 3
WHEN $4::text <> '' AND u.phone = $4::text THEN 0
WHEN COALESCE(um.exact, false) OR lower(u.username) = $2::text THEN 1
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $2::text THEN 2
WHEN lower(u.first_name) = $2::text THEN 3
WHEN c.contact_user_id IS NOT NULL THEN 4
ELSE 5
END AS rank
FROM users u
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
WHERE u.id <> $4::bigint
LEFT JOIN contacts c ON c.user_id = $5::bigint AND c.contact_user_id = u.id
LEFT JOIN username_matches um ON um.peer_id = u.id
WHERE u.id <> $5::bigint
AND u.deleted_at IS NULL
AND $3::text <> ''
AND $2::text <> ''
AND (
($2::text <> '' AND u.phone LIKE $2::text || '%')
OR lower(u.username) LIKE $5::text || '%' ESCAPE '\'
OR lower(u.first_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(u.last_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(c.contact_first_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(c.contact_last_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $5::text || '%' ESCAPE '\'
($4::text <> '' AND u.phone LIKE $4::text || '%')
OR um.peer_id IS NOT NULL
OR lower(u.username) LIKE $3::text || '%' ESCAPE '\'
OR lower(u.first_name) LIKE '%' || $3::text || '%' ESCAPE '\'
OR lower(u.last_name) LIKE '%' || $3::text || '%' ESCAPE '\'
OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $3::text || '%' ESCAPE '\'
OR lower(c.contact_first_name) LIKE '%' || $3::text || '%' ESCAPE '\'
OR lower(c.contact_last_name) LIKE '%' || $3::text || '%' ESCAPE '\'
OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $3::text || '%' ESCAPE '\'
)
)
SELECT
@ -511,10 +524,10 @@ LIMIT $1
type SearchUsersParams struct {
LimitCount int32
PhoneQuery string
QueryLower string
CurrentUserID int64
QueryLike string
PhoneQuery string
CurrentUserID int64
}
type SearchUsersRow struct {
@ -550,10 +563,10 @@ type SearchUsersRow struct {
func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) {
rows, err := q.db.Query(ctx, searchUsers,
arg.LimitCount,
arg.PhoneQuery,
arg.QueryLower,
arg.CurrentUserID,
arg.QueryLike,
arg.PhoneQuery,
arg.CurrentUserID,
)
if err != nil {
return nil, err

View file

@ -735,6 +735,61 @@ WHERE `+where, args...)
return g, true, nil
}
func (s *StarGiftStore) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
if s == nil || s.db == nil || viewerUserID <= 0 || msgID <= 0 {
return domain.SavedStarGiftRef{}, false, nil
}
var ownerType string
var ownerID, savedID int64
err := s.db.QueryRow(ctx, `
SELECT gift.owner_peer_type,gift.owner_peer_id,gift.saved_id
FROM star_gift_user_message_refs ref
JOIN peer_star_gifts gift ON gift.id=ref.saved_gift_id
JOIN message_boxes box
ON box.owner_user_id=ref.owner_user_id AND box.box_id=ref.msg_id
WHERE ref.owner_user_id=$1 AND ref.msg_id=$2
AND NOT box.deleted
AND gift.lifecycle_status='active'
AND (
(gift.owner_peer_type='user' AND gift.owner_peer_id=$1)
OR (
gift.owner_peer_type='channel'
AND (
(
box.media #>> '{service_action,kind}'='star_gift'
AND box.media #>> '{service_action,star_gift,peer_channel_id}'=gift.owner_peer_id::text
AND box.media #>> '{service_action,star_gift,saved_id}'=gift.saved_id::text
)
OR (
box.media #>> '{service_action,kind}'='star_gift_unique'
AND box.media #>> '{service_action,star_gift_unique,peer,Type}'='channel'
AND box.media #>> '{service_action,star_gift_unique,peer,ID}'=gift.owner_peer_id::text
AND box.media #>> '{service_action,star_gift_unique,saved_id}'=gift.saved_id::text
)
)
)
)`,
viewerUserID, msgID).Scan(&ownerType, &ownerID, &savedID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGiftRef{}, false, nil
}
if err != nil {
return domain.SavedStarGiftRef{}, false, fmt.Errorf("resolve star gift user message ref: %w", err)
}
owner := domain.Peer{Type: domain.PeerType(ownerType), ID: ownerID}
switch owner.Type {
case domain.PeerTypeUser:
return domain.SavedStarGiftRef{Owner: owner, MsgID: msgID}, true, nil
case domain.PeerTypeChannel:
if savedID <= 0 {
return domain.SavedStarGiftRef{}, false, domain.ErrStarGiftOwnerInvalid
}
return domain.SavedStarGiftRef{Owner: owner, SavedID: savedID}, true, nil
default:
return domain.SavedStarGiftRef{}, false, domain.ErrStarGiftOwnerInvalid
}
}
func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (int, error) {
if !validStarGiftOwner(owner) {
return 0, nil

View file

@ -0,0 +1,234 @@
package postgres
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
const (
maxChannelStarGiftNotificationRecipients = 256
channelStarGiftNotificationLeaseSeconds = 60
)
type channelStarGiftNotificationJob struct {
SavedGiftID int64
TargetUserID int64
GiftDate int
Action domain.MessageStarGiftAction
Attempts int
}
func enqueueChannelStarGiftNotifications(
ctx context.Context,
tx pgx.Tx,
savedGiftID int64,
channelID int64,
giftDate int,
action *domain.MessageStarGiftAction,
) error {
if savedGiftID <= 0 || channelID <= 0 || giftDate <= 0 || action == nil ||
action.PeerChannelID != channelID || action.SavedID <= 0 {
return fmt.Errorf("enqueue channel star gift notifications: invalid intent")
}
actionJSON, err := json.Marshal(action)
if err != nil {
return fmt.Errorf("encode channel star gift notification: %w", err)
}
_, err = tx.Exec(ctx, `
WITH candidates AS (
SELECT creator_user_id AS user_id
FROM channels
WHERE id=$2 AND NOT deleted
UNION
SELECT user_id
FROM channel_members
WHERE channel_id=$2 AND status='active'
AND (role='creator' OR (
role='admin'
AND COALESCE((admin_rights->>'PostMessages')::boolean,false)
))
), bounded AS (
SELECT user_id FROM candidates
WHERE user_id>0
ORDER BY user_id
LIMIT $5
)
INSERT INTO star_gift_channel_notification_jobs
(saved_gift_id,target_user_id,gift_date,action,next_attempt_at)
SELECT $1,bounded.user_id,$3,$4::jsonb,$3
FROM bounded
LEFT JOIN star_gift_notification_settings settings
ON settings.user_id=bounded.user_id AND settings.channel_id=$2
WHERE COALESCE(settings.enabled,TRUE)
ON CONFLICT(saved_gift_id,target_user_id) DO NOTHING`,
savedGiftID, channelID, giftDate, string(actionJSON), maxChannelStarGiftNotificationRecipients)
if err != nil {
return fmt.Errorf("enqueue channel star gift notifications: %w", err)
}
return nil
}
func (s *StarGiftLifecycleStore) dispatchChannelStarGiftNotifications(
ctx context.Context,
now int,
limit int,
savedGiftID int64,
) (int, error) {
if s == nil || s.db == nil || s.messages == nil || now <= 0 || limit <= 0 {
return 0, domain.ErrStarGiftUnavailable
}
if limit > maxChannelStarGiftNotificationRecipients {
limit = maxChannelStarGiftNotificationRecipients
}
jobs, err := s.claimChannelStarGiftNotificationJobs(ctx, now, limit, savedGiftID)
if err != nil {
return 0, err
}
var firstErr error
delivered := 0
for _, job := range jobs {
messageID, sendErr := s.deliverChannelStarGiftNotification(ctx, job)
if sendErr == nil {
tag, markErr := s.db.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
SET delivered_at=$3,message_id=$4,lease_until=0,last_error='',updated_at=now()
WHERE saved_gift_id=$1 AND target_user_id=$2 AND delivered_at=0`,
job.SavedGiftID, job.TargetUserID, now, messageID)
if markErr == nil && tag.RowsAffected() == 1 {
delivered++
continue
}
if markErr == nil {
markErr = fmt.Errorf("channel star gift notification job disappeared")
}
sendErr = markErr
}
if firstErr == nil {
firstErr = sendErr
}
retryAt := now + channelStarGiftNotificationRetrySeconds(job.Attempts)
_, _ = s.db.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
SET next_attempt_at=$3,lease_until=0,last_error=$4,updated_at=now()
WHERE saved_gift_id=$1 AND target_user_id=$2 AND delivered_at=0`,
job.SavedGiftID, job.TargetUserID, retryAt, truncateStarGiftNotificationError(sendErr))
}
return delivered, firstErr
}
func (s *StarGiftLifecycleStore) claimChannelStarGiftNotificationJobs(
ctx context.Context,
now int,
limit int,
savedGiftID int64,
) ([]channelStarGiftNotificationJob, error) {
jobs := make([]channelStarGiftNotificationJob, 0, limit)
err := withTx(ctx, s.db, "claim channel star gift notifications", func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
WITH picked AS (
SELECT saved_gift_id,target_user_id
FROM star_gift_channel_notification_jobs
WHERE delivered_at=0 AND next_attempt_at<=$1 AND lease_until<$1
AND ($3::bigint=0 OR saved_gift_id=$3)
ORDER BY next_attempt_at,saved_gift_id,target_user_id
FOR UPDATE SKIP LOCKED
LIMIT $2
)
UPDATE star_gift_channel_notification_jobs job
SET attempts=job.attempts+1,
lease_until=$1+$4,
updated_at=now()
FROM picked
WHERE job.saved_gift_id=picked.saved_gift_id
AND job.target_user_id=picked.target_user_id
RETURNING job.saved_gift_id,job.target_user_id,job.gift_date,job.action,job.attempts`,
now, limit, savedGiftID, channelStarGiftNotificationLeaseSeconds)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var job channelStarGiftNotificationJob
var actionJSON []byte
if err := rows.Scan(&job.SavedGiftID, &job.TargetUserID, &job.GiftDate, &actionJSON, &job.Attempts); err != nil {
return err
}
if err := json.Unmarshal(actionJSON, &job.Action); err != nil {
return fmt.Errorf("decode channel star gift notification: %w", err)
}
if job.SavedGiftID <= 0 || job.TargetUserID <= 0 || job.GiftDate <= 0 ||
job.Action.PeerChannelID <= 0 || job.Action.SavedID <= 0 {
return fmt.Errorf("decode channel star gift notification: invalid intent")
}
jobs = append(jobs, job)
}
return rows.Err()
})
return jobs, err
}
func (s *StarGiftLifecycleStore) deliverChannelStarGiftNotification(
ctx context.Context,
job channelStarGiftNotificationJob,
) (int, error) {
fingerprint := sha256.Sum256([]byte(fmt.Sprintf(
"telesrv:channel-star-gift-notification:v1:%d:%d",
job.SavedGiftID, job.TargetUserID,
)))
action := job.Action
request := domain.SendPrivateTextRequest{
SenderUserID: domain.OfficialSystemUserID,
RecipientUserID: job.TargetUserID,
RandomID: lifecycleCommandRandomID("channel-star-gift-notification", job.SavedGiftID, job.TargetUserID),
Date: job.GiftDate,
IdempotencyFingerprint: fingerprint[:],
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &action,
}},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, request, privateSendTxHooks{
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
if sent.RecipientMessage.ID <= 0 {
return fmt.Errorf("channel star gift notification missing recipient box")
}
return registerChannelNotificationMessageRef(ctx, tx, job.TargetUserID,
sent.RecipientMessage.ID, job.SavedGiftID)
},
})
if err != nil {
return 0, err
}
if sent.RecipientMessage.ID <= 0 {
return 0, fmt.Errorf("channel star gift notification replay missing recipient box")
}
return sent.RecipientMessage.ID, nil
}
func channelStarGiftNotificationRetrySeconds(attempt int) int {
if attempt < 1 {
attempt = 1
}
delay := attempt * attempt * 5
if delay > 3600 {
return 3600
}
return delay
}
func truncateStarGiftNotificationError(err error) string {
if err == nil {
return ""
}
value := strings.TrimSpace(err.Error())
runes := []rune(value)
if len(runes) > 1000 {
value = string(runes[:1000])
}
return value
}

View file

@ -149,15 +149,9 @@ UPDATE star_gift_catalog SET collectible_revision_id=$2, updated_at=now() WHERE
}
func (s *StarGiftStore) ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
var revisionID int64
err := s.db.QueryRow(ctx, `
SELECT collectible_revision_id FROM star_gift_catalog
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftCollectibleRevision{}, false, nil
}
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, fmt.Errorf("get active collectible revision: %w", err)
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
if err != nil || !ok {
return domain.StarGiftCollectibleRevision{}, ok, err
}
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
if err != nil {
@ -166,6 +160,32 @@ WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisio
return revision, true, nil
}
func (s *StarGiftStore) ActiveCollectibleProjection(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
if err != nil || !ok {
return domain.StarGiftCollectibleRevision{}, ok, err
}
revision, err := collectibleRevisionProjectionByID(ctx, s.db, revisionID, samplePerKind)
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, err
}
return revision, true, nil
}
func activeCollectibleRevisionID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (int64, bool, error) {
var revisionID int64
err := db.QueryRow(ctx, `
SELECT collectible_revision_id FROM star_gift_catalog
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("get active collectible revision: %w", err)
}
return revisionID, true, nil
}
func (s *StarGiftStore) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
if len(giftIDs) == 0 {
@ -195,6 +215,19 @@ WHERE c.gift_id=ANY($1) AND r.status='published'`, giftIDs)
}
func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64) (domain.StarGiftCollectibleRevision, error) {
return readCollectibleRevisionByID(ctx, db, revisionID, collectibleRevisionReadOptions{includeAnimationJSON: true})
}
func collectibleRevisionProjectionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, error) {
return readCollectibleRevisionByID(ctx, db, revisionID, collectibleRevisionReadOptions{samplePerKind: samplePerKind})
}
type collectibleRevisionReadOptions struct {
includeAnimationJSON bool
samplePerKind int
}
func readCollectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
var publishedAt pgtype.Timestamptz
@ -213,40 +246,67 @@ FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
revision.PublishedAt = publishedAt.Time
}
var err error
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel); err != nil {
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern); err != nil {
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID); err != nil {
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return revision, nil
}
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind) ([]domain.StarGiftCollectibleAttribute, error) {
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
table := "star_gift_collectible_models"
if kind == domain.StarGiftCollectiblePattern {
table = "star_gift_collectible_patterns"
} else if kind != domain.StarGiftCollectibleModel {
return nil, domain.ErrStarGiftCollectibleInvalid
}
craftedExpression := "false"
if kind == domain.StarGiftCollectibleModel {
craftedExpression = "a.crafted"
}
animationJSONExpression := "''::text"
if options.includeAnimationJSON {
animationJSONExpression = "a.animation_json::text"
}
prefix := ""
from := fmt.Sprintf("%s a JOIN documents d ON d.id=a.document_id", table)
args := []any{revisionID}
if options.samplePerKind > 0 {
extra := ""
if kind == domain.StarGiftCollectibleModel {
extra = " AND NOT crafted"
}
prefix = fmt.Sprintf(`WITH picked AS MATERIALIZED (
SELECT id FROM %s
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s
ORDER BY random()
LIMIT $2
)`, table, extra)
from = fmt.Sprintf("picked p JOIN %s a ON a.id=p.id JOIN documents d ON d.id=a.document_id", table)
args = append(args, options.samplePerKind)
}
rows, err := db.Query(ctx, fmt.Sprintf(`
%s
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0),
%s, COALESCE(a.official_document_id,0), a.sort_order,
a.animation_json::text, a.animation_sha256, a.source_name, a.source_format,
%s, a.animation_sha256, a.source_name, a.source_format,
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM %s a JOIN documents d ON d.id=a.document_id
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
FROM %s
%s
ORDER BY a.sort_order, a.id`, prefix, craftedExpression, animationJSONExpression, from,
func() string {
if kind == domain.StarGiftCollectibleModel {
return "a.crafted"
if options.samplePerKind > 0 {
return ""
}
return "false"
}(), table), revisionID)
return "WHERE a.collectible_revision_id=$1"
}()), args...)
if err != nil {
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
}
@ -275,11 +335,29 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
return out, rows.Err()
}
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
rows, err := db.Query(ctx, `
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
prefix := ""
from := "star_gift_collectible_backdrops a"
where := "WHERE a.collectible_revision_id=$1"
args := []any{revisionID}
if options.samplePerKind > 0 {
prefix = `WITH picked AS MATERIALIZED (
SELECT id FROM star_gift_collectible_backdrops
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0
ORDER BY random()
LIMIT $2
)`
from = "picked p JOIN star_gift_collectible_backdrops a ON a.id=p.id"
where = ""
args = append(args, options.samplePerKind)
}
rows, err := db.Query(ctx, fmt.Sprintf(`
%s
SELECT a.id, a.collectible_revision_id, a.name, a.backdrop_id, a.center_color, a.edge_color, a.pattern_color,
a.text_color, a.rarity_kind, COALESCE(a.rarity_permille,0), a.sort_order
FROM %s
%s
ORDER BY a.sort_order, a.id`, prefix, from, where), args...)
if err != nil {
return nil, fmt.Errorf("list collectible backdrops: %w", err)
}

View file

@ -70,6 +70,22 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 {
t.Fatalf("published pool = %+v", poolRevision)
}
storedRevision, found, err := gifts.ActiveCollectibleRevision(ctx, entry.Gift.ID)
if err != nil || !found || storedRevision.Models[0].Animation == nil || len(storedRevision.Models[0].Animation.JSON) == 0 {
t.Fatalf("full active collectible revision = found:%v err:%v value:%+v", found, err, storedRevision)
}
fullProjection, found, err := gifts.ActiveCollectibleProjection(ctx, entry.Gift.ID, 0)
if err != nil || !found || len(fullProjection.Models) != 3 || len(fullProjection.Patterns) != 2 || len(fullProjection.Backdrops) != 2 ||
fullProjection.Models[0].Animation == nil || len(fullProjection.Models[0].Animation.JSON) != 0 ||
fullProjection.Patterns[0].Animation == nil || len(fullProjection.Patterns[0].Animation.JSON) != 0 {
t.Fatalf("complete collectible projection = found:%v err:%v value:%+v", found, err, fullProjection)
}
sampleProjection, found, err := gifts.ActiveCollectibleProjection(ctx, entry.Gift.ID, 1)
if err != nil || !found || len(sampleProjection.Models) != 1 || len(sampleProjection.Patterns) != 1 || len(sampleProjection.Backdrops) != 1 ||
sampleProjection.Models[0].Crafted || sampleProjection.Models[0].RarityKind != domain.StarGiftRarityPermille ||
sampleProjection.Models[0].Animation == nil || len(sampleProjection.Models[0].Animation.JSON) != 0 {
t.Fatalf("sampled collectible projection = found:%v err:%v value:%+v", found, err, sampleProjection)
}
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
if err != nil {
t.Fatalf("collectible availability: %v", err)

View file

@ -181,7 +181,8 @@ WHERE saved_gift_id IS NULL ORDER BY gift_id LIMIT $1`, minAuctionInt(remaining,
}
}
}
return nil
_, err := s.dispatchChannelStarGiftNotifications(ctx, now, minAuctionInt(limit, 100), 0)
return err
}
func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) {

View file

@ -147,6 +147,14 @@ VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.F
}
return registerUserStarGiftMessageRef(ctx, tx, req.Owner.ID, ownerMessageID, result.Saved.ID, 0)
}
notificationMessageID := sent.RecipientMessage.ID
if notificationMessageID <= 0 {
return fmt.Errorf("prepaid channel gift notification missing recipient box")
}
if err := registerViewerStarGiftMessageRef(ctx, tx, req.PayerUserID, notificationMessageID,
result.Saved.ID, req.Owner, 0); err != nil {
return err
}
action := messageReq.Media.ServiceAction.StarGift
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,
result.Saved.SavedID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: action})

View file

@ -431,8 +431,8 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
return domain.ErrStarGiftTransferUnavailable
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type='user',owner_peer_id=$2,from_user_id=$3,
msg_id=$4,saved_id=0,upgrade_msg_id=$4,gift_date=$5,name_hidden=false,unsaved=false,pinned_order=0,
can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil {
msg_id=$4,saved_id=0,upgrade_msg_id=$4,gift_date=$5,name_hidden=false,unsaved=$6,pinned_order=0,
can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date, req.RecipientUnsaved); err != nil {
return err
}
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
@ -446,6 +446,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
}
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date
result.Saved.FromUserID = req.ActorUserID
result.Saved.Unsaved = req.RecipientUnsaved
if sourceSaved.Owner.Type == domain.PeerTypeUser {
_, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date)
return err
@ -587,14 +588,25 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
return domain.ErrStarGiftResaleUnavailable
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET owner_peer_type=$2,owner_peer_id=$3,from_user_id=$4,
msg_id=$5,saved_id=$6,upgrade_msg_id=$5,gift_date=$7,name_hidden=false,unsaved=false,pinned_order=0,can_transfer_at=0
WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil {
msg_id=$5,saved_id=$6,upgrade_msg_id=$5,gift_date=$7,name_hidden=false,unsaved=$8,pinned_order=0,can_transfer_at=0
WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date,
req.To.Type == domain.PeerTypeUser && req.RecipientUnsaved); err != nil {
return err
}
result.Saved.Unsaved = req.To.Type == domain.PeerTypeUser && req.RecipientUnsaved
if req.To.Type == domain.PeerTypeUser {
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
return err
}
} else {
notificationMessageID := sent.RecipientMessage.ID
if notificationMessageID <= 0 {
return fmt.Errorf("channel resale notification missing buyer box")
}
if err := registerViewerStarGiftMessageRef(ctx, tx, req.BuyerUserID, notificationMessageID,
result.Saved.ID, req.To, result.Unique.ID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id,
buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key)
@ -1396,6 +1408,18 @@ ON CONFLICT(user_id,channel_id) DO UPDATE SET enabled=EXCLUDED.enabled,updated_a
return err
}
func (s *StarGiftLifecycleStore) StarGiftNotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error) {
if s == nil || s.db == nil || userID <= 0 || channelID <= 0 {
return false, domain.ErrStarGiftOwnerInvalid
}
var enabled bool
err := s.db.QueryRow(ctx, `SELECT COALESCE((
SELECT enabled FROM star_gift_notification_settings
WHERE user_id=$1 AND channel_id=$2
),TRUE)`, userID, channelID).Scan(&enabled)
return enabled, err
}
func (s *StarGiftLifecycleStore) RecordStarGiftWithdrawal(ctx context.Context, req domain.StarGiftWithdrawalRequest, provider, providerRequestID, url string, expiresAt int) (domain.StarGiftWithdrawal, error) {
if req.UserID <= 0 || !req.Ref.Valid() || req.Date <= 0 || expiresAt <= req.Date || strings.TrimSpace(provider) == "" || strings.TrimSpace(providerRequestID) == "" || strings.TrimSpace(url) == "" {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
@ -1587,27 +1611,25 @@ VALUES($1,$2,$3,$4)`, userID, s.tonStartingGrant, string(domain.StarsReasonGrant
return balance, nil
}
func (s *StarGiftLifecycleStore) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) {
if userID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes {
func (s *StarGiftLifecycleStore) TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if userID <= 0 {
return domain.TonTransactionPage{}, domain.ErrStarGiftOwnerInvalid
}
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.TonTransactionPage{}, err
}
if _, err := s.TonBalance(ctx, userID); err != nil {
return domain.TonTransactionPage{}, err
}
cursor, hasCursor := domain.DecodeStarsCursor(offset)
args := []any{userID, limit + 1}
where := "user_id=$1"
if hasCursor {
where += " AND id<$3"
args = append(args, cursor)
}
where, order, args := starsTransactionQueryParts("user_id", "amount_nanoton", userID, query)
rows, err := s.db.Query(ctx, `SELECT id,user_id,COALESCE(peer_type,''),COALESCE(peer_id,0),COALESCE(gift_id,0),
amount_nanoton,date,reason FROM ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...)
amount_nanoton,date,reason FROM ton_transactions WHERE `+where+` ORDER BY id `+order+` LIMIT $2`, args...)
if err != nil {
return domain.TonTransactionPage{}, err
}
defer rows.Close()
items := make([]domain.TonTransaction, 0, limit+1)
items := make([]domain.TonTransaction, 0, query.Limit+1)
for rows.Next() {
var item domain.TonTransaction
var peerType string
@ -1621,8 +1643,8 @@ amount_nanoton,date,reason FROM ton_transactions WHERE `+where+` ORDER BY id DES
return domain.TonTransactionPage{}, err
}
page := domain.TonTransactionPage{}
if len(items) > limit {
items = items[:limit]
if len(items) > query.Limit {
items = items[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID)
}
page.Transactions = items
@ -1644,24 +1666,22 @@ func (s *StarGiftLifecycleStore) ChannelStarsBalance(ctx context.Context, channe
return balance, err
}
func (s *StarGiftLifecycleStore) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
if channelID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes {
func (s *StarGiftLifecycleStore) ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if channelID <= 0 {
return domain.StarsTransactionPage{}, domain.ErrStarGiftOwnerInvalid
}
cursor, hasCursor := domain.DecodeStarsCursor(offset)
args := []any{channelID, limit + 1}
where := "channel_id=$1"
if hasCursor {
where += " AND id<$3"
args = append(args, cursor)
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
where, order, args := starsTransactionQueryParts("channel_id", "amount", channelID, query)
rows, err := s.db.Query(ctx, `SELECT id,COALESCE(peer_type,''),COALESCE(peer_id,0),amount,date,reason
FROM channel_stars_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...)
FROM channel_stars_transactions WHERE `+where+` ORDER BY id `+order+` LIMIT $2`, args...)
if err != nil {
return domain.StarsTransactionPage{}, err
}
defer rows.Close()
items := make([]domain.StarsTransaction, 0, limit+1)
items := make([]domain.StarsTransaction, 0, query.Limit+1)
for rows.Next() {
var item domain.StarsTransaction
var peerType string
@ -1675,8 +1695,8 @@ FROM channel_stars_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args
return domain.StarsTransactionPage{}, err
}
page := domain.StarsTransactionPage{}
if len(items) > limit {
items = items[:limit]
if len(items) > query.Limit {
items = items[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID)
}
page.Transactions = items
@ -1693,24 +1713,22 @@ func (s *StarGiftLifecycleStore) ChannelTonBalance(ctx context.Context, channelI
return balance, err
}
func (s *StarGiftLifecycleStore) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) {
if channelID <= 0 || limit <= 0 || limit > domain.MaxStarsTransactionsLimit || len(offset) > domain.MaxStarsTransactionsOffsetBytes {
func (s *StarGiftLifecycleStore) ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if channelID <= 0 {
return domain.TonTransactionPage{}, domain.ErrStarGiftOwnerInvalid
}
cursor, hasCursor := domain.DecodeStarsCursor(offset)
args := []any{channelID, limit + 1}
where := "channel_id=$1"
if hasCursor {
where += " AND id<$3"
args = append(args, cursor)
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.TonTransactionPage{}, err
}
where, order, args := starsTransactionQueryParts("channel_id", "amount_nanoton", channelID, query)
rows, err := s.db.Query(ctx, `SELECT id,COALESCE(peer_type,''),COALESCE(peer_id,0),COALESCE(gift_id,0),amount_nanoton,date,reason
FROM channel_ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args...)
FROM channel_ton_transactions WHERE `+where+` ORDER BY id `+order+` LIMIT $2`, args...)
if err != nil {
return domain.TonTransactionPage{}, err
}
defer rows.Close()
items := make([]domain.TonTransaction, 0, limit+1)
items := make([]domain.TonTransaction, 0, query.Limit+1)
for rows.Next() {
var item domain.TonTransaction
var peerType string
@ -1724,8 +1742,8 @@ FROM channel_ton_transactions WHERE `+where+` ORDER BY id DESC LIMIT $2`, args..
return domain.TonTransactionPage{}, err
}
page := domain.TonTransactionPage{}
if len(items) > limit {
items = items[:limit]
if len(items) > query.Limit {
items = items[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID)
}
page.Transactions = items

View file

@ -89,15 +89,23 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
}
ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift
if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade ||
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 {
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 ||
ordinaryAction.PrepaidUpgradeHash != "" {
t.Fatalf("ordinary purchase action mixed paid price with prepaid amount: %+v", ordinaryAction)
}
senderOrdinaryAction := purchased.Send.SenderMessage.Media.ServiceAction.StarGift
if senderOrdinaryAction == nil || senderOrdinaryAction.CanUpgrade ||
senderOrdinaryAction.PrepaidUpgradeHash != purchased.Saved.PrepaidUpgradeHash {
t.Fatalf("ordinary sender purchase projection = %+v", senderOrdinaryAction)
}
replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 ||
replayedPurchase.Send.SenderMessage.ID != purchased.Send.SenderMessage.ID ||
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err)
}
verifyPrepaidViewerProjectionMigrationNoop(t, ctx, pool, purchased.Saved.ID, owner.ID, buyer.ID,
purchased.Send.RecipientMessage.ID, purchased.Send.SenderMessage.ID)
target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash)
if err != nil || target.ID != purchased.Saved.ID || price != 100 {
@ -418,7 +426,7 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, upgraded.Send.RecipientMess
Scan(&resaleCommission); err != nil || resaleCommission != 100 {
t.Fatalf("TON resale commission = %d err %v", resaleCommission, err)
}
tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, "", 20)
tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, domain.StarsTransactionQuery{Limit: 20})
if err != nil || tonPage.Balance != 999000 || len(tonPage.Transactions) < 2 {
t.Fatalf("TON ledger page = %+v err %v", tonPage, err)
}
@ -811,17 +819,37 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
now := int(time.Now().Unix())
users := NewUserStore(pool)
actor := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ChannelGiftActor", "")
notifyAdmin := createTestUser(t, ctx, users, "+1882"+suffix+"02", "ChannelGiftNotifyAdmin", "")
mutedAdmin := createTestUser(t, ctx, users, "+1882"+suffix+"03", "ChannelGiftMutedAdmin", "")
noPostAdmin := createTestUser(t, ctx, users, "+1882"+suffix+"04", "ChannelGiftNoPostAdmin", "")
ordinaryMember := createTestUser(t, ctx, users, "+1882"+suffix+"05", "ChannelGiftMember", "")
if _, _, err := NewStarsStore(pool).EnsureGrant(ctx, actor.ID, 10000, now); err != nil {
t.Fatalf("grant actor stars: %v", err)
}
created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
channelStore := NewChannelStore(pool)
created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: actor.ID, Title: "Gift Channel " + suffix, Megagroup: true, Date: now,
MemberUserIDs: []int64{notifyAdmin.ID, mutedAdmin.ID, noPostAdmin.ID, ordinaryMember.ID},
})
if err != nil {
t.Fatalf("create gift channel: %v", err)
}
for _, admin := range []domain.User{notifyAdmin, mutedAdmin} {
if _, err := channelStore.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
UserID: actor.ID, ChannelID: created.Channel.ID, MemberID: admin.ID,
AdminRights: domain.ChannelAdminRights{PostMessages: true}, Date: now,
}); err != nil {
t.Fatalf("grant channel gift PostMessages admin %d: %v", admin.ID, err)
}
}
if _, err := channelStore.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
UserID: actor.ID, ChannelID: created.Channel.ID, MemberID: noPostAdmin.ID,
AdminRights: domain.ChannelAdminRights{ChangeInfo: true}, Date: now,
}); err != nil {
t.Fatalf("grant non-posting channel admin: %v", err)
}
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
createdTarget, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
createdTarget, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: actor.ID, Title: "Gift Target Channel " + suffix, Megagroup: true, Date: now,
})
if err != nil {
@ -867,9 +895,16 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
if err := lifecycle.SetStarGiftNotifications(ctx, mutedAdmin.ID, created.Channel.ID, false); err != nil {
t.Fatalf("disable muted admin channel gift notifications: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
var channelPtsBeforePurchase int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&channelPtsBeforePurchase); err != nil {
t.Fatalf("load channel pts before gift purchase: %v", err)
}
channelPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1})
purchased, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq)
@ -881,6 +916,107 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(&regularLogs); err != nil || regularLogs != 1 {
t.Fatalf("channel purchase admin logs = %d err %v", regularLogs, err)
}
var notificationRecipients []int64
rows, err := pool.Query(ctx, `SELECT target_user_id FROM star_gift_channel_notification_jobs
WHERE saved_gift_id=$1 ORDER BY target_user_id`, purchased.Saved.ID)
if err != nil {
t.Fatalf("list channel gift notification recipients: %v", err)
}
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
rows.Close()
t.Fatalf("scan channel gift notification recipient: %v", err)
}
notificationRecipients = append(notificationRecipients, userID)
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatalf("iterate channel gift notification recipients: %v", err)
}
rows.Close()
wantRecipients := []int64{actor.ID, notifyAdmin.ID}
if fmt.Sprint(notificationRecipients) != fmt.Sprint(wantRecipients) {
t.Fatalf("channel gift notification recipients=%v want=%v (muted/no-post/member excluded)",
notificationRecipients, wantRecipients)
}
var notificationMessageID, notificationDeliveredAt, notificationAttempts int
if err := pool.QueryRow(ctx, `SELECT message_id,delivered_at,attempts
FROM star_gift_channel_notification_jobs
WHERE saved_gift_id=$1 AND target_user_id=$2`, purchased.Saved.ID, actor.ID).
Scan(&notificationMessageID, &notificationDeliveredAt, &notificationAttempts); err != nil ||
notificationMessageID <= 0 || notificationDeliveredAt <= 0 || notificationAttempts != 1 {
t.Fatalf("channel gift notification job message=%d delivered=%d attempts=%d err=%v",
notificationMessageID, notificationDeliveredAt, notificationAttempts, err)
}
notificationRef, found, err := gifts.ResolveUserMessageRef(ctx, actor.ID, notificationMessageID)
if err != nil || !found || notificationRef.Owner != channelPeer ||
notificationRef.SavedID != purchased.Saved.SavedID {
t.Fatalf("channel gift notification alias = %+v found=%v err=%v", notificationRef, found, err)
}
var notificationPts, notificationEvents, notificationOutbox, channelPtsAfterPurchase int
var notificationMediaJSON string
if err := pool.QueryRow(ctx, `SELECT pts,media::text FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2`, actor.ID, notificationMessageID).
Scan(&notificationPts, &notificationMediaJSON); err != nil {
t.Fatalf("load channel gift notification message: %v", err)
}
notificationMedia, err := decodeMessageMedia(notificationMediaJSON)
if err != nil || notificationMedia == nil || notificationMedia.ServiceAction == nil ||
notificationMedia.ServiceAction.StarGift == nil ||
notificationMedia.ServiceAction.StarGift.PeerChannelID != created.Channel.ID ||
notificationMedia.ServiceAction.StarGift.SavedID != purchased.Saved.SavedID {
t.Fatalf("channel gift notification media = %+v err=%v", notificationMedia, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='new_message'`, actor.ID, notificationPts).Scan(&notificationEvents); err != nil ||
notificationEvents != 1 {
t.Fatalf("channel gift notification events=%d err=%v", notificationEvents, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts=$2`, actor.ID, notificationPts).Scan(&notificationOutbox); err != nil ||
notificationOutbox != 1 {
t.Fatalf("channel gift notification outbox=%d err=%v", notificationOutbox, err)
}
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&channelPtsAfterPurchase); err != nil ||
channelPtsAfterPurchase != channelPtsBeforePurchase {
t.Fatalf("channel gift notification changed channel pts: before=%d after=%d err=%v",
channelPtsBeforePurchase, channelPtsAfterPurchase, err)
}
// Simulate a process stopping after the private message committed but before
// the job completion update. The next claim must replay the same message and
// must not allocate a second account PTS/event/outbox row.
if _, err := pool.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
SET delivered_at=0,message_id=0,next_attempt_at=$3,lease_until=0
WHERE saved_gift_id=$1 AND target_user_id=$2`, purchased.Saved.ID, actor.ID, now+1); err != nil {
t.Fatalf("reset notification job for replay probe: %v", err)
}
if delivered, err := lifecycle.dispatchChannelStarGiftNotifications(ctx, now+2, 1, purchased.Saved.ID); err != nil || delivered != 1 {
t.Fatalf("replay channel gift notification delivered=%d err=%v", delivered, err)
}
var replayMessageID, replayEventCount int
if err := pool.QueryRow(ctx, `SELECT message_id FROM star_gift_channel_notification_jobs
WHERE saved_gift_id=$1 AND target_user_id=$2`, purchased.Saved.ID, actor.ID).Scan(&replayMessageID); err != nil ||
replayMessageID != notificationMessageID {
t.Fatalf("channel gift notification replay message=%d want=%d err=%v", replayMessageID, notificationMessageID, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='new_message'`, actor.ID, notificationPts).Scan(&replayEventCount); err != nil ||
replayEventCount != 1 {
t.Fatalf("channel gift notification replay events=%d err=%v", replayEventCount, err)
}
if enabled, err := lifecycle.StarGiftNotificationsEnabled(ctx, actor.ID, created.Channel.ID); err != nil || !enabled {
t.Fatalf("default channel gift notification setting enabled=%v err=%v", enabled, err)
}
if err := lifecycle.SetStarGiftNotifications(ctx, actor.ID, created.Channel.ID, false); err != nil {
t.Fatalf("disable channel gift notifications: %v", err)
}
if enabled, err := lifecycle.StarGiftNotificationsEnabled(ctx, actor.ID, created.Channel.ID); err != nil || enabled {
t.Fatalf("disabled channel gift notification setting enabled=%v err=%v", enabled, err)
}
if err := lifecycle.SetStarGiftNotifications(ctx, actor.ID, created.Channel.ID, true); err != nil {
t.Fatalf("re-enable channel gift notifications: %v", err)
}
var channelPrice string
var channelPrepaidAmount any
if err := pool.QueryRow(ctx, `SELECT message #>> '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}'
@ -914,7 +1050,7 @@ WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_
if balance, err := lifecycle.ChannelStarsBalance(ctx, created.Channel.ID); err != nil || balance != 20 {
t.Fatalf("channel stars balance projection = %d err %v", balance, err)
}
starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, "", 20)
starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, domain.StarsTransactionQuery{Limit: 20})
if err != nil || starsPage.Balance != 20 || len(starsPage.Transactions) != 1 ||
starsPage.Transactions[0].Amount != 20 || starsPage.Transactions[0].Reason != domain.StarsReasonGift {
t.Fatalf("channel stars transaction projection = %+v err %v", starsPage, err)
@ -949,6 +1085,11 @@ WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_
channelPrepay.Send.RecipientMessage.OwnerUserID != actor.ID {
t.Fatalf("channel prepaid entitlement = %+v err %v", channelPrepay, err)
}
prepayAlias, found, err := gifts.ResolveUserMessageRef(ctx, actor.ID, channelPrepay.Send.RecipientMessage.ID)
if err != nil || !found || prepayAlias.Owner != channelPeer ||
prepayAlias.SavedID != channelPrepay.Saved.SavedID {
t.Fatalf("channel prepaid notification alias = %+v found=%v err=%v", prepayAlias, found, err)
}
var prepayLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 {
@ -983,12 +1124,17 @@ WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel
t.Fatalf("channel prepaid upgrade = %+v err %v", upgraded, err)
}
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer ||
if action == nil || action.FromUserID != actor.ID || action.Peer != channelPeer ||
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 ||
action.CanCraftAt != 0 || action.Gift.CraftChancePermille != 500 ||
upgraded.Saved.CanCraftAt != now+5 || upgraded.Unique.CraftChancePermille != 500 {
t.Fatalf("channel upgrade service action = %+v", action)
}
upgradeAlias, found, err := gifts.ResolveUserMessageRef(ctx, actor.ID, upgraded.Send.RecipientMessage.ID)
if err != nil || !found || upgradeAlias.Owner != channelPeer ||
upgradeAlias.SavedID != upgraded.Saved.SavedID {
t.Fatalf("channel upgrade notification alias = %+v found=%v err=%v", upgradeAlias, found, err)
}
var ptsAfterUpgrade int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsAfterUpgrade); err != nil || ptsAfterUpgrade != ptsBeforeUpgrade {
t.Fatalf("channel pts after profile gift upgrade = %d want %d err %v", ptsAfterUpgrade, ptsBeforeUpgrade, err)
@ -1042,7 +1188,7 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
if balance, err := lifecycle.ChannelTonBalance(ctx, created.Channel.ID); err != nil || balance != 900 {
t.Fatalf("channel ton balance projection = %d err %v", balance, err)
}
tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, "", 20)
tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, domain.StarsTransactionQuery{Limit: 20})
if err != nil || tonPage.Balance != 900 || len(tonPage.Transactions) != 1 ||
tonPage.Transactions[0].Amount != 900 || tonPage.Transactions[0].Reason != domain.StarsReasonGiftResale {
t.Fatalf("channel ton transaction projection = %+v err %v", tonPage, err)
@ -1388,6 +1534,136 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).
assertMigratedAction(payerUserID, payerPrepayMessageID, 0)
}
func verifyPrepaidViewerProjectionMigrationNoop(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
savedGiftID int64,
ownerUserID int64,
senderUserID int64,
ownerMessageID int,
senderMessageID int,
) {
t.Helper()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin prepaid viewer migration probe: %v", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
var hash string
if err := tx.QueryRow(ctx, `SELECT prepaid_upgrade_hash FROM peer_star_gifts WHERE id=$1`, savedGiftID).Scan(&hash); err != nil || hash == "" {
t.Fatalf("load prepaid hash for viewer migration probe: hash=%q err=%v", hash, err)
}
var ownerPtsBefore, senderPtsBefore int
if err := tx.QueryRow(ctx, `SELECT contiguous_pts FROM user_update_watermarks WHERE user_id=$1`, ownerUserID).Scan(&ownerPtsBefore); err != nil {
t.Fatalf("load owner watermark before viewer migration: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT contiguous_pts FROM user_update_watermarks WHERE user_id=$1`, senderUserID).Scan(&senderPtsBefore); err != nil {
t.Fatalf("load sender watermark before viewer migration: %v", err)
}
var messageSenderID, privateMessageID int64
if err := tx.QueryRow(ctx, `SELECT message_sender_id,private_message_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, ownerUserID, ownerMessageID).
Scan(&messageSenderID, &privateMessageID); err != nil {
t.Fatalf("load ordinary gift message root for viewer migration: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE message_boxes
SET media=jsonb_set(jsonb_set(media,'{service_action,star_gift,can_upgrade}','true'::jsonb,true),
'{service_action,star_gift,prepaid_upgrade_hash}',to_jsonb($3::text),true)
WHERE (owner_user_id=$1 AND box_id=$4) OR (owner_user_id=$2 AND box_id=$5)`,
ownerUserID, senderUserID, hash, ownerMessageID, senderMessageID); err != nil {
t.Fatalf("restore stale prepaid viewer boxes: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE private_messages
SET media=jsonb_set(jsonb_set(media,'{service_action,star_gift,can_upgrade}','true'::jsonb,true),
'{service_action,star_gift,prepaid_upgrade_hash}',to_jsonb($3::text),true)
WHERE sender_user_id=$1 AND id=$2`, messageSenderID, privateMessageID, hash); err != nil {
t.Fatalf("restore stale prepaid shared message: %v", err)
}
var ownerMediaBefore, senderMediaBefore, sharedMediaBefore string
if err := tx.QueryRow(ctx, `SELECT media::text FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
ownerUserID, ownerMessageID).Scan(&ownerMediaBefore); err != nil {
t.Fatalf("load owner media before retired migration: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT media::text FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
senderUserID, senderMessageID).Scan(&senderMediaBefore); err != nil {
t.Fatalf("load sender media before retired migration: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT media::text FROM private_messages WHERE sender_user_id=$1 AND id=$2`,
messageSenderID, privateMessageID).Scan(&sharedMediaBefore); err != nil {
t.Fatalf("load shared media before retired migration: %v", err)
}
var eventsBefore, outboxBefore int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events WHERE user_id IN ($1,$2)`,
ownerUserID, senderUserID).Scan(&eventsBefore); err != nil {
t.Fatalf("count events before retired migration: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox WHERE target_user_id IN ($1,$2)`,
ownerUserID, senderUserID).Scan(&outboxBefore); err != nil {
t.Fatalf("count outbox before retired migration: %v", err)
}
migrationSQL, err := deploy.Migrations.ReadFile("migrations/0163_star_gift_prepaid_viewer_projection.up.sql")
if err != nil {
t.Fatalf("read prepaid viewer migration: %v", err)
}
if _, err := tx.Exec(ctx, string(migrationSQL)); err != nil {
t.Fatalf("apply prepaid viewer migration probe: %v", err)
}
var ownerMediaAfter, senderMediaAfter, sharedMediaAfter string
var ownerPtsAfter, senderPtsAfter int
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
ownerUserID, ownerMessageID).Scan(&ownerMediaAfter, &ownerPtsAfter); err != nil {
t.Fatalf("load owner box after retired migration: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
senderUserID, senderMessageID).Scan(&senderMediaAfter, &senderPtsAfter); err != nil {
t.Fatalf("load sender box after retired migration: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT media::text FROM private_messages WHERE sender_user_id=$1 AND id=$2`,
messageSenderID, privateMessageID).Scan(&sharedMediaAfter); err != nil {
t.Fatalf("load shared media after retired migration: %v", err)
}
if ownerMediaAfter != ownerMediaBefore || senderMediaAfter != senderMediaBefore || sharedMediaAfter != sharedMediaBefore {
t.Fatalf("retired migration changed message media")
}
if ownerPtsAfter != ownerPtsBefore || senderPtsAfter != senderPtsBefore {
t.Fatalf("retired migration changed PTS: owner=%d/%d sender=%d/%d",
ownerPtsBefore, ownerPtsAfter, senderPtsBefore, senderPtsAfter)
}
var eventsAfter, outboxAfter int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events WHERE user_id IN ($1,$2)`,
ownerUserID, senderUserID).Scan(&eventsAfter); err != nil {
t.Fatalf("count events after retired migration: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox WHERE target_user_id IN ($1,$2)`,
ownerUserID, senderUserID).Scan(&outboxAfter); err != nil {
t.Fatalf("count outbox after retired migration: %v", err)
}
if eventsAfter != eventsBefore || outboxAfter != outboxBefore {
t.Fatalf("retired migration changed event/outbox counts: events=%d/%d outbox=%d/%d",
eventsBefore, eventsAfter, outboxBefore, outboxAfter)
}
var storedHash string
if err := tx.QueryRow(ctx, `SELECT prepaid_upgrade_hash FROM peer_star_gifts WHERE id=$1`, savedGiftID).Scan(&storedHash); err != nil || storedHash != hash {
t.Fatalf("retired migration changed aggregate hash=%q want=%q err=%v", storedHash, hash, err)
}
// Reproduce the deployment shape that made the original 0163 abort. A
// retired version slot must advance even when an aggregate has no live owner
// box; it has no authority to classify or repair historical message state.
if _, err := tx.Exec(ctx, `DELETE FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
ownerUserID, ownerMessageID); err != nil {
t.Fatalf("remove owner box for retired migration probe: %v", err)
}
if _, err := tx.Exec(ctx, string(migrationSQL)); err != nil {
t.Fatalf("retired migration rejected missing owner box: %v", err)
}
}
func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID != userID {

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 20260714003097 {
t.Fatalf("migration status = %+v, want clean version 20260714003097", status)
if status.Dirty || status.Empty || status.Version != 20260714003125 {
t.Fatalf("migration status = %+v, want clean version 20260714003125", status)
}
}

View file

@ -1,11 +1,61 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestProjectPrivateStarGiftPurchaseScopesViewerCapabilities(t *testing.T) {
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
PeerUserID: 200, To: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
CanUpgrade: true, PrepaidUpgradeHash: "prepaid-upgrade-hash-0123456789",
},
},
}
req := &domain.SendPrivateTextRequest{SenderUserID: 100, RecipientUserID: 200, Media: media}
projection, err := projectPrivateStarGiftPurchase(context.Background(), nil, req)
if err != nil {
t.Fatalf("project purchase: %v", err)
}
shared := privateStarGiftAction(projection.Shared)
sender := privateStarGiftAction(projection.Sender)
recipient := privateStarGiftAction(projection.Recipient)
if shared == nil || shared.CanUpgrade || shared.PrepaidUpgradeHash != "" {
t.Fatalf("shared projection retained viewer capability: %+v", shared)
}
if sender == nil || sender.CanUpgrade || sender.PrepaidUpgradeHash == "" {
t.Fatalf("sender projection = %+v, want hash without can_upgrade", sender)
}
if recipient == nil || !recipient.CanUpgrade || recipient.PrepaidUpgradeHash != "" {
t.Fatalf("recipient projection = %+v, want can_upgrade without hash", recipient)
}
if original := privateStarGiftAction(media); original == nil || !original.CanUpgrade || original.PrepaidUpgradeHash == "" {
t.Fatalf("source projection was mutated: %+v", original)
}
selfReq := &domain.SendPrivateTextRequest{SenderUserID: 200, RecipientUserID: 200, Media: media}
selfProjection, err := projectPrivateStarGiftPurchase(context.Background(), nil, selfReq)
if err != nil {
t.Fatalf("project self purchase: %v", err)
}
self := privateStarGiftAction(selfProjection.Sender)
if self == nil || !self.CanUpgrade || self.PrepaidUpgradeHash != "" {
t.Fatalf("self-owner projection = %+v, want can_upgrade without hash", self)
}
bad := *req
bad.RecipientUserID = 300
if _, err := projectPrivateStarGiftPurchase(context.Background(), nil, &bad); err == nil {
t.Fatal("mismatched gift owner and recipient was accepted")
}
}
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
unique := domain.UniqueStarGift{ID: 7}

View file

@ -54,6 +54,12 @@ func projectPrivateStarGiftSourceRef(
sharedAction.GiftMsgID = 0
senderAction.GiftMsgID = 0
recipientAction.GiftMsgID = 0
// messageActionStarGift.can_upgrade is receiver-only. A separate
// prepayment notification shares one logical private message, but its
// payer box must not advertise owner actions.
sharedAction.CanUpgrade = false
senderAction.CanUpgrade = req.SenderUserID == sourceOwnerUserID && senderAction.CanUpgrade
recipientAction.CanUpgrade = req.RecipientUserID == sourceOwnerUserID && recipientAction.CanUpgrade
if req.SenderUserID == sourceOwnerUserID {
senderAction.GiftMsgID = sourceOwnerBoxID
} else {
@ -66,6 +72,66 @@ func projectPrivateStarGiftSourceRef(
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
}
// projectPrivateStarGiftPurchase scopes the two mutually exclusive actions of
// an ordinary user gift to the correct account-local message box:
// - the gift owner/receiver may upgrade it and must not receive the separate
// prepayment hash;
// - the non-owner sender may use the hash to prepay for the owner's upgrade,
// but must not receive the receiver-only can_upgrade capability.
//
// The shared logical envelope carries neither viewer-only field. This keeps a
// future read/replay path from accidentally treating it as either participant's
// projection while the two message_boxes remain the durable wire truth.
func projectPrivateStarGiftPurchase(
_ context.Context,
_ pgx.Tx,
req *domain.SendPrivateTextRequest,
) (privateSendMediaProjection, error) {
if req == nil || req.Media == nil || req.SenderUserID <= 0 || req.RecipientUserID <= 0 {
return privateSendMediaProjection{}, fmt.Errorf("project private star gift purchase: invalid scope")
}
action := privateStarGiftAction(req.Media)
if action == nil {
return privateSendMediaProjection{}, fmt.Errorf("project private star gift purchase: unsupported media")
}
ownerUserID := action.PeerUserID
if ownerUserID == 0 && action.To.Type == domain.PeerTypeUser {
ownerUserID = action.To.ID
}
if ownerUserID <= 0 || ownerUserID != req.RecipientUserID {
return privateSendMediaProjection{}, fmt.Errorf(
"project private star gift purchase: owner %d does not match recipient %d",
ownerUserID, req.RecipientUserID,
)
}
shared, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
sender, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
recipient, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
sharedAction := privateStarGiftAction(shared)
senderAction := privateStarGiftAction(sender)
recipientAction := privateStarGiftAction(recipient)
sharedAction.PrepaidUpgradeHash = ""
sharedAction.CanUpgrade = false
if req.SenderUserID == ownerUserID {
senderAction.PrepaidUpgradeHash = ""
} else {
senderAction.CanUpgrade = false
}
recipientAction.PrepaidUpgradeHash = ""
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
}
func cloneMessageMedia(media *domain.MessageMedia) (*domain.MessageMedia, error) {
encoded, err := encodeMessageMedia(media)
if err != nil {
@ -97,6 +163,8 @@ func encodeSharedPrivateStarGiftMedia(media *domain.MessageMedia) ([]byte, error
action.UpgradeMsgID = 0
if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser {
action.SavedID = 0
action.PrepaidUpgradeHash = ""
action.CanUpgrade = false
}
case privateStarGiftUniqueAction(shared) != nil:
action := privateStarGiftUniqueAction(shared)

View file

@ -131,6 +131,7 @@ func (s *StarGiftLifecycleStore) PurchaseStarGift(ctx context.Context, req domai
result.Gift, result.Saved, result.Balance = gift, saved, balance
return nil
},
projectMedia: projectPrivateStarGiftPurchase,
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
msgID := sent.RecipientMessage.ID
if msgID <= 0 {
@ -188,6 +189,9 @@ func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context,
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, id, req.Date, action); err != nil {
return err
}
if err := enqueueChannelStarGiftNotifications(ctx, tx, id, req.To.ID, req.Date, action.StarGift); err != nil {
return err
}
if err := s.insertStarGiftPurchaseCommand(ctx, tx, req, id, gift.Stars+saved.PrepaidUpgradeStars, balance.Balance); err != nil {
return err
}
@ -202,6 +206,9 @@ func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context,
}
return domain.StarGiftPurchaseResult{}, err
}
// The purchase remains successful once its transaction has committed. Any
// immediate delivery failure leaves a durable job for the lifecycle sweeper.
_, _ = s.dispatchChannelStarGiftNotifications(ctx, req.Date, maxChannelStarGiftNotificationRecipients, result.Saved.ID)
return result, nil
}
@ -278,7 +285,7 @@ last_sale_date=$2,updated_at=now() WHERE gift_id=$1`, gift.ID, req.Date); err !=
}
saved := domain.SavedStarGift{Owner: req.To, FromUserID: req.BuyerUserID, GiftID: gift.ID, RevisionID: gift.RevisionID,
Date: req.Date, NameHidden: req.HideName, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: upgradePrice,
PrepaidUpgradeHash: prepayHash, Message: req.Message}
PrepaidUpgradeHash: prepayHash, Message: req.Message, Unsaved: req.RecipientUnsaved}
return gift, saved, balance, nil
}

View file

@ -0,0 +1,266 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
// TestStarGiftResaleClearsSellerProfileStatePostgres pins the full seller-side
// teardown of a resale: a collectible that was worn as an emoji status and
// pinned to the profile must, the moment somebody buys it, stop being worn, stop
// being pinned, leave the seller's saved-gift list, and produce the three
// durable seller-visible updates the clients need to converge on that
// (user_emoji_status for the cleared status, new_message for the sale card,
// edit_message for the retired older card).
//
// This is the "my sold gift is still on my profile" report: everything below is
// server state and server push, so a client still showing the gift after this
// test passes is showing its own cache, not our state.
func TestStarGiftResaleClearsSellerProfileStatePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
seller := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ResaleSeller", "")
buyer := createTestUser(t, ctx, users, "+1882"+suffix+"02", "ResaleBuyer2", "")
sellerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: seller.ID}
stars := NewStarsStore(pool)
for _, u := range []domain.User{seller, buyer} {
if _, _, err := stars.EnsureGrant(ctx, u.ID, 10000, now); err != nil {
t.Fatalf("grant: %v", err)
}
}
gifts := NewStarGiftStore(pool)
base := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "ResaleSync " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
Document: collectibleTestDocument(base, "resale-sync.tgs"),
Blob: collectibleTestBlob(base, "resale-sync"), Animation: collectibleTestAnimation("resale-sync.tgs"),
Actor: "integration", CommandID: "resale-sync-catalog-" + suffix,
})
if err != nil {
t.Fatalf("catalog: %v", err)
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "rsy-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(base+1, "model.tgs"), Blob: collectibleTestBlobPtr(base+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Base Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(base+4, "model-two.tgs"), Blob: collectibleTestBlobPtr(base+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(base+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(base+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(base+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(base+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Day", BackdropID: 78, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
},
Actor: "integration", CommandID: "resale-sync-pool-" + suffix,
}); err != nil {
t.Fatalf("pool: %v", err)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
purchase := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{
BuyerUserID: seller.ID, To: sellerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true,
CommandKey: "resale-sync-purchase-" + suffix, Date: now,
})
bought, err := lifecycle.PurchaseStarGift(ctx, purchase)
if err != nil {
t.Fatalf("purchase: %v", err)
}
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: seller.ID, Ref: domain.SavedStarGiftRef{Owner: sellerPeer, MsgID: bought.Saved.MsgID},
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "resale-sync-upgrade-" + suffix, Date: now + 1,
})
if err != nil {
t.Fatalf("upgrade: %v", err)
}
// Wear it.
selected, valid := domain.CollectibleEmojiStatus(upgraded.Unique)
if !valid {
t.Fatalf("cannot wear: %+v", upgraded.Unique)
}
if _, err := users.UpdateEmojiStatus(ctx, seller.ID, domain.UserEmojiStatus{
DocumentID: selected.DocumentID, Collectible: selected,
}); err != nil {
t.Fatalf("wear: %v", err)
}
// Pin it to the profile.
if err := gifts.SetPinned(ctx, sellerPeer, []int64{upgraded.Saved.ID}); err != nil {
t.Fatalf("pin: %v", err)
}
var pinnedOrder int
if err := pool.QueryRow(ctx, `SELECT pinned_order FROM peer_star_gifts WHERE id=$1`, upgraded.Saved.ID).Scan(&pinnedOrder); err != nil {
t.Fatalf("read pin: %v", err)
}
if pinnedOrder == 0 {
t.Fatalf("gift was not pinned before the sale")
}
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{
ActorUserID: seller.ID, Ref: domain.SavedStarGiftRef{Owner: sellerPeer, MsgID: upgraded.Saved.MsgID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 500}, Date: now + 2,
})
if err != nil {
t.Fatalf("list: %v", err)
}
// The seller's profile-visible state right before the sale.
beforePage, err := gifts.ListByOwner(ctx, sellerPeer, true, "", 20)
if err != nil {
t.Fatalf("list before: %v", err)
}
if len(beforePage.Gifts) != 1 {
t.Fatalf("seller saved gifts before the sale = %d, want 1", len(beforePage.Gifts))
}
sellerPtsBefore, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, seller.ID)
if err != nil {
t.Fatalf("pts before: %v", err)
}
resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{
BuyerUserID: buyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID},
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 500}, FormID: 31001,
CommandKey: "resale-sync-resale-" + suffix, Date: now + 3,
})
if err != nil {
t.Fatalf("resale: %v", err)
}
buyerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID}
if resold.Unique.Owner != buyerPeer || resold.Saved.Owner != buyerPeer {
t.Fatalf("resale ownership = unique %+v saved %+v, want %+v", resold.Unique.Owner, resold.Saved.Owner, buyerPeer)
}
// The gift is no longer pinned to the seller's profile.
if err := pool.QueryRow(ctx, `SELECT pinned_order FROM peer_star_gifts WHERE id=$1`, upgraded.Saved.ID).Scan(&pinnedOrder); err != nil {
t.Fatalf("read pin after: %v", err)
}
if pinnedOrder != 0 {
t.Fatalf("sold gift is still pinned to the seller's profile: pinned_order = %d", pinnedOrder)
}
// The seller no longer wears it: the lifecycle trigger cleared the status.
var docID int64
var collectibleID *int64
if err := pool.QueryRow(ctx, `SELECT emoji_status_document_id,emoji_status_collectible_id FROM users WHERE id=$1`,
seller.ID).Scan(&docID, &collectibleID); err != nil {
t.Fatalf("read status: %v", err)
}
if docID != 0 || collectibleID != nil {
t.Fatalf("seller still wears the sold collectible: document_id=%d collectible_id=%v", docID, collectibleID)
}
// And it has left the seller's saved-gift list entirely.
afterPage, err := gifts.ListByOwner(ctx, sellerPeer, true, "", 20)
if err != nil {
t.Fatalf("list after: %v", err)
}
if len(afterPage.Gifts) != 0 {
t.Fatalf("seller still owns %d saved gifts after the sale", len(afterPage.Gifts))
}
buyerPage, err := gifts.ListByOwner(ctx, buyerPeer, true, "", 20)
if err != nil {
t.Fatalf("buyer list after: %v", err)
}
if len(buyerPage.Gifts) != 1 || buyerPage.Gifts[0].ID != upgraded.Saved.ID || buyerPage.Gifts[0].PinnedOrder != 0 {
t.Fatalf("buyer saved gifts = %+v, want exactly the unpinned sold gift", buyerPage.Gifts)
}
// Every seller-visible consequence is also a durable update the clients can
// converge on, in contiguous pts order and with no gap.
sellerPtsAfter, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, seller.ID)
if err != nil {
t.Fatalf("pts after: %v", err)
}
rows, err := pool.Query(ctx, `SELECT pts,event_type FROM user_update_events
WHERE user_id=$1 AND pts>$2 ORDER BY pts`, seller.ID, sellerPtsBefore)
if err != nil {
t.Fatalf("events: %v", err)
}
kinds := make([]string, 0, 3)
pointers := make([]int, 0, 3)
for rows.Next() {
var pts int
var kind string
if err := rows.Scan(&pts, &kind); err != nil {
rows.Close()
t.Fatal(err)
}
pointers = append(pointers, pts)
kinds = append(kinds, kind)
}
rows.Close()
if err := rows.Err(); err != nil {
t.Fatal(err)
}
want := []string{
string(domain.UpdateEventUserEmojiStatus),
string(domain.UpdateEventNewMessage),
string(domain.UpdateEventEditMessage),
}
if len(kinds) != len(want) {
t.Fatalf("seller update events = %v (pts %v), want %v", kinds, pointers, want)
}
for i, kind := range want {
if kinds[i] != kind {
t.Fatalf("seller update events = %v, want %v", kinds, want)
}
if pointers[i] != sellerPtsBefore+1+i {
t.Fatalf("seller update pts = %v, want contiguous from %d", pointers, sellerPtsBefore+1)
}
}
if sellerPtsAfter != sellerPtsBefore+len(want) {
t.Fatalf("seller contiguous pts = %d, want %d", sellerPtsAfter, sellerPtsBefore+len(want))
}
// Each of them is also queued for online dispatch, and none of them is
// suppressed by the buyer's origin session.
var dispatched int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts>$2 AND exclude_auth_key_id=0 AND exclude_session_id=0`,
seller.ID, sellerPtsBefore).Scan(&dispatched); err != nil {
t.Fatalf("dispatch rows: %v", err)
}
if dispatched != len(want) {
t.Fatalf("seller dispatch rows = %d, want %d", dispatched, len(want))
}
// The cleared status must serialise as an explicit empty status, not as a
// dropped update: a client that never sees it keeps rendering the sold gift.
events, err := NewUpdateEventStore(pool).ListAfter(ctx, seller.ID, sellerPtsBefore, 10)
if err != nil {
t.Fatalf("read events: %v", err)
}
found := false
for _, event := range events {
if event.Type != domain.UpdateEventUserEmojiStatus {
continue
}
found = true
if event.UserID != seller.ID || !event.EmojiStatus.Empty() || !event.EmojiStatus.Valid() {
t.Fatalf("cleared emoji status event = %+v, want a valid empty status for %d", event, seller.ID)
}
}
if !found {
t.Fatalf("no user_emoji_status event in %+v", events)
}
}

View file

@ -152,6 +152,7 @@ WHERE collectible_revision_id=$1 AND crafted
MsgID: ownerMessageID,
Date: req.Date,
NameHidden: req.HideName,
Unsaved: req.RecipientUnsaved,
LifecycleStatus: domain.StarGiftLifecycleActive,
Message: req.Message,
TransferStars: s.lifecycle.TransferStars,
@ -517,7 +518,7 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
locked.CanCraftAt = canCraftAt
locked.Unique = &unique
result.Saved, result.Unique, result.Balance = locked, unique, balance
action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID)
action := starGiftUpgradeUniqueAction(locked, unique, req)
messageReq.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
@ -529,7 +530,7 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
ownerMessageID := sent.RecipientMessage.ID
if saved.FromUserID == req.UserID {
if result.Saved.Owner.Type == domain.PeerTypeUser && saved.FromUserID == req.UserID {
ownerMessageID = sent.SenderMessage.ID
}
if ownerMessageID <= 0 {
@ -547,6 +548,9 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
result.Saved.ID, result.Unique.ID); err != nil {
return err
}
} else if err := registerViewerStarGiftMessageRef(ctx, tx, req.UserID, ownerMessageID,
result.Saved.ID, result.Saved.Owner, result.Unique.ID); err != nil {
return err
}
result.Saved.UpgradeMsgID = ownerMessageID
if result.Saved.Owner.Type == domain.PeerTypeUser {
@ -575,7 +579,7 @@ WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts)
return fmt.Errorf("save star gift source edit pts lost command row")
}
} else {
action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req, messageSenderID)
action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req)
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, result.Saved.Owner.ID,
req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action,
@ -598,15 +602,16 @@ WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts)
return result, nil
}
func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest, messageSenderID int64) *domain.MessageStarGiftUniqueAction {
func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest) *domain.MessageStarGiftUniqueAction {
fromUserID := saved.FromUserID
if saved.NameHidden {
fromUserID = 0
}
if saved.Owner.Type == domain.PeerTypeChannel {
// TDesktop recognizes a channel-owned upgrade from the official service
// peer plus action.peer=channel and action.saved_id.
fromUserID = messageSenderID
// The private envelope is sent by 777000, while action.from_id identifies
// the administrator who performed the upgrade. TDesktop uses that
// distinction to render "upgraded" instead of an unknown transfer.
fromUserID = req.UserID
}
peer := saved.Owner
savedID := saved.SavedID

View file

@ -5,6 +5,8 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
@ -22,24 +24,71 @@ func registerUserStarGiftMessageRef(
savedGiftID int64,
uniqueGiftID int64,
) error {
if ownerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || uniqueGiftID < 0 {
return fmt.Errorf("register user star gift message ref: invalid identity")
return registerViewerStarGiftMessageRef(ctx, tx, ownerUserID, msgID, savedGiftID,
domain.Peer{Type: domain.PeerTypeUser, ID: ownerUserID}, uniqueGiftID)
}
// registerViewerStarGiftMessageRef binds one viewer-local private message to
// the aggregate owner explicitly named by the action. The alias does not grant
// ownership: RPC callers resolve the real owner and authorize it again.
func registerViewerStarGiftMessageRef(
ctx context.Context,
tx pgx.Tx,
viewerUserID int64,
msgID int,
savedGiftID int64,
expectedOwner domain.Peer,
uniqueGiftID int64,
) error {
if viewerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || !validLifecyclePeer(expectedOwner) || uniqueGiftID < 0 {
return fmt.Errorf("register star gift message ref: invalid identity")
}
tag, err := tx.Exec(ctx, `
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT $1,$2,p.id
FROM peer_star_gifts p
WHERE p.id=$3 AND p.owner_peer_type='user' AND p.owner_peer_id=$1
AND (($4::bigint=0 AND p.unique_gift_id IS NULL) OR ($4::bigint>0 AND p.unique_gift_id=$4::bigint))
WHERE p.id=$3 AND p.owner_peer_type=$4 AND p.owner_peer_id=$5
AND (($6::bigint=0 AND p.unique_gift_id IS NULL) OR ($6::bigint>0 AND p.unique_gift_id=$6::bigint))
AND p.lifecycle_status='active'
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
SET saved_gift_id=EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`, ownerUserID, msgID, savedGiftID, uniqueGiftID)
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`,
viewerUserID, msgID, savedGiftID, string(expectedOwner.Type), expectedOwner.ID, uniqueGiftID)
if err != nil {
return fmt.Errorf("register user star gift message ref: %w", err)
return fmt.Errorf("register star gift message ref: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("register user star gift message ref: identity collision")
return fmt.Errorf("register star gift message ref: identity collision")
}
return nil
}
func registerChannelNotificationMessageRef(
ctx context.Context,
tx pgx.Tx,
viewerUserID int64,
msgID int,
savedGiftID int64,
) error {
if viewerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 {
return fmt.Errorf("register channel notification star gift message ref: invalid identity")
}
tag, err := tx.Exec(ctx, `
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT $1,$2,gift.id
FROM star_gift_channel_notification_jobs job
JOIN peer_star_gifts gift ON gift.id=job.saved_gift_id
WHERE job.saved_gift_id=$3 AND job.target_user_id=$1
AND gift.owner_peer_type='channel' AND gift.lifecycle_status='active'
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
SET saved_gift_id=EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`,
viewerUserID, msgID, savedGiftID)
if err != nil {
return fmt.Errorf("register channel notification star gift message ref: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("register channel notification star gift message ref: identity collision")
}
return nil
}

View file

@ -0,0 +1,117 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestStarGiftLedgerTransactionDirectionsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
ownerID := (time.Now().UnixNano() & 0x1fffffffffffffff) + 3_000_000_000
channelID := ownerID + 1
lifecycle := NewStarGiftLifecycleStore(pool, nil, 0)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id=$1`, ownerID)
_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id=$1`, ownerID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_transactions WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_ton_transactions WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_ton_balances WHERE channel_id=$1`, channelID)
})
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,70,true)`, ownerID); err != nil {
t.Fatalf("insert ton balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,70)`, channelID); err != nil {
t.Fatalf("insert channel stars balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,70)`, channelID); err != nil {
t.Fatalf("insert channel ton balance: %v", err)
}
for i, amount := range []int64{100, -40, 20, -10} {
date := 1_800_000_000 + i
if _, err := pool.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,date) VALUES($1,$2,'adjust',$3)`, ownerID, amount, date); err != nil {
t.Fatalf("insert ton transaction %d: %v", i, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_transactions(channel_id,actor_user_id,amount,reason,date) VALUES($1,$2,$3,'adjust',$4)`, channelID, ownerID, amount, date); err != nil {
t.Fatalf("insert channel stars transaction %d: %v", i, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_ton_transactions(channel_id,actor_user_id,amount_nanoton,reason,date) VALUES($1,$2,$3,'adjust',$4)`, channelID, ownerID, amount, date); err != nil {
t.Fatalf("insert channel ton transaction %d: %v", i, err)
}
}
tonIncoming, err := lifecycle.TonTransactions(ctx, ownerID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("personal ton incoming: %v", err)
}
assertTonTransactionAmounts(t, tonIncoming.Transactions, []int64{20, 100})
tonOutgoing, err := lifecycle.TonTransactions(ctx, ownerID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing, Ascending: true,
})
if err != nil {
t.Fatalf("personal ton outgoing: %v", err)
}
assertTonTransactionAmounts(t, tonOutgoing.Transactions, []int64{-40, -10})
channelIncoming1, err := lifecycle.ChannelStarsTransactions(ctx, channelID, domain.StarsTransactionQuery{
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("channel stars incoming page1: %v", err)
}
assertPostgresStarsAmounts(t, channelIncoming1.Transactions, []int64{20})
if channelIncoming1.NextOffset == "" {
t.Fatal("channel stars incoming page1 missing next offset")
}
channelIncoming2, err := lifecycle.ChannelStarsTransactions(ctx, channelID, domain.StarsTransactionQuery{
Offset: channelIncoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("channel stars incoming page2: %v", err)
}
assertPostgresStarsAmounts(t, channelIncoming2.Transactions, []int64{100})
if channelIncoming2.NextOffset != "" {
t.Fatalf("channel stars terminal next offset = %q", channelIncoming2.NextOffset)
}
channelTonOutgoing, err := lifecycle.ChannelTonTransactions(ctx, channelID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing,
})
if err != nil {
t.Fatalf("channel ton outgoing: %v", err)
}
assertTonTransactionAmounts(t, channelTonOutgoing.Transactions, []int64{-10, -40})
}
func assertPostgresStarsAmounts(t *testing.T, transactions []domain.StarsTransaction, want []int64) {
t.Helper()
if len(transactions) != len(want) {
t.Fatalf("stars transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
}
for i, amount := range want {
if transactions[i].Amount != amount {
t.Fatalf("stars transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
}
}
}
func assertTonTransactionAmounts(t *testing.T, transactions []domain.TonTransaction, want []int64) {
t.Helper()
if len(transactions) != len(want) {
t.Fatalf("ton transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
}
for i, amount := range want {
if transactions[i].Amount != amount {
t.Fatalf("ton transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
}
}
}

View file

@ -130,12 +130,13 @@ func (s *StarsStore) Debit(ctx context.Context, userID, amount int64, reason dom
return out, nil
}
func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if userID == 0 {
return domain.StarsTransactionPage{}, nil
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
bal, err := s.GetBalance(ctx, userID)
if err != nil {
@ -143,29 +144,19 @@ func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, offset
}
page := domain.StarsTransactionPage{Balance: bal.Balance}
// keyset多取一条以探测是否还有下一页。
args := []any{userID, limit + 1}
query := `
// keyset方向过滤先于 LIMIT多取一条以探测同一视图是否还有下一页。
where, order, args := starsTransactionQueryParts("user_id", "amount", userID, query)
rows, err := s.db.Query(ctx, `
SELECT id, peer_type, peer_id, amount, reason, title, description, date
FROM stars_transactions
WHERE user_id = $1
ORDER BY id DESC
LIMIT $2`
if cursor, ok := domain.DecodeStarsCursor(offset); ok {
query = `
SELECT id, peer_type, peer_id, amount, reason, title, description, date
FROM stars_transactions
WHERE user_id = $1 AND id < $3
ORDER BY id DESC
LIMIT $2`
args = append(args, cursor)
}
rows, err := s.db.Query(ctx, query, args...)
WHERE `+where+`
ORDER BY id `+order+`
LIMIT $2`, args...)
if err != nil {
return domain.StarsTransactionPage{}, fmt.Errorf("list stars transactions: %w", err)
}
defer rows.Close()
txns := make([]domain.StarsTransaction, 0, limit)
txns := make([]domain.StarsTransaction, 0, query.Limit+1)
for rows.Next() {
var (
t domain.StarsTransaction
@ -186,14 +177,37 @@ LIMIT $2`
if err := rows.Err(); err != nil {
return domain.StarsTransactionPage{}, fmt.Errorf("iterate stars transactions: %w", err)
}
if len(txns) > limit {
txns = txns[:limit]
if len(txns) > query.Limit {
txns = txns[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(txns[len(txns)-1].ID)
}
page.Transactions = txns
return page, nil
}
// starsTransactionQueryParts centralizes the sign predicate and keyset
// direction for personal/channel Stars and TON ledgers. Column names are only
// package-owned constants; client values remain bind parameters.
func starsTransactionQueryParts(ownerColumn, amountColumn string, ownerID int64, query domain.StarsTransactionQuery) (string, string, []any) {
where := ownerColumn + "=$1"
switch query.Direction {
case domain.StarsTransactionDirectionIncoming:
where += " AND " + amountColumn + ">0"
case domain.StarsTransactionDirectionOutgoing:
where += " AND " + amountColumn + "<0"
}
order, comparator := "DESC", "<"
if query.Ascending {
order, comparator = "ASC", ">"
}
args := []any{ownerID, query.Limit + 1}
if cursor, ok := domain.DecodeStarsCursor(query.Offset); ok {
where += " AND id" + comparator + "$3"
args = append(args, cursor)
}
return where, order, args
}
// insertStarsTxn 在事务内写一条流水amount 带符号)。
func insertStarsTxn(ctx context.Context, tx pgx.Tx, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) error {
if _, err := tx.Exec(ctx, `

View file

@ -0,0 +1,628 @@
package postgres
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// StarsPurchaseStore commits fiat Stars top-ups, friend gifts and giveaway
// launches. No external
// provider is contacted by this local development checkout; form binding and
// settlement idempotency are still production-shaped so retries cannot mint twice.
type StarsPurchaseStore struct {
db sqlcgen.DBTX
messages *MessageStore
channels *ChannelStore
}
func NewStarsPurchaseStore(db sqlcgen.DBTX, messages *MessageStore, channels ...*ChannelStore) *StarsPurchaseStore {
var channelStore *ChannelStore
if len(channels) > 0 {
channelStore = channels[0]
}
return &StarsPurchaseStore{db: db, messages: messages, channels: channelStore}
}
func (s *StarsPurchaseStore) IssueStarsPurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
if s == nil || s.db == nil || !validStarsPurchaseForm(form) {
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
}
purposeJSON, err := starsPurchasePurposeJSON(form)
if err != nil {
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
}
for attempt := 0; attempt < 8; attempt++ {
formID, err := newStarsPurchaseFormID()
if err != nil {
return domain.StarsPurchaseForm{}, fmt.Errorf("generate stars purchase form id: %w", err)
}
tag, err := s.db.Exec(ctx, `
INSERT INTO stars_purchase_forms
(buyer_user_id,form_id,kind,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,issued_at,expires_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT DO NOTHING`, form.BuyerUserID, formID, string(form.Kind), starsPurchaseRecipientValue(form.RecipientUserID),
starsPurchasePeerTypeValue(form.SpendPurposePeer), starsPurchasePeerIDValue(form.SpendPurposePeer),
purposeJSON, form.Stars, form.Currency, form.Amount, form.IssuedAt, form.ExpiresAt)
if err != nil {
return domain.StarsPurchaseForm{}, fmt.Errorf("insert stars purchase form: %w", err)
}
if tag.RowsAffected() == 1 {
form.FormID = formID
return form, nil
}
}
return domain.StarsPurchaseForm{}, domain.ErrStarsGiftUnavailable
}
var errStarsPurchaseReplay = errors.New("stars purchase replay")
func (s *StarsPurchaseStore) PurchaseStars(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
if s == nil || s.db == nil || req.FormID == 0 || req.Date <= 0 || !validStarsPurchaseCommand(req.StarsPurchaseForm) {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
fingerprint := starsPurchaseFingerprint(req)
if replay, found, err := s.loadStarsPurchaseReplay(ctx, req, fingerprint); err != nil || found {
return replay, err
}
// A committed command is replayable after both the checkout and giveaway
// deadlines: the provider may retry a successful submission after losing the
// response, and a terminal campaign must not turn that exact retry into a
// different outcome. The deadline only gates a first settlement.
if req.Kind == domain.StarsPurchaseGiveaway && req.Giveaway.UntilDate <= req.Date {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormExpired
}
switch req.Kind {
case domain.StarsPurchaseTopup:
return s.purchaseStarsTopup(ctx, req, fingerprint)
case domain.StarsPurchaseGiveaway:
return s.purchaseStarsGiveaway(ctx, req, fingerprint)
}
if s.messages == nil {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
transactionID := fmt.Sprintf("stars-gift:%d:%d", req.BuyerUserID, req.FormID)
randomID := lifecycleCommandRandomID("stars-fiat-gift", req.BuyerUserID, req.FormID)
media := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionGiftStars,
GiftStars: &domain.MessageGiftStarsAction{Currency: req.Currency, Amount: req.Amount,
Stars: req.Stars, TransactionID: transactionID},
}}
messageReq := domain.SendPrivateTextRequest{
SenderUserID: req.BuyerUserID, RecipientUserID: req.RecipientUserID,
RandomID: randomID, Date: req.Date, Media: media,
OriginUserID: req.BuyerUserID, OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID, IdempotencyFingerprint: fingerprint[:],
}
result := domain.StarsPurchaseResult{TransactionID: transactionID}
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
return err
} else if found {
return errStarsPurchaseReplay
}
balance := domain.StarsBalance{UserID: req.RecipientUserID}
if err := tx.QueryRow(ctx, `
INSERT INTO stars_balances (user_id,balance,updated_at) VALUES($1,$2,now())
ON CONFLICT (user_id) DO UPDATE
SET balance=stars_balances.balance+EXCLUDED.balance, updated_at=now()
RETURNING balance,granted`, req.RecipientUserID, req.Stars).Scan(&balance.Balance, &balance.Granted); err != nil {
return fmt.Errorf("credit stars gift recipient: %w", err)
}
if err := insertStarsTxn(ctx, tx, req.RecipientUserID, req.Stars, domain.StarsReasonGift,
domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, req.Date,
"Stars gift", fmt.Sprintf("%d Stars", req.Stars)); err != nil {
return err
}
result.Balance = balance
if send.Media == nil || send.Media.ServiceAction == nil || send.Media.ServiceAction.GiftStars == nil {
return domain.ErrStarsPurchaseFormInvalid
}
send.Media.ServiceAction.GiftStars.BalanceAfter = balance.Balance
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
_, err := tx.Exec(ctx, `
INSERT INTO stars_purchase_commands
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
balance_after,transaction_id,created_at)
VALUES($1,$2,$3,$4,$5,NULL,NULL,'{}'::jsonb,$6,$7,$8,$9,$10,$11)`, req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:],
req.RecipientUserID, req.Stars, req.Currency, req.Amount, result.Balance.Balance, transactionID, req.Date)
if err != nil {
return fmt.Errorf("insert stars gift purchase command: %w", err)
}
result.Send = sent
return nil
},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if errors.Is(err, errStarsPurchaseReplay) {
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarsPurchaseResult{}, err
}
result.Send = sent
return result, nil
}
func (s *StarsPurchaseStore) purchaseStarsTopup(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, error) {
transactionID := fmt.Sprintf("stars-topup:%d:%d", req.BuyerUserID, req.FormID)
result := domain.StarsPurchaseResult{TransactionID: transactionID}
err := withTx(ctx, s.db, "settle stars topup", func(tx pgx.Tx) error {
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
return err
} else if found {
return errStarsPurchaseReplay
}
result.Balance = domain.StarsBalance{UserID: req.BuyerUserID}
if err := tx.QueryRow(ctx, `
INSERT INTO stars_balances (user_id,balance,updated_at) VALUES($1,$2,now())
ON CONFLICT (user_id) DO UPDATE
SET balance=stars_balances.balance+EXCLUDED.balance, updated_at=now()
RETURNING balance,granted`, req.BuyerUserID, req.Stars).Scan(&result.Balance.Balance, &result.Balance.Granted); err != nil {
return fmt.Errorf("credit stars topup buyer: %w", err)
}
if err := insertStarsTxn(ctx, tx, req.BuyerUserID, req.Stars, domain.StarsReasonTopup,
req.SpendPurposePeer, req.Date, "Stars top-up", "telesrv dev purchase"); err != nil {
return err
}
_, err := tx.Exec(ctx, `
INSERT INTO stars_purchase_commands
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
balance_after,transaction_id,created_at)
VALUES($1,$2,$3,$4,NULL,$5,$6,'{}'::jsonb,$7,$8,$9,$10,$11,$12)`, req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:],
starsPurchasePeerTypeValue(req.SpendPurposePeer), starsPurchasePeerIDValue(req.SpendPurposePeer),
req.Stars, req.Currency, req.Amount, result.Balance.Balance, transactionID, req.Date)
if err != nil {
return fmt.Errorf("insert stars topup command: %w", err)
}
return nil
})
if errors.Is(err, errStarsPurchaseReplay) {
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
return replay, replayErr
}
}
if err != nil {
return domain.StarsPurchaseResult{}, err
}
return result, nil
}
func (s *StarsPurchaseStore) purchaseStarsGiveaway(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, error) {
if s.channels == nil || req.Giveaway == nil {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
purposeJSON, err := starsPurchasePurposeJSON(req.StarsPurchaseForm)
if err != nil {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
giveaway := *req.Giveaway
transactionID := fmt.Sprintf("stars-giveaway:%d:%d", req.BuyerUserID, req.FormID)
result := domain.StarsPurchaseResult{TransactionID: transactionID}
sendReq := domain.SendChannelMessageRequest{
UserID: req.BuyerUserID, ChannelID: giveaway.BoostPeer.ID,
RandomID: giveaway.RandomID, Date: req.Date,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindGiveaway, Giveaway: &domain.MessageGiveaway{
OnlyNewSubscribers: giveaway.OnlyNewSubscribers, WinnersAreVisible: giveaway.WinnersAreVisible,
Channels: starsGiveawayChannelIDs(giveaway), CountriesISO2: append([]string(nil), giveaway.CountriesISO2...),
PrizeDescription: giveaway.PrizeDescription, Quantity: giveaway.Users, Stars: req.Stars, UntilDate: giveaway.UntilDate,
}},
IdempotencyFingerprint: fingerprint[:],
}
hooks := channelSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, _ *domain.SendChannelMessageRequest) error {
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
return err
} else if found {
return errStarsPurchaseReplay
}
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendChannelMessageResult) error {
if sent.Message.ID <= 0 || sent.Event.Pts <= 0 || sent.Event.PtsCount != 1 {
return fmt.Errorf("settle stars giveaway: invalid channel send receipt")
}
if _, err := tx.Exec(ctx, `
INSERT INTO stars_giveaways
(buyer_user_id,form_id,channel_id,launch_message_id,random_id,stars,users,per_user_stars,yearly_boosts,
until_date,purpose_json,state,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'active',$12)`,
req.BuyerUserID, req.FormID, giveaway.BoostPeer.ID, sent.Message.ID, giveaway.RandomID,
req.Stars, giveaway.Users, giveaway.PerUserStars, giveaway.YearlyBoosts, giveaway.UntilDate, purposeJSON, req.Date); err != nil {
return fmt.Errorf("insert stars giveaway campaign: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO stars_purchase_commands
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
balance_after,transaction_id,created_at)
VALUES($1,$2,$3,$4,NULL,NULL,NULL,$5,$6,$7,$8,0,$9,$10)`,
req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:], purposeJSON,
req.Stars, req.Currency, req.Amount, transactionID, req.Date); err != nil {
return fmt.Errorf("insert stars giveaway purchase command: %w", err)
}
result.ChannelSend = sent
return nil
},
}
sent, err := s.channels.sendChannelMessageWithHooks(ctx, sendReq, hooks)
if err != nil {
if errors.Is(err, errStarsPurchaseReplay) {
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarsPurchaseResult{}, err
}
if sent.Duplicate {
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
return replay, replayErr
}
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
result.ChannelSend = sent
return result, nil
}
func starsGiveawayChannelIDs(giveaway domain.StarsGiveawayPurchase) []int64 {
ids := make([]int64, 0, 1+len(giveaway.AdditionalPeers))
ids = append(ids, giveaway.BoostPeer.ID)
for _, peer := range giveaway.AdditionalPeers {
ids = append(ids, peer.ID)
}
return ids
}
func validateStarsPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarsPurchaseRequest, lock bool) error {
query := `SELECT kind,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,issued_at,expires_at
FROM stars_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2`
if lock {
query += ` FOR UPDATE`
}
var kind string
var recipientID pgtype.Int8
var spendPeerType pgtype.Text
var spendPeerID pgtype.Int8
var purposeJSON []byte
var stars, amount int64
var currency string
var issuedAt, expiresAt int
err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID).
Scan(&kind, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, &currency, &amount, &issuedAt, &expiresAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarsPurchaseFormInvalid
}
if err != nil {
return fmt.Errorf("load stars gift form: %w", err)
}
if req.Date >= expiresAt {
return domain.ErrStarsPurchaseFormExpired
}
if issuedAt <= 0 || kind != string(req.Kind) || nullableStarsRecipient(recipientID) != req.RecipientUserID ||
nullableStarsPeer(spendPeerType, spendPeerID) != req.SpendPurposePeer || stars != req.Stars ||
currency != req.Currency || amount != req.Amount || !sameStarsPurchasePurpose(purposeJSON, req.StarsPurchaseForm) {
return domain.ErrStarsPurchaseFormInvalid
}
return nil
}
func (s *StarsPurchaseStore) loadStarsPurchaseReplay(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, bool, error) {
var recipientID pgtype.Int8
var spendPeerType pgtype.Text
var spendPeerID pgtype.Int8
var stars, amount, balance int64
var kind, currency, transactionID string
var storedFingerprint, purposeJSON []byte
err := s.db.QueryRow(ctx, `
SELECT kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,balance_after,transaction_id
FROM stars_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.BuyerUserID, req.FormID).
Scan(&kind, &storedFingerprint, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, &currency, &amount, &balance, &transactionID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarsPurchaseResult{}, false, nil
}
if err != nil {
return domain.StarsPurchaseResult{}, false, fmt.Errorf("load stars purchase replay: %w", err)
}
if kind != string(req.Kind) || !bytes.Equal(storedFingerprint, fingerprint[:]) || nullableStarsRecipient(recipientID) != req.RecipientUserID ||
nullableStarsPeer(spendPeerType, spendPeerID) != req.SpendPurposePeer ||
stars != req.Stars || currency != req.Currency || amount != req.Amount || transactionID == "" ||
!sameStarsPurchasePurpose(purposeJSON, req.StarsPurchaseForm) {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
result := domain.StarsPurchaseResult{
Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance},
TransactionID: transactionID, Duplicate: true,
}
if req.Kind == domain.StarsPurchaseTopup {
return result, true, nil
}
if req.Kind == domain.StarsPurchaseGiveaway {
if s.channels == nil || req.Giveaway == nil {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
sent, found, err := s.channels.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.Giveaway.BoostPeer.ID, SenderUserID: req.BuyerUserID,
RandomID: req.Giveaway.RandomID, IdempotencyFingerprint: fingerprint[:],
})
if err != nil {
return domain.StarsPurchaseResult{}, false, err
}
if !found {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
result.ChannelSend = sent
return result, true, nil
}
if s.messages == nil {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
sent, found, err := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
SenderUserID: req.BuyerUserID, RecipientUserID: req.RecipientUserID,
RandomID: lifecycleCommandRandomID("stars-fiat-gift", req.BuyerUserID, req.FormID),
IdempotencyFingerprint: fingerprint[:],
})
if err != nil {
return domain.StarsPurchaseResult{}, false, err
}
if !found {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
result.Balance.UserID = req.RecipientUserID
result.Send = sent
return result, true, nil
}
func starsPurchaseCommandExists(ctx context.Context, db sqlcgen.DBTX, buyerUserID, formID int64) (bool, error) {
var exists bool
if err := db.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM stars_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2)`, buyerUserID, formID).Scan(&exists); err != nil {
return false, fmt.Errorf("check stars purchase command: %w", err)
}
return exists, nil
}
func starsPurchaseFingerprint(req domain.StarsPurchaseRequest) [32]byte {
purposeJSON, _ := starsPurchasePurposeJSON(req.StarsPurchaseForm)
return sha256.Sum256([]byte(fmt.Sprintf("telesrv:stars-fiat-purchase:v2:%s:%d:%d:%s:%d:%d:%s:%d:%d:%s",
req.Kind, req.BuyerUserID, req.RecipientUserID, req.SpendPurposePeer.Type, req.SpendPurposePeer.ID,
req.Stars, req.Currency, req.Amount, req.FormID, purposeJSON)))
}
func newStarsPurchaseFormID() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, err
}
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
if id == 0 {
return 1, nil
}
return id, nil
}
func validStarsPurchaseForm(form domain.StarsPurchaseForm) bool {
if !validStarsPurchaseCommand(form) || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 {
return false
}
return form.Kind != domain.StarsPurchaseGiveaway ||
(form.Giveaway.UntilDate > form.IssuedAt && form.Giveaway.UntilDate <= form.IssuedAt+7*24*60*60)
}
func validStarsPurchaseCommand(form domain.StarsPurchaseForm) bool {
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || len(form.Currency) != 3 {
return false
}
validSpendPeer := (form.SpendPurposePeer == domain.Peer{}) ||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0)
switch form.Kind {
case domain.StarsPurchaseTopup:
return form.RecipientUserID == 0 && validSpendPeer && form.Giveaway == nil
case domain.StarsPurchaseGift:
return form.RecipientUserID > 0 && form.RecipientUserID != form.BuyerUserID && form.SpendPurposePeer == (domain.Peer{}) && form.Giveaway == nil
case domain.StarsPurchaseGiveaway:
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && validStarsGiveawayPurchase(form.Giveaway, form.Stars)
default:
return false
}
}
func validStarsGiveawayPurchase(g *domain.StarsGiveawayPurchase, stars int64) bool {
if g == nil || g.BoostPeer.Type != domain.PeerTypeChannel || g.BoostPeer.ID <= 0 || g.RandomID == 0 ||
g.UntilDate <= 0 || g.Users <= 0 || g.PerUserStars <= 0 || g.YearlyBoosts < 0 ||
int64(g.Users) > math.MaxInt64/g.PerUserStars || int64(g.Users)*g.PerUserStars != stars ||
len(g.AdditionalPeers) > 10 || len(g.CountriesISO2) > 10 || utf8.RuneCountInString(g.PrizeDescription) > 128 {
return false
}
seenPeers := map[int64]struct{}{g.BoostPeer.ID: struct{}{}}
for _, peer := range g.AdditionalPeers {
if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
return false
}
if _, exists := seenPeers[peer.ID]; exists {
return false
}
seenPeers[peer.ID] = struct{}{}
}
seenCountries := make(map[string]struct{}, len(g.CountriesISO2))
for _, country := range g.CountriesISO2 {
if len(country) != 2 || country != strings.ToUpper(country) || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' {
return false
}
if _, exists := seenCountries[country]; exists {
return false
}
seenCountries[country] = struct{}{}
}
return true
}
func starsPurchasePurposeJSON(form domain.StarsPurchaseForm) ([]byte, error) {
if form.Kind != domain.StarsPurchaseGiveaway {
return []byte(`{}`), nil
}
if form.Giveaway == nil {
return nil, domain.ErrStarsPurchaseFormInvalid
}
return json.Marshal(form.Giveaway)
}
func sameStarsPurchasePurpose(stored []byte, form domain.StarsPurchaseForm) bool {
want, err := starsPurchasePurposeJSON(form)
if err != nil {
return false
}
if form.Kind != domain.StarsPurchaseGiveaway {
var value map[string]any
return json.Unmarshal(stored, &value) == nil && len(value) == 0
}
var decoded domain.StarsGiveawayPurchase
if err := json.Unmarshal(stored, &decoded); err != nil {
return false
}
got, err := json.Marshal(&decoded)
return err == nil && bytes.Equal(got, want)
}
func starsPurchaseRecipientValue(recipientUserID int64) any {
if recipientUserID == 0 {
return nil
}
return recipientUserID
}
func nullableStarsRecipient(value pgtype.Int8) int64 {
if !value.Valid {
return 0
}
return value.Int64
}
func starsPurchasePeerTypeValue(peer domain.Peer) any {
if peer == (domain.Peer{}) {
return nil
}
return string(peer.Type)
}
func starsPurchasePeerIDValue(peer domain.Peer) any {
if peer == (domain.Peer{}) {
return nil
}
return peer.ID
}
func nullableStarsPeer(peerType pgtype.Text, peerID pgtype.Int8) domain.Peer {
if !peerType.Valid || !peerID.Valid {
return domain.Peer{}
}
return domain.Peer{Type: domain.PeerType(peerType.String), ID: peerID.Int64}
}
func (s *StarsPurchaseStore) GetStarsGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
if s == nil || s.db == nil || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
}
var purposeJSON []byte
var state string
var startDate, untilDate int
err := s.db.QueryRow(ctx, `
SELECT purpose_json,state,created_at,until_date
FROM stars_giveaways WHERE channel_id=$1 AND launch_message_id=$2`, channelID, messageID).
Scan(&purposeJSON, &state, &startDate, &untilDate)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarsGiveawayInfo{}, domain.ErrMessageIDInvalid
}
if err != nil {
return domain.StarsGiveawayInfo{}, fmt.Errorf("load stars giveaway info: %w", err)
}
var purpose domain.StarsGiveawayPurchase
if err := json.Unmarshal(purposeJSON, &purpose); err != nil || purpose.BoostPeer.ID != channelID {
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
}
info := domain.StarsGiveawayInfo{StartDate: startDate}
if state == "cancelled" {
return info, nil
}
if state != "active" || date >= untilDate {
info.PreparingResults = true
return info, nil
}
channels := starsGiveawayChannelIDs(purpose)
for _, requiredChannelID := range channels {
var role, status string
var joinedAt int
err := s.db.QueryRow(ctx, `
SELECT role,status,joined_at FROM channel_members WHERE channel_id=$1 AND user_id=$2`, requiredChannelID, viewerUserID).
Scan(&role, &status, &joinedAt)
if errors.Is(err, pgx.ErrNoRows) {
return info, nil
}
if err != nil {
return domain.StarsGiveawayInfo{}, fmt.Errorf("load giveaway participant membership: %w", err)
}
if status != string(domain.ChannelMemberActive) {
return info, nil
}
if role == string(domain.ChannelRoleCreator) || role == string(domain.ChannelRoleAdmin) {
info.AdminDisallowedChatID = requiredChannelID
return info, nil
}
if purpose.OnlyNewSubscribers && joinedAt > 0 && joinedAt <= startDate {
info.JoinedTooEarlyDate = joinedAt
return info, nil
}
}
if len(purpose.CountriesISO2) > 0 {
var country string
if err := s.db.QueryRow(ctx, `
SELECT COALESCE((SELECT cc.iso2 FROM country_codes cc WHERE cc.country_code=u.country_code ORDER BY cc.id LIMIT 1),'')
FROM users u WHERE u.id=$1`, viewerUserID).Scan(&country); err != nil {
return domain.StarsGiveawayInfo{}, fmt.Errorf("load giveaway participant country: %w", err)
}
allowed := false
for _, candidate := range purpose.CountriesISO2 {
if candidate == country {
allowed = true
break
}
}
if !allowed {
info.DisallowedCountry = country
return info, nil
}
}
info.Participating = true
return info, nil
}
var _ store.StarsPurchaseStore = (*StarsPurchaseStore)(nil)
var _ store.StarsGiveawayStore = (*StarsPurchaseStore)(nil)

View file

@ -0,0 +1,366 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
type starsPurchaseAttempt struct {
result domain.StarsPurchaseResult
err error
}
func purchaseStarsTwiceConcurrently(t *testing.T, ctx context.Context, store *StarsPurchaseStore, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, domain.StarsPurchaseResult) {
t.Helper()
start := make(chan struct{})
attempts := make(chan starsPurchaseAttempt, 2)
for range 2 {
go func() {
<-start
result, err := store.PurchaseStars(ctx, req)
attempts <- starsPurchaseAttempt{result: result, err: err}
}()
}
close(start)
var first, replay domain.StarsPurchaseResult
firstCount, replayCount := 0, 0
for range 2 {
attempt := <-attempts
if attempt.err != nil {
t.Fatalf("concurrent Stars purchase: %v", attempt.err)
}
if attempt.result.Duplicate {
replay, replayCount = attempt.result, replayCount+1
} else {
first, firstCount = attempt.result, firstCount+1
}
}
if firstCount != 1 || replayCount != 1 {
t.Fatalf("concurrent Stars purchase first/replay counts = %d/%d, want 1/1", firstCount, replayCount)
}
return first, replay
}
func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
buyer, err := users.Create(ctx, domain.User{AccessHash: 94101, Phone: "+1665941" + suffix + "01", FirstName: "GiftBuyer"})
if err != nil {
t.Fatalf("create buyer: %v", err)
}
recipient, err := users.Create(ctx, domain.User{AccessHash: 94102, Phone: "+1665941" + suffix + "02", FirstName: "GiftRecipient"})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id=$1", recipient.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id=$1", recipient.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{buyer.ID, recipient.ID})
})
messages := NewMessageStore(pool)
store := NewStarsPurchaseStore(pool, messages)
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
Stars: 2500, Currency: "USD", Amount: 199,
IssuedAt: 1_700_000_000, ExpiresAt: 1_700_000_600,
})
if err != nil || issued.FormID == 0 {
t.Fatalf("issue form = %+v err=%v", issued, err)
}
var origin [8]byte
origin[0] = 9
req := domain.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
Stars: 2500, Currency: "USD", Amount: 199,
},
Date: 1_700_000_100, OriginAuthKeyID: origin, OriginSessionID: 77,
}
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.Balance.Balance != 2500 || first.TransactionID == "" ||
first.Send.SenderEvent.PtsCount != 1 || first.Send.RecipientEvent.PtsCount != 1 {
t.Fatalf("first purchase = %+v", first)
}
if first.Send.SenderMessage.Pts <= 0 || first.Send.RecipientMessage.Pts <= 0 ||
first.Send.SenderMessage.UID == 0 || first.Send.SenderMessage.UID != first.Send.RecipientMessage.UID {
t.Fatalf("bilateral send = %+v", first.Send)
}
action := first.Send.RecipientMessage.Media.ServiceAction.GiftStars
if action == nil || action.Stars != 2500 || action.Currency != "USD" || action.Amount != 199 ||
action.TransactionID != first.TransactionID || action.BalanceAfter != 2500 {
t.Fatalf("recipient gift action = %+v", action)
}
if !replay.Duplicate || replay.TransactionID != first.TransactionID ||
replay.Send.SenderMessage.ID != first.Send.SenderMessage.ID || replay.Send.SenderEvent.Pts != first.Send.SenderEvent.Pts {
t.Fatalf("replay = %+v, first=%+v", replay, first)
}
var balance, txnCount, commandCount int64
if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", recipient.ID).Scan(&balance); err != nil {
t.Fatalf("load recipient balance: %v", err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='gift'", recipient.ID).Scan(&txnCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if balance != 2500 || txnCount != 1 || commandCount != 1 {
t.Fatalf("replay footprint balance=%d txns=%d commands=%d", balance, txnCount, commandCount)
}
for _, userID := range []int64{buyer.ID, recipient.ID} {
var eventCount, outboxCount int64
if err := pool.QueryRow(ctx, "SELECT count(*) FROM user_update_events WHERE user_id=$1", userID).Scan(&eventCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM dispatch_outbox WHERE target_user_id=$1", userID).Scan(&outboxCount); err != nil {
t.Fatal(err)
}
if eventCount != 1 || outboxCount != 1 {
t.Fatalf("user %d event/outbox=%d/%d, want 1/1", userID, eventCount, outboxCount)
}
}
tampered := req
tampered.Amount++
if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered replay err=%v", err)
}
expired, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
Stars: 1000, Currency: "USD", Amount: 99,
IssuedAt: 1_699_999_000, ExpiresAt: 1_699_999_600,
})
if err != nil {
t.Fatalf("issue expired form: %v", err)
}
expiredReq := req
expiredReq.FormID, expiredReq.Stars, expiredReq.Amount = expired.FormID, 1000, 99
if _, err := store.PurchaseStars(ctx, expiredReq); !errors.Is(err, domain.ErrStarsPurchaseFormExpired) {
t.Fatalf("expired form err=%v", err)
}
if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", recipient.ID).Scan(&balance); err != nil || balance != 2500 {
t.Fatalf("balance after failures=%d err=%v", balance, err)
}
}
func TestStarsTopupPurchaseAtomicReplayAndPurposeBindingPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
buyer, err := users.Create(ctx, domain.User{AccessHash: 94201, Phone: "+1665942" + suffix + "01", FirstName: "TopupBuyer"})
if err != nil {
t.Fatalf("create buyer: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=$1", buyer.ID)
})
store := NewStarsPurchaseStore(pool, nil)
purposePeer := domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID + 100}
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseTopup, BuyerUserID: buyer.ID, SpendPurposePeer: purposePeer,
Stars: 2500, Currency: "USD", Amount: 199,
IssuedAt: 1_700_000_000, ExpiresAt: 1_700_000_600,
})
if err != nil || issued.FormID == 0 {
t.Fatalf("issue form = %+v err=%v", issued, err)
}
req := domain.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseTopup, BuyerUserID: buyer.ID,
SpendPurposePeer: purposePeer, Stars: 2500, Currency: "USD", Amount: 199,
},
Date: 1_700_000_100,
}
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.Balance.Balance != 2500 || first.TransactionID == "" {
t.Fatalf("first purchase = %+v", first)
}
if !replay.Duplicate || replay.Balance.Balance != 2500 || replay.TransactionID != first.TransactionID {
t.Fatalf("replay = %+v, first=%+v", replay, first)
}
var balance, txnCount, commandCount int64
if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", buyer.ID).Scan(&balance); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='topup'", buyer.ID).Scan(&txnCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if balance != 2500 || txnCount != 1 || commandCount != 1 {
t.Fatalf("replay footprint balance=%d txns=%d commands=%d", balance, txnCount, commandCount)
}
tampered := req
tampered.SpendPurposePeer.ID++
if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered purpose replay err=%v", err)
}
otherBuyer := req
otherBuyer.BuyerUserID++
if _, err := store.PurchaseStars(ctx, otherBuyer); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("cross-account form err=%v", err)
}
if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", buyer.ID).Scan(&balance); err != nil || balance != 2500 {
t.Fatalf("balance after invalid submissions=%d err=%v", balance, err)
}
}
func TestStarsGiveawayPurchaseAtomicChannelPTSReplayAndInfoPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
owner, err := users.Create(ctx, domain.User{AccessHash: 94301, Phone: "+1665943" + suffix + "01", FirstName: "GiveawayOwner", CountryCode: "1"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 94302, Phone: "+1665943" + suffix + "02", FirstName: "GiveawayMember", CountryCode: "1"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Stars Giveaway " + suffix, Megagroup: true,
MemberUserIDs: []int64{member.ID}, Date: 1_700_000_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_giveaways WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=$1", channelID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
before, err := channels.GetChannelByID(ctx, channelID)
if err != nil {
t.Fatal(err)
}
purpose := &domain.StarsGiveawayPurchase{
BoostPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
CountriesISO2: []string{"US"}, RandomID: 9430001, UntilDate: 1_700_003_700,
Users: 2, PerUserStars: 500, YearlyBoosts: 4, WinnersAreVisible: true,
}
store := NewStarsPurchaseStore(pool, nil, channels)
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: purpose,
Stars: 1000, Currency: "USD", Amount: 99, IssuedAt: 1_700_000_100, ExpiresAt: 1_700_000_700,
})
if err != nil || issued.FormID == 0 {
t.Fatalf("issue giveaway form=%+v err=%v", issued, err)
}
req := domain.StarsPurchaseRequest{StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: purpose,
Stars: 1000, Currency: "USD", Amount: 99,
}, Date: 1_700_000_200}
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.TransactionID == "" || first.ChannelSend.Event.PtsCount != 1 ||
first.ChannelSend.Event.Pts != before.Pts+1 || first.ChannelSend.Message.Media == nil ||
first.ChannelSend.Message.Media.Giveaway == nil {
t.Fatalf("first giveaway result=%+v before_pts=%d", first, before.Pts)
}
media := first.ChannelSend.Message.Media.Giveaway
if media.Stars != 1000 || media.Quantity != 2 || len(media.Channels) != 1 || media.Channels[0] != channelID ||
media.UntilDate != purpose.UntilDate || !media.WinnersAreVisible {
t.Fatalf("giveaway media=%+v", media)
}
difference, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: member.ID, ChannelID: channelID, Pts: before.Pts, Limit: 10,
})
if err != nil || difference.Pts != first.ChannelSend.Event.Pts || len(difference.Events) != 1 || len(difference.NewMessages) != 1 ||
difference.NewMessages[0].Media == nil || difference.NewMessages[0].Media.Giveaway == nil ||
difference.NewMessages[0].Media.Giveaway.Stars != 1000 {
t.Fatalf("giveaway channel difference=%+v err=%v", difference, err)
}
if !replay.Duplicate || replay.TransactionID != first.TransactionID ||
replay.ChannelSend.Message.ID != first.ChannelSend.Message.ID || replay.ChannelSend.Event.Pts != first.ChannelSend.Event.Pts {
t.Fatalf("giveaway replay=%+v first=%+v", replay, first)
}
lateReplayReq := req
lateReplayReq.Date = purpose.UntilDate
lateReplay, err := store.PurchaseStars(ctx, lateReplayReq)
if err != nil || !lateReplay.Duplicate || lateReplay.TransactionID != first.TransactionID ||
lateReplay.ChannelSend.Message.ID != first.ChannelSend.Message.ID || lateReplay.ChannelSend.Event.Pts != first.ChannelSend.Event.Pts {
t.Fatalf("giveaway replay after until_date=%+v err=%v first=%+v", lateReplay, err, first)
}
latePurpose := *purpose
latePurpose.RandomID++
lateForm, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: &latePurpose,
Stars: 1000, Currency: "USD", Amount: 99,
IssuedAt: purpose.UntilDate - 100, ExpiresAt: purpose.UntilDate + 500,
})
if err != nil {
t.Fatalf("issue giveaway form before until_date: %v", err)
}
lateFirstReq := req
lateFirstReq.FormID = lateForm.FormID
lateFirstReq.Giveaway = &latePurpose
lateFirstReq.Date = purpose.UntilDate
if _, err := store.PurchaseStars(ctx, lateFirstReq); !errors.Is(err, domain.ErrStarsPurchaseFormExpired) {
t.Fatalf("first giveaway settlement at until_date err=%v, want form expired", err)
}
var campaigns, commands, messages, events, balanceRows, txns int64
queries := []struct {
query string
args []any
target *int64
}{
{"SELECT count(*) FROM stars_giveaways WHERE buyer_user_id=$1", []any{owner.ID}, &campaigns},
{"SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", []any{owner.ID}, &commands},
{"SELECT count(*) FROM channel_messages WHERE channel_id=$1 AND id=$2", []any{channelID, first.ChannelSend.Message.ID}, &messages},
{"SELECT count(*) FROM channel_update_events WHERE channel_id=$1 AND pts=$2", []any{channelID, first.ChannelSend.Event.Pts}, &events},
{"SELECT count(*) FROM stars_balances WHERE user_id=$1", []any{owner.ID}, &balanceRows},
{"SELECT count(*) FROM stars_transactions WHERE user_id=$1", []any{owner.ID}, &txns},
}
for _, item := range queries {
if err := pool.QueryRow(ctx, item.query, item.args...).Scan(item.target); err != nil {
t.Fatalf("footprint query %q: %v", item.query, err)
}
}
if campaigns != 1 || commands != 1 || messages != 1 || events != 1 || balanceRows != 0 || txns != 0 {
t.Fatalf("footprint campaigns=%d commands=%d messages=%d events=%d balances=%d txns=%d", campaigns, commands, messages, events, balanceRows, txns)
}
ownerInfo, err := store.GetStarsGiveawayInfo(ctx, owner.ID, channelID, first.ChannelSend.Message.ID, 1_700_000_300)
if err != nil || ownerInfo.AdminDisallowedChatID != channelID || ownerInfo.Participating {
t.Fatalf("owner giveaway info=%+v err=%v", ownerInfo, err)
}
memberInfo, err := store.GetStarsGiveawayInfo(ctx, member.ID, channelID, first.ChannelSend.Message.ID, 1_700_000_300)
if err != nil || !memberInfo.Participating || memberInfo.StartDate != req.Date {
t.Fatalf("member giveaway info=%+v err=%v", memberInfo, err)
}
preparing, err := store.GetStarsGiveawayInfo(ctx, member.ID, channelID, first.ChannelSend.Message.ID, purpose.UntilDate)
if err != nil || !preparing.PreparingResults || preparing.Participating {
t.Fatalf("preparing giveaway info=%+v err=%v", preparing, err)
}
tampered := req
changed := *purpose
changed.Users, changed.PerUserStars = 1, 1000
tampered.Giveaway = &changed
if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered giveaway replay err=%v", err)
}
}

View file

@ -65,7 +65,7 @@ func TestStarsLedgerPostgres(t *testing.T) {
}
// 流水grant(+1000) / debit(-300) / credit(+50) 共 3 条,倒序最新在前。
page, err := st.ListTransactions(ctx, u.ID, "", 2)
page, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Limit: 2})
if err != nil {
t.Fatalf("list page1: %v", err)
}
@ -78,7 +78,7 @@ func TestStarsLedgerPostgres(t *testing.T) {
if page.Balance != 750 {
t.Fatalf("page balance = %d, want 750", page.Balance)
}
page2, err := st.ListTransactions(ctx, u.ID, page.NextOffset, 2)
page2, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Offset: page.NextOffset, Limit: 2})
if err != nil {
t.Fatalf("list page2: %v", err)
}
@ -88,4 +88,41 @@ func TestStarsLedgerPostgres(t *testing.T) {
if page2.Transactions[0].Reason != domain.StarsReasonGrant || page2.Transactions[0].Amount != 1000 {
t.Fatalf("page2[0] = %+v, want +1000 grant (oldest)", page2.Transactions[0])
}
incoming1, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page1: %v", err)
}
if len(incoming1.Transactions) != 1 || incoming1.Transactions[0].Amount != 50 || incoming1.NextOffset == "" {
t.Fatalf("incoming page1 = %+v next=%q, want +50 and next", incoming1.Transactions, incoming1.NextOffset)
}
incoming2, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Offset: incoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page2: %v", err)
}
if len(incoming2.Transactions) != 1 || incoming2.Transactions[0].Amount != 1000 || incoming2.NextOffset != "" {
t.Fatalf("incoming page2 = %+v next=%q, want +1000 terminal", incoming2.Transactions, incoming2.NextOffset)
}
outgoing, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing,
})
if err != nil || len(outgoing.Transactions) != 1 || outgoing.Transactions[0].Amount != -300 {
t.Fatalf("outgoing = %+v err=%v, want only -300", outgoing.Transactions, err)
}
ascending, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Limit: 10, Ascending: true})
if err != nil || len(ascending.Transactions) != 3 {
t.Fatalf("ascending = %+v err=%v", ascending.Transactions, err)
}
wantAscending := []int64{1000, -300, 50}
for i, amount := range wantAscending {
if ascending.Transactions[i].Amount != amount {
t.Fatalf("ascending[%d].amount = %d, want %d", i, ascending.Transactions[i].Amount, amount)
}
}
}

View file

@ -1126,7 +1126,7 @@ WHERE sv.owner_peer_type = $1
OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\'
OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\'
OR lower(u.username) LIKE $7 ESCAPE '\'
OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\'
OR lower(c.contact_phone) LIKE $7 ESCAPE '\'
)`, string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, req.JustContacts, querySet, queryLike).Scan(&count); err != nil {
return domain.StoryViewList{}, fmt.Errorf("count story views: %w", err)
}
@ -1160,7 +1160,7 @@ WHERE sv.owner_peer_type = $1
OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\'
OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\'
OR lower(u.username) LIKE $7 ESCAPE '\'
OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\'
OR lower(c.contact_phone) LIKE $7 ESCAPE '\'
)
AND (
NOT $9::boolean

View file

@ -115,13 +115,30 @@ func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.Use
row, err := s.q.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, false, nil
// The scalar users.username column only holds the editable slot, so a
// collectible username resolves through the registry instead. This is a
// fallback rather than the primary path: the fast lookup above stays
// untouched for every pre-existing username.
return s.byCollectibleUsername(ctx, strings.ToLower(username))
}
return domain.User{}, false, fmt.Errorf("get user by username: %w", err)
}
return userFromModel(row), true, nil
}
// byCollectibleUsername resolves an active collectible username to its holder.
// An inactive (client-hidden) name stays occupied but must not resolve.
func (s *UserStore) byCollectibleUsername(ctx context.Context, usernameLower string) (domain.User, bool, error) {
owner, found, err := getPeerUsernameOwner(ctx, s.db, usernameLower, false)
if err != nil {
return domain.User{}, false, fmt.Errorf("get user by collectible username: %w", err)
}
if !found || !owner.collectible || !owner.active || owner.peerType != peerUsernameTypeUser {
return domain.User{}, false, nil
}
return s.ByID(ctx, owner.peerID)
}
func (s *UserStore) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) {
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
if usernameLower == "" {
@ -228,7 +245,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s
}
return domain.User{}, fmt.Errorf("lock user for username update: %w", err)
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, username, usernameLower); err != nil {
return domain.User{}, err
}
row, err := qtx.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{
@ -299,7 +316,7 @@ func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, err
}
usernameLower := strings.ToLower(row.Username)
if usernameLower != "" {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil {
return domain.User{}, err
}
}
@ -360,7 +377,46 @@ func (s *UserStore) SetScamFake(ctx context.Context, userID int64, scam, fake bo
if scam && fake {
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
}
row, err := s.q.SetUserScamFake(ctx, sqlcgen.SetUserScamFakeParams{
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.User{}, fmt.Errorf("set user scam/fake: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.User{}, fmt.Errorf("begin set user scam/fake: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
qtx := s.q.WithTx(tx)
var currentScam, currentFake bool
if err := tx.QueryRow(ctx, `
SELECT scam, fake
FROM users
WHERE id = $1
FOR UPDATE`, userID).Scan(&currentScam, &currentFake); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, fmt.Errorf("lock user scam/fake: %w", err)
}
if currentScam == scam && currentFake == fake {
row, err := qtx.GetUserByID(ctx, userID)
if err != nil {
return domain.User{}, fmt.Errorf("reload unchanged user scam/fake: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.User{}, fmt.Errorf("commit unchanged user scam/fake: %w", err)
}
committed = true
return userFromModel(row), nil
}
row, err := qtx.SetUserScamFake(ctx, sqlcgen.SetUserScamFakeParams{
ID: userID,
Scam: scam,
Fake: fake,
@ -371,9 +427,71 @@ func (s *UserStore) SetScamFake(ctx context.Context, userID int64, scam, fake bo
}
return domain.User{}, fmt.Errorf("set user scam/fake: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.User{}, fmt.Errorf("commit user scam/fake: %w", err)
}
committed = true
return userFromModel(row), nil
}
const maxModerationFlagAudience = 4096
// ModerationFlagAudience returns the bounded set of accounts that can already
// observe the target through a direct contact or private dialog. It is used
// only for best-effort, non-PTS updateUser fanout after the authoritative flag
// mutation commits.
func (s *UserStore) ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error) {
if limit > maxModerationFlagAudience {
limit = maxModerationFlagAudience
}
return moderationFlagAudience(ctx, s.db, userID, limit)
}
func moderationFlagAudience(ctx context.Context, db sqlcgen.DBTX, userID int64, limit int) ([]int64, error) {
if userID <= 0 || limit <= 0 {
return nil, nil
}
rows, err := db.Query(ctx, `
SELECT picked.user_id
FROM (
SELECT candidates.user_id
FROM (
SELECT $1::bigint AS user_id, 0 AS priority, 2147483647::bigint AS activity
UNION ALL
SELECT contact_user_id, 1, 0 FROM contacts WHERE user_id = $1
UNION ALL
SELECT user_id, 1, 0 FROM contacts WHERE contact_user_id = $1
UNION ALL
SELECT peer_id, 2, top_message_date FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
UNION ALL
SELECT user_id, 2, top_message_date FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
) candidates
JOIN users u ON u.id = candidates.user_id AND u.deleted_at IS NULL
GROUP BY candidates.user_id
ORDER BY min(candidates.priority), max(candidates.activity) DESC, candidates.user_id
LIMIT $2
) picked
ORDER BY picked.user_id`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list moderation flag audience: %w", err)
}
defer rows.Close()
out := make([]int64, 0)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan moderation flag audience: %w", err)
}
if id != 0 {
out = append(out, id)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate moderation flag audience: %w", err)
}
return out, nil
}
// SweepExpiredPremium 清空到期会员行并返回清理后的用户。
func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) {
if limit <= 0 {

View file

@ -30,6 +30,9 @@ ON CONFLICT (user_id) DO NOTHING`, userID)
func reserveUserPts(ctx context.Context, db sqlcgen.DBTX, userID int64, count int) (int, error) {
count = normalizePtsCount(count)
if userID == 0 {
return 0, fmt.Errorf("user pts: missing user id")
}
// Single upsert instead of ensure-insert-then-update: this runs on every
// message send/update event, and the ensure step was a no-op round trip
// for every user past their first-ever pts allocation.
@ -38,7 +41,7 @@ func reserveUserPts(ctx context.Context, db sqlcgen.DBTX, userID int64, count in
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE
SET contiguous_pts = user_update_watermarks.contiguous_pts + $2,
SET contiguous_pts = user_update_watermarks.contiguous_pts + EXCLUDED.contiguous_pts,
updated_at = now()
RETURNING contiguous_pts`, userID, count).Scan(&pts); err != nil {
return 0, fmt.Errorf("reserve user pts: %w", err)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,967 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// verificationTestUser inserts a throwaway user row and registers the cleanup for
// everything the verification tables may hang off it. Events reference the
// application with ON DELETE RESTRICT, so the timeline has to go first.
func verificationTestUser(t *testing.T, pool *pgxpool.Pool) int64 {
t.Helper()
ctx := context.Background()
suffix := randomSuffix(t)
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name)
VALUES ($1, $2, 'verification test')
RETURNING id`, time.Now().UnixNano()&0x7fffffffffffffff, "9"+suffix).Scan(&id); err != nil {
t.Fatalf("insert verification test user: %v", err)
}
t.Cleanup(func() {
cleanupCtx := context.Background()
_, _ = pool.Exec(cleanupCtx, `
DELETE FROM verification_application_events
WHERE application_id IN (
SELECT id FROM verification_applications
WHERE applicant_user_id = $1 OR target_id = $1
)`, id)
_, _ = pool.Exec(cleanupCtx, `
DELETE FROM verification_notification_outbox
WHERE application_id IN (
SELECT id FROM verification_applications
WHERE applicant_user_id = $1 OR target_id = $1
)`, id)
_, _ = pool.Exec(cleanupCtx, `
DELETE FROM verification_applications
WHERE applicant_user_id = $1 OR target_id = $1`, id)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// verificationTestDraft is a payload that clears domain.ValidateForSubmission.
func verificationTestDraft() domain.VerificationDraftInput {
return domain.VerificationDraftInput{
Category: "media",
Description: strings.Repeat("independent newsroom covering the region ", 2),
OfficialWebsite: "https://example.com",
SocialLinks: []string{"https://t.me/example"},
PressLinks: []string{
"https://press.example.com/story",
"https://press.example.org/profile",
},
AdditionalNote: "filed through the bot dialog",
}
}
func verificationTestRequest(applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.SubmitVerificationApplicationRequest {
return domain.SubmitVerificationApplicationRequest{
ApplicantUserID: applicant,
TargetType: targetType,
TargetID: targetID,
TargetTitle: "Target " + username,
TargetUsername: username,
Draft: verificationTestDraft(),
CorrelationID: fmt.Sprintf("corr-%d", targetID),
}
}
// submittedVerificationApplication drives the applicant path up to the review
// queue, which is the state every reviewer test starts from.
func submittedVerificationApplication(t *testing.T, s *VerificationStore, applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.VerificationApplication {
t.Helper()
ctx := context.Background()
app, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(applicant, targetType, targetID, username))
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
app, err = s.SubmitVerificationApplication(ctx, app.ID, app.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
return app
}
func verificationUserVerified(t *testing.T, pool *pgxpool.Pool, userID int64) bool {
t.Helper()
var verified bool
if err := pool.QueryRow(context.Background(),
`SELECT verified FROM users WHERE id = $1`, userID).Scan(&verified); err != nil {
t.Fatalf("read verified flag: %v", err)
}
return verified
}
// verificationTxApply is the callback shape the app layer is expected to use: the
// peer flag is written through the transaction that is deciding the application,
// so the two writes commit or roll back together.
func verificationTxApply(_ *testing.T) func(context.Context, domain.VerificationApplication) error {
return func(ctx context.Context, app domain.VerificationApplication) error {
tx, ok := VerificationTxFromContext(ctx)
if !ok {
return fmt.Errorf("decision context carries no transaction")
}
if _, err := NewUserStore(tx).SetVerified(ctx, app.TargetID, true); err != nil {
return err
}
return nil
}
}
// TestVerificationDraftLifecyclePostgres covers the applicant path against the
// real schema: a draft is opened once and resumed on the next /start, the payload
// round-trips, and only a complete application reaches the queue.
func TestVerificationDraftLifecyclePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
req := verificationTestRequest(applicant, domain.VerificationTargetBot, target, "AlphaBot")
req.Draft = domain.VerificationDraftInput{Category: "media"}
app, created, err := s.CreateVerificationDraft(ctx, req)
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
if app.Status != domain.VerificationStatusDraft || app.Version != 1 {
t.Fatalf("draft state = %s v%d, want draft v1", app.Status, app.Version)
}
if !app.SubmittedAt.IsZero() || !app.ReviewedAt.IsZero() || app.ReviewerAdminID != "" {
t.Fatal("fresh draft carries review metadata")
}
resumed, created, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(applicant, domain.VerificationTargetBot, target, "AlphaBot"))
if err != nil || created {
t.Fatalf("resume draft: created=%v err=%v", created, err)
}
if resumed.ID != app.ID {
t.Fatalf("resumed draft = %d, want %d", resumed.ID, app.ID)
}
if _, err := s.SubmitVerificationApplication(ctx, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("submit incomplete draft err = %v, want ErrVerificationApplicationInvalid", err)
}
saved, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft())
if err != nil {
t.Fatalf("save draft: %v", err)
}
if saved.Version != app.Version+1 || len(saved.PressLinks) != 2 ||
saved.SocialLinks[0] != "https://t.me/example" {
t.Fatalf("saved draft = v%d social=%v press=%v", saved.Version, saved.SocialLinks, saved.PressLinks)
}
if !saved.UpdatedAt.Equal(saved.UpdatedAt.UTC()) || saved.UpdatedAt.Before(saved.CreatedAt) {
t.Fatalf("updated_at = %v, created_at = %v", saved.UpdatedAt, saved.CreatedAt)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft()); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("stale save err = %v, want ErrVerificationVersionConflict", err)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, saved.Version,
domain.VerificationDraftInput{OfficialWebsite: "http://127.0.0.1/x"}); !errors.Is(err, domain.ErrVerificationURLInvalid) {
t.Fatalf("private-host save err = %v, want ErrVerificationURLInvalid", err)
}
reread, err := s.VerificationApplication(ctx, app.ID)
if err != nil {
t.Fatalf("read: %v", err)
}
if reread.Description != saved.Description || reread.AdditionalNote != saved.AdditionalNote ||
reread.Category != "media" || reread.CorrelationID != fmt.Sprintf("corr-%d", target) {
t.Fatalf("payload did not round-trip: %+v", reread)
}
submitted, err := s.SubmitVerificationApplication(ctx, saved.ID, saved.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
if submitted.Status != domain.VerificationStatusSubmitted || submitted.SubmittedAt.IsZero() {
t.Fatalf("submitted = %s at %v", submitted.Status, submitted.SubmittedAt)
}
if !submitted.ReviewedAt.IsZero() || submitted.ReviewerAdminID != "" {
t.Fatal("submitted application carries a reviewer")
}
if _, err := s.VerificationDraftForApplicant(ctx, applicant); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("draft after submit err = %v, want ErrVerificationApplicationNotFound", err)
}
active, err := s.ActiveVerificationApplicationForTarget(ctx, domain.VerificationTargetBot, target)
if err != nil || active.ID != app.ID {
t.Fatalf("active for target = %d err=%v", active.ID, err)
}
}
// TestVerificationActiveTargetUniquenessPostgres is the partial unique index at
// work, including the cancelled-draft case the submitted_at CHECK constrains.
func TestVerificationActiveTargetUniquenessPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
first := verificationTestUser(t, pool)
second := verificationTestUser(t, pool)
third := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, first, domain.VerificationTargetChannel, target, "beta")
if _, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(second, domain.VerificationTargetChannel, target, "beta")); !errors.Is(err, domain.ErrVerificationApplicationExists) {
t.Fatalf("second application err = %v, want ErrVerificationApplicationExists", err)
}
// The same numeric id in the user namespace is a different target.
namespaced, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(second, domain.VerificationTargetBot, target, "betabot"))
if err != nil {
t.Fatalf("other namespace draft: %v", err)
}
// One draft per applicant: naming another target resumes the same
// conversation instead of tripping the applicant-draft unique index.
resumed, created, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(second, domain.VerificationTargetBot, third, "betabot2"))
if err != nil || created || resumed.ID != namespaced.ID {
t.Fatalf("cross-target draft = %d created=%v err=%v, want draft %d", resumed.ID, created, err, namespaced.ID)
}
cancelled, err := s.CancelVerificationApplication(ctx, app.ID, app.Version, "changed my mind")
if err != nil {
t.Fatalf("cancel: %v", err)
}
if cancelled.Status != domain.VerificationStatusCancelled || cancelled.SubmittedAt.IsZero() {
t.Fatalf("cancelled = %s at %v", cancelled.Status, cancelled.SubmittedAt)
}
if cancelled.DecisionReason != "" || cancelled.ReviewerAdminID != "" || !cancelled.ReviewedAt.IsZero() {
t.Fatalf("cancel wrote decision metadata: %+v", cancelled)
}
if _, err := s.CancelVerificationApplication(ctx, cancelled.ID, cancelled.Version, "again"); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("cancel of cancelled err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(third, domain.VerificationTargetChannel, target, "beta")); err != nil {
t.Fatalf("draft after cancellation: %v", err)
}
}
// TestVerificationCancelledDraftPostgres pins the one place the schema forces the
// store's hand: a draft that is withdrawn before submission still needs
// submitted_at, because "status <> 'draft'" requires it.
func TestVerificationCancelledDraftPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
draft, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(applicant, domain.VerificationTargetBot, target, "iota"))
if err != nil {
t.Fatalf("create draft: %v", err)
}
cancelled, err := s.CancelVerificationApplication(ctx, draft.ID, draft.Version, "never mind")
if err != nil {
t.Fatalf("cancel draft: %v", err)
}
if cancelled.SubmittedAt.IsZero() {
t.Fatal("cancelled draft has no submitted_at, which the CHECK forbids")
}
events, err := s.VerificationApplicationEvents(ctx, draft.ID, 10)
if err != nil || len(events) != 2 {
t.Fatalf("history = %d rows err=%v, want created + cancelled", len(events), err)
}
if events[0].Kind != domain.VerificationEventCancelled ||
events[0].FromStatus != domain.VerificationStatusDraft ||
events[0].ToStatus != domain.VerificationStatusCancelled ||
events[0].Reason != "never mind" {
t.Fatalf("cancelled event = %+v", events[0])
}
}
// TestVerificationApproveWritesPeerFlagPostgres is the core invariant: the peer
// flag and the decision share one transaction, so a failing callback leaves
// neither behind and a successful one cannot be observed without the other.
func TestVerificationApproveWritesPeerFlagPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "gamma")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if claimed.Status != domain.VerificationStatusInReview ||
claimed.ReviewerAdminID != "admin-a" || !claimed.ReviewedAt.IsZero() {
t.Fatalf("claimed = %+v", claimed)
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-b",
}); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("re-claim err = %v, want ErrVerificationStatusInvalid", err)
}
// The callback sets the flag through the decision transaction and then fails,
// so the rollback has to take the flag with it.
failing := errors.New("notification pipeline unavailable")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
}, true, func(ctx context.Context, decided domain.VerificationApplication) error {
if err := verificationTxApply(t)(ctx, decided); err != nil {
return err
}
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing approve err = %v, want %v", err, failing)
}
rolled, err := s.VerificationApplication(ctx, app.ID)
if err != nil {
t.Fatalf("read after failed approve: %v", err)
}
if rolled.Status != domain.VerificationStatusInReview || rolled.Version != claimed.Version {
t.Fatalf("failed approve left %s v%d, want in_review v%d", rolled.Status, rolled.Version, claimed.Version)
}
if verificationUserVerified(t, pool, target) {
t.Fatal("rolled-back approval left the target verified")
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(verificationRowsFor(pending, app.ID)) != 0 {
t.Fatalf("outbox after failed approve = %+v, want empty", pending)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 10)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventApproved {
t.Fatal("failed approve appended an approved event")
}
}
approved, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
InternalNote: "checked the press coverage", CorrelationID: "cmd-1",
}, true, verificationTxApply(t))
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.VerificationStatusApproved || approved.ReviewedAt.IsZero() ||
approved.ReviewerAdminID != "admin-a" || approved.Version != claimed.Version+1 ||
approved.InternalNote != "checked the press coverage" {
t.Fatalf("approved = %+v", approved)
}
if !verificationUserVerified(t, pool, target) {
t.Fatal("approved application whose target is not verified")
}
repeat, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-b",
}, true, func(context.Context, domain.VerificationApplication) error {
t.Error("idempotent approve invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.ReviewerAdminID != "admin-a" {
t.Fatalf("repeat approve mutated the record: v%d by %q", repeat.Version, repeat.ReviewerAdminID)
}
pending, err = s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending: %v", err)
}
mine := verificationRowsFor(pending, app.ID)
if len(mine) != 1 || mine[0].Kind != "approved" || mine[0].RecipientUserID != applicant {
t.Fatalf("outbox = %+v, want exactly one approved row for the applicant", mine)
}
if mine[0].Application.ID != app.ID || mine[0].Application.TargetUsername != "gamma" ||
mine[0].Application.Status != domain.VerificationStatusApproved {
t.Fatalf("outbox row carries no application context: %+v", mine[0].Application)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a", Reason: "changed our mind",
}, false, nil); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("reject after approve err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a",
}, true, nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
}
// TestVerificationRejectAndCooldownPostgres covers the rejection path and the
// cooldown lookup the re-application check is measured from.
func TestVerificationRejectAndCooldownPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
other := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetChannel, target, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, false, nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without reason err = %v, want ErrVerificationReasonRequired", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: " ", Reason: "not eligible",
}, false, nil); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("reject without reviewer err = %v, want ErrVerificationApplicationInvalid", err)
}
rejected, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
Reason: "press coverage is not independent", InternalNote: "second attempt this month",
}, false, nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.VerificationStatusRejected || rejected.DecisionReason == "" ||
rejected.ReviewedAt.IsZero() {
t.Fatalf("rejected = %+v", rejected)
}
if verificationUserVerified(t, pool, target) {
t.Fatal("rejection verified the target")
}
cooldown, err := s.LastVerificationRejection(ctx, applicant, domain.VerificationTargetChannel, target)
if err != nil || cooldown.ID != app.ID {
t.Fatalf("cooldown lookup = %d err=%v", cooldown.ID, err)
}
if _, err := s.LastVerificationRejection(ctx, other, domain.VerificationTargetChannel, target); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("cooldown for another applicant err = %v, want ErrVerificationApplicationNotFound", err)
}
second := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetChannel, target, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: second.ID, Version: second.Version, Reviewer: "admin-b", Reason: "still no",
}, false, nil); err != nil {
t.Fatalf("second reject: %v", err)
}
cooldown, err = s.LastVerificationRejection(ctx, applicant, domain.VerificationTargetChannel, target)
if err != nil || cooldown.ID != second.ID {
t.Fatalf("newest rejection = %d err=%v, want %d", cooldown.ID, err, second.ID)
}
history, err := s.VerificationApplicationsForApplicant(ctx, applicant, 10)
if err != nil || len(history) != 2 || history[0].ID != second.ID || history[1].ID != app.ID {
t.Fatalf("applicant history = %+v err=%v", history, err)
}
}
// TestVerificationConcurrentDecisionPostgres runs two reviewers at the same time
// on the same version. Exactly one decision, exactly one notification, and the
// loser is told it lost instead of silently overwriting the winner.
func TestVerificationConcurrentDecisionPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "epsilon")
var mu sync.Mutex
calls := 0
apply := func(ctx context.Context, decided domain.VerificationApplication) error {
mu.Lock()
calls++
mu.Unlock()
return verificationTxApply(t)(ctx, decided)
}
results := make([]error, 2)
var wg sync.WaitGroup
for i, reviewer := range []string{"admin-a", "admin-b"} {
wg.Add(1)
go func(i int, reviewer string) {
defer wg.Done()
_, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: reviewer,
}, true, apply)
results[i] = err
}(i, reviewer)
}
wg.Wait()
winners, conflicts := 0, 0
for _, err := range results {
switch {
case err == nil:
winners++
case errors.Is(err, domain.ErrVerificationVersionConflict):
conflicts++
default:
t.Fatalf("unexpected concurrent decision error: %v", err)
}
}
if winners != 1 || conflicts != 1 {
t.Fatalf("concurrent decision = %d winners, %d conflicts, want 1 and 1", winners, conflicts)
}
if calls != 1 {
t.Fatalf("applyVerified ran %d times, want exactly 1", calls)
}
final, err := s.VerificationApplication(ctx, app.ID)
if err != nil || final.Status != domain.VerificationStatusApproved || final.Version != app.Version+1 {
t.Fatalf("final application = %s v%d err=%v", final.Status, final.Version, err)
}
if !verificationUserVerified(t, pool, target) {
t.Fatal("approved application whose target is not verified")
}
pending, err := s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending: %v", err)
}
if got := verificationRowsFor(pending, app.ID); len(got) != 1 {
t.Fatalf("outbox = %+v, want exactly one notification", got)
}
var events int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM verification_application_events
WHERE application_id = $1 AND kind = 'approved'`, app.ID).Scan(&events); err != nil {
t.Fatalf("count approved events: %v", err)
}
if events != 1 {
t.Fatalf("approved history rows = %d, want 1", events)
}
}
// TestVerificationRevokePostgres takes the badge back: the flag is cleared in the
// same transaction, the application stays approved as history, and the revocation
// notifies exactly once.
func TestVerificationRevokePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
orphanTarget := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "zeta")
approved, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, verificationTxApply(t))
if err != nil {
t.Fatalf("approve: %v", err)
}
clear := func(ctx context.Context, peer domain.Peer) error {
tx, ok := VerificationTxFromContext(ctx)
if !ok {
return fmt.Errorf("revocation context carries no transaction")
}
if peer.Type != domain.PeerTypeUser {
return fmt.Errorf("unexpected peer type %q", peer.Type)
}
_, err := NewUserStore(tx).SetVerified(ctx, peer.ID, false)
return err
}
req := domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: target,
Reviewer: "admin-b", Reason: "impersonation report upheld", CorrelationID: "cmd-r",
}
if _, _, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: target, Reviewer: "admin-b",
}, clear); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("revoke without reason err = %v, want ErrVerificationReasonRequired", err)
}
failing := errors.New("peer store unavailable")
if _, _, err := s.RevokeVerification(ctx, req, func(ctx context.Context, peer domain.Peer) error {
if err := clear(ctx, peer); err != nil {
return err
}
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing revoke err = %v, want %v", err, failing)
}
if !verificationUserVerified(t, pool, target) {
t.Fatal("rolled-back revocation cleared the flag anyway")
}
revoked, changed, err := s.RevokeVerification(ctx, req, clear)
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if revoked.ID != approved.ID || revoked.Status != domain.VerificationStatusApproved {
t.Fatalf("revoked application = %d %s, want %d approved", revoked.ID, revoked.Status, approved.ID)
}
if verificationUserVerified(t, pool, target) {
t.Fatal("revocation left the target verified")
}
repeat, changed, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
t.Error("idempotent revoke invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat revoke: changed=%v err=%v", changed, err)
}
if repeat.ID != approved.ID {
t.Fatalf("repeat revoke returned %d, want %d", repeat.ID, approved.ID)
}
var revokedEvents, revokedOutbox int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM verification_application_events
WHERE application_id = $1 AND kind = 'revoked'`, app.ID).Scan(&revokedEvents); err != nil {
t.Fatalf("count revoked events: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM verification_notification_outbox
WHERE application_id = $1 AND kind = 'revoked'`, app.ID).Scan(&revokedOutbox); err != nil {
t.Fatalf("count revoked outbox rows: %v", err)
}
if revokedEvents != 1 || revokedOutbox != 1 {
t.Fatalf("revocation recorded %d events and %d outbox rows, want 1 and 1", revokedEvents, revokedOutbox)
}
// A flag with no application behind it is still cleared: leaving it standing
// is worse than a missing audit row.
if _, err := NewUserStore(pool).SetVerified(ctx, orphanTarget, true); err != nil {
t.Fatalf("seed orphan flag: %v", err)
}
orphan, changed, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: orphanTarget,
Reviewer: "admin-b", Reason: "manual flag from an older deployment",
}, clear)
if err != nil || !changed || orphan.ID != 0 {
t.Fatalf("orphan revoke: app=%d changed=%v err=%v", orphan.ID, changed, err)
}
if verificationUserVerified(t, pool, orphanTarget) {
t.Fatal("orphan revocation left the flag standing")
}
}
// TestVerificationHistoryAndOutboxPostgres pins the append-only timeline and
// walks one notification from pending to delivered.
func TestVerificationHistoryAndOutboxPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "eta")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a", CorrelationID: "cmd-9",
}, true, verificationTxApply(t)); err != nil {
t.Fatalf("approve: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending: %v", err)
}
mine := verificationRowsFor(pending, app.ID)
if len(mine) != 1 || mine[0].Attempts != 0 {
t.Fatalf("pending = %+v, want one fresh row", mine)
}
id := mine[0].ID
if err := s.MarkVerificationNotificationFailed(ctx, id, "bot blocked by user"); err != nil {
t.Fatalf("fail: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending after failure: %v", err)
}
mine = verificationRowsFor(pending, app.ID)
if len(mine) != 1 || mine[0].Attempts != 1 {
t.Fatalf("after failure = %+v, want still pending with one attempt", mine)
}
var lastError string
if err := pool.QueryRow(ctx, `
SELECT last_error FROM verification_notification_outbox WHERE id = $1`, id).Scan(&lastError); err != nil {
t.Fatalf("read last_error: %v", err)
}
if lastError != "bot blocked by user" {
t.Fatalf("last_error = %q", lastError)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("deliver: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending after delivery: %v", err)
}
if got := verificationRowsFor(pending, app.ID); len(got) != 0 {
t.Fatalf("delivered row is still pending: %+v", got)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("repeat deliver: %v", err)
}
if err := s.MarkVerificationNotificationFailed(ctx, id, "late error"); err != nil {
t.Fatalf("fail after delivery: %v", err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, 0); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("deliver id=0 err = %v, want ErrVerificationApplicationInvalid", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
wantKinds := []domain.VerificationApplicationEventKind{
domain.VerificationEventNotified,
domain.VerificationEventApproved,
domain.VerificationEventClaimed,
domain.VerificationEventSubmitted,
domain.VerificationEventCreated,
}
if len(events) != len(wantKinds) {
t.Fatalf("history = %d rows, want %d: %+v", len(events), len(wantKinds), events)
}
for i, kind := range wantKinds {
if events[i].Kind != kind {
t.Fatalf("history[%d] = %s, want %s", i, events[i].Kind, kind)
}
if i > 0 && events[i].ID >= events[i-1].ID {
t.Fatalf("history is not newest-first at %d", i)
}
}
if events[1].FromStatus != domain.VerificationStatusInReview ||
events[1].ToStatus != domain.VerificationStatusApproved ||
events[1].Actor != "admin-a" || events[1].CorrelationID != "cmd-9" {
t.Fatalf("approved event = %+v", events[1])
}
if events[0].Reason != "approved" {
t.Fatalf("notified event = %+v, want the approved notification", events[0])
}
// The timeline is append-only: nothing in the store rewrites a row, and the
// delivered notification did not touch the earlier ones.
var mutated int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM verification_application_events
WHERE application_id = $1 AND created_at < (
SELECT created_at FROM verification_application_events
WHERE application_id = $1 ORDER BY id LIMIT 1
)`, app.ID).Scan(&mutated); err != nil {
t.Fatalf("check event ordering: %v", err)
}
if mutated != 0 {
t.Fatalf("%d history rows predate the first one", mutated)
}
}
// TestVerificationQueueQueriesPostgres covers the review-queue projection against
// the real indexes: status and target filters, reviewer scoping, the search shapes
// and keyset paging. Every assertion is scoped by the date lower bound so rows
// from other runs cannot leak into it.
func TestVerificationQueueQueriesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
suffix := randomSuffix(t)
start := time.Now().UTC().Truncate(time.Microsecond)
before, err := s.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts before: %v", err)
}
firstApplicant := verificationTestUser(t, pool)
secondApplicant := verificationTestUser(t, pool)
thirdApplicant := verificationTestUser(t, pool)
firstTarget := verificationTestUser(t, pool)
secondTarget := verificationTestUser(t, pool)
thirdTarget := verificationTestUser(t, pool)
reviewer := "admin-" + suffix
first := submittedVerificationApplication(t, s, firstApplicant, domain.VerificationTargetBot, firstTarget, "alpha"+suffix)
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: first.ID, Version: first.Version, Reviewer: reviewer,
}); err != nil {
t.Fatalf("claim: %v", err)
}
second := submittedVerificationApplication(t, s, secondApplicant, domain.VerificationTargetChannel, secondTarget, "Beta"+suffix)
third, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(thirdApplicant, domain.VerificationTargetSupergroup, thirdTarget, "gamma"+suffix))
if err != nil {
t.Fatalf("third draft: %v", err)
}
ids := func(apps []domain.VerificationApplication) []int64 {
out := make([]int64, 0, len(apps))
for _, app := range apps {
out = append(out, app.ID)
}
return out
}
equal := func(got []int64, want ...int64) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
list := func(filter domain.VerificationApplicationFilter) []int64 {
t.Helper()
if filter.CreatedAt.IsZero() {
filter.CreatedAt = start
}
got, err := s.ListVerificationApplications(ctx, filter)
if err != nil {
t.Fatalf("list: %v", err)
}
return ids(got)
}
if got := list(domain.VerificationApplicationFilter{}); !equal(got, third.ID, second.ID, first.ID) {
t.Fatalf("queue order = %v, want newest first", got)
}
if got := list(domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{domain.VerificationStatusSubmitted, domain.VerificationStatusInReview},
}); !equal(got, second.ID, first.ID) {
t.Fatalf("status filter = %v", got)
}
if got := list(domain.VerificationApplicationFilter{
TargetType: domain.VerificationTargetChannel,
}); !equal(got, second.ID) {
t.Fatalf("target type filter = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Reviewer: reviewer}); !equal(got, first.ID) {
t.Fatalf("reviewer filter = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Reviewer: "admin-nobody"}); len(got) != 0 {
t.Fatalf("unknown reviewer = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: fmt.Sprint(first.ID)}); !equal(got, first.ID) {
t.Fatalf("application id search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: fmt.Sprint(secondTarget)}); !equal(got, second.ID) {
t.Fatalf("peer id search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: "@beta" + suffix}); !equal(got, second.ID) {
t.Fatalf("username search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: "ALPHA" + suffix}); !equal(got, first.ID) {
t.Fatalf("case-insensitive username search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: "nobody" + suffix}); len(got) != 0 {
t.Fatalf("miss search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{CreatedAt: second.CreatedAt}); !equal(got, third.ID, second.ID) {
t.Fatalf("since filter = %v", got)
}
// BeforeID without a cursor timestamp still bounds the page.
if got := list(domain.VerificationApplicationFilter{BeforeID: second.ID}); !equal(got, first.ID) {
t.Fatalf("id-only cursor = %v", got)
}
page, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: start, Limit: 2,
})
if err != nil || !equal(ids(page), third.ID, second.ID) {
t.Fatalf("first page = %v err=%v", ids(page), err)
}
last := page[len(page)-1]
next, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: start, Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || !equal(ids(next), first.ID) {
t.Fatalf("second page = %v err=%v", ids(next), err)
}
last = next[len(next)-1]
tail, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: start, Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || len(tail) != 0 {
t.Fatalf("third page = %v err=%v, want empty", ids(tail), err)
}
after, err := s.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts after: %v", err)
}
for status, want := range map[domain.VerificationStatus]int64{
domain.VerificationStatusDraft: 1,
domain.VerificationStatusSubmitted: 1,
domain.VerificationStatusInReview: 1,
} {
if got := after[status] - before[status]; got != want {
t.Fatalf("count delta for %s = %d, want %d", status, got, want)
}
}
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{"bogus"},
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("bogus status filter err = %v, want ErrVerificationApplicationInvalid", err)
}
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: "bogus",
}); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("bogus target filter err = %v, want ErrVerificationTargetInvalid", err)
}
}
// TestVerificationMissingApplicationPostgres pins the not-found surface every
// mutation shares.
func TestVerificationMissingApplicationPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
var missing int64
if err := pool.QueryRow(ctx,
`SELECT COALESCE(max(id), 0) + 1000 FROM verification_applications`).Scan(&missing); err != nil {
t.Fatalf("pick missing id: %v", err)
}
if _, err := s.VerificationApplication(ctx, missing); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("read err = %v", err)
}
if _, err := s.SubmitVerificationApplication(ctx, missing, 1); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("submit err = %v", err)
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: missing, Version: 1, Reviewer: "admin-a",
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("claim err = %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: missing, Version: 1, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return nil
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("decide err = %v", err)
}
if _, err := s.VerificationApplicationEvents(ctx, missing, 10); err != nil {
t.Fatalf("events of a missing application: %v", err)
}
}
// verificationRowsFor narrows the shared outbox to one application, so a test
// never depends on what other rows the test database happens to hold.
func verificationRowsFor(rows []store.VerificationNotification, applicationID int64) []store.VerificationNotification {
out := make([]store.VerificationNotification, 0, len(rows))
for _, row := range rows {
if row.ApplicationID == applicationID {
out = append(out, row)
}
}
return out
}