Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
557
internal/store/postgres/account_rating.go
Normal file
557
internal/store/postgres/account_rating.go
Normal 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
|
||||
}
|
||||
462
internal/store/postgres/account_rating_integration_test.go
Normal file
462
internal/store/postgres/account_rating_integration_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
152
internal/store/postgres/auth_delivery_report.go
Normal file
152
internal/store/postgres/auth_delivery_report.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1416
internal/store/postgres/bot_verification.go
Normal file
1416
internal/store/postgres/bot_verification.go
Normal file
File diff suppressed because it is too large
Load diff
1068
internal/store/postgres/bot_verification_integration_test.go
Normal file
1068
internal/store/postgres/bot_verification_integration_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{} {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(¤tScam, ¤tFake); 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
149
internal/store/postgres/client_telemetry.go
Normal file
149
internal/store/postgres/client_telemetry.go
Normal 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
|
||||
}
|
||||
750
internal/store/postgres/collectible_username.go
Normal file
750
internal/store/postgres/collectible_username.go
Normal 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
|
||||
}
|
||||
773
internal/store/postgres/collectible_username_integration_test.go
Normal file
773
internal/store/postgres/collectible_username_integration_test.go
Normal 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(®istry); 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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,使两个反向 delete(X 删与 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_clear(preserveEmptyDialogs)是请求者的本端语义"清空但保留我这侧
|
||||
// 空会话"。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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
248
internal/store/postgres/message_no_forwards.go
Normal file
248
internal/store/postgres/message_no_forwards.go
Normal 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
|
||||
}
|
||||
229
internal/store/postgres/message_no_forwards_integration_test.go
Normal file
229
internal/store/postgres/message_no_forwards_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
308
internal/store/postgres/message_saved_reactions.go
Normal file
308
internal/store/postgres/message_saved_reactions.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
441
internal/store/postgres/moderation.go
Normal file
441
internal/store/postgres/moderation.go
Normal 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
|
||||
}
|
||||
1310
internal/store/postgres/moderation_cases.go
Normal file
1310
internal/store/postgres/moderation_cases.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
471
internal/store/postgres/moderation_integration_test.go
Normal file
471
internal/store/postgres/moderation_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
353
internal/store/postgres/moderation_registries.go
Normal file
353
internal/store/postgres/moderation_registries.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
103
internal/store/postgres/privacy_integration_test.go
Normal file
103
internal/store/postgres/privacy_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 的云草稿,避免对端重建会话后旧草稿复活。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 '\'
|
||||
|
|
|
|||
|
|
@ -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 重建(失效被未失效的源自我抵消)。
|
||||
// 不参与重连 flush:Redis 是跨实例共享的,整库清空是错的,漏掉的通知靠其自身 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -68,8 +68,6 @@ type AccountPassword struct {
|
|||
SrpBSecret []byte
|
||||
SrpB []byte
|
||||
RecoveryEmail string
|
||||
RecoveryCode string
|
||||
RecoveryCodeExpiresAt pgtype.Timestamptz
|
||||
LoginEmail string
|
||||
PasswordChangedAt pgtype.Timestamptz
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
234
internal/store/postgres/star_gift_channel_notifications.go
Normal file
234
internal/store/postgres/star_gift_channel_notifications.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(®ularLogs); 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(¬ificationMessageID, ¬ificationDeliveredAt, ¬ificationAttempts); 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(¬ificationPts, ¬ificationMediaJSON); 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(¬ificationEvents); 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(¬ificationOutbox); 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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, `
|
||||
|
|
|
|||
628
internal/store/postgres/stars_gift_purchase.go
Normal file
628
internal/store/postgres/stars_gift_purchase.go
Normal 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, ¤cy, &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, ¤cy, &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)
|
||||
366
internal/store/postgres/stars_gift_purchase_integration_test.go
Normal file
366
internal/store/postgres/stars_gift_purchase_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(¤tScam, ¤tFake); 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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
1342
internal/store/postgres/verification.go
Normal file
1342
internal/store/postgres/verification.go
Normal file
File diff suppressed because it is too large
Load diff
967
internal/store/postgres/verification_integration_test.go
Normal file
967
internal/store/postgres/verification_integration_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue