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

@ -2,9 +2,12 @@
package stargifts
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"strings"
"sync"
@ -22,26 +25,65 @@ type BlobBackend interface {
}
type Service struct {
store store.StarGiftStore
upgrades store.StarGiftUpgradeStore
blobs BlobBackend
dc int
store store.StarGiftStore
upgrades store.StarGiftUpgradeStore
lifecycle store.StarGiftLifecycleStore
withdrawal StarGiftWithdrawalProvider
blobs BlobBackend
dc int
mu sync.RWMutex
built bool
gifts []domain.StarGift
byID map[int64]domain.StarGift
hash int
formMu sync.Mutex
forms map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm
}
type starGiftPurchaseFormKey struct {
buyerUserID int64
formID int64
}
// AtomicPurchaseConfigured reports whether the production aggregate
// coordinator is installed. It lets the RPC package keep its isolated memory
// test adapter without silently downgrading PostgreSQL deployments.
func (s *Service) AtomicPurchaseConfigured() bool { return s != nil && s.lifecycle != nil }
type Option func(*Service)
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
return func(service *Service) { service.upgrades = upgrades }
}
func WithLifecycleStore(lifecycle store.StarGiftLifecycleStore) Option {
return func(service *Service) { service.lifecycle = lifecycle }
}
type StarGiftWithdrawalProvider interface {
Name() string
CreateWithdrawal(ctx context.Context, req StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error)
}
type StarGiftWithdrawalProviderRequest struct {
UserID int64
Gift domain.UniqueStarGift
}
type StarGiftWithdrawalProviderResult struct {
RequestID string
URL string
ExpiresAt int
}
func WithWithdrawalProvider(provider StarGiftWithdrawalProvider) Option {
return func(service *Service) { service.withdrawal = provider }
}
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
service := &Service{store: st, blobs: blobs, dc: dc}
service := &Service{store: st, blobs: blobs, dc: dc, forms: make(map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm)}
for _, opt := range opts {
opt(service)
}
@ -137,21 +179,33 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
}
if err := s.materializeCatalogWrite(ctx, &write); err != nil {
return domain.StarGiftCatalogEntry{}, err
}
entry, err := s.store.CreateCatalogRevision(ctx, write)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
s.InvalidateStarGiftCatalog()
return entry, nil
}
func (s *Service) materializeCatalogWrite(ctx context.Context, write *domain.StarGiftCatalogWrite) error {
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
if err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err)
return fmt.Errorf("store star gift animation: %w", err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err)
return fmt.Errorf("generate star gift file reference: %w", err)
}
write.Document = domain.Document{
ID: documentID,
@ -175,12 +229,66 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi
SHA256: append([]byte(nil), write.Animation.SHA256...),
MimeType: "application/x-tgsticker",
}
entry, err := s.store.CreateCatalogRevision(ctx, write)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
return nil
}
// CreateCatalogBundle materializes every verified asset before publishing both active
// revision pointers in one store transaction. Blob writes are content-addressed and may be
// safely orphaned for later GC if the database transaction fails.
func (s *Service) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogBundleResult{}, fmt.Errorf("star gift catalog importer is not configured")
}
s.InvalidateStarGiftCatalog()
return entry, nil
write.Catalog.Title = strings.TrimSpace(write.Catalog.Title)
if write.Catalog.Stars <= 0 || write.Catalog.ConvertStars < 0 || write.Catalog.ConvertStars > write.Catalog.Stars ||
write.Catalog.Animation.Width != 512 || write.Catalog.Animation.Height != 512 || len(write.Catalog.Animation.TGS) == 0 ||
len([]rune(write.Catalog.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
var officialSource map[string]any
if write.Catalog.OfficialGiftID < 0 {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Catalog.OfficialGiftID > 0 && (len(write.Catalog.SourceManifestSHA256) != 32 ||
json.Unmarshal(write.Catalog.OfficialSourceJSON, &officialSource) != nil || officialSource == nil) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Catalog.OfficialGiftID == 0 && (len(write.Catalog.SourceManifestSHA256) != 0 || len(write.Catalog.OfficialSourceJSON) != 0) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Collectible != nil {
write.Collectible.SlugPrefix = strings.ToLower(strings.TrimSpace(write.Collectible.SlugPrefix))
if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID ||
!bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid
}
validation := *write.Collectible
if validation.GiftID == 0 {
validation.GiftID = write.Catalog.GiftID
if validation.GiftID == 0 {
validation.GiftID = 1
}
}
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
}
if err := s.materializeCatalogWrite(ctx, &write.Catalog); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
if write.Collectible != nil {
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Models); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Patterns); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
}
result, err := s.store.CreateCatalogBundle(ctx, write)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
@ -225,55 +333,56 @@ func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.St
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
materialize := func(attributes []domain.StarGiftCollectibleAttribute) error {
for i := range attributes {
animation := attributes[i].Animation
if animation == nil {
return domain.ErrStarGiftCollectibleInvalid
}
objectKey, err := s.blobs.Put(ctx, animation.TGS)
if err != nil {
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return fmt.Errorf("generate collectible file reference: %w", err)
}
attributes[i].Document = &domain.Document{
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
Size: int64(len(animation.TGS)), DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
},
}
attributes[i].Blob = &domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
}
}
return nil
}
if err := materialize(write.Models); err != nil {
if err := s.materializeCollectibleAttributes(ctx, write.Models); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if err := materialize(write.Patterns); err != nil {
if err := s.materializeCollectibleAttributes(ctx, write.Patterns); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return s.PublishCollectibleRevision(ctx, write)
}
func (s *Service) materializeCollectibleAttributes(ctx context.Context, attributes []domain.StarGiftCollectibleAttribute) error {
for i := range attributes {
animation := attributes[i].Animation
if animation == nil {
return domain.ErrStarGiftCollectibleInvalid
}
objectKey, err := s.blobs.Put(ctx, animation.TGS)
if err != nil {
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return fmt.Errorf("generate collectible file reference: %w", err)
}
attributes[i].Document = &domain.Document{
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
Size: int64(len(animation.TGS)), DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
},
}
attributes[i].Blob = &domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
}
}
return nil
}
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.store == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil
@ -335,6 +444,338 @@ func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest
return result, err
}
func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) {
if s == nil || s.upgrades == nil {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
}
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
}
result, err := s.lifecycle.PurchaseStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
// IssuePurchaseForm creates one fresh payment intent. PostgreSQL persists the
// intent so server restarts cannot turn a valid checkout into an unbound
// payment. The bounded in-memory branch exists only for isolated RPC tests.
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) {
if !validPurchaseForm(form) {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid
}
if s != nil && s.lifecycle != nil {
return s.lifecycle.IssueStarGiftPurchaseForm(ctx, form)
}
if s == nil {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
s.formMu.Lock()
defer s.formMu.Unlock()
for key, existing := range s.forms {
if existing.ExpiresAt < form.IssuedAt {
delete(s.forms, key)
}
}
for attempt := 0; attempt < 8; attempt++ {
formID, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftPurchaseForm{}, err
}
key := starGiftPurchaseFormKey{buyerUserID: form.BuyerUserID, formID: formID}
if _, exists := s.forms[key]; exists {
continue
}
form.FormID = formID
s.forms[key] = form
return form, nil
}
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
// ValidatePurchaseForm is a read-only preflight used for precise RPC errors.
// The PostgreSQL purchase transaction repeats this validation while holding a
// row lock; callers must not treat this preflight as the atomicity boundary.
func (s *Service) ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error {
if s != nil && s.lifecycle != nil {
return s.lifecycle.ValidateStarGiftPurchaseForm(ctx, req)
}
if s == nil || req.FormID == 0 {
return domain.ErrStarGiftFormExpired
}
s.formMu.Lock()
defer s.formMu.Unlock()
form, ok := s.forms[starGiftPurchaseFormKey{buyerUserID: req.BuyerUserID, formID: req.FormID}]
if !ok || form.ExpiresAt < req.Date {
return domain.ErrStarGiftFormExpired
}
return validatePurchaseFormIntent(form, req)
}
func validPurchaseForm(form domain.StarGiftPurchaseForm) bool {
return form.FormID == 0 && form.BuyerUserID > 0 && form.To.ID > 0 &&
(form.To.Type == domain.PeerTypeUser || form.To.Type == domain.PeerTypeChannel) &&
form.GiftID > 0 && form.RevisionID > 0 && form.ChargeStars > 0 && form.IssuedAt > 0 &&
form.ExpiresAt == form.IssuedAt+600 && len([]rune(form.Message)) <= 128
}
func validatePurchaseFormIntent(form domain.StarGiftPurchaseForm, req domain.StarGiftPurchaseRequest) error {
if form.BuyerUserID != req.BuyerUserID || 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 *Service) ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable
}
return s.lifecycle.ListResaleStarGifts(ctx, filter)
}
func (s *Service) ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftValueInfo{}, domain.ErrStarGiftResaleUnavailable
}
return s.lifecycle.UniqueStarGiftValueInfo(ctx, uniqueGiftID)
}
func (s *Service) SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) {
if s == nil || s.lifecycle == nil {
return domain.UniqueStarGift{}, domain.ErrStarGiftResaleUnavailable
}
result, err := s.lifecycle.SetStarGiftListing(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable
}
return s.lifecycle.TransferStarGift(ctx, req)
}
func (s *Service) PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable
}
result, err := s.lifecycle.PurchaseResaleStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid
}
return s.lifecycle.SendStarGiftOffer(ctx, req)
}
func (s *Service) ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid
}
return s.lifecycle.ResolveStarGiftOffer(ctx, req)
}
func (s *Service) ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) {
if s == nil || s.lifecycle == nil {
return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable
}
return s.lifecycle.ListCraftStarGifts(ctx, userID, giftID, offset, limit)
}
func (s *Service) Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
return s.lifecycle.CraftStarGift(ctx, req)
}
func (s *Service) AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.StarGiftAuctionState(ctx, userID, giftID, slug, now)
}
func (s *Service) ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) {
if s == nil || s.lifecycle == nil {
return nil, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.ActiveStarGiftAuctions(ctx, userID, now)
}
func (s *Service) AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) {
if s == nil || s.lifecycle == nil {
return nil, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.StarGiftAuctionAcquired(ctx, userID, giftID)
}
func (s *Service) BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftAuction{}, domain.StarsBalance{}, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.BidStarGiftAuction(ctx, req)
}
func (s *Service) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) {
if s == nil || s.lifecycle == nil {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.PrepaidUpgradeTarget(ctx, owner, hash)
}
func (s *Service) PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.PrepayStarGiftUpgrade(ctx, req)
}
func (s *Service) DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.DropStarGiftOriginalDetails(ctx, req)
}
func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error {
if s == nil || s.lifecycle == nil {
return domain.ErrStarGiftUnavailable
}
return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled)
}
func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) {
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
saved, found, err := s.store.GetByRef(ctx, req.Ref)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UniqueGiftID == 0 || !saved.LifecycleStatus.Live() || saved.CanExportAt > req.Date {
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable
}
unique, found, err := s.store.UniqueByID(ctx, saved.UniqueGiftID)
if err != nil || !found || unique.Burned || unique.Owner != saved.Owner {
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable
}
providerResult, err := s.withdrawal.CreateWithdrawal(ctx, StarGiftWithdrawalProviderRequest{UserID: req.UserID, Gift: unique})
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
if strings.TrimSpace(providerResult.RequestID) == "" || strings.TrimSpace(providerResult.URL) == "" || providerResult.ExpiresAt <= req.Date {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
recorded, err := s.lifecycle.RecordStarGiftWithdrawal(ctx, req, s.withdrawal.Name(), providerResult.RequestID, providerResult.URL, providerResult.ExpiresAt)
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return recorded, nil
}
func (s *Service) ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftWithdrawal{}, false, nil
}
return s.lifecycle.ResolveStarGiftWithdrawal(ctx, providerRequestID)
}
func (s *Service) CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
return s.lifecycle.CompleteStarGiftWithdrawal(ctx, providerRequestID, date)
}
func (s *Service) TonBalance(ctx context.Context, userID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.TonBalance(ctx, userID)
}
func (s *Service) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.TonTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.TonTransactions(ctx, userID, offset, limit)
}
func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.ChannelStarsBalance(ctx, channelID)
}
func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.StarsTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, offset, limit)
}
func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.ChannelTonBalance(ctx, channelID)
}
func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.TonTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.ChannelTonTransactions(ctx, channelID, offset, limit)
}
func (s *Service) SweepLifecycle(ctx context.Context, now, limit int) error {
if s == nil || s.lifecycle == nil {
return nil
}
return s.lifecycle.SweepStarGiftLifecycle(ctx, now, limit)
}
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
return s.store.ListCollections(ctx, owner)
}
@ -360,6 +801,17 @@ func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs
}
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil {
if revision, ok, err := s.store.ActiveCollectibleRevision(ctx, gift.GiftID); err != nil {
return 0, err
} else if ok && revision.Published && revision.Issued < revision.SupplyTotal {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)
}
gift.PrepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
}
}
return s.store.Create(ctx, gift)
}
@ -396,10 +848,20 @@ func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef,
return s.store.SetUnsaved(ctx, ref, unsaved)
}
// Convert keeps the in-memory/catalog store primitive available to isolated
// tests and non-production adapters. RPC production paths must use
// ConvertAggregate so balance credit and terminal state cannot split.
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
return s.store.MarkConverted(ctx, ref)
}
func (s *Service) ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftConvertResult{}, domain.ErrStarGiftUnavailable
}
return s.lifecycle.ConvertStarGift(ctx, req)
}
func randomPositiveInt64() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {