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:
parent
f2c2fd0236
commit
14bf7d1e20
92 changed files with 14768 additions and 727 deletions
|
|
@ -21,7 +21,18 @@ func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGif
|
|||
return prepareAnimation(fileName, data)
|
||||
}
|
||||
|
||||
// PrepareOfficialAnimation preserves expressions present in Telegram's signed-in official
|
||||
// snapshot. Callers must first verify the file against manifest size and SHA-256; ordinary
|
||||
// operator uploads continue through PrepareAnimation and reject expressions.
|
||||
func (s *Service) PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
return prepareAnimationWithPolicy(fileName, data, true)
|
||||
}
|
||||
|
||||
func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
return prepareAnimationWithPolicy(fileName, data, false)
|
||||
}
|
||||
|
||||
func prepareAnimationWithPolicy(fileName string, data []byte, allowExpressions bool) (domain.StarGiftAnimation, error) {
|
||||
fileName = strings.TrimSpace(filepath.Base(fileName))
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
format := domain.StarGiftAnimationLottie
|
||||
|
|
@ -46,7 +57,7 @@ func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, e
|
|||
rawJSON = data
|
||||
}
|
||||
|
||||
normalized, meta, err := normalizeAndValidateLottie(rawJSON)
|
||||
normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions)
|
||||
if err != nil {
|
||||
return domain.StarGiftAnimation{}, err
|
||||
}
|
||||
|
|
@ -80,7 +91,7 @@ type lottieMetadata struct {
|
|||
Assets []json.RawMessage `json:"assets"`
|
||||
}
|
||||
|
||||
func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) {
|
||||
func normalizeAndValidateLottie(data []byte, allowExpressions bool) ([]byte, lottieMetadata, error) {
|
||||
data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
|
||||
if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
|
|
@ -94,7 +105,7 @@ func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) {
|
|||
if _, ok := root.(map[string]any); !ok {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
if containsLottieExpression(root) {
|
||||
if !allowExpressions && containsLottieExpression(root) {
|
||||
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
var meta lottieMetadata
|
||||
|
|
|
|||
|
|
@ -91,3 +91,26 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
|
|||
t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCatalogBundleRejectsMismatchedOfficialProvenance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStarGiftStore()
|
||||
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
|
||||
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash := make([]byte, sha256.Size)
|
||||
_, err = svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{
|
||||
Catalog: domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 25, Enabled: true, Title: "Official", Animation: animation,
|
||||
OfficialGiftID: 10, SourceManifestSHA256: hash, OfficialSourceJSON: []byte(`{"id":10}`),
|
||||
},
|
||||
Collectible: &domain.StarGiftCollectibleWrite{
|
||||
OfficialGiftID: 11, SourceManifestSHA256: hash,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("mismatched provenance err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
internal/app/stargifts/local_withdrawal.go
Normal file
50
internal/app/stargifts/local_withdrawal.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const localWithdrawalTTL = 15 * time.Minute
|
||||
|
||||
// LocalWithdrawalProvider implements the TON/export UX entirely inside
|
||||
// telesrv. It mints an unguessable, short-lived bearer URL; no external
|
||||
// blockchain, Fragment endpoint, wallet or network RPC is contacted.
|
||||
type LocalWithdrawalProvider struct {
|
||||
publicBaseURL string
|
||||
}
|
||||
|
||||
func NewLocalWithdrawalProvider(publicBaseURL string) (*LocalWithdrawalProvider, error) {
|
||||
publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
|
||||
parsed, err := url.Parse(publicBaseURL)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
|
||||
(parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return nil, fmt.Errorf("invalid local star gift withdrawal base URL")
|
||||
}
|
||||
return &LocalWithdrawalProvider{publicBaseURL: publicBaseURL}, nil
|
||||
}
|
||||
|
||||
func (p *LocalWithdrawalProvider) Name() string { return "telesrv-local" }
|
||||
|
||||
func (p *LocalWithdrawalProvider) CreateWithdrawal(_ context.Context, _ StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error) {
|
||||
if p == nil || p.publicBaseURL == "" {
|
||||
return StarGiftWithdrawalProviderResult{}, fmt.Errorf("local star gift withdrawal provider is not configured")
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return StarGiftWithdrawalProviderResult{}, fmt.Errorf("generate local withdrawal token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
return StarGiftWithdrawalProviderResult{
|
||||
RequestID: token,
|
||||
URL: p.publicBaseURL + "/gift-withdrawal/" + url.PathEscape(token),
|
||||
ExpiresAt: int(time.Now().Add(localWithdrawalTTL).Unix()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ StarGiftWithdrawalProvider = (*LocalWithdrawalProvider)(nil)
|
||||
34
internal/app/stargifts/local_withdrawal_test.go
Normal file
34
internal/app/stargifts/local_withdrawal_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalWithdrawalProviderIsInternalAndBounded(t *testing.T) {
|
||||
for _, invalid := range []string{"", "ftp://example.test", "https://user@example.test", "https://example.test/?token=bad", "https://example.test/#bad"} {
|
||||
if _, err := NewLocalWithdrawalProvider(invalid); err == nil {
|
||||
t.Fatalf("invalid withdrawal base URL %q accepted", invalid)
|
||||
}
|
||||
}
|
||||
provider, err := NewLocalWithdrawalProvider("https://example.test/base/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := time.Now()
|
||||
result, err := provider.CreateWithdrawal(context.Background(), StarGiftWithdrawalProviderRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if provider.Name() != "telesrv-local" || len(result.RequestID) != 43 ||
|
||||
result.URL != "https://example.test/base/gift-withdrawal/"+result.RequestID ||
|
||||
strings.ContainsAny(result.RequestID, "+/=") {
|
||||
t.Fatalf("local withdrawal result = %+v", result)
|
||||
}
|
||||
expires := time.Unix(int64(result.ExpiresAt), 0)
|
||||
if expires.Before(before.Add(14*time.Minute)) || expires.After(before.Add(16*time.Minute)) {
|
||||
t.Fatalf("local withdrawal expiry = %v, want about 15 minutes", expires)
|
||||
}
|
||||
}
|
||||
56
internal/app/stargifts/official_snapshot_test.go
Normal file
56
internal/app/stargifts/official_snapshot_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package stargifts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/officialgifts"
|
||||
)
|
||||
|
||||
// This opt-in test is run by the official import audit. It validates every distinct base,
|
||||
// model and pattern document with the trusted official animation policy, including the
|
||||
// small set of Telegram-authored expression animations.
|
||||
func TestConfiguredOfficialSnapshotAnimations(t *testing.T) {
|
||||
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
|
||||
if root == "" {
|
||||
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
|
||||
}
|
||||
catalog := officialgifts.New(root)
|
||||
items, err := catalog.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &stargifts.Service{}
|
||||
seen := map[int64]struct{}{}
|
||||
validate := func(document officialgifts.Document) {
|
||||
t.Helper()
|
||||
if _, ok := seen[document.ID]; ok {
|
||||
return
|
||||
}
|
||||
seen[document.ID] = struct{}{}
|
||||
if _, err := service.PrepareOfficialAnimation(document.FileName, document.Data); err != nil {
|
||||
t.Fatalf("document %d (%s): %v", document.ID, document.Path, err)
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
bundle, err := catalog.Bundle(context.Background(), item.ID, item.ModelCount+item.PatternCount+item.BackdropCount > 0)
|
||||
if err != nil {
|
||||
t.Fatalf("gift %d: %v", item.ID, err)
|
||||
}
|
||||
validate(bundle.BaseDocument)
|
||||
if bundle.Collectible == nil {
|
||||
continue
|
||||
}
|
||||
for _, model := range bundle.Collectible.Models {
|
||||
validate(model.Document)
|
||||
}
|
||||
for _, pattern := range bundle.Collectible.Patterns {
|
||||
validate(pattern.Document)
|
||||
}
|
||||
}
|
||||
if len(seen) != 8333 {
|
||||
t.Fatalf("validated %d documents, want 8333", len(seen))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue