feat: sync official star gift lifecycle tooling

Sync telesrv 4c0e2d9 (feat: complete official star gift lifecycle and admin tooling).

Public adjustments: skipped private docs/deploy-nginx/README source changes, kept iamxvbaba/td public dependency, replaced local/private sample IP and orange seed label.
This commit is contained in:
A 2026-07-19 03:11:35 +08:00
parent f2c2fd0236
commit 14bf7d1e20
92 changed files with 14768 additions and 727 deletions

View file

@ -4,6 +4,8 @@ import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
@ -53,6 +55,22 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se
_ = tx.Rollback(ctx)
}
}()
if err := s.appendStarGiftAdminLogTx(ctx, tx, channelID, senderUserID, savedID, date, action); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit star gift admin log: %w", err)
}
committed = true
return nil
}
// appendStarGiftAdminLogTx is the aggregate-local form used when the saved gift,
// inventory/balance mutation and Recent Actions entry must commit together.
func (s *ChannelStore) appendStarGiftAdminLogTx(ctx context.Context, tx pgx.Tx, channelID, senderUserID, savedID int64, date int, action domain.ChannelMessageAction) error {
if channelID == 0 || senderUserID == 0 || savedID <= 0 {
return domain.ErrChannelInvalid
}
channel, err := getChannelByID(ctx, tx, channelID)
if err != nil {
return err
@ -63,29 +81,14 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se
}
action = channelServiceActionForMessage(channelID, messageID, action)
msg := domain.ChannelMessage{
ChannelID: channelID,
ID: messageID,
SenderUserID: senderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID},
Date: date,
Post: channel.Broadcast,
Action: &action,
Pts: channel.Pts,
ChannelID: channelID, ID: messageID, SenderUserID: senderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date,
Post: channel.Broadcast, Action: &action, Pts: channel.Pts,
}
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID,
UserID: senderUserID,
Date: date,
Type: domain.ChannelAdminLogSendMessage,
Message: &msg,
}); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit star gift admin log: %w", err)
}
committed = true
return nil
return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID, UserID: senderUserID, Date: date,
Type: domain.ChannelAdminLogSendMessage, Message: &msg,
})
}
func (s *ChannelStore) appendServiceMessage(ctx context.Context, label string, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {

View file

@ -24,6 +24,16 @@ func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore {
const starGiftCatalogSelect = `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
@ -75,6 +85,16 @@ func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) (
}
gift, err := scanCatalogGift(s.db.QueryRow(ctx, `
SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
@ -95,14 +115,34 @@ WHERE r.id = $1`, revisionID))
func scanCatalogGift(row rowScanner) (domain.StarGift, error) {
var gift domain.StarGift
var attrsJSON, thumbsJSON string
var releasedByType string
var releasedByID int64
var hasBackground bool
var background domain.StarGiftBackground
if err := row.Scan(
&gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title,
&gift.Limited, &gift.SoldOut, &gift.Birthday, &gift.RequirePremium,
&gift.LimitedPerUser, &gift.PeerColorAvailable, &gift.Auction,
&gift.AvailabilityRemains, &gift.AvailabilityTotal, &gift.AvailabilityResale,
&gift.FirstSaleDate, &gift.LastSaleDate, &gift.ResellMinStars,
&releasedByType, &releasedByID, &gift.PerUserTotal, &gift.LockedUntilDate,
&gift.AuctionSlug, &gift.GiftsPerRound, &gift.AuctionStartDate, &gift.UpgradeVariants,
&hasBackground, &background.CenterColor, &background.EdgeColor, &background.TextColor,
&gift.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued,
&gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date,
&gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
); err != nil {
return domain.StarGift{}, err
}
if releasedByType != "" && releasedByID > 0 {
gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID}
}
if hasBackground {
gift.Background = &background
}
if gift.LimitedPerUser {
gift.PerUserRemains = gift.PerUserTotal
}
attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil {
return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err)
@ -148,8 +188,12 @@ func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain.
return fmt.Errorf("allocate star gift id: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_catalog (gift_id, active_revision_id, enabled, sort_order)
VALUES ($1,$2,$3,$4)`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
INSERT INTO star_gift_catalog (
gift_id, active_revision_id, enabled, sort_order, availability_remains,
availability_resale, resell_min_stars, first_sale_date, last_sale_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, giftID, revisionID, write.Enabled, write.SortOrder,
write.AvailabilityRemains, write.AvailabilityResale, write.ResellMinStars,
write.FirstSaleDate, write.LastSaleDate); err != nil {
return fmt.Errorf("insert star gift catalog: %w", err)
}
} else {
@ -180,20 +224,38 @@ WHERE gift_id = $1`, giftID).Scan(&revision); err != nil {
INSERT INTO star_gift_catalog_revisions (
id, gift_id, revision, title, stars, convert_stars, document_id,
animation_json, animation_sha256, source_name, source_format,
width, height, frame_rate, in_point, out_point, created_by, command_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
width, height, frame_rate, in_point, out_point, created_by, command_id,
official_gift_id, source_manifest_sha256, official_source,
limited, sold_out, birthday, require_premium, limited_per_user,
peer_color_available, auction, availability_total,
released_by_peer_type, released_by_peer_id, per_user_total, locked_until_date,
auction_slug, gifts_per_round, auction_start_date, upgrade_variants,
background_center_color, background_edge_color, background_text_color
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
NULLIF($19::bigint,0),$20,$21::jsonb,$22,$23,$24,$25,$26,$27,$28,$29,
$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40
)`,
revisionID, giftID, revision, write.Title, write.Stars, write.ConvertStars, write.Document.ID,
string(write.Animation.JSON), write.Animation.SHA256, write.Animation.SourceName, string(write.Animation.SourceFormat),
write.Animation.Width, write.Animation.Height, write.Animation.FrameRate, write.Animation.InPoint, write.Animation.OutPoint,
write.Actor, write.CommandID,
write.Actor, write.CommandID, write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256), nullableOfficialGiftJSON(write.OfficialSourceJSON),
write.Limited, write.SoldOut, write.Birthday, write.RequirePremium, write.LimitedPerUser,
write.PeerColorAvailable, write.Auction, write.AvailabilityTotal,
nullableStarGiftPeerType(write.ReleasedBy), nullableStarGiftPeerID(write.ReleasedBy), write.PerUserTotal,
write.LockedUntilDate, write.AuctionSlug, write.GiftsPerRound, write.AuctionStartDate,
write.UpgradeVariants, nullableBackgroundColor(write.Background, "center"),
nullableBackgroundColor(write.Background, "edge"), nullableBackgroundColor(write.Background, "text"),
); err != nil {
return fmt.Errorf("insert star gift revision: %w", err)
}
if write.GiftID != 0 {
if _, err := tx.Exec(ctx, `
UPDATE star_gift_catalog
SET active_revision_id=$2, enabled=$3, sort_order=$4, updated_at=now()
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
SET active_revision_id=$2, enabled=$3, sort_order=$4, availability_remains=$5,
availability_resale=$6, resell_min_stars=$7, first_sale_date=$8, last_sale_date=$9, updated_at=now()
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder, write.AvailabilityRemains,
write.AvailabilityResale, write.ResellMinStars, write.FirstSaleDate, write.LastSaleDate); err != nil {
return fmt.Errorf("activate star gift revision: %w", err)
}
}
@ -208,6 +270,62 @@ WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != n
return entry, nil
}
func nullableStarGiftPeerType(peer domain.Peer) any {
if peer.ID <= 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return nil
}
return string(peer.Type)
}
func nullableStarGiftPeerID(peer domain.Peer) any {
if nullableStarGiftPeerType(peer) == nil {
return nil
}
return peer.ID
}
func nullableBackgroundColor(background *domain.StarGiftBackground, component string) any {
if background == nil {
return nil
}
switch component {
case "center":
return background.CenterColor
case "edge":
return background.EdgeColor
default:
return background.TextColor
}
}
func (s *StarGiftStore) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
var result domain.StarGiftCatalogBundleResult
err := withTx(ctx, s.db, "create star gift catalog bundle", func(tx pgx.Tx) error {
nested := NewStarGiftStore(tx)
entry, err := nested.CreateCatalogRevision(ctx, write.Catalog)
if err != nil {
return err
}
result.Catalog = entry
if write.Collectible != nil {
collectibleWrite := *write.Collectible
collectibleWrite.GiftID = entry.Gift.ID
revision, err := nested.PublishCollectibleRevision(ctx, collectibleWrite)
if err != nil {
return err
}
result.Collectible = &revision
entry, err = catalogEntryByID(ctx, tx, entry.Gift.ID)
if err != nil {
return err
}
result.Catalog = entry
}
return nil
})
return result, err
}
func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE star_gift_catalog SET enabled=$2, updated_at=now()
@ -267,6 +385,16 @@ WHERE c.gift_id=$1`, giftID).Scan(&raw)
func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) {
row := db.QueryRow(ctx, `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text,
@ -280,8 +408,20 @@ JOIN documents d ON d.id=r.document_id
WHERE c.gift_id=$1`, giftID)
var entry domain.StarGiftCatalogEntry
var attrsJSON, thumbsJSON, sourceFormat string
var releasedByType string
var releasedByID int64
var hasBackground bool
var background domain.StarGiftBackground
if err := row.Scan(
&entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title,
&entry.Gift.Limited, &entry.Gift.SoldOut, &entry.Gift.Birthday, &entry.Gift.RequirePremium,
&entry.Gift.LimitedPerUser, &entry.Gift.PeerColorAvailable, &entry.Gift.Auction,
&entry.Gift.AvailabilityRemains, &entry.Gift.AvailabilityTotal, &entry.Gift.AvailabilityResale,
&entry.Gift.FirstSaleDate, &entry.Gift.LastSaleDate, &entry.Gift.ResellMinStars,
&releasedByType, &releasedByID, &entry.Gift.PerUserTotal, &entry.Gift.LockedUntilDate,
&entry.Gift.AuctionSlug, &entry.Gift.GiftsPerRound, &entry.Gift.AuctionStartDate,
&entry.Gift.UpgradeVariants, &hasBackground, &background.CenterColor, &background.EdgeColor,
&background.TextColor,
&entry.Gift.UpgradeStars, &entry.Gift.UpgradeTotal, &entry.Gift.UpgradeIssued,
&entry.Gift.Sticker.ID, &entry.Gift.Sticker.AccessHash, &entry.Gift.Sticker.FileReference, &entry.Gift.Sticker.Date,
&entry.Gift.Sticker.MimeType, &entry.Gift.Sticker.Size, &entry.Gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
@ -291,6 +431,15 @@ WHERE c.gift_id=$1`, giftID)
); err != nil {
return domain.StarGiftCatalogEntry{}, err
}
if releasedByType != "" && releasedByID > 0 {
entry.Gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID}
}
if hasBackground {
entry.Gift.Background = &background
}
if entry.Gift.LimitedPerUser {
entry.Gift.PerUserRemains = entry.Gift.PerUserTotal
}
attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
@ -315,14 +464,14 @@ func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (
WITH next_id AS (
SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id
)
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, message)
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, prepaid_upgrade_hash, gift_num, message)
SELECT next_id.id, $1,$2,$3,$4,$5,$6,
CASE WHEN $1 = 'channel' AND $7::bigint = 0 THEN next_id.id ELSE $7::bigint END,
$8,$9,$10,false,$11,$12,$13
$8,$9,$10,false,$11,$12,$13,$14,$15
FROM next_id
RETURNING id`,
string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date,
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.Message).Scan(&id)
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.PrepaidUpgradeHash, gift.GiftNum, gift.Message).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create star gift: %w", err)
}
@ -347,7 +496,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.S
JOIN star_gift_catalog c ON c.gift_id = p.gift_id
LEFT JOIN star_gift_collectible_revisions acr
ON acr.id = c.collectible_revision_id AND acr.status = 'published'`
conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "NOT p.converted"}
conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "p.lifecycle_status = 'active'"}
args := []any{string(owner.Type), owner.ID}
if filter.ExcludeUnsaved {
conditions = append(conditions, "NOT p.unsaved")
@ -394,7 +543,9 @@ WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d
limitPlaceholder := len(args)
rows, err := s.db.Query(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
@ -434,47 +585,95 @@ func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer,
if len(refs) == 0 {
return []int64{}, nil
}
type resolveKey struct {
value int64
slug string
}
keys := make([]resolveKey, 0, len(refs))
values := make([]int64, 0, len(refs))
seenValues := make(map[int64]struct{}, len(refs))
column := "msg_id"
slugs := make([]string, 0, len(refs))
seenKeys := make(map[string]struct{}, len(refs))
for _, ref := range refs {
if ref.Owner != owner || !ref.Valid() {
return nil, domain.ErrStarGiftNotFound
}
if ref.Slug != "" {
slug := strings.ToLower(strings.TrimSpace(ref.Slug))
key := "slug:" + slug
if _, duplicate := seenKeys[key]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenKeys[key] = struct{}{}
keys = append(keys, resolveKey{slug: slug})
slugs = append(slugs, slug)
continue
}
value := int64(ref.MsgID)
if owner.Type == domain.PeerTypeChannel {
column = "saved_id"
value = ref.SavedID
}
if _, duplicate := seenValues[value]; duplicate {
key := fmt.Sprintf("id:%d", value)
if _, duplicate := seenKeys[key]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenValues[value] = struct{}{}
seenKeys[key] = struct{}{}
keys = append(keys, resolveKey{value: value})
values = append(values, value)
}
rows, err := s.db.Query(ctx, `SELECT `+column+`::bigint, id FROM peer_star_gifts
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND `+column+`::bigint=ANY($3::bigint[])`, string(owner.Type), owner.ID, values)
query := `SELECT p.saved_id::bigint, COALESCE(u.slug, ''), p.id
FROM peer_star_gifts p
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
FROM peer_star_gifts p
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.msg_id::bigint=ANY($3::bigint[])
OR u.slug=ANY($4::text[]))`
}
rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs)
if err != nil {
return nil, fmt.Errorf("resolve saved star gifts: %w", err)
}
defer rows.Close()
resolved := make(map[int64]int64, len(values))
resolvedValues := make(map[int64]int64, len(values))
resolvedSlugs := make(map[string]int64, len(slugs))
for rows.Next() {
var value, id int64
if err := rows.Scan(&value, &id); err != nil {
var primaryValue, id int64
var slug string
if err := rows.Scan(&primaryValue, &slug, &id); err != nil {
return nil, fmt.Errorf("scan resolved saved star gift: %w", err)
}
resolved[value] = id
if existing := resolvedValues[primaryValue]; existing != 0 && existing != id {
return nil, domain.ErrStarGiftCollectibleInvalid
}
resolvedValues[primaryValue] = id
if slug != "" {
if existing := resolvedSlugs[slug]; existing != 0 && existing != id {
return nil, domain.ErrStarGiftCollectibleInvalid
}
resolvedSlugs[slug] = id
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err)
}
out := make([]int64, 0, len(values))
for _, value := range values {
id := resolved[value]
out := make([]int64, 0, len(keys))
seenIDs := make(map[int64]struct{}, len(keys))
for _, key := range keys {
id := resolvedValues[key.value]
if key.slug != "" {
id = resolvedSlugs[key.slug]
}
if id == 0 {
return nil, domain.ErrStarGiftNotFound
}
if _, duplicate := seenIDs[id]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenIDs[id] = struct{}{}
out = append(out, id)
}
return out, nil
@ -487,7 +686,9 @@ func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRe
where, args := savedStarGiftRefWhere(ref)
row := s.db.QueryRow(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
@ -510,7 +711,7 @@ func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (in
return 0, nil
}
var n int
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil {
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND lifecycle_status='active' AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil {
return 0, fmt.Errorf("count star gifts: %w", err)
}
return n, nil
@ -524,7 +725,7 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift
args = append(args, unsaved)
tag, err := s.db.Exec(ctx, `
UPDATE peer_star_gifts SET unsaved = $4
WHERE `+where+` AND NOT converted`, args...)
WHERE `+where+` AND lifecycle_status='active'`, args...)
if err != nil {
return false, fmt.Errorf("set star gift unsaved: %w", err)
}
@ -543,7 +744,9 @@ func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarG
where, args := savedStarGiftRefWhere(ref)
row := tx.QueryRow(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
@ -564,13 +767,14 @@ WHERE `+where+` FOR UPDATE`, args...)
if g.UniqueGiftID != 0 {
return domain.ErrStarGiftAlreadyUpgraded
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, lifecycle_status='converted', unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil {
return fmt.Errorf("mark star gift converted: %w", err)
}
if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil {
return err
}
g.Converted = true
g.LifecycleStatus = domain.StarGiftLifecycleConverted
g.Unsaved = true
g.PinnedOrder = 0
g.CollectionIDs = nil
@ -587,7 +791,9 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
var g domain.SavedStarGift
var ownerType string
if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.RevisionID, &g.MsgID, &g.SavedID, &g.Date,
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.Message, &g.UniqueGiftID,
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.PrepaidUpgradeHash, &g.GiftNum,
&g.LifecycleStatus, &g.TransferStars, &g.CanExportAt, &g.CanTransferAt, &g.CanResellAt,
&g.DropOriginalDetailsStars, &g.CanCraftAt, &g.Message, &g.UniqueGiftID,
&g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil {
return domain.SavedStarGift{}, err
}
@ -597,6 +803,10 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
args := []any{string(ref.Owner.Type), ref.Owner.ID}
if ref.Slug != "" {
args = append(args, strings.ToLower(strings.TrimSpace(ref.Slug)))
return "owner_peer_type = $1 AND owner_peer_id = $2 AND unique_gift_id = (SELECT id FROM unique_star_gifts WHERE slug = $3)", args
}
switch ref.Owner.Type {
case domain.PeerTypeChannel:
args = append(args, ref.SavedID)

View file

@ -14,6 +14,34 @@ import (
"telesrv/internal/store/postgres/sqlcgen"
)
func nullablePermille(attribute domain.StarGiftCollectibleAttribute) any {
if attribute.RarityKind != domain.StarGiftRarityPermille {
return nil
}
return attribute.RarityPermille
}
func nullableSHA256(value []byte) any {
if len(value) == 0 {
return nil
}
return value
}
func nullablePositiveInt64(value int64) any {
if value <= 0 {
return nil
}
return value
}
func nullableOfficialGiftJSON(value []byte) any {
if len(value) == 0 {
return nil
}
return string(value)
}
func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
write.Actor = strings.TrimSpace(write.Actor)
@ -38,13 +66,15 @@ SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE
var revisionID int64
if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collectible_revisions
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id)
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7)
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID).Scan(&revisionID); err != nil {
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id,
official_gift_id, source_manifest_sha256)
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7,NULLIF($8::bigint,0),$9)
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID,
write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256)).Scan(&revisionID); err != nil {
return fmt.Errorf("insert collectible revision: %w", err)
}
media := NewMediaStore(tx)
insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute) error {
insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute, models bool) error {
for _, attribute := range attributes {
if err := media.PutDocument(ctx, *attribute.Document); err != nil {
return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err)
@ -53,35 +83,53 @@ RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, wr
return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err)
}
animation := attribute.Animation
query := fmt.Sprintf(`
var query string
if models {
query = fmt.Sprintf(`
INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_permille, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
attribute.RarityPermille, attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
rarity_kind, rarity_permille, crafted, official_document_id, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
string(attribute.RarityKind), nullablePermille(attribute), attribute.Crafted,
nullablePositiveInt64(attribute.OfficialDocumentID), attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
} else {
query = fmt.Sprintf(`
INSERT INTO %s
(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, official_document_id, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
string(attribute.RarityKind), nullablePermille(attribute), nullablePositiveInt64(attribute.OfficialDocumentID),
attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
}
}
return nil
}
if err := insertAnimated("star_gift_collectible_models", write.Models); err != nil {
if err := insertAnimated("star_gift_collectible_models", write.Models, true); err != nil {
return err
}
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns); err != nil {
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns, false); err != nil {
return err
}
for _, attribute := range write.Backdrops {
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_collectible_backdrops
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_permille, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
text_color, rarity_kind, rarity_permille, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
attribute.CenterColor, attribute.EdgeColor, attribute.PatternColor, attribute.TextColor,
attribute.RarityPermille, attribute.SortOrder); err != nil {
string(attribute.RarityKind), nullablePermille(attribute), attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible backdrop: %w", err)
}
}
@ -152,10 +200,11 @@ func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID in
var publishedAt pgtype.Timestamptz
if err := db.QueryRow(ctx, `
SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status,
created_by, created_at, published_at
created_by, created_at, published_at, COALESCE(official_gift_id,0), source_manifest_sha256
FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
&revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal,
&revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt,
&revision.OfficialGiftID, &revision.SourceManifestSHA256,
); err != nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err)
}
@ -184,13 +233,20 @@ func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, rev
return nil, domain.ErrStarGiftCollectibleInvalid
}
rows, err := db.Query(ctx, fmt.Sprintf(`
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_permille, a.sort_order,
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0),
%s, COALESCE(a.official_document_id,0), a.sort_order,
a.animation_json::text, a.animation_sha256, a.source_name, a.source_format,
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM %s a JOIN documents d ON d.id=a.document_id
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisionID)
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
func() string {
if kind == domain.StarGiftCollectibleModel {
return "a.crafted"
}
return "false"
}(), table), revisionID)
if err != nil {
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
}
@ -199,7 +255,8 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio
for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}}
var attrsJSON, thumbsJSON, sourceFormat string
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityPermille, &attribute.SortOrder,
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityKind,
&attribute.RarityPermille, &attribute.Crafted, &attribute.OfficialDocumentID, &attribute.SortOrder,
&attribute.Animation.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat,
&attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint,
&attribute.Document.ID, &attribute.Document.AccessHash, &attribute.Document.FileReference, &attribute.Document.Date,
@ -221,7 +278,7 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
rows, err := db.Query(ctx, `
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_permille, sort_order
text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
if err != nil {
return nil, fmt.Errorf("list collectible backdrops: %w", err)
@ -232,7 +289,7 @@ FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY s
attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop}
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID,
&attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor,
&attribute.RarityPermille, &attribute.SortOrder); err != nil {
&attribute.RarityKind, &attribute.RarityPermille, &attribute.SortOrder); err != nil {
return nil, err
}
out = append(out, attribute)
@ -307,14 +364,24 @@ func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string,
func uniqueStarGiftQuery(predicate string) string {
return fmt.Sprintf(`
SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num,
u.owner_peer_type, u.owner_peer_id, u.keep_original_details, u.created_at,
r.issued, r.supply_total, sg.from_user_id, sg.owner_peer_type, sg.owner_peer_id,
COALESCE(u.owner_peer_type,''), COALESCE(u.owner_peer_id,0), u.keep_original_details, u.created_at,
u.require_premium, u.resale_ton_only, u.theme_available, u.burned, u.crafted,
u.owner_name, u.owner_address, u.gift_address,
COALESCE(l.currency,''), COALESCE(l.amount,0), COALESCE(l.version,0),
COALESCE(u.released_by_peer_type,''), COALESCE(u.released_by_peer_id,0),
u.value_amount, u.value_currency, u.value_usd_amount,
COALESCE(u.theme_peer_type,''), COALESCE(u.theme_peer_id,0),
COALESCE(u.host_peer_type,''), COALESCE(u.host_peer_id,0),
u.offer_min_stars, u.craft_chance_permille, u.last_sale_date,
u.last_sale_currency, u.last_sale_amount,
r.issued, r.supply_total, sg.from_user_id, u.original_owner_peer_type, u.original_owner_peer_id,
sg.gift_date, sg.message, sg.name_hidden,
m.id, m.name, m.rarity_permille, md.id, md.access_hash, md.file_reference, md.date,
m.id, m.name, m.rarity_kind, COALESCE(m.rarity_permille,0), m.crafted, md.id, md.access_hash, md.file_reference, md.date,
md.mime_type, md.size, md.dc_id, md.attributes::text, md.thumbs::text,
p.id, p.name, p.rarity_permille, pd.id, pd.access_hash, pd.file_reference, pd.date,
p.id, p.name, p.rarity_kind, COALESCE(p.rarity_permille,0), pd.id, pd.access_hash, pd.file_reference, pd.date,
pd.mime_type, pd.size, pd.dc_id, pd.attributes::text, pd.thumbs::text,
b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color, b.rarity_permille
b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color,
b.rarity_kind, COALESCE(b.rarity_permille,0)
FROM unique_star_gifts u
JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id
JOIN star_gift_collectible_models m ON m.id=u.model_attribute_id
@ -323,12 +390,14 @@ JOIN star_gift_collectible_patterns p ON p.id=u.pattern_attribute_id
JOIN documents pd ON pd.id=p.document_id
JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_id
JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id
LEFT JOIN star_gift_listings l ON l.unique_gift_id=u.id
WHERE %s`, predicate)
}
func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var unique domain.UniqueStarGift
var ownerType, originalOwnerType string
var ownerType, originalOwnerType, listingCurrency, releasedByType, themePeerType, hostPeerType, lastSaleCurrency string
var listingAmount, lastSaleAmount int64
unique.Model.Kind = domain.StarGiftCollectibleModel
unique.Pattern.Kind = domain.StarGiftCollectiblePattern
unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop
@ -337,23 +406,40 @@ func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var modelAttrs, modelThumbs, patternAttrs, patternThumbs string
if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID,
&unique.Title, &unique.Slug, &unique.Num, &ownerType, &unique.Owner.ID, &unique.KeepOriginalDetails,
&unique.CreatedAt, &unique.AvailabilityIssued, &unique.AvailabilityTotal,
&unique.CreatedAt, &unique.RequirePremium, &unique.ResaleTonOnly, &unique.ThemeAvailable,
&unique.Burned, &unique.Crafted, &unique.OwnerName, &unique.OwnerAddress, &unique.GiftAddress,
&listingCurrency, &listingAmount, &unique.ResellVersion, &releasedByType, &unique.ReleasedBy.ID,
&unique.ValueAmount, &unique.ValueCurrency, &unique.ValueUSD,
&themePeerType, &unique.ThemePeer.ID, &hostPeerType, &unique.Host.ID,
&unique.OfferMinStars, &unique.CraftChancePermille, &unique.LastSaleDate,
&lastSaleCurrency, &lastSaleAmount,
&unique.AvailabilityIssued, &unique.AvailabilityTotal,
&unique.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate,
&unique.OriginalMessage, &unique.OriginalNameHidden,
&unique.Model.ID, &unique.Model.Name, &unique.Model.RarityPermille,
&unique.Model.ID, &unique.Model.Name, &unique.Model.RarityKind, &unique.Model.RarityPermille, &unique.Model.Crafted,
&unique.Model.Document.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference,
&unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size,
&unique.Model.Document.DCID, &modelAttrs, &modelThumbs,
&unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityPermille,
&unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityKind, &unique.Pattern.RarityPermille,
&unique.Pattern.Document.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference,
&unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size,
&unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs,
&unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor,
&unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor, &unique.Backdrop.RarityPermille); err != nil {
&unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor,
&unique.Backdrop.RarityKind, &unique.Backdrop.RarityPermille); err != nil {
return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err)
}
unique.Owner.Type = domain.PeerType(ownerType)
unique.OriginalOwner.Type = domain.PeerType(originalOwnerType)
unique.ReleasedBy.Type = domain.PeerType(releasedByType)
unique.ThemePeer.Type = domain.PeerType(themePeerType)
unique.Host.Type = domain.PeerType(hostPeerType)
if listingCurrency != "" && listingAmount > 0 {
unique.ResellAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(listingCurrency), Amount: listingAmount}
}
if lastSaleCurrency != "" && unique.LastSaleDate > 0 {
unique.LastSaleAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(lastSaleCurrency), Amount: lastSaleAmount}
}
unique.Model.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Backdrop.CollectibleRevisionID = unique.CollectibleRevisionID
@ -603,7 +689,7 @@ func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, own
}
rows, err := db.Query(ctx, `
SELECT id FROM peer_star_gifts
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND id=ANY($3::bigint[])
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND lifecycle_status='active' AND id=ANY($3::bigint[])
FOR UPDATE`, string(owner.Type), owner.ID, ids)
if err != nil {
return nil, err

View file

@ -34,26 +34,35 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityPermille: 1000,
Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 922,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs"),
OfficialDocumentID: 5100000000000000001,
}, {
Kind: domain.StarGiftCollectibleModel, Name: "Crafted Aurora", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
Document: collectibleTestDocumentPtr(baseDocumentID+3, "crafted-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+3, "crafted-model"), Animation: collectibleTestAnimationPtr("crafted-model.tgs"),
OfficialDocumentID: 5100000000000000003,
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityPermille: 1000,
Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 989,
Document: collectibleTestDocumentPtr(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,
RarityPermille: 1000,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999,
}},
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) != 1 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 {
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 2 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 ||
!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)
}
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
@ -74,21 +83,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("issued after rejected manual update = %d err %v, want 0", guardedIssued, err)
}
savedID, err := gifts.Create(ctx, domain.SavedStarGift{
messages := NewMessageStore(pool)
saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700001, Date: 1700001000, ConvertStars: 25, Message: "original",
Date: 1700001000, ConvertStars: 25, Message: "original",
})
if err != nil {
t.Fatalf("create saved gift: %v", err)
}
savedID := saved.ID
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil {
t.Fatalf("grant upgrade stars: %v", err)
}
messages := NewMessageStore(pool)
upgrades := NewStarGiftUpgradeStore(pool, messages)
req := domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700001},
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID},
KeepOriginalDetails: true, ChargeStars: 100, FormID: 991,
CommandKey: "paid-" + suffix, Date: 1700001002,
}
@ -108,6 +115,31 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
ownerMessage.Media.ServiceAction.StarGiftUnique == nil || ownerMessage.Media.ServiceAction.StarGiftUnique.Gift.ID != upgraded.Unique.ID {
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)
}
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil ||
ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade {
t.Fatalf("owner source gift was not durably marked upgraded: %+v", ownerSourceEdit)
}
senderSourceEdit := upgradedSourceEditForUser(upgraded, sender.ID)
if senderSourceEdit.Message.Media == nil || senderSourceEdit.Message.Media.ServiceAction == nil ||
senderSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
senderSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Send.SenderMessage.ID {
t.Fatalf("sender source gift has wrong box-local upgrade link: %+v", senderSourceEdit)
}
difference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, ownerMessage.Pts-1, 4)
if err != nil || len(difference) < 2 || difference[0].Type != domain.UpdateEventNewMessage ||
difference[0].Message.ID != ownerMessage.ID || difference[1].Type != domain.UpdateEventEditMessage ||
difference[1].Message.ID != saved.MsgID || difference[1].Message.Media == nil ||
difference[1].Message.Media.ServiceAction == nil || difference[1].Message.Media.ServiceAction.StarGift == nil ||
difference[1].Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID {
t.Fatalf("owner upgrade difference = %+v err %v", difference, err)
}
var (
issued, uniqueCount, commandCount int
@ -128,12 +160,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
if issued != 1 || uniqueCount != 1 || commandCount != 1 || reason != string(domain.StarsReasonGiftUpgrade) {
t.Fatalf("durable aggregate issued=%d unique=%d command=%d reason=%q", issued, uniqueCount, commandCount, reason)
}
receipt, found, err := upgrades.StarGiftUpgradeReceipt(ctx, owner.ID, req.CommandKey)
if err != nil || !found || receipt.SourceSavedGiftID != savedID || receipt.UniqueGiftID != upgraded.Unique.ID ||
receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid ||
!receipt.KeepOriginalDetails || receipt.BalanceAfter != 900 || receipt.SourceEditPts != ownerSourceEdit.Event.Pts {
t.Fatalf("upgrade receipt = %+v found=%v err=%v", receipt, found, err)
}
replayed, err := upgrades.UpgradeStarGift(ctx, req)
if err != nil {
t.Fatalf("replay upgrade: %v", err)
}
if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 {
if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 ||
upgradedSourceEditForUser(replayed, owner.ID).Event.Pts != ownerSourceEdit.Event.Pts {
t.Fatalf("replayed upgrade = %+v", replayed)
}
conflictingReplay := req
@ -152,17 +191,15 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("balance after retries = %+v err %v", bal, err)
}
prepaidSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
prepaidSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
// A later pool revision may raise the current price; the historical paid
// amount remains an entitlement instead of being compared to that price.
MsgID: 700002, Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
})
if err != nil {
t.Fatalf("create prepaid saved gift: %v", err)
}
prepaidSavedID := prepaidSaved.ID
prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700002},
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaidSaved.MsgID},
RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005,
})
if err != nil {
@ -174,26 +211,24 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("prepaid upgrade = %+v", prepaid)
}
insufficientSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
insufficientSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700003, Date: 1700001006, ConvertStars: 25,
Date: 1700001006, ConvertStars: 25,
})
if err != nil {
t.Fatalf("create insufficient saved gift: %v", err)
}
insufficientSavedID := insufficientSaved.ID
if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction,
domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil {
t.Fatalf("seed isolated paid reaction debit: %v", err)
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003},
ChargeStars: 100, CommandKey: "insufficient-" + suffix, Date: 1700001008,
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID},
ChargeStars: 100, FormID: 994, CommandKey: "insufficient-" + suffix, Date: 1700001008,
}); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("insufficient upgrade err = %v", err)
}
insufficientSaved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
if err != nil || !found || insufficientSaved.ID != insufficientSavedID || insufficientSaved.UniqueGiftID != 0 {
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientSaved, found, err)
insufficientAfter, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID})
if err != nil || !found || insufficientAfter.ID != insufficientSavedID || insufficientAfter.UniqueGiftID != 0 {
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientAfter, found, err)
}
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil || issued != 2 {
t.Fatalf("issued after rejected upgrade = %d err %v, want 2", issued, err)
@ -220,12 +255,10 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "")
concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID}
if _, err := gifts.Create(ctx, domain.SavedStarGift{
concurrentSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700004, Date: 1700001010, ConvertStars: 25,
}); err != nil {
t.Fatalf("create concurrent upgrade target: %v", err)
}
Date: 1700001010, ConvertStars: 25,
})
if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil {
t.Fatalf("grant concurrent balance: %v", err)
}
@ -238,7 +271,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
go func() {
<-start
_, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: 700004},
UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: concurrentSaved.MsgID},
ChargeStars: 100, FormID: 993, CommandKey: "concurrent-upgrade-" + suffix, Date: 1700001012,
})
results <- concurrentDebitResult{kind: "gift_upgrade", err: err}
@ -302,19 +335,19 @@ 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", RarityPermille: 1000,
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", RarityPermille: 1000,
Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(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,
RarityPermille: 1000,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
}},
Actor: "integration", CommandID: "soldout-pool-" + suffix,
})
@ -323,27 +356,26 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
}
soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "")
soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID}
for index, msgID := range []int{700010, 700011} {
if _, err := gifts.Create(ctx, domain.SavedStarGift{
soldOutSaved := make([]domain.SavedStarGift, 0, 2)
for index := range 2 {
soldOutSaved = append(soldOutSaved, createCollectibleSavedGift(t, ctx, messages, gifts, soldOutEntry.Gift, domain.SavedStarGift{
Owner: soldOutPeer, FromUserID: sender.ID, GiftID: soldOutEntry.Gift.ID, RevisionID: soldOutEntry.Gift.RevisionID,
MsgID: msgID, Date: 1700001020 + index, ConvertStars: 10,
}); err != nil {
t.Fatalf("create sold-out target %d: %v", msgID, err)
}
Date: 1700001020 + index, ConvertStars: 10,
}))
}
if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil {
t.Fatalf("grant sold-out owner balance: %v", err)
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700010},
ChargeStars: 10, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[0].MsgID},
ChargeStars: 10, FormID: 995, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
}); err != nil {
t.Fatalf("fill collectible supply: %v", err)
}
balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700011},
ChargeStars: 10, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[1].MsgID},
ChargeStars: 10, FormID: 996, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
}); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) {
t.Fatalf("sold-out upgrade err = %v", err)
}
@ -357,7 +389,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
if err != nil {
t.Fatalf("create ordinary collection: %v", err)
}
converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID})
if err != nil || !converted.Converted || converted.PinnedOrder != 0 || len(converted.CollectionIDs) != 0 {
t.Fatalf("convert collection member = %+v err %v", converted, err)
}
@ -386,6 +418,106 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
}
}
func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1779"+suffix+"51", "NoCraftSender", "")
owner := createTestUser(t, ctx, users, "+1779"+suffix+"52", "NoCraftOwner", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "No Craft " + suffix, Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "no-craft-gift.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "no-craft-gift"), Animation: collectibleTestAnimation("no-craft-gift.tgs"),
Actor: "integration", CommandID: "no-craft-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create no-craft catalog gift: %v", err)
}
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: collectibleTestDocumentPtr(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,
}},
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 {
t.Fatalf("no-craft pool models = %+v", revision.Models)
}
messages := NewMessageStore(pool)
saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
Date: now, ConvertStars: 25,
})
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, now); err != nil {
t.Fatalf("grant no-craft upgrade stars: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 750,
}))
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID},
ChargeStars: 100, FormID: 551, CommandKey: "no-craft-upgrade-" + suffix, Date: now + 1,
})
if err != nil {
t.Fatalf("upgrade no-craft gift: %v", err)
}
uniqueAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if upgraded.Unique.CraftChancePermille != 0 || upgraded.Saved.CanCraftAt != 0 ||
uniqueAction == nil || uniqueAction.Gift.CraftChancePermille != 0 || uniqueAction.CanCraftAt != 0 {
t.Fatalf("no-craft capability leaked: saved=%+v unique=%+v action=%+v", upgraded.Saved, upgraded.Unique, uniqueAction)
}
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000)
page, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 10)
if err != nil || page.Count != 0 || len(page.Gifts) != 0 {
t.Fatalf("no-craft candidate page = %+v err %v", page, err)
}
if _, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{
UserID: owner.ID, Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: saved.MsgID}},
CommandKey: "no-craft-attempt-" + suffix, Date: now + 2,
}); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
t.Fatalf("no-craft attempt err = %v", err)
}
var lifecycleStatus string
var burned bool
var commandCount int
if err := pool.QueryRow(ctx, `SELECT p.lifecycle_status,u.burned
FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p.id=$1`, upgraded.Saved.ID).
Scan(&lifecycleStatus, &burned); err != nil {
t.Fatalf("load no-craft aggregate: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`,
owner.ID, "no-craft-attempt-"+suffix).Scan(&commandCount); err != nil {
t.Fatalf("count no-craft commands: %v", err)
}
if lifecycleStatus != "active" || burned || commandCount != 0 {
t.Fatalf("no-craft attempt mutated aggregate: status=%q burned=%t commands=%d", lifecycleStatus, burned, commandCount)
}
}
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
@ -427,3 +559,53 @@ func collectibleTestBlobPtr(id int64, suffix string) *domain.FileBlob {
blob := collectibleTestBlob(id, suffix)
return &blob
}
// createCollectibleSavedGift seeds the same valid source-message + saved-gift
// invariant as the purchase aggregate. Tests must not invent a peer_star_gifts
// msg_id that has no durable message box behind it.
func createCollectibleSavedGift(
t *testing.T,
ctx context.Context,
messages *MessageStore,
gifts *StarGiftStore,
gift domain.StarGift,
saved domain.SavedStarGift,
) domain.SavedStarGift {
t.Helper()
sticker := gift.Sticker
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: saved.FromUserID,
RecipientUserID: saved.Owner.ID,
RandomID: (time.Now().UnixNano() & 0x7fffffffffffffff) ^ saved.Owner.ID ^ int64(saved.Date),
Date: saved.Date,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars,
Title: gift.Title, Sticker: &sticker, Message: saved.Message,
FromUserID: saved.FromUserID, PeerUserID: saved.Owner.ID, Saved: true,
CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0,
UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars,
},
}},
})
if err != nil {
t.Fatalf("create collectible source message: %v", err)
}
saved.MsgID = sent.RecipientMessage.ID
id, err := gifts.Create(ctx, saved)
if err != nil {
t.Fatalf("create saved gift: %v", err)
}
saved.ID = id
return saved
}
func upgradedSourceEditForUser(result domain.StarGiftUpgradeResult, userID int64) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID == userID {
return edit
}
}
return domain.EditedMessageForUser{UserID: userID}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,227 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// markCraftInputMessagesTx makes the chat projection part of the same commit
// as the craft outcome. TDesktop derives the Craft entry directly from the
// messageActionStarGiftUnique snapshot, so changing only peer_star_gifts and
// unique_star_gifts would leave an already-burned input actionable.
func (s *StarGiftLifecycleStore) markCraftInputMessagesTx(
ctx context.Context,
tx pgx.Tx,
req domain.StarGiftCraftRequest,
savedIDs []int64,
) ([]domain.EditedMessageForUser, []int32, error) {
edits := make([]domain.EditedMessageForUser, 0, len(savedIDs)*2)
ownerPTS := make([]int32, 0, len(savedIDs))
for _, savedID := range savedIDs {
saved, found, err := savedStarGiftByID(ctx, tx, savedID)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UniqueGiftID <= 0 || saved.UpgradeMsgID <= 0 {
if err != nil {
return nil, nil, err
}
return nil, nil, domain.ErrStarGiftCraftUnavailable
}
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID)
if err != nil || !found {
if err != nil {
return nil, nil, err
}
return nil, nil, domain.ErrStarGiftCraftUnavailable
}
inputEdits, ownerPT, err := s.markCraftInputMessageTx(ctx, tx, req, saved, unique)
if err != nil {
return nil, nil, err
}
edits = append(edits, inputEdits...)
ownerPTS = append(ownerPTS, int32(ownerPT))
}
return edits, ownerPTS, nil
}
func (s *StarGiftLifecycleStore) markCraftInputMessageTx(
ctx context.Context,
tx pgx.Tx,
req domain.StarGiftCraftRequest,
saved domain.SavedStarGift,
unique domain.UniqueStarGift,
) ([]domain.EditedMessageForUser, int, error) {
q := sqlcgen.New(tx)
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: req.UserID,
BoxID: int32(saved.UpgradeMsgID),
PeerType: string(domain.PeerTypeUser),
PeerID: saved.FromUserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, domain.ErrStarGiftCraftUnavailable
}
return nil, 0, fmt.Errorf("lock craft input 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, 0, fmt.Errorf("list craft input message boxes: %w", err)
}
if len(boxes) == 0 {
return nil, 0, domain.ErrStarGiftCraftUnavailable
}
edits := make([]domain.EditedMessageForUser, 0, len(boxes))
ownerPTS := 0
var privateMediaJSON []byte
for _, box := range boxes {
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, 0, fmt.Errorf("decode craft input message media: %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 != unique.ID {
return nil, 0, fmt.Errorf("craft input message %d has invalid unique gift projection", box.BoxID)
}
action := media.ServiceAction.StarGiftUnique
action.Gift = unique
action.Saved = saved.LifecycleStatus.Live() && !saved.Unsaved
action.CanExportAt = saved.CanExportAt
action.TransferStars = saved.TransferStars
action.CanTransferAt = saved.CanTransferAt
action.CanResellAt = saved.CanResellAt
action.DropOriginalDetailsStars = saved.DropOriginalDetailsStars
action.CanCraftAt = saved.CanCraftAt
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return nil, 0, fmt.Errorf("encode craft input message media: %w", err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
if err != nil {
return nil, 0, fmt.Errorf("allocate craft input 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, 0, fmt.Errorf("update craft input message box: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, 0, fmt.Errorf("update craft input message box lost row")
}
msg, err := messageFromVisibleBoxRow(box)
if err != nil {
return nil, 0, 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, 0, 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, 0, fmt.Errorf("append craft input edit event: %w", err)
}
dispatchAuthKeyID := [8]byte{}
dispatchSessionID := int64(0)
if msg.OwnerUserID == req.UserID {
dispatchAuthKeyID = req.OriginAuthKeyID
dispatchSessionID = req.OriginSessionID
ownerPTS = pts
}
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, 0, fmt.Errorf("enqueue craft input 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 ownerPTS <= 0 || len(privateMediaJSON) == 0 {
return nil, 0, fmt.Errorf("craft input message missing owner projection")
}
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, 0, fmt.Errorf("update craft input private message: %w", err)
}
return edits, ownerPTS, nil
}
func (s *StarGiftLifecycleStore) loadCraftInputMessageReplays(
ctx context.Context,
req domain.StarGiftCraftRequest,
savedIDs []int64,
ptsValues []int32,
) ([]domain.EditedMessageForUser, error) {
if len(savedIDs) != len(ptsValues) {
return nil, domain.ErrStarGiftCraftUnavailable
}
edits := make([]domain.EditedMessageForUser, 0, len(savedIDs))
for i, savedID := range savedIDs {
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UpgradeMsgID <= 0 || ptsValues[i] <= 0 {
if err != nil {
return nil, err
}
return nil, domain.ErrStarGiftCraftUnavailable
}
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.UpgradeMsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("load craft input 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 craft input replay box: %w", err)
}
if len(boxes) != 1 || int(boxes[0].BoxID) != saved.UpgradeMsgID {
return nil, domain.ErrStarGiftCraftUnavailable
}
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, ptsValues[i], saved.UpgradeMsgID).Scan(&eventDate)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrStarGiftCraftUnavailable
}
return nil, fmt.Errorf("load craft input replay event: %w", err)
}
msg, err := messageFromVisibleBoxRow(boxes[0])
if err != nil {
return nil, err
}
msg.Pts = int(ptsValues[i])
event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage,
Pts: int(ptsValues[i]), PtsCount: 1, Date: eventDate, Message: msg}
edits = append(edits, domain.EditedMessageForUser{UserID: req.UserID, Message: msg, Event: event})
}
return edits, nil
}

View file

@ -0,0 +1,282 @@
package postgres
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func (s *StarGiftLifecycleStore) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) {
hash = strings.TrimSpace(hash)
if s == nil || s.db == nil || !validLifecyclePeer(owner) || len(hash) < 32 || len(hash) > 256 {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
row := s.db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id,
p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[])
FROM peer_star_gifts p WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3`,
string(owner.Type), owner.ID, hash)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID != 0 || saved.PrepaidUpgradeStars != 0 {
if err != nil {
return domain.SavedStarGift{}, 0, err
}
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
revision, err := locklessActiveCollectibleRevision(ctx, s.db, saved.GiftID)
if err != nil || revision.UpgradeStars <= 0 || revision.Issued >= revision.SupplyTotal {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
return saved, revision.UpgradeStars, nil
}
func locklessActiveCollectibleRevision(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, giftID int64) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
err := db.QueryRow(ctx, `SELECT r.id,r.gift_id,r.upgrade_stars,r.supply_total,r.issued,r.slug_prefix,r.status
FROM star_gift_catalog c JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
WHERE c.gift_id=$1`, giftID).Scan(&revision.ID, &revision.GiftID, &revision.UpgradeStars,
&revision.SupplyTotal, &revision.Issued, &revision.SlugPrefix, &status)
if err != nil || status != "published" {
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
}
return revision, nil
}
func (s *StarGiftLifecycleStore) PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) {
req.Hash, req.CommandKey = strings.TrimSpace(req.Hash), strings.TrimSpace(req.CommandKey)
if s == nil || s.messages == nil || req.PayerUserID <= 0 || !validLifecyclePeer(req.Owner) ||
len(req.Hash) < 32 || len(req.Hash) > 256 || req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
if replay, found, err := s.loadPrepaidUpgradeReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found {
return replay, err
}
if req.ChargeStars <= 0 {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
target, price, err := s.PrepaidUpgradeTarget(ctx, req.Owner, req.Hash)
if err != nil || price != req.ChargeStars {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
fingerprint := sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-prepay:v2:%d:%s:%d:%s:%d:%d", req.PayerUserID,
req.Owner.Type, req.Owner.ID, req.Hash, req.FormID, req.ChargeStars)))
placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true, CanUpgrade: true, UpgradeSeparate: true}}}
messageSenderID, recipientUserID := req.PayerUserID, req.Owner.ID
if req.Owner.Type == domain.PeerTypeChannel {
messageSenderID, recipientUserID = domain.OfficialSystemUserID, req.PayerUserID
}
messageReq := domain.SendPrivateTextRequest{SenderUserID: messageSenderID, RecipientUserID: recipientUserID,
RandomID: lifecycleCommandRandomID("prepay", req.PayerUserID, req.Owner.ID, req.Hash), Media: placeholder, Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.PayerUserID,
IdempotencyFingerprint: fingerprint[:]}
var result domain.StarGiftPrepaidUpgradeResult
hooks := privateSendTxHooks{before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error {
locked, err := lockSavedStarGiftByPrepayHash(ctx, tx, req.Owner, req.Hash)
if err != nil || locked.ID != target.ID || !locked.LifecycleStatus.Live() || locked.UniqueGiftID != 0 || locked.PrepaidUpgradeStars != 0 {
return domain.ErrStarGiftCollectibleUnavailable
}
revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID)
if err != nil || revision.UpgradeStars != req.ChargeStars || revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleUnavailable
}
balance, err := s.debitLifecycleAmount(ctx, tx, req.PayerUserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars},
domain.StarsReasonGiftPrepaid, req.Owner, req.Date, "Prepaid star gift upgrade")
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET prepaid_upgrade_stars=$2,prepaid_upgrade_hash='' WHERE id=$1`, locked.ID, req.ChargeStars); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_prepaid_upgrade_commands(payer_user_id,command_key,saved_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil {
return err
}
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, locked.RevisionID)
if err != nil || !found {
return domain.ErrStarGiftCollectibleUnavailable
}
sticker := gift.Sticker
action := &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: locked.ConvertStars, Title: gift.Title, Sticker: &sticker,
FromUserID: req.PayerUserID, To: req.Owner, SavedID: locked.SavedID, Saved: true, CanUpgrade: true,
PrepaidUpgrade: true, UpgradeSeparate: true, UpgradePriceStars: req.ChargeStars,
UpgradeStars: req.ChargeStars, GiftMsgID: locked.MsgID,
}
if req.Owner.Type == domain.PeerTypeChannel {
action.PeerChannelID = req.Owner.ID
} else {
action.PeerUserID = req.Owner.ID
}
messageReq.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{
GiftID: action.GiftID, Stars: action.Stars, ConvertStars: action.ConvertStars, Title: action.Title,
Sticker: action.Sticker, FromUserID: action.FromUserID, PeerUserID: action.PeerUserID,
PeerChannelID: action.PeerChannelID, To: action.To, SavedID: action.SavedID, Saved: action.Saved,
CanUpgrade: action.CanUpgrade, PrepaidUpgrade: action.PrepaidUpgrade, UpgradeSeparate: action.UpgradeSeparate,
UpgradePriceStars: action.UpgradePriceStars, UpgradeStars: action.UpgradeStars, GiftMsgID: action.GiftMsgID}}}
locked.PrepaidUpgradeStars, locked.PrepaidUpgradeHash = req.ChargeStars, ""
result.Saved, result.Balance = locked, balance
return nil
}, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
if req.Owner.Type != domain.PeerTypeChannel {
return nil
}
action := messageReq.Media.ServiceAction.StarGift
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,
result.Saved.SavedID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: action})
}}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPrepaidUpgradeResult{}, err
}
result.Send, result.Duplicate = sent, sent.Duplicate
if sent.Duplicate {
replay, _, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent)
return replay, replayErr
}
return result, nil
}
func lockSavedStarGiftByPrepayHash(ctx context.Context, tx pgx.Tx, owner domain.Peer, hash string) (domain.SavedStarGift, error) {
row := tx.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id,
p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[])
FROM peer_star_gifts p WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3 FOR UPDATE`,
string(owner.Type), owner.ID, hash)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, domain.ErrStarGiftCollectibleUnavailable
}
return saved, err
}
func (s *StarGiftLifecycleStore) loadPrepaidUpgradeReplay(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPrepaidUpgradeResult, bool, error) {
var savedID, balance int64
err := s.db.QueryRow(ctx, `SELECT saved_gift_id,balance_after FROM star_gift_prepaid_upgrade_commands WHERE payer_user_id=$1 AND command_key=$2`,
req.PayerUserID, req.CommandKey).Scan(&savedID, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftPrepaidUpgradeResult{}, false, nil
}
if err != nil {
return domain.StarGiftPrepaidUpgradeResult{}, false, err
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftPrepaidUpgradeResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
return domain.StarGiftPrepaidUpgradeResult{Saved: saved, Balance: domain.StarsBalance{UserID: req.PayerUserID, Balance: balance}, Send: sent, Duplicate: true}, true, nil
}
func (s *StarGiftLifecycleStore) DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) {
req.CommandKey = strings.TrimSpace(req.CommandKey)
if s == nil || s.db == nil || req.UserID <= 0 || !req.Ref.Valid() ||
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) || !validLifecyclePeer(req.Ref.Owner) ||
req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
if replay, found, err := s.loadDropDetailsReplay(ctx, req); err != nil || found {
return replay, err
}
if req.ChargeStars <= 0 {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
var result domain.StarGiftDropOriginalDetailsResult
err := withTx(ctx, s.db, "drop star gift original details", func(tx pgx.Tx) error {
saved, unique, err := lockOwnedUniqueStarGift(ctx, tx, req.UserID, req.Ref)
if err != nil || saved.DropOriginalDetailsStars != req.ChargeStars || !unique.KeepOriginalDetails {
return domain.ErrStarGiftCollectibleUnavailable
}
balance, err := s.debitLifecycleAmount(ctx, tx, req.UserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars},
domain.StarsReasonGiftDrop, saved.Owner, req.Date, "Drop star gift original details")
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET keep_original_details=false,updated_at=now() WHERE id=$1`, unique.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET drop_original_details_stars=0 WHERE id=$1`, saved.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_drop_details_commands(user_id,command_key,saved_gift_id,unique_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, req.UserID, req.CommandKey, saved.ID, unique.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil {
return err
}
saved.DropOriginalDetailsStars, unique.KeepOriginalDetails = 0, false
result = domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique, Balance: balance}
return nil
})
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadDropDetailsReplay(ctx, req); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftDropOriginalDetailsResult{}, err
}
return result, nil
}
func (s *StarGiftLifecycleStore) loadDropDetailsReplay(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, bool, error) {
var savedID, uniqueID, balance int64
err := s.db.QueryRow(ctx, `SELECT saved_gift_id,unique_gift_id,balance_after FROM star_gift_drop_details_commands WHERE user_id=$1 AND command_key=$2`,
req.UserID, req.CommandKey).Scan(&savedID, &uniqueID, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftDropOriginalDetailsResult{}, false, nil
}
if err != nil {
return domain.StarGiftDropOriginalDetailsResult{}, false, err
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
if err != nil || !found {
return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
return domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique,
Balance: domain.StarsBalance{UserID: req.UserID, Balance: balance}, Duplicate: true}, true, nil
}
func savedStarGiftByID(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, savedID int64) (domain.SavedStarGift, bool, error) {
row := db.QueryRow(ctx, `SELECT p.id,p.owner_peer_type,p.owner_peer_id,p.from_user_id,p.gift_id,p.catalog_revision_id,
p.msg_id,p.saved_id,p.gift_date,p.name_hidden,p.unsaved,p.converted,p.convert_stars,p.prepaid_upgrade_stars,p.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
p.message,COALESCE(p.unique_gift_id,0),p.upgrade_msg_id,p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order,i.collection_id) FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id),ARRAY[]::integer[])
FROM peer_star_gifts p WHERE p.id=$1`, savedID)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, false, nil
}
return saved, err == nil, err
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,827 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"telesrv/internal/domain"
)
func TestStarGiftLifecycleAggregatePostgres(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, "+1881"+suffix+"01", "GiftBuyer", "")
owner := createTestUser(t, ctx, users, "+1881"+suffix+"02", "GiftOwner", "")
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", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
stars := NewStarsStore(pool)
for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser} {
if _, _, err := stars.EnsureGrant(ctx, user.ID, 10000, now); err != nil {
t.Fatalf("grant stars to %d: %v", user.ID, err)
}
}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Lifecycle " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "lifecycle.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "lifecycle"), Animation: collectibleTestAnimation("lifecycle.tgs"),
Actor: "integration", CommandID: "lifecycle-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create lifecycle catalog: %v", err)
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "life-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
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")},
},
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(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)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
GiftID: entry.Gift.ID, CommandKey: "purchase-" + suffix, Date: now, Message: "hello"})
purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
if err != nil {
t.Fatalf("purchase gift: %v", err)
}
if purchased.Saved.ID <= 0 || purchased.Saved.MsgID <= 0 || purchased.Saved.PrepaidUpgradeHash == "" || purchased.Balance.Balance != 9950 {
t.Fatalf("purchase result = %+v", purchased)
}
ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift
if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade ||
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 {
t.Fatalf("ordinary purchase action mixed paid price with prepaid amount: %+v", ordinaryAction)
}
replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 ||
replayedPurchase.Send.SenderMessage.ID != purchased.Send.SenderMessage.ID ||
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err)
}
target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash)
if err != nil || target.ID != purchased.Saved.ID || price != 100 {
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,
ChargeStars: 100, FormID: 11002, CommandKey: "prepay-" + suffix, Date: now + 1,
})
if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9850 {
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},
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "upgrade-" + suffix, Date: now + 2,
})
if err != nil {
t.Fatalf("upgrade prepaid gift: %v", err)
}
if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 ||
upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails {
t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique)
}
upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) ||
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)
}
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,
})
if err != nil || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 || dropped.Balance.Balance != 9975 {
t.Fatalf("drop original details = %+v err %v", dropped, err)
}
// Expiry is driven by the background sweep, refunds exactly once and emits a
// durable declined/expired service message even when no user opens the offer.
expiring, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300},
Duration: 120, RandomID: 22001, Date: now + 10,
})
if err != nil || expiring.Balance.Balance != 9700 {
t.Fatalf("send expiring offer = %+v err %v", expiring, err)
}
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+131, 1000); err != nil {
t.Fatalf("sweep expired offer: %v", err)
}
var expiredStatus string
var resolutionNotified bool
if err := pool.QueryRow(ctx, `SELECT status,resolution_notified FROM star_gift_offers WHERE id=$1`, expiring.Offer.ID).
Scan(&expiredStatus, &resolutionNotified); err != nil || expiredStatus != "expired" || !resolutionNotified {
t.Fatalf("expired offer state = %q notified %v err %v", expiredStatus, resolutionNotified, err)
}
if balance, err := stars.GetBalance(ctx, offerBuyer.ID); err != nil || balance.Balance != 10000 {
t.Fatalf("expired offer refund balance = %+v err %v", balance, err)
}
// TON offers use the same durable offer state machine, but only mutate the
// internal telesrv TON ledger. Idempotent replay must report that ledger's
// balance instead of accidentally projecting the buyer's Stars balance.
tonOfferReq := domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 300},
Duration: 120, RandomID: 22003, Date: now + 132}
tonOffer, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq)
if err != nil || tonOffer.Balance.Balance != 999700 {
t.Fatalf("send TON offer = %+v err %v", tonOffer, err)
}
tonOfferReplay, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq)
if err != nil || !tonOfferReplay.Duplicate || tonOfferReplay.Balance.Balance != 999700 {
t.Fatalf("replay TON offer = %+v err %v", tonOfferReplay, err)
}
if _, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{
OwnerUserID: owner.ID, OfferMsgID: tonOffer.Offer.OfferMsgID, Decline: true, Date: now + 133,
}); err != nil {
t.Fatalf("decline TON offer: %v", err)
}
if balance, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || balance != 1_000_000 {
t.Fatalf("declined TON offer refund balance = %d err %v", balance, err)
}
acceptedOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300},
Duration: 120, RandomID: 22002, Date: now + 140,
})
if err != nil {
t.Fatalf("send accepted offer: %v", err)
}
accepted, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{
OwnerUserID: owner.ID, OfferMsgID: acceptedOffer.Offer.OfferMsgID, Date: now + 141,
})
if err != nil || accepted.Offer.Status != "accepted" || accepted.Unique.Owner.ID != offerBuyer.ID || accepted.Saved.MsgID <= 0 {
t.Fatalf("accept offer = %+v err %v", accepted, err)
}
if balance, err := stars.GetBalance(ctx, owner.ID); err != nil || balance.Balance != 10245 {
t.Fatalf("offer seller balance = %+v err %v", balance, err)
}
var offerCommission int64
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`,
fmt.Sprintf("offer:%d", acceptedOffer.Offer.ID)).Scan(&offerCommission); err != nil || offerCommission != 30 {
t.Fatalf("accepted Stars offer commission = %d err %v", offerCommission, err)
}
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: offerBuyer.ID,
Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: offerBuyer.ID}, MsgID: accepted.Saved.MsgID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 142,
})
if err != nil || listed.ResellAmount == nil || listed.ResellAmount.Currency != domain.StarGiftCurrencyTON {
t.Fatalf("TON listing = %+v err %v", listed, err)
}
tonBefore, err := lifecycle.TonBalance(ctx, resaleBuyer.ID)
if err != nil || tonBefore != 1_000_000 {
t.Fatalf("resale buyer TON grant = %d err %v", tonBefore, err)
}
resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{
BuyerUserID: resaleBuyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID},
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 11004,
CommandKey: "resale-" + suffix, Date: now + 143,
})
if err != nil || resold.Unique.Owner.ID != resaleBuyer.ID || resold.Balance.Balance != 999000 || resold.Saved.TransferStars != 25 {
t.Fatalf("TON resale = %+v err %v", resold, err)
}
if sellerTON, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || sellerTON != 1_000_900 {
t.Fatalf("TON seller local balance = %d err %v", sellerTON, err)
}
var resaleCommission int64
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, "resale-"+suffix).
Scan(&resaleCommission); err != nil || resaleCommission != 100 {
t.Fatalf("TON resale commission = %d err %v", resaleCommission, err)
}
tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, "", 20)
if err != nil || tonPage.Balance != 999000 || len(tonPage.Transactions) < 2 {
t.Fatalf("TON ledger page = %+v err %v", tonPage, err)
}
transferred, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{ActorUserID: resaleBuyer.ID,
Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}, MsgID: resold.Saved.MsgID},
To: ownerPeer, ChargeStars: 25, FormID: 11005, CommandKey: "transfer-back-" + suffix, Date: now + 144,
})
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
t.Fatalf("paid transfer = %+v err %v", transferred, err)
}
// A second prepaid collectible makes craft chance exactly 1000‰. Success
// preserves the first aggregate as crafted and burns the other input. The
// fresh payment intent must create another gift even though buyer, owner and
// catalog gift are identical to the first purchase.
secondPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-second-" + suffix, Date: now + 145})
secondPurchase, err := lifecycle.PurchaseStarGift(ctx, secondPurchaseReq)
if err != nil {
t.Fatalf("purchase second prepaid gift: %v", err)
}
prepaidAction := secondPurchase.Send.RecipientMessage.Media.ServiceAction.StarGift
if prepaidAction == nil || !prepaidAction.PrepaidUpgrade || prepaidAction.UpgradePriceStars != 100 || prepaidAction.UpgradeStars != 100 {
t.Fatalf("prepaid purchase action lost price/entitlement split: %+v", prepaidAction)
}
secondUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: secondPurchase.Saved.MsgID}, RequirePrepaid: true,
CommandKey: "upgrade-second-" + suffix, Date: now + 146,
})
if err != nil {
t.Fatalf("upgrade second prepaid gift: %v", err)
}
listedForCraft, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 146,
})
if err != nil || listedForCraft.ResellAmount == nil || listedForCraft.ResellAmount.Amount != 125 {
t.Fatalf("list craft input = %+v err %v", listedForCraft, err)
}
loserBalanceBeforeOffer, err := stars.GetBalance(ctx, loser.ID)
if err != nil {
t.Fatalf("craft offer buyer balance: %v", err)
}
pendingCraftOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: loser.ID,
Owner: ownerPeer, Slug: transferred.Unique.Slug,
Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125},
Duration: 120, RandomID: 22003, Date: now + 146,
})
if err != nil || pendingCraftOffer.Offer.Status != "pending" {
t.Fatalf("pending craft offer = %+v err %v", pendingCraftOffer, err)
}
resolvedCraftIDs, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
})
if err != nil || len(resolvedCraftIDs) != 2 || resolvedCraftIDs[0] != transferred.Saved.ID || resolvedCraftIDs[1] != secondUpgrade.Saved.ID {
t.Fatalf("resolve mixed craft refs = %v err %v", resolvedCraftIDs, err)
}
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)
}
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)
}
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("duplicate official identities err = %v", err)
}
crafted, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{UserID: owner.ID,
Refs: []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
// TDesktop sends collectibles without a manage id as the official
// inputSavedStarGiftSlug alias.
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}, CommandKey: "craft-" + suffix, Date: now + 147,
})
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)
}
craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID)
craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit)
burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID)
burnedInputAction := starGiftUniqueActionFromEdit(burnedInputEdit)
if craftedInputAction == nil || !craftedInputAction.Gift.Crafted || craftedInputAction.Gift.Burned ||
craftedInputAction.Gift.CraftChancePermille != 0 || !craftedInputAction.Saved || craftedInputAction.CanCraftAt != 0 {
t.Fatalf("crafted input message projection = %+v", craftedInputAction)
}
if burnedInputAction == nil || !burnedInputAction.Gift.Burned || burnedInputAction.Gift.CraftChancePermille != 0 ||
burnedInputAction.Saved || burnedInputAction.CanCraftAt != 0 {
t.Fatalf("burned input message projection = %+v", burnedInputAction)
}
craftReq := domain.StarGiftCraftRequest{UserID: owner.ID,
Refs: []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}, CommandKey: "craft-" + suffix, Date: now + 147,
}
craftedReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
if err != nil || !craftedReplay.Duplicate || !craftedReplay.Success || craftedReplay.Gift == nil ||
craftedReplay.Send.RecipientMessage.ID != crafted.Send.RecipientMessage.ID ||
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, transferred.Unique.ID).Event.Pts != craftedInputEdit.Event.Pts ||
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts {
t.Fatalf("craft success replay = %+v err %v", craftedReplay, 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 {
t.Fatalf("craft input listings = %d err %v", craftListings, err)
}
if err := pool.QueryRow(ctx, `SELECT availability_resale FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&resaleAvailability); err != nil || resaleAvailability != 0 {
t.Fatalf("craft resale projection = %d err %v", resaleAvailability, err)
}
var craftOfferStatus string
if err := pool.QueryRow(ctx, `SELECT status FROM star_gift_offers WHERE id=$1`, pendingCraftOffer.Offer.ID).Scan(&craftOfferStatus); err != nil || craftOfferStatus != "cancelled" {
t.Fatalf("craft offer status = %q err %v", craftOfferStatus, err)
}
loserBalanceAfterCraft, err := stars.GetBalance(ctx, loser.ID)
if err != nil || loserBalanceAfterCraft.Balance != loserBalanceBeforeOffer.Balance {
t.Fatalf("craft offer refund balance = %+v err %v, want %d", loserBalanceAfterCraft, err, loserBalanceBeforeOffer.Balance)
}
var secondStatus string
if err := pool.QueryRow(ctx, `SELECT lifecycle_status FROM peer_star_gifts WHERE id=$1`, secondUpgrade.Saved.ID).Scan(&secondStatus); err != nil || secondStatus != "burned" {
t.Fatalf("second craft input status = %q err %v", secondStatus, err)
}
// A failed draw is just as terminal as success: the input aggregate and both
// users' message snapshots are burned in the outcome transaction. An exact
// retry replays the receipt, while a fresh command cannot consume it again.
thirdPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-third-" + suffix, Date: now + 148})
thirdPurchase, err := lifecycle.PurchaseStarGift(ctx, thirdPurchaseReq)
if err != nil {
t.Fatalf("purchase third prepaid gift: %v", err)
}
thirdUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: thirdPurchase.Saved.MsgID}, RequirePrepaid: true,
CommandKey: "upgrade-third-" + suffix, Date: now + 149,
})
if err != nil {
t.Fatalf("upgrade third prepaid gift: %v", err)
}
failingLifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000,
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}},
CommandKey: "craft-fail-" + suffix, Date: now + 150,
}
failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq)
if err != nil || failedCraft.Success || failedCraft.Chance != 500 || failedCraft.Gift != nil {
t.Fatalf("craft failure result = %+v err %v", failedCraft, err)
}
failedInputEdit := craftedSourceEditForUserAndGift(failedCraft, owner.ID, thirdUpgrade.Unique.ID)
failedInputAction := starGiftUniqueActionFromEdit(failedInputEdit)
if failedInputAction == nil || !failedInputAction.Gift.Burned || failedInputAction.Gift.CraftChancePermille != 0 ||
failedInputAction.Gift.OfferMinStars != 0 || failedInputAction.Saved || failedInputAction.CanCraftAt != 0 {
t.Fatalf("failed craft message projection = %+v", failedInputAction)
}
var failedLifecycle string
var failedUnsaved bool
var failedTransferStars int64
var failedCanExportAt, failedCanTransferAt, failedCanResellAt, failedCanCraftAt int
var failedDropStars int64
if err := pool.QueryRow(ctx, `SELECT lifecycle_status,unsaved,transfer_stars,can_export_at,can_transfer_at,
can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHERE id=$1`, thirdUpgrade.Saved.ID).
Scan(&failedLifecycle, &failedUnsaved, &failedTransferStars, &failedCanExportAt, &failedCanTransferAt,
&failedCanResellAt, &failedDropStars, &failedCanCraftAt); err != nil || failedLifecycle != "burned" || !failedUnsaved ||
failedTransferStars != 0 || failedCanExportAt != 0 || failedCanTransferAt != 0 || failedCanResellAt != 0 ||
failedDropStars != 0 || failedCanCraftAt != 0 {
t.Fatalf("failed craft saved aggregate = status %q unsaved %v transfer %d export %d transfer_at %d resale %d drop %d craft %d err %v",
failedLifecycle, failedUnsaved, failedTransferStars, failedCanExportAt, failedCanTransferAt,
failedCanResellAt, failedDropStars, failedCanCraftAt, err)
}
var failedBurned bool
var failedChance, failedOfferMin int
if err := pool.QueryRow(ctx, `SELECT burned,craft_chance_permille,offer_min_stars FROM unique_star_gifts WHERE id=$1`, thirdUpgrade.Unique.ID).
Scan(&failedBurned, &failedChance, &failedOfferMin); err != nil || !failedBurned || failedChance != 0 || failedOfferMin != 0 {
t.Fatalf("failed craft unique aggregate = burned %v chance %d offer %d err %v", failedBurned, failedChance, failedOfferMin, err)
}
failedReplay, err := failingLifecycle.CraftStarGift(ctx, failureReq)
if err != nil || !failedReplay.Duplicate || failedReplay.Success || failedReplay.Chance != failedCraft.Chance ||
craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts {
t.Fatalf("craft failure replay = %+v err %v", failedReplay, err)
}
invalidRetry := failureReq
invalidRetry.CommandKey = "craft-fail-new-command-" + suffix
if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
t.Fatalf("fresh command reused burned craft input: %v", err)
}
craftCandidates, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 20)
if err != nil || craftCandidates.Count != 0 || len(craftCandidates.Gifts) != 0 {
t.Fatalf("terminal craft inputs remained candidates: %+v err %v", craftCandidates, err)
}
withdrawalReq := domain.StarGiftWithdrawalRequest{UserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, Date: now + 151}
recorded, err := lifecycle.RecordStarGiftWithdrawal(ctx, withdrawalReq, "local", "withdraw-"+suffix,
"https://telesrv.invalid/gift-withdrawal/"+suffix, now+748)
if err != nil || recorded.Status != "pending" {
t.Fatalf("record local withdrawal = %+v err %v", recorded, err)
}
completed, err := lifecycle.CompleteStarGiftWithdrawal(ctx, recorded.ProviderRequestID, now+152)
if err != nil || completed.Status != "completed" || completed.Gift.OwnerAddress == "" || completed.Gift.GiftAddress == "" {
t.Fatalf("complete local withdrawal = %+v err %v", completed, err)
}
// Auction winner reservation is consumed; the unreachable lower bid is
// refunded atomically. Award delivery is durable and includes gift_num.
auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true,
AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10,
AuctionSlug: "auction-" + suffix,
Document: collectibleTestDocument(baseDocumentID+100, "auction.tgs"),
Blob: collectibleTestBlob(baseDocumentID+100, "auction"), Animation: collectibleTestAnimation("auction.tgs"),
Actor: "integration", CommandID: "auction-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create auction catalog: %v", err)
}
winnerState, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: resaleBuyer.ID,
GiftID: auctionEntry.Gift.ID, Peer: ownerPeer, BidAmount: 200, FormID: 12001, Date: now, Message: "winner"})
if err != nil || winnerState.UserState.BidAmount != 200 {
t.Fatalf("winner bid state = %+v err %v", winnerState, err)
}
if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: loser.ID,
GiftID: auctionEntry.Gift.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: loser.ID},
BidAmount: 150, FormID: 12002, Date: now + 1}); err != nil {
t.Fatalf("loser bid: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+2); err != nil {
t.Fatalf("make auction round due: %v", err)
}
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+2, 1000); err != nil {
t.Fatalf("settle auction sweep: %v", err)
}
acquired, err := lifecycle.StarGiftAuctionAcquired(ctx, resaleBuyer.ID, auctionEntry.Gift.ID)
if err != nil || len(acquired) != 1 || acquired[0].GiftNum != 1 || acquired[0].BidAmount != 200 {
t.Fatalf("auction acquired = %+v err %v", acquired, err)
}
if loserBalance, err := stars.GetBalance(ctx, loser.ID); err != nil || loserBalance.Balance != 10000 {
t.Fatalf("auction loser refund = %+v err %v", loserBalance, err)
}
var auctionSavedCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE gift_id=$1 AND gift_num=1 AND convert_stars=0`, auctionEntry.Gift.ID).
Scan(&auctionSavedCount); err != nil || auctionSavedCount != 1 {
t.Fatalf("auction saved award count = %d err %v", auctionSavedCount, err)
}
}
func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
actor := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ChannelGiftActor", "")
if _, _, err := NewStarsStore(pool).EnsureGrant(ctx, actor.ID, 10000, now); err != nil {
t.Fatalf("grant actor stars: %v", err)
}
created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: actor.ID, Title: "Gift Channel " + suffix, Megagroup: true, Date: now,
})
if err != nil {
t.Fatalf("create gift channel: %v", err)
}
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
createdTarget, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: actor.ID, Title: "Gift Target Channel " + suffix, Megagroup: true, Date: now,
})
if err != nil {
t.Fatalf("create target gift channel: %v", err)
}
targetChannelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: createdTarget.Channel.ID}
gifts := NewStarGiftStore(pool)
baseDocumentID := (time.Now().UnixNano() & 0x7ffffffffffff000) + 500
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Channel Gift " + suffix, Stars: 50, ConvertStars: 20, Enabled: true, Limited: true,
AvailabilityTotal: 5, AvailabilityRemains: 5,
Document: collectibleTestDocument(baseDocumentID, "channel-gift.tgs"), Blob: collectibleTestBlob(baseDocumentID, "channel-gift"),
Animation: collectibleTestAnimation("channel-gift.tgs"), Actor: "integration", CommandID: "channel-gift-" + suffix,
})
if err != nil {
t.Fatalf("create channel gift catalog: %v", err)
}
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: collectibleTestDocumentPtr(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}},
Actor: "integration", CommandID: "channel-gift-pool-" + suffix,
}); err != nil {
t.Fatalf("publish channel gift pool: %v", err)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
channelPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1})
purchased, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq)
if err != nil || purchased.Saved.SavedID <= 0 || purchased.Balance.Balance != 9950 {
t.Fatalf("atomic channel purchase = %+v err %v", purchased, err)
}
var regularLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(&regularLogs); err != nil || regularLogs != 1 {
t.Fatalf("channel purchase admin logs = %d err %v", regularLogs, err)
}
var channelPrice string
var channelPrepaidAmount any
if err := pool.QueryRow(ctx, `SELECT message #>> '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}'
FROM channel_admin_log_events WHERE channel_id=$1 AND event_type='send_message' ORDER BY id DESC LIMIT 1`, created.Channel.ID).
Scan(&channelPrice, &channelPrepaidAmount); err != nil || channelPrice != "100" || channelPrepaidAmount != nil {
t.Fatalf("channel ordinary action price=%q prepaid=%v err=%v", channelPrice, channelPrepaidAmount, err)
}
if replay, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq); err != nil || !replay.Duplicate {
t.Fatalf("channel purchase replay = %+v err %v", replay, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(&regularLogs); err != nil || regularLogs != 1 {
t.Fatalf("channel replay duplicated admin log count=%d err %v", regularLogs, err)
}
converted, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 2})
if err != nil || !converted.Saved.Converted || converted.OwnerBalance != 20 {
t.Fatalf("atomic channel conversion = %+v err %v", converted, err)
}
var channelBalance, conversionRows, conversionTxns int64
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 {
t.Fatalf("channel conversion balance = %d err %v", channelBalance, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_conversions WHERE saved_gift_id=$1`, purchased.Saved.ID).Scan(&conversionRows); err != nil || conversionRows != 1 {
t.Fatalf("channel conversion command rows = %d err %v", conversionRows, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_stars_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, entry.Gift.ID).Scan(&conversionTxns); err != nil || conversionTxns != 1 {
t.Fatalf("channel conversion transactions = %d err %v", conversionTxns, err)
}
if balance, err := lifecycle.ChannelStarsBalance(ctx, created.Channel.ID); err != nil || balance != 20 {
t.Fatalf("channel stars balance projection = %d err %v", balance, err)
}
starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, "", 20)
if err != nil || starsPage.Balance != 20 || len(starsPage.Transactions) != 1 ||
starsPage.Transactions[0].Amount != 20 || starsPage.Transactions[0].Reason != domain.StarsReasonGift {
t.Fatalf("channel stars transaction projection = %+v err %v", starsPage, err)
}
if _, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 3}); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
t.Fatalf("repeated channel conversion err = %v, want already converted", err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 {
t.Fatalf("channel balance after replay = %d err %v", channelBalance, err)
}
// A third party may prepay the upgrade entitlement of a channel-owned gift.
// The payer's personal Stars and the channel saved-gift entitlement commit
// together; the payment is also visible in channel Recent Actions.
channelPrepayTargetReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
GiftID: entry.Gift.ID, CommandKey: "channel-prepay-target-" + suffix, Date: now + 4})
channelPrepayTarget, err := lifecycle.PurchaseStarGift(ctx, channelPrepayTargetReq)
if err != nil || channelPrepayTarget.Saved.PrepaidUpgradeHash == "" {
t.Fatalf("channel prepay target purchase = %+v err %v", channelPrepayTarget, err)
}
prepayTarget, prepayPrice, err := lifecycle.PrepaidUpgradeTarget(ctx, channelPeer, channelPrepayTarget.Saved.PrepaidUpgradeHash)
if err != nil || prepayTarget.ID != channelPrepayTarget.Saved.ID || prepayPrice != 100 {
t.Fatalf("channel prepay target = %+v price=%d err=%v", prepayTarget, prepayPrice, err)
}
channelPrepayReq := domain.StarGiftPrepaidUpgradeRequest{
PayerUserID: actor.ID, Owner: channelPeer, Hash: channelPrepayTarget.Saved.PrepaidUpgradeHash,
ChargeStars: 100, FormID: 21006, CommandKey: "channel-prepay-" + suffix, Date: now + 4,
}
channelPrepay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq)
if err != nil || channelPrepay.Saved.PrepaidUpgradeStars != 100 || channelPrepay.Saved.PrepaidUpgradeHash != "" ||
channelPrepay.Send.RecipientMessage.OwnerUserID != actor.ID {
t.Fatalf("channel prepaid entitlement = %+v err %v", channelPrepay, err)
}
var prepayLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 {
t.Fatalf("channel prepaid upgrade admin logs = %d err %v", prepayLogs, err)
}
channelPrepayReplay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq)
if err != nil || !channelPrepayReplay.Duplicate || channelPrepayReplay.Saved.ID != channelPrepay.Saved.ID {
t.Fatalf("channel prepaid entitlement replay = %+v err %v", channelPrepayReplay, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 {
t.Fatalf("channel prepaid upgrade replay logs = %d err %v", prepayLogs, err)
}
var ptsBeforeUpgrade int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsBeforeUpgrade); err != nil {
t.Fatal(err)
}
prepaidPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "channel-prepaid-purchase-" + suffix, Date: now + 4})
prepaidPurchase, err := lifecycle.PurchaseStarGift(ctx, prepaidPurchaseReq)
if err != nil || prepaidPurchase.Saved.PrepaidUpgradeStars != 100 || prepaidPurchase.Saved.SavedID <= 0 {
t.Fatalf("channel prepaid gift purchase = %+v err %v", prepaidPurchase, err)
}
upgradeReq := domain.StarGiftUpgradeRequest{UserID: actor.ID,
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: prepaidPurchase.Saved.SavedID}, RequirePrepaid: true,
KeepOriginalDetails: true, CommandKey: "channel-upgrade-" + suffix, Date: now + 5,
}
upgraded, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
if err != nil || upgraded.Saved.Owner != channelPeer || upgraded.Unique.Owner != channelPeer ||
upgraded.Saved.SavedID != prepaidPurchase.Saved.SavedID || upgraded.Send.RecipientMessage.OwnerUserID != actor.ID {
t.Fatalf("channel prepaid upgrade = %+v err %v", upgraded, err)
}
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer ||
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 {
t.Fatalf("channel upgrade service action = %+v", action)
}
var ptsAfterUpgrade int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsAfterUpgrade); err != nil || ptsAfterUpgrade != ptsBeforeUpgrade {
t.Fatalf("channel pts after profile gift upgrade = %d want %d err %v", ptsAfterUpgrade, ptsBeforeUpgrade, err)
}
var upgradeLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 {
t.Fatalf("channel upgrade admin logs = %d err %v", upgradeLogs, err)
}
replayedUpgrade, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
if err != nil || !replayedUpgrade.Duplicate || replayedUpgrade.Unique.ID != upgraded.Unique.ID {
t.Fatalf("channel upgrade replay = %+v err %v", replayedUpgrade, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 {
t.Fatalf("channel upgrade replay admin logs = %d err %v", upgradeLogs, err)
}
dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{
UserID: actor.ID, Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID},
ChargeStars: 25, FormID: 21007, CommandKey: "channel-drop-details-" + suffix, Date: now + 6,
})
if err != nil || dropped.Saved.Owner != channelPeer || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 {
t.Fatalf("channel drop original details = %+v err %v", dropped, err)
}
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 6,
})
if err != nil || listed.ResellAmount == nil || listed.Owner != channelPeer {
t.Fatalf("list channel collectible = %+v err %v", listed, err)
}
if balance, err := lifecycle.TonBalance(ctx, actor.ID); err != nil || balance != 1_000_000 {
t.Fatalf("channel resale buyer local TON grant = %d err %v", balance, err)
}
resaleReq := domain.StarGiftResalePurchaseRequest{BuyerUserID: actor.ID, Slug: listed.Slug, To: targetChannelPeer,
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 21004,
CommandKey: "channel-to-channel-resale-" + suffix, Date: now + 7,
}
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 {
t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err)
}
var channelTON, channelTONTxns, targetResaleLogs, commission int64
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 local TON proceeds = %d err %v", channelTON, err)
}
if balance, err := lifecycle.ChannelTonBalance(ctx, created.Channel.ID); err != nil || balance != 900 {
t.Fatalf("channel ton balance projection = %d err %v", balance, err)
}
tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, "", 20)
if err != nil || tonPage.Balance != 900 || len(tonPage.Transactions) != 1 ||
tonPage.Transactions[0].Amount != 900 || tonPage.Transactions[0].Reason != domain.StarsReasonGiftResale {
t.Fatalf("channel ton transaction projection = %+v err %v", tonPage, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_ton_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, listed.ID).Scan(&channelTONTxns); err != nil || channelTONTxns != 1 {
t.Fatalf("channel local TON transactions = %d err %v", channelTONTxns, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`,
createdTarget.Channel.ID).Scan(&targetResaleLogs); err != nil || targetResaleLogs != 1 {
t.Fatalf("target channel resale admin logs = %d err %v", targetResaleLogs, err)
}
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, resaleReq.CommandKey).Scan(&commission); err != nil || commission != 100 {
t.Fatalf("channel TON resale commission = %d err %v", commission, err)
}
resaleReplay, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
if err != nil || !resaleReplay.Duplicate || resaleReplay.Unique.ID != resold.Unique.ID {
t.Fatalf("channel resale replay = %+v err %v", resaleReplay, err)
}
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)
}
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 {
t.Fatal(err)
}
balanceBefore, _ := NewStarsStore(pool).GetBalance(ctx, actor.ID)
invalidChannelReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID,
To: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID + 999999}, GiftID: entry.Gift.ID,
CommandKey: "invalid-channel-purchase-" + suffix, Date: now + 2})
_, err = lifecycle.PurchaseStarGift(ctx, invalidChannelReq)
if err == nil {
t.Fatal("purchase to missing channel unexpectedly succeeded")
}
var remainsAfter int
if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsAfter); err != nil || remainsAfter != remainsBefore {
t.Fatalf("inventory after rolled-back channel purchase = %d want %d err %v", remainsAfter, remainsBefore, err)
}
if balanceAfter, err := NewStarsStore(pool).GetBalance(ctx, actor.ID); err != nil || balanceAfter.Balance != balanceBefore.Balance {
t.Fatalf("balance after rolled-back channel purchase = %+v want %+v err %v", balanceAfter, balanceBefore, err)
}
auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Channel Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true,
AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10,
AuctionSlug: "channel-auction-" + suffix,
Document: collectibleTestDocument(baseDocumentID+100, "channel-auction.tgs"), Blob: collectibleTestBlob(baseDocumentID+100, "channel-auction"),
Animation: collectibleTestAnimation("channel-auction.tgs"), Actor: "integration", CommandID: "channel-auction-" + suffix,
})
if err != nil {
t.Fatalf("create channel auction: %v", err)
}
if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: actor.ID,
GiftID: auctionEntry.Gift.ID, Peer: channelPeer, BidAmount: 100, FormID: 22001, Date: now + 3,
}); err != nil {
t.Fatalf("bid channel auction: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+4); err != nil {
t.Fatal(err)
}
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+4, 1000); err != nil {
t.Fatalf("settle channel auction: %v", err)
}
var awardSavedID int64
if err := pool.QueryRow(ctx, `SELECT saved_gift_id FROM star_gift_auction_acquired WHERE gift_id=$1`, auctionEntry.Gift.ID).Scan(&awardSavedID); err != nil || awardSavedID <= 0 {
t.Fatalf("channel auction saved id = %d err %v", awardSavedID, err)
}
var awardLogs int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channel.ID).Scan(&awardLogs); err != nil || awardLogs != 1 {
t.Fatalf("channel auction admin logs = %d err %v", awardLogs, err)
}
}
func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore,
req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest {
t.Helper()
var revisionID int64
if err := lifecycle.db.QueryRow(ctx, `SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1`, req.GiftID).Scan(&revisionID); err != nil {
t.Fatalf("load active gift revision: %v", err)
}
gift, found, err := NewStarGiftStore(lifecycle.db).CatalogRevision(ctx, revisionID)
if err != nil || !found {
t.Fatalf("load gift revision %d: found=%v err=%v", revisionID, found, err)
}
req.RevisionID = gift.RevisionID
req.ChargeStars = gift.Stars
if req.IncludeUpgrade {
req.ChargeStars += gift.UpgradeStars
}
issued, err := lifecycle.IssueStarGiftPurchaseForm(ctx, domain.StarGiftPurchaseForm{
BuyerUserID: req.BuyerUserID, To: req.To, GiftID: req.GiftID, RevisionID: req.RevisionID,
IncludeUpgrade: req.IncludeUpgrade, HideName: req.HideName, Message: req.Message, ChargeStars: req.ChargeStars,
IssuedAt: req.Date, ExpiresAt: req.Date + 600,
})
if err != nil {
t.Fatalf("issue purchase form: %v", err)
}
req.FormID = issued.FormID
return req
}
func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID != userID {
continue
}
action := starGiftUniqueActionFromEdit(edit)
if action != nil && action.Gift.ID == uniqueGiftID {
return edit
}
}
return domain.EditedMessageForUser{UserID: userID}
}
func starGiftUniqueActionFromEdit(edit domain.EditedMessageForUser) *domain.MessageStarGiftUniqueAction {
if edit.Message.Media == nil || edit.Message.Media.ServiceAction == nil {
return nil
}
return edit.Message.Media.ServiceAction.StarGiftUnique
}

View file

@ -0,0 +1,20 @@
package postgres
import (
"os"
"testing"
)
func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
status, err := MigrateAndStatus(dsn)
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 105 {
t.Fatalf("migration status = %+v, want clean version 105", status)
}
}

View file

@ -0,0 +1,94 @@
package postgres
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
store := NewStarGiftStore(pool)
baseID := time.Now().UnixNano() & 0x7ffffffffffff000
manifestSHA := make([]byte, 32)
for i := range manifestSHA {
manifestSHA[i] = 0x5a
}
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.CenterColor, value.EdgeColor, value.PatternColor, value.TextColor = 1, 2, 3, 4
return value
}
value.Document = collectibleTestDocumentPtr(id, name+".tgs")
value.Blob = collectibleTestBlobPtr(id, name)
value.Animation = collectibleTestAnimationPtr(name + ".tgs")
value.OfficialDocumentID = 5200000000000000000 + id%1000
return value
}
bundle := domain.StarGiftCatalogBundleWrite{
Catalog: domain.StarGiftCatalogWrite{
Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseID, "official.tgs"), Blob: collectibleTestBlob(baseID, "official"),
Animation: collectibleTestAnimation("official.tgs"), Actor: "integration", CommandID: "official-catalog-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA,
OfficialSourceJSON: []byte(`{"id":5170145012310081615,"sold_out":true,"birthday":false}`),
},
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,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA,
},
}
result, err := store.CreateCatalogBundle(ctx, bundle)
if err != nil {
t.Fatalf("create official bundle: %v", err)
}
if result.Catalog.Gift.ID == 0 || result.Collectible == nil || result.Catalog.Gift.UpgradeStars != 100 {
t.Fatalf("bundle result = %+v", result)
}
var sourceID int64
var soldOut bool
if err := pool.QueryRow(ctx, `
SELECT official_gift_id, (official_source->>'sold_out')::boolean
FROM star_gift_catalog_revisions WHERE id=$1`, result.Catalog.Gift.RevisionID).Scan(&sourceID, &soldOut); err != nil {
t.Fatal(err)
}
if sourceID != 5170145012310081615 || !soldOut {
t.Fatalf("source id=%d sold_out=%v", sourceID, soldOut)
}
failing := bundle
failing.Catalog.CommandID = "official-rollback-" + suffix
failing.Catalog.Document = collectibleTestDocument(baseID+100, "rollback.tgs")
failing.Catalog.Blob = collectibleTestBlob(baseID+100, "rollback")
failing.Collectible = &domain.StarGiftCollectibleWrite{
UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "rollback-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
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,
}
if _, err := store.CreateCatalogBundle(ctx, failing); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("failing bundle err=%v", err)
}
var rows int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM star_gift_catalog_revisions WHERE command_id=$1`, failing.Catalog.CommandID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 0 {
t.Fatalf("failed bundle left %d catalog revisions", rows)
}
}

View file

@ -0,0 +1,344 @@
package postgres
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func (s *StarGiftLifecycleStore) IssueStarGiftPurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) {
if s == nil || s.db == nil || form.FormID != 0 || form.BuyerUserID <= 0 || !validLifecyclePeer(form.To) ||
form.GiftID <= 0 || form.RevisionID <= 0 || form.ChargeStars <= 0 || form.IssuedAt <= 0 ||
form.ExpiresAt != form.IssuedAt+600 || len([]rune(form.Message)) > 128 {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid
}
for attempt := 0; attempt < 8; attempt++ {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return domain.StarGiftPurchaseForm{}, fmt.Errorf("generate star gift form id: %w", err)
}
form.FormID = int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
if form.FormID == 0 {
form.FormID = 1
}
_, err := s.db.Exec(ctx, `INSERT INTO star_gift_purchase_forms(buyer_user_id,form_id,gift_id,revision_id,
recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message,charge_stars,issued_at,expires_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, form.BuyerUserID, form.FormID, form.GiftID, form.RevisionID,
string(form.To.Type), form.To.ID, form.IncludeUpgrade, form.HideName, form.Message, form.ChargeStars, form.IssuedAt, form.ExpiresAt)
if err == nil {
return form, nil
}
if !isUniqueViolation(err) {
return domain.StarGiftPurchaseForm{}, err
}
}
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
func (s *StarGiftLifecycleStore) ValidateStarGiftPurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error {
if s == nil || s.db == nil {
return domain.ErrStarGiftUnavailable
}
return validateStarGiftPurchaseForm(ctx, s.db, req, false)
}
func validateStarGiftPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarGiftPurchaseRequest, lock bool) error {
if req.BuyerUserID <= 0 || req.FormID == 0 || req.Date <= 0 {
return domain.ErrStarGiftFormExpired
}
query := `SELECT gift_id,revision_id,recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message,
charge_stars,issued_at,expires_at FROM star_gift_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2`
if lock {
query += ` FOR UPDATE`
}
var form domain.StarGiftPurchaseForm
var peerType string
err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID).Scan(&form.GiftID, &form.RevisionID, &peerType, &form.To.ID,
&form.IncludeUpgrade, &form.HideName, &form.Message, &form.ChargeStars, &form.IssuedAt, &form.ExpiresAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftFormExpired
}
if err != nil {
return err
}
form.FormID, form.BuyerUserID, form.To.Type = req.FormID, req.BuyerUserID, domain.PeerType(peerType)
if form.ExpiresAt < req.Date {
return domain.ErrStarGiftFormExpired
}
if form.To != req.To || form.GiftID != req.GiftID || form.IncludeUpgrade != req.IncludeUpgrade ||
form.HideName != req.HideName || form.Message != req.Message {
return domain.ErrStarGiftFormPurposeInvalid
}
if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars {
return domain.ErrStarGiftFormAmountMismatch
}
return nil
}
func (s *StarGiftLifecycleStore) PurchaseStarGift(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
req.CommandKey = strings.TrimSpace(req.CommandKey)
if s == nil || s.db == nil || req.BuyerUserID <= 0 || !validLifecyclePeer(req.To) || req.GiftID <= 0 ||
req.FormID == 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftInvalid
}
if replay, found, err := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found {
return replay, err
}
if err := s.ValidateStarGiftPurchaseForm(ctx, req); err != nil {
return domain.StarGiftPurchaseResult{}, err
}
if req.To.Type == domain.PeerTypeChannel {
return s.purchaseStarGiftToChannel(ctx, req)
}
if s.messages == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
}
fingerprint := starGiftPurchaseFingerprint(req)
messageReq := domain.SendPrivateTextRequest{SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID,
RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID,
RecipientBlocked: req.RecipientBlocked, IdempotencyFingerprint: fingerprint[:],
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true}}}}
var result domain.StarGiftPurchaseResult
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req)
if err != nil {
return err
}
sticker := gift.Sticker
send.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{GiftID: gift.ID,
Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title, Sticker: &sticker, Message: req.Message,
FromUserID: req.BuyerUserID, PeerUserID: req.To.ID, To: req.To, NameHidden: req.HideName, Saved: true,
CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0,
PrepaidUpgradeHash: saved.PrepaidUpgradeHash, UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: saved.PrepaidUpgradeStars}}}
result.Gift, result.Saved, result.Balance = gift, saved, balance
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
msgID := sent.RecipientMessage.ID
if msgID <= 0 {
msgID = sent.SenderMessage.ID
}
result.Saved.MsgID = msgID
id, err := NewStarGiftStore(tx).Create(ctx, result.Saved)
if err != nil {
return err
}
result.Saved.ID = id
return s.insertStarGiftPurchaseCommand(ctx, tx, req, result.Saved.ID, result.Gift.Stars+result.Saved.PrepaidUpgradeStars, result.Balance.Balance)
},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPurchaseResult{}, err
}
result.Send, result.Duplicate = sent, sent.Duplicate
if sent.Duplicate {
replay, _, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent)
return replay, replayErr
}
return result, nil
}
func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
var result domain.StarGiftPurchaseResult
err := withTx(ctx, s.db, "purchase star gift for channel", func(tx pgx.Tx) error {
if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req)
if err != nil {
return err
}
id, err := NewStarGiftStore(tx).Create(ctx, saved)
if err != nil {
return err
}
saved.ID, saved.SavedID = id, id
sticker := gift.Sticker
action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title,
Sticker: &sticker, Message: saved.Message, FromUserID: req.BuyerUserID, PeerChannelID: req.To.ID,
SavedID: id, NameHidden: saved.NameHidden, Saved: true, CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, PrepaidUpgradeHash: saved.PrepaidUpgradeHash,
UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars,
}}
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, id, req.Date, action); err != nil {
return err
}
if err := s.insertStarGiftPurchaseCommand(ctx, tx, req, id, gift.Stars+saved.PrepaidUpgradeStars, balance.Balance); err != nil {
return err
}
result = domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: balance}
return nil
})
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPurchaseResult{}, err
}
return result, nil
}
func (s *StarGiftLifecycleStore) prepareStarGiftPurchase(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest) (domain.StarGift, domain.SavedStarGift, domain.StarsBalance, error) {
var revisionID int64
var enabled bool
var remains int
if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled,availability_remains FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID).
Scan(&revisionID, &enabled, &remains); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid
}
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID)
if err != nil || !found || !enabled || gift.ID != req.GiftID || gift.SoldOut || gift.Auction || gift.LockedUntilDate > req.Date ||
gift.Limited && remains <= 0 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid
}
if gift.RevisionID != req.RevisionID {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch
}
if gift.RequirePremium && !req.BuyerPremium {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrPremiumRequired
}
gift.AvailabilityRemains = remains
upgradePrice := int64(0)
prepayHash := ""
if gift.UpgradeStars > 0 || req.IncludeUpgrade {
revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID)
if err != nil || revision.Issued >= revision.SupplyTotal {
if req.IncludeUpgrade {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable
}
} else if req.IncludeUpgrade {
upgradePrice = revision.UpgradeStars
} else {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
prepayHash = base64.RawURLEncoding.EncodeToString(token[:])
}
}
if req.IncludeUpgrade && upgradePrice <= 0 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable
}
if gift.Stars+upgradePrice != req.ChargeStars {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch
}
var purchased int
if err := tx.QueryRow(ctx, `INSERT INTO star_gift_user_purchases(user_id,gift_id,purchased_count) VALUES($1,$2,1)
ON CONFLICT(user_id,gift_id) DO UPDATE SET purchased_count=star_gift_user_purchases.purchased_count+1,updated_at=now()
WHERE NOT $3 OR star_gift_user_purchases.purchased_count<$4 RETURNING purchased_count`, req.BuyerUserID, gift.ID,
gift.LimitedPerUser, gift.PerUserTotal).Scan(&purchased); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable
}
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
if gift.Limited {
if tag, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET availability_remains=availability_remains-1,
first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END,last_sale_date=$2,updated_at=now()
WHERE gift_id=$1 AND availability_remains>0`, gift.ID, req.Date); err != nil || tag.RowsAffected() != 1 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable
}
} else if _, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END,
last_sale_date=$2,updated_at=now() WHERE gift_id=$1`, gift.ID, req.Date); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
charge := gift.Stars + upgradePrice
balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: charge}, domain.StarsReasonGift,
req.To, req.Date, "Star gift")
if err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
saved := domain.SavedStarGift{Owner: req.To, FromUserID: req.BuyerUserID, GiftID: gift.ID, RevisionID: gift.RevisionID,
Date: req.Date, NameHidden: req.HideName, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: upgradePrice,
PrepaidUpgradeHash: prepayHash, Message: req.Message}
return gift, saved, balance, nil
}
func (s *StarGiftLifecycleStore) insertStarGiftPurchaseCommand(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest, savedID, charge, balance int64) error {
_, err := tx.Exec(ctx, `INSERT INTO star_gift_purchase_commands(buyer_user_id,command_key,gift_id,recipient_peer_type,
recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.CommandKey, req.GiftID, string(req.To.Type), req.To.ID,
savedID, req.FormID, charge, balance, req.Date)
return err
}
func (s *StarGiftLifecycleStore) loadStarGiftPurchaseReplay(ctx context.Context, req domain.StarGiftPurchaseRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPurchaseResult, bool, error) {
var giftID, recipientID, savedID, formID, charge, balance int64
var recipientType string
err := s.db.QueryRow(ctx, `SELECT gift_id,recipient_peer_type,recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after
FROM star_gift_purchase_commands WHERE buyer_user_id=$1 AND command_key=$2`, req.BuyerUserID, req.CommandKey).
Scan(&giftID, &recipientType, &recipientID, &savedID, &formID, &charge, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftPurchaseResult{}, false, nil
}
if err != nil {
return domain.StarGiftPurchaseResult{}, false, err
}
if giftID != req.GiftID || recipientType != string(req.To.Type) || recipientID != req.To.ID || formID != req.FormID || charge <= 0 {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
if saved.Owner != req.To || saved.GiftID != req.GiftID || saved.NameHidden != req.HideName || saved.Message != req.Message ||
(saved.PrepaidUpgradeStars > 0) != req.IncludeUpgrade {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
gift, found, err := NewStarGiftStore(s.db).CatalogRevision(ctx, saved.RevisionID)
if err != nil || !found {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
if req.To.Type == domain.PeerTypeUser && sent.SenderMessage.ID == 0 {
if s.messages == nil {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftUnavailable
}
fingerprint := starGiftPurchaseFingerprint(req)
replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID,
RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), IdempotencyFingerprint: fingerprint[:],
})
if replayErr != nil || !replayFound {
if replayErr != nil {
return domain.StarGiftPurchaseResult{}, false, replayErr
}
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
sent = replay
}
return domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance},
Send: sent, Duplicate: true}, true, nil
}
func starGiftPurchaseFingerprint(req domain.StarGiftPurchaseRequest) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-purchase:v1:%d:%s:%d:%d:%t:%t:%s",
req.BuyerUserID, req.To.Type, req.To.ID, req.GiftID, req.IncludeUpgrade, req.HideName, req.Message)))
}

View file

@ -21,18 +21,38 @@ import (
// upgrades. It intentionally shares MessageStore's allocator and transaction
// machinery so Stars, issuance, the saved gift and durable updates commit once.
type StarGiftUpgradeStore struct {
db sqlcgen.DBTX
messages *MessageStore
db sqlcgen.DBTX
messages *MessageStore
lifecycle domain.StarGiftLifecyclePolicy
}
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore {
return &StarGiftUpgradeStore{db: db, messages: messages}
type StarGiftUpgradeOption func(*StarGiftUpgradeStore)
func WithStarGiftLifecyclePolicy(policy domain.StarGiftLifecyclePolicy) StarGiftUpgradeOption {
return func(s *StarGiftUpgradeStore) {
if policy.Valid() {
s.lifecycle = policy
}
}
}
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...StarGiftUpgradeOption) *StarGiftUpgradeStore {
s := &StarGiftUpgradeStore{db: db, messages: messages, lifecycle: domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250,
}}
for _, opt := range opts {
opt(s)
}
return s
}
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 != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) ||
(req.Ref.Owner.Type != domain.PeerTypeUser && req.Ref.Owner.Type != domain.PeerTypeChannel) ||
req.ChargeStars < 0 || (req.RequirePrepaid && (req.ChargeStars != 0 || req.FormID != 0)) ||
(!req.RequirePrepaid && (req.ChargeStars <= 0 || req.FormID == 0)) ||
req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
}
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
@ -48,7 +68,11 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
"telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t",
commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails,
)))
randomID := starGiftUpgradeRandomID(saved.FromUserID, req.UserID, commandKey)
messageSenderID := saved.FromUserID
if saved.Owner.Type == domain.PeerTypeChannel {
messageSenderID = domain.OfficialSystemUserID
}
randomID := starGiftUpgradeRandomID(messageSenderID, req.UserID, commandKey)
placeholder := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
@ -57,7 +81,7 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
},
}
messageReq := domain.SendPrivateTextRequest{
SenderUserID: saved.FromUserID,
SenderUserID: messageSenderID,
RecipientUserID: req.UserID,
RandomID: randomID,
Media: placeholder,
@ -89,6 +113,19 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
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 collectible craft capability: %w", err)
}
craftChancePermille := 0
canCraftAt := 0
if craftable {
craftChancePermille = s.lifecycle.CraftChancePermille
canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
}
if revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleSoldOut
}
@ -134,10 +171,12 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
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,$13,$14,$15,$16,$17)`,
uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num,
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails); err != nil {
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails,
string(locked.Owner.Type), locked.Owner.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil {
return fmt.Errorf("insert 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 {
@ -145,14 +184,21 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
}
if _, err := tx.Exec(ctx, `
UPDATE peer_star_gifts
SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0
WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil {
SET unique_gift_id=$2, prepaid_upgrade_stars=0, prepaid_upgrade_hash='', convert_stars=0,
transfer_stars=$3,can_export_at=$4,can_transfer_at=$5,can_resell_at=$6,
drop_original_details_stars=$7,can_craft_at=$8
WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`, locked.ID, uniqueID,
s.lifecycle.TransferStars, starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds),
starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds), starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds),
s.lifecycle.DropOriginalDetailsStars, canCraftAt); err != nil {
return fmt.Errorf("upgrade saved star gift: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_upgrade_commands
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after)
VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance); err != nil {
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after,
charge_stars, require_prepaid, keep_original_details)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance,
req.ChargeStars, req.RequirePrepaid, req.KeepOriginalDetails); err != nil {
return fmt.Errorf("insert star gift upgrade command: %w", err)
}
@ -166,21 +212,20 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
locked.UniqueGiftID = uniqueID
locked.PrepaidUpgradeStars = 0
locked.ConvertStars = 0
locked.TransferStars = s.lifecycle.TransferStars
locked.CanExportAt = starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds)
locked.CanTransferAt = starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds)
locked.CanResellAt = starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds)
locked.DropOriginalDetailsStars = s.lifecycle.DropOriginalDetailsStars
locked.CanCraftAt = canCraftAt
locked.Unique = &unique
result.Saved, result.Unique, result.Balance = locked, unique, balance
action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID)
messageReq.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: func() int64 {
if locked.NameHidden {
return 0
}
return locked.FromUserID
}(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved,
PrepaidUpgrade: req.RequirePrepaid,
},
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: action,
},
}
return nil
@ -201,6 +246,40 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
}
result.Saved.UpgradeMsgID = ownerMessageID
if result.Saved.Owner.Type == domain.PeerTypeUser {
edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent)
if err != nil {
return err
}
result.SourceEdits = edits
ownerEditPts := 0
for _, edit := range edits {
if edit.UserID == req.UserID {
ownerEditPts = edit.Event.Pts
break
}
}
if ownerEditPts <= 0 {
return fmt.Errorf("upgrade source edit missing owner event")
}
tag, err := tx.Exec(ctx, `
UPDATE star_gift_upgrade_commands SET source_edit_pts=$3
WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts)
if err != nil {
return fmt.Errorf("save star gift source edit pts: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("save star gift source edit pts lost command row")
}
} else {
action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req, messageSenderID)
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, result.Saved.Owner.ID,
req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action,
}); err != nil {
return fmt.Errorf("append channel star gift upgrade admin log: %w", err)
}
}
return nil
},
}
@ -216,11 +295,186 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
return result, nil
}
func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest, messageSenderID int64) *domain.MessageStarGiftUniqueAction {
fromUserID := saved.FromUserID
if saved.NameHidden {
fromUserID = 0
}
if saved.Owner.Type == domain.PeerTypeChannel {
// TDesktop recognizes a channel-owned upgrade from the official service
// peer plus action.peer=channel and action.saved_id.
fromUserID = messageSenderID
}
savedID := saved.SavedID
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)
}
return &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, 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,
}
}
// 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 (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx(
ctx context.Context,
tx pgx.Tx,
req domain.StarGiftUpgradeRequest,
saved domain.SavedStarGift,
sent domain.SendPrivateTextResult,
) ([]domain.EditedMessageForUser, error) {
if saved.Owner.Type != domain.PeerTypeUser || saved.Owner.ID != req.UserID || saved.MsgID <= 0 {
return nil, domain.ErrStarGiftCollectibleInvalid
}
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
}
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)
}
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode star gift source media: %w", 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)
}
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)
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 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 = 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, `
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 edits, nil
}
func starGiftReadyAt(date, delaySeconds int) int {
if date <= 0 || delaySeconds <= 0 {
return 0
}
const maxProtocolDate = int(1<<31 - 1)
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...)
}
func lockSavedStarGiftByID(ctx context.Context, tx pgx.Tx, savedID int64) (domain.SavedStarGift, error) {
return lockSavedStarGiftWhere(ctx, tx, "p.id = $1", savedID)
}
func lockSavedStarGiftWhere(ctx context.Context, tx pgx.Tx, where string, args ...any) (domain.SavedStarGift, error) {
row := tx.QueryRow(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
@ -283,7 +537,13 @@ func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64,
}
func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) {
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, table), revisionID)
extra := ""
if table == "star_gift_collectible_models" {
extra = " AND NOT crafted"
}
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s
ORDER BY sort_order, id`, table, extra), revisionID)
if err != nil {
return 0, fmt.Errorf("list collectible attributes for issuance: %w", err)
}
@ -305,7 +565,7 @@ func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, re
if err := rows.Err(); err != nil {
return 0, err
}
if len(items) == 0 || total != 1000 {
if len(items) == 0 || total <= 0 {
return 0, domain.ErrStarGiftCollectibleInvalid
}
draw, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
@ -346,20 +606,94 @@ func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain
}
return domain.StarGiftUpgradeResult{}, err
}
var commandUniqueID int64
var balanceAfter int64
if err := s.db.QueryRow(ctx, `SELECT unique_gift_id, balance_after FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&commandUniqueID, &balanceAfter); err != nil {
receipt, found, err := s.StarGiftUpgradeReceipt(ctx, req.UserID, req.CommandKey)
if err != nil {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err)
}
if commandUniqueID != unique.ID || saved.ID != original.ID {
if !found || receipt.UniqueGiftID != unique.ID || receipt.SourceSavedGiftID != saved.ID || saved.ID != original.ID ||
receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid != req.RequirePrepaid ||
receipt.KeepOriginalDetails != req.KeepOriginalDetails {
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
}
uniqueCopy := unique
saved.Unique = &uniqueCopy
sourceEdits, err := s.loadUpgradeSourceReplay(ctx, req, saved, receipt.SourceEditPts)
if err != nil {
return domain.StarGiftUpgradeResult{}, err
}
return domain.StarGiftUpgradeResult{
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter},
Send: sent, Duplicate: true,
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: receipt.BalanceAfter},
Send: sent, SourceEdits: sourceEdits, Duplicate: true,
}, nil
}
func (s *StarGiftUpgradeStore) loadUpgradeSourceReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, saved domain.SavedStarGift, pts int) ([]domain.EditedMessageForUser, error) {
if saved.Owner.Type != domain.PeerTypeUser {
return nil, nil
}
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])
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
}
func (s *StarGiftUpgradeStore) StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) {
commandKey = strings.TrimSpace(commandKey)
if s == nil || s.db == nil || userID <= 0 || commandKey == "" || len(commandKey) > 256 {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
receipt := domain.StarGiftUpgradeReceipt{UserID: userID}
err := s.db.QueryRow(ctx, `
SELECT source_saved_gift_id,form_id,unique_gift_id,charge_stars,balance_after,source_edit_pts,require_prepaid,keep_original_details
FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, userID, commandKey).Scan(
&receipt.SourceSavedGiftID, &receipt.FormID, &receipt.UniqueGiftID, &receipt.ChargeStars,
&receipt.BalanceAfter, &receipt.SourceEditPts, &receipt.RequirePrepaid, &receipt.KeepOriginalDetails)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
if err != nil {
return domain.StarGiftUpgradeReceipt{}, false, err
}
return receipt, true, nil
}
var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil)