Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
|
|
@ -168,7 +168,7 @@ func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) {
|
|||
|
||||
func (s *AdminStore) GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
FROM account_restrictions
|
||||
WHERE user_id = $1`, userID)
|
||||
r, err := scanAccountFreeze(row)
|
||||
|
|
@ -181,13 +181,80 @@ WHERE user_id = $1`, userID)
|
|||
return r, true, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) GetAccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
if s == nil || s.db == nil || len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
FROM account_restrictions
|
||||
WHERE user_id = ANY($1::bigint[]) AND frozen = true`, userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get account freezes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
freeze, err := scanAccountFreeze(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account freeze: %w", err)
|
||||
}
|
||||
out[freeze.UserID] = freeze
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account freezes: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return setAccountFreezeRow(ctx, s.db, freeze)
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("begin set account freeze: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
out, err := setAccountFreezeRow(ctx, tx, freeze)
|
||||
if err != nil {
|
||||
return domain.AccountFreeze{}, err
|
||||
}
|
||||
if err := enqueueAccountFreezeNotifications(ctx, tx, out); err != nil {
|
||||
return domain.AccountFreeze{}, err
|
||||
}
|
||||
// User visibility participates in the same cache/version invalidation spine
|
||||
// as profile and dialog changes. These functions emit cross-instance NOTIFY
|
||||
// events only after the surrounding transaction commits.
|
||||
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_contact_accounts_for_user($1)`, out.UserID); err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user contact projections: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_private_dialog_light_for_user($1)`, out.UserID); err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user dialog projections: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_read_model_version('user_visibility', 0, 'user', $1)`, out.UserID); err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user visibility: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("commit set account freeze: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func setAccountFreezeRow(ctx context.Context, db sqlcgen.DBTX, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
|
||||
var since, until any
|
||||
if freeze.Frozen {
|
||||
since = freeze.Since
|
||||
until = freeze.Until
|
||||
}
|
||||
row := s.db.QueryRow(ctx, `
|
||||
row := db.QueryRow(ctx, `
|
||||
INSERT INTO account_restrictions (
|
||||
user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
)
|
||||
|
|
@ -200,8 +267,9 @@ ON CONFLICT (user_id) DO UPDATE SET
|
|||
reason = EXCLUDED.reason,
|
||||
actor = EXCLUDED.actor,
|
||||
command_id = EXCLUDED.command_id,
|
||||
version = account_restrictions.version + 1,
|
||||
updated_at = now()
|
||||
RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
|
||||
RETURNING user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
|
||||
freeze.UserID, freeze.Frozen, since, until, freeze.AppealURL, freeze.Reason, freeze.Actor, freeze.CommandID,
|
||||
)
|
||||
out, err := scanAccountFreeze(row)
|
||||
|
|
@ -211,12 +279,16 @@ RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
|
||||
type accountFreezeScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanAccountFreeze(row accountFreezeScanner) (domain.AccountFreeze, error) {
|
||||
var r domain.AccountFreeze
|
||||
var since, until pgtype.Timestamptz
|
||||
var updated time.Time
|
||||
if err := row.Scan(
|
||||
&r.UserID, &r.Frozen, &since, &until, &r.AppealURL,
|
||||
&r.UserID, &r.Frozen, &r.Version, &since, &until, &r.AppealURL,
|
||||
&r.Reason, &r.Actor, &r.CommandID, &updated,
|
||||
); err != nil {
|
||||
return domain.AccountFreeze{}, err
|
||||
|
|
@ -230,3 +302,84 @@ func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
|
|||
r.UpdatedAt = updated
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func enqueueAccountFreezeNotifications(ctx context.Context, tx pgx.Tx, freeze domain.AccountFreeze) error {
|
||||
const maxAccountFreezeNotificationAudience = 4096
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO account_freeze_notifications (target_user_id, frozen_user_id, version, frozen)
|
||||
SELECT audience.user_id, $1, $2, $3
|
||||
FROM (
|
||||
SELECT user_id
|
||||
FROM (
|
||||
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
|
||||
FROM contacts WHERE user_id = $1
|
||||
UNION ALL
|
||||
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
|
||||
UNION ALL
|
||||
SELECT peer_id, 1, top_message_date
|
||||
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
|
||||
UNION ALL
|
||||
SELECT user_id, 1, top_message_date
|
||||
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
|
||||
) candidates
|
||||
GROUP BY user_id
|
||||
ORDER BY min(priority), max(activity) DESC, user_id
|
||||
LIMIT $4
|
||||
) audience
|
||||
JOIN users u ON u.id = audience.user_id
|
||||
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
|
||||
ON CONFLICT (target_user_id, frozen_user_id) DO UPDATE SET
|
||||
version = EXCLUDED.version,
|
||||
frozen = EXCLUDED.frozen,
|
||||
status = 'pending',
|
||||
attempts = 0,
|
||||
next_attempt_at = now(),
|
||||
lease_until = NULL,
|
||||
last_error = '',
|
||||
updated_at = now()`, freeze.UserID, freeze.Version, freeze.Frozen, maxAccountFreezeNotificationAudience)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue account freeze notifications: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) {
|
||||
if s == nil || s.db == nil || limit <= 0 || lease <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH claim AS (
|
||||
SELECT id FROM account_freeze_notifications
|
||||
WHERE (status = 'pending' AND next_attempt_at <= $1)
|
||||
OR (status = 'dispatching' AND lease_until <= $1)
|
||||
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
|
||||
)
|
||||
UPDATE account_freeze_notifications n
|
||||
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
|
||||
FROM claim WHERE n.id = claim.id
|
||||
RETURNING n.id, n.target_user_id, n.frozen_user_id, n.version, n.frozen, n.attempts`, now, limit, now.Add(lease))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim account freeze notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountFreezeNotification, 0)
|
||||
for rows.Next() {
|
||||
var n domain.AccountFreezeNotification
|
||||
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.FrozenUserID, &n.Version, &n.Frozen, &n.Attempts); err != nil {
|
||||
return nil, fmt.Errorf("scan account freeze notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AdminStore) CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE account_freeze_notifications
|
||||
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $3
|
||||
WHERE id = $1 AND version = $2`, id, version, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete account freeze notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,12 @@ func TestAccountFreezeMigrationAndStoreRoundTrip(t *testing.T) {
|
|||
const (
|
||||
frozenUserID = int64(1999999881)
|
||||
activeUserID = int64(1999999882)
|
||||
observerID = int64(1999999883)
|
||||
)
|
||||
for _, user := range []struct {
|
||||
id int64
|
||||
phone string
|
||||
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}} {
|
||||
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}, {observerID, "1999999883"}} {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, first_name)
|
||||
VALUES ($1, $1, $2, 'Freeze migration test')`, user.id, user.phone); err != nil {
|
||||
|
|
@ -60,10 +61,32 @@ VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, l
|
|||
t.Fatalf("GetAccountFreeze migrated = %+v found=%v err=%v", migrated, found, err)
|
||||
}
|
||||
if !migrated.Frozen || !migrated.Since.Equal(legacyUpdatedAt) ||
|
||||
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" {
|
||||
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" || migrated.Version != 1 {
|
||||
t.Fatalf("migrated freeze = %+v", migrated)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO contacts (user_id, contact_user_id, contact_first_name)
|
||||
VALUES ($1, $2, 'Visible frozen peer')`, observerID, activeUserID); err != nil {
|
||||
t.Fatalf("insert observer contact: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, top_message_date)
|
||||
VALUES ($1, 'user', $2, 1, 100)`, observerID, activeUserID); err != nil {
|
||||
t.Fatalf("insert observer dialog: %v", err)
|
||||
}
|
||||
var contactVersionBefore, dialogVersionBefore int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, observerID).Scan(&contactVersionBefore); err != nil {
|
||||
t.Fatalf("read initial contact projection version: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, observerID, activeUserID).Scan(&dialogVersionBefore); err != nil {
|
||||
t.Fatalf("read initial dialog projection version: %v", err)
|
||||
}
|
||||
|
||||
since := time.Date(2026, 7, 15, 2, 0, 0, 0, time.UTC)
|
||||
want := domain.AccountFreeze{
|
||||
UserID: activeUserID,
|
||||
|
|
@ -75,21 +98,80 @@ VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, l
|
|||
Actor: "ops",
|
||||
CommandID: "freeze-round-trip",
|
||||
}
|
||||
if _, err := store.SetAccountFreeze(ctx, want); err != nil {
|
||||
updated, err := store.SetAccountFreeze(ctx, want)
|
||||
if err != nil {
|
||||
t.Fatalf("SetAccountFreeze active: %v", err)
|
||||
}
|
||||
if updated.Version != 1 {
|
||||
t.Fatalf("first freeze version = %d, want 1", updated.Version)
|
||||
}
|
||||
got, found, err := store.GetAccountFreeze(ctx, activeUserID)
|
||||
if err != nil || !found || !got.Frozen || !got.Since.Equal(want.Since) ||
|
||||
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL {
|
||||
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL || got.Version != 1 {
|
||||
t.Fatalf("active round trip = %+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
if _, err := store.SetAccountFreeze(ctx, domain.AccountFreeze{
|
||||
var contactVersionAfter, dialogVersionAfter, visibilityVersion int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, observerID).Scan(&contactVersionAfter); err != nil {
|
||||
t.Fatalf("read frozen contact projection version: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, observerID, activeUserID).Scan(&dialogVersionAfter); err != nil {
|
||||
t.Fatalf("read frozen dialog projection version: %v", err)
|
||||
}
|
||||
if contactVersionAfter <= contactVersionBefore || dialogVersionAfter <= dialogVersionBefore {
|
||||
t.Fatalf("projection versions contact %d->%d dialog %d->%d, want increments",
|
||||
contactVersionBefore, contactVersionAfter, dialogVersionBefore, dialogVersionAfter)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'user_visibility' AND owner_user_id = 0 AND peer_type = 'user' AND peer_id = $1`, activeUserID).Scan(&visibilityVersion); err != nil || visibilityVersion != 1 {
|
||||
t.Fatalf("user visibility version = %d err=%v, want 1", visibilityVersion, err)
|
||||
}
|
||||
|
||||
claimAt := time.Now().UTC().Add(time.Minute)
|
||||
claimed, err := store.ClaimAccountFreezeNotifications(ctx, claimAt, 10, time.Minute)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim frozen notification = %+v err=%v, want one", claimed, err)
|
||||
}
|
||||
oldNotification := claimed[0]
|
||||
if oldNotification.TargetUserID != observerID || oldNotification.FrozenUserID != activeUserID || !oldNotification.Frozen || oldNotification.Version != 1 {
|
||||
t.Fatalf("frozen notification = %+v", oldNotification)
|
||||
}
|
||||
|
||||
updated, err = store.SetAccountFreeze(ctx, domain.AccountFreeze{
|
||||
UserID: activeUserID, Reason: "appeal accepted", Actor: "ops", CommandID: "unfreeze-round-trip",
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetAccountFreeze inactive: %v", err)
|
||||
}
|
||||
if updated.Version != 2 {
|
||||
t.Fatalf("unfreeze version = %d, want 2", updated.Version)
|
||||
}
|
||||
// A worker that claimed v1 before the unfreeze cannot acknowledge the
|
||||
// coalesced v2 row and suppress its online refresh.
|
||||
if err := store.CompleteAccountFreezeNotification(ctx, oldNotification.ID, oldNotification.Version, claimAt); err != nil {
|
||||
t.Fatalf("complete stale notification: %v", err)
|
||||
}
|
||||
claimed, err = store.ClaimAccountFreezeNotifications(ctx, claimAt.Add(time.Minute), 10, time.Minute)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim unfreeze notification = %+v err=%v, want one", claimed, err)
|
||||
}
|
||||
newNotification := claimed[0]
|
||||
if newNotification.ID != oldNotification.ID || newNotification.Version != 2 || newNotification.Frozen {
|
||||
t.Fatalf("coalesced unfreeze notification = %+v, previous=%+v", newNotification, oldNotification)
|
||||
}
|
||||
if err := store.CompleteAccountFreezeNotification(ctx, newNotification.ID, newNotification.Version, claimAt.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("complete unfreeze notification: %v", err)
|
||||
}
|
||||
var notificationStatus string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM account_freeze_notifications WHERE id = $1`, newNotification.ID).Scan(¬ificationStatus); err != nil || notificationStatus != "delivered" {
|
||||
t.Fatalf("notification status = %q err=%v, want delivered", notificationStatus, err)
|
||||
}
|
||||
got, found, err = store.GetAccountFreeze(ctx, activeUserID)
|
||||
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" {
|
||||
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" || got.Version != 2 {
|
||||
t.Fatalf("inactive round trip = %+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -87,6 +88,91 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
|
|||
return userFromModel(row), profile, nil
|
||||
}
|
||||
|
||||
// DeleteBotAccount permanently removes a user-created bot in one transaction:
|
||||
// it revokes the bot's sessions, purges its private state, releases its
|
||||
// username, drops the bots row (which invalidates the token) and tombstones the
|
||||
// users row. System service bots and non-bot users are rejected. The reused
|
||||
// helpers are the same vetted primitives that back account deletion, so the
|
||||
// tombstone satisfies users_deletion_state_check. Returns the tombstoned user
|
||||
// for change notifications.
|
||||
func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error) {
|
||||
if botUserID == 0 || domain.IsSystemUserID(botUserID) {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if err := lockUsersForUpdate(ctx, tx, botUserID); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: lock: %w", err)
|
||||
}
|
||||
u, found, err := NewUserStore(tx).ByID(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found || !u.Bot || u.Deleted {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
// Only bots backed by a bots row (created via /newbot or the admin) are
|
||||
// deletable here; system service bots are already excluded above.
|
||||
var hasBotRow bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_user_id = $1)`, botUserID).Scan(&hasBotRow); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: probe bots row: %w", err)
|
||||
}
|
||||
if !hasBotRow {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := enqueueAccountDeletionNotifications(ctx, tx, botUserID); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if _, err := revokeByUserExceptTx(ctx, tx, botUserID, 0); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: revoke sessions: %w", err)
|
||||
}
|
||||
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
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.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM bots WHERE bot_user_id = $1`, botUserID); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: delete bots row: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET
|
||||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, last_seen_at = 0,
|
||||
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,
|
||||
deleted_at = $2, deletion_source = 'manual', deletion_reason = 'admin bot deletion',
|
||||
account_delete_at = NULL, updated_at = $2
|
||||
WHERE id = $1 AND deleted_at IS NULL`, botUserID, now); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: tombstone: %w", err)
|
||||
}
|
||||
u, found, err = NewUserStore(tx).ByID(ctx, botUserID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrUserNotFound
|
||||
}
|
||||
return domain.User{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: commit: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
|
||||
if botUserID == 0 {
|
||||
return domain.BotProfile{}, false, nil
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
|
|||
if bfProfile.TokenSecret != "" || len(bfProfile.Commands) == 0 {
|
||||
t.Fatalf("BotFather profile = %+v, want empty token with seeded commands", bfProfile)
|
||||
}
|
||||
hasDone := false
|
||||
for _, command := range bfProfile.Commands {
|
||||
if command.Command == "done" {
|
||||
hasDone = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDone {
|
||||
t.Fatalf("BotFather commands = %+v, want /done for persistent /setlogin sessions", bfProfile.Commands)
|
||||
}
|
||||
// 空 phone 查询不得命中任何行。
|
||||
if _, found, err := users.ByPhone(ctx, ""); err != nil || found {
|
||||
t.Fatalf("ByPhone('') found=%v err=%v, want not found", found, err)
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ func scanChannel(row rowScanner) (domain.Channel, error) {
|
|||
|
||||
func channelScanDest(ch *domain.Channel, rights, reactionPolicy *string, wallpaper **string) []any {
|
||||
return []any{
|
||||
&ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified,
|
||||
&ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified, &ch.Scam, &ch.Fake, &ch.Gigagroup,
|
||||
&ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.LinkedCommunityID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights,
|
||||
reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until,
|
||||
wallpaper, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID,
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ WHERE i.user_id = $1
|
|||
AND NOT i.deleted
|
||||
AND i.role IN ('creator','admin')
|
||||
AND pm.status = 'active'
|
||||
AND pm.role IN ('creator','admin')
|
||||
AND (pm.role = 'creator' OR (pm.role = 'admin' AND COALESCE((pm.admin_rights->>'ManageDirectMessages')::boolean, false)))
|
||||
ORDER BY COALESCE(d.pinned, false) DESC,
|
||||
COALESCE(d.pinned_order, 0) DESC,
|
||||
COALESCE(top_msg.message_date, d.top_message_date, c.date) DESC,
|
||||
|
|
|
|||
|
|
@ -310,7 +310,8 @@ WITH visible_channels AS (
|
|||
WHERE mono.monoforum AND NOT mono.deleted
|
||||
AND (EXISTS (
|
||||
SELECT 1 FROM channel_members admin
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active'
|
||||
AND (admin.role = 'creator' OR (admin.role = 'admin' AND COALESCE((admin.admin_rights->>'ManageDirectMessages')::boolean, false)))
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM channel_messages message
|
||||
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
|
||||
|
|
@ -355,7 +356,8 @@ WITH visible_channels AS (
|
|||
WHERE mono.monoforum AND NOT mono.deleted
|
||||
AND (EXISTS (
|
||||
SELECT 1 FROM channel_members admin
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active'
|
||||
AND (admin.role = 'creator' OR (admin.role = 'admin' AND COALESCE((admin.admin_rights->>'ManageDirectMessages')::boolean, false)))
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM channel_messages message
|
||||
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
|
||||
|
|
|
|||
|
|
@ -454,7 +454,7 @@ func (s *ChannelStore) monoforumAdminPreview(ctx context.Context, db sqlcgen.DBT
|
|||
}
|
||||
return domain.ChannelMember{}, domain.Channel{}, false, err
|
||||
}
|
||||
if !isChannelAdmin(parentMember) {
|
||||
if !parentMember.CanManageDirectMessages() {
|
||||
return domain.ChannelMember{}, domain.Channel{}, false, nil
|
||||
}
|
||||
return syntheticMonoforumAdminMember(mono, parentMember), parent, true, nil
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6
|
|||
base := "channel_id = $1 AND NOT deleted"
|
||||
extraChannels := []domain.Channel(nil)
|
||||
if channel.Monoforum {
|
||||
if isChannelAdmin(member) {
|
||||
if member.CanManageDirectMessages() {
|
||||
base += " AND saved_peer_id = 0"
|
||||
} else {
|
||||
baseArgs = append(baseArgs, viewerUserID)
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ FOR SHARE OF m, p`, channel.ID).Scan(
|
|||
if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) {
|
||||
return domain.SendChannelMessageResult{}, parentMemberErr
|
||||
}
|
||||
isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
|
||||
isAdmin := parentMemberErr == nil && parentMember.CanManageDirectMessages()
|
||||
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
|
|
@ -237,7 +237,12 @@ SELECT EXISTS (
|
|||
return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err)
|
||||
}
|
||||
recipients := []int64{req.SavedPeer.ID}
|
||||
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator', 'admin') ORDER BY user_id`, parent.ID)
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT user_id
|
||||
FROM channel_members
|
||||
WHERE channel_id = $1 AND status = 'active'
|
||||
AND (role = 'creator' OR (role = 'admin' AND COALESCE((admin_rights->>'ManageDirectMessages')::boolean, false)))
|
||||
ORDER BY user_id`, parent.ID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err)
|
||||
}
|
||||
|
|
@ -313,7 +318,7 @@ func (s *ChannelStore) ListMonoforumHistory(ctx context.Context, filter domain.M
|
|||
}
|
||||
|
||||
// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求调用者是 monoforum 成员),并返回调用者是否为
|
||||
// 其母广播频道的创建者/管理员。非 monoforum/不存在 → ErrChannelInvalid。
|
||||
// 其母广播频道 Direct Messages 管理者。非 monoforum/不存在 → ErrChannelInvalid。
|
||||
func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) {
|
||||
if viewerUserID == 0 || monoforumID == 0 {
|
||||
return domain.Channel{}, false, domain.ErrChannelInvalid
|
||||
|
|
@ -330,8 +335,7 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
|
|||
}
|
||||
isAdmin := false
|
||||
if _, member, memberErr := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); memberErr == nil {
|
||||
isAdmin = member.Status == domain.ChannelMemberActive &&
|
||||
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
|
||||
isAdmin = member.CanManageDirectMessages()
|
||||
} else if !errors.Is(memberErr, domain.ErrChannelPrivate) {
|
||||
return domain.Channel{}, false, memberErr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,6 +284,180 @@ func (s *ChannelStore) SetChannelVerified(ctx context.Context, channelID int64,
|
|||
return channel, nil
|
||||
}
|
||||
|
||||
// SetChannelScamFake 设置/取消频道的 scam 与 fake 标记。
|
||||
func (s *ChannelStore) SetChannelScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
channel, err := s.channelByID(ctx, s.db, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if channel.Scam == scam && channel.Fake == fake {
|
||||
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 {
|
||||
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
|
||||
}
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(channelID)
|
||||
}
|
||||
channel.Scam = scam
|
||||
channel.Fake = fake
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// SetChannelAdminSettings applies an admin-direct moderation-settings patch
|
||||
// (no membership/permission checks). nil fields are left unchanged.
|
||||
func (s *ChannelStore) SetChannelAdminSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if patch.Empty() {
|
||||
return s.channelByID(ctx, s.db, channelID)
|
||||
}
|
||||
sets := make([]string, 0, 7)
|
||||
args := []any{channelID}
|
||||
idx := 2
|
||||
add := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
}
|
||||
if patch.Gigagroup != nil {
|
||||
add("gigagroup", *patch.Gigagroup)
|
||||
}
|
||||
if patch.AntiSpam != nil {
|
||||
add("antispam", *patch.AntiSpam)
|
||||
}
|
||||
if patch.ParticipantsHidden != nil {
|
||||
add("participants_hidden", *patch.ParticipantsHidden)
|
||||
}
|
||||
if patch.NoForwards != nil {
|
||||
add("noforwards", *patch.NoForwards)
|
||||
}
|
||||
if patch.JoinToSend != nil {
|
||||
add("join_to_send", *patch.JoinToSend)
|
||||
}
|
||||
if patch.JoinRequest != nil {
|
||||
add("join_request", *patch.JoinRequest)
|
||||
}
|
||||
if patch.SlowmodeSeconds != nil {
|
||||
add("slowmode_seconds", *patch.SlowmodeSeconds)
|
||||
}
|
||||
query := "UPDATE channels SET " + strings.Join(sets, ", ") + ", updated_at = now() WHERE id = $1 AND NOT deleted"
|
||||
if _, err := s.db.Exec(ctx, query, args...); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("set channel admin settings: %w", err)
|
||||
}
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(channelID)
|
||||
}
|
||||
return s.channelByID(ctx, s.db, channelID)
|
||||
}
|
||||
|
||||
// SetChannelUsernameAdmin force-sets or clears (empty) a channel username with
|
||||
// no permission checks. Username uniqueness is still enforced by peer_usernames.
|
||||
func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.Channel{}, fmt.Errorf("set channel username: db does not support transactions")
|
||||
}
|
||||
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
|
||||
usernameLower := strings.ToLower(username)
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("begin set channel username: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
channel, err := s.channelByID(ctx, tx, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if strings.EqualFold(channel.Username, username) {
|
||||
return channel, nil
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, 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 {
|
||||
return domain.Channel{}, fmt.Errorf("set channel username: %w", err)
|
||||
}
|
||||
if err := markUserChannelMemberIndexPublicTx(ctx, tx, channelID, username != ""); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("commit set channel username: %w", err)
|
||||
}
|
||||
committed = true
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(channelID)
|
||||
}
|
||||
channel.Username = username
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// SetChannelColorAdmin force-sets a channel name/profile color (no permission checks).
|
||||
func (s *ChannelStore) SetChannelColorAdmin(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
channel, err := s.channelByID(ctx, s.db, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if forProfile {
|
||||
if _, err := s.db.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
|
||||
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("set channel profile color: %w", err)
|
||||
}
|
||||
channel.ProfileColor = color
|
||||
} else {
|
||||
if _, err := s.db.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
|
||||
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("set channel color: %w", err)
|
||||
}
|
||||
channel.Color = color
|
||||
}
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(channelID)
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// SetChannelEmojiStatusAdmin force-sets or clears a channel emoji status (no permission checks).
|
||||
func (s *ChannelStore) SetChannelEmojiStatusAdmin(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if status.DocumentID == 0 {
|
||||
status.Until = 0
|
||||
}
|
||||
channel, err := s.channelByID(ctx, s.db, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`,
|
||||
channelID, status.DocumentID, status.Until); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("set channel emoji status: %w", err)
|
||||
}
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(channelID)
|
||||
}
|
||||
channel.EmojiStatus = status
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
|
||||
_ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent.
|
||||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore
|
|||
return s
|
||||
}
|
||||
|
||||
const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified,
|
||||
const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified, c.scam, c.fake, c.gigagroup,
|
||||
c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam,
|
||||
EXISTS (SELECT 1 FROM channel_invites ci WHERE ci.channel_id = c.id AND NOT ci.revoked) AS has_link,
|
||||
c.linked_chat_id, c.linked_community_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text,
|
||||
|
|
|
|||
708
internal/store/postgres/channel_suggested_post.go
Normal file
708
internal/store/postgres/channel_suggested_post.go
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const suggestedPostSettlementAge = 24 * 60 * 60
|
||||
|
||||
type persistedSuggestedPostApproval struct {
|
||||
monoforumID, parentID, actorID, payerID int64
|
||||
messageID, scheduleDate, approvalServiceID, publishedMessageID, settlementDue, finalServiceID int
|
||||
state domain.SuggestedPostLifecycleState
|
||||
price *domain.SuggestedPostPrice
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if req.UserID == 0 || req.MonoforumID == 0 || req.MessageID <= 0 || (!req.Reject && strings.TrimSpace(req.RejectComment) != "") {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = nowUnix()
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("toggle suggested post: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("begin toggle suggested post: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
mono, err := getChannelByID(ctx, tx, req.MonoforumID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
parent, err := getChannelByID(ctx, tx, mono.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if parent.Deleted || !parent.Broadcast || parent.LinkedMonoforumID != mono.ID {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT 1 FROM channel_messages WHERE channel_id=$1 AND id=$2 FOR UPDATE`, mono.ID, req.MessageID); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
original, err := s.getChannelMessage(ctx, tx, mono.ID, req.MessageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if original.Deleted || original.SavedPeer.Type != domain.PeerTypeUser || original.SavedPeer.ID == 0 || original.SuggestedPost == nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
fromSubscriber := original.From.Type == domain.PeerTypeUser
|
||||
manager := domain.ChannelMember{}
|
||||
if fromSubscriber {
|
||||
manager, err = s.getChannelMember(ctx, tx, parent.ID, req.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelPrivate) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
|
||||
}
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if !manager.CanManageDirectMessages() || (!req.Reject && !manager.CanPostChannelMessages()) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
|
||||
}
|
||||
} else if req.UserID != original.SavedPeer.ID {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
|
||||
}
|
||||
|
||||
existing, found, err := loadSuggestedPostApprovalTx(ctx, tx, mono.ID, original.ID, true)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if found && existing.state != domain.SuggestedPostStateBalanceLow {
|
||||
result, err := s.loadSuggestedPostResultTx(ctx, tx, existing, true)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
committed = true
|
||||
return result, nil
|
||||
}
|
||||
if original.SuggestedPost.Accepted || original.SuggestedPost.Rejected {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostAlreadyHandled
|
||||
}
|
||||
price := cloneSuggestedPricePG(original.SuggestedPost.Price)
|
||||
if price != nil && price.Kind == domain.SuggestedPostPriceStars && price.Nanos != 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
scheduleDate := original.SuggestedPost.ScheduleDate
|
||||
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
|
||||
}
|
||||
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, original.SavedPeer.ID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: original.SavedPeer, Recipients: recipients}
|
||||
|
||||
if req.Reject {
|
||||
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected = false, true
|
||||
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.OriginalMessage = original
|
||||
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostRejected: true,
|
||||
SuggestedPostRejectComment: strings.TrimSpace(req.RejectComment), SuggestedPostPrice: price,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.State = domain.SuggestedPostStateRejected
|
||||
if err := upsertSuggestedPostApprovalTx(ctx, tx, persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: result.State, price: price, scheduleDate: scheduleDate, approvalServiceID: result.ServiceMessage.ID}, req.Date); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
} else {
|
||||
stars, ton, enough, err := reserveSuggestedPostPaymentTx(ctx, tx, original.SavedPeer.ID, parent.ID, price, req.Date)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
|
||||
if !enough {
|
||||
if found {
|
||||
result, err = s.loadSuggestedPostResultTx(ctx, tx, existing, true)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
|
||||
} else {
|
||||
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostBalanceTooLow: true,
|
||||
SuggestedPostScheduleDate: scheduleDate, SuggestedPostPrice: price,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.State = domain.SuggestedPostStateBalanceLow
|
||||
if err := upsertSuggestedPostApprovalTx(ctx, tx, persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: result.State, price: price, scheduleDate: scheduleDate, approvalServiceID: result.ServiceMessage.ID}, req.Date); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.OriginalMessage = original
|
||||
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostScheduleDate: effectivePublishDate, SuggestedPostPrice: price})
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
record := persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: domain.SuggestedPostStateScheduled, price: price, scheduleDate: effectivePublishDate, approvalServiceID: result.ServiceMessage.ID}
|
||||
if effectivePublishDate <= req.Date {
|
||||
published, publishErr := s.publishSuggestedPostTx(ctx, tx, parent, original, req.UserID, req.Date)
|
||||
if publishErr != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, publishErr
|
||||
}
|
||||
result.Published = &published
|
||||
record.publishedMessageID = published.Message.ID
|
||||
if price == nil {
|
||||
record.state = domain.SuggestedPostStateCompleted
|
||||
} else {
|
||||
record.state = domain.SuggestedPostStatePublished
|
||||
record.settlementDue = req.Date + suggestedPostSettlementAge
|
||||
}
|
||||
}
|
||||
result.State = record.state
|
||||
if err := upsertSuggestedPostApprovalTx(ctx, tx, record, req.Date); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("commit toggle suggested post: %w", err)
|
||||
}
|
||||
committed = true
|
||||
result.Monoforum, err = getChannelByID(ctx, s.db, mono.ID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("reload suggested post monoforum after commit: %w", err)
|
||||
}
|
||||
result.Parent, err = getChannelByID(ctx, s.db, parent.ID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("reload suggested post parent after commit: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) persistSuggestedPostEditTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage, actor int64, date int) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
|
||||
pts, err := s.reserveChannelPts(ctx, tx, msg.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
encoded, err := marshalJSON(msg.SuggestedPost, "{}")
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
msg.Pts = pts
|
||||
if _, err := tx.Exec(ctx, `UPDATE channel_messages SET suggested_post=$3, pts=$4, updated_at=now() WHERE channel_id=$1 AND id=$2 AND NOT deleted`, msg.ChannelID, msg.ID, encoded, pts); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("update suggested post message: %w", err)
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{ChannelID: msg.ChannelID, Type: domain.ChannelUpdateEditMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
|
||||
if err := insertChannelEventTx(ctx, tx, event); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
return msg, event, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) insertSuggestedPostServiceTx(ctx context.Context, tx pgx.Tx, mono, parent domain.Channel, actor int64, fromChannel bool, saved domain.Peer, replyID, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
|
||||
msgID, err := s.msgIDs.NextChannelMessageID(ctx, mono.ID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
pts, err := s.reserveChannelPts(ctx, tx, mono.ID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
from := domain.Peer{Type: domain.PeerTypeUser, ID: actor}
|
||||
if fromChannel {
|
||||
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
|
||||
}
|
||||
msg := domain.ChannelMessage{ChannelID: mono.ID, ID: msgID, SenderUserID: actor, From: from, SavedPeer: saved, Date: date, ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: mono.ID}, MessageID: replyID}, Action: &action, Pts: pts}
|
||||
event := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
|
||||
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
if err := insertChannelEventTx(ctx, tx, event); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id=$2, pts=$3, updated_at=now() WHERE id=$1`, mono.ID, msgID, pts); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
return msg, event, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) publishSuggestedPostTx(ctx context.Context, tx pgx.Tx, parent domain.Channel, original domain.ChannelMessage, actor int64, date int) (domain.SendChannelMessageResult, error) {
|
||||
msgID, err := s.msgIDs.NextChannelMessageID(ctx, parent.ID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
pts, err := s.reserveChannelPts(ctx, tx, parent.ID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
msg := original
|
||||
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID = parent.ID, msgID, 0, actor
|
||||
msg.From, msg.SendAs, msg.SavedPeer, msg.Date, msg.EditDate, msg.Post = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, nil, domain.Peer{}, date, 0, true
|
||||
msg.ReplyTo, msg.PaidMessageStars, msg.Pts, msg.Deleted = nil, 0, pts, false
|
||||
event := domain.ChannelUpdateEvent{ChannelID: parent.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
|
||||
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if err := insertChannelEventTx(ctx, tx, event); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id=$2, pts=$3, updated_at=now() WHERE id=$1`, parent.ID, msgID, pts); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
recipients, err := s.listActiveChannelMemberIDs(ctx, tx, parent.ID, 0)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
parent.TopMessageID, parent.Pts = msgID, pts
|
||||
return domain.SendChannelMessageResult{Channel: parent, Message: msg, Event: event, Recipients: recipients}, nil
|
||||
}
|
||||
|
||||
func reserveSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) (*domain.StarsBalance, *int64, bool, error) {
|
||||
if price == nil {
|
||||
return nil, nil, true, nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
if price.Nanos != 0 || price.Amount <= 0 {
|
||||
return nil, nil, false, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
balance := domain.StarsBalance{UserID: payerID}
|
||||
err := tx.QueryRow(ctx, `SELECT balance,granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, payerID).Scan(&balance.Balance, &balance.Granted)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &balance, nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if balance.Balance < price.Amount {
|
||||
return &balance, nil, false, nil
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2,updated_at=now() WHERE user_id=$1 RETURNING balance`, payerID, price.Amount).Scan(&balance.Balance); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if err := insertStarsTxn(ctx, tx, payerID, -price.Amount, domain.StarsReasonSuggestedPost, domain.Peer{Type: domain.PeerTypeChannel, ID: parentID}, date, "Suggested post escrow", ""); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
return &balance, nil, true, nil
|
||||
case domain.SuggestedPostPriceTON:
|
||||
var balance int64
|
||||
err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id=$1 FOR UPDATE`, payerID).Scan(&balance)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, &balance, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if balance < price.Amount {
|
||||
return nil, &balance, false, nil
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `UPDATE ton_balances SET balance_nanoton=balance_nanoton-$2,updated_at=now() WHERE user_id=$1 RETURNING balance_nanoton`, payerID, price.Amount).Scan(&balance); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,'channel',$4,$5)`, payerID, -price.Amount, string(domain.StarsReasonSuggestedPost), parentID, date); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
return nil, &balance, true, nil
|
||||
default:
|
||||
return nil, nil, false, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func monoforumManagerRecipientsTx(ctx context.Context, tx pgx.Tx, parentID, subscriberID int64) ([]int64, error) {
|
||||
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id=$1 AND status='active' AND (role='creator' OR (role='admin' AND COALESCE((admin_rights->>'ManageDirectMessages')::boolean,false))) ORDER BY user_id`, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []int64{subscriberID}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return uniqueChannelUserIDs(ids, 0), rows.Err()
|
||||
}
|
||||
|
||||
func upsertSuggestedPostApprovalTx(ctx context.Context, tx pgx.Tx, row persistedSuggestedPostApproval, date int) error {
|
||||
kind, amount, nanos := "", int64(0), 0
|
||||
if row.price != nil {
|
||||
kind, amount, nanos = string(row.price.Kind), row.price.Amount, row.price.Nanos
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO suggested_post_approvals(monoforum_id,suggestion_message_id,parent_channel_id,actor_user_id,payer_user_id,state,price_kind,price_amount,price_nanos,schedule_date,approval_service_message_id,published_message_id,settlement_due,final_service_message_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15)
|
||||
ON CONFLICT(monoforum_id,suggestion_message_id) DO UPDATE SET actor_user_id=EXCLUDED.actor_user_id,state=EXCLUDED.state,price_kind=EXCLUDED.price_kind,price_amount=EXCLUDED.price_amount,price_nanos=EXCLUDED.price_nanos,schedule_date=EXCLUDED.schedule_date,approval_service_message_id=EXCLUDED.approval_service_message_id,published_message_id=EXCLUDED.published_message_id,settlement_due=EXCLUDED.settlement_due,final_service_message_id=EXCLUDED.final_service_message_id,updated_at=EXCLUDED.updated_at`,
|
||||
row.monoforumID, row.messageID, row.parentID, row.actorID, row.payerID, string(row.state), kind, amount, nanos, row.scheduleDate, row.approvalServiceID, row.publishedMessageID, row.settlementDue, row.finalServiceID, date)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadSuggestedPostApprovalTx(ctx context.Context, tx pgx.Tx, monoID int64, messageID int, lock bool) (persistedSuggestedPostApproval, bool, error) {
|
||||
q := `SELECT parent_channel_id,actor_user_id,payer_user_id,state,price_kind,price_amount,price_nanos,schedule_date,approval_service_message_id,published_message_id,settlement_due,final_service_message_id FROM suggested_post_approvals WHERE monoforum_id=$1 AND suggestion_message_id=$2`
|
||||
if lock {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
var row persistedSuggestedPostApproval
|
||||
row.monoforumID, row.messageID = monoID, messageID
|
||||
var state, kind string
|
||||
var amount int64
|
||||
var nanos int
|
||||
err := tx.QueryRow(ctx, q, monoID, messageID).Scan(&row.parentID, &row.actorID, &row.payerID, &state, &kind, &amount, &nanos, &row.scheduleDate, &row.approvalServiceID, &row.publishedMessageID, &row.settlementDue, &row.finalServiceID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return row, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return row, false, err
|
||||
}
|
||||
row.state = domain.SuggestedPostLifecycleState(state)
|
||||
if kind != "" {
|
||||
row.price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceKind(kind), Amount: amount, Nanos: nanos}
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) loadSuggestedPostResultTx(ctx context.Context, tx pgx.Tx, row persistedSuggestedPostApproval, duplicate bool) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
mono, err := getChannelByID(ctx, tx, row.monoforumID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
parent, err := getChannelByID(ctx, tx, row.parentID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
original, err := s.getChannelMessage(ctx, tx, row.monoforumID, row.messageID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
recipients, err := monoforumManagerRecipientsTx(ctx, tx, row.parentID, row.payerID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: row.payerID}, State: row.state, OriginalMessage: original, Recipients: recipients, Duplicate: duplicate}
|
||||
if original.SuggestedPost != nil && (original.SuggestedPost.Accepted || original.SuggestedPost.Rejected) && original.Pts > 0 {
|
||||
eventDate, senderUserID := original.Date, row.actorID
|
||||
// The event row is the exact durable replay source. Retention may have
|
||||
// pruned an old event, in which case the message snapshot still provides
|
||||
// a safe replay with its original date and lifecycle actor.
|
||||
if err := tx.QueryRow(ctx, `SELECT date,sender_user_id FROM channel_update_events WHERE channel_id=$1 AND pts=$2 AND event_type='edit_channel_message'`, row.monoforumID, original.Pts).Scan(&eventDate, &senderUserID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("load suggested post edit event: %w", err)
|
||||
}
|
||||
result.OriginalEvent = domain.ChannelUpdateEvent{ChannelID: row.monoforumID, Type: domain.ChannelUpdateEditMessage, Pts: original.Pts, PtsCount: 1, Date: eventDate, Message: original, SenderUserID: senderUserID}
|
||||
}
|
||||
if row.approvalServiceID > 0 {
|
||||
result.ServiceMessage, err = s.getChannelMessage(ctx, tx, row.monoforumID, row.approvalServiceID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.ServiceEvent = domain.ChannelUpdateEvent{ChannelID: row.monoforumID, Type: domain.ChannelUpdateNewMessage, Pts: result.ServiceMessage.Pts, PtsCount: 1, Date: result.ServiceMessage.Date, Message: result.ServiceMessage, SenderUserID: row.actorID}
|
||||
}
|
||||
if row.publishedMessageID > 0 {
|
||||
msg, e := s.getChannelMessage(ctx, tx, row.parentID, row.publishedMessageID)
|
||||
if e != nil {
|
||||
return result, e
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{ChannelID: row.parentID, Type: domain.ChannelUpdateNewMessage, Pts: msg.Pts, PtsCount: 1, Date: msg.Date, Message: msg, SenderUserID: row.actorID}
|
||||
result.Published = &domain.SendChannelMessageResult{Channel: parent, Message: msg, Event: event}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func cloneSuggestedPricePG(in *domain.SuggestedPostPrice) *domain.SuggestedPostPrice {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
return &out
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ProcessSuggestedPostLifecycle(ctx context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if req.Now == 0 {
|
||||
req.Now = nowUnix()
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 100 {
|
||||
req.Limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT monoforum_id,suggestion_message_id
|
||||
FROM suggested_post_approvals a
|
||||
WHERE (a.state='scheduled' AND a.schedule_date <= $1)
|
||||
OR (a.state='scheduled' AND EXISTS (
|
||||
SELECT 1 FROM channel_messages sm
|
||||
WHERE sm.channel_id=a.monoforum_id AND sm.id=a.suggestion_message_id AND sm.deleted))
|
||||
OR (a.state='published' AND (a.settlement_due <= $1 OR EXISTS (
|
||||
SELECT 1 FROM channel_messages m
|
||||
WHERE m.channel_id=a.parent_channel_id AND m.id=a.published_message_id AND m.deleted)))
|
||||
ORDER BY CASE WHEN a.state='scheduled' THEN a.schedule_date ELSE a.settlement_due END,
|
||||
a.monoforum_id,a.suggestion_message_id
|
||||
LIMIT $2`, req.Now, req.Limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due suggested posts: %w", err)
|
||||
}
|
||||
type key struct {
|
||||
mono int64
|
||||
message int
|
||||
}
|
||||
keys := make([]key, 0, req.Limit)
|
||||
for rows.Next() {
|
||||
var k key
|
||||
if err := rows.Scan(&k.mono, &k.message); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
out := make([]domain.ToggleSuggestedPostApprovalResult, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
result, changed, err := s.processSuggestedPostLifecycleOne(ctx, k.mono, k.message, req.Now)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if changed {
|
||||
out = append(out, result)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, monoID int64, messageID, now int) (domain.ToggleSuggestedPostApprovalResult, bool, error) {
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, fmt.Errorf("suggested post lifecycle: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
row, found, err := loadSuggestedPostApprovalTx(ctx, tx, monoID, messageID, true)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, fmt.Errorf("suggested post lifecycle invariant: approval row disappeared for monoforum %d message %d", monoID, messageID)
|
||||
}
|
||||
if row.state != domain.SuggestedPostStateScheduled && row.state != domain.SuggestedPostStatePublished {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
committed = true
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, nil
|
||||
}
|
||||
mono, err := getChannelByID(ctx, tx, row.monoforumID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
parent, err := getChannelByID(ctx, tx, row.parentID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
original, err := s.getChannelMessage(ctx, tx, row.monoforumID, row.messageID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, row.payerID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: row.payerID}, State: row.state, Recipients: recipients}
|
||||
changed := false
|
||||
if row.state == domain.SuggestedPostStateScheduled && original.Deleted {
|
||||
if row.price != nil {
|
||||
stars, ton, err := refundSuggestedPostPaymentTx(ctx, tx, row.payerID, row.parentID, row.price, now)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
|
||||
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.ServiceMessage, result.ServiceEvent, row.finalServiceID = service, event, service.ID
|
||||
}
|
||||
row.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
|
||||
}
|
||||
if row.state == domain.SuggestedPostStateScheduled && row.scheduleDate <= now {
|
||||
published, err := s.publishSuggestedPostTx(ctx, tx, parent, original, row.actorID, now)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.Published = &published
|
||||
row.publishedMessageID = published.Message.ID
|
||||
if row.price == nil {
|
||||
row.state = domain.SuggestedPostStateCompleted
|
||||
} else {
|
||||
row.state = domain.SuggestedPostStatePublished
|
||||
row.settlementDue = now + suggestedPostSettlementAge
|
||||
}
|
||||
result.State = row.state
|
||||
changed = true
|
||||
}
|
||||
if row.state == domain.SuggestedPostStatePublished {
|
||||
var deleted bool
|
||||
var deleteDate int
|
||||
if err := tx.QueryRow(ctx, `SELECT deleted,delete_date FROM channel_messages WHERE channel_id=$1 AND id=$2 FOR SHARE`, row.parentID, row.publishedMessageID).Scan(&deleted, &deleteDate); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
if deleted && (deleteDate == 0 || deleteDate < row.settlementDue) {
|
||||
stars, ton, err := refundSuggestedPostPaymentTx(ctx, tx, row.payerID, row.parentID, row.price, now)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
|
||||
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
row.state, row.finalServiceID, result.State = domain.SuggestedPostStateRefunded, service.ID, domain.SuggestedPostStateRefunded
|
||||
changed = true
|
||||
} else if row.settlementDue <= now {
|
||||
if err := settleSuggestedPostPaymentTx(ctx, tx, row.actorID, row.payerID, row.parentID, row.price, now); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostSuccess, SuggestedPostPrice: cloneSuggestedPricePG(row.price)})
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
row.state, row.finalServiceID, result.State = domain.SuggestedPostStateCompleted, service.ID, domain.SuggestedPostStateCompleted
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
committed = true
|
||||
return result, false, nil
|
||||
}
|
||||
if err := upsertSuggestedPostApprovalTx(ctx, tx, row, now); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
committed = true
|
||||
result.Monoforum, err = getChannelByID(ctx, s.db, row.monoforumID)
|
||||
if err != nil {
|
||||
return result, true, fmt.Errorf("reload lifecycle monoforum after commit: %w", err)
|
||||
}
|
||||
result.Parent, err = getChannelByID(ctx, s.db, row.parentID)
|
||||
if err != nil {
|
||||
return result, true, fmt.Errorf("reload lifecycle parent after commit: %w", err)
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func (r persistedSuggestedPostApproval) savedPeer() domain.Peer {
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: r.payerID}
|
||||
}
|
||||
|
||||
func refundSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) (*domain.StarsBalance, *int64, error) {
|
||||
if price == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
balance := domain.StarsBalance{UserID: payerID, Granted: true}
|
||||
if err := tx.QueryRow(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,$2,true) ON CONFLICT(user_id) DO UPDATE SET balance=stars_balances.balance+EXCLUDED.balance,updated_at=now() RETURNING balance,granted`, payerID, price.Amount).Scan(&balance.Balance, &balance.Granted); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := insertStarsTxn(ctx, tx, payerID, price.Amount, domain.StarsReasonSuggestedPost, domain.Peer{Type: domain.PeerTypeChannel, ID: parentID}, date, "Suggested post refund", ""); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &balance, nil, nil
|
||||
case domain.SuggestedPostPriceTON:
|
||||
var balance int64
|
||||
if err := tx.QueryRow(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,$2,true) ON CONFLICT(user_id) DO UPDATE SET balance_nanoton=ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now() RETURNING balance_nanoton`, payerID, price.Amount).Scan(&balance); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,'channel',$4,$5)`, payerID, price.Amount, string(domain.StarsReasonSuggestedPost), parentID, date); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, &balance, nil
|
||||
default:
|
||||
return nil, nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func settleSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, actorID, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) error {
|
||||
if price == nil {
|
||||
return nil
|
||||
}
|
||||
credit := price.Amount * paidMessageChannelCommissionPermille / 1000
|
||||
if credit <= 0 {
|
||||
return nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,$2) ON CONFLICT(channel_id) DO UPDATE SET balance=channel_stars_balances.balance+EXCLUDED.balance,updated_at=now()`, parentID, credit); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO channel_stars_transactions(channel_id,actor_user_id,amount,reason,peer_type,peer_id,date) VALUES($1,$2,$3,$4,'user',$5,$6)`, parentID, actorID, credit, string(domain.StarsReasonSuggestedPost), payerID, date)
|
||||
return err
|
||||
case domain.SuggestedPostPriceTON:
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,$2) ON CONFLICT(channel_id) DO UPDATE SET balance_nanoton=channel_ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now()`, parentID, credit); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO channel_ton_transactions(channel_id,actor_user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,$4,'user',$5,$6)`, parentID, actorID, credit, string(domain.StarsReasonSuggestedPost), payerID, date)
|
||||
return err
|
||||
default:
|
||||
return domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestSuggestedPostLifecyclePostgres verifies that message state, channel pts,
|
||||
// escrow and refund are committed through the real PostgreSQL transaction.
|
||||
// It is gated by TELESRV_TEST_POSTGRES_DSN and testPool migrates through 0134.
|
||||
func TestSuggestedPostLifecyclePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 201, Phone: "+1888" + suffix + "01", FirstName: "SuggestOwner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscriber, err := users.Create(ctx, domain.User{AccessHash: 202, Phone: "+1888" + suffix + "02", FirstName: "SuggestSubscriber"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Suggested " + suffix, Broadcast: true, Date: 1_700_000_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 channel_stars_balances WHERE channel_id=$1`, created.Channel.ID)
|
||||
_, _ = 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})
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,100,true) ON CONFLICT(user_id) DO UPDATE SET balance=100,granted=true`, subscriber.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
|
||||
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 71, Message: "postgres suggestion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approved, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: suggestion.Message.ID, Date: 1_700_000_200})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if approved.State != domain.SuggestedPostStatePublished || approved.Published == nil || approved.PayerStarsBalance == nil || approved.PayerStarsBalance.Balance != 90 {
|
||||
t.Fatalf("approved=%+v", approved)
|
||||
}
|
||||
if approved.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || approved.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
|
||||
t.Fatalf("immediate approval dates original/action=%d/%d, want commit date", approved.OriginalMessage.SuggestedPost.ScheduleDate, approved.ServiceMessage.Action.SuggestedPostScheduleDate)
|
||||
}
|
||||
history, err := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: saved, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persistedApprovalDate int
|
||||
for _, message := range history.Messages {
|
||||
if message.ID == approved.ServiceMessage.ID && message.Action != nil {
|
||||
persistedApprovalDate = message.Action.SuggestedPostScheduleDate
|
||||
break
|
||||
}
|
||||
}
|
||||
if persistedApprovalDate != 1_700_000_200 {
|
||||
t.Fatalf("persisted approval history date=%d, want commit date", persistedApprovalDate)
|
||||
}
|
||||
replay, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: suggestion.Message.ID, Date: 1_700_000_201})
|
||||
if err != nil || !replay.Duplicate || replay.OriginalEvent.Type != domain.ChannelUpdateEditMessage || replay.ServiceEvent.Type != domain.ChannelUpdateNewMessage || replay.Published == nil {
|
||||
t.Fatalf("approval replay=%+v err=%v", replay, err)
|
||||
}
|
||||
var state string
|
||||
var scheduleDate int
|
||||
var debit, channelBalance int64
|
||||
if err := pool.QueryRow(ctx, `SELECT state,schedule_date FROM suggested_post_approvals WHERE monoforum_id=$1 AND suggestion_message_id=$2`, monoID, suggestion.Message.ID).Scan(&state, &scheduleDate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = pool.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, created.Channel.ID).Scan(&channelBalance)
|
||||
if state != string(domain.SuggestedPostStatePublished) || scheduleDate != 1_700_000_200 || debit != 90 || channelBalance != 0 {
|
||||
t.Fatalf("state/schedule/debit/channel=%s/%d/%d/%d", state, scheduleDate, debit, channelBalance)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE channel_messages SET deleted=true WHERE channel_id=$1 AND id=$2`, created.Channel.ID, approved.Published.Message.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := channels.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_300, Limit: 10})
|
||||
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateRefunded || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostRefund {
|
||||
t.Fatalf("refund=%+v err=%v", resolved, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var txnNet int64
|
||||
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount),0) FROM stars_transactions WHERE user_id=$1 AND reason=$2`, subscriber.ID, string(domain.StarsReasonSuggestedPost)).Scan(&txnNet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if debit != 100 || txnNet != 0 {
|
||||
t.Fatalf("refund balance/net=%d/%d, want 100/0", debit, txnNet)
|
||||
}
|
||||
|
||||
late, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 72, Message: "late deletion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_400})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approvedAt := 1_700_000_500
|
||||
lateApproved, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: late.Message.ID, Date: approvedAt})
|
||||
if err != nil || lateApproved.Published == nil {
|
||||
t.Fatalf("late approval=%+v err=%v", lateApproved, err)
|
||||
}
|
||||
due := approvedAt + suggestedPostSettlementAge
|
||||
if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{UserID: owner.ID, ChannelID: created.Channel.ID, IDs: []int{lateApproved.Published.Message.ID}, Date: due + 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err = channels.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: due + 2, Limit: 10})
|
||||
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateCompleted || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
|
||||
t.Fatalf("late settlement=%+v err=%v", resolved, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, created.Channel.ID).Scan(&channelBalance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if debit != 90 || channelBalance != 8 {
|
||||
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
|
||||
}
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
|
|||
args = append(args, member.AvailableMinID)
|
||||
where += fmt.Sprintf(" AND id > $%d", len(args))
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
args = append(args, req.UserID)
|
||||
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
|
||||
}
|
||||
|
|
@ -147,7 +147,7 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
}
|
||||
rows.Close()
|
||||
var visibleMonoforumMessageIDs map[int]struct{}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
messageIDs := make([]int, 0)
|
||||
for _, row := range eventRows {
|
||||
messageIDs = append(messageIDs, row.event.MessageIDs...)
|
||||
|
|
@ -172,7 +172,7 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
continue
|
||||
}
|
||||
event = visibleEvent
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
|
||||
if !ok {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -117,8 +117,24 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
|
|||
}
|
||||
|
||||
type privateSendTxHooks struct {
|
||||
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
|
||||
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
|
||||
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
|
||||
projectMedia func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) (privateSendMediaProjection, error)
|
||||
// afterAllocate runs after the immutable logical message and both box IDs
|
||||
// exist, but before either box, update event or replay snapshot is written.
|
||||
// It may finalize req.Media using those IDs; all of its writes remain in the
|
||||
// same private-send transaction.
|
||||
afterAllocate func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest, int, int) error
|
||||
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
|
||||
}
|
||||
|
||||
// privateSendMediaProjection separates the logical private-message payload
|
||||
// from the two account-local message-box projections. Most messages use the
|
||||
// same media for all three fields. Service actions that carry message ids must
|
||||
// project those ids per account because box ids are not shared by both users.
|
||||
type privateSendMediaProjection struct {
|
||||
Shared *domain.MessageMedia
|
||||
Sender *domain.MessageMedia
|
||||
Recipient *domain.MessageMedia
|
||||
}
|
||||
|
||||
func (s *MessageStore) sendPrivateTextWithHooks(ctx context.Context, req domain.SendPrivateTextRequest, hooks privateSendTxHooks) (res domain.SendPrivateTextResult, err error) {
|
||||
|
|
@ -228,7 +244,22 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
}
|
||||
mediaJSON, err := encodeMessageMedia(req.Media)
|
||||
media := privateSendMediaProjection{Shared: req.Media, Sender: req.Media, Recipient: req.Media}
|
||||
if hooks.projectMedia != nil {
|
||||
media, err = hooks.projectMedia(ctx, tx, &req)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
}
|
||||
sharedMediaJSON, err := encodeMessageMedia(media.Shared)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
senderMediaJSON, err := encodeMessageMedia(media.Sender)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
recipientMediaJSON, err := encodeMessageMedia(media.Recipient)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
|
|
@ -255,7 +286,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
TtlPeriod: int32(ttlPeriod),
|
||||
ExpiresAt: int32(expiresAt),
|
||||
EntitiesJson: entities,
|
||||
MediaJson: mediaJSON,
|
||||
MediaJson: sharedMediaJSON,
|
||||
ReplyMarkupJson: replyMarkupJSON,
|
||||
RichMessageJson: richMessageJSON,
|
||||
ViaBotID: req.ViaBotID,
|
||||
|
|
@ -299,6 +330,44 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient pts: %w", err)
|
||||
}
|
||||
}
|
||||
if hooks.afterAllocate != nil {
|
||||
// A callback may replace the media after the ordinary request
|
||||
// fingerprint was computed. Requiring a complete caller-owned
|
||||
// fingerprint keeps random_id replay bound to the final aggregate intent.
|
||||
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "after-allocate private send"); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
if err := hooks.afterAllocate(ctx, tx, &req, senderBoxID, recipientBoxID); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
media = privateSendMediaProjection{Shared: req.Media, Sender: req.Media, Recipient: req.Media}
|
||||
if hooks.projectMedia != nil {
|
||||
media, err = hooks.projectMedia(ctx, tx, &req)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
}
|
||||
sharedMediaJSON, err = encodeMessageMedia(media.Shared)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
senderMediaJSON, err = encodeMessageMedia(media.Sender)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
recipientMediaJSON, err = encodeMessageMedia(media.Recipient)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE private_messages SET media=$3
|
||||
WHERE sender_user_id=$1 AND id=$2`, req.SenderUserID, pm.ID, sharedMediaJSON)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("finalize private message media: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("finalize private message media: logical message disappeared")
|
||||
}
|
||||
}
|
||||
|
||||
senderArg := sqlcgen.CreateMessageBoxParams{
|
||||
OwnerUserID: req.SenderUserID,
|
||||
|
|
@ -315,7 +384,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
ExpiresAt: int32(expiresAt),
|
||||
EntitiesJson: entities,
|
||||
Pts: int32(senderPts),
|
||||
MediaJson: mediaJSON,
|
||||
MediaJson: senderMediaJSON,
|
||||
ReplyMarkupJson: replyMarkupJSON,
|
||||
RichMessageJson: richMessageJSON,
|
||||
ViaBotID: req.ViaBotID,
|
||||
|
|
@ -323,7 +392,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
Effect: req.Effect,
|
||||
// voice/round 在发送者自己的副本上也保持"未听",直到对端
|
||||
// readMessageContents 触发 sender 侧清除;发给自己无人可听,恒已读。
|
||||
MediaUnread: req.Media.HasUnreadPayload() && !selfMessage,
|
||||
MediaUnread: media.Sender.HasUnreadPayload() && !selfMessage,
|
||||
ReactionUnread: false,
|
||||
}
|
||||
applyCreateMessageBoxMetadata(&senderArg, senderMeta)
|
||||
|
|
@ -334,7 +403,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
sender := messageFromBoxRow(senderRow)
|
||||
sender.RandomID = req.RandomID
|
||||
// 共享媒体索引(0118):发送者侧 box 按媒体类别建索引(peer=收件人)。
|
||||
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, req.Media, req.Entities); err != nil {
|
||||
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, media.Sender, req.Entities); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
if err := qtx.UpsertOutboxDialog(ctx, sqlcgen.UpsertOutboxDialogParams{
|
||||
|
|
@ -388,13 +457,13 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
ExpiresAt: int32(expiresAt),
|
||||
EntitiesJson: entities,
|
||||
Pts: int32(recipientPts),
|
||||
MediaJson: mediaJSON,
|
||||
MediaJson: recipientMediaJSON,
|
||||
ReplyMarkupJson: replyMarkupJSON,
|
||||
RichMessageJson: richMessageJSON,
|
||||
ViaBotID: req.ViaBotID,
|
||||
GroupedID: req.GroupedID,
|
||||
Effect: req.Effect,
|
||||
MediaUnread: req.Media.HasUnreadPayload(),
|
||||
MediaUnread: media.Recipient.HasUnreadPayload(),
|
||||
ReactionUnread: false,
|
||||
}
|
||||
applyCreateMessageBoxMetadata(&recipientArg, recipientMeta)
|
||||
|
|
@ -405,7 +474,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
recipient = messageFromBoxRow(recipientRow)
|
||||
recipient.RandomID = req.RandomID
|
||||
// 共享媒体索引(0118):收件人侧 box 按媒体类别建索引(peer=发送者)。
|
||||
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, req.Media, req.Entities); err != nil {
|
||||
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, media.Recipient, req.Entities); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{
|
||||
|
|
|
|||
50
internal/store/postgres/moderation_flags_integration_test.go
Normal file
50
internal/store/postgres/moderation_flags_integration_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestModerationFlagsRejectImpossibleStateAtPostgresBoundary(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
user := createTestUser(t, ctx, users, "+1781"+suffix+"71", "ModerationFlags", "")
|
||||
|
||||
if _, err := users.SetScamFake(ctx, user.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("user store error=%v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET scam=true,fake=true WHERE id=$1`, user.ID); err == nil {
|
||||
t.Fatal("users CHECK constraint accepted scam=true,fake=true")
|
||||
}
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: user.ID,
|
||||
Title: "Moderation " + suffix,
|
||||
Megagroup: true,
|
||||
Date: 1700002000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("channel store error=%v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE channels SET scam=true,fake=true WHERE id=$1`, created.Channel.ID); err == nil {
|
||||
t.Fatal("channels CHECK constraint accepted scam=true,fake=true")
|
||||
}
|
||||
|
||||
gotUser, found, err := users.ByID(ctx, user.ID)
|
||||
if err != nil || !found || gotUser.Scam || gotUser.Fake {
|
||||
t.Fatalf("user after rejected writes=%+v found=%v err=%v", gotUser, found, err)
|
||||
}
|
||||
gotChannel, err := channels.GetChannelByID(ctx, created.Channel.ID)
|
||||
if err != nil || gotChannel.Scam || gotChannel.Fake {
|
||||
t.Fatalf("channel after rejected writes=%+v err=%v", gotChannel, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -164,6 +164,21 @@ SET verified = sqlc.arg(verified)::boolean,
|
|||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetUserScamFake :one
|
||||
UPDATE users
|
||||
SET scam = sqlc.arg(scam)::boolean,
|
||||
fake = sqlc.arg(fake)::boolean,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetUserSupport :one
|
||||
UPDATE users
|
||||
SET support = sqlc.arg(support)::boolean,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: SweepExpiredPremium :many
|
||||
UPDATE users
|
||||
SET premium_expires_at = NULL,
|
||||
|
|
|
|||
|
|
@ -348,6 +348,15 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
|
|||
l.caches.BotProfiles.InvalidateBotProfileReadModel(evt.PeerID)
|
||||
}
|
||||
}
|
||||
case "user_visibility":
|
||||
if evt.PeerType == "user" && evt.PeerID != 0 {
|
||||
if l.caches.RPCProjections != nil {
|
||||
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.PeerID)
|
||||
}
|
||||
if l.caches.Stories != nil {
|
||||
l.caches.Stories.InvalidateStoryReadModelPeer(domain.Peer{Type: domain.PeerTypeUser, ID: evt.PeerID})
|
||||
}
|
||||
}
|
||||
case "bot_full":
|
||||
// bot 资料(name/about/description/commands/menu_button)变更经 bot_info_version
|
||||
// bump 触发(迁移 0013)。channelFullBotInfoCache 按 (viewer,channel) 键、无法按 botID
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
|
|||
const insertBotUser = `-- name: InsertBotUser :one
|
||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
|
||||
VALUES ($1, '', $2, '', $3, '', TRUE, 1)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type InsertBotUserParams struct {
|
||||
|
|
@ -209,6 +209,7 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -216,7 +217,8 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ type AccountDeletionRequest struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AccountFreezeNotification struct {
|
||||
ID int64
|
||||
TargetUserID int64
|
||||
FrozenUserID int64
|
||||
Version int64
|
||||
Frozen bool
|
||||
Status string
|
||||
Attempts int32
|
||||
NextAttemptAt pgtype.Timestamptz
|
||||
LeaseUntil pgtype.Timestamptz
|
||||
LastError string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AccountPassword struct {
|
||||
UserID int64
|
||||
HasRecovery bool
|
||||
|
|
@ -90,6 +105,7 @@ type AccountRestriction struct {
|
|||
FrozenSince pgtype.Timestamptz
|
||||
FrozenUntil pgtype.Timestamptz
|
||||
AppealUrl string
|
||||
Version int64
|
||||
}
|
||||
|
||||
type AccountSetting struct {
|
||||
|
|
@ -394,6 +410,38 @@ type BotEmojiStatusPermission struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BotLoginAllowedUrl struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
Kind string
|
||||
NormalizedUrl string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BotLoginClient struct {
|
||||
BotUserID int64
|
||||
ClientID string
|
||||
ClientSecretHash []byte
|
||||
SecretVersion int64
|
||||
SigningAlgorithm string
|
||||
Enabled bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BotLoginNativeApp struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
Platform string
|
||||
ApplicationID string
|
||||
VerificationID string
|
||||
CallbackUri string
|
||||
VerifiedDisplayName string
|
||||
Enabled bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BotUserPermission struct {
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
|
|
@ -504,6 +552,9 @@ type Channel struct {
|
|||
Wallpaper []byte
|
||||
Verified bool
|
||||
LinkedCommunityID int64
|
||||
Scam bool
|
||||
Fake bool
|
||||
Gigagroup bool
|
||||
}
|
||||
|
||||
type ChannelAdminLogEvent struct {
|
||||
|
|
@ -1596,6 +1647,17 @@ type SeedState struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftAdminGrantCommand struct {
|
||||
RecipientUserID int64
|
||||
CommandKey string
|
||||
RequestFingerprint []byte
|
||||
SenderUserID int64
|
||||
GiftID int64
|
||||
SavedGiftID int64
|
||||
UniqueGiftID int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftAuction struct {
|
||||
GiftID int64
|
||||
Slug string
|
||||
|
|
@ -1654,6 +1716,14 @@ type StarGiftAuctionBidPayment struct {
|
|||
CreatedAt int32
|
||||
}
|
||||
|
||||
type StarGiftBoxMediaRepair struct {
|
||||
OwnerUserID int64
|
||||
BoxID int32
|
||||
PeerType string
|
||||
PeerID int64
|
||||
RepairedMedia []byte
|
||||
}
|
||||
|
||||
type StarGiftCatalog struct {
|
||||
GiftID int64
|
||||
ActiveRevisionID int64
|
||||
|
|
@ -1768,6 +1838,13 @@ type StarGiftCollectiblePattern struct {
|
|||
OfficialDocumentID *int64
|
||||
}
|
||||
|
||||
type StarGiftCollectiblePreviewRepair struct {
|
||||
GiftID int64
|
||||
CollectibleRevisionID int64
|
||||
Reason string
|
||||
RepairedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftCollectibleRevision struct {
|
||||
ID int64
|
||||
GiftID int64
|
||||
|
|
@ -1823,6 +1900,16 @@ type StarGiftCraftCommand struct {
|
|||
ChancePermille int32
|
||||
CreatedAt int32
|
||||
SourceEditPts []int32
|
||||
OutputMedia []byte
|
||||
OutputFingerprint []byte
|
||||
}
|
||||
|
||||
type StarGiftCraftMessageRepair struct {
|
||||
OwnerUserID int64
|
||||
BoxID int32
|
||||
UniqueGiftID int64
|
||||
DesiredCraftChance int32
|
||||
DesiredCanCraftAt int32
|
||||
}
|
||||
|
||||
type StarGiftDropDetailsCommand struct {
|
||||
|
|
@ -1885,6 +1972,29 @@ type StarGiftPatternPreviewDocumentRepair struct {
|
|||
RepairedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftPinRepair struct {
|
||||
ID int64
|
||||
NewOrder int32
|
||||
}
|
||||
|
||||
type StarGiftPrepaidMessageAlias struct {
|
||||
OwnerUserID int64
|
||||
BoxID int32
|
||||
SavedGiftID int64
|
||||
MessageSenderID int64
|
||||
PrivateMessageID int64
|
||||
}
|
||||
|
||||
type StarGiftPrepaidMessageRepair struct {
|
||||
OwnerUserID int64
|
||||
BoxID int32
|
||||
PeerType string
|
||||
PeerID int64
|
||||
MessageSenderID int64
|
||||
PrivateMessageID int64
|
||||
RepairedMedia []byte
|
||||
}
|
||||
|
||||
type StarGiftPrepaidUpgradeCommand struct {
|
||||
PayerUserID int64
|
||||
CommandKey string
|
||||
|
|
@ -1964,6 +2074,14 @@ type StarGiftUpgradeCommand struct {
|
|||
SourceEditPts int32
|
||||
}
|
||||
|
||||
// Owner-local service-message aliases (unique outputs and separate prepaid-upgrade notifications) for one saved gift aggregate.
|
||||
type StarGiftUserMessageRef struct {
|
||||
OwnerUserID int64
|
||||
MsgID int32
|
||||
SavedGiftID int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftUserPurchase struct {
|
||||
UserID int64
|
||||
GiftID int64
|
||||
|
|
@ -1971,6 +2089,14 @@ type StarGiftUserPurchase struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftUserUniqueMediaRepair struct {
|
||||
OwnerUserID int64
|
||||
BoxID int32
|
||||
PeerType string
|
||||
PeerID int64
|
||||
RepairedMedia []byte
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalRequest struct {
|
||||
ID int64
|
||||
UniqueGiftID int64
|
||||
|
|
@ -2100,6 +2226,85 @@ type StoryView struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type SuggestedPostApproval struct {
|
||||
MonoforumID int64
|
||||
SuggestionMessageID int32
|
||||
ParentChannelID int64
|
||||
ActorUserID int64
|
||||
PayerUserID int64
|
||||
State string
|
||||
PriceKind string
|
||||
PriceAmount int64
|
||||
PriceNanos int32
|
||||
ScheduleDate int32
|
||||
ApprovalServiceMessageID int32
|
||||
PublishedMessageID int32
|
||||
SettlementDue int32
|
||||
FinalServiceMessageID int32
|
||||
CreatedAt int32
|
||||
UpdatedAt int32
|
||||
}
|
||||
|
||||
type TelegramLoginCode struct {
|
||||
ID int64
|
||||
RequestID int64
|
||||
CodeHash []byte
|
||||
SealedCode []byte
|
||||
SealNonce []byte
|
||||
SealKeyID string
|
||||
IssuedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
ConsumedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TelegramLoginRequest struct {
|
||||
ID int64
|
||||
RequestTokenHash []byte
|
||||
BrowserTokenHash []byte
|
||||
BotUserID int64
|
||||
ClientID string
|
||||
SigningAlgorithm string
|
||||
Source string
|
||||
ResponseType string
|
||||
RedirectUri string
|
||||
Origin string
|
||||
Domain string
|
||||
RequestedScopes []string
|
||||
OauthState string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
Browser string
|
||||
Platform string
|
||||
Ip string
|
||||
Region string
|
||||
InAppOrigin string
|
||||
IsApp bool
|
||||
VerifiedAppName string
|
||||
MatchCodes []string
|
||||
MatchCode string
|
||||
MatchCodesFirst bool
|
||||
UserIDHint int64
|
||||
PeerType string
|
||||
PeerID int64
|
||||
MessageID int32
|
||||
ButtonID int32
|
||||
Status string
|
||||
AuthorizedUserID *int64
|
||||
ProfileName string
|
||||
GivenName string
|
||||
FamilyName string
|
||||
PreferredUsername string
|
||||
Picture string
|
||||
PhoneNumber string
|
||||
WriteAllowed bool
|
||||
PhoneShared bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
ApprovedAt pgtype.Timestamptz
|
||||
DeclinedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TelesrvCollectiblePatternCorrectionEvent struct {
|
||||
UserID int64
|
||||
Pts int32
|
||||
|
|
@ -2276,6 +2481,7 @@ type User struct {
|
|||
BirthdayMonth int32
|
||||
BirthdayYear int32
|
||||
PersonalChannelID int64
|
||||
SignupEmail string
|
||||
DeletedAt pgtype.Timestamptz
|
||||
DeletionSource string
|
||||
DeletionReason string
|
||||
|
|
@ -2283,7 +2489,8 @@ type User struct {
|
|||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LinkedCommunityID int64
|
||||
SignupEmail string
|
||||
Scam bool
|
||||
Fake bool
|
||||
}
|
||||
|
||||
type UserBusinessProfile struct {
|
||||
|
|
@ -2402,6 +2609,24 @@ type UserUpdateWatermark struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type WebAuthorization struct {
|
||||
Hash int64
|
||||
RequestID int64
|
||||
UserID int64
|
||||
BotUserID int64
|
||||
Domain string
|
||||
Browser string
|
||||
Platform string
|
||||
Ip string
|
||||
Region string
|
||||
GrantedScopes []string
|
||||
PhoneShared bool
|
||||
BotAccessGranted bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
LastActiveAt pgtype.Timestamptz
|
||||
RevokedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type WebPage struct {
|
||||
UrlHash int64
|
||||
WebPageID int64
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (access_hash, phone, signup_email, first_name, last_name, username, country_code, premium_expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
|
|
@ -70,6 +70,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -77,13 +78,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE id = $1
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
|
|
@ -119,6 +121,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -126,13 +129,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByPhone = `-- name: GetUserByPhone :one
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE phone = $1 AND deleted_at IS NULL
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE phone = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
|
||||
|
|
@ -168,6 +172,7 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -175,13 +180,14 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserBySignupEmail = `-- name: GetUserBySignupEmail :one
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User, error) {
|
||||
|
|
@ -217,6 +223,7 @@ func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User,
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -224,13 +231,14 @@ func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User,
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
|
||||
|
|
@ -266,6 +274,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -273,13 +282,14 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUsersByIDs = `-- name: GetUsersByIDs :many
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
FROM users
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id
|
||||
|
|
@ -324,6 +334,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -331,7 +342,8 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -344,7 +356,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
|||
}
|
||||
|
||||
const getUsersByPhones = `-- name: GetUsersByPhones :many
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
FROM users
|
||||
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
|
||||
ORDER BY id
|
||||
|
|
@ -389,6 +401,7 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -396,7 +409,8 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -592,7 +606,7 @@ UPDATE users
|
|||
SET premium_expires_at = $1::timestamptz,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type SetUserPremiumUntilParams struct {
|
||||
|
|
@ -633,6 +647,7 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -640,7 +655,130 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const setUserScamFake = `-- name: SetUserScamFake :one
|
||||
UPDATE users
|
||||
SET scam = $1::boolean,
|
||||
fake = $2::boolean,
|
||||
updated_at = now()
|
||||
WHERE id = $3::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type SetUserScamFakeParams struct {
|
||||
Scam bool
|
||||
Fake bool
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SetUserScamFake(ctx context.Context, arg SetUserScamFakeParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, setUserScamFake, arg.Scam, arg.Fake, arg.ID)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AccessHash,
|
||||
&i.Phone,
|
||||
&i.FirstName,
|
||||
&i.LastName,
|
||||
&i.Username,
|
||||
&i.CountryCode,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Verified,
|
||||
&i.Support,
|
||||
&i.About,
|
||||
&i.LastSeenAt,
|
||||
&i.DefaultHistoryTtlPeriod,
|
||||
&i.IsBot,
|
||||
&i.BotInfoVersion,
|
||||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.ColorSet,
|
||||
&i.Color,
|
||||
&i.ColorBackgroundEmojiID,
|
||||
&i.ProfileColorSet,
|
||||
&i.ProfileColor,
|
||||
&i.ProfileColorBackgroundEmojiID,
|
||||
&i.BirthdayDay,
|
||||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const setUserSupport = `-- name: SetUserSupport :one
|
||||
UPDATE users
|
||||
SET support = $1::boolean,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type SetUserSupportParams struct {
|
||||
Support bool
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SetUserSupport(ctx context.Context, arg SetUserSupportParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, setUserSupport, arg.Support, arg.ID)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AccessHash,
|
||||
&i.Phone,
|
||||
&i.FirstName,
|
||||
&i.LastName,
|
||||
&i.Username,
|
||||
&i.CountryCode,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Verified,
|
||||
&i.Support,
|
||||
&i.About,
|
||||
&i.LastSeenAt,
|
||||
&i.DefaultHistoryTtlPeriod,
|
||||
&i.IsBot,
|
||||
&i.BotInfoVersion,
|
||||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.ColorSet,
|
||||
&i.Color,
|
||||
&i.ColorBackgroundEmojiID,
|
||||
&i.ProfileColorSet,
|
||||
&i.ProfileColor,
|
||||
&i.ProfileColorBackgroundEmojiID,
|
||||
&i.BirthdayDay,
|
||||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -650,7 +788,7 @@ UPDATE users
|
|||
SET verified = $1::boolean,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type SetUserVerifiedParams struct {
|
||||
|
|
@ -691,6 +829,7 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -698,7 +837,8 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -715,7 +855,7 @@ WHERE id IN (
|
|||
ORDER BY premium_expires_at
|
||||
LIMIT $2::int
|
||||
)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type SweepExpiredPremiumParams struct {
|
||||
|
|
@ -762,6 +902,7 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -769,7 +910,8 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -788,7 +930,7 @@ SET birthday_day = $1::int,
|
|||
birthday_year = $3::int,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserBirthdayParams struct {
|
||||
|
|
@ -836,6 +978,7 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -843,7 +986,8 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -855,7 +999,7 @@ SET color_set = $1::boolean,
|
|||
color_background_emoji_id = $3::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserColorParams struct {
|
||||
|
|
@ -903,6 +1047,7 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -910,7 +1055,8 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -923,7 +1069,7 @@ SET emoji_status_document_id = $1::bigint,
|
|||
emoji_status_collectible = $4::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $5::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserEmojiStatusParams struct {
|
||||
|
|
@ -973,6 +1119,7 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -980,7 +1127,8 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1007,7 +1155,7 @@ UPDATE users
|
|||
SET personal_channel_id = $1::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserPersonalChannelParams struct {
|
||||
|
|
@ -1048,6 +1196,7 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -1055,7 +1204,8 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1065,7 +1215,7 @@ UPDATE users
|
|||
SET phone = $1::text,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserPhoneParams struct {
|
||||
|
|
@ -1106,6 +1256,7 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -1113,7 +1264,8 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1124,7 +1276,7 @@ SET phone = $1::text,
|
|||
signup_email = $2::text,
|
||||
updated_at = now()
|
||||
WHERE id = $3::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserPhoneAndSignupEmailParams struct {
|
||||
|
|
@ -1166,6 +1318,7 @@ func (q *Queries) UpdateUserPhoneAndSignupEmail(ctx context.Context, arg UpdateU
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -1173,7 +1326,8 @@ func (q *Queries) UpdateUserPhoneAndSignupEmail(ctx context.Context, arg UpdateU
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1185,7 +1339,7 @@ SET first_name = $2,
|
|||
about = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
|
|
@ -1233,6 +1387,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -1240,7 +1395,8 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1252,7 +1408,7 @@ SET profile_color_set = $1::boolean,
|
|||
profile_color_background_emoji_id = $3::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserProfileColorParams struct {
|
||||
|
|
@ -1300,6 +1456,7 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -1307,7 +1464,8 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1317,7 +1475,7 @@ UPDATE users
|
|||
SET username = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
|
||||
`
|
||||
|
||||
type UpdateUserUsernameParams struct {
|
||||
|
|
@ -1358,6 +1516,7 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.SignupEmail,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
|
|
@ -1365,7 +1524,8 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
|
|||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
&i.Scam,
|
||||
&i.Fake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -649,11 +649,17 @@ LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
|
|||
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
|
||||
AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))`
|
||||
if owner.Type == domain.PeerTypeUser {
|
||||
query = `SELECT p.msg_id::bigint, COALESCE(u.slug, ''), p.id
|
||||
query = `SELECT ref.msg_id, COALESCE(u.slug, ''), p.id
|
||||
FROM peer_star_gifts p
|
||||
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT p.msg_id::bigint AS msg_id
|
||||
UNION ALL
|
||||
SELECT r.msg_id::bigint FROM star_gift_user_message_refs r
|
||||
WHERE r.saved_gift_id=p.id AND r.owner_user_id=p.owner_peer_id
|
||||
) ref
|
||||
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
|
||||
AND (p.msg_id::bigint=ANY($3::bigint[])
|
||||
AND (ref.msg_id=ANY($3::bigint[])
|
||||
OR u.slug=ANY($4::text[]))`
|
||||
}
|
||||
rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs)
|
||||
|
|
@ -744,15 +750,42 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift
|
|||
if !ref.Valid() {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
args = append(args, unsaved)
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE peer_star_gifts SET unsaved = $4
|
||||
WHERE `+where+` AND lifecycle_status='active'`, args...)
|
||||
changed := false
|
||||
err := withTx(ctx, s.db, "set star gift unsaved", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
|
||||
return err
|
||||
}
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
var savedID int64
|
||||
var pinnedOrder int
|
||||
err := tx.QueryRow(ctx, `SELECT id,pinned_order FROM peer_star_gifts WHERE `+where+` AND lifecycle_status='active' FOR UPDATE`, args...).Scan(&savedID, &pinnedOrder)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET unsaved=$2,pinned_order=CASE WHEN $2 THEN 0 ELSE pinned_order END WHERE id=$1`, savedID, unsaved); err != nil {
|
||||
return err
|
||||
}
|
||||
if unsaved && pinnedOrder > 0 {
|
||||
// The positive-order unique index is immediate. Move the bounded
|
||||
// vector one vacant slot at a time so no transient duplicate order
|
||||
// can be observed by PostgreSQL.
|
||||
for order := pinnedOrder + 1; order <= domain.MaxPinnedStarGifts; order++ {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$4
|
||||
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order=$3`, string(ref.Owner.Type), ref.Owner.ID, order, order-1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("set star gift unsaved: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
|
|
@ -836,7 +869,11 @@ func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
|
|||
return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args
|
||||
default:
|
||||
args = append(args, ref.MsgID)
|
||||
return "owner_peer_type = $1 AND owner_peer_id = $2 AND msg_id = $3", args
|
||||
return `owner_peer_type = $1 AND owner_peer_id = $2 AND (
|
||||
msg_id = $3 OR EXISTS (
|
||||
SELECT 1 FROM star_gift_user_message_refs r
|
||||
WHERE r.saved_gift_id = id AND r.owner_user_id = $2 AND r.msg_id = $3
|
||||
))`, args
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -690,6 +690,9 @@ func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Pee
|
|||
}
|
||||
|
||||
func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
|
||||
return err
|
||||
|
|
@ -698,11 +701,14 @@ func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedG
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != len(savedGiftIDs) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
for order, id := range ids {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2 WHERE id=$1`, id, order+1); err != nil {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2,unsaved=false WHERE id=$1`, id, order+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,24 +43,29 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
Document: collectibleTestDocumentPtr(baseDocumentID+3, "crafted-model.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+3, "crafted-model"), Animation: collectibleTestAnimationPtr("crafted-model.tgs"),
|
||||
OfficialDocumentID: 5100000000000000003,
|
||||
}, {
|
||||
Kind: domain.StarGiftCollectibleModel, Name: "Solar", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 78,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+4, "model-two.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs"),
|
||||
OfficialDocumentID: 5100000000000000004,
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 989,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "pattern.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs"),
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999,
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 989,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Rings", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 11,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Daylight", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1},
|
||||
},
|
||||
Actor: "integration", CommandID: "collectibles-" + suffix,
|
||||
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: make([]byte, 32),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish collectible pool: %v", err)
|
||||
}
|
||||
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 2 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 ||
|
||||
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 3 || len(poolRevision.Patterns) != 2 || len(poolRevision.Backdrops) != 2 ||
|
||||
!poolRevision.Models[1].Crafted || poolRevision.Models[1].RarityKind != domain.StarGiftRarityLegendary ||
|
||||
poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 {
|
||||
t.Fatalf("published pool = %+v", poolRevision)
|
||||
|
|
@ -116,8 +121,27 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
t.Fatalf("owner upgrade service message = %+v", ownerMessage)
|
||||
}
|
||||
uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique
|
||||
if uniqueAction.SavedID != int64(saved.MsgID) {
|
||||
t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID)
|
||||
if uniqueAction.SavedID != 0 || uniqueAction.Peer.Type != "" || uniqueAction.Peer.ID != 0 {
|
||||
t.Fatalf("user unique action leaked channel peer/saved_id: %+v", uniqueAction)
|
||||
}
|
||||
senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
|
||||
if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 {
|
||||
t.Fatalf("sender unique action leaked owner-only saved_id: %+v", senderUniqueAction)
|
||||
}
|
||||
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: ownerMessage.ID}); err != nil || !found || byOutput.ID != savedID {
|
||||
t.Fatalf("owner upgrade output ref = %+v found %v err %v", byOutput, found, err)
|
||||
}
|
||||
var ownerAliasCount, senderAliasCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, owner.ID, ownerMessage.ID, savedID).Scan(&ownerAliasCount); err != nil {
|
||||
t.Fatalf("load owner upgrade output alias: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2`, sender.ID, ownerMessage.ID).Scan(&senderAliasCount); err != nil {
|
||||
t.Fatalf("load sender upgrade output alias: %v", err)
|
||||
}
|
||||
if ownerAliasCount != 1 || senderAliasCount != 0 {
|
||||
t.Fatalf("upgrade output aliases owner=%d sender=%d, want owner-only", ownerAliasCount, senderAliasCount)
|
||||
}
|
||||
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
|
||||
if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil ||
|
||||
|
|
@ -334,21 +358,22 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
}
|
||||
soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"),
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"),
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2,
|
||||
CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
}},
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Nova Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+103, "nova-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+103, "nova-model-two"), Animation: collectibleTestAnimationPtr("nova-model-two.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Ray Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+104, "nova-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+104, "nova-pattern-two"), Animation: collectibleTestAnimationPtr("nova-pattern-two.tgs")},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2, CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Light", BackdropID: 3, CenterColor: 0xeeeeee, EdgeColor: 0xcccccc, PatternColor: 0xaaaaaa, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
},
|
||||
Actor: "integration", CommandID: "soldout-pool-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -418,6 +443,65 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStarGiftCollectiblePreviewActivationGuardPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
|
||||
gifts := NewStarGiftStore(pool)
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Unsafe Preview " + suffix, Stars: 10, ConvertStars: 5, Enabled: true,
|
||||
Document: collectibleTestDocument(baseDocumentID, "unsafe-preview.tgs"), Blob: collectibleTestBlob(baseDocumentID, "unsafe-preview"),
|
||||
Animation: collectibleTestAnimation("unsafe-preview.tgs"), Actor: "integration", CommandID: "unsafe-preview-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create unsafe-preview catalog: %v", err)
|
||||
}
|
||||
var revisionID int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO star_gift_collectible_revisions
|
||||
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id)
|
||||
VALUES ($1, 1, 10, 10, $2, 'draft', 'integration', $3)
|
||||
RETURNING id`, entry.Gift.ID, "unsafe-"+suffix, "unsafe-preview-pool-"+suffix).Scan(&revisionID); err != nil {
|
||||
t.Fatalf("insert unsafe-preview revision: %v", err)
|
||||
}
|
||||
animation := collectibleTestAnimation("unsafe-preview-attribute.tgs")
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO star_gift_collectible_models
|
||||
(collectible_revision_id, name, document_id, animation_json, animation_sha256, source_name, source_format,
|
||||
width, height, frame_rate, in_point, out_point, rarity_kind, rarity_permille, crafted, sort_order)
|
||||
VALUES ($1, 'Only Model', $2, $3::jsonb, $4, 'model.tgs', 'tgs', 512, 512, 30, 0, 60, 'permille', 1000, false, 0)`,
|
||||
revisionID, baseDocumentID, string(animation.JSON), animation.SHA256); err != nil {
|
||||
t.Fatalf("insert unsafe-preview model: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO star_gift_collectible_patterns
|
||||
(collectible_revision_id, name, document_id, animation_json, animation_sha256, source_name, source_format,
|
||||
width, height, frame_rate, in_point, out_point, rarity_kind, rarity_permille, sort_order)
|
||||
VALUES ($1, 'Only Pattern', $2, $3::jsonb, $4, 'pattern.tgs', 'tgs', 512, 512, 30, 0, 60, 'permille', 1000, 0)`,
|
||||
revisionID, baseDocumentID, string(animation.JSON), animation.SHA256); err != nil {
|
||||
t.Fatalf("insert unsafe-preview pattern: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO star_gift_collectible_backdrops
|
||||
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, text_color,
|
||||
rarity_kind, rarity_permille, sort_order)
|
||||
VALUES ($1, 'Only Backdrop', 1, 1, 2, 3, 4, 'permille', 1000, 0)`, revisionID); err != nil {
|
||||
t.Fatalf("insert unsafe-preview backdrop: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE star_gift_collectible_revisions SET status='published', published_at=now() WHERE id=$1`, revisionID); err != nil {
|
||||
t.Fatalf("publish unsafe-preview revision directly: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE star_gift_catalog SET collectible_revision_id=$2 WHERE gift_id=$1`, entry.Gift.ID, revisionID); err == nil {
|
||||
t.Fatal("database activated a collectible preview pool with one client-distinct item per category")
|
||||
}
|
||||
var activeRevisionID *int64
|
||||
if err := pool.QueryRow(ctx, `SELECT collectible_revision_id FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&activeRevisionID); err != nil || activeRevisionID != nil {
|
||||
t.Fatalf("unsafe preview activation pointer=%v err=%v, want null", activeRevisionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -441,27 +525,28 @@ func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) {
|
|||
}
|
||||
revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "no-craft-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleModel, Name: "Ordinary", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "no-craft-model.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+1, "no-craft-model"), Animation: collectibleTestAnimationPtr("no-craft-model.tgs"),
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "no-craft-pattern.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+2, "no-craft-pattern"), Animation: collectibleTestAnimationPtr("no-craft-pattern.tgs"),
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
}},
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Ordinary", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "no-craft-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "no-craft-model"), Animation: collectibleTestAnimationPtr("no-craft-model.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Ordinary Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+3, "no-craft-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "no-craft-model-two"), Animation: collectibleTestAnimationPtr("no-craft-model-two.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "no-craft-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "no-craft-pattern"), Animation: collectibleTestAnimationPtr("no-craft-pattern.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+4, "no-craft-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "no-craft-pattern-two"), Animation: collectibleTestAnimationPtr("no-craft-pattern-two.tgs")},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
},
|
||||
Actor: "integration", CommandID: "no-craft-pool-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish no-craft pool: %v", err)
|
||||
}
|
||||
if len(revision.Models) != 1 || revision.Models[0].Crafted {
|
||||
if len(revision.Models) != 2 || revision.Models[0].Crafted || revision.Models[1].Crafted {
|
||||
t.Fatalf("no-craft pool models = %+v", revision.Models)
|
||||
}
|
||||
|
||||
|
|
@ -518,6 +603,125 @@ FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminUniqueStarGiftGrantIsAtomicAndReplayable(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
now := int(time.Now().Unix())
|
||||
recipient := createTestUser(t, ctx, NewUserStore(pool), "+1780"+suffix+"61", "AdminGiftRecipient", "")
|
||||
gifts := NewStarGiftStore(pool)
|
||||
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Admin Grant " + suffix, Stars: 50, ConvertStars: 25, Enabled: true,
|
||||
Document: collectibleTestDocument(baseDocumentID, "admin-grant-gift.tgs"),
|
||||
Blob: collectibleTestBlob(baseDocumentID, "admin-grant-gift"), Animation: collectibleTestAnimation("admin-grant-gift.tgs"),
|
||||
Actor: "integration", CommandID: "admin-grant-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create admin grant catalog gift: %v", err)
|
||||
}
|
||||
revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 2, SlugPrefix: "admin-grant-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "admin-model-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "admin-model-one"), Animation: collectibleTestAnimationPtr("admin-model-one.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "admin-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "admin-model-two"), Animation: collectibleTestAnimationPtr("admin-model-two.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "admin-pattern-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "admin-pattern-one"), Animation: collectibleTestAnimationPtr("admin-pattern-one.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+4, "admin-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "admin-pattern-two"), Animation: collectibleTestAnimationPtr("admin-pattern-two.tgs")},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop One", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
},
|
||||
Actor: "integration", CommandID: "admin-grant-pool-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish admin grant collectible pool: %v", err)
|
||||
}
|
||||
upgrades := NewStarGiftUpgradeStore(pool, NewMessageStore(pool))
|
||||
invalid := domain.AdminStarGiftGrant{
|
||||
SenderID: domain.OfficialSystemUserID, Recipient: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
|
||||
GiftID: entry.Gift.ID, Upgrade: true, CommandKey: "admin-invalid-" + suffix, Date: now,
|
||||
ModelAttributeID: revision.Models[0].ID + 9_999_999,
|
||||
}
|
||||
if _, err := upgrades.GrantUniqueStarGift(ctx, invalid); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("invalid admin grant error=%v", err)
|
||||
}
|
||||
var issued, messageCount, savedCount, uniqueCount, commandCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalidRandomID := lifecycleCommandRandomID("admin-collectible-grant", recipient.ID, invalid.CommandKey)
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM private_messages WHERE sender_user_id=$1 AND random_id=$2`,
|
||||
domain.OfficialSystemUserID, invalidRandomID).Scan(&messageCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type='user' AND owner_peer_id=$1 AND gift_id=$2`,
|
||||
recipient.ID, entry.Gift.ID).Scan(&savedCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM unique_star_gifts WHERE gift_id=$1`, entry.Gift.ID).Scan(&uniqueCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
|
||||
recipient.ID).Scan(&commandCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issued != 0 || messageCount != 0 || savedCount != 0 || uniqueCount != 0 || commandCount != 0 {
|
||||
t.Fatalf("failed grant leaked state: issued=%d messages=%d saved=%d unique=%d commands=%d",
|
||||
issued, messageCount, savedCount, uniqueCount, commandCount)
|
||||
}
|
||||
|
||||
req := invalid
|
||||
req.CommandKey = "admin-success-" + suffix
|
||||
req.Message = "atomic collectible"
|
||||
req.ModelAttributeID = revision.Models[0].ID
|
||||
req.PatternAttributeID = revision.Patterns[0].ID
|
||||
req.BackdropAttributeID = revision.Backdrops[0].ID
|
||||
granted, err := upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("grant admin unique gift: %v", err)
|
||||
}
|
||||
action := granted.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
||||
if granted.Duplicate || granted.Saved.MsgID <= 0 || granted.Saved.MsgID != granted.Saved.UpgradeMsgID ||
|
||||
granted.Saved.UniqueGiftID != granted.Unique.ID || granted.Unique.Num != 1 ||
|
||||
action == nil || !action.Assigned || !action.Saved || action.Gift.ID != granted.Unique.ID {
|
||||
t.Fatalf("admin grant result=%+v action=%+v", granted, action)
|
||||
}
|
||||
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, granted.Send.RecipientMessage.Pts-1, 1)
|
||||
if err != nil || len(events) != 1 || events[0].Message.Media == nil ||
|
||||
events[0].Message.Media.ServiceAction == nil ||
|
||||
events[0].Message.Media.ServiceAction.StarGiftUnique == nil ||
|
||||
events[0].Message.Media.ServiceAction.StarGiftUnique.Gift.ID != granted.Unique.ID {
|
||||
t.Fatalf("admin grant durable update=%+v err=%v", events, err)
|
||||
}
|
||||
|
||||
replay, err := upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("replay admin unique gift: %v", err)
|
||||
}
|
||||
if !replay.Duplicate || replay.Saved.ID != granted.Saved.ID || replay.Unique.ID != granted.Unique.ID ||
|
||||
replay.Send.RecipientMessage.ID != granted.Send.RecipientMessage.ID {
|
||||
t.Fatalf("admin grant replay=%+v want saved=%d unique=%d msg=%d",
|
||||
replay, granted.Saved.ID, granted.Unique.ID, granted.Send.RecipientMessage.ID)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
|
||||
recipient.ID).Scan(&commandCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issued != 1 || commandCount != 1 {
|
||||
t.Fatalf("replay duplicated aggregate: issued=%d commands=%d", issued, commandCount)
|
||||
}
|
||||
}
|
||||
|
||||
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -18,6 +19,37 @@ const (
|
|||
maxStarGiftAuctionAcquired = 1000
|
||||
)
|
||||
|
||||
// starGiftCraftOutputIntent is the immutable message intent committed with a
|
||||
// successful craft outcome. The aggregate may subsequently be hidden, listed
|
||||
// or otherwise edited; an exact retry must still use the first intent and its
|
||||
// fingerprint instead of rebuilding a different message from mutable state.
|
||||
type starGiftCraftOutputIntent struct {
|
||||
Media *domain.MessageMedia
|
||||
Fingerprint []byte
|
||||
Date int
|
||||
SavedGiftID int64
|
||||
}
|
||||
|
||||
func starGiftCraftOutputMedia(userID int64, gift domain.UniqueStarGift, saved domain.SavedStarGift) *domain.MessageMedia {
|
||||
return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: gift, FromUserID: userID, Saved: !saved.Unsaved, Craft: true,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt,
|
||||
CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars,
|
||||
CanCraftAt: saved.CanCraftAt,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func starGiftCraftOutputRequest(req domain.StarGiftCraftRequest, intent starGiftCraftOutputIntent) domain.SendPrivateTextRequest {
|
||||
return domain.SendPrivateTextRequest{
|
||||
SenderUserID: req.UserID, RecipientUserID: req.UserID,
|
||||
RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: intent.Date,
|
||||
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID,
|
||||
IdempotencyFingerprint: append([]byte(nil), intent.Fingerprint...), Media: intent.Media,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultStarGiftCraftDraw(upper int) (int, error) {
|
||||
if upper <= 0 {
|
||||
return 0, domain.ErrStarGiftCraftUnavailable
|
||||
|
|
@ -158,7 +190,8 @@ func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID,
|
|||
}
|
||||
args := []any{userID, giftID}
|
||||
where := `p.owner_peer_type='user' AND p.owner_peer_id=$1 AND p.gift_id=$2
|
||||
AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer
|
||||
AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL
|
||||
AND p.can_craft_at>0 AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer
|
||||
AND NOT u.burned AND u.owner_address='' AND u.craft_chance_permille>0
|
||||
AND EXISTS (SELECT 1 FROM star_gift_collectible_models m
|
||||
WHERE m.collectible_revision_id=u.collectible_revision_id AND m.crafted)`
|
||||
|
|
@ -234,11 +267,11 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
|
|||
// A committed failed craft has already moved every input out of the active
|
||||
// lifecycle. Consult the immutable receipt before active-gift resolution so
|
||||
// an exact transport retry can still replay the same terminal result.
|
||||
if replay, found, err := s.loadCraftReplay(ctx, req); err != nil || found {
|
||||
if replay, output, found, err := s.loadCraftReplay(ctx, req); err != nil || found {
|
||||
if err != nil || !replay.Success {
|
||||
return replay, err
|
||||
}
|
||||
return s.deliverCraftSuccess(ctx, req, replay)
|
||||
return s.deliverCraftSuccess(ctx, req, replay, output)
|
||||
}
|
||||
savedIDs, err := NewStarGiftStore(s.db).ResolveSavedIDs(ctx, owner, req.Refs)
|
||||
if err != nil {
|
||||
|
|
@ -248,7 +281,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
|
|||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
var result domain.StarGiftCraftResult
|
||||
var resultUniqueID int64
|
||||
var output starGiftCraftOutputIntent
|
||||
err = withTx(ctx, s.db, "craft star gift", func(tx pgx.Tx) error {
|
||||
lockedRows, err := tx.Query(ctx, `SELECT id FROM peer_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(savedIDs))
|
||||
if err != nil {
|
||||
|
|
@ -269,7 +302,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
|
|||
chance := 0
|
||||
for i := range req.Refs {
|
||||
saved, err := lockSavedStarGiftByID(ctx, tx, savedIDs[i])
|
||||
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt > req.Date {
|
||||
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt <= 0 || saved.CanCraftAt > req.Date {
|
||||
return domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID)
|
||||
|
|
@ -374,111 +407,164 @@ WHERE id=ANY($1::bigint[])`, savedIDs[burnFrom:]); err != nil {
|
|||
}
|
||||
result.SourceEdits = sourceEdits
|
||||
var resultID any
|
||||
var outputMediaJSON any
|
||||
var outputFingerprint any
|
||||
if result.Success {
|
||||
resultID = firstUniqueID
|
||||
resultUniqueID = firstUniqueID
|
||||
gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, firstUniqueID)
|
||||
if err != nil || !found {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
saved, found, err := savedStarGiftByID(ctx, tx, firstSavedID)
|
||||
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != gift.ID ||
|
||||
!saved.LifecycleStatus.Live() || gift.Owner != owner || gift.Burned || !gift.Crafted {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
media := starGiftCraftOutputMedia(req.UserID, gift, saved)
|
||||
intent := starGiftCraftOutputIntent{Media: media, Date: req.Date, SavedGiftID: saved.ID}
|
||||
fingerprint, err := store.PrivateSendFingerprint(starGiftCraftOutputRequest(req, intent))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fingerprint crafted gift output: %w", err)
|
||||
}
|
||||
mediaJSON, err := encodeMessageMedia(media)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode crafted gift output: %w", err)
|
||||
}
|
||||
intent.Fingerprint = fingerprint
|
||||
output = intent
|
||||
outputMediaJSON = mediaJSON
|
||||
outputFingerprint = fingerprint
|
||||
giftCopy := gift
|
||||
result.Gift = &giftCopy
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO star_gift_craft_commands(user_id,command_key,input_unique_gift_ids,gift_id,
|
||||
success,result_unique_gift_id,chance_permille,created_at,source_edit_pts) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID,
|
||||
strings.TrimSpace(req.CommandKey), uniqueIDs, giftID, result.Success, resultID, chance, req.Date, sourceEditPTS)
|
||||
success,result_unique_gift_id,chance_permille,created_at,source_edit_pts,output_media,output_fingerprint)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, req.UserID, strings.TrimSpace(req.CommandKey), uniqueIDs,
|
||||
giftID, result.Success, resultID, chance, req.Date, sourceEditPTS, outputMediaJSON, outputFingerprint)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if replay, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found {
|
||||
if replay, replayOutput, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found {
|
||||
if replayErr == nil && found && replay.Success {
|
||||
return s.deliverCraftSuccess(ctx, req, replay, replayOutput)
|
||||
}
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
return domain.StarGiftCraftResult{}, err
|
||||
}
|
||||
if result.Success {
|
||||
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, resultUniqueID)
|
||||
if err != nil || !found {
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
result.Gift = &gift
|
||||
}
|
||||
if result.Success {
|
||||
return s.deliverCraftSuccess(ctx, req, result)
|
||||
return s.deliverCraftSuccess(ctx, req, result, output)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult) (domain.StarGiftCraftResult, error) {
|
||||
if result.Gift == nil || s.messages == nil {
|
||||
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult, output starGiftCraftOutputIntent) (domain.StarGiftCraftResult, error) {
|
||||
if result.Gift == nil || s.messages == nil || output.Media == nil || output.Date <= 0 || output.SavedGiftID <= 0 ||
|
||||
store.ValidateSendFingerprint(output.Fingerprint, "crafted gift output") != nil {
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, result.Gift.ID)
|
||||
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: req.UserID,
|
||||
RecipientUserID: req.UserID, RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: req.Date,
|
||||
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: *result.Gift, FromUserID: req.UserID, Peer: saved.Owner, Saved: !saved.Unsaved, Craft: true,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt,
|
||||
CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars,
|
||||
CanCraftAt: saved.CanCraftAt}}}})
|
||||
messageReq := starGiftCraftOutputRequest(req, output)
|
||||
hooks := privateSendTxHooks{after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
return registerUserStarGiftMessageRef(ctx, tx, req.UserID, sent.SenderMessage.ID, output.SavedGiftID, result.Gift.ID)
|
||||
}}
|
||||
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, err
|
||||
}
|
||||
registered, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, sent.SenderMessage.ID, output.SavedGiftID)
|
||||
if err != nil || !registered {
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, err
|
||||
}
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
result.Send = sent
|
||||
result.Duplicate = result.Duplicate || sent.Duplicate
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, bool, error) {
|
||||
func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, starGiftCraftOutputIntent, bool, error) {
|
||||
var success bool
|
||||
var resultID *int64
|
||||
var chance int
|
||||
var inputUniqueIDs []int64
|
||||
var sourceEditPTS []int32
|
||||
err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts
|
||||
var createdAt int
|
||||
var outputMediaJSON, outputFingerprint []byte
|
||||
err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts,
|
||||
created_at,output_media,output_fingerprint
|
||||
FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`,
|
||||
req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS)
|
||||
req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS,
|
||||
&createdAt, &outputMediaJSON, &outputFingerprint)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarGiftCraftResult{}, false, nil
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, false, err
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
|
||||
}
|
||||
if len(req.Refs) != len(inputUniqueIDs) || len(req.Refs) != len(sourceEditPTS) {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
savedIDs := make([]int64, 0, len(inputUniqueIDs))
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
|
||||
for i, uniqueID := range inputUniqueIDs {
|
||||
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID)
|
||||
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != uniqueID {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
ref := req.Refs[i]
|
||||
if ref.Owner != owner || ref.Slug == "" && ref.MsgID != saved.MsgID {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
if ref.Owner != owner {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
if ref.Slug != "" {
|
||||
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
|
||||
if err != nil || !found || !strings.EqualFold(ref.Slug, unique.Slug) {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
} else if ref.MsgID != saved.MsgID {
|
||||
matches, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, ref.MsgID, saved.ID)
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
|
||||
}
|
||||
if !matches {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
}
|
||||
savedIDs = append(savedIDs, saved.ID)
|
||||
}
|
||||
sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS)
|
||||
if err != nil {
|
||||
return domain.StarGiftCraftResult{}, false, err
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
|
||||
}
|
||||
result := domain.StarGiftCraftResult{Success: success, Chance: chance, SourceEdits: sourceEdits, Duplicate: true}
|
||||
if resultID != nil {
|
||||
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, *resultID)
|
||||
if err != nil || !found {
|
||||
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
if !success {
|
||||
if resultID != nil || len(outputMediaJSON) != 0 || len(outputFingerprint) != 0 {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
result.Gift = &gift
|
||||
return result, starGiftCraftOutputIntent{}, true, nil
|
||||
}
|
||||
return result, true, nil
|
||||
if resultID == nil || createdAt <= 0 || store.ValidateSendFingerprint(outputFingerprint, "crafted gift replay") != nil {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
media, err := decodeMessageMedia(string(outputMediaJSON))
|
||||
if err != nil || media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
|
||||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
|
||||
!media.ServiceAction.StarGiftUnique.Craft || media.ServiceAction.StarGiftUnique.Gift.ID != *resultID {
|
||||
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
gift := media.ServiceAction.StarGiftUnique.Gift
|
||||
result.Gift = &gift
|
||||
output := starGiftCraftOutputIntent{Media: media, Fingerprint: append([]byte(nil), outputFingerprint...),
|
||||
Date: createdAt, SavedGiftID: savedIDs[0]}
|
||||
return result, output, true, nil
|
||||
}
|
||||
|
||||
func chooseCraftedModel(ctx context.Context, tx pgx.Tx, revisionID int64) (int64, error) {
|
||||
|
|
|
|||
|
|
@ -149,7 +149,10 @@ WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxI
|
|||
return nil, 0, fmt.Errorf("enqueue craft input edit: %w", err)
|
||||
}
|
||||
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
|
||||
privateMediaJSON = mediaJSON
|
||||
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,9 +134,18 @@ VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.F
|
|||
locked.PrepaidUpgradeStars, locked.PrepaidUpgradeHash = req.ChargeStars, ""
|
||||
result.Saved, result.Balance = locked, balance
|
||||
return nil
|
||||
}, projectMedia: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) {
|
||||
if result.Saved.Owner.Type != domain.PeerTypeUser {
|
||||
return privateSendMediaProjection{Shared: messageReq.Media, Sender: messageReq.Media, Recipient: messageReq.Media}, nil
|
||||
}
|
||||
return projectPrivateStarGiftSourceRef(ctx, tx, messageReq, result.Saved.Owner.ID, result.Saved.MsgID)
|
||||
}, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
if req.Owner.Type != domain.PeerTypeChannel {
|
||||
return nil
|
||||
if req.Owner.Type == domain.PeerTypeUser {
|
||||
ownerMessageID := sent.RecipientMessage.ID
|
||||
if sent.SenderMessage.OwnerUserID == req.Owner.ID {
|
||||
ownerMessageID = sent.SenderMessage.ID
|
||||
}
|
||||
return registerUserStarGiftMessageRef(ctx, tx, req.Owner.ID, ownerMessageID, result.Saved.ID, 0)
|
||||
}
|
||||
action := messageReq.Media.ServiceAction.StarGift
|
||||
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,
|
||||
|
|
|
|||
|
|
@ -142,6 +142,26 @@ func TestStarGiftStorePostgres(t *testing.T) {
|
|||
if !slices.Equal(gotMsgIDs, wantMsgIDs) {
|
||||
t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs)
|
||||
}
|
||||
// Hiding a pinned gift atomically unpins it and compacts the remaining
|
||||
// vector. Pinning it again makes it visible in the same owner transaction.
|
||||
if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}, true); err != nil || !ok {
|
||||
t.Fatalf("hide pinned gift = %v err %v", ok, err)
|
||||
}
|
||||
hiddenPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
|
||||
if err != nil || !found || !hiddenPinned.Unsaved || hiddenPinned.PinnedOrder != 0 {
|
||||
t.Fatalf("hidden pinned gift = %+v found %v err %v", hiddenPinned, found, err)
|
||||
}
|
||||
remainingPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 102})
|
||||
if err != nil || !found || remainingPinned.PinnedOrder != 1 {
|
||||
t.Fatalf("remaining pin after compaction = %+v found %v err %v", remainingPinned, found, err)
|
||||
}
|
||||
if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil {
|
||||
t.Fatalf("repin hidden gift: %v", err)
|
||||
}
|
||||
repinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
|
||||
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
|
||||
t.Fatalf("repinned hidden gift = %+v found %v err %v", repinned, found, err)
|
||||
}
|
||||
if err := st.SetPinned(ctx, ownerPeer, nil); err != nil {
|
||||
t.Fatalf("clear pinned profile order: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
|
|||
}},
|
||||
}
|
||||
var result domain.StarGiftTransferResult
|
||||
var sourceSaved domain.SavedStarGift
|
||||
hooks := privateSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
|
||||
saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date)
|
||||
|
|
@ -414,6 +415,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
|
|||
if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceSaved = saved
|
||||
unique.Owner = req.To
|
||||
saved.Owner = req.To
|
||||
result.Saved, result.Unique, result.Balance = saved, unique, balance
|
||||
|
|
@ -433,6 +435,9 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
|
|||
can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id,
|
||||
from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), result.Unique.ID,
|
||||
|
|
@ -441,6 +446,10 @@ 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
|
||||
if sourceSaved.Owner.Type == domain.PeerTypeUser {
|
||||
_, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
|
@ -502,6 +511,7 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
|
|||
}
|
||||
var result domain.StarGiftTransferResult
|
||||
var commissionAmount int64
|
||||
var sourceSaved domain.SavedStarGift
|
||||
hooks := privateSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
|
||||
var listingCurrency, sellerType string
|
||||
|
|
@ -557,12 +567,15 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
|
|||
gift.ResellAmount = nil
|
||||
gift.LastSaleDate = req.Date
|
||||
gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount}
|
||||
sourceSaved = saved
|
||||
saved.Owner = req.To
|
||||
if req.To.Type == domain.PeerTypeChannel {
|
||||
saved.MsgID, saved.SavedID = 0, saved.ID
|
||||
}
|
||||
result.Saved, result.Unique, result.Balance = saved, gift, balance
|
||||
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved)
|
||||
resaleAmount := req.Amount
|
||||
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
|
||||
return nil
|
||||
},
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
|
|
@ -578,6 +591,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
|
|||
WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, result.Unique.ID, string(seller.Type), seller.ID,
|
||||
|
|
@ -601,6 +619,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
|
|||
}
|
||||
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, savedID, msgID, req.Date
|
||||
result.Saved.FromUserID = messageSenderID
|
||||
if sourceSaved.Owner.Type == domain.PeerTypeUser {
|
||||
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return updateStarGiftResaleProjection(ctx, tx, result.Unique.GiftID)
|
||||
},
|
||||
}
|
||||
|
|
@ -803,7 +826,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
offer.Gift = gift
|
||||
actionKind := domain.MessageServiceActionStarGiftUnique
|
||||
action := &domain.MessageServiceAction{Kind: actionKind, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: gift, FromUserID: req.OwnerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: offer.BuyerUserID},
|
||||
Gift: gift, FromUserID: req.OwnerUserID,
|
||||
Transferred: true, FromOffer: true, Saved: true,
|
||||
}}
|
||||
if req.Decline {
|
||||
|
|
@ -816,6 +839,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
|
||||
var result domain.StarGiftOfferResult
|
||||
var commissionAmount int64
|
||||
var sourceSaved domain.SavedStarGift
|
||||
hooks := privateSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
|
||||
locked, err := scanStarGiftOffer(tx.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id,
|
||||
|
|
@ -874,10 +898,15 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
current.LastSaleDate = req.Date
|
||||
current.LastSaleAmount = &locked.Price
|
||||
locked.Status, locked.ResolvedAt, locked.Gift = "accepted", req.Date, current
|
||||
sourceSaved = saved
|
||||
saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID}
|
||||
result.Offer = locked
|
||||
result.Unique = current
|
||||
result.Saved = saved
|
||||
send.Media.ServiceAction.StarGiftUnique.Gift = current
|
||||
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(current, req.OwnerUserID, saved.Owner, saved)
|
||||
send.Media.ServiceAction.StarGiftUnique.FromOffer = true
|
||||
resaleAmount := locked.Price
|
||||
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
|
||||
return nil
|
||||
},
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
|
|
@ -890,6 +919,10 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
WHERE id=$1`, result.Saved.ID, result.Offer.BuyerUserID, req.OwnerUserID, msgID, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, result.Offer.BuyerUserID, msgID,
|
||||
result.Saved.ID, result.Unique.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: result.Offer.BuyerUserID}
|
||||
result.Saved.FromUserID, result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = req.OwnerUserID, msgID, 0, msgID, req.Date
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id,
|
||||
|
|
@ -899,6 +932,9 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
|
|||
req.Date, fmt.Sprintf("offer:%d", result.Offer.ID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
return updateStarGiftResaleProjection(ctx, tx, result.Offer.Gift.GiftID)
|
||||
},
|
||||
}
|
||||
|
|
@ -1077,6 +1113,7 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
|
|||
if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceSaved := saved
|
||||
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,updated_at=now() WHERE id=$1`, unique.ID, req.To.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1100,6 +1137,11 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
|
|||
return err
|
||||
}
|
||||
result.Saved, result.Unique, result.Balance = saved, unique, balance
|
||||
if sourceSaved.Owner.Type == domain.PeerTypeUser {
|
||||
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, unique, req.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
|
|
@ -1148,10 +1190,23 @@ func ensureNoStarGiftMarketConflict(ctx context.Context, tx pgx.Tx, uniqueID int
|
|||
}
|
||||
|
||||
func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction {
|
||||
return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: to,
|
||||
SavedID: saved.SavedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt,
|
||||
peer := to
|
||||
savedID := saved.SavedID
|
||||
canCraftAt := saved.CanCraftAt
|
||||
if to.Type == domain.PeerTypeUser {
|
||||
// peer and saved_id are a shared channel-only TL flag. A user-owned
|
||||
// transferred gift is managed by this action message's id.
|
||||
peer = domain.Peer{}
|
||||
savedID = 0
|
||||
} else {
|
||||
// Preserve the durable entitlement for a future transfer back to a user,
|
||||
// but keep channel Craft hidden until its write/update path exists.
|
||||
canCraftAt = 0
|
||||
}
|
||||
return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: peer,
|
||||
SavedID: savedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt,
|
||||
TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt}
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt}
|
||||
}
|
||||
|
||||
func (s *StarGiftLifecycleStore) debitLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount,
|
||||
|
|
@ -1461,15 +1516,22 @@ WHERE provider_request_id=$1 FOR UPDATE`, providerRequestID).Scan(&uniqueID, &ow
|
|||
requestHash := sha256.Sum256([]byte(providerRequestID))
|
||||
giftAddress := fmt.Sprintf("telesrv-gift:%s:%x", unique.Slug, requestHash[:8])
|
||||
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=NULL,owner_peer_id=NULL,
|
||||
owner_address=$2,gift_address=$3,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil {
|
||||
owner_address=$2,gift_address=$3,craft_chance_permille=0,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0 WHERE id=$1`, saved.ID); err != nil {
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0,can_craft_at=0 WHERE id=$1`, saved.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='completed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date); err != nil {
|
||||
return err
|
||||
}
|
||||
unique.Owner = domain.Peer{}
|
||||
unique.OwnerAddress = ownerAddress
|
||||
unique.GiftAddress = giftAddress
|
||||
unique.CraftChancePermille = 0
|
||||
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, saved, unique, date); err != nil {
|
||||
return err
|
||||
}
|
||||
return updateStarGiftResaleProjection(ctx, tx, unique.GiftID)
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/deploy"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -21,10 +24,11 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
|||
offerBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"03", "OfferBuyer", "")
|
||||
resaleBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"04", "ResaleBuyer", "")
|
||||
loser := createTestUser(t, ctx, users, "+1881"+suffix+"05", "AuctionLoser", "")
|
||||
prepayPayer := createTestUser(t, ctx, users, "+1881"+suffix+"06", "PrepayPayer", "")
|
||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
|
||||
stars := NewStarsStore(pool)
|
||||
for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser} {
|
||||
for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser, prepayPayer} {
|
||||
if _, _, err := stars.EnsureGrant(ctx, user.ID, 10000, now); err != nil {
|
||||
t.Fatalf("grant stars to %d: %v", user.ID, err)
|
||||
}
|
||||
|
|
@ -48,12 +52,19 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
|||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Base Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+4, "model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+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},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}},
|
||||
Actor: "integration", CommandID: "lifecycle-pool-" + suffix,
|
||||
}); err != nil {
|
||||
t.Fatalf("publish lifecycle pool: %v", err)
|
||||
|
|
@ -93,32 +104,203 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
|||
t.Fatalf("prepaid target = %+v price %d err %v", target, price, err)
|
||||
}
|
||||
prepaid, err := lifecycle.PrepayStarGiftUpgrade(ctx, domain.StarGiftPrepaidUpgradeRequest{
|
||||
PayerUserID: buyer.ID, Owner: ownerPeer, Hash: purchased.Saved.PrepaidUpgradeHash,
|
||||
PayerUserID: prepayPayer.ID, Owner: ownerPeer, Hash: purchased.Saved.PrepaidUpgradeHash,
|
||||
ChargeStars: 100, FormID: 11002, CommandKey: "prepay-" + suffix, Date: now + 1,
|
||||
})
|
||||
if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9850 {
|
||||
if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9900 {
|
||||
t.Fatalf("prepay upgrade = %+v err %v", prepaid, err)
|
||||
}
|
||||
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID},
|
||||
prepaySenderAction := prepaid.Send.SenderMessage.Media.ServiceAction.StarGift
|
||||
prepayOwnerAction := prepaid.Send.RecipientMessage.Media.ServiceAction.StarGift
|
||||
if prepaySenderAction == nil || prepayOwnerAction == nil ||
|
||||
prepaySenderAction.GiftMsgID != 0 ||
|
||||
prepayOwnerAction.GiftMsgID != purchased.Send.RecipientMessage.ID {
|
||||
t.Fatalf("prepay gift_msg_id is not owner-only: sender=%+v owner=%+v purchase=%+v",
|
||||
prepaySenderAction, prepayOwnerAction, purchased.Send)
|
||||
}
|
||||
prepaySenderDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, prepayPayer.ID, prepaid.Send.SenderMessage.Pts-1, 1)
|
||||
if err != nil || len(prepaySenderDifference) != 1 || prepaySenderDifference[0].Message.Media == nil ||
|
||||
prepaySenderDifference[0].Message.Media.ServiceAction == nil ||
|
||||
prepaySenderDifference[0].Message.Media.ServiceAction.StarGift == nil ||
|
||||
prepaySenderDifference[0].Message.Media.ServiceAction.StarGift.GiftMsgID != 0 {
|
||||
t.Fatalf("payer prepay difference leaked owner-only gift_msg_id: events=%+v err=%v", prepaySenderDifference, err)
|
||||
}
|
||||
prepayOwnerDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, prepaid.Send.RecipientMessage.Pts-1, 1)
|
||||
if err != nil || len(prepayOwnerDifference) != 1 || prepayOwnerDifference[0].Message.Media == nil ||
|
||||
prepayOwnerDifference[0].Message.Media.ServiceAction == nil ||
|
||||
prepayOwnerDifference[0].Message.Media.ServiceAction.StarGift == nil ||
|
||||
prepayOwnerDifference[0].Message.Media.ServiceAction.StarGift.GiftMsgID != purchased.Send.RecipientMessage.ID {
|
||||
t.Fatalf("owner prepay difference lost box-local gift_msg_id: events=%+v err=%v", prepayOwnerDifference, err)
|
||||
}
|
||||
var sharedPrepayMediaJSON string
|
||||
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
|
||||
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
|
||||
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, prepaid.Send.RecipientMessage.ID).Scan(&sharedPrepayMediaJSON); err != nil {
|
||||
t.Fatalf("load shared prepay media: %v", err)
|
||||
}
|
||||
sharedPrepayMedia, err := decodeMessageMedia(sharedPrepayMediaJSON)
|
||||
if err != nil || sharedPrepayMedia == nil || sharedPrepayMedia.ServiceAction == nil ||
|
||||
sharedPrepayMedia.ServiceAction.StarGift == nil || sharedPrepayMedia.ServiceAction.StarGift.GiftMsgID != 0 {
|
||||
t.Fatalf("shared prepay media retained account-local gift_msg_id: media=%+v err=%v", sharedPrepayMedia, err)
|
||||
}
|
||||
if byPrepay, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaid.Send.RecipientMessage.ID}); err != nil || !found || byPrepay.ID != purchased.Saved.ID {
|
||||
t.Fatalf("prepay owner message ref = %+v found=%v err=%v", byPrepay, found, err)
|
||||
}
|
||||
var ownerPrepayAlias, payerPrepayAlias int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, owner.ID, prepaid.Send.RecipientMessage.ID, purchased.Saved.ID).Scan(&ownerPrepayAlias); err != nil {
|
||||
t.Fatalf("load owner prepay alias: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2`, prepayPayer.ID, prepaid.Send.SenderMessage.ID).Scan(&payerPrepayAlias); err != nil {
|
||||
t.Fatalf("load payer prepay alias: %v", err)
|
||||
}
|
||||
if ownerPrepayAlias != 1 || payerPrepayAlias != 0 {
|
||||
t.Fatalf("prepay aliases owner=%d payer=%d, want owner-only", ownerPrepayAlias, payerPrepayAlias)
|
||||
}
|
||||
upgradeReq := domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaid.Send.RecipientMessage.ID},
|
||||
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "upgrade-" + suffix, Date: now + 2,
|
||||
})
|
||||
}
|
||||
upgraded, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade prepaid gift: %v", err)
|
||||
}
|
||||
if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 ||
|
||||
upgraded.Saved.CanCraftAt != now+2 ||
|
||||
upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails {
|
||||
t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique)
|
||||
}
|
||||
readinessTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin craft readiness guard probe: %v", err)
|
||||
}
|
||||
if _, err = readinessTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil {
|
||||
_ = readinessTx.Rollback(ctx)
|
||||
t.Fatalf("stage mismatched craft readiness: %v", err)
|
||||
}
|
||||
if _, err = readinessTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil {
|
||||
_ = readinessTx.Rollback(ctx)
|
||||
t.Fatal("deferred guard accepted positive craft chance with zero readiness")
|
||||
}
|
||||
_ = readinessTx.Rollback(ctx)
|
||||
chanceTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin craft chance guard probe: %v", err)
|
||||
}
|
||||
if _, err = chanceTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil {
|
||||
_ = chanceTx.Rollback(ctx)
|
||||
t.Fatalf("stage mismatched craft chance: %v", err)
|
||||
}
|
||||
if _, err = chanceTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil {
|
||||
_ = chanceTx.Rollback(ctx)
|
||||
t.Fatal("deferred guard accepted positive readiness with zero craft chance")
|
||||
}
|
||||
_ = chanceTx.Rollback(ctx)
|
||||
terminalTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin atomic craft terminal guard probe: %v", err)
|
||||
}
|
||||
if _, err = terminalTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil {
|
||||
_ = terminalTx.Rollback(ctx)
|
||||
t.Fatalf("stage terminal craft chance: %v", err)
|
||||
}
|
||||
if _, err = terminalTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil {
|
||||
_ = terminalTx.Rollback(ctx)
|
||||
t.Fatalf("stage terminal craft readiness: %v", err)
|
||||
}
|
||||
if _, err = terminalTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err != nil {
|
||||
_ = terminalTx.Rollback(ctx)
|
||||
t.Fatalf("deferred guard rejected atomic craft terminal state: %v", err)
|
||||
}
|
||||
if err = terminalTx.Rollback(ctx); err != nil {
|
||||
t.Fatalf("rollback atomic craft terminal guard probe: %v", err)
|
||||
}
|
||||
upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
||||
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
|
||||
if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) ||
|
||||
senderUpgradeAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
|
||||
ownerSourceEdit := upgradedSourceEditForMessage(upgraded, owner.ID, purchased.Saved.MsgID)
|
||||
ownerPrepayEdit := upgradedSourceEditForMessage(upgraded, owner.ID, prepaid.Send.RecipientMessage.ID)
|
||||
payerPrepayEdit := upgradedSourceEditForMessage(upgraded, prepayPayer.ID, prepaid.Send.SenderMessage.ID)
|
||||
if upgradeAction == nil || upgradeAction.SavedID != 0 || upgradeAction.Peer.Type != "" || upgradeAction.Peer.ID != 0 ||
|
||||
upgradeAction.CanCraftAt != now+2 || senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 ||
|
||||
senderUpgradeAction.CanCraftAt != now+2 ||
|
||||
ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade {
|
||||
t.Fatalf("upgrade message linkage = action %+v source edit %+v", upgradeAction, ownerSourceEdit)
|
||||
}
|
||||
if ownerPrepayEdit.Event.Pts <= ownerSourceEdit.Event.Pts || ownerPrepayEdit.Message.Media == nil ||
|
||||
ownerPrepayEdit.Message.Media.ServiceAction == nil || ownerPrepayEdit.Message.Media.ServiceAction.StarGift == nil ||
|
||||
ownerPrepayEdit.Message.Media.ServiceAction.StarGift.CanUpgrade ||
|
||||
ownerPrepayEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Send.RecipientMessage.ID {
|
||||
t.Fatalf("owner prepay card did not converge with upgrade: %+v", ownerPrepayEdit)
|
||||
}
|
||||
if payerPrepayEdit.Event.Pts <= prepaid.Send.SenderMessage.Pts || payerPrepayEdit.Message.Media == nil ||
|
||||
payerPrepayEdit.Message.Media.ServiceAction == nil || payerPrepayEdit.Message.Media.ServiceAction.StarGift == nil ||
|
||||
payerPrepayEdit.Message.Media.ServiceAction.StarGift.CanUpgrade ||
|
||||
payerPrepayEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != 0 {
|
||||
t.Fatalf("third-party payer prepay card retained an owner action/link: %+v", payerPrepayEdit)
|
||||
}
|
||||
ownerUpgradeDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, upgraded.Send.RecipientMessage.Pts-1, 3)
|
||||
if err != nil || len(ownerUpgradeDifference) != 3 ||
|
||||
ownerUpgradeDifference[0].Type != domain.UpdateEventNewMessage ||
|
||||
ownerUpgradeDifference[1].Type != domain.UpdateEventEditMessage || ownerUpgradeDifference[1].Message.ID != purchased.Saved.MsgID ||
|
||||
ownerUpgradeDifference[2].Type != domain.UpdateEventEditMessage || ownerUpgradeDifference[2].Message.ID != prepaid.Send.RecipientMessage.ID {
|
||||
t.Fatalf("owner prepaid upgrade difference = %+v err=%v", ownerUpgradeDifference, err)
|
||||
}
|
||||
payerUpgradeDifference, err := NewUpdateEventStore(pool).ListAfter(ctx, prepayPayer.ID, prepaid.Send.SenderMessage.Pts, 1)
|
||||
if err != nil || len(payerUpgradeDifference) != 1 || payerUpgradeDifference[0].Type != domain.UpdateEventEditMessage ||
|
||||
payerUpgradeDifference[0].Message.ID != prepaid.Send.SenderMessage.ID {
|
||||
t.Fatalf("payer prepaid upgrade difference = %+v err=%v", payerUpgradeDifference, err)
|
||||
}
|
||||
replayedUpgrade, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
|
||||
if err != nil || !replayedUpgrade.Duplicate || replayedUpgrade.Unique.ID != upgraded.Unique.ID ||
|
||||
upgradedSourceEditForMessage(replayedUpgrade, owner.ID, purchased.Saved.MsgID).Event.Pts != ownerSourceEdit.Event.Pts ||
|
||||
upgradedSourceEditForMessage(replayedUpgrade, owner.ID, prepaid.Send.RecipientMessage.ID).Event.Pts != ownerPrepayEdit.Event.Pts {
|
||||
t.Fatalf("replay prepaid upgrade from notification = %+v err=%v", replayedUpgrade, err)
|
||||
}
|
||||
var sharedUpgradeSourceMediaJSON string
|
||||
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
|
||||
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
|
||||
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, purchased.Saved.MsgID).Scan(&sharedUpgradeSourceMediaJSON); err != nil {
|
||||
t.Fatalf("load shared upgraded source media: %v", err)
|
||||
}
|
||||
sharedUpgradeSourceMedia, err := decodeMessageMedia(sharedUpgradeSourceMediaJSON)
|
||||
if err != nil || sharedUpgradeSourceMedia == nil || sharedUpgradeSourceMedia.ServiceAction == nil ||
|
||||
sharedUpgradeSourceMedia.ServiceAction.StarGift == nil ||
|
||||
sharedUpgradeSourceMedia.ServiceAction.StarGift.UpgradeMsgID != 0 ||
|
||||
sharedUpgradeSourceMedia.ServiceAction.StarGift.GiftMsgID != 0 {
|
||||
t.Fatalf("shared upgraded source media retained account-local message id: media=%+v err=%v", sharedUpgradeSourceMedia, err)
|
||||
}
|
||||
var sharedUpgradedPrepayMediaJSON string
|
||||
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
|
||||
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
|
||||
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, prepaid.Send.RecipientMessage.ID).Scan(&sharedUpgradedPrepayMediaJSON); err != nil {
|
||||
t.Fatalf("load shared upgraded prepay media: %v", err)
|
||||
}
|
||||
sharedUpgradedPrepayMedia, err := decodeMessageMedia(sharedUpgradedPrepayMediaJSON)
|
||||
if err != nil || sharedUpgradedPrepayMedia == nil || sharedUpgradedPrepayMedia.ServiceAction == nil ||
|
||||
sharedUpgradedPrepayMedia.ServiceAction.StarGift == nil ||
|
||||
sharedUpgradedPrepayMedia.ServiceAction.StarGift.CanUpgrade ||
|
||||
sharedUpgradedPrepayMedia.ServiceAction.StarGift.PrepaidUpgradeHash != "" ||
|
||||
sharedUpgradedPrepayMedia.ServiceAction.StarGift.UpgradeMsgID != 0 ||
|
||||
sharedUpgradedPrepayMedia.ServiceAction.StarGift.GiftMsgID != 0 {
|
||||
t.Fatalf("shared upgraded prepay media retained an action or account-local id: media=%+v err=%v", sharedUpgradedPrepayMedia, err)
|
||||
}
|
||||
verifyPrepaidMessageRefMigration(t, ctx, pool, purchased.Saved.ID, owner.ID, prepayPayer.ID,
|
||||
prepaid.Send.RecipientMessage.ID, prepaid.Send.SenderMessage.ID, upgraded.Send.RecipientMessage.ID)
|
||||
var sharedUpgradeMediaJSON string
|
||||
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
|
||||
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
|
||||
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, upgraded.Send.RecipientMessage.ID).Scan(&sharedUpgradeMediaJSON); err != nil {
|
||||
t.Fatalf("load shared upgrade media: %v", err)
|
||||
}
|
||||
sharedUpgradeMedia, err := decodeMessageMedia(sharedUpgradeMediaJSON)
|
||||
if err != nil || sharedUpgradeMedia == nil || sharedUpgradeMedia.ServiceAction == nil ||
|
||||
sharedUpgradeMedia.ServiceAction.StarGiftUnique == nil || sharedUpgradeMedia.ServiceAction.StarGiftUnique.SavedID != 0 {
|
||||
t.Fatalf("shared upgrade media retained account-local saved_id: media=%+v err=%v", sharedUpgradeMedia, err)
|
||||
}
|
||||
dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID},
|
||||
ChargeStars: 25, FormID: 11003, CommandKey: "drop-" + suffix, Date: now + 3,
|
||||
|
|
@ -248,6 +430,47 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
|||
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
|
||||
t.Fatalf("paid transfer = %+v err %v", transferred, err)
|
||||
}
|
||||
const historicalOwnerMessageID = 2_147_483_000
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
|
||||
VALUES($1,$2,$3)`, resaleBuyer.ID, historicalOwnerMessageID, transferred.Saved.ID); err != nil {
|
||||
t.Fatalf("insert historical old-owner message ref: %v", err)
|
||||
}
|
||||
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{{
|
||||
Owner: ownerPeer, MsgID: historicalOwnerMessageID,
|
||||
}}); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
t.Fatalf("current owner resolved another owner's historical message ref: %v", err)
|
||||
}
|
||||
var retiredSourceMediaJSON string
|
||||
var retiredSourcePTS int
|
||||
if err := pool.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, resaleBuyer.ID, resold.Saved.MsgID).
|
||||
Scan(&retiredSourceMediaJSON, &retiredSourcePTS); err != nil {
|
||||
t.Fatalf("load retired transfer source projection: %v", err)
|
||||
}
|
||||
retiredSourceMedia, err := decodeMessageMedia(retiredSourceMediaJSON)
|
||||
if err != nil || retiredSourceMedia == nil || retiredSourceMedia.ServiceAction == nil ||
|
||||
retiredSourceMedia.ServiceAction.StarGiftUnique == nil {
|
||||
t.Fatalf("decode retired transfer source projection: media=%+v err=%v", retiredSourceMedia, err)
|
||||
}
|
||||
retiredSourceAction := retiredSourceMedia.ServiceAction.StarGiftUnique
|
||||
if retiredSourceAction.Gift.Owner != ownerPeer || retiredSourceAction.Gift.CraftChancePermille != 0 ||
|
||||
!retiredSourceAction.Transferred || retiredSourceAction.Saved || retiredSourceAction.CanCraftAt != 0 ||
|
||||
retiredSourceAction.CanExportAt != 0 || retiredSourceAction.TransferStars != 0 ||
|
||||
retiredSourceAction.CanTransferAt != 0 || retiredSourceAction.CanResellAt != 0 ||
|
||||
retiredSourceAction.DropOriginalDetailsStars != 0 || retiredSourceAction.ResaleAmount != nil {
|
||||
t.Fatalf("retired transfer source remained actionable: %+v", retiredSourceAction)
|
||||
}
|
||||
var retiredEventCount, retiredOutboxCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
|
||||
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, resaleBuyer.ID, retiredSourcePTS, resold.Saved.MsgID).
|
||||
Scan(&retiredEventCount); err != nil || retiredEventCount != 1 {
|
||||
t.Fatalf("retired transfer source event count=%d err=%v", retiredEventCount, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, resaleBuyer.ID, retiredSourcePTS).
|
||||
Scan(&retiredOutboxCount); err != nil || retiredOutboxCount != 1 {
|
||||
t.Fatalf("retired transfer source outbox count=%d err=%v", retiredOutboxCount, err)
|
||||
}
|
||||
clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID)
|
||||
if err != nil || !found || !clearedUser.EmojiStatus().Empty() {
|
||||
t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err)
|
||||
|
|
@ -322,13 +545,13 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
|
|||
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID},
|
||||
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
|
||||
}); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err)
|
||||
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("duplicate upgrade output/slug identities err = %v", err)
|
||||
}
|
||||
if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
|
||||
Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID,
|
||||
}); err != nil || found {
|
||||
t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}); err != nil || !found || saved.ID != secondUpgrade.Saved.ID {
|
||||
t.Fatalf("upgrade output message id failed to resolve: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}
|
||||
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID},
|
||||
|
|
@ -347,6 +570,15 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
|
|||
if err != nil || !crafted.Success || crafted.Chance != 1000 || crafted.Gift == nil || !crafted.Gift.Crafted || crafted.Send.RecipientMessage.ID <= 0 {
|
||||
t.Fatalf("craft result = %+v err %v", crafted, err)
|
||||
}
|
||||
craftOutputAction := crafted.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
|
||||
if craftOutputAction == nil || craftOutputAction.Peer.Type != "" || craftOutputAction.Peer.ID != 0 || craftOutputAction.SavedID != 0 || !craftOutputAction.Craft {
|
||||
t.Fatalf("craft output action leaked channel identity: %+v", craftOutputAction)
|
||||
}
|
||||
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
|
||||
Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID,
|
||||
}); err != nil || !found || byOutput.ID != transferred.Saved.ID || byOutput.UniqueGiftID != crafted.Gift.ID {
|
||||
t.Fatalf("craft output message ref = %+v found %v err %v", byOutput, found, err)
|
||||
}
|
||||
craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID)
|
||||
craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit)
|
||||
burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID)
|
||||
|
|
@ -359,6 +591,21 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
|
|||
burnedInputAction.Saved || burnedInputAction.CanCraftAt != 0 {
|
||||
t.Fatalf("burned input message projection = %+v", burnedInputAction)
|
||||
}
|
||||
for _, edit := range []domain.EditedMessageForUser{craftedInputEdit, burnedInputEdit} {
|
||||
var sharedCraftInputMediaJSON string
|
||||
if err := pool.QueryRow(ctx, `SELECT p.media::text FROM private_messages p
|
||||
JOIN message_boxes b ON b.message_sender_id=p.sender_user_id AND b.private_message_id=p.id
|
||||
WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&sharedCraftInputMediaJSON); err != nil {
|
||||
t.Fatalf("load shared craft input media for box %d: %v", edit.Message.ID, err)
|
||||
}
|
||||
sharedCraftInputMedia, err := decodeMessageMedia(sharedCraftInputMediaJSON)
|
||||
if err != nil || sharedCraftInputMedia == nil || sharedCraftInputMedia.ServiceAction == nil ||
|
||||
sharedCraftInputMedia.ServiceAction.StarGiftUnique == nil ||
|
||||
sharedCraftInputMedia.ServiceAction.StarGiftUnique.SavedID != 0 {
|
||||
t.Fatalf("shared craft input retained account-local saved_id for box %d: media=%+v err=%v",
|
||||
edit.Message.ID, sharedCraftInputMedia, err)
|
||||
}
|
||||
}
|
||||
craftReq := domain.StarGiftCraftRequest{UserID: owner.ID,
|
||||
Refs: []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
|
||||
|
|
@ -372,6 +619,40 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
|
|||
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts {
|
||||
t.Fatalf("craft success replay = %+v err %v", craftedReplay, err)
|
||||
}
|
||||
craftOutputRef := domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID}
|
||||
if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, true); err != nil || !changed {
|
||||
t.Fatalf("hide crafted output before replay: changed=%v err=%v", changed, err)
|
||||
}
|
||||
hiddenReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
|
||||
if err != nil || !hiddenReplay.Duplicate || hiddenReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID {
|
||||
t.Fatalf("craft replay after hide = %+v err %v", hiddenReplay, err)
|
||||
}
|
||||
if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, false); err != nil || !changed {
|
||||
t.Fatalf("restore crafted output before listing replay: changed=%v err=%v", changed, err)
|
||||
}
|
||||
listedCraftOutput, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
|
||||
Ref: craftOutputRef, Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 148,
|
||||
})
|
||||
if err != nil || listedCraftOutput.ResellAmount == nil || listedCraftOutput.ResellAmount.Amount != 125 {
|
||||
t.Fatalf("list crafted output before replay = %+v err %v", listedCraftOutput, err)
|
||||
}
|
||||
listedReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
|
||||
if err != nil || !listedReplay.Duplicate || listedReplay.Gift == nil || listedReplay.Gift.ResellAmount != nil ||
|
||||
listedReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID {
|
||||
t.Fatalf("craft replay after listing did not use frozen output = %+v err %v", listedReplay, err)
|
||||
}
|
||||
if _, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
|
||||
Ref: craftOutputRef, Date: now + 149,
|
||||
}); err != nil {
|
||||
t.Fatalf("remove crafted output listing: %v", err)
|
||||
}
|
||||
var outputReceiptMedia string
|
||||
var outputReceiptFingerprint []byte
|
||||
if err := pool.QueryRow(ctx, `SELECT output_media::text,output_fingerprint FROM star_gift_craft_commands
|
||||
WHERE user_id=$1 AND command_key=$2`, owner.ID, craftReq.CommandKey).Scan(&outputReceiptMedia, &outputReceiptFingerprint); err != nil ||
|
||||
outputReceiptMedia == "" || len(outputReceiptFingerprint) != 32 {
|
||||
t.Fatalf("craft immutable output receipt: media=%q fingerprint=%d err=%v", outputReceiptMedia, len(outputReceiptFingerprint), err)
|
||||
}
|
||||
var craftListings, resaleAvailability int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`,
|
||||
[]int64{transferred.Unique.ID, secondUpgrade.Unique.ID}).Scan(&craftListings); err != nil || craftListings != 0 {
|
||||
|
|
@ -413,7 +694,7 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
|
|||
WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}),
|
||||
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
|
||||
failureReq := domain.StarGiftCraftRequest{UserID: owner.ID,
|
||||
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.MsgID}},
|
||||
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.UpgradeMsgID}},
|
||||
CommandKey: "craft-fail-" + suffix, Date: now + 150,
|
||||
}
|
||||
failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq)
|
||||
|
|
@ -452,6 +733,11 @@ can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHER
|
|||
craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts {
|
||||
t.Fatalf("craft failure replay = %+v err %v", failedReplay, err)
|
||||
}
|
||||
wrongAliasReplay := failureReq
|
||||
wrongAliasReplay.Refs = []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}}
|
||||
if _, err := failingLifecycle.CraftStarGift(ctx, wrongAliasReplay); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
|
||||
t.Fatalf("craft replay accepted another aggregate alias: %v", err)
|
||||
}
|
||||
invalidRetry := failureReq
|
||||
invalidRetry.CommandKey = "craft-fail-new-command-" + suffix
|
||||
if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
|
||||
|
|
@ -555,13 +841,24 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
|
|||
}
|
||||
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}},
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "channel-crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-crafted"), Animation: collectibleTestAnimationPtr("channel-crafted.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+4, "channel-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "channel-model-two"), Animation: collectibleTestAnimationPtr("channel-model-two.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "channel-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+5, "channel-pattern-two"), Animation: collectibleTestAnimationPtr("channel-pattern-two.tgs")},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop Two", BackdropID: 89, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
|
||||
},
|
||||
Actor: "integration", CommandID: "channel-gift-pool-" + suffix,
|
||||
}); err != nil {
|
||||
t.Fatalf("publish channel gift pool: %v", err)
|
||||
|
|
@ -687,7 +984,9 @@ WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel
|
|||
}
|
||||
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
||||
if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer ||
|
||||
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 {
|
||||
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)
|
||||
}
|
||||
var ptsAfterUpgrade int
|
||||
|
|
@ -731,7 +1030,9 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
|
|||
}
|
||||
resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
|
||||
if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer ||
|
||||
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 {
|
||||
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 ||
|
||||
resold.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
|
||||
resold.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
|
||||
t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err)
|
||||
}
|
||||
var channelTON, channelTONTxns, targetResaleLogs, commission int64
|
||||
|
|
@ -763,6 +1064,36 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
|
|||
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 {
|
||||
t.Fatalf("channel TON proceeds after replay = %d err %v", channelTON, err)
|
||||
}
|
||||
toUser, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{
|
||||
ActorUserID: actor.ID,
|
||||
Ref: domain.SavedStarGiftRef{
|
||||
Owner: targetChannelPeer, SavedID: resold.Saved.SavedID,
|
||||
},
|
||||
To: domain.Peer{Type: domain.PeerTypeUser, ID: actor.ID}, ChargeStars: resold.Saved.TransferStars,
|
||||
CommandKey: "channel-craft-entitlement-to-user-" + suffix, Date: now + 8,
|
||||
})
|
||||
if err != nil || toUser.Saved.Owner.Type != domain.PeerTypeUser || toUser.Saved.Owner.ID != actor.ID ||
|
||||
toUser.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
|
||||
toUser.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
|
||||
t.Fatalf("channel-to-user Craft entitlement transfer = %+v err %v", toUser, err)
|
||||
}
|
||||
toUserAction := toUser.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
|
||||
if toUserAction == nil || toUserAction.CanCraftAt != upgraded.Saved.CanCraftAt {
|
||||
t.Fatalf("channel-to-user action did not restore Craft readiness: %+v", toUserAction)
|
||||
}
|
||||
backToChannel, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{
|
||||
ActorUserID: actor.ID,
|
||||
Ref: domain.SavedStarGiftRef{
|
||||
Owner: toUser.Saved.Owner, MsgID: toUser.Saved.MsgID,
|
||||
},
|
||||
To: channelPeer, ChargeStars: toUser.Saved.TransferStars,
|
||||
CommandKey: "user-craft-entitlement-to-channel-" + suffix, Date: now + 9,
|
||||
})
|
||||
if err != nil || backToChannel.Saved.Owner != channelPeer ||
|
||||
backToChannel.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
|
||||
backToChannel.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
|
||||
t.Fatalf("user-to-channel Craft entitlement transfer = %+v err %v", backToChannel, err)
|
||||
}
|
||||
|
||||
var remainsBefore int
|
||||
if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsBefore); err != nil {
|
||||
|
|
@ -816,6 +1147,125 @@ WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channe
|
|||
}
|
||||
}
|
||||
|
||||
func TestStarGiftCraftFailureConsumesThreeInputsPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
now := int(time.Now().Unix())
|
||||
users := NewUserStore(pool)
|
||||
buyer := createTestUser(t, ctx, users, "+1883"+suffix+"01", "CraftBuyer", "")
|
||||
owner := createTestUser(t, ctx, users, "+1883"+suffix+"02", "CraftOwner", "")
|
||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
stars := NewStarsStore(pool)
|
||||
for _, userID := range []int64{buyer.ID, owner.ID} {
|
||||
if _, _, err := stars.EnsureGrant(ctx, userID, 10000, now); err != nil {
|
||||
t.Fatalf("grant craft stars to %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
gifts := NewStarGiftStore(pool)
|
||||
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Three Input Craft " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
|
||||
Document: collectibleTestDocument(baseDocumentID, "three-input.tgs"),
|
||||
Blob: collectibleTestBlob(baseDocumentID, "three-input"), Animation: collectibleTestAnimation("three-input.tgs"),
|
||||
Actor: "integration", CommandID: "three-input-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create three-input catalog: %v", err)
|
||||
}
|
||||
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "three-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "base.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "base"), Animation: collectibleTestAnimationPtr("base.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Base Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+4, "base-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "base-two"), Animation: collectibleTestAnimationPtr("base-two.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 88, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 89, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
|
||||
},
|
||||
Actor: "integration", CommandID: "three-input-pool-" + suffix,
|
||||
}); err != nil {
|
||||
t.Fatalf("publish three-input collectible: %v", err)
|
||||
}
|
||||
|
||||
messages := NewMessageStore(pool)
|
||||
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000,
|
||||
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
|
||||
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
||||
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250,
|
||||
}))
|
||||
refs := make([]domain.SavedStarGiftRef, 0, 3)
|
||||
uniqueIDs := make([]int64, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{
|
||||
BuyerUserID: buyer.ID, To: ownerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true,
|
||||
CommandKey: fmt.Sprintf("three-input-purchase-%s-%d", suffix, i), Date: now + i,
|
||||
})
|
||||
purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
|
||||
if err != nil {
|
||||
t.Fatalf("purchase three-input gift %d: %v", i, err)
|
||||
}
|
||||
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, RequirePrepaid: true,
|
||||
CommandKey: fmt.Sprintf("three-input-upgrade-%s-%d", suffix, i), Date: now + 10 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade three-input gift %d: %v", i, err)
|
||||
}
|
||||
refs = append(refs, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: upgraded.Saved.UpgradeMsgID})
|
||||
uniqueIDs = append(uniqueIDs, upgraded.Unique.ID)
|
||||
}
|
||||
req := domain.StarGiftCraftRequest{UserID: owner.ID, Refs: refs,
|
||||
CommandKey: "three-input-craft-fail-" + suffix, Date: now + 20}
|
||||
failed, err := lifecycle.CraftStarGift(ctx, req)
|
||||
if err != nil || failed.Success || failed.Chance != 750 || failed.Gift != nil {
|
||||
t.Fatalf("three-input craft failure = %+v err=%v", failed, err)
|
||||
}
|
||||
for _, uniqueID := range uniqueIDs {
|
||||
edit := craftedSourceEditForUserAndGift(failed, owner.ID, uniqueID)
|
||||
action := starGiftUniqueActionFromEdit(edit)
|
||||
if edit.Event.Pts <= 0 || action == nil || !action.Gift.Burned || action.Gift.CraftChancePermille != 0 ||
|
||||
action.Saved || action.CanCraftAt != 0 {
|
||||
t.Fatalf("three-input terminal edit for %d = %+v", uniqueID, edit)
|
||||
}
|
||||
var burned bool
|
||||
var status string
|
||||
if err := pool.QueryRow(ctx, `SELECT u.burned,p.lifecycle_status
|
||||
FROM unique_star_gifts u JOIN peer_star_gifts p ON p.unique_gift_id=u.id WHERE u.id=$1`, uniqueID).
|
||||
Scan(&burned, &status); err != nil || !burned || status != "burned" {
|
||||
t.Fatalf("three-input terminal aggregate %d burned=%v status=%q err=%v", uniqueID, burned, status, err)
|
||||
}
|
||||
}
|
||||
var sourcePTS []int32
|
||||
var outputMedia, outputFingerprint []byte
|
||||
if err := pool.QueryRow(ctx, `SELECT source_edit_pts,output_media,output_fingerprint
|
||||
FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, owner.ID, req.CommandKey).
|
||||
Scan(&sourcePTS, &outputMedia, &outputFingerprint); err != nil || len(sourcePTS) != 3 ||
|
||||
len(outputMedia) != 0 || len(outputFingerprint) != 0 {
|
||||
t.Fatalf("three-input failure receipt pts=%v media=%d fingerprint=%d err=%v", sourcePTS, len(outputMedia), len(outputFingerprint), err)
|
||||
}
|
||||
replay, err := lifecycle.CraftStarGift(ctx, req)
|
||||
if err != nil || !replay.Duplicate || replay.Success || replay.Chance != 750 {
|
||||
t.Fatalf("three-input failure replay = %+v err=%v", replay, err)
|
||||
}
|
||||
for i, uniqueID := range uniqueIDs {
|
||||
if edit := craftedSourceEditForUserAndGift(replay, owner.ID, uniqueID); edit.Event.Pts != int(sourcePTS[i]) {
|
||||
t.Fatalf("three-input replay pts for %d = %d want %d", uniqueID, edit.Event.Pts, sourcePTS[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore,
|
||||
req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest {
|
||||
t.Helper()
|
||||
|
|
@ -844,6 +1294,100 @@ func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *St
|
|||
return req
|
||||
}
|
||||
|
||||
func upgradedSourceEditForMessage(result domain.StarGiftUpgradeResult, userID int64, messageID int) domain.EditedMessageForUser {
|
||||
for _, edit := range result.SourceEdits {
|
||||
if edit.UserID == userID && edit.Message.ID == messageID {
|
||||
return edit
|
||||
}
|
||||
}
|
||||
return domain.EditedMessageForUser{UserID: userID}
|
||||
}
|
||||
|
||||
func verifyPrepaidMessageRefMigration(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
savedGiftID int64,
|
||||
ownerUserID int64,
|
||||
payerUserID int64,
|
||||
ownerPrepayMessageID int,
|
||||
payerPrepayMessageID int,
|
||||
ownerUpgradeMessageID int,
|
||||
) {
|
||||
t.Helper()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin prepaid message migration probe: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
|
||||
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, ownerPrepayMessageID).
|
||||
Scan(&messageSenderID, &privateMessageID); err != nil {
|
||||
t.Fatalf("load prepaid message root for migration probe: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, ownerUserID, ownerPrepayMessageID, savedGiftID); err != nil {
|
||||
t.Fatalf("remove prepaid alias for migration probe: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE message_boxes
|
||||
SET media=jsonb_set(media #- '{service_action,star_gift,upgrade_msg_id}',
|
||||
'{service_action,star_gift,can_upgrade}','true'::jsonb,true)
|
||||
WHERE message_sender_id=$1 AND private_message_id=$2 AND NOT deleted`, messageSenderID, privateMessageID); err != nil {
|
||||
t.Fatalf("restore stale prepaid message boxes for migration probe: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE private_messages
|
||||
SET media=jsonb_set(media #- '{service_action,star_gift,upgrade_msg_id}',
|
||||
'{service_action,star_gift,can_upgrade}','true'::jsonb,true)
|
||||
WHERE sender_user_id=$1 AND id=$2`, messageSenderID, privateMessageID); err != nil {
|
||||
t.Fatalf("restore stale shared prepaid message for migration probe: %v", err)
|
||||
}
|
||||
|
||||
migrationSQL, err := deploy.Migrations.ReadFile("migrations/0135_star_gift_prepaid_message_refs.up.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read prepaid message migration: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(migrationSQL)); err != nil {
|
||||
t.Fatalf("apply prepaid message migration probe: %v", err)
|
||||
}
|
||||
|
||||
var aliasCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, ownerUserID, ownerPrepayMessageID, savedGiftID).Scan(&aliasCount); err != nil || aliasCount != 1 {
|
||||
t.Fatalf("migrated prepaid alias count=%d err=%v", aliasCount, err)
|
||||
}
|
||||
assertMigratedAction := func(userID int64, messageID int, wantUpgradeMessageID int) {
|
||||
t.Helper()
|
||||
var mediaJSON string
|
||||
var pts int
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, userID, messageID).Scan(&mediaJSON, &pts); err != nil {
|
||||
t.Fatalf("load migrated prepaid box %d/%d: %v", userID, messageID, err)
|
||||
}
|
||||
media, err := decodeMessageMedia(mediaJSON)
|
||||
if err != nil || media == nil || media.ServiceAction == nil || media.ServiceAction.StarGift == nil ||
|
||||
media.ServiceAction.StarGift.CanUpgrade || media.ServiceAction.StarGift.PrepaidUpgradeHash != "" ||
|
||||
media.ServiceAction.StarGift.UpgradeMsgID != wantUpgradeMessageID {
|
||||
t.Fatalf("migrated prepaid box %d/%d = %+v err=%v", userID, messageID, media, err)
|
||||
}
|
||||
var eventCount, outboxCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
|
||||
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, userID, pts, messageID).Scan(&eventCount); err != nil {
|
||||
t.Fatalf("load migrated prepaid event: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).Scan(&outboxCount); err != nil {
|
||||
t.Fatalf("load migrated prepaid outbox: %v", err)
|
||||
}
|
||||
if eventCount != 1 || outboxCount != 1 {
|
||||
t.Fatalf("migrated prepaid event/outbox counts=%d/%d", eventCount, outboxCount)
|
||||
}
|
||||
}
|
||||
assertMigratedAction(ownerUserID, ownerPrepayMessageID, ownerUpgradeMessageID)
|
||||
assertMigratedAction(payerUserID, payerPrepayMessageID, 0)
|
||||
}
|
||||
|
||||
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 != 20260714003082 {
|
||||
t.Fatalf("migration status = %+v, want clean version 20260714003082", status)
|
||||
if status.Dirty || status.Empty || status.Version != 20260714003097 {
|
||||
t.Fatalf("migration status = %+v, want clean version 20260714003097", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
196
internal/store/postgres/star_gift_lifecycle_projection.go
Normal file
196
internal/store/postgres/star_gift_lifecycle_projection.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// retireUserStarGiftMessagesTx closes every user-scoped unique-gift action
|
||||
// emitted for the source ownership epoch. Ownership moves and terminal export
|
||||
// must not leave an older chat card with Craft/transfer/resale capabilities.
|
||||
// The aggregate mutation and all message edits share one transaction and each
|
||||
// visible box receives its own durable pts/event/outbox entry.
|
||||
func (s *StarGiftLifecycleStore) retireUserStarGiftMessagesTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
source domain.SavedStarGift,
|
||||
current domain.UniqueStarGift,
|
||||
date int,
|
||||
) ([]domain.EditedMessageForUser, error) {
|
||||
if s == nil || s.messages == nil || source.Owner.Type != domain.PeerTypeUser || source.Owner.ID <= 0 ||
|
||||
source.ID <= 0 || source.UniqueGiftID <= 0 || current.ID != source.UniqueGiftID || date <= 0 {
|
||||
return nil, domain.ErrStarGiftTransferUnavailable
|
||||
}
|
||||
|
||||
messageIDs := map[int]struct{}{}
|
||||
if source.MsgID > 0 {
|
||||
messageIDs[source.MsgID] = struct{}{}
|
||||
}
|
||||
if source.UpgradeMsgID > 0 {
|
||||
messageIDs[source.UpgradeMsgID] = struct{}{}
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT msg_id FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND saved_gift_id=$2
|
||||
ORDER BY msg_id`, source.Owner.ID, source.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gift message projections: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var msgID int
|
||||
if err := rows.Scan(&msgID); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan star gift message projection: %w", err)
|
||||
}
|
||||
if msgID > 0 {
|
||||
messageIDs[msgID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("iterate star gift message projections: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
ids := make([]int, 0, len(messageIDs))
|
||||
for msgID := range messageIDs {
|
||||
ids = append(ids, msgID)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
|
||||
q := sqlcgen.New(tx)
|
||||
edits := make([]domain.EditedMessageForUser, 0, len(ids)*2)
|
||||
seenPrivateMessages := make(map[string]struct{}, len(ids))
|
||||
for _, msgID := range ids {
|
||||
var peerType string
|
||||
var peerID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT peer_type,peer_id FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted
|
||||
FOR UPDATE`, source.Owner.ID, msgID).Scan(&peerType, &peerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock star gift message projection: %w", err)
|
||||
}
|
||||
if peerType != string(domain.PeerTypeUser) || peerID <= 0 {
|
||||
return nil, fmt.Errorf("star gift message projection %d is not private", msgID)
|
||||
}
|
||||
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
|
||||
OwnerUserID: source.Owner.ID, BoxID: int32(msgID), PeerType: peerType, PeerID: peerID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load star gift message projection: %w", err)
|
||||
}
|
||||
logicalKey := fmt.Sprintf("%d:%d", target.MessageSenderID, target.PrivateMessageID)
|
||||
if _, duplicate := seenPrivateMessages[logicalKey]; duplicate {
|
||||
continue
|
||||
}
|
||||
seenPrivateMessages[logicalKey] = struct{}{}
|
||||
|
||||
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
|
||||
OwnerUserIds: privateMessageOwnerIDs(source.Owner.ID, peerID),
|
||||
MessageSenderID: target.MessageSenderID, PrivateMessageID: target.PrivateMessageID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list visible star gift message projections: %w", err)
|
||||
}
|
||||
var privateMediaJSON []byte
|
||||
matched := false
|
||||
for _, box := range boxes {
|
||||
media, err := decodeMessageMedia(box.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode star gift message projection: %w", err)
|
||||
}
|
||||
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
|
||||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
|
||||
media.ServiceAction.StarGiftUnique.Gift.ID != current.ID {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
action := media.ServiceAction.StarGiftUnique
|
||||
retiredGift := current
|
||||
retiredGift.CraftChancePermille = 0
|
||||
retiredGift.ResellAmount = nil
|
||||
action.Gift = retiredGift
|
||||
action.Peer = domain.Peer{}
|
||||
action.SavedID = 0
|
||||
action.Saved = false
|
||||
if validLifecyclePeer(current.Owner) && current.Owner != source.Owner {
|
||||
action.Transferred = true
|
||||
}
|
||||
action.CanExportAt = 0
|
||||
action.TransferStars = 0
|
||||
action.ResaleAmount = nil
|
||||
action.CanTransferAt = 0
|
||||
action.CanResellAt = 0
|
||||
action.DropOriginalDetailsStars = 0
|
||||
action.CanCraftAt = 0
|
||||
|
||||
mediaJSON, err := encodeMessageMedia(media)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode retired star gift projection: %w", err)
|
||||
}
|
||||
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocate retired star gift pts: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE message_boxes SET media=$3,pts=$4
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update retired star gift projection: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fmt.Errorf("update retired star gift projection lost row")
|
||||
}
|
||||
msg, err := messageFromVisibleBoxRow(box)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Media = media
|
||||
msg.Pts = pts
|
||||
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
|
||||
Pts: pts, PtsCount: 1, Date: date, Message: msg}
|
||||
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
|
||||
return nil, fmt.Errorf("append retired star gift edit event: %w", err)
|
||||
}
|
||||
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
|
||||
ExcludeAuthKeyID: 0, ExcludeSessionID: 0,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("enqueue retired star gift edit: %w", err)
|
||||
}
|
||||
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
|
||||
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if len(privateMediaJSON) == 0 {
|
||||
return nil, fmt.Errorf("retired star gift projection missing shared media")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE private_messages SET media=$3
|
||||
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
|
||||
return nil, fmt.Errorf("update retired star gift private media: %w", err)
|
||||
}
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
115
internal/store/postgres/star_gift_lifecycle_test.go
Normal file
115
internal/store/postgres/star_gift_lifecycle_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
|
||||
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
|
||||
unique := domain.UniqueStarGift{ID: 7}
|
||||
user := domain.Peer{Type: domain.PeerTypeUser, ID: 100}
|
||||
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 200}
|
||||
|
||||
if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 || action.CanCraftAt != saved.CanCraftAt {
|
||||
t.Fatalf("user transfer action leaked channel saved_id: %+v", action)
|
||||
}
|
||||
if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID || action.CanCraftAt != 0 {
|
||||
t.Fatalf("channel transfer action lost channel saved_id: %+v", action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftCraftReadyAt(t *testing.T) {
|
||||
const date = 1_780_000_000
|
||||
if got := starGiftCraftReadyAt(date, 0); got != date {
|
||||
t.Fatalf("zero-delay craft ready_at = %d, want %d", got, date)
|
||||
}
|
||||
if got := starGiftCraftReadyAt(date, 60); got != date+60 {
|
||||
t.Fatalf("delayed craft ready_at = %d, want %d", got, date+60)
|
||||
}
|
||||
if got := starGiftCraftReadyAt(0, 0); got != 0 {
|
||||
t.Fatalf("invalid-date craft ready_at = %d, want 0", got)
|
||||
}
|
||||
if got := starGiftCraftReadyAt(1<<31-10, 60); got != 1<<31-1 {
|
||||
t.Fatalf("overflow craft ready_at = %d, want max int32", got)
|
||||
}
|
||||
if got := starGiftCraftReadyAt(1<<31+10, 0); got != 1<<31-1 {
|
||||
t.Fatalf("oversized-date craft ready_at = %d, want max int32", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSharedPrivateStarGiftMediaOmitsUserBoxLocalRefs(t *testing.T) {
|
||||
ordinary := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{
|
||||
PeerUserID: 9,
|
||||
SavedID: 10, GiftMsgID: 11, UpgradeMsgID: 12,
|
||||
},
|
||||
},
|
||||
}
|
||||
encoded, err := encodeSharedPrivateStarGiftMedia(ordinary)
|
||||
if err != nil {
|
||||
t.Fatalf("encode ordinary shared projection: %v", err)
|
||||
}
|
||||
sharedOrdinary, err := decodeMessageMedia(string(encoded))
|
||||
if err != nil {
|
||||
t.Fatalf("decode ordinary shared projection: %v", err)
|
||||
}
|
||||
ordinaryAction := sharedOrdinary.ServiceAction.StarGift
|
||||
if ordinaryAction.SavedID != 0 || ordinaryAction.GiftMsgID != 0 || ordinaryAction.UpgradeMsgID != 0 {
|
||||
t.Fatalf("ordinary shared projection retained box-local refs: %+v", ordinaryAction)
|
||||
}
|
||||
if original := ordinary.ServiceAction.StarGift; original.SavedID != 10 || original.GiftMsgID != 11 || original.UpgradeMsgID != 12 {
|
||||
t.Fatalf("ordinary source projection was mutated: %+v", original)
|
||||
}
|
||||
|
||||
unique := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 9}, SavedID: 13,
|
||||
},
|
||||
},
|
||||
}
|
||||
encoded, err = encodeSharedPrivateStarGiftMedia(unique)
|
||||
if err != nil {
|
||||
t.Fatalf("encode unique shared projection: %v", err)
|
||||
}
|
||||
sharedUnique, err := decodeMessageMedia(string(encoded))
|
||||
if err != nil {
|
||||
t.Fatalf("decode unique shared projection: %v", err)
|
||||
}
|
||||
if action := sharedUnique.ServiceAction.StarGiftUnique; action.SavedID != 0 {
|
||||
t.Fatalf("unique shared projection retained user saved_id: %+v", action)
|
||||
}
|
||||
if unique.ServiceAction.StarGiftUnique.SavedID != 13 {
|
||||
t.Fatalf("unique source projection was mutated: %+v", unique.ServiceAction.StarGiftUnique)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSharedPrivateStarGiftMediaPreservesChannelSavedID(t *testing.T) {
|
||||
media := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}, SavedID: 14,
|
||||
},
|
||||
},
|
||||
}
|
||||
encoded, err := encodeSharedPrivateStarGiftMedia(media)
|
||||
if err != nil {
|
||||
t.Fatalf("encode channel shared projection: %v", err)
|
||||
}
|
||||
shared, err := decodeMessageMedia(string(encoded))
|
||||
if err != nil {
|
||||
t.Fatalf("decode channel shared projection: %v", err)
|
||||
}
|
||||
if action := shared.ServiceAction.StarGiftUnique; action.SavedID != 14 {
|
||||
t.Fatalf("channel shared projection lost saved_id: %+v", action)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) {
|
|||
attribute := func(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
|
||||
value := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 918}
|
||||
if kind == domain.StarGiftCollectibleBackdrop {
|
||||
value.BackdropID = 0
|
||||
value.BackdropID = int(id)
|
||||
value.CenterColor, value.EdgeColor, value.PatternColor, value.TextColor = 1, 2, 3, 4
|
||||
return value
|
||||
}
|
||||
|
|
@ -46,10 +46,19 @@ func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) {
|
|||
},
|
||||
Collectible: &domain.StarGiftCollectibleWrite{
|
||||
UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "official-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleModel, baseID+1, "model")},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+2, "pattern")},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")},
|
||||
Actor: "integration", CommandID: "official-pool-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
attribute(domain.StarGiftCollectibleModel, baseID+1, "model"),
|
||||
attribute(domain.StarGiftCollectibleModel, baseID+3, "model-two"),
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
attribute(domain.StarGiftCollectiblePattern, baseID+2, "pattern"),
|
||||
attribute(domain.StarGiftCollectiblePattern, baseID+4, "pattern-two"),
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop"),
|
||||
attribute(domain.StarGiftCollectibleBackdrop, 1, "backdrop-two"),
|
||||
},
|
||||
Actor: "integration", CommandID: "official-pool-" + suffix,
|
||||
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA,
|
||||
},
|
||||
}
|
||||
|
|
@ -81,9 +90,15 @@ FROM star_gift_catalog_revisions WHERE id=$1`, result.Catalog.Gift.RevisionID).S
|
|||
attribute(domain.StarGiftCollectibleModel, baseID+101, "duplicate"),
|
||||
attribute(domain.StarGiftCollectibleModel, baseID+102, "duplicate"),
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+103, "pattern")},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")},
|
||||
Actor: "integration", CommandID: "rollback-pool-" + suffix,
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
attribute(domain.StarGiftCollectiblePattern, baseID+103, "pattern"),
|
||||
attribute(domain.StarGiftCollectiblePattern, baseID+104, "pattern-two"),
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop"),
|
||||
attribute(domain.StarGiftCollectibleBackdrop, 1, "backdrop-two"),
|
||||
},
|
||||
Actor: "integration", CommandID: "rollback-pool-" + suffix,
|
||||
}
|
||||
if _, err := store.CreateCatalogBundle(ctx, failing); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("failing bundle err=%v", err)
|
||||
|
|
|
|||
131
internal/store/postgres/star_gift_private_projection.go
Normal file
131
internal/store/postgres/star_gift_private_projection.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source
|
||||
// message identity only in the gift owner's message-box projection. Telegram
|
||||
// defines gift_msg_id as receiver-only. A non-owner counterpart box id is not
|
||||
// a valid substitute: it could resolve to an unrelated gift owned by that
|
||||
// viewer. User unique actions do not use channel-only peer/saved_id fields;
|
||||
// their owner-scoped message ids are registered separately at write time.
|
||||
func projectPrivateStarGiftSourceRef(
|
||||
_ context.Context,
|
||||
_ pgx.Tx,
|
||||
req *domain.SendPrivateTextRequest,
|
||||
sourceOwnerUserID int64,
|
||||
sourceOwnerBoxID int,
|
||||
) (privateSendMediaProjection, error) {
|
||||
if req == nil || req.Media == nil || sourceOwnerUserID <= 0 || sourceOwnerBoxID <= 0 ||
|
||||
(sourceOwnerUserID != req.SenderUserID && sourceOwnerUserID != req.RecipientUserID) {
|
||||
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: invalid scope")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
switch {
|
||||
case privateStarGiftAction(shared) != nil:
|
||||
sharedAction := privateStarGiftAction(shared)
|
||||
senderAction := privateStarGiftAction(sender)
|
||||
recipientAction := privateStarGiftAction(recipient)
|
||||
if sharedAction.GiftMsgID != sourceOwnerBoxID {
|
||||
return privateSendMediaProjection{}, fmt.Errorf(
|
||||
"project private star gift source: gift_msg_id %d does not match owner box %d",
|
||||
sharedAction.GiftMsgID, sourceOwnerBoxID,
|
||||
)
|
||||
}
|
||||
sharedAction.GiftMsgID = 0
|
||||
senderAction.GiftMsgID = 0
|
||||
recipientAction.GiftMsgID = 0
|
||||
if req.SenderUserID == sourceOwnerUserID {
|
||||
senderAction.GiftMsgID = sourceOwnerBoxID
|
||||
} else {
|
||||
recipientAction.GiftMsgID = sourceOwnerBoxID
|
||||
}
|
||||
default:
|
||||
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media")
|
||||
}
|
||||
|
||||
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
|
||||
}
|
||||
|
||||
func cloneMessageMedia(media *domain.MessageMedia) (*domain.MessageMedia, error) {
|
||||
encoded, err := encodeMessageMedia(media)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone private message media: %w", err)
|
||||
}
|
||||
cloned, err := decodeMessageMedia(string(encoded))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone private message media: %w", err)
|
||||
}
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
// encodeSharedPrivateStarGiftMedia returns the logical private-message
|
||||
// envelope for an already viewpoint-projected Star Gift service message.
|
||||
// Conversation message ids belong to a single owner's message_boxes
|
||||
// namespace, so the shared row must never retain them. saved_id is likewise
|
||||
// box-local for user gifts, while channel saved ids remain globally meaningful
|
||||
// inside the channel gift namespace.
|
||||
func encodeSharedPrivateStarGiftMedia(media *domain.MessageMedia) ([]byte, error) {
|
||||
shared, err := cloneMessageMedia(media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case privateStarGiftAction(shared) != nil:
|
||||
action := privateStarGiftAction(shared)
|
||||
action.GiftMsgID = 0
|
||||
action.UpgradeMsgID = 0
|
||||
if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser {
|
||||
action.SavedID = 0
|
||||
}
|
||||
case privateStarGiftUniqueAction(shared) != nil:
|
||||
action := privateStarGiftUniqueAction(shared)
|
||||
if action.Peer.Type == domain.PeerTypeUser {
|
||||
action.SavedID = 0
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("encode shared private star gift media: unsupported media")
|
||||
}
|
||||
|
||||
encoded, err := encodeMessageMedia(shared)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode shared private star gift media: %w", err)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func privateStarGiftAction(media *domain.MessageMedia) *domain.MessageStarGiftAction {
|
||||
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
|
||||
media.ServiceAction.Kind != domain.MessageServiceActionStarGift {
|
||||
return nil
|
||||
}
|
||||
return media.ServiceAction.StarGift
|
||||
}
|
||||
|
||||
func privateStarGiftUniqueAction(media *domain.MessageMedia) *domain.MessageStarGiftUniqueAction {
|
||||
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
|
||||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique {
|
||||
return nil
|
||||
}
|
||||
return media.ServiceAction.StarGiftUnique
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
|
|
@ -46,6 +47,296 @@ func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...St
|
|||
return s
|
||||
}
|
||||
|
||||
// GrantUniqueStarGift atomically assigns a newly minted collectible from the
|
||||
// official system account. The saved gift, unique issuance, service message,
|
||||
// pts/outbox and immutable command receipt share MessageStore's transaction.
|
||||
func (s *StarGiftUpgradeStore) GrantUniqueStarGift(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
req.Message = strings.TrimSpace(req.Message)
|
||||
if s == nil || s.db == nil || s.messages == nil || req.SenderID != domain.OfficialSystemUserID ||
|
||||
req.Recipient.Type != domain.PeerTypeUser || req.Recipient.ID <= 0 || req.GiftID <= 0 || !req.Upgrade ||
|
||||
req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 ||
|
||||
req.ModelAttributeID < 0 || req.PatternAttributeID < 0 || req.BackdropAttributeID < 0 {
|
||||
return domain.AdminStarGiftGrantResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
fingerprint := adminStarGiftGrantFingerprint(req)
|
||||
if replay, found, err := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, domain.SendPrivateTextResult{}); err != nil || found {
|
||||
return replay, err
|
||||
}
|
||||
|
||||
placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Assigned: true,
|
||||
Saved: true,
|
||||
},
|
||||
}}
|
||||
messageReq := domain.SendPrivateTextRequest{
|
||||
SenderUserID: req.SenderID,
|
||||
RecipientUserID: req.Recipient.ID,
|
||||
RandomID: lifecycleCommandRandomID("admin-collectible-grant", req.Recipient.ID, req.CommandKey),
|
||||
Date: req.Date,
|
||||
OriginUserID: req.SenderID,
|
||||
RecipientBlocked: req.RecipientBlocked,
|
||||
IdempotencyFingerprint: fingerprint[:],
|
||||
Media: placeholder,
|
||||
}
|
||||
|
||||
var result domain.AdminStarGiftGrantResult
|
||||
hooks := privateSendTxHooks{
|
||||
afterAllocate: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest, senderBoxID, recipientBoxID int) error {
|
||||
ownerMessageID := recipientBoxID
|
||||
if req.SenderID == req.Recipient.ID {
|
||||
ownerMessageID = senderBoxID
|
||||
}
|
||||
if ownerMessageID <= 0 {
|
||||
return fmt.Errorf("admin collectible grant missing owner message id")
|
||||
}
|
||||
|
||||
var revisionID int64
|
||||
var enabled bool
|
||||
if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled
|
||||
FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID).Scan(&revisionID, &enabled); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrStarGiftNotFound
|
||||
}
|
||||
return fmt.Errorf("lock admin collectible catalog gift: %w", err)
|
||||
}
|
||||
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !enabled || gift.ID != req.GiftID {
|
||||
return domain.ErrStarGiftNotFound
|
||||
}
|
||||
revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision.Issued >= revision.SupplyTotal {
|
||||
return domain.ErrStarGiftCollectibleSoldOut
|
||||
}
|
||||
modelID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID, req.ModelAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patternID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID, req.PatternAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backdropID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID, req.BackdropAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var craftable bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM star_gift_collectible_models
|
||||
WHERE collectible_revision_id=$1 AND crafted
|
||||
)`, revision.ID).Scan(&craftable); err != nil {
|
||||
return fmt.Errorf("load admin collectible craft capability: %w", err)
|
||||
}
|
||||
craftChancePermille, canCraftAt := 0, 0
|
||||
if craftable {
|
||||
craftChancePermille = s.lifecycle.CraftChancePermille
|
||||
if craftChancePermille > 0 {
|
||||
canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
saved := domain.SavedStarGift{
|
||||
Owner: req.Recipient,
|
||||
FromUserID: req.SenderID,
|
||||
GiftID: gift.ID,
|
||||
RevisionID: gift.RevisionID,
|
||||
MsgID: ownerMessageID,
|
||||
Date: req.Date,
|
||||
NameHidden: req.HideName,
|
||||
LifecycleStatus: domain.StarGiftLifecycleActive,
|
||||
Message: req.Message,
|
||||
TransferStars: s.lifecycle.TransferStars,
|
||||
CanExportAt: starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds),
|
||||
CanTransferAt: starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds),
|
||||
CanResellAt: starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds),
|
||||
DropOriginalDetailsStars: s.lifecycle.DropOriginalDetailsStars,
|
||||
CanCraftAt: canCraftAt,
|
||||
UpgradeMsgID: ownerMessageID,
|
||||
}
|
||||
savedID, err := NewStarGiftStore(tx).Create(ctx, saved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved.ID = savedID
|
||||
|
||||
num := revision.Issued + 1
|
||||
var uniqueID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
|
||||
return fmt.Errorf("allocate admin unique star gift id: %w", err)
|
||||
}
|
||||
slug := fmt.Sprintf("%s-%d", revision.SlugPrefix, num)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO unique_star_gifts
|
||||
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num,
|
||||
owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
|
||||
backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id,
|
||||
craft_chance_permille, offer_min_stars)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,true,$13,$14,$15,$16)`,
|
||||
uniqueID, gift.ID, revision.ID, savedID, gift.Title, slug, num,
|
||||
string(req.Recipient.Type), req.Recipient.ID, modelID, patternID, backdropID,
|
||||
string(req.Recipient.Type), req.Recipient.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil {
|
||||
return fmt.Errorf("insert admin unique star gift: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil {
|
||||
return fmt.Errorf("increment admin collectible issuance: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE peer_star_gifts
|
||||
SET unique_gift_id=$2,upgrade_msg_id=$3,convert_stars=0,prepaid_upgrade_stars=0,prepaid_upgrade_hash='',
|
||||
transfer_stars=$4,can_export_at=$5,can_transfer_at=$6,can_resell_at=$7,
|
||||
drop_original_details_stars=$8,can_craft_at=$9
|
||||
WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`,
|
||||
savedID, uniqueID, ownerMessageID, s.lifecycle.TransferStars, saved.CanExportAt,
|
||||
saved.CanTransferAt, saved.CanResellAt, s.lifecycle.DropOriginalDetailsStars, canCraftAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("link admin unique star gift: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("link admin unique star gift lost aggregate row")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO star_gift_admin_grant_commands
|
||||
(recipient_user_id,command_key,request_fingerprint,sender_user_id,gift_id,saved_gift_id,unique_gift_id,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,to_timestamp($8))`,
|
||||
req.Recipient.ID, req.CommandKey, fingerprint[:], req.SenderID, gift.ID, savedID, uniqueID, req.Date); err != nil {
|
||||
return fmt.Errorf("insert admin collectible grant command: %w", err)
|
||||
}
|
||||
|
||||
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("new admin unique star gift %d disappeared", uniqueID)
|
||||
}
|
||||
saved.UniqueGiftID = uniqueID
|
||||
saved.Unique = &unique
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, req.Recipient.ID, ownerMessageID, savedID, uniqueID); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Saved, result.Unique = saved, unique
|
||||
send.Media = adminStarGiftUniqueMedia(saved, unique)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if replay, found, replayErr := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, sent); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
return domain.AdminStarGiftGrantResult{}, err
|
||||
}
|
||||
result.Send, result.Duplicate = sent, sent.Duplicate
|
||||
if sent.Duplicate {
|
||||
replay, found, replayErr := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, sent)
|
||||
if replayErr != nil {
|
||||
return domain.AdminStarGiftGrantResult{}, replayErr
|
||||
}
|
||||
if !found {
|
||||
return domain.AdminStarGiftGrantResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return replay, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func adminStarGiftUniqueMedia(saved domain.SavedStarGift, unique domain.UniqueStarGift) *domain.MessageMedia {
|
||||
fromUserID := saved.FromUserID
|
||||
if saved.NameHidden {
|
||||
fromUserID = 0
|
||||
}
|
||||
return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, FromUserID: fromUserID, Assigned: true, Saved: true,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
|
||||
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func adminStarGiftGrantFingerprint(req domain.AdminStarGiftGrant) [32]byte {
|
||||
return sha256.Sum256([]byte(fmt.Sprintf(
|
||||
"telesrv:admin-star-gift-grant:v1:%d:%s:%d:%d:%t:%q:%d:%d:%d",
|
||||
req.SenderID, req.Recipient.Type, req.Recipient.ID, req.GiftID, req.HideName, req.Message,
|
||||
req.ModelAttributeID, req.PatternAttributeID, req.BackdropAttributeID,
|
||||
)))
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) loadAdminStarGiftGrantReplay(
|
||||
ctx context.Context,
|
||||
req domain.AdminStarGiftGrant,
|
||||
fingerprint [32]byte,
|
||||
sent domain.SendPrivateTextResult,
|
||||
) (domain.AdminStarGiftGrantResult, bool, error) {
|
||||
var storedFingerprint []byte
|
||||
var senderID, giftID, savedID, uniqueID int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT request_fingerprint,sender_user_id,gift_id,saved_gift_id,unique_gift_id
|
||||
FROM star_gift_admin_grant_commands
|
||||
WHERE recipient_user_id=$1 AND command_key=$2`, req.Recipient.ID, req.CommandKey).Scan(
|
||||
&storedFingerprint, &senderID, &giftID, &savedID, &uniqueID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AdminStarGiftGrantResult{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AdminStarGiftGrantResult{}, false, err
|
||||
}
|
||||
if senderID != req.SenderID || giftID != req.GiftID || !bytes.Equal(storedFingerprint, fingerprint[:]) {
|
||||
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return domain.AdminStarGiftGrantResult{}, false, err
|
||||
}
|
||||
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return domain.AdminStarGiftGrantResult{}, false, err
|
||||
}
|
||||
if saved.Owner != req.Recipient || saved.FromUserID != req.SenderID || saved.GiftID != req.GiftID ||
|
||||
saved.UniqueGiftID != uniqueID || saved.MsgID <= 0 || saved.UpgradeMsgID != saved.MsgID ||
|
||||
unique.SourceSavedGiftID != savedID || unique.Owner != req.Recipient {
|
||||
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if sent.SenderMessage.ID == 0 {
|
||||
replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
|
||||
SenderUserID: req.SenderID, RecipientUserID: req.Recipient.ID,
|
||||
RandomID: lifecycleCommandRandomID("admin-collectible-grant", req.Recipient.ID, req.CommandKey),
|
||||
IdempotencyFingerprint: fingerprint[:],
|
||||
})
|
||||
if replayErr != nil {
|
||||
return domain.AdminStarGiftGrantResult{}, false, replayErr
|
||||
}
|
||||
if !replayFound {
|
||||
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
sent = replay
|
||||
}
|
||||
uniqueCopy := unique
|
||||
saved.Unique = &uniqueCopy
|
||||
return domain.AdminStarGiftGrantResult{
|
||||
Saved: saved, Unique: unique, Send: sent, Duplicate: true,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() ||
|
||||
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) ||
|
||||
|
|
@ -122,9 +413,15 @@ WHERE collectible_revision_id=$1 AND crafted
|
|||
}
|
||||
craftChancePermille := 0
|
||||
canCraftAt := 0
|
||||
// Keep the durable Craft entitlement attached to the collectible across
|
||||
// user/channel ownership moves. The RPC projection suppresses the
|
||||
// readiness marker for channel owners until channel Craft execution is
|
||||
// implemented, without destroying the official gift property.
|
||||
if craftable {
|
||||
craftChancePermille = s.lifecycle.CraftChancePermille
|
||||
canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
|
||||
if craftChancePermille > 0 {
|
||||
canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
|
||||
}
|
||||
}
|
||||
if revision.Issued >= revision.SupplyTotal {
|
||||
return domain.ErrStarGiftCollectibleSoldOut
|
||||
|
|
@ -144,15 +441,15 @@ WHERE collectible_revision_id=$1 AND crafted
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modelID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID)
|
||||
modelID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID, req.ModelAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patternID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID)
|
||||
patternID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID, req.PatternAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backdropID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID)
|
||||
backdropID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID, req.BackdropAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -245,6 +542,12 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
|
|||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
|
||||
}
|
||||
if result.Saved.Owner.Type == domain.PeerTypeUser {
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, result.Saved.Owner.ID, ownerMessageID,
|
||||
result.Saved.ID, result.Unique.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result.Saved.UpgradeMsgID = ownerMessageID
|
||||
if result.Saved.Owner.Type == domain.PeerTypeUser {
|
||||
edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent)
|
||||
|
|
@ -305,27 +608,69 @@ func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.Uniqu
|
|||
// peer plus action.peer=channel and action.saved_id.
|
||||
fromUserID = messageSenderID
|
||||
}
|
||||
peer := saved.Owner
|
||||
savedID := saved.SavedID
|
||||
canCraftAt := saved.CanCraftAt
|
||||
if saved.Owner.Type == domain.PeerTypeUser {
|
||||
// For user-owned gifts messageActionStarGiftUnique.saved_id is the
|
||||
// stable source gift message id. TDesktop uses this back-reference as
|
||||
// inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs.
|
||||
savedID = int64(saved.MsgID)
|
||||
// peer and saved_id share one TL flag and are defined for channel gifts.
|
||||
// For user gifts both must be absent; official clients use the emitted
|
||||
// service-message id (registered owner-locally by the send transaction).
|
||||
peer = domain.Peer{}
|
||||
savedID = 0
|
||||
} else {
|
||||
// The current Craft state machine is user-owned only. Android treats a
|
||||
// positive can_craft_at as the channel Craft entry marker, so do not
|
||||
// advertise a write path that the server cannot execute yet.
|
||||
canCraftAt = 0
|
||||
}
|
||||
return &domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, SavedID: savedID,
|
||||
Gift: unique, FromUserID: fromUserID, Peer: peer, SavedID: savedID,
|
||||
Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
|
||||
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt,
|
||||
}
|
||||
}
|
||||
|
||||
// markPrivateStarGiftSourceUpgradedTx rewrites both visible copies of the
|
||||
// original gift service message in the same transaction that creates the
|
||||
// unique gift message. upgrade_msg_id is box-local, so each owner projection
|
||||
// must point at that owner's copy of the new service message. Every rewrite is
|
||||
// a durable edit_message event with its own pts and outbox row.
|
||||
func userStarGiftSourceMessageIDs(ctx context.Context, db interface {
|
||||
Query(context.Context, string, ...any) (pgx.Rows, error)
|
||||
}, saved domain.SavedStarGift) ([]int, error) {
|
||||
if saved.Owner.Type != domain.PeerTypeUser || saved.Owner.ID <= 0 || saved.ID <= 0 || saved.MsgID <= 0 {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
messageIDs := []int{saved.MsgID}
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT msg_id FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND saved_gift_id=$2 AND msg_id<>$3
|
||||
ORDER BY msg_id`, saved.Owner.ID, saved.ID, saved.MsgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gift source message refs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var msgID int
|
||||
if err := rows.Scan(&msgID); err != nil {
|
||||
return nil, fmt.Errorf("scan star gift source message ref: %w", err)
|
||||
}
|
||||
if msgID <= 0 {
|
||||
return nil, fmt.Errorf("star gift source message ref has invalid id")
|
||||
}
|
||||
messageIDs = append(messageIDs, msgID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate star gift source message refs: %w", err)
|
||||
}
|
||||
return messageIDs, nil
|
||||
}
|
||||
|
||||
// markPrivateStarGiftSourceUpgradedTx rewrites every ordinary gift projection
|
||||
// owned by the source aggregate: the original gift message and each separately
|
||||
// prepaid-upgrade notification. The two visible boxes of every logical private
|
||||
// message are updated together. upgrade_msg_id is box-local and is set only
|
||||
// when that viewer owns a box for the emitted unique-gift message; a third-party
|
||||
// payer sees the prepayment become non-actionable without receiving an invalid
|
||||
// owner-local link. Every rewrite is a durable edit_message event with its own
|
||||
// pts and outbox row.
|
||||
func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
|
|
@ -336,30 +681,11 @@ func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx(
|
|||
if saved.Owner.Type != domain.PeerTypeUser || saved.Owner.ID != req.UserID || saved.MsgID <= 0 {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
messageIDs, err := userStarGiftSourceMessageIDs(ctx, tx, saved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := sqlcgen.New(tx)
|
||||
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
|
||||
OwnerUserID: req.UserID,
|
||||
BoxID: int32(saved.MsgID),
|
||||
PeerType: string(domain.PeerTypeUser),
|
||||
PeerID: saved.FromUserID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return nil, fmt.Errorf("lock star gift source message: %w", err)
|
||||
}
|
||||
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
|
||||
OwnerUserIds: privateMessageOwnerIDs(req.UserID, saved.FromUserID),
|
||||
MessageSenderID: target.MessageSenderID,
|
||||
PrivateMessageID: target.PrivateMessageID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gift source message boxes: %w", err)
|
||||
}
|
||||
if len(boxes) == 0 {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
upgradeMessageIDs := make(map[int64]int, 2)
|
||||
if sent.SenderMessage.OwnerUserID > 0 && sent.SenderMessage.ID > 0 {
|
||||
upgradeMessageIDs[sent.SenderMessage.OwnerUserID] = sent.SenderMessage.ID
|
||||
|
|
@ -367,84 +693,158 @@ func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx(
|
|||
if sent.RecipientMessage.OwnerUserID > 0 && sent.RecipientMessage.ID > 0 {
|
||||
upgradeMessageIDs[sent.RecipientMessage.OwnerUserID] = sent.RecipientMessage.ID
|
||||
}
|
||||
edits := make([]domain.EditedMessageForUser, 0, len(boxes))
|
||||
var privateMediaJSON []byte
|
||||
for _, box := range boxes {
|
||||
upgradeMessageID := upgradeMessageIDs[box.OwnerUserID]
|
||||
if upgradeMessageID <= 0 {
|
||||
return nil, fmt.Errorf("upgrade service message missing box for user %d", box.OwnerUserID)
|
||||
edits := make([]domain.EditedMessageForUser, 0, len(messageIDs)*2)
|
||||
seenPrivateMessages := make(map[string]struct{}, len(messageIDs))
|
||||
primaryRewritten := false
|
||||
for _, sourceMessageID := range messageIDs {
|
||||
var peerType string
|
||||
var peerID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT peer_type,peer_id FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted
|
||||
FOR UPDATE`, req.UserID, sourceMessageID).Scan(&peerType, &peerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if sourceMessageID == saved.MsgID {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
continue
|
||||
}
|
||||
media, err := decodeMessageMedia(box.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode star gift source media: %w", err)
|
||||
return nil, fmt.Errorf("lock star gift source ref %d: %w", sourceMessageID, err)
|
||||
}
|
||||
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
|
||||
media.ServiceAction.Kind != domain.MessageServiceActionStarGift || media.ServiceAction.StarGift == nil {
|
||||
return nil, fmt.Errorf("star gift source message %d has invalid media", box.BoxID)
|
||||
if peerType != string(domain.PeerTypeUser) || peerID <= 0 {
|
||||
return nil, fmt.Errorf("star gift source ref %d is not private", sourceMessageID)
|
||||
}
|
||||
action := media.ServiceAction.StarGift
|
||||
if action.UpgradeMsgID != 0 && action.UpgradeMsgID != upgradeMessageID {
|
||||
return nil, fmt.Errorf("star gift source message %d has conflicting upgrade message %d", box.BoxID, action.UpgradeMsgID)
|
||||
}
|
||||
action.UpgradeMsgID = upgradeMessageID
|
||||
action.CanUpgrade = false
|
||||
mediaJSON, err := encodeMessageMedia(media)
|
||||
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
|
||||
OwnerUserID: req.UserID, BoxID: int32(sourceMessageID), PeerType: peerType, PeerID: peerID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode upgraded star gift source media: %w", err)
|
||||
return nil, fmt.Errorf("load star gift source ref %d: %w", sourceMessageID, err)
|
||||
}
|
||||
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
|
||||
ownerMedia, err := decodeMessageMedia(target.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocate star gift source edit pts: %w", err)
|
||||
return nil, fmt.Errorf("decode star gift source ref %d: %w", sourceMessageID, err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
ownerAction := privateStarGiftAction(ownerMedia)
|
||||
if ownerAction == nil {
|
||||
// The newly emitted unique action is registered before source edits in
|
||||
// the same transaction and belongs to the same aggregate, but is not a
|
||||
// source projection to rewrite.
|
||||
if privateStarGiftUniqueAction(ownerMedia) != nil {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("star gift source ref %d has invalid media", sourceMessageID)
|
||||
}
|
||||
if ownerAction.GiftID != saved.GiftID {
|
||||
return nil, fmt.Errorf("star gift source ref %d points to gift %d", sourceMessageID, ownerAction.GiftID)
|
||||
}
|
||||
if sourceMessageID != saved.MsgID && (!ownerAction.UpgradeSeparate || !ownerAction.PrepaidUpgrade || ownerAction.GiftMsgID != saved.MsgID) {
|
||||
return nil, fmt.Errorf("star gift source ref %d is not a prepaid notification for message %d", sourceMessageID, saved.MsgID)
|
||||
}
|
||||
logicalKey := fmt.Sprintf("%d:%d", target.MessageSenderID, target.PrivateMessageID)
|
||||
if _, duplicate := seenPrivateMessages[logicalKey]; duplicate {
|
||||
continue
|
||||
}
|
||||
seenPrivateMessages[logicalKey] = struct{}{}
|
||||
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
|
||||
OwnerUserIds: privateMessageOwnerIDs(req.UserID, peerID), MessageSenderID: target.MessageSenderID,
|
||||
PrivateMessageID: target.PrivateMessageID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gift source ref %d boxes: %w", sourceMessageID, err)
|
||||
}
|
||||
if len(boxes) == 0 {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
var privateMediaJSON []byte
|
||||
for _, box := range boxes {
|
||||
media, err := decodeMessageMedia(box.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode star gift source media: %w", err)
|
||||
}
|
||||
action := privateStarGiftAction(media)
|
||||
if action == nil || action.GiftID != saved.GiftID {
|
||||
return nil, fmt.Errorf("star gift source message %d has invalid media", box.BoxID)
|
||||
}
|
||||
upgradeMessageID := upgradeMessageIDs[box.OwnerUserID]
|
||||
if action.UpgradeMsgID != 0 && upgradeMessageID > 0 && action.UpgradeMsgID != upgradeMessageID {
|
||||
return nil, fmt.Errorf("star gift source message %d has conflicting upgrade message %d", box.BoxID, action.UpgradeMsgID)
|
||||
}
|
||||
if upgradeMessageID > 0 {
|
||||
action.UpgradeMsgID = upgradeMessageID
|
||||
} else {
|
||||
if box.OwnerUserID == req.UserID {
|
||||
return nil, fmt.Errorf("upgrade service message missing owner box")
|
||||
}
|
||||
action.UpgradeMsgID = 0
|
||||
}
|
||||
action.CanUpgrade = false
|
||||
action.PrepaidUpgradeHash = ""
|
||||
mediaJSON, err := encodeMessageMedia(media)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode upgraded star gift source media: %w", err)
|
||||
}
|
||||
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocate star gift source edit pts: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE message_boxes SET media=$3, pts=$4
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update star gift source message box: %w", err)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update star gift source message box: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fmt.Errorf("update star gift source message box lost row")
|
||||
}
|
||||
msg, err := messageFromVisibleBoxRow(box)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Media = media
|
||||
msg.Pts = pts
|
||||
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
|
||||
Pts: pts, PtsCount: 1, Date: req.Date, Message: msg}
|
||||
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
|
||||
return nil, fmt.Errorf("append star gift source edit event: %w", err)
|
||||
}
|
||||
dispatchAuthKeyID := [8]byte{}
|
||||
dispatchSessionID := int64(0)
|
||||
if msg.OwnerUserID == req.UserID {
|
||||
dispatchAuthKeyID = req.OriginAuthKeyID
|
||||
dispatchSessionID = req.OriginSessionID
|
||||
}
|
||||
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
|
||||
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("enqueue star gift source edit: %w", err)
|
||||
}
|
||||
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
|
||||
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fmt.Errorf("update star gift source message box lost row")
|
||||
if len(privateMediaJSON) == 0 {
|
||||
return nil, fmt.Errorf("upgrade source message missing private media projection")
|
||||
}
|
||||
msg, err := messageFromVisibleBoxRow(box)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Media = media
|
||||
msg.Pts = pts
|
||||
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event := domain.UpdateEvent{
|
||||
UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
|
||||
Pts: pts, PtsCount: 1, Date: req.Date, Message: msg,
|
||||
}
|
||||
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
|
||||
return nil, fmt.Errorf("append star gift source edit event: %w", err)
|
||||
}
|
||||
dispatchAuthKeyID := [8]byte{}
|
||||
dispatchSessionID := int64(0)
|
||||
if msg.OwnerUserID == req.UserID {
|
||||
dispatchAuthKeyID = req.OriginAuthKeyID
|
||||
dispatchSessionID = req.OriginSessionID
|
||||
}
|
||||
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
|
||||
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("enqueue star gift source edit: %w", err)
|
||||
}
|
||||
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
|
||||
privateMediaJSON = mediaJSON
|
||||
}
|
||||
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
|
||||
}
|
||||
if len(privateMediaJSON) == 0 {
|
||||
return nil, fmt.Errorf("upgrade source message missing private media projection")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE private_messages SET media=$3
|
||||
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
|
||||
return nil, fmt.Errorf("update star gift source private message: %w", err)
|
||||
return nil, fmt.Errorf("update star gift source private message: %w", err)
|
||||
}
|
||||
if sourceMessageID == saved.MsgID {
|
||||
primaryRewritten = true
|
||||
}
|
||||
}
|
||||
if !primaryRewritten {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
|
|
@ -460,6 +860,27 @@ func starGiftReadyAt(date, delaySeconds int) int {
|
|||
return date + delaySeconds
|
||||
}
|
||||
|
||||
// starGiftCraftReadyAt differs intentionally from the other lifecycle delay
|
||||
// fields. Official Android clients use a positive can_craft_at both as the
|
||||
// capability marker and as the readiness boundary, so an immediately
|
||||
// craftable gift must carry its upgrade date instead of omitting the field.
|
||||
func starGiftCraftReadyAt(date, delaySeconds int) int {
|
||||
if date <= 0 || delaySeconds < 0 {
|
||||
return 0
|
||||
}
|
||||
const maxProtocolDate = int(1<<31 - 1)
|
||||
if date >= maxProtocolDate {
|
||||
return maxProtocolDate
|
||||
}
|
||||
if delaySeconds == 0 {
|
||||
return date
|
||||
}
|
||||
if delaySeconds > maxProtocolDate-date {
|
||||
return maxProtocolDate
|
||||
}
|
||||
return date + delaySeconds
|
||||
}
|
||||
|
||||
func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
return lockSavedStarGiftWhere(ctx, tx, where, args...)
|
||||
|
|
@ -536,6 +957,30 @@ func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64,
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// resolveCollectibleAttribute returns explicitID when it names a renderable
|
||||
// attribute belonging to revisionID (admin-pinned choice), otherwise it falls
|
||||
// back to the weighted random draw. Models excluded from the random pool
|
||||
// (crafted) are also rejected for explicit selection to preserve invariants.
|
||||
func resolveCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID, explicitID int64) (int64, error) {
|
||||
if explicitID <= 0 {
|
||||
return chooseCollectibleAttribute(ctx, tx, table, revisionID)
|
||||
}
|
||||
extra := ""
|
||||
if table == "star_gift_collectible_models" {
|
||||
extra = " AND NOT crafted"
|
||||
}
|
||||
var ok bool
|
||||
if err := tx.QueryRow(ctx, fmt.Sprintf(`SELECT EXISTS (SELECT 1 FROM %s
|
||||
WHERE id=$1 AND collectible_revision_id=$2 AND rarity_kind='permille' AND rarity_permille > 0%s)`, table, extra),
|
||||
explicitID, revisionID).Scan(&ok); err != nil {
|
||||
return 0, fmt.Errorf("validate collectible attribute: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return explicitID, nil
|
||||
}
|
||||
|
||||
func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) {
|
||||
extra := ""
|
||||
if table == "star_gift_collectible_models" {
|
||||
|
|
@ -634,46 +1079,77 @@ func (s *StarGiftUpgradeStore) loadUpgradeSourceReplay(ctx context.Context, req
|
|||
if pts <= 0 || saved.MsgID <= 0 {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
var privateMessageID, messageSenderID int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT private_message_id,message_sender_id FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND peer_id=$3 AND NOT deleted`,
|
||||
req.UserID, saved.MsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// A later delete event is authoritative; replaying the old edit here
|
||||
// would transiently resurrect the source message.
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load star gift source replay message: %w", err)
|
||||
}
|
||||
boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
|
||||
OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load star gift source replay box: %w", err)
|
||||
}
|
||||
if len(boxes) != 1 || int(boxes[0].BoxID) != saved.MsgID {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
var eventDate int
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT date FROM user_update_events
|
||||
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`,
|
||||
req.UserID, pts, saved.MsgID).Scan(&eventDate)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return nil, fmt.Errorf("load star gift source replay event: %w", err)
|
||||
}
|
||||
msg, err := messageFromVisibleBoxRow(boxes[0])
|
||||
messageIDs, err := userStarGiftSourceMessageIDs(ctx, s.db, saved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Pts = pts
|
||||
event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage, Pts: pts, PtsCount: 1, Date: eventDate, Message: msg}
|
||||
return []domain.EditedMessageForUser{{UserID: req.UserID, Message: msg, Event: event}}, nil
|
||||
edits := make([]domain.EditedMessageForUser, 0, len(messageIDs))
|
||||
for _, messageID := range messageIDs {
|
||||
var privateMessageID, messageSenderID int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT private_message_id,message_sender_id FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND NOT deleted`,
|
||||
req.UserID, messageID).Scan(&privateMessageID, &messageSenderID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// A later delete event is authoritative; replaying the old edit here
|
||||
// would transiently resurrect that source projection.
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load star gift source replay message %d: %w", messageID, err)
|
||||
}
|
||||
boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
|
||||
OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load star gift source replay box %d: %w", messageID, err)
|
||||
}
|
||||
if len(boxes) != 1 || int(boxes[0].BoxID) != messageID {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
media, err := decodeMessageMedia(boxes[0].MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode star gift source replay box %d: %w", messageID, err)
|
||||
}
|
||||
action := privateStarGiftAction(media)
|
||||
if action == nil {
|
||||
if privateStarGiftUniqueAction(media) != nil {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("star gift source replay box %d has invalid media", messageID)
|
||||
}
|
||||
if action.GiftID != saved.GiftID || action.CanUpgrade || action.UpgradeMsgID != saved.UpgradeMsgID {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
var eventPts, eventDate int
|
||||
if messageID == saved.MsgID {
|
||||
eventPts = pts
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT date FROM user_update_events
|
||||
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`,
|
||||
req.UserID, eventPts, messageID).Scan(&eventDate)
|
||||
} else {
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT pts,date FROM user_update_events
|
||||
WHERE user_id=$1 AND pts>=$2 AND event_type='edit_message' AND message_box_id=$3
|
||||
ORDER BY pts LIMIT 1`, req.UserID, pts, messageID).Scan(&eventPts, &eventDate)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return nil, fmt.Errorf("load star gift source replay event %d: %w", messageID, err)
|
||||
}
|
||||
msg, err := messageFromVisibleBoxRow(boxes[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Pts = eventPts
|
||||
event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage,
|
||||
Pts: eventPts, PtsCount: 1, Date: eventDate, Message: msg}
|
||||
edits = append(edits, domain.EditedMessageForUser{UserID: req.UserID, Message: msg, Event: event})
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) {
|
||||
|
|
|
|||
62
internal/store/postgres/star_gift_user_message_ref.go
Normal file
62
internal/store/postgres/star_gift_user_message_ref.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
|
||||
// for a user-owned gift. Official clients may continue from a freshly emitted
|
||||
// messageActionStarGiftUnique or a separate prepaid-upgrade notification and
|
||||
// pass that message id to a lifecycle RPC, while payments.getSavedStarGifts may
|
||||
// still expose the original received gift message as the aggregate's primary
|
||||
// msg_id. expectedUniqueGiftID is zero for an ordinary gift and positive for a
|
||||
// unique gift; the write boundary never aliases across lifecycle states.
|
||||
func registerUserStarGiftMessageRef(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
ownerUserID int64,
|
||||
msgID int,
|
||||
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")
|
||||
}
|
||||
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))
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register user star gift message ref: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("register user star gift message ref: identity collision")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userStarGiftMessageRefMatches(
|
||||
ctx context.Context,
|
||||
db interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
},
|
||||
ownerUserID int64,
|
||||
msgID int,
|
||||
savedGiftID int64,
|
||||
) (bool, error) {
|
||||
var matches bool
|
||||
err := db.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM star_gift_user_message_refs
|
||||
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3
|
||||
)`, ownerUserID, msgID, savedGiftID).Scan(&matches)
|
||||
return matches, err
|
||||
}
|
||||
|
|
@ -17,6 +17,19 @@ type fakeStoryReadModelCache struct {
|
|||
flushes int
|
||||
}
|
||||
|
||||
type fakeRPCProjectionReadModelCache struct {
|
||||
users []int64
|
||||
}
|
||||
|
||||
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForViewer(int64) {}
|
||||
func (f *fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForUser(id int64) {
|
||||
f.users = append(f.users, id)
|
||||
}
|
||||
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForPeer(int64, domain.Peer) {
|
||||
}
|
||||
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForChannel(int64) {}
|
||||
func (*fakeRPCProjectionReadModelCache) FlushRPCProjectionReadModel() {}
|
||||
|
||||
func (f *fakeStoryReadModelCache) InvalidateStoryReadModelViewers(ids ...int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -78,6 +91,29 @@ func TestReadModelChangeListenerRoutesStoryPeer(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReadModelChangeListenerRoutesUserVisibility(t *testing.T) {
|
||||
stories := &fakeStoryReadModelCache{}
|
||||
rpcProjections := &fakeRPCProjectionReadModelCache{}
|
||||
listener := NewReadModelChangeListener("", ReadModelCacheSet{
|
||||
Stories: stories,
|
||||
RPCProjections: rpcProjections,
|
||||
}, nil)
|
||||
|
||||
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"user","peer_id":777,"version":2}`)
|
||||
if len(rpcProjections.users) != 1 || rpcProjections.users[0] != 777 {
|
||||
t.Fatalf("RPC projection invalidations = %v, want [777]", rpcProjections.users)
|
||||
}
|
||||
if peers := stories.peersSnapshot(); len(peers) != 1 || peers[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 777}) {
|
||||
t.Fatalf("story projection invalidations = %+v, want user 777", peers)
|
||||
}
|
||||
|
||||
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"channel","peer_id":888,"version":3}`)
|
||||
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"user","peer_id":0,"version":4}`)
|
||||
if len(rpcProjections.users) != 1 || len(stories.peersSnapshot()) != 1 {
|
||||
t.Fatalf("invalid visibility events were not ignored: users=%v peers=%+v", rpcProjections.users, stories.peersSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite 验证 0135 触发器:写 stories /
|
||||
// story_hidden_peers → story_peer bump → 统一 read-model NOTIFY → 按 owner peer 失效故事投影。
|
||||
func TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite(t *testing.T) {
|
||||
|
|
|
|||
1106
internal/store/postgres/telegram_login.go
Normal file
1106
internal/store/postgres/telegram_login.go
Normal file
File diff suppressed because it is too large
Load diff
434
internal/store/postgres/telegram_login_integration_test.go
Normal file
434
internal/store/postgres/telegram_login_integration_test.go
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func telegramLoginPGHash(value string) []byte {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func TestTelegramLoginStorePostgresAtomicStateAndCodeConsumption(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
suffix := now.UnixNano() % 1_000_000_000
|
||||
|
||||
users := NewUserStore(pool)
|
||||
bots := NewBotStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{
|
||||
AccessHash: suffix + 101,
|
||||
Phone: fmt.Sprintf("1777%09d", suffix),
|
||||
FirstName: "OIDC Owner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create oidc owner: %v", err)
|
||||
}
|
||||
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
|
||||
AccessHash: suffix + 102,
|
||||
FirstName: "OIDC Test Bot",
|
||||
Username: fmt.Sprintf("oidc_%09d_bot", suffix),
|
||||
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "bot-secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("create oidc bot: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID)
|
||||
})
|
||||
|
||||
store := NewTelegramLoginStore(pool)
|
||||
client, err := store.UpsertTelegramLoginClient(ctx, domain.TelegramLoginClient{
|
||||
BotUserID: bot.ID,
|
||||
ClientID: fmt.Sprintf("%d", bot.ID),
|
||||
SecretHash: telegramLoginPGHash("client-secret"),
|
||||
SecretVersion: 1,
|
||||
SigningAlgorithm: domain.TelegramLoginSigningRS256,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert oidc client: %v", err)
|
||||
}
|
||||
redirectURI := fmt.Sprintf("https://rp-%d.example/callback", suffix)
|
||||
origin := fmt.Sprintf("https://rp-%d.example", suffix)
|
||||
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
|
||||
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
|
||||
NormalizedURL: redirectURI, CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("add redirect: %v", err)
|
||||
}
|
||||
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
|
||||
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedWebOrigin,
|
||||
NormalizedURL: origin, CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("add web origin: %v", err)
|
||||
}
|
||||
|
||||
newRequest := func(label string) domain.TelegramLoginRequest {
|
||||
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
||||
RequestTokenHash: telegramLoginPGHash("request-" + label),
|
||||
BrowserTokenHash: telegramLoginPGHash("browser-" + label),
|
||||
BotUserID: bot.ID,
|
||||
ClientID: client.ClientID,
|
||||
SigningAlgorithm: client.SigningAlgorithm,
|
||||
Source: domain.TelegramLoginRequestWeb,
|
||||
ResponseType: "code",
|
||||
RedirectURI: redirectURI,
|
||||
Origin: origin,
|
||||
Domain: fmt.Sprintf("rp-%d.example", suffix),
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopeBotAccess},
|
||||
State: "state",
|
||||
Nonce: "nonce",
|
||||
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox",
|
||||
Platform: "Windows",
|
||||
IP: "192.0.2.10",
|
||||
Region: "Test Region",
|
||||
MatchCodes: []string{"🟢", "🔵", "🟠"},
|
||||
MatchCode: "🔵",
|
||||
MatchCodesFirst: true,
|
||||
Status: domain.TelegramLoginRequestPending,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(5 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create request %s: %v", label, err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
request := newRequest(fmt.Sprintf("race-%d", suffix))
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
<-start
|
||||
_, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
||||
RequestID: request.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
||||
WriteAllowed: true,
|
||||
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
|
||||
}, suffix+10_000)
|
||||
errs <- err
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, err := store.DeclineTelegramLoginRequest(ctx, request.ID, owner.ID, now.Add(time.Second))
|
||||
errs <- err
|
||||
}()
|
||||
close(start)
|
||||
var success, conflict int
|
||||
for range 2 {
|
||||
err := <-errs
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatalf("accept/decline race error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("accept/decline success=%d conflict=%d, want 1/1", success, conflict)
|
||||
}
|
||||
|
||||
codeRequest := newRequest(fmt.Sprintf("code-%d", suffix))
|
||||
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
||||
RequestID: codeRequest.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
||||
WriteAllowed: true,
|
||||
MatchCode: codeRequest.MatchCode, ApprovedAt: now.Add(2 * time.Second),
|
||||
}, suffix+20_000)
|
||||
if err != nil {
|
||||
t.Fatalf("approve code request: %v", err)
|
||||
}
|
||||
canSend, err := bots.CanBotSendMessage(ctx, bot.ID, owner.ID)
|
||||
if err != nil || !canSend {
|
||||
t.Fatalf("bot access after atomic approval = %v,%v", canSend, err)
|
||||
}
|
||||
code := domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: codeRequest.ID,
|
||||
CodeHash: telegramLoginPGHash(fmt.Sprintf("code-%d", suffix)),
|
||||
SealedCode: append(make([]byte, 32), 1),
|
||||
SealNonce: make([]byte, 12),
|
||||
SealKeyID: "integration-key",
|
||||
IssuedAt: now.Add(3 * time.Second),
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
|
||||
t.Fatalf("put code: %v", err)
|
||||
}
|
||||
exchange := domain.TelegramLoginCodeExchange{
|
||||
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
|
||||
RedirectURI: codeRequest.RedirectURI, CodeChallenge: codeRequest.CodeChallenge, Now: now.Add(4 * time.Second),
|
||||
}
|
||||
start = make(chan struct{})
|
||||
errs = make(chan error, 8)
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, exchange)
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
success, conflict = 0, 0
|
||||
for err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatalf("code consume race error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 7 {
|
||||
t.Fatalf("code consume success=%d consumed=%d, want 1/7", success, conflict)
|
||||
}
|
||||
|
||||
miniRequest, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
||||
RequestTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-request-%d", suffix)),
|
||||
BrowserTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-browser-%d", suffix)),
|
||||
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
|
||||
Source: domain.TelegramLoginRequestMiniApp, ResponseType: "post_message",
|
||||
RedirectURI: origin + "/", Origin: origin, InAppOrigin: origin, Domain: fmt.Sprintf("rp-%d.example", suffix),
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
|
||||
Browser: "Telegram Mini App", Platform: "Telegram Mini App", IP: "192.0.2.11", Region: "Test Region",
|
||||
MatchCodes: []string{"🟢", "🔵", "🟠"}, MatchCode: "🔵", MatchCodesFirst: true,
|
||||
Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create mini-app request: %v", err)
|
||||
}
|
||||
if _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
||||
RequestID: miniRequest.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
||||
MatchCode: miniRequest.MatchCode, ApprovedAt: now.Add(5 * time.Second),
|
||||
}, suffix+25_000); err != nil {
|
||||
t.Fatalf("approve mini-app request: %v", err)
|
||||
}
|
||||
directToken := domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: miniRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("mini-token-%d", suffix)),
|
||||
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
|
||||
IssuedAt: now.Add(6 * time.Second), ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, directToken); err != nil {
|
||||
t.Fatalf("put mini-app token: %v", err)
|
||||
}
|
||||
start = make(chan struct{})
|
||||
errs = make(chan error, 8)
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, _, err := store.ConsumeTelegramLoginDirectToken(ctx, directToken.CodeHash, origin, now.Add(7*time.Second))
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
success, conflict = 0, 0
|
||||
for err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatalf("mini-app token consume race error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 7 {
|
||||
t.Fatalf("mini-app token consume success=%d consumed=%d, want 1/7", success, conflict)
|
||||
}
|
||||
|
||||
if revoked, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, web.Hash, now.Add(5*time.Second)); err != nil || !revoked {
|
||||
t.Fatalf("revoke web authorization = %v,%v", revoked, err)
|
||||
}
|
||||
if listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID); err != nil {
|
||||
t.Fatalf("list web authorizations: %v", err)
|
||||
} else {
|
||||
for _, got := range listed {
|
||||
if got.Hash == web.Hash {
|
||||
t.Fatalf("revoked web authorization still listed: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
|
||||
return store.DeleteTelegramLoginAllowedURL(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTelegramLoginStorePostgresNativeCallbackAndRetention(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
suffix := now.UnixNano() % 1_000_000_000
|
||||
|
||||
users := NewUserStore(pool)
|
||||
bots := NewBotStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{
|
||||
AccessHash: suffix + 301, Phone: fmt.Sprintf("1666%09d", suffix), FirstName: "Native Owner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
|
||||
AccessHash: suffix + 302, FirstName: "Native Login Bot", Username: fmt.Sprintf("native_%09d_bot", suffix),
|
||||
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "native-bot-secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) })
|
||||
|
||||
store := NewTelegramLoginStore(pool)
|
||||
client, err := store.CreateTelegramLoginClient(ctx, domain.TelegramLoginClient{
|
||||
BotUserID: bot.ID, ClientID: fmt.Sprintf("%d", bot.ID), SecretHash: telegramLoginPGHash("native-secret"),
|
||||
SecretVersion: 1, SigningAlgorithm: domain.TelegramLoginSigningRS256, Enabled: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const callbackURI = "bedolaga://telegram-login"
|
||||
nativeApp, err := store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{
|
||||
BotUserID: bot.ID, Platform: domain.TelegramLoginNativeAndroid, ApplicationID: "dev.bedolaga.demo",
|
||||
VerificationID: strings.Repeat("A", 64), CallbackURI: callbackURI, VerifiedDisplayName: "Bedolaga Demo",
|
||||
Enabled: true, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
createRequest := func(label string) domain.TelegramLoginRequest {
|
||||
t.Helper()
|
||||
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
||||
RequestTokenHash: telegramLoginPGHash("native-request-" + label), BrowserTokenHash: telegramLoginPGHash("native-browser-" + label),
|
||||
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
|
||||
Source: domain.TelegramLoginRequestNative, ResponseType: "code", RedirectURI: callbackURI,
|
||||
Domain: "dev.bedolaga.demo", Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
|
||||
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", CodeChallengeMethod: "S256",
|
||||
Browser: "TelegramLogin/Android", Platform: "Android", IP: "192.0.2.20", Region: "Test Region",
|
||||
IsApp: true, VerifiedAppName: "Bedolaga Demo", MatchCodes: []string{}, Status: domain.TelegramLoginRequestPending,
|
||||
CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create native request: %v", err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
approve := func(request domain.TelegramLoginRequest, hash int64) domain.TelegramLoginWebAuthorization {
|
||||
t.Helper()
|
||||
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
||||
RequestID: request.ID, Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: "Native Owner", GivenName: "Native"},
|
||||
ApprovedAt: now.Add(time.Second),
|
||||
}, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("approve native request: %v", err)
|
||||
}
|
||||
return web
|
||||
}
|
||||
|
||||
revokedRequest := createRequest(fmt.Sprintf("revoked-%d", suffix))
|
||||
revokedWeb := approve(revokedRequest, suffix+30_000)
|
||||
code := domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: revokedRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("native-code-%d", suffix)),
|
||||
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
|
||||
IssuedAt: now.Add(2 * time.Second), ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{
|
||||
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
|
||||
RedirectURI: callbackURI, CodeChallenge: revokedRequest.CodeChallenge, Now: now.Add(3 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatalf("consume native code: %v", err)
|
||||
}
|
||||
if ok, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, revokedWeb.Hash, now.Add(4*time.Second)); err != nil || !ok {
|
||||
t.Fatalf("revoke native authorization = %v,%v", ok, err)
|
||||
}
|
||||
|
||||
activeRequest := createRequest(fmt.Sprintf("active-%d", suffix))
|
||||
activeWeb := approve(activeRequest, suffix+40_000)
|
||||
deleted, err := store.DeleteExpiredTelegramLoginArtifacts(ctx, now.Add(2*time.Hour), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted < 2 {
|
||||
t.Fatalf("retention deleted=%d, want at least code and revoked request", deleted)
|
||||
}
|
||||
if _, found, _ := store.GetTelegramLoginRequest(ctx, revokedRequest.ID); found {
|
||||
t.Fatal("revoked native request survived retention")
|
||||
}
|
||||
if _, found, _ := store.GetTelegramLoginRequest(ctx, activeRequest.ID); !found {
|
||||
t.Fatal("active native request was deleted")
|
||||
}
|
||||
listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID)
|
||||
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
|
||||
t.Fatalf("active authorization list=%#v err=%v", listed, err)
|
||||
}
|
||||
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
|
||||
return store.DeleteTelegramLoginNativeApp(ctx, client.BotUserID, nativeApp.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func assertTelegramLoginConfigDeleteTakesClientLock(t *testing.T, pool *pgxpool.Pool, botUserID int64, remove func() (bool, error)) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var lockedID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT bot_user_id FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&lockedID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
deleted, err := remove()
|
||||
if err == nil && !deleted {
|
||||
err = errors.New("configuration row was not deleted")
|
||||
}
|
||||
result <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-result:
|
||||
t.Fatalf("configuration delete bypassed client serialization lock: %v", err)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("configuration delete remained blocked after client lock committed")
|
||||
}
|
||||
}
|
||||
|
|
@ -340,6 +340,40 @@ func (s *UserStore) SetVerified(ctx context.Context, userID int64, verified bool
|
|||
return userFromModel(row), nil
|
||||
}
|
||||
|
||||
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。
|
||||
func (s *UserStore) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
|
||||
row, err := s.q.SetUserSupport(ctx, sqlcgen.SetUserSupportParams{
|
||||
ID: userID,
|
||||
Support: support,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.User{}, fmt.Errorf("set user support: %w", err)
|
||||
}
|
||||
return userFromModel(row), nil
|
||||
}
|
||||
|
||||
// SetScamFake 设置/取消用户的 scam 与 fake 标记(bot 复用同一路径)。
|
||||
func (s *UserStore) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
|
||||
if scam && fake {
|
||||
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
row, err := s.q.SetUserScamFake(ctx, sqlcgen.SetUserScamFakeParams{
|
||||
ID: userID,
|
||||
Scam: scam,
|
||||
Fake: fake,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.User{}, fmt.Errorf("set user scam/fake: %w", err)
|
||||
}
|
||||
return userFromModel(row), nil
|
||||
}
|
||||
|
||||
// SweepExpiredPremium 清空到期会员行并返回清理后的用户。
|
||||
func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) {
|
||||
if limit <= 0 {
|
||||
|
|
@ -567,6 +601,8 @@ func userFromModel(r sqlcgen.User) domain.User {
|
|||
Username: r.Username,
|
||||
CountryCode: r.CountryCode,
|
||||
Verified: r.Verified,
|
||||
Scam: r.Scam,
|
||||
Fake: r.Fake,
|
||||
Support: r.Support,
|
||||
Bot: r.IsBot,
|
||||
BotInfoVersion: int(r.BotInfoVersion),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue